Porównaj commity

...

123 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
andrekir ec3a046fb6 2.1.9 2023-04-17 18:39:29 -03:00
andrekir 691b735483 fix: correct display of connection status 2023-04-17 18:37:19 -03:00
andrekir 42e16dd5b3 update proto submodule to latest 2023-04-17 18:29:49 -03:00
Andre K a5fa47292e
refactor: split configs into individual components (#623) 2023-04-17 17:13:26 -03:00
andrekir 6fe5f2733a feat: update Polish localization strings 2023-04-16 06:23:13 -03:00
andrekir 600d79babc feat: update Russian localization strings 2023-04-16 06:22:25 -03:00
Andre K da5f1d529d
feat: add traceroute (#620) 2023-04-16 06:16:41 -03:00
andrekir 72c278c92c chore: update Protobuf to 3.22.3 2023-04-14 07:35:22 -03:00
andrekir 236c33715d chore: update MGRS Android to 2.2.2 2023-04-14 07:35:12 -03:00
andrekir 88d415f2ce fix: primaryChannel IndexOutOfBoundsException 2023-04-14 07:26:18 -03:00
andrekir 97fe340587 2.1.8 2023-04-13 18:27:59 -03:00
andrekir 0692ec5383 chore: update Firebase Analytics to 21.2.2 2023-04-13 18:24:14 -03:00
Andre K 9f6e5f0a8f
feat: update Greek localization strings (#619) 2023-04-13 18:04:27 -03:00
Andre K a8be41d158
feat: add node colors (#618) 2023-04-13 17:54:52 -03:00
andrekir dc596e25a2 fix: corrected channel name change behavior 2023-04-13 17:34:28 -03:00
andrekir e050ebd2a9 refactor: move UI text logic to view model 2023-04-13 17:20:26 -03:00
andrekir 28b905db23 fix: improve null-safety handling in filter logic
to avoid NullPointerException: it.name must not be null at com.geeksville.mesh.repository.bluetooth.BluetoothRepository$createBondedDevicesFlow$2.invokeSuspend(BluetoothRepository.kt:96)
2023-04-13 17:11:40 -03:00
andrekir 5599a5d2a5 feat: add mqtt.root config 2023-04-13 17:06:25 -03:00
andrekir a5a6061c20 update proto submodule to latest 2023-04-13 17:04:03 -03:00
andrekir 304fe3fcd2 fix: change vector background color to transparent 2023-04-10 20:35:03 -03:00
andrekir f5ca450bd4 chore: update Kotlin Serialization to 1.5.0 2023-04-10 20:32:25 -03:00
andrekir 5e0e84131f chore: update Awesome App Rating to 2.6.0 2023-04-10 20:32:05 -03:00
andrekir e03ab70e66 chore: add Lifecycle utilities for Compose
and replace collectAsState() with collectAsStateWithLifecycle()
2023-04-10 20:29:47 -03:00
andrekir 8e7597704b chore: update Accompanist to 0.30.1 2023-04-10 20:27:43 -03:00
andrekir e2ffceb922 chore: update Compose BOM to 2023.03.00 2023-04-10 20:27:02 -03:00
andrekir 8d688ea6a4 2.1.7 2023-04-08 07:24:36 -03:00
andrekir 20fa73cfe9 feat: update Icelandic localization strings 2023-04-08 07:24:05 -03:00
andrekir 91fd059946 feat: update Chinese localization strings 2023-04-08 07:22:13 -03:00
Andre K 5bf4c9c184
refactor: migrate ChannelFragment to Composable (#615) 2023-04-07 12:43:29 -03:00
andrekir a560555a01 fix: add synchronized() to service numOnlineNodes
to avoid `ConcurrentModificationException` from nodeDBbyNodeNum changes
2023-04-07 11:28:17 -03:00
andrekir 440fc97fa0 feat: add onFocusChanged parameter to EditTextPreference 2023-04-07 11:26:09 -03:00
andrekir f6bb86ada2 feat: add maxSize field to module configs 2023-04-07 11:12:52 -03:00
andrekir 804d4f0e27 feat: update `devices` list from repositories 2023-04-06 21:04:03 -03:00
andrekir 2d0d7b3986 fix: update CompanionDeviceManager associate() to Activity context 2023-04-06 19:10:45 -03:00
andrekir 4e9ec5f096 fix: initialize `devices` before constructor execution 2023-04-06 19:08:19 -03:00
andrekir 189e9aeee4 feat: add tls_enabled config 2023-04-06 19:07:21 -03:00
andrekir 242f13f1f8 update proto submodule to latest 2023-04-04 23:51:40 -03:00
andrekir d91ba72041 2.1.6 2023-04-04 23:28:21 -03:00
andrekir 2a74eceb17 refactor: change RegularPreference text style 2023-04-04 23:26:41 -03:00
andrekir d2c73f2d4b fix: reverse DropDownPreference trailing icon orientation 2023-04-04 23:23:47 -03:00
Garrett M eec87422b0
closes #580 use "min" instead of "m" for minutes (#609) 2023-04-03 18:35:43 -03:00
andrekir 09662c0141 fix: removeObservers when onStop is called (UI not visible) 2023-04-03 18:30:54 -03:00
andrekir 7fedc2b0e1 refactor: move activity out of BTScanModel 2023-04-03 18:29:41 -03:00
andrekir b7d91224e4 feat: add `ModemPreset.LONG_MODERATE` string name
https://github.com/meshtastic/firmware/pull/2400
2023-04-03 18:17:23 -03:00
andrekir 5d90584734 fix: add synchronized() to avoid concurrent modifications
between saveSettings() and discardNodeDB()
2023-04-03 18:12:24 -03:00
andrekir b3d581486a fix: add onDestroyView() to release fragment resources 2023-04-03 18:03:55 -03:00
andrekir 4dec73ea76 fix: broadcastSmartMinimumIntervalSecs typo 2023-04-01 21:10:29 -03:00
andrekir 04452e4756 chore: fix kapt warning
moving 'kotlin-kapt' to the bottom of plugins {} gets rid of the warning:

"The following options were not recognized by any processor: '[dagger.fastInit, dagger.hilt.android.internal.disableAndroidSuperclassValidation, dagger.hilt.android.internal.projectType, dagger.hilt.internal.useAggregatingRootProcessor, kapt.kotlin.generated]'"
2023-04-01 08:16:51 -03:00
andrekir effc450af8 feat: create BLEDeviceListEntry class 2023-04-01 08:03:32 -03:00
andrekir d4c125b962 fix: CompanionDeviceManager context 2023-04-01 07:58:37 -03:00
andrekir ca7e459dfe refactor: change battery powered from 111 to 101
https://github.com/meshtastic/firmware/issues/2392
2023-04-01 07:49:40 -03:00
andrekir 6ba95c87ef feat: add double_tap_as_button_press config 2023-04-01 07:45:42 -03:00
andrekir 48c110c3da update proto submodule to latest 2023-04-01 07:14:50 -03:00
76 zmienionych plików z 5638 dodań i 3069 usunięć

Wyświetl plik

@ -1,12 +1,12 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'kotlin-parcelize'
id 'kotlinx-serialization'
id 'com.google.dagger.hilt.android'
id 'de.mobilej.unmock'
id 'com.google.protobuf'
id 'kotlin-kapt'
}
unMock {
@ -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 30105 // format is Mmmss (where M is 1+the numeric major number
versionName "2.1.5"
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'
@ -144,6 +144,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
@ -159,16 +160,23 @@ dependencies {
// Hilt
implementation "com.google.dagger:hilt-android:$hilt_version"
// implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
kapt "com.google.dagger:hilt-compiler:$hilt_version"
// Navigation
def nav_version = "2.5.3"
implementation "androidx.navigation:navigation-compose:$nav_version"
androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
// Compose
def composeBom = platform('androidx.compose:compose-bom:2023.01.00')
def composeBom = platform('androidx.compose:compose-bom:2023.05.01')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material:material'
implementation 'androidx.activity:activity-compose'
implementation 'androidx.compose.runtime:runtime-livedata'
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.29.2-rc"
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.30.1"
// Android Studio Preview support
implementation 'androidx.compose.ui:ui-tooling-preview'
@ -184,20 +192,20 @@ dependencies {
implementation "org.osmdroid:osmdroid-wms:$osmdroid_version"
implementation "org.osmdroid:osmdroid-geopackage:$osmdroid_version"
implementation 'com.github.MKergall:osmbonuspack:6.9.0'
implementation('mil.nga.mgrs:mgrs-android:2.2.1') { exclude group: 'com.google.android.gms' }
implementation('mil.nga.mgrs:mgrs-android:2.2.2') { exclude group: 'com.google.android.gms' }
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
// kotlin serialization
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
// rate this app
googleImplementation 'com.suddenh4x.ratingdialog:awesome-app-rating:2.4.0'
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"
@ -212,8 +220,8 @@ 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-analytics:21.2.1'
googleImplementation 'com.google.firebase:firebase-crashlytics:18.3.7'
googleImplementation 'com.google.firebase:firebase-analytics:21.2.2'
// barcode support
// per https://github.com/journeyapps/zxing-android-embedded#older-sdk-versions for minSdkVersion 21
@ -225,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,19 +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 ->
@ -210,10 +203,6 @@ class MainActivity : AppCompatActivity(), Logging {
tab.icon = ContextCompat.getDrawable(this, tabInfos[position].icon)
}.attach()
model.connectionState.observe(this) { connected ->
updateConnectionStatusImage(connected)
}
// Handle any intent
handleIntent(intent)
}
@ -638,14 +627,22 @@ 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()
model.connectionState.removeObservers(this)
bluetoothViewModel.enabled.removeObservers(this)
model.requestChannelUrl.removeObservers(this)
super.onStop()
}
override fun onStart() {
super.onStart()
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)
@ -682,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()
}
@ -708,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)
}
@ -749,16 +747,10 @@ class MainActivity : AppCompatActivity(), Logging {
handler.removeCallbacksAndMessages(null)
return true
}
R.id.device_settings -> {
R.id.radio_config -> {
val node = model.ourNodeInfo.value ?: return true
supportFragmentManager.beginTransaction()
.add(R.id.mainActivityLayout, DeviceSettingsFragment())
.addToBackStack(null)
.commit()
return true
}
R.id.module_settings -> {
supportFragmentManager.beginTransaction()
.add(R.id.mainActivityLayout, ModuleSettingsFragment())
.add(R.id.mainActivityLayout, DeviceSettingsFragment(node))
.addToBackStack(null)
.commit()
return true

Wyświetl plik

@ -1,6 +1,8 @@
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
@ -26,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
* */
@ -153,6 +158,15 @@ data class NodeInfo(
var environmentMetrics: EnvironmentMetrics? = null,
) : Parcelable {
val colors: Pair<Int, Int>
get() { // returns foreground and background @ColorInt for each 'num'
val r = (num and 0xFF0000) shr 16
val g = (num and 0x00FF00) shr 8
val b = num and 0x0000FF
val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255
return Pair(if (brightness > 0.5) Color.BLACK else Color.WHITE, Color.rgb(r, g, b))
}
val batteryLevel get() = deviceMetrics?.batteryLevel
val voltage get() = deviceMetrics?.voltage
val batteryStr get() = if (batteryLevel in 1..100) String.format("%d%%", batteryLevel) else ""

Wyświetl plik

@ -1,6 +1,7 @@
package com.geeksville.mesh.android
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.bluetooth.BluetoothManager
import android.location.LocationManager
@ -9,7 +10,6 @@ import android.content.Context
import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
import androidx.core.content.ContextCompat
import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.R
/**
@ -18,14 +18,9 @@ import com.geeksville.mesh.R
val Context.bluetoothManager: BluetoothManager?
get() = getSystemService(Context.BLUETOOTH_SERVICE).takeIf { hasBluetoothPermission() } as? BluetoothManager?
val Context.deviceManager: CompanionDeviceManager?
get() {
if (GeeksvilleApplication.currentActivity is MainActivity) {
val activity = GeeksvilleApplication.currentActivity
if (hasCompanionDeviceApi()) return activity?.getSystemService(Context.COMPANION_DEVICE_SERVICE) as? CompanionDeviceManager?
}
return null
}
val Context.companionDeviceManager: CompanionDeviceManager?
@SuppressLint("NewApi")
get() = getSystemService(Context.COMPANION_DEVICE_SERVICE).takeIf { hasCompanionDeviceApi() } as? CompanionDeviceManager?
val Context.usbManager: UsbManager get() = requireNotNull(getSystemService(Context.USB_SERVICE) as? UsbManager?) { "USB_SERVICE is not available"}

Wyświetl plik

@ -51,4 +51,14 @@ data class MeshLog(@PrimaryKey val uuid: String,
return null
} ?: nodeInfo?.position
}
}
val routeDiscovery: MeshProtos.RouteDiscovery?
get() {
return meshPacket?.run {
if (hasDecoded() && decoded.portnumValue == Portnums.PortNum.TRACEROUTE_APP_VALUE) {
return MeshProtos.RouteDiscovery.parseFrom(decoded.payload)
}
return null
}
}
}

Wyświetl plik

@ -1,26 +1,22 @@
package com.geeksville.mesh.model
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Application
import android.app.PendingIntent
import android.bluetooth.BluetoothDevice
import android.bluetooth.le.*
import android.companion.AssociationRequest
import android.companion.BluetoothDeviceFilter
import android.companion.CompanionDeviceManager
import android.content.*
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.net.nsd.NsdServiceInfo
import android.os.RemoteException
import androidx.activity.result.IntentSenderRequest
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.R
import com.geeksville.mesh.android.*
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
@ -29,58 +25,16 @@ import com.geeksville.mesh.repository.radio.MockInterface
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.repository.radio.SerialInterface
import com.geeksville.mesh.repository.usb.UsbRepository
import com.geeksville.mesh.ui.SLogging
import com.geeksville.mesh.ui.changeDeviceSelection
import com.geeksville.mesh.util.anonymize
import com.geeksville.mesh.util.exceptionReporter
import com.hoho.android.usbserial.driver.UsbSerialDriver
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import java.util.regex.Pattern
import javax.inject.Inject
/// Show the UI asking the user to bond with a device, call changeSelection() if/when bonding completes
@SuppressLint("MissingPermission")
private fun requestBonding(
activity: MainActivity,
device: BluetoothDevice,
onComplete: (Int) -> Unit
) {
SLogging.info("Starting bonding for ${device.anonymize}")
// We need this receiver to get informed when the bond attempt finished
val bondChangedReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) = exceptionReporter {
val state =
intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
SLogging.debug("Received bond state changed $state")
if (state != BluetoothDevice.BOND_BONDING) {
context.unregisterReceiver(this) // we stay registered until bonding completes (either with BONDED or NONE)
SLogging.debug("Bonding completed, state=$state")
onComplete(state)
}
}
}
val filter = IntentFilter()
filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
activity.registerReceiver(bondChangedReceiver, filter)
// We ignore missing BT adapters, because it lets us run on the emulator
try {
device.createBond()
} catch (ex: Throwable) {
SLogging.warn("Failed creating Bluetooth bond: ${ex.message}")
}
}
@HiltViewModel
class BTScanModel @Inject constructor(
private val application: Application,
@ -91,11 +45,18 @@ class BTScanModel @Inject constructor(
) : ViewModel(), Logging {
private val context: Context get() = application.applicationContext
val devices = MutableLiveData<MutableMap<String, DeviceListEntry>>(mutableMapOf())
private val bleDevices = MutableLiveData<List<BluetoothDevice>>(listOf())
private val usbDevices = MutableLiveData<Map<String, UsbSerialDriver>>(mapOf())
init {
bluetoothRepository.state.value.bondedDevices.onEach {
setupScan() // TODO clean up device list updates
}.launchIn(viewModelScope)
combine(
bluetoothRepository.state.value.bondedDevices,
usbRepository.serialDevicesWithDrivers
) { ble, usb ->
bleDevices.value = ble
usbDevices.value = usb
}.onEach { setupScan() }.launchIn(viewModelScope)
debug("BTScanModel created")
}
@ -114,6 +75,13 @@ class BTScanModel @Inject constructor(
val isTCP: Boolean get() = prefix == 't'
}
@SuppressLint("MissingPermission")
class BLEDeviceListEntry(device: BluetoothDevice) : DeviceListEntry(
device.name,
"x${device.address}",
device.bondState == BluetoothDevice.BOND_BONDED
)
class USBDeviceListEntry(usbManager: UsbManager, val usb: UsbSerialDriver) : DeviceListEntry(
usb.device.deviceName,
SerialInterface.toInterfaceName(usb.device.deviceName),
@ -131,17 +99,16 @@ class BTScanModel @Inject constructor(
debug("BTScanModel cleared")
}
private val deviceManager get() = context.deviceManager
val hasCompanionDeviceApi get() = application.hasCompanionDeviceApi()
val hasBluetoothPermission get() = application.hasBluetoothPermission()
private val usbManager get() = context.usbManager
var selectedAddress: String? = null
val errorText = object : MutableLiveData<String?>(null) {}
val errorText = MutableLiveData<String?>(null)
fun setErrorText(text: String) {
errorText.value = text
}
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"
@ -174,17 +141,6 @@ class BTScanModel @Inject constructor(
fullAddr,
isBonded
)
// If nothing was selected, by default select the first valid thing we see
val activity: MainActivity? = try {
GeeksvilleApplication.currentActivity as MainActivity? // Can be null if app is shutting down
} catch (_: ClassCastException) {
// Buggy "Z812" phones apparently have the wrong class type for this
errormsg("Unexpected class for main activity")
null
}
if (selectedAddress == null && entry.bonded && activity != null)
changeScanSelection(activity, fullAddr)
addDevice(entry) // Add/replace entry
}
}
@ -218,7 +174,7 @@ class BTScanModel @Inject constructor(
/**
* returns true if we could start scanning, false otherwise
*/
fun setupScan(): Boolean {
private fun setupScan(): Boolean {
selectedAddress = radioInterfaceService.getDeviceAddress()
return if (MockInterface.addressValid(context, usbRepository, "")) {
@ -236,36 +192,33 @@ 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
val activity = GeeksvilleApplication.currentActivity
if (selectedAddress == null && activity is MainActivity)
changeScanSelection(
activity,
testnodes.first().fullAddress
)
true
} else {
if (scanner == null) {
// Clear the old device list
devices.value?.clear()
val newDevs = mutableMapOf<String, DeviceListEntry>()
fun addDevice(entry: DeviceListEntry) {
newDevs[entry.fullAddress] = entry
}
// Include a placeholder for "None"
addDevice(DeviceListEntry(context.getString(R.string.none), "n", true))
// Include paired Bluetooth devices
addBluetoothDevices()
bleDevices.value?.forEach {
addDevice(BLEDeviceListEntry(it))
}
// Include Network Service Discovery
nsdRepository.resolvedList?.forEach { service ->
addDevice(TCPDeviceListEntry(service))
}
val serialDevices by lazy { usbRepository.serialDevicesWithDrivers.value }
serialDevices.forEach { (_, d) ->
addDevice(USBDeviceListEntry(usbManager, d))
usbDevices.value?.forEach { (_, d) ->
addDevice(USBDeviceListEntry(context.usbManager, d))
}
devices.value = newDevs
} else {
debug("scan already running")
}
@ -274,7 +227,7 @@ class BTScanModel @Inject constructor(
}
private var networkDiscovery: Job? = null
fun startScan() {
fun startScan(activity: Activity?) {
_spinner.value = true
// Start Network Service Discovery (find TCP devices)
@ -282,10 +235,7 @@ class BTScanModel @Inject constructor(
.onEach { addDevice(TCPDeviceListEntry(it)) }
.launchIn(viewModelScope)
if (hasBluetoothPermission) {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S && hasCompanionDeviceApi)
startCompanionScan() else startClassicScan()
}
if (activity != null) startCompanionScan(activity) else startClassicScan()
}
@SuppressLint("MissingPermission")
@ -313,6 +263,8 @@ class BTScanModel @Inject constructor(
}
}
fun getRemoteDevice(address: String) = bluetoothRepository.getRemoteDevice(address)
/**
* @return DeviceListEntry from full Address (prefix + address).
* If Bluetooth is enabled and BLE Address is valid, get remote device information.
@ -320,23 +272,14 @@ class BTScanModel @Inject constructor(
@SuppressLint("MissingPermission")
fun getDeviceListEntry(fullAddress: String, bonded: Boolean = false): DeviceListEntry {
val address = fullAddress.substring(1)
val device = bluetoothRepository.getRemoteDevice(address)
val device = getRemoteDevice(address)
return if (device != null && device.name != null) {
DeviceListEntry(device.name, fullAddress, device.bondState != BluetoothDevice.BOND_NONE)
BLEDeviceListEntry(device)
} else {
DeviceListEntry(address, fullAddress, bonded)
}
}
@SuppressLint("MissingPermission")
private fun addBluetoothDevices() {
bluetoothRepository.getBondedDevices()
?.filter { it.name != null && it.name.matches(Regex(BLE_NAME_PATTERN)) }
?.forEach {
addDevice(DeviceListEntry(it.name, "x${it.address}", true))
}
}
private val _spinner = MutableLiveData(false)
val spinner: LiveData<Boolean> get() = _spinner
@ -371,9 +314,9 @@ class BTScanModel @Inject constructor(
}
@SuppressLint("NewApi")
private fun startCompanionScan() {
private fun startCompanionScan(activity: Activity) {
debug("starting companion scan")
deviceManager?.associate(
activity.companionDeviceManager?.associate(
associationRequest(),
@SuppressLint("NewApi")
object : CompanionDeviceManager.Callback() {
@ -393,118 +336,16 @@ class BTScanModel @Inject constructor(
)
}
val devices = object : MutableLiveData<MutableMap<String, DeviceListEntry>>(mutableMapOf()) {
/**
* Called when the number of active observers change from 1 to 0.
*
*
* This does not mean that there are no observers left, there may still be observers but their
* lifecycle states aren't [Lifecycle.State.STARTED] or [Lifecycle.State.RESUMED]
* (like an Activity in the back stack).
*
*
* You can check if there are observers via [.hasObservers].
*/
override fun onInactive() {
super.onInactive()
stopScan()
}
/**
* Called immediately after activity calls MeshService.changeDeviceAddress
*/
fun changeSelectedAddress(newAddress: String) {
selectedAddress = newAddress
devices.value = devices.value // Force a GUI update
}
/// 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
fun onSelected(activity: MainActivity, it: DeviceListEntry): Boolean {
// If the device is paired, let user select it, otherwise start the pairing flow
if (it.bonded) {
changeScanSelection(activity, it.fullAddress)
return true
} else {
// Handle requesting USB or bluetooth permissions for the device
debug("Requesting permissions for the device")
exceptionReporter {
if (it.isBLE) {
// Request bonding for bluetooth
// We ignore missing BT adapters, because it lets us run on the emulator
bluetoothRepository
.getRemoteDevice(it.address)?.let { device ->
requestBonding(activity, device) { state ->
if (state == BluetoothDevice.BOND_BONDED) {
errorText.value = activity.getString(R.string.pairing_completed)
changeScanSelection(activity, it.fullAddress)
} else {
errorText.value =
activity.getString(R.string.pairing_failed_try_again)
}
// Force the GUI to redraw
devices.value = devices.value
}
}
}
}
if (it.isUSB) {
it as USBDeviceListEntry
val usbReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (ACTION_USB_PERMISSION == intent.action) {
val device: UsbDevice =
intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)!!
if (intent.getBooleanExtra(
UsbManager.EXTRA_PERMISSION_GRANTED,
false
)
) {
info("User approved USB access")
changeScanSelection(activity, it.fullAddress)
// Force the GUI to redraw
devices.value = devices.value
} else {
errormsg("USB permission denied for device $device")
}
}
// We don't need to stay registered
activity.unregisterReceiver(this)
}
}
val permissionIntent =
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
PendingIntent.getBroadcast(activity, 0, Intent(ACTION_USB_PERMISSION), 0)
} else {
PendingIntent.getBroadcast(activity, 0, Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE)
}
val filter = IntentFilter(ACTION_USB_PERMISSION)
activity.registerReceiver(usbReceiver, filter)
usbManager.requestPermission(it.usb.device, permissionIntent)
}
return false
}
}
/// Change to a new macaddr selection, updating GUI and radio
fun changeScanSelection(context: MainActivity, newAddr: String) {
try {
info("Changing device to ${newAddr.anonymize}")
changeDeviceSelection(context, newAddr)
selectedAddress =
newAddr // do this after changeDeviceSelection, so if it throws the change will be discarded
devices.value = devices.value // Force a GUI update
} catch (ex: RemoteException) {
errormsg("Failed talking to service, probably it is shutting down $ex.message")
// ignore the failure and the GUI won't be updating anyways
}
}
companion object {
const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$"
const val BLE_NAME_PATTERN = BluetoothRepository.BLE_NAME_PATTERN
const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION"
}
}
}

Wyświetl plik

@ -52,6 +52,7 @@ data class Channel(
ModemPreset.MEDIUM_SLOW -> "MediumSlow"
ModemPreset.LONG_FAST -> "LongFast"
ModemPreset.LONG_SLOW -> "LongSlow"
ModemPreset.LONG_MODERATE -> "LongMod"
ModemPreset.VERY_LONG_SLOW -> "VLongSlow"
else -> "Invalid"
}

Wyświetl plik

@ -12,6 +12,7 @@ enum class ChannelOption(
MEDIUM_FAST(ModemPreset.MEDIUM_FAST, R.string.modem_config_medium),
MEDIUM_SLOW(ModemPreset.MEDIUM_SLOW, R.string.modem_config_slow_medium),
LONG_FAST(ModemPreset.LONG_FAST, R.string.modem_config_long),
LONG_MODERATE(ModemPreset.LONG_MODERATE, R.string.modem_config_mod_long),
LONG_SLOW(ModemPreset.LONG_SLOW, R.string.modem_config_slow_long),
VERY_LONG_SLOW(ModemPreset.VERY_LONG_SLOW, R.string.modem_config_very_long);

Wyświetl plik

@ -40,11 +40,9 @@ data class ChannelSet(
* Return the primary channel info
*/
val primaryChannel: Channel?
get() =
if (protobuf.settingsCount > 0)
Channel(protobuf.getSettings(0), protobuf.loraConfig)
else
null
get() = with(protobuf) {
if (settingsCount > 0) Channel(getSettings(0), loraConfig) else null
}
/// Return an URL that represents the current channel values
/// @param upperCasePrefix - portions of the URL can be upper case to make for more efficient QR codes

Wyświetl plik

@ -56,6 +56,7 @@ class NodeDB(private val ui: UIViewModel) {
private val _nodes = MutableLiveData<Map<String, NodeInfo>>(mapOf(*(if (seedWithTestNodes) testNodes else listOf()).map { it.user!!.id to it }
.toTypedArray()))
val nodes: LiveData<Map<String, NodeInfo>> get() = _nodes
val nodesByNum get() = nodes.value?.values?.associateBy { it.num }
fun setNodes(nodes: Map<String, NodeInfo>) {
_nodes.value = nodes

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,13 +26,14 @@ 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
@ -49,11 +52,12 @@ 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
@ -82,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 {
@ -95,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
@ -118,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 ->
@ -129,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 {
@ -140,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")
}
@ -172,6 +188,14 @@ class UIViewModel @Inject constructor(
.filterValues { it.data.waypoint!!.expire > System.currentTimeMillis() / 1000 }
}.asLiveData()
/**
* Called immediately after activity observes packetResponse
*/
fun clearPacketResponse() {
requestId.value = null
_packetResponse.value = null
}
fun generatePacketId(): Int? {
return try {
meshService?.packetId
@ -207,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)
@ -282,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?>()
@ -313,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)
@ -440,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) {
@ -460,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.
*/
@ -544,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
}
}
@ -552,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
}
}
@ -576,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
@ -624,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

@ -61,14 +61,9 @@ class BluetoothRepository @Inject constructor(
}
fun getBluetoothLeScanner(): BluetoothLeScanner? {
return bluetoothAdapterLazy.get()?.bluetoothLeScanner
}
@SuppressLint("MissingPermission")
fun getBondedDevices(): Set<BluetoothDevice>? {
return bluetoothAdapterLazy.get()
?.takeIf { application.hasBluetoothPermission() }
?.bondedDevices
?.bluetoothLeScanner
}
@SuppressLint("MissingPermission")
@ -94,16 +89,18 @@ class BluetoothRepository @Inject constructor(
* Creates a cold Flow used to obtain the set of bonded devices.
*/
@SuppressLint("MissingPermission") // Already checked prior to calling
private suspend fun createBondedDevicesFlow(adapter: BluetoothAdapter): Flow<Set<BluetoothDevice>> {
return flow<Set<BluetoothDevice>> {
private suspend fun createBondedDevicesFlow(adapter: BluetoothAdapter): Flow<List<BluetoothDevice>> {
return flow<List<BluetoothDevice>> {
val devices = adapter.bondedDevices ?: emptySet()
while (true) {
emit(adapter.bondedDevices ?: emptySet())
emit(devices.filter { it.name?.matches(Regex(BLE_NAME_PATTERN)) == true })
delay(REFRESH_DELAY_MS)
}
}.flowOn(dispatchers.default).distinctUntilChanged()
}
companion object {
const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$"
const val REFRESH_DELAY_MS = 1000L
}
}

Wyświetl plik

@ -13,5 +13,5 @@ data class BluetoothState(
/** If we have adequate permissions and bluetooth is enabled */
val enabled: Boolean = false,
/** If enabled, a cold flow of the currently bonded devices */
val bondedDevices: Flow<Set<BluetoothDevice>> = flowOf(emptySet())
val bondedDevices: Flow<List<BluetoothDevice>> = flowOf(emptyList())
)

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

@ -12,7 +12,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.collections.ArrayList
@Singleton
class NsdRepository @Inject constructor(
@ -21,7 +20,7 @@ class NsdRepository @Inject constructor(
) : Logging {
private val resolveQueue = Semaphore(1)
private var hostsList: ArrayList<NsdServiceInfo>? = ArrayList()
private var hostsList: ArrayList<NsdServiceInfo>? = null
val resolvedList: List<NsdServiceInfo>? get() = hostsList
@ -91,7 +90,7 @@ class NsdRepository @Inject constructor(
companion object {
//To find all the available networks SERVICE_TYPE = "_services._dns-sd._udp"
const val SERVICE_TYPE = "_http._tcp."
const val SERVICE_TYPE = "_https._tcp."
const val serviceName = "Meshtastic"
}
}

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
@ -307,9 +299,8 @@ class MeshService : Service(), Logging {
private fun getPrefs() = getSharedPreferences("service-prefs", Context.MODE_PRIVATE)
/// Save information about our mesh to disk, so we will have it when we next start the service (even before we hear from our device)
private fun saveSettings() {
private fun saveSettings() = synchronized(nodeDBbyNodeNum) {
myNodeInfo?.let { myInfo ->
val nodeDBbyNodeNum = nodeDBbyNodeNum.toMap()
val settings = MeshServiceSettingsData(
myInfo = myInfo,
nodeDB = nodeDBbyNodeNum.values.toTypedArray(),
@ -356,7 +347,7 @@ class MeshService : Service(), Logging {
/**
* discard entire node db & message state - used when downloading a new db from the device
*/
private fun discardNodeDB() {
private fun discardNodeDB() = synchronized(nodeDBbyNodeNum) {
debug("Discarding NodeDB")
myNodeInfo = null
nodeDBbyNodeNum.clear()
@ -428,7 +419,8 @@ class MeshService : Service(), Logging {
/**
* How many nodes are currently online (including our local node)
*/
private val numOnlineNodes get() = nodeDBbyNodeNum.values.count { it.isOnline }
private val numOnlineNodes
get() = synchronized(nodeDBbyNodeNum) { nodeDBbyNodeNum.values.count { it.isOnline } }
private fun toNodeNum(id: String): Int = when (id) {
DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
@ -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,35 +1762,45 @@ class MeshService : Service(), Logging {
stopLocationRequests()
}
override fun requestPosition(idNum: Int, position: Position) =
toRemoteExceptions {
val (lat, lon, alt) = with(position) { Triple(latitude, longitude, altitude) }
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 requestShutdown(idNum: Int) = toRemoteExceptions {
sendToRadio(newMeshPacketTo(idNum).buildAdminPacket {
override fun requestTraceroute(requestId: Int, destNum: Int) = toRemoteExceptions {
sendToRadio(newMeshPacketTo(destNum).buildMeshPacket(id = requestId) {
portnumValue = Portnums.PortNum.TRACEROUTE_APP_VALUE
payload = routeDiscovery {}.toByteString()
wantResponse = true
})
}
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

@ -1,373 +1,415 @@
package com.geeksville.mesh.ui
import android.content.ActivityNotFoundException
import android.content.Intent
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.net.Uri
import android.os.Bundle
import android.os.RemoteException
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
import android.widget.ImageView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.SnackbarHost
import androidx.compose.material.SnackbarHostState
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalClipboardManager
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.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.asLiveData
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
import com.geeksville.mesh.android.hideKeyboard
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.android.BuildUtils.debug
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.databinding.ChannelFragmentBinding
import com.geeksville.mesh.model.Channel
import com.geeksville.mesh.model.ChannelOption
import com.geeksville.mesh.model.ChannelSet
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.util.onEditorAction
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.components.DropDownPreference
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.android.material.snackbar.Snackbar
import com.google.protobuf.ByteString
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import dagger.hilt.android.AndroidEntryPoint
import java.security.SecureRandom
// Make an image view dim
fun ImageView.setDim() {
val matrix = ColorMatrix()
matrix.setSaturation(0f) //0 means grayscale
val cf = ColorMatrixColorFilter(matrix)
colorFilter = cf
imageAlpha = 64 // 128 = 0.5
}
/// Return image view to normal
fun ImageView.setOpaque() {
colorFilter = null
imageAlpha = 255
}
import kotlinx.coroutines.launch
@AndroidEntryPoint
class ChannelFragment : ScreenFragment("Channel"), Logging {
private var _binding: ChannelFragmentBinding? = null
// This property is only valid between onCreateView and onDestroyView.
private val binding get() = _binding!!
private val model: UIViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = ChannelFragmentBinding.inflate(inflater, container, false)
return binding.root
}
/// Called when the lock/unlock icon has changed
private fun onEditingChanged() {
val isEditing = binding.editableCheckbox.isChecked
binding.channelOptions.isEnabled = isEditing
binding.shareButton.isEnabled = !isEditing
binding.resetButton.isEnabled = isEditing
binding.scanButton.isEnabled = isEditing
binding.channelNameView.isEnabled = isEditing
if (isEditing) // Dim the (stale) QR code while editing...
binding.qrView.setDim()
else
binding.qrView.setOpaque()
}
/// Pull the latest data from the model (discarding any user edits)
private fun setGUIfromModel() {
val channels = model.channels.value
val channel = channels.primaryChannel
val connected = model.isConnected()
// Only let buttons work if we are connected to the radio
binding.editableCheckbox.isChecked = false // start locked
onEditingChanged() // we just locked the gui
binding.shareButton.isEnabled = connected
if (channel != null) {
binding.qrView.visibility = View.VISIBLE
binding.channelNameEdit.visibility = View.VISIBLE
binding.channelNameEdit.setText(channel.humanName)
// For now, we only let the user edit/save channels while the radio is awake - because the service
// doesn't cache DeviceConfig writes.
binding.editableCheckbox.isEnabled = connected
val bitmap = channels.qrCode
if (bitmap != null)
binding.qrView.setImageBitmap(bitmap)
val modemPreset = channel.loraConfig.modemPreset
val channelOption = ChannelOption.fromConfig(modemPreset)
binding.filledExposedDropdown.setText(
getString(
channelOption?.configRes ?: R.string.modem_config_unrecognized
), false
)
} else {
binding.qrView.visibility = View.INVISIBLE
binding.channelNameEdit.visibility = View.INVISIBLE
binding.editableCheckbox.isEnabled = false
}
val modemPresets = ChannelOption.values()
val modemPresetList = modemPresets.map { getString(it.configRes) }
val adapter = ArrayAdapter(
requireContext(),
R.layout.dropdown_menu_popup_item,
modemPresetList
)
binding.filledExposedDropdown.setAdapter(adapter)
}
private fun shareChannel() {
model.channels.value.let { channels ->
GeeksvilleApplication.analytics.track(
"share",
DataPair("content_type", "channel")
) // track how many times users share channels
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, channels.getChannelUrl().toString())
putExtra(
Intent.EXTRA_TITLE,
getString(R.string.url_for_join)
)
type = "text/plain"
}
try {
val shareIntent = Intent.createChooser(sendIntent, null)
requireActivity().startActivity(shareIntent)
} catch (ex: ActivityNotFoundException) {
Snackbar.make(
requireView(),
R.string.no_app_found,
Snackbar.LENGTH_SHORT
).show()
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppCompatTheme {
ChannelScreen(model)
}
}
}
}
}
@Composable
fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
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()
val modemPresetName = Channel(Channel.default.settings, channelSet.loraConfig).name
val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
viewModel.setRequestChannelUrl(Uri.parse(result.contents))
}
}
fun zxingScan() {
debug("Starting zxing QR code scanner")
val zxingScan = ScanOptions()
zxingScan.setCameraId(0)
zxingScan.setPrompt("")
zxingScan.setBeepEnabled(false)
zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
barcodeLauncher.launch(zxingScan)
}
val requestPermissionAndScanLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions.entries.all { it.value }) zxingScan()
}
fun requestPermissionAndScan() {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.camera_required)
.setMessage(R.string.why_camera_required)
.setNeutralButton(R.string.cancel) { _, _ ->
debug("Camera permission denied")
}
.setPositiveButton(R.string.accept) { _, _ ->
requestPermissionAndScanLauncher.launch(context.getCameraPermissions())
}
.show()
}
/// Send new channel settings to the device
private fun installSettings(
newChannel: ChannelProtos.ChannelSettings,
newLoRaConfig: ConfigProtos.Config.LoRaConfig
fun installSettings(
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 {
model.setChannels(newSet)
viewModel.setChannels(newSet)
// Since we are writing to DeviceConfig, that will trigger the rest of the GUI update (QR code etc)
} catch (ex: RemoteException) {
errormsg("ignoring channel problem", ex)
setGUIfromModel() // Throw away user edits
channelSet = channels.protobuf // Throw away user edits
// Tell the user to try again
Snackbar.make(
requireView(),
R.string.radio_sleeping,
Snackbar.LENGTH_SHORT
).show()
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.radio_sleeping))
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
fun installSettings(
newChannel: ChannelProtos.ChannelSettings,
newLoRaConfig: ConfigProtos.Config.LoRaConfig
) {
val newSet = channelSet {
settings.add(newChannel)
loraConfig = newLoRaConfig
}
installSettings(newSet)
}
val barcodeLauncher = registerForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
model.setRequestChannelUrl(Uri.parse(result.contents))
fun resetButton() {
// User just locked it, we should warn and then apply changes to radio
MaterialAlertDialogBuilder(context)
.setTitle(R.string.reset_to_defaults)
.setMessage(R.string.are_you_sure_change_default)
.setNeutralButton(R.string.cancel) { _, _ ->
channelSet = channels.protobuf // throw away any edits
}
}
.setPositiveButton(R.string.apply) { _, _ ->
debug("Switching back to default channel")
installSettings(
Channel.default.settings,
Channel.default.loraConfig.copy {
region = viewModel.region
txEnabled = viewModel.txEnabled
}
)
}
.show()
}
fun zxingScan() {
debug("Starting zxing QR code scanner")
val zxingScan = ScanOptions()
zxingScan.setCameraId(0)
zxingScan.setPrompt("")
zxingScan.setBeepEnabled(false)
zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
barcodeLauncher.launch(zxingScan)
}
val requestPermissionAndScanLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions.entries.all { it.value }) zxingScan()
fun sendButton() {
primaryChannel?.let { primaryChannel ->
val humanName = primaryChannel.humanName
val message = buildString {
append(context.getString(R.string.are_you_sure_channel))
if (primaryChannel.settings == Channel.default.settings)
append("\n\n" + context.getString(R.string.warning_default_psk, humanName))
}
fun requestPermissionAndScan() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.camera_required)
.setMessage(R.string.why_camera_required)
MaterialAlertDialogBuilder(context)
.setTitle(R.string.change_channel)
.setMessage(message)
.setNeutralButton(R.string.cancel) { _, _ ->
debug("Camera permission denied")
channelSet = channels.protobuf
}
.setPositiveButton(getString(R.string.accept)) { _, _ ->
requestPermissionAndScanLauncher.launch(requireContext().getCameraPermissions())
.setPositiveButton(R.string.accept) { _, _ ->
installSettings(channelSet)
}
.show()
}
}
binding.channelNameEdit.onEditorAction(EditorInfo.IME_ACTION_DONE) {
requireActivity().hideKeyboard()
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 {
RegularPreference(
title = stringResource(R.string.channel_name),
subtitle = primaryChannel?.humanName.orEmpty(),
onClick = { showChannelEditor = true },
enabled = enabled,
trailingIcon = Icons.TwoTone.Edit
)
}
binding.resetButton.setOnClickListener {
// User just locked it, we should warn and then apply changes to radio
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.reset_to_defaults)
.setMessage(R.string.are_you_sure_change_default)
.setNeutralButton(R.string.cancel) { _, _ ->
setGUIfromModel() // throw away any edits
}
.setPositiveButton(R.string.apply) { _, _ ->
debug("Switching back to default channel")
installSettings(
Channel.default.settings,
Channel.default.loraConfig.copy {
region = model.region
txEnabled = model.txEnabled
}
)
}
.show()
if (!isEditing) item {
Image(
painter = ChannelSet(channelSet).qrCode?.let { BitmapPainter(it.asImageBitmap()) }
?: painterResource(id = R.drawable.qrcode),
contentDescription = stringResource(R.string.qr_code),
contentScale = ContentScale.FillWidth,
alpha = if (enabled) 1f else 0.25f,
// colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 16.dp)
)
}
binding.scanButton.setOnClickListener {
if (requireContext().hasCameraPermission()) zxingScan()
else requestPermissionAndScan()
item {
var valueState by remember(channelUrl) { mutableStateOf(channelUrl) }
val isError = valueState != channelUrl
OutlinedTextField(
value = valueState.toString(),
onValueChange = {
try {
valueState = Uri.parse(it)
channelSet = ChannelSet(valueState).protobuf
} catch (ex: Throwable) {
// channelSet failed to update, isError true
}
},
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
label = { Text("URL") },
isError = isError,
trailingIcon = {
val isUrlEqual = channelUrl == channels.getChannelUrl()
IconButton(onClick = {
when {
isError -> valueState = channelUrl
!isUrlEqual -> viewModel.setRequestChannelUrl(channelUrl)
else -> {
// track how many times users share channels
GeeksvilleApplication.analytics.track(
"share",
DataPair("content_type", "channel")
)
clipboardManager.setText(AnnotatedString(channelUrl.toString()))
}
}
}) {
Icon(
painter = when {
isError -> rememberVectorPainter(Icons.TwoTone.Close)
!isUrlEqual -> rememberVectorPainter(Icons.TwoTone.Check)
else -> painterResource(R.drawable.ic_twotone_content_copy_24)
},
contentDescription = when {
isError -> "Error"
!isUrlEqual -> stringResource(R.string.send)
else -> "Copy"
},
tint = if (isError) MaterialTheme.colors.error
else LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
)
}
},
maxLines = 1,
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
)
}
// Note: Do not use setOnCheckedChanged here because we don't want to be called when we programmatically disable editing
binding.editableCheckbox.setOnClickListener {
item {
DropDownPreference(title = stringResource(id = R.string.channel_options),
enabled = enabled,
items = ChannelOption.values()
.map { it.modemPreset to stringResource(it.configRes) },
selectedItem = channelSet.loraConfig.modemPreset,
onItemSelected = {
val lora = channelSet.loraConfig.copy { modemPreset = it }
channelSet = channelSet.copy { loraConfig = lora }
})
}
/// We use this to determine if the user tried to install a custom name
var originalName = ""
val checked = binding.editableCheckbox.isChecked
if (checked) {
// User just unlocked for editing - remove the # goo around the channel name
model.channels.value.primaryChannel?.let { ch ->
// Note: We are careful to show the empty string here if the user was on a default channel, so the user knows they should it for any changes
originalName = ch.settings.name
binding.channelNameEdit.setText(originalName)
}
} else {
// User just locked it, we should warn and then apply changes to radio
model.channels.value.primaryChannel?.let { oldPrimary ->
var newSettings = oldPrimary.settings
val newName = binding.channelNameEdit.text.toString().trim()
// Find the new modem config
val selectedModemPresetString =
binding.filledExposedDropdown.editableText.toString()
var newModemPreset = getModemPreset(selectedModemPresetString)
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 user changes channel name or the name is non-default and the settings changed
val shouldUseRandomKey =
newName != originalName || (newName.isNotEmpty() && newModemPreset != oldPrimary.loraConfig.modemPreset)
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.take(11) // proto max_size:12
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 = model.config.lora.copy {
usePreset = true
modemPreset = newModemPreset
bandwidth = 0
spreadFactor = 0
codingRate = 0
}
val humanName = Channel(newSettings, newLoRaConfig).humanName
binding.channelNameEdit.setText(humanName)
val message = buildString {
append(getString(R.string.are_you_sure_channel))
if (!shouldUseRandomKey)
append("\n\n" + getString(R.string.warning_default_psk).format(humanName))
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.change_channel)
.setMessage(message)
.setNeutralButton(R.string.cancel) { _, _ ->
setGUIfromModel()
}
.setPositiveButton(getString(R.string.accept)) { _, _ ->
// Generate a new channel with only the changes the user can change in the GUI
installSettings(newSettings, newLoRaConfig)
}
.show()
}
if (isEditing) item {
PreferenceFooter(
enabled = enabled,
onCancelClicked = {
focusManager.clearFocus()
channelSet = channels.protobuf
},
onSaveClicked = {
focusManager.clearFocus()
// viewModel.setRequestChannelUrl(channelUrl)
sendButton()
})
} else {
item {
PreferenceFooter(
enabled = enabled,
negativeText = R.string.reset,
onNegativeClicked = {
focusManager.clearFocus()
resetButton()
},
positiveText = R.string.scan,
onPositiveClicked = {
focusManager.clearFocus()
// viewModel.setRequestChannelUrl(channelUrl)
if (context.hasCameraPermission()) zxingScan() else requestPermissionAndScan()
})
}
onEditingChanged() // update GUI on what user is allowed to edit/share
}
// Share this particular channel if someone clicks share
binding.shareButton.setOnClickListener {
shareChannel()
}
model.channels.asLiveData().observe(viewLifecycleOwner) {
setGUIfromModel()
}
// If connection state changes, we might need to enable/disable buttons
model.connectionState.observe(viewLifecycleOwner) {
setGUIfromModel()
}
}
private fun getModemPreset(selectedChannelOptionString: String): ConfigProtos.Config.LoRaConfig.ModemPreset {
for (item in ChannelOption.values()) {
if (getString(item.configRes) == selectedChannelOptionString)
return item.modemPreset
}
return ConfigProtos.Config.LoRaConfig.ModemPreset.UNRECOGNIZED
}
SnackbarHost(hostState = snackbarHostState)
}
@Preview(showBackground = true)
@Composable
fun ChannelScreenPreview() {
// ChannelScreen()
}

Wyświetl plik

@ -222,6 +222,13 @@ class ContactsFragment : ScreenFragment("Messages"), Logging {
}
}
override fun onDestroyView() {
super.onDestroyView()
actionMode?.finish()
actionMode = null
_binding = null
}
private inner class ActionModeCallback : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.menu_messages, menu)

Wyświetl plik

@ -51,4 +51,9 @@ class DebugFragment : Fragment() {
logs?.let { adapter.setLogs(it) }
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

Wyświetl plik

@ -1,21 +1,105 @@
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.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
import androidx.core.content.ContextCompat
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
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
import com.geeksville.mesh.ui.components.config.RemoteHardwareConfigItemList
import com.geeksville.mesh.ui.components.config.SerialConfigItemList
import com.geeksville.mesh.ui.components.config.StoreForwardConfigItemList
import com.geeksville.mesh.ui.components.config.TelemetryConfigItemList
import com.geeksville.mesh.ui.components.config.UserConfigItemList
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()
@ -29,9 +113,676 @@ class DeviceSettingsFragment : ScreenFragment("Device Settings"), Logging {
setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground))
setContent {
AppCompatTheme {
DeviceSettingsItemList(model)
RadioConfigNavHost(node, model)
}
}
}
}
}
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, 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(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 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(
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 = userConfig,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { userInput ->
focusManager.clearFocus()
viewModel.setRemoteOwner(destNum, userInput)
userConfig = userInput
}
)
}
composable("device") {
DeviceConfigItemList(
deviceConfig = radioConfig.device,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { deviceInput ->
focusManager.clearFocus()
val config = config { device = deviceInput }
viewModel.setRemoteConfig(destNum, config)
radioConfig = config
}
)
}
composable("position") {
PositionConfigItemList(
isLocal = isLocal,
location = location,
positionConfig = radioConfig.position,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { locationInput, positionInput ->
focusManager.clearFocus()
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 = radioConfig.power,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { powerInput ->
focusManager.clearFocus()
val config = config { power = powerInput }
viewModel.setRemoteConfig(destNum, config)
radioConfig = config
}
)
}
composable("network") {
NetworkConfigItemList(
networkConfig = radioConfig.network,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { networkInput ->
focusManager.clearFocus()
val config = config { network = networkInput }
viewModel.setRemoteConfig(destNum, config)
radioConfig = config
}
)
}
composable("display") {
DisplayConfigItemList(
displayConfig = radioConfig.display,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { displayInput ->
focusManager.clearFocus()
val config = config { display = displayInput }
viewModel.setRemoteConfig(destNum, config)
radioConfig = config
}
)
}
composable("lora") {
LoRaConfigItemList(
loraConfig = radioConfig.lora,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { loraInput ->
focusManager.clearFocus()
val config = config { lora = loraInput }
viewModel.setRemoteConfig(destNum, config)
radioConfig = config
}
)
}
composable("bluetooth") {
BluetoothConfigItemList(
bluetoothConfig = radioConfig.bluetooth,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { bluetoothInput ->
focusManager.clearFocus()
val config = config { bluetooth = bluetoothInput }
viewModel.setRemoteConfig(destNum, config)
radioConfig = config
}
)
}
composable("mqtt") {
MQTTConfigItemList(
mqttConfig = moduleConfig.mqtt,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { mqttInput ->
focusManager.clearFocus()
val config = moduleConfig { mqtt = mqttInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
)
}
composable("serial") {
SerialConfigItemList(
serialConfig = moduleConfig.serial,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { serialInput ->
focusManager.clearFocus()
val config = moduleConfig { serial = serialInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
)
}
composable("ext_not") {
ExternalNotificationConfigItemList(
ringtone = ringtone,
extNotificationConfig = moduleConfig.externalNotification,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { ringtoneInput, extNotificationInput ->
focusManager.clearFocus()
if (ringtoneInput != ringtone) {
viewModel.setRingtone(destNum, ringtoneInput)
ringtone = ringtoneInput
}
if (extNotificationInput != moduleConfig.externalNotification) {
val config = moduleConfig { externalNotification = extNotificationInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
}
)
}
composable("store_forward") {
StoreForwardConfigItemList(
storeForwardConfig = moduleConfig.storeForward,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { storeForwardInput ->
focusManager.clearFocus()
val config = moduleConfig { storeForward = storeForwardInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
)
}
composable("range_test") {
RangeTestConfigItemList(
rangeTestConfig = moduleConfig.rangeTest,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { rangeTestInput ->
focusManager.clearFocus()
val config = moduleConfig { rangeTest = rangeTestInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
)
}
composable("telemetry") {
TelemetryConfigItemList(
telemetryConfig = moduleConfig.telemetry,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { telemetryInput ->
focusManager.clearFocus()
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 = { messagesInput, cannedMessageInput ->
focusManager.clearFocus()
if (messagesInput != cannedMessageMessages) {
viewModel.setCannedMessages(destNum, messagesInput)
cannedMessageMessages = messagesInput
}
if (cannedMessageInput != moduleConfig.cannedMessage) {
val config = moduleConfig { cannedMessage = cannedMessageInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
}
)
}
composable("audio") {
AudioConfigItemList(
audioConfig = moduleConfig.audio,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { audioInput ->
focusManager.clearFocus()
val config = moduleConfig { audio = audioInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
)
}
composable("remote_hardware") {
RemoteHardwareConfigItemList(
remoteHardwareConfig = moduleConfig.remoteHardware,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { remoteHardwareInput ->
focusManager.clearFocus()
val config = moduleConfig { remoteHardware = remoteHardwareInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
)
}
}
}
@Composable
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)
.clickable(enabled = enabled) { onClick() },
elevation = 4.dp
) {
Row(
verticalAlignment = Alignment.CenterVertically,
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 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, enabled = enabled) { onRouteClick(configs.config) }
}
item { PreferenceCategory(stringResource(R.string.module_settings)) }
items(ModuleDest.values()) { modules ->
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()
}

Wyświetl plik

@ -1,900 +0,0 @@
package com.geeksville.mesh.ui
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.ConfigProtos.Config.NetworkConfig
import com.geeksville.mesh.R
import com.geeksville.mesh.copy
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.getInitials
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.components.BitwisePreference
import com.geeksville.mesh.ui.components.DropDownPreference
import com.geeksville.mesh.ui.components.EditIPv4Preference
import com.geeksville.mesh.ui.components.EditListPreference
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
@Composable
fun DeviceSettingsItemList(viewModel: UIViewModel = viewModel()) {
val focusManager = LocalFocusManager.current
val hasWifi = viewModel.hasWifi()
val connectionState by viewModel.connectionState.observeAsState()
val connected = connectionState == MeshService.ConnectionState.CONNECTED
val localConfig by viewModel.localConfig.collectAsState()
val ourNodeInfo by viewModel.ourNodeInfo.collectAsState()
var userInput by remember(ourNodeInfo?.user) { mutableStateOf(ourNodeInfo?.user) }
var positionInfo by remember(ourNodeInfo?.position) { mutableStateOf(ourNodeInfo?.position) }
// Temporary [ConfigProtos.Config] state holders
var deviceInput by remember(localConfig.device) { mutableStateOf(localConfig.device) }
var positionInput by remember(localConfig.position) { mutableStateOf(localConfig.position) }
var powerInput by remember(localConfig.power) { mutableStateOf(localConfig.power) }
var networkInput by remember(localConfig.network) { mutableStateOf(localConfig.network) }
var displayInput by remember(localConfig.display) { mutableStateOf(localConfig.display) }
var loraInput by remember(localConfig.lora) { mutableStateOf(localConfig.lora) }
var bluetoothInput by remember(localConfig.bluetooth) { mutableStateOf(localConfig.bluetooth) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "User Config") }
item {
RegularPreference(title = "Node ID",
subtitle = userInput?.id ?: stringResource(id = R.string.unknown),
onClick = {})
}
item { Divider() }
item {
EditTextPreference(title = "Long name",
value = userInput?.longName ?: stringResource(id = R.string.unknown_username),
maxSize = 39, // long_name max_size:40
enabled = connected && userInput?.longName != null,
isError = userInput?.longName.isNullOrEmpty(),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
userInput?.let { userInput = it.copy(longName = value) }
if (getInitials(value).toByteArray().size <= 4) // short_name max_size:5
userInput?.let { userInput = it.copy(shortName = getInitials(value)) }
})
}
item {
EditTextPreference(title = "Short name",
value = userInput?.shortName ?: stringResource(id = R.string.unknown),
maxSize = 4, // short_name max_size:5
enabled = connected && userInput?.shortName != null,
isError = userInput?.shortName.isNullOrEmpty(),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
userInput?.let { userInput = it.copy(shortName = value) }
})
}
item {
RegularPreference(title = "Hardware model",
subtitle = userInput?.hwModel?.name ?: stringResource(id = R.string.unknown),
onClick = {})
}
item { Divider() }
item {
SwitchPreference(title = "Licensed amateur radio",
checked = userInput?.isLicensed ?: false,
enabled = connected && userInput?.isLicensed != null,
onCheckedChange = { value ->
userInput?.let { userInput = it.copy(isLicensed = value) }
})
}
item { Divider() }
item {
PreferenceFooter(
enabled = userInput != ourNodeInfo?.user,
onCancelClicked = {
focusManager.clearFocus()
userInput = ourNodeInfo?.user
}, onSaveClicked = {
focusManager.clearFocus()
userInput?.let { viewModel.setOwner(it) }
})
}
item { PreferenceCategory(text = "Device Config") }
item {
DropDownPreference(title = "Role",
enabled = connected,
items = ConfigProtos.Config.DeviceConfig.Role.values()
.filter { it != ConfigProtos.Config.DeviceConfig.Role.UNRECOGNIZED }
.map { it to it.name },
selectedItem = deviceInput.role,
onItemSelected = { deviceInput = deviceInput.copy { role = it } })
}
item { Divider() }
item {
SwitchPreference(title = "Serial output enabled",
checked = deviceInput.serialEnabled,
enabled = connected,
onCheckedChange = { deviceInput = deviceInput.copy { serialEnabled = it } })
}
item { Divider() }
item {
SwitchPreference(title = "Debug log enabled",
checked = deviceInput.debugLogEnabled,
enabled = connected,
onCheckedChange = { deviceInput = deviceInput.copy { debugLogEnabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Redefine PIN_BUTTON",
value = deviceInput.buttonGpio,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
deviceInput = deviceInput.copy { buttonGpio = it }
})
}
item {
EditTextPreference(title = "Redefine PIN_BUZZER",
value = deviceInput.buzzerGpio,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
deviceInput = deviceInput.copy { buzzerGpio = it }
})
}
item {
DropDownPreference(title = "Rebroadcast mode",
enabled = connected,
items = ConfigProtos.Config.DeviceConfig.RebroadcastMode.values()
.filter { it != ConfigProtos.Config.DeviceConfig.RebroadcastMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = deviceInput.rebroadcastMode,
onItemSelected = { deviceInput = deviceInput.copy { rebroadcastMode = it } })
}
item { Divider() }
item {
EditTextPreference(title = "NodeInfo broadcast interval",
value = deviceInput.nodeInfoBroadcastSecs,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
deviceInput = deviceInput.copy { nodeInfoBroadcastSecs = it }
})
}
item {
PreferenceFooter(
enabled = deviceInput != localConfig.device,
onCancelClicked = {
focusManager.clearFocus()
deviceInput = localConfig.device
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateDeviceConfig { deviceInput }
})
}
item { PreferenceCategory(text = "Position Config") }
item {
EditTextPreference(title = "Position broadcast interval",
value = positionInput.positionBroadcastSecs,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
positionInput = positionInput.copy { positionBroadcastSecs = it }
})
}
item {
SwitchPreference(title = "Smart position enabled",
checked = positionInput.positionBroadcastSmartEnabled,
enabled = connected,
onCheckedChange = {
positionInput = positionInput.copy { positionBroadcastSmartEnabled = it }
})
}
item { Divider() }
if (positionInput.positionBroadcastSmartEnabled) {
item {
EditTextPreference(title = "Smart broadcast minimum distance",
value = positionInput.broadcastSmartMinimumDistance,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
positionInput = positionInput.copy { broadcastSmartMinimumDistance = it }
})
}
item {
EditTextPreference(title = "Smart broadcast minimum interval",
value = positionInput.broadcastSmartMinimumIntervalSecs,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
positionInput = positionInput.copy { broadcastSmartMinimumDistance = it }
})
}
}
item {
SwitchPreference(title = "Use fixed position",
checked = positionInput.fixedPosition,
enabled = connected,
onCheckedChange = { positionInput = positionInput.copy { fixedPosition = it } })
}
item { Divider() }
if (positionInput.fixedPosition) {
item {
EditTextPreference(title = "Latitude",
value = positionInfo?.latitude ?: 0.0,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
if (value >= -90 && value <= 90.0)
positionInfo?.let { positionInfo = it.copy(latitude = value) }
})
}
item {
EditTextPreference(title = "Longitude",
value = positionInfo?.longitude ?: 0.0,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
if (value >= -180 && value <= 180.0)
positionInfo?.let { positionInfo = it.copy(longitude = value) }
})
}
item {
EditTextPreference(title = "Altitude",
value = positionInfo?.altitude ?: 0,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
positionInfo?.let { positionInfo = it.copy(altitude = value) }
})
}
}
item {
SwitchPreference(title = "GPS enabled",
checked = positionInput.gpsEnabled,
enabled = connected,
onCheckedChange = { positionInput = positionInput.copy { gpsEnabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "GPS update interval",
value = positionInput.gpsUpdateInterval,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { positionInput = positionInput.copy { gpsUpdateInterval = it } })
}
item {
EditTextPreference(title = "Fix attempt duration",
value = positionInput.gpsAttemptTime,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { positionInput = positionInput.copy { gpsAttemptTime = it } })
}
item {
BitwisePreference(title = "Position flags",
value = positionInput.positionFlags,
enabled = connected,
items = ConfigProtos.Config.PositionConfig.PositionFlags.values()
.filter { it != ConfigProtos.Config.PositionConfig.PositionFlags.UNSET && it != ConfigProtos.Config.PositionConfig.PositionFlags.UNRECOGNIZED }
.map { it.number to it.name },
onItemSelected = { positionInput = positionInput.copy { positionFlags = it } }
)
}
item { Divider() }
item {
EditTextPreference(title = "Redefine GPS_RX_PIN",
value = positionInput.rxGpio,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { positionInput = positionInput.copy { rxGpio = it } })
}
item {
EditTextPreference(title = "Redefine GPS_TX_PIN",
value = positionInput.txGpio,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { positionInput = positionInput.copy { txGpio = it } })
}
item {
PreferenceFooter(
enabled = positionInput != localConfig.position || positionInfo != ourNodeInfo?.position,
onCancelClicked = {
focusManager.clearFocus()
positionInput = localConfig.position
positionInfo = ourNodeInfo?.position
},
onSaveClicked = {
focusManager.clearFocus()
if (positionInfo != ourNodeInfo?.position && positionInput.fixedPosition)
positionInfo?.let { viewModel.requestPosition(0, it) }
if (positionInput != localConfig.position) viewModel.updatePositionConfig { positionInput }
})
}
item { PreferenceCategory(text = "Power Config") }
item {
SwitchPreference(title = "Enable power saving mode",
checked = powerInput.isPowerSaving,
enabled = connected && hasWifi, // We consider hasWifi = ESP32
onCheckedChange = { powerInput = powerInput.copy { isPowerSaving = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Shutdown on battery delay",
value = powerInput.onBatteryShutdownAfterSecs,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
powerInput = powerInput.copy { onBatteryShutdownAfterSecs = it }
})
}
item {
EditTextPreference(title = "ADC multiplier override ratio",
value = powerInput.adcMultiplierOverride,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { powerInput = powerInput.copy { adcMultiplierOverride = it } })
}
item {
EditTextPreference(title = "Wait for Bluetooth duration",
value = powerInput.waitBluetoothSecs,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { powerInput = powerInput.copy { waitBluetoothSecs = it } })
}
item {
EditTextPreference(title = "Mesh SDS timeout",
value = powerInput.meshSdsTimeoutSecs,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { powerInput = powerInput.copy { meshSdsTimeoutSecs = it } })
}
item {
EditTextPreference(title = "Super deep sleep duration",
value = powerInput.sdsSecs,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { powerInput = powerInput.copy { sdsSecs = it } })
}
item {
EditTextPreference(title = "Light sleep duration",
value = powerInput.lsSecs,
enabled = connected && hasWifi, // we consider hasWifi = ESP32
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { powerInput = powerInput.copy { lsSecs = it } })
}
item {
EditTextPreference(title = "Minimum wake time",
value = powerInput.minWakeSecs,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { powerInput = powerInput.copy { minWakeSecs = it } })
}
item {
PreferenceFooter(
enabled = powerInput != localConfig.power,
onCancelClicked = {
focusManager.clearFocus()
powerInput = localConfig.power
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updatePowerConfig { powerInput }
})
}
item { PreferenceCategory(text = "Network Config") }
item {
SwitchPreference(title = "WiFi enabled",
checked = networkInput.wifiEnabled,
enabled = connected && hasWifi,
onCheckedChange = { networkInput = networkInput.copy { wifiEnabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "SSID",
value = networkInput.wifiSsid,
maxSize = 32, // wifi_ssid max_size:33
enabled = connected && hasWifi,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
networkInput = networkInput.copy { wifiSsid = it }
})
}
item {
EditTextPreference(title = "PSK",
value = networkInput.wifiPsk,
maxSize = 63, // wifi_psk max_size:64
enabled = connected && hasWifi,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
networkInput = networkInput.copy { wifiPsk = it }
})
}
item {
EditTextPreference(title = "NTP server",
value = networkInput.ntpServer,
maxSize = 32, // ntp_server max_size:33
enabled = connected && hasWifi,
isError = networkInput.ntpServer.isEmpty(),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
networkInput = networkInput.copy { ntpServer = it }
})
}
item {
EditTextPreference(title = "rsyslog server",
value = networkInput.rsyslogServer,
maxSize = 32, // rsyslog_server max_size:33
enabled = connected && hasWifi,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
networkInput = networkInput.copy { rsyslogServer = it }
})
}
item {
SwitchPreference(title = "Ethernet enabled",
checked = networkInput.ethEnabled,
enabled = connected,
onCheckedChange = { networkInput = networkInput.copy { ethEnabled = it } })
}
item { Divider() }
item {
DropDownPreference(title = "IPv4 mode",
enabled = connected,
items = NetworkConfig.AddressMode.values()
.filter { it != NetworkConfig.AddressMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = networkInput.addressMode,
onItemSelected = { networkInput = networkInput.copy { addressMode = it } })
}
item { Divider() }
item { PreferenceCategory(text = "IPv4 Config") }
item {
EditIPv4Preference(title = "IP",
value = networkInput.ipv4Config.ip,
enabled = connected && networkInput.addressMode == NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = networkInput.ipv4Config.copy { ip = it }
networkInput = networkInput.copy { ipv4Config = ipv4 }
})
}
item {
EditIPv4Preference(title = "Gateway",
value = networkInput.ipv4Config.gateway,
enabled = connected && networkInput.addressMode == NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = networkInput.ipv4Config.copy { gateway = it }
networkInput = networkInput.copy { ipv4Config = ipv4 }
})
}
item {
EditIPv4Preference(title = "Subnet",
value = networkInput.ipv4Config.subnet,
enabled = connected && networkInput.addressMode == NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = networkInput.ipv4Config.copy { subnet = it }
networkInput = networkInput.copy { ipv4Config = ipv4 }
})
}
item {
EditIPv4Preference(title = "DNS",
value = networkInput.ipv4Config.dns,
enabled = connected && networkInput.addressMode == NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = networkInput.ipv4Config.copy { dns = it }
networkInput = networkInput.copy { ipv4Config = ipv4 }
})
}
item {
PreferenceFooter(
enabled = networkInput != localConfig.network,
onCancelClicked = {
focusManager.clearFocus()
networkInput = localConfig.network
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateNetworkConfig { networkInput }
})
}
item { PreferenceCategory(text = "Display Config") }
item {
EditTextPreference(title = "Screen timeout",
value = displayInput.screenOnSecs,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { displayInput = displayInput.copy { screenOnSecs = it } })
}
item {
DropDownPreference(title = "GPS coordinates format",
enabled = connected,
items = ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.values()
.filter { it != ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.UNRECOGNIZED }
.map { it to it.name },
selectedItem = displayInput.gpsFormat,
onItemSelected = { displayInput = displayInput.copy { gpsFormat = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Auto screen carousel",
value = displayInput.autoScreenCarouselSecs,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
displayInput = displayInput.copy { autoScreenCarouselSecs = it }
})
}
item {
SwitchPreference(title = "Compass north top",
checked = displayInput.compassNorthTop,
enabled = connected,
onCheckedChange = { displayInput = displayInput.copy { compassNorthTop = it } })
}
item { Divider() }
item {
SwitchPreference(title = "Flip screen",
checked = displayInput.flipScreen,
enabled = connected,
onCheckedChange = { displayInput = displayInput.copy { flipScreen = it } })
}
item { Divider() }
item {
DropDownPreference(title = "Display units",
enabled = connected,
items = ConfigProtos.Config.DisplayConfig.DisplayUnits.values()
.filter { it != ConfigProtos.Config.DisplayConfig.DisplayUnits.UNRECOGNIZED }
.map { it to it.name },
selectedItem = displayInput.units,
onItemSelected = { displayInput = displayInput.copy { units = it } })
}
item { Divider() }
item {
DropDownPreference(title = "Override OLED auto-detect",
enabled = connected,
items = ConfigProtos.Config.DisplayConfig.OledType.values()
.filter { it != ConfigProtos.Config.DisplayConfig.OledType.UNRECOGNIZED }
.map { it to it.name },
selectedItem = displayInput.oled,
onItemSelected = { displayInput = displayInput.copy { oled = it } })
}
item { Divider() }
item {
DropDownPreference(title = "Display mode",
enabled = connected,
items = ConfigProtos.Config.DisplayConfig.DisplayMode.values()
.filter { it != ConfigProtos.Config.DisplayConfig.DisplayMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = displayInput.displaymode,
onItemSelected = { displayInput = displayInput.copy { displaymode = it } })
}
item { Divider() }
item {
SwitchPreference(title = "Heading bold",
checked = displayInput.headingBold,
enabled = connected,
onCheckedChange = { displayInput = displayInput.copy { headingBold = it } })
}
item { Divider() }
item {
SwitchPreference(title = "Wake screen on tap or motion",
checked = displayInput.wakeOnTapOrMotion,
enabled = connected,
onCheckedChange = { displayInput = displayInput.copy { wakeOnTapOrMotion = it } })
}
item { Divider() }
item {
PreferenceFooter(
enabled = displayInput != localConfig.display,
onCancelClicked = {
focusManager.clearFocus()
displayInput = localConfig.display
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateDisplayConfig { displayInput }
})
}
item { PreferenceCategory(text = "LoRa Config") }
item {
SwitchPreference(title = "Use modem preset",
checked = loraInput.usePreset,
enabled = connected,
onCheckedChange = { loraInput = loraInput.copy { usePreset = it } })
}
item { Divider() }
if (loraInput.usePreset) {
item {
DropDownPreference(title = "Modem preset",
enabled = connected && loraInput.usePreset,
items = ConfigProtos.Config.LoRaConfig.ModemPreset.values()
.filter { it != ConfigProtos.Config.LoRaConfig.ModemPreset.UNRECOGNIZED }
.map { it to it.name },
selectedItem = loraInput.modemPreset,
onItemSelected = { loraInput = loraInput.copy { modemPreset = it } })
}
item { Divider() }
} else {
item {
EditTextPreference(title = "Bandwidth",
value = loraInput.bandwidth,
enabled = connected && !loraInput.usePreset,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { bandwidth = it } })
}
item {
EditTextPreference(title = "Spread factor",
value = loraInput.spreadFactor,
enabled = connected && !loraInput.usePreset,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { spreadFactor = it } })
}
item {
EditTextPreference(title = "Coding rate",
value = loraInput.codingRate,
enabled = connected && !loraInput.usePreset,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { codingRate = it } })
}
}
item {
EditTextPreference(title = "Frequency offset (MHz)",
value = loraInput.frequencyOffset,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { frequencyOffset = it } })
}
item {
DropDownPreference(title = "Region (frequency plan)",
enabled = connected,
items = ConfigProtos.Config.LoRaConfig.RegionCode.values()
.filter { it != ConfigProtos.Config.LoRaConfig.RegionCode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = loraInput.region,
onItemSelected = { loraInput = loraInput.copy { region = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Hop limit",
value = loraInput.hopLimit,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { hopLimit = it } })
}
item {
SwitchPreference(title = "TX enabled",
checked = loraInput.txEnabled,
enabled = connected,
onCheckedChange = { loraInput = loraInput.copy { txEnabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "TX power",
value = loraInput.txPower,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { txPower = it } })
}
item {
EditTextPreference(title = "Channel number",
value = loraInput.channelNum,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { channelNum = it } })
}
item {
SwitchPreference(title = "Override Duty Cycle",
checked = loraInput.overrideDutyCycle,
enabled = connected,
onCheckedChange = { loraInput = loraInput.copy { overrideDutyCycle = it } })
}
item { Divider() }
item {
EditListPreference(title = "Ignore incoming",
list = loraInput.ignoreIncomingList,
maxCount = 3, // ignore_incoming max_count:3
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValuesChanged = { list ->
loraInput = loraInput.copy {
ignoreIncoming.clear()
ignoreIncoming.addAll(list.filter { it != 0 })
}
})
}
item {
SwitchPreference(title = "SX126X RX boosted gain",
checked = loraInput.sx126XRxBoostedGain,
enabled = connected,
onCheckedChange = { loraInput = loraInput.copy { sx126XRxBoostedGain = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Override frequency (MHz)",
value = loraInput.overrideFrequency,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { overrideFrequency = it } })
}
item {
PreferenceFooter(
enabled = loraInput != localConfig.lora,
onCancelClicked = {
focusManager.clearFocus()
loraInput = localConfig.lora
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateLoraConfig { loraInput }
})
}
item { PreferenceCategory(text = "Bluetooth Config") }
item {
SwitchPreference(title = "Bluetooth enabled",
checked = bluetoothInput.enabled,
enabled = connected,
onCheckedChange = { bluetoothInput = bluetoothInput.copy { enabled = it } })
}
item { Divider() }
item {
DropDownPreference(title = "Pairing mode",
enabled = connected,
items = ConfigProtos.Config.BluetoothConfig.PairingMode.values()
.filter { it != ConfigProtos.Config.BluetoothConfig.PairingMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = bluetoothInput.mode,
onItemSelected = { bluetoothInput = bluetoothInput.copy { mode = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Fixed PIN",
value = bluetoothInput.fixedPin,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
if (it.toString().length == 6) // ensure 6 digits
bluetoothInput = bluetoothInput.copy { fixedPin = it }
})
}
item {
PreferenceFooter(
enabled = bluetoothInput != localConfig.bluetooth,
onCancelClicked = {
focusManager.clearFocus()
bluetoothInput = localConfig.bluetooth
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateBluetoothConfig { bluetoothInput }
})
}
}
}

Wyświetl plik

@ -320,6 +320,13 @@ class MessagesFragment : Fragment(), Logging {
}
}
override fun onDestroyView() {
super.onDestroyView()
actionMode?.finish()
actionMode = null
_binding = null
}
private inner class ActionModeCallback : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.menu_messages, menu)

Wyświetl plik

@ -1,37 +0,0 @@
package com.geeksville.mesh.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.content.ContextCompat
import androidx.fragment.app.activityViewModels
import com.geeksville.mesh.R
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.model.UIViewModel
import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class ModuleSettingsFragment : ScreenFragment("Module Settings"), Logging {
private val model: UIViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground))
setContent {
AppCompatTheme {
ModuleSettingsItemList(model)
}
}
}
}
}

Wyświetl plik

@ -1,775 +0,0 @@
package com.geeksville.mesh.ui
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig
import com.geeksville.mesh.copy
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.PreferenceCategory
import com.geeksville.mesh.ui.components.PreferenceFooter
import com.geeksville.mesh.ui.components.SwitchPreference
@Composable
fun ModuleSettingsItemList(viewModel: UIViewModel = viewModel()) {
val focusManager = LocalFocusManager.current
val connectionState by viewModel.connectionState.observeAsState()
val connected = connectionState == MeshService.ConnectionState.CONNECTED
val moduleConfig by viewModel.moduleConfig.collectAsState()
// Temporary [ModuleConfigProtos.ModuleConfig] state holders
var mqttInput by remember(moduleConfig.mqtt) { mutableStateOf(moduleConfig.mqtt) }
var serialInput by remember(moduleConfig.serial) { mutableStateOf(moduleConfig.serial) }
var externalNotificationInput by remember(moduleConfig.externalNotification) { mutableStateOf(moduleConfig.externalNotification) }
var storeForwardInput by remember(moduleConfig.storeForward) { mutableStateOf(moduleConfig.storeForward) }
var rangeTestInput by remember(moduleConfig.rangeTest) { mutableStateOf(moduleConfig.rangeTest) }
var telemetryInput by remember(moduleConfig.telemetry) { mutableStateOf(moduleConfig.telemetry) }
var cannedMessageInput by remember(moduleConfig.cannedMessage) { mutableStateOf(moduleConfig.cannedMessage) }
var audioInput by remember(moduleConfig.audio) { mutableStateOf(moduleConfig.audio) }
var remoteHardwareInput by remember(moduleConfig.remoteHardware) { mutableStateOf(moduleConfig.remoteHardware) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "MQTT Config") }
item {
SwitchPreference(title = "MQTT enabled",
checked = mqttInput.enabled,
enabled = connected,
onCheckedChange = { mqttInput = mqttInput.copy { enabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Address",
value = mqttInput.address,
enabled = connected,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
if (value.toByteArray().size <= 63) // address max_size:64
mqttInput = mqttInput.copy { address = value }
})
}
item {
EditTextPreference(title = "Username",
value = mqttInput.username,
enabled = connected,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
if (value.toByteArray().size <= 63) // username max_size:64
mqttInput = mqttInput.copy { username = value }
})
}
item {
EditTextPreference(title = "Password",
value = mqttInput.password,
enabled = connected,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
if (value.toByteArray().size <= 63) // password max_size:64
mqttInput = mqttInput.copy { password = value }
})
}
item {
SwitchPreference(title = "Encryption enabled",
checked = mqttInput.encryptionEnabled,
enabled = connected,
onCheckedChange = { mqttInput = mqttInput.copy { encryptionEnabled = it } })
}
item { Divider() }
item {
SwitchPreference(title = "JSON output enabled",
checked = mqttInput.jsonEnabled,
enabled = connected,
onCheckedChange = { mqttInput = mqttInput.copy { jsonEnabled = it } })
}
item { Divider() }
item {
PreferenceFooter(
enabled = mqttInput != moduleConfig.mqtt,
onCancelClicked = {
focusManager.clearFocus()
mqttInput = moduleConfig.mqtt
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateMQTTConfig { mqttInput }
})
}
item { PreferenceCategory(text = "Serial Config") }
item {
SwitchPreference(title = "Serial enabled",
checked = serialInput.enabled,
enabled = connected,
onCheckedChange = { serialInput = serialInput.copy { enabled = it } })
}
item { Divider() }
item {
SwitchPreference(title = "Echo enabled",
checked = serialInput.echo,
enabled = connected,
onCheckedChange = { serialInput = serialInput.copy { echo = it } })
}
item { Divider() }
item {
EditTextPreference(title = "RX",
value = serialInput.rxd,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { serialInput = serialInput.copy { rxd = it } })
}
item {
EditTextPreference(title = "TX",
value = serialInput.txd,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { serialInput = serialInput.copy { txd = it } })
}
item {
DropDownPreference(title = "Serial baud rate",
enabled = connected,
items = ModuleConfig.SerialConfig.Serial_Baud.values()
.filter { it != ModuleConfig.SerialConfig.Serial_Baud.UNRECOGNIZED }
.map { it to it.name },
selectedItem = serialInput.baud,
onItemSelected = { serialInput = serialInput.copy { baud = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Timeout",
value = serialInput.timeout,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { serialInput = serialInput.copy { timeout = it } })
}
item {
DropDownPreference(title = "Serial mode",
enabled = connected,
items = ModuleConfig.SerialConfig.Serial_Mode.values()
.filter { it != ModuleConfig.SerialConfig.Serial_Mode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = serialInput.mode,
onItemSelected = { serialInput = serialInput.copy { mode = it } })
}
item { Divider() }
item {
PreferenceFooter(
enabled = serialInput != moduleConfig.serial,
onCancelClicked = {
focusManager.clearFocus()
serialInput = moduleConfig.serial
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateSerialConfig { serialInput }
})
}
item { PreferenceCategory(text = "External Notification Config") }
item {
SwitchPreference(title = "External notification enabled",
checked = externalNotificationInput.enabled,
enabled = connected,
onCheckedChange = {
externalNotificationInput = externalNotificationInput.copy { enabled = it }
})
}
item { Divider() }
item {
EditTextPreference(title = "Output milliseconds",
value = externalNotificationInput.outputMs,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
externalNotificationInput = externalNotificationInput.copy { outputMs = it }
})
}
item {
EditTextPreference(title = "Output",
value = externalNotificationInput.output,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
externalNotificationInput = externalNotificationInput.copy { output = it }
})
}
item {
SwitchPreference(title = "Active",
checked = externalNotificationInput.active,
enabled = connected,
onCheckedChange = {
externalNotificationInput = externalNotificationInput.copy { active = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Alert message",
checked = externalNotificationInput.alertMessage,
enabled = connected,
onCheckedChange = {
externalNotificationInput = externalNotificationInput.copy { alertMessage = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Alert bell",
checked = externalNotificationInput.alertBell,
enabled = connected,
onCheckedChange = {
externalNotificationInput = externalNotificationInput.copy { alertBell = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Use PWM buzzer",
checked = externalNotificationInput.usePwm,
enabled = connected,
onCheckedChange = {
externalNotificationInput = externalNotificationInput.copy { usePwm = it }
})
}
item { Divider() }
item {
EditTextPreference(title = "Output vibra",
value = externalNotificationInput.outputVibra,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
externalNotificationInput = externalNotificationInput.copy { outputVibra = it }
})
}
item {
EditTextPreference(title = "Output buzzer",
value = externalNotificationInput.outputBuzzer,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
externalNotificationInput = externalNotificationInput.copy { outputBuzzer = it }
})
}
item {
SwitchPreference(title = "Alert message vibra",
checked = externalNotificationInput.alertMessageVibra,
enabled = connected,
onCheckedChange = {
externalNotificationInput =
externalNotificationInput.copy { alertMessageVibra = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Alert message buzzer",
checked = externalNotificationInput.alertMessageBuzzer,
enabled = connected,
onCheckedChange = {
externalNotificationInput =
externalNotificationInput.copy { alertMessageBuzzer = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Alert bell vibra",
checked = externalNotificationInput.alertBellVibra,
enabled = connected,
onCheckedChange = {
externalNotificationInput =
externalNotificationInput.copy { alertBellVibra = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Alert bell buzzer",
checked = externalNotificationInput.alertBellBuzzer,
enabled = connected,
onCheckedChange = {
externalNotificationInput =
externalNotificationInput.copy { alertBellBuzzer = it }
})
}
item { Divider() }
item {
EditTextPreference(title = "Nag timeout",
value = externalNotificationInput.nagTimeout,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
externalNotificationInput = externalNotificationInput.copy { nagTimeout = it }
})
}
item {
PreferenceFooter(
enabled = externalNotificationInput != moduleConfig.externalNotification,
onCancelClicked = {
focusManager.clearFocus()
externalNotificationInput = moduleConfig.externalNotification
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateExternalNotificationConfig { externalNotificationInput }
})
}
item { PreferenceCategory(text = "Store & Forward Config") }
item {
SwitchPreference(title = "Store & Forward enabled",
checked = storeForwardInput.enabled,
enabled = connected,
onCheckedChange = { storeForwardInput = storeForwardInput.copy { enabled = it } })
}
item { Divider() }
item {
SwitchPreference(title = "Heartbeat",
checked = storeForwardInput.heartbeat,
enabled = connected,
onCheckedChange = { storeForwardInput = storeForwardInput.copy { heartbeat = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Number of records",
value = storeForwardInput.records,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { storeForwardInput = storeForwardInput.copy { records = it } })
}
item {
EditTextPreference(title = "History return max",
value = storeForwardInput.historyReturnMax,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
storeForwardInput = storeForwardInput.copy { historyReturnMax = it }
})
}
item {
EditTextPreference(title = "History return window",
value = storeForwardInput.historyReturnWindow,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
storeForwardInput = storeForwardInput.copy { historyReturnWindow = it }
})
}
item {
PreferenceFooter(
enabled = storeForwardInput != moduleConfig.storeForward,
onCancelClicked = {
focusManager.clearFocus()
storeForwardInput = moduleConfig.storeForward
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateStoreForwardConfig { storeForwardInput }
})
}
item { PreferenceCategory(text = "Range Test Config") }
item {
SwitchPreference(title = "Range test enabled",
checked = rangeTestInput.enabled,
enabled = connected,
onCheckedChange = { rangeTestInput = rangeTestInput.copy { enabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Sender message interval",
value = rangeTestInput.sender,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { rangeTestInput = rangeTestInput.copy { sender = it } })
}
item {
SwitchPreference(title = "Save .CSV in storage (ESP32 only)",
checked = rangeTestInput.save,
enabled = connected,
onCheckedChange = { rangeTestInput = rangeTestInput.copy { save = it } })
}
item { Divider() }
item {
PreferenceFooter(
enabled = rangeTestInput != moduleConfig.rangeTest,
onCancelClicked = {
focusManager.clearFocus()
rangeTestInput = moduleConfig.rangeTest
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateRangeTestConfig { rangeTestInput }
})
}
item { PreferenceCategory(text = "Telemetry Config") }
item {
EditTextPreference(title = "Device metrics update interval",
value = telemetryInput.deviceUpdateInterval,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
telemetryInput = telemetryInput.copy { deviceUpdateInterval = it }
})
}
item {
EditTextPreference(title = "Environment metrics update interval",
value = telemetryInput.environmentUpdateInterval,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
telemetryInput = telemetryInput.copy { environmentUpdateInterval = it }
})
}
item {
SwitchPreference(title = "Environment metrics module enabled",
checked = telemetryInput.environmentMeasurementEnabled,
enabled = connected,
onCheckedChange = {
telemetryInput = telemetryInput.copy { environmentMeasurementEnabled = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Environment metrics on-screen enabled",
checked = telemetryInput.environmentScreenEnabled,
enabled = connected,
onCheckedChange = {
telemetryInput = telemetryInput.copy { environmentScreenEnabled = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Environment metrics use Fahrenheit",
checked = telemetryInput.environmentDisplayFahrenheit,
enabled = connected,
onCheckedChange = {
telemetryInput = telemetryInput.copy { environmentDisplayFahrenheit = it }
})
}
item { Divider() }
item {
PreferenceFooter(
enabled = telemetryInput != moduleConfig.telemetry,
onCancelClicked = {
focusManager.clearFocus()
telemetryInput = moduleConfig.telemetry
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateTelemetryConfig { telemetryInput }
})
}
item { PreferenceCategory(text = "Canned Message Config") }
item {
SwitchPreference(title = "Canned message enabled",
checked = cannedMessageInput.enabled,
enabled = connected,
onCheckedChange = {
cannedMessageInput = cannedMessageInput.copy { enabled = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Rotary encoder #1 enabled",
checked = cannedMessageInput.rotary1Enabled,
enabled = connected,
onCheckedChange = {
cannedMessageInput = cannedMessageInput.copy { rotary1Enabled = it }
})
}
item { Divider() }
item {
EditTextPreference(title = "GPIO pin for rotary encoder A port",
value = cannedMessageInput.inputbrokerPinA,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
cannedMessageInput = cannedMessageInput.copy { inputbrokerPinA = it }
})
}
item {
EditTextPreference(title = "GPIO pin for rotary encoder B port",
value = cannedMessageInput.inputbrokerPinB,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
cannedMessageInput = cannedMessageInput.copy { inputbrokerPinB = it }
})
}
item {
EditTextPreference(title = "GPIO pin for rotary encoder Press port",
value = cannedMessageInput.inputbrokerPinPress,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
cannedMessageInput = cannedMessageInput.copy { inputbrokerPinPress = it }
})
}
item {
DropDownPreference(title = "Generate input event on Press",
enabled = connected,
items = ModuleConfig.CannedMessageConfig.InputEventChar.values()
.filter { it != ModuleConfig.CannedMessageConfig.InputEventChar.UNRECOGNIZED }
.map { it to it.name },
selectedItem = cannedMessageInput.inputbrokerEventPress,
onItemSelected = {
cannedMessageInput = cannedMessageInput.copy { inputbrokerEventPress = it }
})
}
item { Divider() }
item {
DropDownPreference(title = "Generate input event on CW",
enabled = connected,
items = ModuleConfig.CannedMessageConfig.InputEventChar.values()
.filter { it != ModuleConfig.CannedMessageConfig.InputEventChar.UNRECOGNIZED }
.map { it to it.name },
selectedItem = cannedMessageInput.inputbrokerEventCw,
onItemSelected = {
cannedMessageInput = cannedMessageInput.copy { inputbrokerEventCw = it }
})
}
item { Divider() }
item {
DropDownPreference(title = "Generate input event on CCW",
enabled = connected,
items = ModuleConfig.CannedMessageConfig.InputEventChar.values()
.filter { it != ModuleConfig.CannedMessageConfig.InputEventChar.UNRECOGNIZED }
.map { it to it.name },
selectedItem = cannedMessageInput.inputbrokerEventCcw,
onItemSelected = {
cannedMessageInput = cannedMessageInput.copy { inputbrokerEventCcw = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Up/Down/Select input enabled",
checked = cannedMessageInput.updown1Enabled,
enabled = connected,
onCheckedChange = {
cannedMessageInput = cannedMessageInput.copy { updown1Enabled = it }
})
}
item { Divider() }
item {
EditTextPreference(title = "Allow input source",
value = cannedMessageInput.allowInputSource,
enabled = connected,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
if (value.toByteArray().size <= 15) // allow_input_source max_size:16
cannedMessageInput = cannedMessageInput.copy { allowInputSource = value }
})
}
item {
SwitchPreference(title = "Send bell",
checked = cannedMessageInput.sendBell,
enabled = connected,
onCheckedChange = {
cannedMessageInput = cannedMessageInput.copy { sendBell = it }
})
}
item { Divider() }
item {
PreferenceFooter(
enabled = cannedMessageInput != moduleConfig.cannedMessage,
onCancelClicked = {
focusManager.clearFocus()
cannedMessageInput = moduleConfig.cannedMessage
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateCannedMessageConfig { cannedMessageInput }
})
}
item { PreferenceCategory(text = "Audio Config") }
item {
SwitchPreference(title = "CODEC 2 enabled",
checked = audioInput.codec2Enabled,
enabled = connected,
onCheckedChange = { audioInput = audioInput.copy { codec2Enabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "PTT pin",
value = audioInput.pttPin,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { audioInput = audioInput.copy { pttPin = it } })
}
item {
DropDownPreference(title = "CODEC2 sample rate",
enabled = connected,
items = ModuleConfig.AudioConfig.Audio_Baud.values()
.filter { it != ModuleConfig.AudioConfig.Audio_Baud.UNRECOGNIZED }
.map { it to it.name },
selectedItem = audioInput.bitrate,
onItemSelected = { audioInput = audioInput.copy { bitrate = it } })
}
item { Divider() }
item {
EditTextPreference(title = "I2S word select",
value = audioInput.i2SWs,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { audioInput = audioInput.copy { i2SWs = it } })
}
item {
EditTextPreference(title = "I2S data in",
value = audioInput.i2SSd,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { audioInput = audioInput.copy { i2SSd = it } })
}
item {
EditTextPreference(title = "I2S data out",
value = audioInput.i2SDin,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { audioInput = audioInput.copy { i2SDin = it } })
}
item {
EditTextPreference(title = "I2S clock",
value = audioInput.i2SSck,
enabled = connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { audioInput = audioInput.copy { i2SSck = it } })
}
item {
PreferenceFooter(
enabled = audioInput != moduleConfig.audio,
onCancelClicked = {
focusManager.clearFocus()
audioInput = moduleConfig.audio
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateAudioConfig { audioInput }
})
}
item { PreferenceCategory(text = "Remote Hardware Config") }
item {
SwitchPreference(title = "Remote Hardware enabled",
checked = remoteHardwareInput.enabled,
enabled = connected,
onCheckedChange = {
remoteHardwareInput = remoteHardwareInput.copy { enabled = it }
})
}
item { Divider() }
item {
PreferenceFooter(
enabled = remoteHardwareInput != moduleConfig.remoteHardware,
onCancelClicked = {
focusManager.clearFocus()
remoteHardwareInput = moduleConfig.remoteHardware
},
onSaveClicked = {
focusManager.clearFocus()
viewModel.updateRemoteHardwareConfig { remoteHardwareInput }
})
}
}
}

Wyświetl plik

@ -109,6 +109,11 @@ class QuickChatSettingsFragment : ScreenFragment("Quick Chat Settings"), Logging
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
data class DialogBuilder(
val builder: MaterialAlertDialogBuilder,
val nameInput: EditText,
@ -135,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)
@ -144,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

@ -1,14 +1,19 @@
package com.geeksville.mesh.ui
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.bluetooth.BluetoothDevice
import android.companion.CompanionDeviceManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
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
@ -22,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
@ -43,26 +48,16 @@ import com.geeksville.mesh.repository.radio.MockInterface
import com.geeksville.mesh.repository.usb.UsbRepository
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.SoftwareUpdateService
import com.geeksville.mesh.util.anonymize
import com.geeksville.mesh.util.exceptionReporter
import com.geeksville.mesh.util.exceptionToSnackbar
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
object SLogging : Logging
/// Change to a new macaddr selection, updating GUI and radio
fun changeDeviceSelection(context: MainActivity, newAddr: String?) {
// FIXME, this is a kinda yucky way to find the service
context.model.meshService?.let { service ->
MeshService.changeDeviceAddress(context, service, newAddr)
}
}
@AndroidEntryPoint
class SettingsFragment : ScreenFragment("Settings"), Logging {
private var _binding: SettingsFragmentBinding? = null
@ -79,10 +74,14 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
@Inject
internal lateinit var locationRepository: LocationRepository
private var receivingLocationUpdates: Job? = null
private val myActivity get() = requireActivity() as MainActivity
private val hasCompanionDeviceApi by lazy { requireContext().hasCompanionDeviceApi() }
private val useCompanionDeviceApi by lazy {
android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S && hasCompanionDeviceApi
}
private fun doFirmwareUpdate() {
model.meshService?.let { service ->
@ -193,24 +192,22 @@ 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())
// Update the status string (highest priority messages first)
val info = model.myNodeInfo.value
val statusText = binding.scanStatusText
when (connected) {
MeshService.ConnectionState.CONNECTED -> {
statusText.text = if (region.number == 0) getString(R.string.must_set_region)
else getString(R.string.connected_to).format(info?.firmwareString ?: "unknown")
}
MeshService.ConnectionState.DISCONNECTED ->
statusText.text = getString(R.string.not_connected)
MeshService.ConnectionState.DEVICE_SLEEP ->
statusText.text = getString(R.string.connected_sleeping)
else -> {}
MeshService.ConnectionState.CONNECTED ->
if (region.number == 0) R.string.must_set_region else R.string.connected_to
MeshService.ConnectionState.DISCONNECTED -> R.string.not_connected
MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping
else -> null
}?.let {
val firmwareString = info?.firmwareString ?: getString(R.string.unknown)
scanModel.setErrorText(getString(it, firmwareString))
}
}
@ -250,14 +247,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
it.data
?.getParcelableExtra<BluetoothDevice>(CompanionDeviceManager.EXTRA_DEVICE)
?.let { device ->
scanModel.onSelected(
myActivity,
BTScanModel.DeviceListEntry(
device.name,
"x${device.address}",
device.bondState == BluetoothDevice.BOND_BONDED
)
)
onSelected(BTScanModel.BLEDeviceListEntry(device))
}
}
@ -275,9 +265,9 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions.entries.all { it.value }) {
// Older versions of android only need Location permission
if (myActivity.hasBackgroundPermission()) {
if (requireContext().hasBackgroundPermission()) {
binding.provideLocationCheckbox.isChecked = true
} else requestBackgroundAndCheckLauncher.launch(myActivity.getBackgroundPermissions())
} else requestBackgroundAndCheckLauncher.launch(requireContext().getBackgroundPermissions())
} else {
debug("User denied location permission")
showSnackbar(getString(R.string.why_background_required))
@ -291,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) {
@ -307,7 +301,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
val configCount = it.allFields.size
val configTotal = ConfigProtos.Config.getDescriptor().fields.size
if (configCount > 0)
binding.scanStatusText.text = "Device config ($configCount / $configTotal)"
scanModel.setErrorText("Device config ($configCount / $configTotal)")
} else updateNodeInfo()
}
@ -316,7 +310,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
val moduleCount = it.allFields.size
val moduleTotal = ModuleConfigProtos.ModuleConfig.getDescriptor().fields.size
if (moduleCount > 0)
binding.scanStatusText.text = "Module config ($moduleCount / $moduleTotal)"
scanModel.setErrorText("Module config ($moduleCount / $moduleTotal)")
} else updateNodeInfo()
}
@ -324,7 +318,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
if (!model.isConnected()) it.protobuf.let { ch ->
val maxChannels = model.myNodeInfo.value?.maxChannels ?: "8"
if (!ch.hasLoraConfig() && ch.settingsCount > 0)
binding.scanStatusText.text = "Channels (${ch.settingsCount} / $maxChannels)"
scanModel.setErrorText("Channels (${ch.settingsCount} / $maxChannels)")
}
}
@ -333,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
@ -372,21 +362,23 @@ 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
view.isChecked = isChecked && myActivity.hasBackgroundPermission()
view.isChecked = isChecked && requireContext().hasBackgroundPermission()
if (view.isPressed) { // We want to ignore changes caused by code (as opposed to the user)
debug("User changed location tracking to $isChecked")
@ -400,10 +392,10 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
}
.setPositiveButton(getString(R.string.accept)) { _, _ ->
// Make sure we have location permission (prerequisite)
if (!myActivity.hasLocationPermission()) {
requestLocationAndBackgroundLauncher.launch(myActivity.getLocationPermissions())
if (!requireContext().hasLocationPermission()) {
requestLocationAndBackgroundLauncher.launch(requireContext().getLocationPermissions())
} else {
requestBackgroundAndCheckLauncher.launch(myActivity.getBackgroundPermissions())
requestBackgroundAndCheckLauncher.launch(requireContext().getBackgroundPermissions())
}
}
.show()
@ -459,8 +451,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
if (!device.bonded) // If user just clicked on us, try to bond
binding.scanStatusText.setText(R.string.starting_pairing)
b.isChecked =
scanModel.onSelected(myActivity, device)
b.isChecked = onSelected(device)
}
}
@ -496,10 +487,9 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
if (curRadio != null && !MockInterface.addressValid(requireContext(), usbRepository, "")) {
binding.warningNotPaired.visibility = View.GONE
// binding.scanStatusText.text = getString(R.string.current_pair).format(curRadio)
} else if (bluetoothViewModel.enabled.value == true){
} else if (bluetoothViewModel.enabled.value == true) {
binding.warningNotPaired.visibility = View.VISIBLE
binding.scanStatusText.text = getString(R.string.not_paired_yet)
scanModel.setErrorText(getString(R.string.not_paired_yet))
}
}
@ -513,13 +503,134 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
scanModel.stopScan()
}, SCAN_PERIOD)
scanning = true
scanModel.startScan()
scanModel.startScan(requireActivity().takeIf { useCompanionDeviceApi })
} else {
scanning = false
scanModel.stopScan()
}
}
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) {
changeDeviceAddress(it.fullAddress)
return true
} else {
// Handle requesting USB or bluetooth permissions for the device
debug("Requesting permissions for the device")
exceptionReporter {
if (it.isBLE) {
// Request bonding for bluetooth
// We ignore missing BT adapters, because it lets us run on the emulator
scanModel.getRemoteDevice(it.address)?.let { device ->
requestBonding(device) { state ->
if (state == BluetoothDevice.BOND_BONDED) {
scanModel.setErrorText(getString(R.string.pairing_completed))
changeDeviceAddress(it.fullAddress)
} else {
scanModel.setErrorText(getString(R.string.pairing_failed_try_again))
}
}
}
}
}
if (it.isUSB) {
it as BTScanModel.USBDeviceListEntry
val usbReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (BTScanModel.ACTION_USB_PERMISSION == intent.action) {
val device: UsbDevice =
intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)!!
if (intent.getBooleanExtra(
UsbManager.EXTRA_PERMISSION_GRANTED,
false
)
) {
info("User approved USB access")
changeDeviceAddress(it.fullAddress)
} else {
errormsg("USB permission denied for device $device")
}
}
// We don't need to stay registered
requireActivity().unregisterReceiver(this)
}
}
val permissionIntent =
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
PendingIntent.getBroadcast(activity, 0, Intent(BTScanModel.ACTION_USB_PERMISSION), 0)
} else {
PendingIntent.getBroadcast(activity, 0, Intent(BTScanModel.ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE)
}
val filter = IntentFilter(BTScanModel.ACTION_USB_PERMISSION)
requireActivity().registerReceiver(usbReceiver, filter)
requireContext().usbManager.requestPermission(it.usb.device, permissionIntent)
}
return false
}
}
/// Show the UI asking the user to bond with a device, call changeSelection() if/when bonding completes
@SuppressLint("MissingPermission")
private fun requestBonding(
device: BluetoothDevice,
onComplete: (Int) -> Unit
) {
info("Starting bonding for ${device.anonymize}")
// We need this receiver to get informed when the bond attempt finished
val bondChangedReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) = exceptionReporter {
val state =
intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
debug("Received bond state changed $state")
if (state != BluetoothDevice.BOND_BONDING) {
context.unregisterReceiver(this) // we stay registered until bonding completes (either with BONDED or NONE)
debug("Bonding completed, state=$state")
onComplete(state)
}
}
}
val filter = IntentFilter()
filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
requireActivity().registerReceiver(bondChangedReceiver, filter)
// We ignore missing BT adapters, because it lets us run on the emulator
try {
device.createBond()
} catch (ex: Throwable) {
warn("Failed creating Bluetooth bond: ${ex.message}")
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@ -529,7 +640,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions.entries.all { it.value }) {
checkBTEnabled()
if (!scanModel.hasCompanionDeviceApi) checkLocationEnabled()
if (!hasCompanionDeviceApi) checkLocationEnabled()
scanLeDevice()
} else {
errormsg("User denied scan permissions")
@ -541,9 +652,9 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
binding.changeRadioButton.setOnClickListener {
debug("User clicked changeRadioButton")
scanLeDevice()
if (scanModel.hasBluetoothPermission) {
if (requireContext().hasBluetoothPermission()) {
checkBTEnabled()
if (!scanModel.hasCompanionDeviceApi) checkLocationEnabled()
if (!hasCompanionDeviceApi) checkLocationEnabled()
} else {
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.required_permissions))
@ -553,7 +664,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
}
.setPositiveButton(R.string.accept) { _, _ ->
info("requesting scan permissions")
requestPermissionAndScanLauncher.launch(myActivity.getBluetoothPermissions())
requestPermissionAndScanLauncher.launch(requireContext().getBluetoothPermissions())
}
.show()
}
@ -608,10 +719,8 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
override fun onResume() {
super.onResume()
scanModel.setupScan()
// system permissions might have changed while we were away
binding.provideLocationCheckbox.isChecked = myActivity.hasBackgroundPermission() && (model.provideLocation.value ?: false)
binding.provideLocationCheckbox.isChecked = requireContext().hasBackgroundPermission() && (model.provideLocation.value ?: false)
myActivity.registerReceiver(updateProgressReceiver, updateProgressFilter)
@ -622,6 +731,12 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
if (binding.provideLocationCheckbox.isChecked)
checkLocationEnabled(getString(R.string.location_disabled))
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
const val SCAN_PERIOD: Long = 10000 // Stops scanning after 10 seconds
}

Wyświetl plik

@ -1,5 +1,6 @@
package com.geeksville.mesh.ui
import android.content.res.ColorStateList
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
@ -12,6 +13,7 @@ import androidx.core.os.bundleOf
import androidx.core.text.HtmlCompat
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.asLiveData
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.geeksville.mesh.NodeInfo
@ -54,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
@ -61,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 -> {
@ -85,55 +89,18 @@ class UsersFragment : ScreenFragment("Users"), Logging {
model.requestPosition(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.traceroute -> {
if (position > 0 && user != null) {
debug("requesting traceroute for ${user.longName}")
model.requestTraceroute(node.num)
}
}
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
@ -205,8 +172,13 @@ class UsersFragment : ScreenFragment("Users"), Logging {
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val n = nodes[position]
val user = n.user
holder.chipNode.text = user?.shortName ?: "UNK"
val name = user?.longName ?: "Unknown node"
val (textColor, nodeColor) = n.colors
with(holder.chipNode) {
text = user?.shortName ?: "UNK"
chipBackgroundColor = ColorStateList.valueOf(nodeColor)
setTextColor(textColor)
}
val name = user?.longName ?: getString(R.string.unknown_username)
holder.nodeNameView.text = name
val pos = n.validPosition
@ -241,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 {
@ -288,9 +255,9 @@ 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)
)
111 -> Pair(R.drawable.ic_power_plug_24, "")
101 -> Pair(R.drawable.ic_power_plug_24, "")
else -> Pair(R.drawable.ic_battery_full_24, "?")
}
@ -314,8 +281,40 @@ 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 ?: return@let
fun nodeName(num: Int) = model.nodeDB.nodesByNum?.get(num)?.user?.longName
var routeStr = "${nodeName(meshPacket.to)} --> "
routeList.forEach { num -> routeStr += "${nodeName(num)} --> " }
routeStr += "${nodeName(meshPacket.from)}"
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.traceroute)
.setMessage(routeStr)
.setPositiveButton(R.string.okay) { _, _ -> }
.show()
model.clearPacketResponse()
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

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

@ -37,8 +37,8 @@ fun <T> DropDownPreference(
dropDownExpanded = true
},
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,12 +14,12 @@ 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
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@ -34,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()) }
@ -53,7 +54,9 @@ fun EditTextPreference(
onValueChanged(int)
}
},
modifier = modifier
onFocusChanged = {},
modifier = modifier,
trailingIcon = trailingIcon
)
}
@ -84,6 +87,7 @@ fun EditTextPreference(
onValueChanged(float)
}
},
onFocusChanged = {},
modifier = modifier
)
}
@ -98,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,
@ -109,12 +114,13 @@ 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)
}
},
onFocusChanged = {},
modifier = modifier
)
}
@ -128,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(
@ -150,48 +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,
@ -203,6 +177,8 @@ fun EditTextPreference(
onValueChanged: (String) -> 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) }
@ -211,7 +187,7 @@ fun EditTextPreference(
singleLine = true,
modifier = modifier
.fillMaxWidth()
.onFocusEvent { isFocused = it.isFocused },
.onFocusEvent { isFocused = it.isFocused; onFocusChanged(it) },
enabled = enabled,
isError = isError,
onValueChange = {
@ -225,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)
}
}
)
@ -267,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
@ -41,35 +40,39 @@ fun PreferenceFooter(
Row(
modifier = modifier
.fillMaxWidth()
.size(48.dp),
.height(64.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
modifier = modifier
.fillMaxWidth()
.height(48.dp)
.weight(1f),
enabled = enabled,
onClick = onNegativeClicked,
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Red)
colors = ButtonDefaults.buttonColors(
disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled)
)
) {
Text(
text = stringResource(id = negativeText),
style = MaterialTheme.typography.body1,
color = if (!enabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) else Color.Unspecified,
)
}
Button(
modifier = modifier
.fillMaxWidth()
.height(48.dp)
.weight(1f),
enabled = enabled,
onClick = onPositiveClicked,
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Green)
colors = ButtonDefaults.buttonColors(
disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled)
)
) {
Text(
text = stringResource(id = positiveText),
style = MaterialTheme.typography.body1,
color = if (!enabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) else Color.DarkGray,
)
}
}

Wyświetl plik

@ -60,17 +60,17 @@ fun RegularPreference(
Column {
Text(
text = title,
style = MaterialTheme.typography.body1,
color = if (!enabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) else Color.Unspecified,
)
Text(
text = subtitle,
style = MaterialTheme.typography.body2,
color = if (!enabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) else MaterialTheme.colors.onSurface.copy(
alpha = ContentAlpha.medium
),
)
Text(
text = subtitle,
style = MaterialTheme.typography.body1,
color = if (!enabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) else Color.Unspecified,
)
}
if (trailingIcon != null) Icon(
trailingIcon, "trailingIcon",

Wyświetl plik

@ -0,0 +1,73 @@
package com.geeksville.mesh.ui.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material.Card
import androidx.compose.material.ContentAlpha
import androidx.compose.material.Icon
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun TextDividerPreference(
title: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
trailingIcon: ImageVector? = null,
) {
TextDividerPreference(
title = AnnotatedString(text = title),
enabled = enabled,
modifier = modifier,
trailingIcon = trailingIcon,
)
}
@Composable
fun TextDividerPreference(
title: AnnotatedString,
modifier: Modifier = Modifier,
enabled: Boolean = true,
trailingIcon: ImageVector? = null,
) {
Card(
modifier = modifier.fillMaxWidth(),
backgroundColor = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(all = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.body1,
color = if (!enabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) else Color.Unspecified,
)
if (trailingIcon != null) Icon(
trailingIcon, "trailingIcon",
modifier = modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.End),
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun TextDividerPreferencePreview() {
TextDividerPreference(title = "Advanced settings")
}

Wyświetl plik

@ -0,0 +1,119 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.tooling.preview.Preview
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.AudioConfig
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.components.DropDownPreference
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.SwitchPreference
@Composable
fun AudioConfigItemList(
audioConfig: AudioConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (AudioConfig) -> Unit,
) {
var audioInput by remember(audioConfig) { mutableStateOf(audioConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "Audio Config") }
item {
SwitchPreference(title = "CODEC 2 enabled",
checked = audioInput.codec2Enabled,
enabled = enabled,
onCheckedChange = { audioInput = audioInput.copy { codec2Enabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "PTT pin",
value = audioInput.pttPin,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { audioInput = audioInput.copy { pttPin = it } })
}
item {
DropDownPreference(title = "CODEC2 sample rate",
enabled = enabled,
items = AudioConfig.Audio_Baud.values()
.filter { it != AudioConfig.Audio_Baud.UNRECOGNIZED }
.map { it to it.name },
selectedItem = audioInput.bitrate,
onItemSelected = { audioInput = audioInput.copy { bitrate = it } })
}
item { Divider() }
item {
EditTextPreference(title = "I2S word select",
value = audioInput.i2SWs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { audioInput = audioInput.copy { i2SWs = it } })
}
item {
EditTextPreference(title = "I2S data in",
value = audioInput.i2SSd,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { audioInput = audioInput.copy { i2SSd = it } })
}
item {
EditTextPreference(title = "I2S data out",
value = audioInput.i2SDin,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { audioInput = audioInput.copy { i2SDin = it } })
}
item {
EditTextPreference(title = "I2S clock",
value = audioInput.i2SSck,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { audioInput = audioInput.copy { i2SSck = it } })
}
item {
PreferenceFooter(
enabled = audioInput != audioConfig,
onCancelClicked = {
focusManager.clearFocus()
audioInput = audioConfig
},
onSaveClicked = { onSaveClicked(audioInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun AudioConfigPreview(){
AudioConfigItemList(
audioConfig = AudioConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { },
)
}

Wyświetl plik

@ -0,0 +1,90 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.tooling.preview.Preview
import com.geeksville.mesh.ConfigProtos.Config.BluetoothConfig
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.components.DropDownPreference
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.SwitchPreference
@Composable
fun BluetoothConfigItemList(
bluetoothConfig: BluetoothConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (BluetoothConfig) -> Unit,
) {
var bluetoothInput by remember(bluetoothConfig) { mutableStateOf(bluetoothConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "Bluetooth Config") }
item {
SwitchPreference(title = "Bluetooth enabled",
checked = bluetoothInput.enabled,
enabled = enabled,
onCheckedChange = { bluetoothInput = bluetoothInput.copy { this.enabled = it } })
}
item { Divider() }
item {
DropDownPreference(title = "Pairing mode",
enabled = enabled,
items = BluetoothConfig.PairingMode.values()
.filter { it != BluetoothConfig.PairingMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = bluetoothInput.mode,
onItemSelected = { bluetoothInput = bluetoothInput.copy { mode = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Fixed PIN",
value = bluetoothInput.fixedPin,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
if (it.toString().length == 6) // ensure 6 digits
bluetoothInput = bluetoothInput.copy { fixedPin = it }
})
}
item {
PreferenceFooter(
enabled = bluetoothInput != bluetoothConfig,
onCancelClicked = {
focusManager.clearFocus()
bluetoothInput = bluetoothConfig
},
onSaveClicked = { onSaveClicked(bluetoothInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun BluetoothConfigPreview(){
BluetoothConfigItemList(
bluetoothConfig = BluetoothConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { },
)
}

Wyświetl plik

@ -0,0 +1,205 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.CannedMessageConfig
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.components.DropDownPreference
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.SwitchPreference
@Composable
fun CannedMessageConfigItemList(
messages: String,
cannedMessageConfig: CannedMessageConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (messages: String, config: CannedMessageConfig) -> Unit,
) {
var messagesInput by remember { mutableStateOf(messages) }
var cannedMessageInput by remember { mutableStateOf(cannedMessageConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "Canned Message Config") }
item {
SwitchPreference(title = "Canned message enabled",
checked = cannedMessageInput.enabled,
enabled = enabled,
onCheckedChange = {
cannedMessageInput = cannedMessageInput.copy { this.enabled = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Rotary encoder #1 enabled",
checked = cannedMessageInput.rotary1Enabled,
enabled = enabled,
onCheckedChange = {
cannedMessageInput = cannedMessageInput.copy { rotary1Enabled = it }
})
}
item { Divider() }
item {
EditTextPreference(title = "GPIO pin for rotary encoder A port",
value = cannedMessageInput.inputbrokerPinA,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
cannedMessageInput = cannedMessageInput.copy { inputbrokerPinA = it }
})
}
item {
EditTextPreference(title = "GPIO pin for rotary encoder B port",
value = cannedMessageInput.inputbrokerPinB,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
cannedMessageInput = cannedMessageInput.copy { inputbrokerPinB = it }
})
}
item {
EditTextPreference(title = "GPIO pin for rotary encoder Press port",
value = cannedMessageInput.inputbrokerPinPress,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
cannedMessageInput = cannedMessageInput.copy { inputbrokerPinPress = it }
})
}
item {
DropDownPreference(title = "Generate input event on Press",
enabled = enabled,
items = CannedMessageConfig.InputEventChar.values()
.filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED }
.map { it to it.name },
selectedItem = cannedMessageInput.inputbrokerEventPress,
onItemSelected = {
cannedMessageInput = cannedMessageInput.copy { inputbrokerEventPress = it }
})
}
item { Divider() }
item {
DropDownPreference(title = "Generate input event on CW",
enabled = enabled,
items = CannedMessageConfig.InputEventChar.values()
.filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED }
.map { it to it.name },
selectedItem = cannedMessageInput.inputbrokerEventCw,
onItemSelected = {
cannedMessageInput = cannedMessageInput.copy { inputbrokerEventCw = it }
})
}
item { Divider() }
item {
DropDownPreference(title = "Generate input event on CCW",
enabled = enabled,
items = CannedMessageConfig.InputEventChar.values()
.filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED }
.map { it to it.name },
selectedItem = cannedMessageInput.inputbrokerEventCcw,
onItemSelected = {
cannedMessageInput = cannedMessageInput.copy { inputbrokerEventCcw = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Up/Down/Select input enabled",
checked = cannedMessageInput.updown1Enabled,
enabled = enabled,
onCheckedChange = {
cannedMessageInput = cannedMessageInput.copy { updown1Enabled = it }
})
}
item { Divider() }
item {
EditTextPreference(title = "Allow input source",
value = cannedMessageInput.allowInputSource,
maxSize = 63, // allow_input_source max_size:16
enabled = enabled,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
cannedMessageInput = cannedMessageInput.copy { allowInputSource = it }
})
}
item {
SwitchPreference(title = "Send bell",
checked = cannedMessageInput.sendBell,
enabled = enabled,
onCheckedChange = {
cannedMessageInput = cannedMessageInput.copy { sendBell = it }
})
}
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 || messagesInput != messages,
onCancelClicked = {
focusManager.clearFocus()
messagesInput = messages
cannedMessageInput = cannedMessageConfig
},
onSaveClicked = { onSaveClicked(messagesInput,cannedMessageInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun CannedMessageConfigPreview(){
CannedMessageConfigItemList(
messages = "",
cannedMessageConfig = CannedMessageConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
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

@ -0,0 +1,148 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.tooling.preview.Preview
import com.geeksville.mesh.ConfigProtos.Config.DeviceConfig
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.components.DropDownPreference
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.SwitchPreference
@Composable
fun DeviceConfigItemList(
deviceConfig: DeviceConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (DeviceConfig) -> Unit,
) {
var deviceInput by remember(deviceConfig) { mutableStateOf(deviceConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "Device Config") }
item {
DropDownPreference(title = "Role",
enabled = enabled,
items = DeviceConfig.Role.values()
.filter { it != DeviceConfig.Role.UNRECOGNIZED }
.map { it to it.name },
selectedItem = deviceInput.role,
onItemSelected = { deviceInput = deviceInput.copy { role = it } })
}
item { Divider() }
item {
SwitchPreference(title = "Serial output enabled",
checked = deviceInput.serialEnabled,
enabled = enabled,
onCheckedChange = { deviceInput = deviceInput.copy { serialEnabled = it } })
}
item { Divider() }
item {
SwitchPreference(title = "Debug log enabled",
checked = deviceInput.debugLogEnabled,
enabled = enabled,
onCheckedChange = { deviceInput = deviceInput.copy { debugLogEnabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Redefine PIN_BUTTON",
value = deviceInput.buttonGpio,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
deviceInput = deviceInput.copy { buttonGpio = it }
})
}
item {
EditTextPreference(title = "Redefine PIN_BUZZER",
value = deviceInput.buzzerGpio,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
deviceInput = deviceInput.copy { buzzerGpio = it }
})
}
item {
DropDownPreference(title = "Rebroadcast mode",
enabled = enabled,
items = DeviceConfig.RebroadcastMode.values()
.filter { it != DeviceConfig.RebroadcastMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = deviceInput.rebroadcastMode,
onItemSelected = { deviceInput = deviceInput.copy { rebroadcastMode = it } })
}
item { Divider() }
item {
EditTextPreference(title = "NodeInfo broadcast interval (seconds)",
value = deviceInput.nodeInfoBroadcastSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
deviceInput = deviceInput.copy { nodeInfoBroadcastSecs = it }
})
}
item {
SwitchPreference(title = "Double tap as button press",
checked = deviceInput.doubleTapAsButtonPress,
enabled = enabled,
onCheckedChange = {
deviceInput = deviceInput.copy { doubleTapAsButtonPress = it }
})
}
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,
onCancelClicked = {
focusManager.clearFocus()
deviceInput = deviceConfig
},
onSaveClicked = { onSaveClicked(deviceInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun DeviceConfigPreview(){
DeviceConfigItemList(
deviceConfig = DeviceConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { },
)
}

Wyświetl plik

@ -0,0 +1,155 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.tooling.preview.Preview
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.components.DropDownPreference
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.SwitchPreference
@Composable
fun DisplayConfigItemList(
displayConfig: DisplayConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (DisplayConfig) -> Unit,
) {
var displayInput by remember(displayConfig) { mutableStateOf(displayConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "Display Config") }
item {
EditTextPreference(title = "Screen timeout (seconds)",
value = displayInput.screenOnSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { displayInput = displayInput.copy { screenOnSecs = it } })
}
item {
DropDownPreference(title = "GPS coordinates format",
enabled = enabled,
items = DisplayConfig.GpsCoordinateFormat.values()
.filter { it != DisplayConfig.GpsCoordinateFormat.UNRECOGNIZED }
.map { it to it.name },
selectedItem = displayInput.gpsFormat,
onItemSelected = { displayInput = displayInput.copy { gpsFormat = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Auto screen carousel (seconds)",
value = displayInput.autoScreenCarouselSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
displayInput = displayInput.copy { autoScreenCarouselSecs = it }
})
}
item {
SwitchPreference(title = "Compass north top",
checked = displayInput.compassNorthTop,
enabled = enabled,
onCheckedChange = { displayInput = displayInput.copy { compassNorthTop = it } })
}
item { Divider() }
item {
SwitchPreference(title = "Flip screen",
checked = displayInput.flipScreen,
enabled = enabled,
onCheckedChange = { displayInput = displayInput.copy { flipScreen = it } })
}
item { Divider() }
item {
DropDownPreference(title = "Display units",
enabled = enabled,
items = DisplayConfig.DisplayUnits.values()
.filter { it != DisplayConfig.DisplayUnits.UNRECOGNIZED }
.map { it to it.name },
selectedItem = displayInput.units,
onItemSelected = { displayInput = displayInput.copy { units = it } })
}
item { Divider() }
item {
DropDownPreference(title = "Override OLED auto-detect",
enabled = enabled,
items = DisplayConfig.OledType.values()
.filter { it != DisplayConfig.OledType.UNRECOGNIZED }
.map { it to it.name },
selectedItem = displayInput.oled,
onItemSelected = { displayInput = displayInput.copy { oled = it } })
}
item { Divider() }
item {
DropDownPreference(title = "Display mode",
enabled = enabled,
items = DisplayConfig.DisplayMode.values()
.filter { it != DisplayConfig.DisplayMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = displayInput.displaymode,
onItemSelected = { displayInput = displayInput.copy { displaymode = it } })
}
item { Divider() }
item {
SwitchPreference(title = "Heading bold",
checked = displayInput.headingBold,
enabled = enabled,
onCheckedChange = { displayInput = displayInput.copy { headingBold = it } })
}
item { Divider() }
item {
SwitchPreference(title = "Wake screen on tap or motion",
checked = displayInput.wakeOnTapOrMotion,
enabled = enabled,
onCheckedChange = { displayInput = displayInput.copy { wakeOnTapOrMotion = it } })
}
item { Divider() }
item {
PreferenceFooter(
enabled = displayInput != displayConfig,
onCancelClicked = {
focusManager.clearFocus()
displayInput = displayConfig
},
onSaveClicked = { onSaveClicked(displayInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun DisplayConfigPreview(){
DisplayConfigItemList(
displayConfig = DisplayConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { },
)
}

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

@ -0,0 +1,227 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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
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.SwitchPreference
import com.geeksville.mesh.ui.components.TextDividerPreference
@Composable
fun ExternalNotificationConfigItemList(
ringtone: String,
extNotificationConfig: ExternalNotificationConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (ringtone: String, config: ExternalNotificationConfig) -> Unit,
) {
var ringtoneInput by remember { mutableStateOf(ringtone) }
var externalNotificationInput by remember { mutableStateOf(extNotificationConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "External Notification Config") }
item {
SwitchPreference(title = "External notification enabled",
checked = externalNotificationInput.enabled,
enabled = enabled,
onCheckedChange = {
externalNotificationInput = externalNotificationInput.copy { this.enabled = it }
})
}
item { TextDividerPreference("Notifications on message receipt", enabled = enabled) }
item {
SwitchPreference(title = "Alert message LED",
checked = externalNotificationInput.alertMessage,
enabled = enabled,
onCheckedChange = {
externalNotificationInput = externalNotificationInput.copy { alertMessage = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Alert message buzzer",
checked = externalNotificationInput.alertMessageBuzzer,
enabled = enabled,
onCheckedChange = {
externalNotificationInput =
externalNotificationInput.copy { alertMessageBuzzer = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Alert message vibra",
checked = externalNotificationInput.alertMessageVibra,
enabled = enabled,
onCheckedChange = {
externalNotificationInput =
externalNotificationInput.copy { alertMessageVibra = it }
})
}
item { TextDividerPreference("Notifications on alert/bell receipt", enabled = enabled) }
item {
SwitchPreference(title = "Alert bell LED",
checked = externalNotificationInput.alertBell,
enabled = enabled,
onCheckedChange = {
externalNotificationInput = externalNotificationInput.copy { alertBell = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Alert bell buzzer",
checked = externalNotificationInput.alertBellBuzzer,
enabled = enabled,
onCheckedChange = {
externalNotificationInput =
externalNotificationInput.copy { alertBellBuzzer = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Alert bell vibra",
checked = externalNotificationInput.alertBellVibra,
enabled = enabled,
onCheckedChange = {
externalNotificationInput =
externalNotificationInput.copy { alertBellVibra = it }
})
}
item { Divider() }
item {
EditTextPreference(title = "Output LED (GPIO)",
value = externalNotificationInput.output,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
externalNotificationInput = externalNotificationInput.copy { output = it }
})
}
if (externalNotificationInput.output != 0) item {
SwitchPreference(title = "Output LED active high",
checked = externalNotificationInput.active,
enabled = enabled,
onCheckedChange = {
externalNotificationInput = externalNotificationInput.copy { active = it }
})
}
item { Divider() }
item {
EditTextPreference(title = "Output buzzer (GPIO)",
value = externalNotificationInput.outputBuzzer,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
externalNotificationInput = externalNotificationInput.copy { outputBuzzer = it }
})
}
if (externalNotificationInput.outputBuzzer != 0) item {
SwitchPreference(title = "Use PWM buzzer",
checked = externalNotificationInput.usePwm,
enabled = enabled,
onCheckedChange = {
externalNotificationInput = externalNotificationInput.copy { usePwm = it }
})
}
item { Divider() }
item {
EditTextPreference(title = "Output vibra (GPIO)",
value = externalNotificationInput.outputVibra,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
externalNotificationInput = externalNotificationInput.copy { outputVibra = it }
})
}
item {
EditTextPreference(title = "Output duration (milliseconds)",
value = externalNotificationInput.outputMs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
externalNotificationInput = externalNotificationInput.copy { outputMs = it }
})
}
item {
EditTextPreference(title = "Nag timeout (seconds)",
value = externalNotificationInput.nagTimeout,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
externalNotificationInput = externalNotificationInput.copy { nagTimeout = it }
})
}
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 != extNotificationConfig || ringtoneInput != ringtone,
onCancelClicked = {
focusManager.clearFocus()
ringtoneInput = ringtone
externalNotificationInput = extNotificationConfig
},
onSaveClicked = { onSaveClicked(ringtoneInput, externalNotificationInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun ExternalNotificationConfigPreview(){
ExternalNotificationConfigItemList(
ringtone = "",
extNotificationConfig = ExternalNotificationConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { _, _ -> },
)
}

Wyświetl plik

@ -0,0 +1,195 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.tooling.preview.Preview
import com.geeksville.mesh.ConfigProtos.Config.LoRaConfig
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.components.DropDownPreference
import com.geeksville.mesh.ui.components.EditListPreference
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.SwitchPreference
@Composable
fun LoRaConfigItemList(
loraConfig: LoRaConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (LoRaConfig) -> Unit,
) {
var loraInput by remember(loraConfig) { mutableStateOf(loraConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "LoRa Config") }
item {
SwitchPreference(title = "Use modem preset",
checked = loraInput.usePreset,
enabled = enabled,
onCheckedChange = { loraInput = loraInput.copy { usePreset = it } })
}
item { Divider() }
if (loraInput.usePreset) {
item {
DropDownPreference(title = "Modem preset",
enabled = enabled && loraInput.usePreset,
items = LoRaConfig.ModemPreset.values()
.filter { it != LoRaConfig.ModemPreset.UNRECOGNIZED }
.map { it to it.name },
selectedItem = loraInput.modemPreset,
onItemSelected = { loraInput = loraInput.copy { modemPreset = it } })
}
item { Divider() }
} else {
item {
EditTextPreference(title = "Bandwidth",
value = loraInput.bandwidth,
enabled = enabled && !loraInput.usePreset,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { bandwidth = it } })
}
item {
EditTextPreference(title = "Spread factor",
value = loraInput.spreadFactor,
enabled = enabled && !loraInput.usePreset,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { spreadFactor = it } })
}
item {
EditTextPreference(title = "Coding rate",
value = loraInput.codingRate,
enabled = enabled && !loraInput.usePreset,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { codingRate = it } })
}
}
item {
EditTextPreference(title = "Frequency offset (MHz)",
value = loraInput.frequencyOffset,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { frequencyOffset = it } })
}
item {
DropDownPreference(title = "Region (frequency plan)",
enabled = enabled,
items = LoRaConfig.RegionCode.values()
.filter { it != LoRaConfig.RegionCode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = loraInput.region,
onItemSelected = { loraInput = loraInput.copy { region = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Hop limit",
value = loraInput.hopLimit,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { hopLimit = it } })
}
item {
SwitchPreference(title = "TX enabled",
checked = loraInput.txEnabled,
enabled = enabled,
onCheckedChange = { loraInput = loraInput.copy { txEnabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "TX power",
value = loraInput.txPower,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { txPower = it } })
}
item {
EditTextPreference(title = "Channel number",
value = loraInput.channelNum,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { channelNum = it } })
}
item {
SwitchPreference(title = "Override Duty Cycle",
checked = loraInput.overrideDutyCycle,
enabled = enabled,
onCheckedChange = { loraInput = loraInput.copy { overrideDutyCycle = it } })
}
item { Divider() }
item {
EditListPreference(title = "Ignore incoming",
list = loraInput.ignoreIncomingList,
maxCount = 3, // ignore_incoming max_count:3
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValuesChanged = { list ->
loraInput = loraInput.copy {
ignoreIncoming.clear()
ignoreIncoming.addAll(list.filter { it != 0 })
}
})
}
item {
SwitchPreference(title = "SX126X RX boosted gain",
checked = loraInput.sx126XRxBoostedGain,
enabled = enabled,
onCheckedChange = { loraInput = loraInput.copy { sx126XRxBoostedGain = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Override frequency (MHz)",
value = loraInput.overrideFrequency,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { loraInput = loraInput.copy { overrideFrequency = it } })
}
item {
PreferenceFooter(
enabled = loraInput != loraConfig,
onCancelClicked = {
focusManager.clearFocus()
loraInput = loraConfig
},
onSaveClicked = { onSaveClicked(loraInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun LoRaConfigPreview(){
LoRaConfigItemList(
loraConfig = LoRaConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { },
)
}

Wyświetl plik

@ -0,0 +1,146 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.MQTTConfig
import com.geeksville.mesh.copy
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.SwitchPreference
@Composable
fun MQTTConfigItemList(
mqttConfig: MQTTConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (MQTTConfig) -> Unit,
) {
var mqttInput by remember(mqttConfig) { mutableStateOf(mqttConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "MQTT Config") }
item {
SwitchPreference(title = "MQTT enabled",
checked = mqttInput.enabled,
enabled = enabled,
onCheckedChange = { mqttInput = mqttInput.copy { this.enabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Address",
value = mqttInput.address,
maxSize = 63, // address max_size:64
enabled = enabled,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { mqttInput = mqttInput.copy { address = it } })
}
item {
EditTextPreference(title = "Username",
value = mqttInput.username,
maxSize = 63, // username max_size:64
enabled = enabled,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { mqttInput = mqttInput.copy { username = it } })
}
item {
EditTextPreference(title = "Password",
value = mqttInput.password,
maxSize = 63, // password max_size:64
enabled = enabled,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { mqttInput = mqttInput.copy { password = it } })
}
item {
SwitchPreference(title = "Encryption enabled",
checked = mqttInput.encryptionEnabled,
enabled = enabled,
onCheckedChange = { mqttInput = mqttInput.copy { encryptionEnabled = it } })
}
item { Divider() }
item {
SwitchPreference(title = "JSON output enabled",
checked = mqttInput.jsonEnabled,
enabled = enabled,
onCheckedChange = { mqttInput = mqttInput.copy { jsonEnabled = it } })
}
item { Divider() }
item {
SwitchPreference(title = "TLS enabled",
checked = mqttInput.tlsEnabled,
enabled = enabled,
onCheckedChange = { mqttInput = mqttInput.copy { tlsEnabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Root topic",
value = mqttInput.root,
maxSize = 15, // root max_size:16
enabled = enabled,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { mqttInput = mqttInput.copy { root = it } })
}
item {
PreferenceFooter(
enabled = mqttInput != mqttConfig,
onCancelClicked = {
focusManager.clearFocus()
mqttInput = mqttConfig
},
onSaveClicked = { onSaveClicked(mqttInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun MQTTConfigPreview(){
MQTTConfigItemList(
mqttConfig = MQTTConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { },
)
}

Wyświetl plik

@ -0,0 +1,195 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.ConfigProtos.Config.NetworkConfig
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.components.DropDownPreference
import com.geeksville.mesh.ui.components.EditIPv4Preference
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.SwitchPreference
@Composable
fun NetworkConfigItemList(
networkConfig: NetworkConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (NetworkConfig) -> Unit,
) {
var networkInput by remember(networkConfig) { mutableStateOf(networkConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "Network Config") }
item {
SwitchPreference(title = "WiFi enabled",
checked = networkInput.wifiEnabled,
enabled = enabled,
onCheckedChange = { networkInput = networkInput.copy { wifiEnabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "SSID",
value = networkInput.wifiSsid,
maxSize = 32, // wifi_ssid max_size:33
enabled = enabled,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
networkInput = networkInput.copy { wifiSsid = it }
})
}
item {
EditTextPreference(title = "PSK",
value = networkInput.wifiPsk,
maxSize = 63, // wifi_psk max_size:64
enabled = enabled,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
networkInput = networkInput.copy { wifiPsk = it }
})
}
item {
EditTextPreference(title = "NTP server",
value = networkInput.ntpServer,
maxSize = 32, // ntp_server max_size:33
enabled = enabled,
isError = networkInput.ntpServer.isEmpty(),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
networkInput = networkInput.copy { ntpServer = it }
})
}
item {
EditTextPreference(title = "rsyslog server",
value = networkInput.rsyslogServer,
maxSize = 32, // rsyslog_server max_size:33
enabled = enabled,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
networkInput = networkInput.copy { rsyslogServer = it }
})
}
item {
SwitchPreference(title = "Ethernet enabled",
checked = networkInput.ethEnabled,
enabled = enabled,
onCheckedChange = { networkInput = networkInput.copy { ethEnabled = it } })
}
item { Divider() }
item {
DropDownPreference(title = "IPv4 mode",
enabled = enabled,
items = NetworkConfig.AddressMode.values()
.filter { it != NetworkConfig.AddressMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = networkInput.addressMode,
onItemSelected = { networkInput = networkInput.copy { addressMode = it } })
}
item { Divider() }
item {
EditIPv4Preference(title = "IP",
value = networkInput.ipv4Config.ip,
enabled = enabled && networkInput.addressMode == NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = networkInput.ipv4Config.copy { ip = it }
networkInput = networkInput.copy { ipv4Config = ipv4 }
})
}
item {
EditIPv4Preference(title = "Gateway",
value = networkInput.ipv4Config.gateway,
enabled = enabled && networkInput.addressMode == NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = networkInput.ipv4Config.copy { gateway = it }
networkInput = networkInput.copy { ipv4Config = ipv4 }
})
}
item {
EditIPv4Preference(title = "Subnet",
value = networkInput.ipv4Config.subnet,
enabled = enabled && networkInput.addressMode == NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = networkInput.ipv4Config.copy { subnet = it }
networkInput = networkInput.copy { ipv4Config = ipv4 }
})
}
item {
EditIPv4Preference(title = "DNS",
value = networkInput.ipv4Config.dns,
enabled = enabled && networkInput.addressMode == NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = networkInput.ipv4Config.copy { dns = it }
networkInput = networkInput.copy { ipv4Config = ipv4 }
})
}
item {
PreferenceFooter(
enabled = networkInput != networkConfig,
onCancelClicked = {
focusManager.clearFocus()
networkInput = networkConfig
},
onSaveClicked = { onSaveClicked(networkInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun NetworkConfigPreview(){
NetworkConfigItemList(
networkConfig = NetworkConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
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

@ -0,0 +1,201 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.tooling.preview.Preview
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.ConfigProtos.Config.PositionConfig
import com.geeksville.mesh.Position
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.components.BitwisePreference
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.SwitchPreference
@Composable
fun PositionConfigItemList(
isLocal: Boolean = false,
location: Position?,
positionConfig: PositionConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (position: Position?, config: PositionConfig) -> Unit,
) {
var locationInput by remember { mutableStateOf(location) }
var positionInput by remember { mutableStateOf(positionConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "Position Config") }
item {
EditTextPreference(title = "Position broadcast interval (seconds)",
value = positionInput.positionBroadcastSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
positionInput = positionInput.copy { positionBroadcastSecs = it }
})
}
item {
SwitchPreference(title = "Smart position enabled",
checked = positionInput.positionBroadcastSmartEnabled,
enabled = enabled,
onCheckedChange = {
positionInput = positionInput.copy { positionBroadcastSmartEnabled = it }
})
}
item { Divider() }
if (positionInput.positionBroadcastSmartEnabled) {
item {
EditTextPreference(title = "Smart broadcast minimum distance (meters)",
value = positionInput.broadcastSmartMinimumDistance,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
positionInput = positionInput.copy { broadcastSmartMinimumDistance = it }
})
}
item {
EditTextPreference(title = "Smart broadcast minimum interval (seconds)",
value = positionInput.broadcastSmartMinimumIntervalSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
positionInput = positionInput.copy { broadcastSmartMinimumIntervalSecs = it }
})
}
}
item {
SwitchPreference(title = "Use fixed position",
checked = positionInput.fixedPosition,
enabled = enabled,
onCheckedChange = { positionInput = positionInput.copy { fixedPosition = it } })
}
item { Divider() }
if (positionInput.fixedPosition) {
item {
EditTextPreference(title = "Latitude",
value = locationInput?.latitude ?: 0.0,
enabled = enabled && isLocal,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
if (value >= -90 && value <= 90.0)
locationInput?.let { locationInput = it.copy(latitude = value) }
})
}
item {
EditTextPreference(title = "Longitude",
value = locationInput?.longitude ?: 0.0,
enabled = enabled && isLocal,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
if (value >= -180 && value <= 180.0)
locationInput?.let { locationInput = it.copy(longitude = value) }
})
}
item {
EditTextPreference(title = "Altitude (meters)",
value = locationInput?.altitude ?: 0,
enabled = enabled && isLocal,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
locationInput?.let { locationInput = it.copy(altitude = value) }
})
}
}
item {
SwitchPreference(title = "GPS enabled",
checked = positionInput.gpsEnabled,
enabled = enabled,
onCheckedChange = { positionInput = positionInput.copy { gpsEnabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "GPS update interval (seconds)",
value = positionInput.gpsUpdateInterval,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { positionInput = positionInput.copy { gpsUpdateInterval = it } })
}
item {
EditTextPreference(title = "Fix attempt duration (seconds)",
value = positionInput.gpsAttemptTime,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { positionInput = positionInput.copy { gpsAttemptTime = it } })
}
item {
BitwisePreference(title = "Position flags",
value = positionInput.positionFlags,
enabled = enabled,
items = ConfigProtos.Config.PositionConfig.PositionFlags.values()
.filter { it != PositionConfig.PositionFlags.UNSET && it != PositionConfig.PositionFlags.UNRECOGNIZED }
.map { it.number to it.name },
onItemSelected = { positionInput = positionInput.copy { positionFlags = it } }
)
}
item { Divider() }
item {
EditTextPreference(title = "Redefine GPS_RX_PIN",
value = positionInput.rxGpio,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { positionInput = positionInput.copy { rxGpio = it } })
}
item {
EditTextPreference(title = "Redefine GPS_TX_PIN",
value = positionInput.txGpio,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { positionInput = positionInput.copy { txGpio = it } })
}
item {
PreferenceFooter(
enabled = positionInput != positionConfig || locationInput != location,
onCancelClicked = {
focusManager.clearFocus()
locationInput = location
positionInput = positionConfig
},
onSaveClicked = { onSaveClicked(locationInput, positionInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun PositionConfigPreview(){
PositionConfigItemList(
location = null,
positionConfig = PositionConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { _, _ -> },
)
}

Wyświetl plik

@ -0,0 +1,125 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.tooling.preview.Preview
import com.geeksville.mesh.ConfigProtos.Config.PowerConfig
import com.geeksville.mesh.copy
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.SwitchPreference
@Composable
fun PowerConfigItemList(
powerConfig: PowerConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (PowerConfig) -> Unit,
) {
var powerInput by remember(powerConfig) { mutableStateOf(powerConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "Power Config") }
item {
SwitchPreference(title = "Enable power saving mode",
checked = powerInput.isPowerSaving,
enabled = enabled,
onCheckedChange = { powerInput = powerInput.copy { isPowerSaving = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Shutdown on battery delay (seconds)",
value = powerInput.onBatteryShutdownAfterSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
powerInput = powerInput.copy { onBatteryShutdownAfterSecs = it }
})
}
item {
EditTextPreference(title = "ADC multiplier override ratio",
value = powerInput.adcMultiplierOverride,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { powerInput = powerInput.copy { adcMultiplierOverride = it } })
}
item {
EditTextPreference(title = "Wait for Bluetooth duration (seconds)",
value = powerInput.waitBluetoothSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { powerInput = powerInput.copy { waitBluetoothSecs = it } })
}
item {
EditTextPreference(title = "Mesh SDS timeout (seconds)",
value = powerInput.meshSdsTimeoutSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { powerInput = powerInput.copy { meshSdsTimeoutSecs = it } })
}
item {
EditTextPreference(title = "Super deep sleep duration (seconds)",
value = powerInput.sdsSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { powerInput = powerInput.copy { sdsSecs = it } })
}
item {
EditTextPreference(title = "Light sleep duration (seconds)",
value = powerInput.lsSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { powerInput = powerInput.copy { lsSecs = it } })
}
item {
EditTextPreference(title = "Minimum wake time (seconds)",
value = powerInput.minWakeSecs,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { powerInput = powerInput.copy { minWakeSecs = it } })
}
item {
PreferenceFooter(
enabled = powerInput != powerConfig,
onCancelClicked = {
focusManager.clearFocus()
powerInput = powerConfig
},
onSaveClicked = { onSaveClicked(powerInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun PowerConfigPreview(){
PowerConfigItemList(
powerConfig = PowerConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { },
)
}

Wyświetl plik

@ -0,0 +1,83 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.tooling.preview.Preview
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.RangeTestConfig
import com.geeksville.mesh.copy
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.SwitchPreference
@Composable
fun RangeTestConfigItemList(
rangeTestConfig: RangeTestConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (RangeTestConfig) -> Unit,
) {
var rangeTestInput by remember(rangeTestConfig) { mutableStateOf(rangeTestConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "Range Test Config") }
item {
SwitchPreference(title = "Range test enabled",
checked = rangeTestInput.enabled,
enabled = enabled,
onCheckedChange = { rangeTestInput = rangeTestInput.copy { this.enabled = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Sender message interval (seconds)",
value = rangeTestInput.sender,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { rangeTestInput = rangeTestInput.copy { sender = it } })
}
item {
SwitchPreference(title = "Save .CSV in storage (ESP32 only)",
checked = rangeTestInput.save,
enabled = enabled,
onCheckedChange = { rangeTestInput = rangeTestInput.copy { save = it } })
}
item { Divider() }
item {
PreferenceFooter(
enabled = rangeTestInput != rangeTestConfig,
onCancelClicked = {
focusManager.clearFocus()
rangeTestInput = rangeTestConfig
},
onSaveClicked = { onSaveClicked(rangeTestInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun RangeTestConfig(){
RangeTestConfigItemList(
rangeTestConfig = RangeTestConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { },
)
}

Wyświetl plik

@ -0,0 +1,93 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.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
@Composable
fun RemoteHardwareConfigItemList(
remoteHardwareConfig: RemoteHardwareConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (RemoteHardwareConfig) -> Unit,
) {
var remoteHardwareInput by remember(remoteHardwareConfig) { mutableStateOf(remoteHardwareConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "Remote Hardware Config") }
item {
SwitchPreference(title = "Remote Hardware enabled",
checked = remoteHardwareInput.enabled,
enabled = enabled,
onCheckedChange = {
remoteHardwareInput = remoteHardwareInput.copy { this.enabled = it }
})
}
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,
onCancelClicked = {
focusManager.clearFocus()
remoteHardwareInput = remoteHardwareConfig
},
onSaveClicked = { onSaveClicked(remoteHardwareInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun RemoteHardwareConfigPreview(){
RemoteHardwareConfigItemList(
remoteHardwareConfig = RemoteHardwareConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { },
)
}

Wyświetl plik

@ -0,0 +1,122 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.tooling.preview.Preview
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.SerialConfig
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.components.DropDownPreference
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.SwitchPreference
@Composable
fun SerialConfigItemList(
serialConfig: SerialConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (SerialConfig) -> Unit,
) {
var serialInput by remember(serialConfig) { mutableStateOf(serialConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "Serial Config") }
item {
SwitchPreference(title = "Serial enabled",
checked = serialInput.enabled,
enabled = enabled,
onCheckedChange = { serialInput = serialInput.copy { this.enabled = it } })
}
item { Divider() }
item {
SwitchPreference(title = "Echo enabled",
checked = serialInput.echo,
enabled = enabled,
onCheckedChange = { serialInput = serialInput.copy { echo = it } })
}
item { Divider() }
item {
EditTextPreference(title = "RX",
value = serialInput.rxd,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { serialInput = serialInput.copy { rxd = it } })
}
item {
EditTextPreference(title = "TX",
value = serialInput.txd,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { serialInput = serialInput.copy { txd = it } })
}
item {
DropDownPreference(title = "Serial baud rate",
enabled = enabled,
items = SerialConfig.Serial_Baud.values()
.filter { it != SerialConfig.Serial_Baud.UNRECOGNIZED }
.map { it to it.name },
selectedItem = serialInput.baud,
onItemSelected = { serialInput = serialInput.copy { baud = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Timeout",
value = serialInput.timeout,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { serialInput = serialInput.copy { timeout = it } })
}
item {
DropDownPreference(title = "Serial mode",
enabled = enabled,
items = SerialConfig.Serial_Mode.values()
.filter { it != SerialConfig.Serial_Mode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = serialInput.mode,
onItemSelected = { serialInput = serialInput.copy { mode = it } })
}
item { Divider() }
item {
PreferenceFooter(
enabled = serialInput != serialConfig,
onCancelClicked = {
focusManager.clearFocus()
serialInput = serialConfig
},
onSaveClicked = { onSaveClicked(serialInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun SerialConfigPreview(){
SerialConfigItemList(
serialConfig = SerialConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { },
)
}

Wyświetl plik

@ -0,0 +1,105 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.tooling.preview.Preview
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.StoreForwardConfig
import com.geeksville.mesh.copy
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.SwitchPreference
@Composable
fun StoreForwardConfigItemList(
storeForwardConfig: StoreForwardConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (StoreForwardConfig) -> Unit,
) {
var storeForwardInput by remember(storeForwardConfig) { mutableStateOf(storeForwardConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "Store & Forward Config") }
item {
SwitchPreference(title = "Store & Forward enabled",
checked = storeForwardInput.enabled,
enabled = enabled,
onCheckedChange = {
storeForwardInput = storeForwardInput.copy { this.enabled = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Heartbeat",
checked = storeForwardInput.heartbeat,
enabled = enabled,
onCheckedChange = { storeForwardInput = storeForwardInput.copy { heartbeat = it } })
}
item { Divider() }
item {
EditTextPreference(title = "Number of records",
value = storeForwardInput.records,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { storeForwardInput = storeForwardInput.copy { records = it } })
}
item {
EditTextPreference(title = "History return max",
value = storeForwardInput.historyReturnMax,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
storeForwardInput = storeForwardInput.copy { historyReturnMax = it }
})
}
item {
EditTextPreference(title = "History return window",
value = storeForwardInput.historyReturnWindow,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
storeForwardInput = storeForwardInput.copy { historyReturnWindow = it }
})
}
item {
PreferenceFooter(
enabled = storeForwardInput != storeForwardConfig,
onCancelClicked = {
focusManager.clearFocus()
storeForwardInput = storeForwardConfig
},
onSaveClicked = { onSaveClicked(storeForwardInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun StoreForwardConfigPreview(){
StoreForwardConfigItemList(
storeForwardConfig = StoreForwardConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { },
)
}

Wyświetl plik

@ -0,0 +1,109 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.tooling.preview.Preview
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.TelemetryConfig
import com.geeksville.mesh.copy
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.SwitchPreference
@Composable
fun TelemetryConfigItemList(
telemetryConfig: TelemetryConfig,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (TelemetryConfig) -> Unit,
) {
var telemetryInput by remember(telemetryConfig) { mutableStateOf(telemetryConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "Telemetry Config") }
item {
EditTextPreference(title = "Device metrics update interval (seconds)",
value = telemetryInput.deviceUpdateInterval,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
telemetryInput = telemetryInput.copy { deviceUpdateInterval = it }
})
}
item {
EditTextPreference(title = "Environment metrics update interval (seconds)",
value = telemetryInput.environmentUpdateInterval,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
telemetryInput = telemetryInput.copy { environmentUpdateInterval = it }
})
}
item {
SwitchPreference(title = "Environment metrics module enabled",
checked = telemetryInput.environmentMeasurementEnabled,
enabled = enabled,
onCheckedChange = {
telemetryInput = telemetryInput.copy { environmentMeasurementEnabled = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Environment metrics on-screen enabled",
checked = telemetryInput.environmentScreenEnabled,
enabled = enabled,
onCheckedChange = {
telemetryInput = telemetryInput.copy { environmentScreenEnabled = it }
})
}
item { Divider() }
item {
SwitchPreference(title = "Environment metrics use Fahrenheit",
checked = telemetryInput.environmentDisplayFahrenheit,
enabled = enabled,
onCheckedChange = {
telemetryInput = telemetryInput.copy { environmentDisplayFahrenheit = it }
})
}
item { Divider() }
item {
PreferenceFooter(
enabled = telemetryInput != telemetryConfig,
onCancelClicked = {
focusManager.clearFocus()
telemetryInput = telemetryConfig
},
onSaveClicked = { onSaveClicked(telemetryInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun TelemetryConfigPreview(){
TelemetryConfigItemList(
telemetryConfig = TelemetryConfig.getDefaultInstance(),
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { },
)
}

Wyświetl plik

@ -0,0 +1,122 @@
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.MeshProtos
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: MeshProtos.User,
enabled: Boolean,
focusManager: FocusManager,
onSaveClicked: (MeshProtos.User) -> Unit,
) {
var userInput by remember(userConfig) { mutableStateOf(userConfig) }
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item { PreferenceCategory(text = "User Config") }
item {
RegularPreference(title = "Node ID",
subtitle = userInput.id,
onClick = {})
}
item { Divider() }
item {
EditTextPreference(title = "Long name",
value = userInput.longName,
maxSize = 39, // long_name max_size:40
enabled = enabled,
isError = userInput.longName.isEmpty(),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
userInput = userInput.copy { longName = it }
if (getInitials(it).toByteArray().size <= 4) // short_name max_size:5
userInput = userInput.copy { shortName = getInitials(it) }
})
}
item {
EditTextPreference(title = "Short name",
value = userInput.shortName,
maxSize = 4, // short_name max_size:5
enabled = enabled,
isError = userInput.shortName.isEmpty(),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { userInput = userInput.copy { shortName = it } })
}
item {
RegularPreference(title = "Hardware model",
subtitle = userInput.hwModel.name,
onClick = {})
}
item { Divider() }
item {
SwitchPreference(title = "Licensed amateur radio",
checked = userInput.isLicensed,
enabled = enabled,
onCheckedChange = { userInput = userInput.copy { isLicensed = it } })
}
item { Divider() }
item {
PreferenceFooter(
enabled = userInput != userConfig,
onCancelClicked = {
focusManager.clearFocus()
userInput = userConfig
}, onSaveClicked = { onSaveClicked(userInput) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun UserConfigPreview(){
UserConfigItemList(
userConfig = user {
id = "!a280d9c8"
longName = "Meshtastic d9c8"
shortName = "d9c8"
hwModel = MeshProtos.HardwareModel.RAK4631
isLicensed = false
},
enabled = true,
focusManager = LocalFocusManager.current,
onSaveClicked = { },
)
}

Wyświetl plik

@ -36,12 +36,12 @@ fun formatAgo(lastSeenUnix: Int): String {
val diffMin = (currentTime - lastSeenUnix) / 60
if (diffMin < 1)
return "now"
if (diffMin < 100)
return diffMin.toString() + "m"
if (diffMin < 6000)
return (diffMin / 60).toString() + "h"
if (diffMin < 144000)
return (diffMin / (60 * 24)).toString() + "d"
if (diffMin < 59)
return diffMin.toString() + " min"
if (diffMin < 2880)
return (diffMin / 60).toString() + " h"
if (diffMin < 1440000)
return (diffMin / (60 * 24)).toString() + " d"
return "?"
}

@ -1 +1 @@
Subproject commit 2b55d8421b2f872f8c50e001bd107a2bc56a5157
Subproject commit d7327c3de2a1dbd9ebb90864c703f97c673a4fc7

Wyświetl plik

@ -9,6 +9,6 @@
android:strokeColor="@android:color/white"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
<path
android:fillColor="@android:color/white"
android:fillColor="@android:color/transparent"
android:pathData="M12,9m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0" />
</vector>

Wyświetl plik

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillAlpha="0.3"
android:fillColor="@android:color/white"
android:pathData="M8,7h11v14H8z"
android:strokeAlpha="0.3" />
<path
android:fillColor="@android:color/white"
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z" />
</vector>

Wyświetl plik

@ -1,145 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/channelNameView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="64dp"
android:hint="@string/channel_name"
app:counterEnabled="true"
app:counterMaxLength="11"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/channelNameEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:digits="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890- "
android:imeOptions="actionDone"
android:singleLine="true"
android:text="@string/unset" />
</com.google.android.material.textfield.TextInputLayout>
<ImageView
android:id="@+id/qrView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:contentDescription="@string/qr_code"
app:layout_constraintBottom_toTopOf="@+id/channelOptions"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/channelNameView"
app:srcCompat="@drawable/qrcode" />
<!--
geeksville: no longer used but keeping as a good example of a button group. instead I use
a toggleable icon.
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/editGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="96dp"
android:layout_marginTop="16dp"
app:layout_constraintStart_toStartOf="parent"
app:singleSelection="true"
app:selectionRequired="true"
app:layout_constraintTop_toBottomOf="@+id/channelOptions">
<Button
android:id="@+id/locked"
style="@style/Widget.App.Button.OutlinedButton.IconOnly"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
app:icon="@drawable/ic_twotone_lock_24" />
<Button
android:id="@+id/unlocked"
style="@style/Widget.App.Button.OutlinedButton.IconOnly"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
app:icon="@drawable/ic_twotone_lock_open_24" />
</com.google.android.material.button.MaterialButtonToggleGroup>
-->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/channelOptions"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:layout_marginEnd="64dp"
android:layout_marginBottom="16dp"
android:hint="@string/channel_options"
app:layout_constraintBottom_toTopOf="@+id/editableCheckbox"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<AutoCompleteTextView
android:id="@+id/filled_exposed_dropdown"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/resetButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:text="@string/reset"
app:layout_constraintBottom_toBottomOf="@id/bottomButtonsGuideline"
app:layout_constraintEnd_toStartOf="@id/editableCheckbox" />
<Button
android:id="@+id/scanButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="@string/scan"
app:layout_constraintBottom_toBottomOf="@id/bottomButtonsGuideline"
app:layout_constraintStart_toEndOf="@id/editableCheckbox" />
<CheckBox
android:id="@+id/editableCheckbox"
android:minWidth="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:button="@drawable/sl_lock_24dp"
app:layout_constraintBottom_toBottomOf="@id/bottomButtonsGuideline"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<ImageButton
android:id="@+id/shareButton"
style="@android:style/Widget.Material.ImageButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:contentDescription="@string/share"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="@id/bottomButtonsGuideline"
app:srcCompat="@drawable/ic_twotone_share_24" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/bottomButtonsGuideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:orientation="horizontal"
app:layout_constraintGuide_end="16dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceSubtitle1"
/>

Wyświetl plik

@ -17,13 +17,9 @@
android:checked="false"
android:title="@string/protocol_stress_test" />
<item
android:id="@+id/device_settings"
android:id="@+id/radio_config"
app:showAsAction="withText"
android:title="@string/device_settings" />
<item
android:id="@+id/module_settings"
app:showAsAction="withText"
android:title="@string/module_settings" />
<item
android:id="@+id/save_messages_csv"
app:showAsAction="withText"

Wyświetl plik

@ -10,23 +10,15 @@
android:id="@+id/request_position"
android:title="@string/request_position"
app:showAsAction="withText" />
<item
android:id="@+id/traceroute"
android:title="@string/traceroute"
app:showAsAction="withText" />
</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

@ -7,10 +7,12 @@
<string name="connection_status">Κατάσταση Σύνδεσης</string>
<string name="application_icon">Εικονίδιο εφαρμογής </string>
<string name="unknown_username">Άγνωστο Όνομα Χρήστη</string>
<string name="send">Αποστολή</string>
<string name="send_text">Αποστολή κειμένου</string>
<string name="warning_not_paired">Δεν έχετε κάνει ακόμη pair μια συσκευή συμβατή με Meshtastic με το τηλέφωνο. Παρακαλώ κάντε pair μια συσκευή και ορίστε το όνομα χρήστη.\n\nΗ εφαρμογή ανοιχτού κώδικα βρίσκεται σε alpha-testing, αν εντοπίσετε προβλήματα παρακαλώ δημοσιεύστε τα στο forum: meshtastic.discourse.group.\n\nΠερισσότερες πληροφορίες στην ιστοσελίδα - www.meshtastic.org.</string>
<string name="you">Εσύ, Εσείς</string>
<string name="your_name">Όνομα</string>
<string name="analytics_okay">Ανώνυμα στατιστικά χρήσης και αναφορές crash.</string>
<string name="analytics_okay">Ανώνυμα στατιστικά στοιχεία χρήσης και αναφορές κατάρρευσης.</string>
<string name="looking_for_meshtastic_devices">Αναζήτηση συσκευών Meshtastic …</string>
<string name="starting_pairing">Αρχή pairing</string>
<string name="url_for_join">Διεύθυνση URL για συμμετοχή σε Meshtastic mesh</string>
@ -22,19 +24,19 @@
<string name="do_you_want_switch">Θέλετε να αλλάξετε %s κανάλι?</string>
<string name="permission_missing">Λείπει μια απαιτούμενη άδεια, Meshtastic δεν θα λειτοργεί σωστά. Ενεργοποιήστε τις ρυθμίσεις εφαρμογής Android.</string>
<string name="radio_sleeping">Radio σε κατάσταση ύπνου, δεν γίνεται αλλαγή καναλιού</string>
<string name="report_bug">Αναφορά Bug</string>
<string name="report_a_bug">Αναφέρετε ένα bug</string>
<string name="report_bug_text">Είστε σίγουροι ότι θέλετε να αναφέρετε ένα bug? Μετά την αναφορά δημοσιεύστε στο meshtastic.discourse.group ώστε να συνδέσουμε την αναφορά με το συμβάν.</string>
<string name="report_bug">Αναφορά Σφάλματος</string>
<string name="report_a_bug">Αναφέρετε ένα σφάλμα</string>
<string name="report_bug_text">Είστε σίγουροι ότι θέλετε να αναφέρετε ένα σφαλμα? Μετά την αναφορά δημοσιεύστε στο meshtastic.discourse.group ώστε να συνδέσουμε την αναφορά με το συμβάν.</string>
<string name="report">Αναφορά</string>
<string name="not_paired_yet">Δεν έχετε κάνει pair με radio ακόμη.</string>
<string name="change_radio">Αλλαγή radio</string>
<string name="pairing_completed">Η διαδικασία pairing ολοκληρώθηκε, εκκίνηση υπηρεσίας</string>
<string name="pairing_failed_try_again">Η διαδικασία pairing απέτυχε, παρακαλώ επιλέξτε πάλι</string>
<string name="location_disabled">Ο εντοπισμός τοποθεσίας είναι απενεργοποιημένος, δε μπορούμε να μοιραστούμε τη θέση σας με το mesh.</string>
<string name="pairing_failed_try_again">Η διαδικασία ζευγοποιησης απέτυχε, παρακαλώ επιλέξτε πάλι</string>
<string name="location_disabled">Η πρόσβαση στην τοποθεσία είναι απενεργοποιημένη, δεν μπορεί να παρέχει θέση στο πλέγμα.</string>
<string name="share">Κοινοποίηση</string>
<string name="disconnected">Αποσυνδεδεμένο</string>
<string name="device_sleeping">Συσκευή σε ύπνωση</string>
<string name="connected_count">Συνδεδεμένος: %1$s από %2$s online</string>
<string name="connected_count">Συνδέθηκε: %1$s από %2$s σε σύνδεση</string>
<string name="list_of_nodes">Λίστα κόμβων δικτύου</string>
<string name="update_firmware">Αναβάθμιση Firmware</string>
<string name="connected">Συνδεδεμένο στο radio</string>
@ -48,6 +50,7 @@
<string name="modem_config_short">Μικρή εμβέλεια (γρήγορο)</string>
<string name="modem_config_medium">Μεσαία εμβέλεια (γρήγορο)</string>
<string name="modem_config_long">Μεγάλη εμβέλεια (γρήγορο)</string>
<string name="modem_config_mod_long">Long Range / Moderate</string>
<string name="modem_config_very_long">Πολύ μεγάλη εμβέλεια (αργό)</string>
<string name="modem_config_unrecognized">ΜΗ ΑΝΑΓΝΩΡΙΣΙΜΟ</string>
<string name="meshtastic_service_notifications">Ειδοποιήσεις Υπηρεσίας</string>
@ -56,9 +59,111 @@
<string name="a_list_of_nodes_in_the_mesh">Λίστα κόμβων στο mesh</string>
<string name="text_messages">Μηνύματα</string>
<string name="channel_invalid">Αυτό το κανάλι URL δεν είναι ορθό και δεν μπορεί να χρησιμοποιηθεί</string>
<string name="debug_panel">Πίνακας αποσφαλμάτωσης</string>
<string name="debug_last_messages">Αποσφαλματώσετε τα τελευταία μηνύματά</string>
<string name="clear">Καθαρό, Εκκαθάριση,</string>
<string name="updating_firmware">Ενημέρωσή λογισμικού…</string>
<string name="update_successful">Επιτυχής ενημέρωση</string>
<string name="update_failed">Αποτυχία ενημέρωσης</string>
<string name="message_reception_time">χρόνος λήψης μηνυμάτων</string>
<string name="message_reception_state">κατάσταση λήψης μηνύματος</string>
<string name="message_delivery_status">Κατάσταση παράδοσης μηνύματος</string>
<string name="meshtastic_messages_notifications">Ειδοποιήσεις μηνυμάτων εφαρμογής</string>
<string name="protocol_stress_test">Δοκιμή αντοχής πρωτοκόλλου</string>
<string name="firmware_too_old">Απαιτείται ενημέρωση υλικολογισμικού</string>
<string name="firmware_old">Το λογισμικό του πομποδεκτη είναι πολύ παλιό για να μιλήσει σε αυτήν την εφαρμογή, παρακαλώ πηγαίνετε στις ρυθμισεις και επιλέξτε \"Ενημέρωση υλικολογισμικού\". Για περισσότερες πληροφορίες σχετικά με αυτό ανατρέξτε στον <a href="https://github.com/meshtastic/Meshtastic-device#firmware-installation">οδηγό εγκατάστασης του Firmware</a> του Github.</string>
<string name="okay">Εντάξει</string>
<string name="must_set_region">Πρέπει να ορίσετε μια περιοχή!</string>
<string name="region">Περιφέρεια</string>
<string name="cant_change_no_radio">Couldn\'t change channel, because radio is not yet connected. Please try again.</string>
<string name="save_messages">Εξαγωγή rangetest.csv</string>
<string name="reset">Επαναφορά</string>
<string name="scan">Σάρωση</string>
<string name="are_you_sure_change_default">Είστε σίγουροι ότι θέλετε να αλλάξετε στο προεπιλεγμένο κανάλι;</string>
<string name="reset_to_defaults">Επαναφορά προεπιλογών</string>
<string name="apply">Εφαρμογή</string>
<string name="no_app_found">Δεν βρέθηκε εφαρμογή για την αποστολή διευθύνσεων URL</string>
<string name="theme">Θέμα</string>
<string name="theme_light">Φωτεινό</string>
<string name="theme_dark">Σκούρο</string>
<string name="theme_system">Προκαθορισμένο του συστήματος</string>
<string name="choose_theme">Επέλεξε θέμα</string>
<string name="background_required">Τοποθεσία φόντου</string>
<string name="why_background_required">For this feature, you must grant Location permission option \"Allow all the time\".\nThis allows Meshtastic to read your smartphone location and send it to other members of your mesh, even when the app is closed or not in use.</string>
<string name="required_permissions">Απαιτούμενες άδειες</string>
<string name="provide_location_to_mesh">Παρέχετε τοποθεσία στο πλέγμα</string>
<string name="camera_required">Άδεια κάμερας</string>
<string name="why_camera_required">Πρέπει να μας δοθεί πρόσβαση στην κάμερα για να διαβάσουμε τους κωδικούς QR. Δεν θα αποθηκευτούν φωτογραφίες ή βίντεο.</string>
<string name="modem_config_slow_short">Μικρή εμβέλεια (αργό)</string>
<string name="modem_config_slow_medium">Μεσαία εμβέλεια (αργό)</string>
<plurals name="delete_messages">
<item quantity="one">Διαγραφή μηνύματος;</item>
<item quantity="other">Διαγραφή %s μηνυμάτων;</item>
</plurals>
<string name="delete">Διαγραφή</string>
<string name="delete_for_everyone">Διαγραφή για όλους</string>
<string name="delete_for_me">Διαγραφή από μένα</string>
<string name="select_all">Επιλογή όλων</string>
<string name="modem_config_slow_long">μεγάλη εμβέλεια (αργό)</string>
<string name="map_style_selection">Επιλογή Ύφους</string>
<string name="map_download_region">Λήψη Περιοχής</string>
<string name="name">Ονομα</string>
<string name="description">Περιγραφή</string>
<string name="locked">Κλειδωμένο</string>
<string name="save">Αποθήκευση</string>
<string name="preferences_language">Γλώσσα</string>
<string name="preferences_system_default">Προκαθορισμένο του συστήματος</string>
<string name="resend">Αποστολή ξανά</string>
<string name="shutdown">Τερματισμός λειτουργίας</string>
<string name="reboot">Επανεκκίνηση</string>
<string name="traceroute">Traceroute</string>
<string name="intro_show">Προβολή Εισαγωγής</string>
<string name="intro_welcome">Καλώς ήλθατε στο Meshtastic</string>
<string name="intro_welcome_text">Το Meshtastic ειναι μια εκτός δικτύου κρυπτογραφημενη πλατφορμα επικοινωνιας ανοιχτού κωδικα.
Οι συσκευες meshtastic δημιουργουν ενα πλεγμα δικτυου και επικοινωνουν με πρωτοκολλο Lora για την αποστολή μηνυματων.</string>
<string name="intro_started">…Ας ξεκινήσουμε!</string>
<string name="intro_started_text">Συνδέστε τη συσκευή Meshtastic χρησιμοποιώντας είτε Bluetooth, Serial ή WiFi. \n\nΜπορείτε να δείτε ποιες συσκευές είναι συμβατές στο www.meshtastic.org/docs/hardware</string>
<string name="intro_encryption">"Εγκατάσταση κρυπτογράφησης"</string>
<string name="intro_encryption_text">Ως πρότυπο, έχει οριστεί ένα προεπιλεγμένο κλειδί κρυπτογράφησης. Για να ενεργοποιήσετε το δικό σας κανάλι και την βελτιωμένη κρυπτογράφηση, μεταβείτε στην καρτέλα του καναλιού και αλλάξτε το όνομα του καναλιού, αυτό θα ορίσει ένα τυχαίο κλειδί για κρυπτογράφηση AES256. \n\nΓια να επικοινωνήσουν με άλλες συσκευές, θα πρέπει να σαρώσουν τον κωδικό QR σας ή να ακολουθήσουν τον κοινόχρηστο σύνδεσμο για να ρυθμίσετε τις ρυθμίσεις καναλιού.</string>
<string name="message">Μήνυμα</string>
<string name="quick_chat">Γρήγορες επιλογές συνομιλίας</string>
<string name="quick_chat_new">Νέα γρήγορη συνομιλία</string>
<string name="quick_chat_edit">Επεξεργασία ταχείας συνομιλίας</string>
<string name="quick_chat_append">Append to message</string>
<string name="quick_chat_instant">Άμεση αποστολή</string>
<string name="warning_default_psk">Empty channel names use the default encryption key (any device on %s can read your messages).</string>
<string name="factory_reset">Επαναφορά εργοστασιακών ρυθμίσεων</string>
<string name="factory_reset_description">Αυτό θα καθαρίσει όλες τις ρυθμίσεις συσκευής που έχετε κάνει.</string>
<string name="bluetooth_disabled">Bluetooth απενεργοποιημένο</string>
<string name="permission_missing_31">Το Meshtastic χρειάζεται την άδεια των κοντινών συσκευών για να βρείτε και να συνδεθείτε σε συσκευές μέσω Bluetooth. Μπορείτε να το απενεργοποιήσετε όταν δεν χρησιμοποιείται.</string>
<string name="direct_message">Άμεσο Μήνυμα</string>
<string name="nodedb_reset">NodeDB reset</string>
<string name="nodedb_reset_description">Αυτό θα καθαρίσει όλους τους κόμβους από αυτήν τη λίστα.</string>
<string name="map_select_download_region">Επιλογή περιοχής λήψης</string>
<string name="map_5_miles">5 Μίλια</string>
<string name="map_10_miles">10 μίλια</string>
<string name="map_15_miles">15 μίλια</string>
<string name="map_tile_download_estimate">Tile download estimate:</string>
<string name="map_start_download">Εκκίνηση Λήψης</string>
<string name="request_position">Αίτηση θέσης</string>
<string name="close">Κλείσιμο</string>
<string name="device_settings">Ρυθμίσεις συσκευής</string>
<string name="module_settings">Ρυθμίσεις πρόσθετου</string>
<string name="add">Προσθήκη</string>
<string name="calculating">Υπολογισμός…</string>
<string name="map_offline_manager">Διαχειριστής Εκτός Δικτύου</string>
<string name="map_cache_size">Μέγεθος τρέχουσας προσωρινής μνήμης</string>
<string name="map_cache_info">Χωρητικότητα προσωρινής μνήμης: %1$.2f MB\nΧρήση προσωρινής μνήμης: %2$.2f MB</string>
<string name="map_clear_tiles">Clear Downloaded Tiles</string>
<string name="map_tile_source">Tile Source</string>
<string name="map_purge_success">Η προσωρινή μνήμη SQL καθαρίστηκε για %s</string>
<string name="map_purge_fail">SQL Cache purge failed, see logcat for details</string>
<string name="map_cache_manager">Διαχείριση Προσωρινής Αποθήκευσης</string>
<string name="map_download_complete">Η λήψη ολοκληρώθηκε!</string>
<string name="map_download_errors">Λήψη ολοκληρώθηκε με %s σφάλματα</string>
<string name="map_cache_tiles">%s tiles</string>
<string name="map_subDescription">bearing: %1$s° distance: %2$s</string>
<string name="waypoint_edit">Επεξεργασία σημείου διαδρομής</string>
<string name="waypoint_delete">Διαγραφή σημείου πορείας;</string>
<string name="waypoint_new">Νέο σημείο πορείας</string>
</resources>

Wyświetl plik

@ -54,7 +54,7 @@
<string name="modem_config_unrecognized">ÓÞEKKT</string>
<string name="meshtastic_service_notifications">Tilkynningar um þjónustu</string>
<string name="location_disabled_warning">Kveikt verður að vera á staðsetningu (Mikil nákvæmni) til að finna nýjan búnað yfir Blátönn. Þú getur slökkt á því eftir á.</string>
<string name="about">Um</string>
<string name="about">Um smáforrit</string>
<string name="a_list_of_nodes_in_the_mesh">Listi yfir nóður á möskva</string>
<string name="text_messages">Textaskilaboð</string>
<string name="channel_invalid">Þetta rásar URL er ógilt og ónothæft</string>

Wyświetl plik

@ -50,6 +50,7 @@
<string name="modem_config_short">Krótki zasięg / Szybko</string>
<string name="modem_config_medium">Średni zasięg / Szybko</string>
<string name="modem_config_long">Daleki zasięg / Szybko</string>
<string name="modem_config_mod_long">Długi zasięg / Średnio</string>
<string name="modem_config_very_long">Bardzo daleki zasięg / Wolno</string>
<string name="modem_config_unrecognized">NIE ROZPOZNANO</string>
<string name="meshtastic_service_notifications">Powiadomienia o usługach</string>
@ -117,6 +118,7 @@
<string name="resend">Ponów</string>
<string name="shutdown">Wyłącz</string>
<string name="reboot">Restart</string>
<string name="traceroute">Śledzenie trasy</string>
<string name="intro_show">Wprowadzenie</string>
<string name="intro_welcome">Witaj w Meshtastic</string>
<string name="intro_welcome_text">Meshtastic jest szyfrowaną platformą komunikacji typu open-source bez dostępu do sieci. Radiostacja Meshtastic tworzy sieć mesh i komunikuje się za pomocą protokołu LoRa do wysyłania i odbierania wiadomości tekstowych.</string>

Wyświetl plik

@ -50,6 +50,7 @@
<string name="modem_config_short">Малое расстояние / Быстро</string>
<string name="modem_config_medium">Среднее расстояние / Быстро</string>
<string name="modem_config_long">Большое расстояние / Быстро</string>
<string name="modem_config_mod_long">Большая дальность / Умеренная (Middle Range, instead Moderate. Moderate -> unwanted association with moderator)</string>
<string name="modem_config_very_long">Очень большое расстояние / Медленный</string>
<string name="modem_config_unrecognized">НЕРАСПОЗНАНО</string>
<string name="meshtastic_service_notifications">Служебные уведомления</string>
@ -117,6 +118,7 @@
<string name="resend">Отправить</string>
<string name="shutdown">Выключение</string>
<string name="reboot">Перезагрузить</string>
<string name="traceroute">Трассировка маршрута</string>
<string name="intro_show">Показать Введение</string>
<string name="intro_welcome">Добро пожаловать в Meshtastic</string>
<string name="intro_welcome_text">Meshtastic - это открытая, автономная, зашифрованная коммуникационная платформа. Радиостанции Meshtastic образуют ячеистую сеть и общаются с использованием протокола LoRa для отправки текстовых сообщений.</string>

Wyświetl plik

@ -2,31 +2,31 @@
<resources>
<string name="channel_name">頻道名稱</string>
<string name="channel_options">頻道選項</string>
<string name="qr_code">QR </string>
<string name="qr_code">QR </string>
<string name="unset">取消設定</string>
<string name="connection_status">連線狀態</string>
<string name="application_icon">應用程式圖示</string>
<string name="unknown_username">未知的使用者名</string>
<string name="send"></string>
<string name="send_text">傳送訊</string>
<string name="warning_not_paired">您尚未將手機與 Meshtastic 相容的裝置配對。請先配對裝置並設置您的使用者名稱。\n\n此開源應用程式仍在開發中如有問題請在我們的論壇 meshtastic.discourse.group 上面發文詢問。\n\n 也可參閱我們的網頁 - www.meshtastic.org。</string>
<string name="unknown_username">未知的使用者名</string>
<string name="send"></string>
<string name="send_text">传送讯</string>
<string name="warning_not_paired">您尚未将手机与 Meshtastic 兼容的装置配对。请先配对装置并设置您的用户名称。\n\n此开源应用程序仍在开发中如有问题请在我们的论坛 meshtastic.discourse.group 上面发文询问。\n\n 也可参阅我们的网页 - www.meshtastic.org。</string>
<string name="you"></string>
<string name="your_name">你的名</string>
<string name="analytics_okay">匿名使用統計資訊和故障報</string>
<string name="looking_for_meshtastic_devices">正在尋找 Meshtastic 裝置...</string>
<string name="your_name">你的名</string>
<string name="analytics_okay">匿名使用统计信息和故障报</string>
<string name="looking_for_meshtastic_devices">正在寻找 Meshtastic 装置...</string>
<string name="starting_pairing">開始配對</string>
<string name="url_for_join">加入 Meshtastic 網狀網路的 URL</string>
<string name="url_for_join">加入 Meshtastic 网状网络的 URL</string>
<string name="accept">接受</string>
<string name="cancel">取消</string>
<string name="change_channel">換頻</string>
<string name="are_you_sure_channel">確定要更改頻道嗎?在您分享新的頻道設定之前,與其他節點的所有通訊都將停止。</string>
<string name="new_channel_rcvd">收到新的道 URL</string>
<string name="do_you_want_switch">您要切換到 \'%s\' 頻道嗎</string>
<string name="permission_missing">Meshtastic 需要位置權限並啟用位置信息才能通過藍牙查找新設備。找到之後可以再關閉這權限。</string>
<string name="radio_sleeping">無法更改頻道,因為無線電處於休眠狀態</string>
<string name="change_channel">换频</string>
<string name="are_you_sure_channel">确定要更改频道吗?在您分享新的频道设定之前,与其他节点的所有通讯都将停止。</string>
<string name="new_channel_rcvd">收到新的道 URL</string>
<string name="do_you_want_switch">您要切换到 \'%s\' 频道吗</string>
<string name="permission_missing">Meshtastic 需要位置权限并启用位置信息才能通过蓝牙查找新设备。找到之后可以再关闭这权限。</string>
<string name="radio_sleeping">无法更改频道,因为无线电处于休眠状态</string>
<string name="report_bug">報告BUG</string>
<string name="report_a_bug">報問題</string>
<string name="report_bug_text">確定要報告錯誤嗎?報告後,請在 meshtastic.discourse.group 上貼文,以便我們可以將報告與您發現的問題匹配。</string>
<string name="report_a_bug">报问题</string>
<string name="report_bug_text">确定要报告错误吗?报告后,请在 meshtastic.discourse.group 上贴文,以便我们可以将报告与您发现的问题匹配。</string>
<string name="report">報告</string>
<string name="not_paired_yet">您還沒有配對.</string>
<string name="change_radio">換設備</string>
@ -36,17 +36,17 @@
<string name="share">分享</string>
<string name="disconnected">已中斷連線</string>
<string name="device_sleeping">設備休眠中</string>
<string name="connected_count">連接:%1$s 的 %2$s 上線</string>
<string name="connected_count">连接:%1$s 的 %2$s 上线</string>
<string name="list_of_nodes">網絡中的節點列表</string>
<string name="update_firmware">更新韌體</string>
<string name="connected">連接到設備</string>
<string name="connected_to">連接至設備 (%s)</string>
<string name="not_connected">尚未連線</string>
<string name="connected_sleeping">連接至設備,但設備正在休眠中</string>
<string name="connected_to">连接至设备 (%s)</string>
<string name="not_connected">尚未联机</string>
<string name="connected_sleeping">连接至设备,但设备正在休眠中</string>
<string name="update_to">更新到%s</string>
<string name="app_too_old">需要更新應用程式</string>
<string name="must_update">您必須在應用商店(或 Github上更新此應用程式。程式太舊了以至於無法與此裝置進行通訊。 請閱讀有關此主題<a href="https://meshtastic.org/docs/software/android/android-installation">文件</a></string>
<string name="none"> (停用)</string>
<string name="app_too_old">需要更新应用程序</string>
<string name="must_update">您必须在应用商店(或 Github上更新此应用程序。程序太旧了以至于无法与此装置进行通讯。 请阅读有关此主题<a href="https://meshtastic.org/docs/software/android/android-installation">文件</a></string>
<string name="none"> (停用)</string>
<string name="modem_config_short">短距離(速度快)</string>
<string name="modem_config_medium">中等距离(速度快)</string>
<string name="modem_config_long">长距离(速度快)</string>
@ -69,42 +69,42 @@
<string name="message_delivery_status">消息传递状态</string>
<string name="meshtastic_messages_notifications">消息通知</string>
<string name="protocol_stress_test">協定壓力測試</string>
<string name="firmware_too_old">需要韌體更新</string>
<string name="firmware_old">裝置韌體版本過舊,無法與此應用程式通訊。請前往設定面板並選擇「更新韌體」。欲了解更多信息,請參閱 Github 上的<a href="https://github.com/meshtastic/Meshtastic-device#firmware-installation">韌體安裝指南</a></string>
<string name="firmware_too_old">需要韧体更新</string>
<string name="firmware_old">版本过旧,无法与此应用程序通讯。请前往设定面板并选择「更新韧体」。欲了解更多信息,请参阅 Github 上的<a href="https://github.com/meshtastic/Meshtastic-device#firmware-installation">韧体安装指南</a></string>
<string name="okay">好的</string>
<string name="must_set_region">您必須先選擇一個地區</string>
<string name="must_set_region">您必须先选择一个地区</string>
<string name="region">地區</string>
<string name="cant_change_no_radio">無法更改頻道,因為裝置尚未連接。請再試一次。</string>
<string name="cant_change_no_radio">无法更改频道,因为装置尚未连接。请再试一次。</string>
<string name="save_messages">匯出 範圍測試.csv</string>
<string name="reset">重置</string>
<string name="scan"></string>
<string name="are_you_sure_change_default">您是否確定要改回預設頻道?</string>
<string name="reset_to_defaults">復預設設</string>
<string name="scan"></string>
<string name="are_you_sure_change_default">您是否确定要改回默认频道?</string>
<string name="reset_to_defaults">复预设设</string>
<string name="apply">申请</string>
<string name="no_app_found">找不到可用於發送 URL 的應用程式</string>
<string name="no_app_found">找不到可用于发送 URL 的应用程序</string>
<string name="theme">主題</string>
<string name="theme_light"></string>
<string name="theme_light"></string>
<string name="theme_dark">深色</string>
<string name="theme_system">系統預設</string>
<string name="choose_theme">選擇主題</string>
<string name="background_required">背景定位</string>
<string name="why_background_required">使用此功能,您必須授予「一直允許」的位置權限選項。\n這允許 Meshtastic 讀取您的智慧手機位置,並在應用程式關閉或未使用時,將位置發送給網狀網絡中的其他成員</string>
<string name="required_permissions">所需限:</string>
<string name="why_background_required">使用此功能,您必须授予「一直允许」的位置权限选项。\n这允许 Meshtastic 读取您的智能手机位置,并在应用程序关闭或未使用时,将位置发送给网状网络中的其他成员</string>
<string name="required_permissions">所需限:</string>
<string name="provide_location_to_mesh">提供網格的位置</string>
<string name="camera_required">機權</string>
<string name="why_camera_required">們必須獲得訪問攝像頭的權限才能讀取 QR 碼。不會保存任何圖片或影片。</string>
<string name="camera_required">机权</string>
<string name="why_camera_required">们必须获得访问摄像头的权限才能读取 QR 码。不会保存任何图片或影片。</string>
<string name="modem_config_slow_short">短距離(速度慢)</string>
<string name="modem_config_slow_medium">中等距離(速度慢)</string>
<plurals name="delete_messages">
<item quantity="other">刪除 %s 訊息?</item>
<item quantity="other">删除 %s 讯息?</item>
</plurals>
<string name="delete"></string>
<string name="delete_for_everyone">從所有人的聊天紀錄中刪</string>
<string name="delete"></string>
<string name="delete_for_everyone">从所有人的聊天纪录中删</string>
<string name="delete_for_me">從我的聊天紀錄中刪除</string>
<string name="select_all">選擇全部</string>
<string name="modem_config_slow_long">長距離(速度慢)</string>
<string name="map_style_selection">樣式選擇</string>
<string name="map_download_region">載區</string>
<string name="modem_config_slow_long">长距离(速度慢)</string>
<string name="map_style_selection">样式选择</string>
<string name="map_download_region">载区</string>
<string name="name">名稱</string>
<string name="description">描述說明</string>
<string name="locked">鎖定</string>
@ -116,50 +116,50 @@
<string name="reboot">重新開機</string>
<string name="intro_show">顯示介紹指南</string>
<string name="intro_welcome">歡迎來到 Meshtastic</string>
<string name="intro_welcome_text">Meshtastic 是一個開源、離網、加密的通訊平台。Meshtastic 無線電組成網狀網絡,使用 LoRa 協議傳送文字訊息進行通訊</string>
<string name="intro_welcome_text">Meshtastic 是一个开源、离网、加密的通讯平台。Meshtastic 无线电组成网状网络,使用 LoRa 协议传送文字讯息进行通讯</string>
<string name="intro_started">…讓我們開始吧!</string>
<string name="intro_started_text">使用藍牙、序列埠或 WiFi 連接您的 Meshtastic 設備。 \n\n您可以在 www.meshtastic.org/docs/hardware 查看兼容的設備</string>
<string name="intro_encryption">"設置加密"</string>
<string name="intro_encryption_text">為標準,設置了默認加密密鑰。 要啟用您自己的頻道和增強加密,請轉到頻道選項卡並更改頻道名稱,這將為 AES256 加密設置一個隨機密鑰。 \n\n要與其他設備通信他們需要掃描您的二維碼或點擊共享鏈接來配置頻道設置。</string>
<string name="message">息:</string>
<string name="quick_chat">快速聊天選項</string>
<string name="intro_encryption_text">为标准,设置了默认加密密钥。 要启用您自己的频道和增强加密,请转到频道选项卡并更改频道名称,这将为 AES256 加密设置一个随机密钥。 \n\n要与其他设备通信他们需要扫描您的二维码或点击共享链接来配置频道设置。</string>
<string name="message">息:</string>
<string name="quick_chat">快速聊天选项</string>
<string name="quick_chat_new">新的快速聊天</string>
<string name="quick_chat_edit">編輯快速聊天</string>
<string name="quick_chat_append">附加到</string>
<string name="quick_chat_instant">即時發</string>
<string name="quick_chat_edit">编辑快速聊天</string>
<string name="quick_chat_append">附加到</string>
<string name="quick_chat_instant">实时发</string>
<string name="warning_default_psk">空频道名称使用默认的加密密钥(%s上的任何设备都可以读取你的信息</string>
<string name="factory_reset">復出廠設</string>
<string name="factory_reset_description">這將清除你已經完成的所有設備設定。</string>
<string name="bluetooth_disabled">牙已禁用</string>
<string name="permission_missing_31">Meshtastic 需要附近設備許可才能通過藍牙查找和連接設備。 您可以在不使用時將其關閉</string>
<string name="direct_message">直接發訊</string>
<string name="nodedb_reset">節點數據庫重置</string>
<string name="nodedb_reset_description">這將從該列表中清除所有節點</string>
<string name="map_select_download_region">選擇下載地區</string>
<string name="factory_reset">复出厂设</string>
<string name="factory_reset_description">这将清除你已经完成的所有设备设定。</string>
<string name="bluetooth_disabled">牙已禁用</string>
<string name="permission_missing_31">Meshtastic 需要附近设备许可才能通过蓝牙查找和连接设备。 您可以在不使用时将其关闭</string>
<string name="direct_message">直接发讯</string>
<string name="nodedb_reset">节点数据库重置</string>
<string name="nodedb_reset_description">这将从该列表中清除所有节点</string>
<string name="map_select_download_region">选择下载地区</string>
<string name="map_5_miles">5英里</string>
<string name="map_10_miles">10英里</string>
<string name="map_15_miles">15英里</string>
<string name="map_tile_download_estimate">磚下載估計:</string>
<string name="map_start_download">開始下載</string>
<string name="map_tile_download_estimate">砖下载估计:</string>
<string name="map_start_download">开始下载</string>
<string name="request_position">要求位置</string>
<string name="close">關閉</string>
<string name="device_settings">設備設</string>
<string name="module_settings">組設</string>
<string name="close">关闭</string>
<string name="device_settings">设备设</string>
<string name="module_settings">块设</string>
<string name="add">新增</string>
<string name="calculating">正在算……</string>
<string name="map_offline_manager">離線管理</string>
<string name="map_cache_size">當前緩存大小</string>
<string name="map_cache_info">緩存容量: %1$.2f MB\n緩存使用: %2$.2f MB</string>
<string name="map_clear_tiles">清除下載的圖磚</string>
<string name="map_tile_source">圖磚來</string>
<string name="calculating">正在算……</string>
<string name="map_offline_manager">脱机管理</string>
<string name="map_cache_size">当前缓存大小</string>
<string name="map_cache_info">缓存容量: %1$.2f MB\n缓存使用: %2$.2f MB</string>
<string name="map_clear_tiles">清除下载的图砖</string>
<string name="map_tile_source">图砖来</string>
<string name="map_purge_success">清除 %s 的 SQL 快取</string>
<string name="map_purge_fail">清除 SQL 快取失敗,請查看 logcat 紀錄</string>
<string name="map_cache_manager">緩存管理員</string>
<string name="map_download_complete">已完成!</string>
<string name="map_download_errors">載完成,但有 %s 個錯誤</string>
<string name="map_cache_tiles">%s 圖磚</string>
<string name="map_subDescription">方位:%1$s° 距%2$s</string>
<string name="waypoint_edit">編輯航點</string>
<string name="waypoint_delete">刪除航點?</string>
<string name="waypoint_new">新建航</string>
<string name="map_purge_fail">清除 SQL 快取失败,请查看 logcat 纪录</string>
<string name="map_cache_manager">缓存管理员</string>
<string name="map_download_complete">已完成!</string>
<string name="map_download_errors">载完成,但有 %s 个错误</string>
<string name="map_cache_tiles">%s 图砖</string>
<string name="map_subDescription">方位:%1$s° 距%2$s</string>
<string name="waypoint_edit">编辑航点</string>
<string name="waypoint_delete">删除航点?</string>
<string name="waypoint_new">新建航</string>
</resources>

Wyświetl plik

@ -61,6 +61,7 @@
<string name="modem_config_short">Short Range / Fast</string>
<string name="modem_config_medium">Medium Range / Fast</string>
<string name="modem_config_long">Long Range / Fast</string>
<string name="modem_config_mod_long">Long Range / Moderate</string>
<string name="modem_config_very_long">Very Long Range / Slow</string>
<string name="modem_config_unrecognized">UNRECOGNIZED</string>
<string name="meshtastic_service_notifications">Service notifications</string>
@ -126,6 +127,7 @@
<string name="resend">Resend</string>
<string name="shutdown">Shutdown</string>
<string name="reboot">Reboot</string>
<string name="traceroute">Traceroute</string>
<string name="intro_show">Show Introduction</string>
<string name="intro_welcome">Welcome to Meshtastic</string>
<string name="intro_welcome_text">Meshtastic is an open-source, off-grid, encrypted communication platform. The Meshtastic radios form a mesh network and communicate using the LoRa protocol to send text messages.</string>
@ -155,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.2'
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