diff --git a/app/src/androidTest/java/com/geeksville/mesh/compose/ScannedQrCodeDialogTest.kt b/app/src/androidTest/java/com/geeksville/mesh/compose/ScannedQrCodeDialogTest.kt index 4219d6561..11bce1ca7 100644 --- a/app/src/androidTest/java/com/geeksville/mesh/compose/ScannedQrCodeDialogTest.kt +++ b/app/src/androidTest/java/com/geeksville/mesh/compose/ScannedQrCodeDialogTest.kt @@ -14,12 +14,10 @@ import com.geeksville.mesh.channelSettings import com.geeksville.mesh.copy import com.geeksville.mesh.model.Channel import com.geeksville.mesh.ui.components.ScannedQrCodeDialog -import com.google.protobuf.ByteString import org.junit.Assert import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.security.SecureRandom @RunWith(AndroidJUnit4::class) class ScannedQrCodeDialogTest { @@ -30,12 +28,7 @@ class ScannedQrCodeDialogTest { private fun getString(id: Int): String = InstrumentationRegistry.getInstrumentation().targetContext.getString(id) - private fun getRandomKey(): ByteString { - val random = SecureRandom() - val bytes = ByteArray(32) - random.nextBytes(bytes) - return ByteString.copyFrom(bytes) - } + private fun getRandomKey() = Channel.getRandomKey() private val channels = channelSet { settings.add(Channel.default.settings) diff --git a/app/src/main/java/com/geeksville/mesh/model/Channel.kt b/app/src/main/java/com/geeksville/mesh/model/Channel.kt index 007b81706..3ce19cdb9 100644 --- a/app/src/main/java/com/geeksville/mesh/model/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/Channel.kt @@ -6,6 +6,7 @@ import com.geeksville.mesh.ConfigKt.loRaConfig import com.geeksville.mesh.ConfigProtos import com.geeksville.mesh.channelSettings import com.google.protobuf.ByteString +import java.security.SecureRandom /** Utility function to make it easy to declare byte arrays - FIXME move someplace better */ fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() } @@ -37,6 +38,13 @@ data class Channel( txEnabled = true } ) + + fun getRandomKey(size: Int = 32): ByteString { + val bytes = ByteArray(size) + val random = SecureRandom() + random.nextBytes(bytes) + return ByteString.copyFrom(bytes) + } } /// Return the name of our channel as a human readable string. If empty string, assume "Default" per mesh.proto spec diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/EditBase64Preference.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EditBase64Preference.kt new file mode 100644 index 000000000..f419d43fa --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/EditBase64Preference.kt @@ -0,0 +1,105 @@ +package com.geeksville.mesh.ui.components + +import android.util.Base64 +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.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.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.LaunchedEffect +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.onFocusChanged +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.R +import com.geeksville.mesh.model.Channel +import com.google.protobuf.ByteString +import com.google.protobuf.kotlin.toByteString + +@Composable +fun EditBase64Preference( + title: String, + value: ByteString, + enabled: Boolean, + keyboardActions: KeyboardActions, + onValueChange: (ByteString) -> Unit, + modifier: Modifier = Modifier, +) { + fun ByteString.encodeToString() = Base64.encodeToString(this.toByteArray(), Base64.NO_WRAP) + fun String.toByteString() = Base64.decode(this, Base64.NO_WRAP).toByteString() + + var valueState by remember { mutableStateOf(value.encodeToString()) } + val isError = value.encodeToString() != valueState + + // don't update values while the user is editing + var isFocused by remember { mutableStateOf(false) } + LaunchedEffect(value) { + if (!isFocused) { + valueState = value.encodeToString() + } + } + + OutlinedTextField( + value = valueState, + onValueChange = { + valueState = it + runCatching { it.toByteString() }.onSuccess(onValueChange) + }, + modifier = modifier + .fillMaxWidth() + .onFocusChanged { focusState -> isFocused = focusState.isFocused }, + enabled = enabled, + label = { Text(text = title) }, + isError = isError, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Password, imeAction = ImeAction.Done + ), + keyboardActions = keyboardActions, + trailingIcon = { + IconButton( + onClick = { + val psk = if (isError) value else Channel.getRandomKey() + valueState = psk.encodeToString() + onValueChange(psk) + } + ) { + Icon( + imageVector = if (isError) Icons.TwoTone.Close else Icons.TwoTone.Refresh, + contentDescription = stringResource(if (isError) R.string.error else R.string.reset), + tint = if (isError) MaterialTheme.colors.error + else LocalContentColor.current.copy(alpha = LocalContentAlpha.current) + ) + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun EditBase64PreferencePreview() { + EditBase64Preference( + title = "Title", + value = Channel.getRandomKey(), + enabled = true, + keyboardActions = KeyboardActions {}, + onValueChange = {}, + modifier = Modifier.padding(16.dp) + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/EditChannelDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/EditChannelDialog.kt index 670f6cb38..649d772af 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/config/EditChannelDialog.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/EditChannelDialog.kt @@ -1,6 +1,5 @@ package com.geeksville.mesh.ui.components.config -import android.util.Base64 import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -12,17 +11,8 @@ 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.Text import androidx.compose.material.TextButton -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 @@ -40,13 +30,11 @@ import com.geeksville.mesh.R import com.geeksville.mesh.channelSettings import com.geeksville.mesh.copy import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.ui.components.EditBase64Preference import com.geeksville.mesh.ui.components.EditTextPreference import com.geeksville.mesh.ui.components.PositionPrecisionPreference import com.geeksville.mesh.ui.components.SwitchPreference import com.google.accompanist.themeadapter.appcompat.AppCompatTheme -import com.google.protobuf.ByteString -import com.google.protobuf.kotlin.toByteString -import java.security.SecureRandom @Suppress("LongMethod") @OptIn(ExperimentalLayoutApi::class) @@ -58,19 +46,7 @@ fun EditChannelDialog( modifier: Modifier = Modifier, modemPresetName: String = "Default", ) { - fun encodeToString(input: ByteString) = - Base64.encodeToString(input.toByteArray() ?: ByteArray(0), Base64.DEFAULT) - var channelInput by remember(channelSettings) { mutableStateOf(channelSettings) } - var pskString by remember(channelInput) { mutableStateOf(encodeToString(channelInput.psk)) } - val pskError = pskString != encodeToString(channelInput.psk) - - fun getRandomKey() { - val random = SecureRandom() - val bytes = ByteArray(32) - random.nextBytes(bytes) - channelInput = channelInput.copy { psk = ByteString.copyFrom(bytes) } - } AlertDialog( onDismissRequest = onDismissRequest, @@ -90,49 +66,23 @@ fun EditChannelDialog( ), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChanged = { - channelInput = channelInput.copy { name = it } - if (channelInput.psk == Channel.default.settings.psk) getRandomKey() + channelInput = channelInput.copy { + name = it + if (psk == Channel.default.settings.psk) psk = Channel.getRandomKey() + } }, onFocusChanged = { isFocused = it.isFocused }, ) - OutlinedTextField( - value = pskString, - onValueChange = { - try { - pskString = it // empty (no crypto), 128 or 256 bit only - val decoded = Base64.decode(it, Base64.DEFAULT).toByteString() - val fullPsk = Channel(channelSettings { psk = decoded }).psk - if (fullPsk.size() in setOf(0, 16, 32)) { - channelInput = channelInput.copy { psk = decoded } - } - } catch (ex: Throwable) { - // Base64 decode failed, pskError true - } - }, - modifier = modifier.fillMaxWidth(), + EditBase64Preference( + title = "PSK", + value = channelInput.psk, 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) { - channelInput = channelInput.copy { psk = channelSettings.psk } - pskString = encodeToString(channelInput.psk) - } 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) - ) + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChange = { + val fullPsk = Channel(channelSettings { psk = it }).psk + if (fullPsk.size() in setOf(0, 16, 32)) { + channelInput = channelInput.copy { psk = it } } }, ) @@ -189,7 +139,7 @@ fun EditChannelDialog( onClick = { onAddClick(channelInput.copy { name = channelInput.name.trim() }) }, - enabled = !pskError, + enabled = true, ) { Text(stringResource(R.string.save)) } } }