Merge pull request #4234 from TeamAmaze/bugfix/sftpconnectdialog-select-pem

SftpConnectDialog: Wrap open DocumentsUI intent with Intent.createChooser() to get PEM content
This commit is contained in:
Vishal Nehra
2024-08-20 15:22:29 +05:30
committed by GitHub
4 changed files with 279 additions and 240 deletions
@@ -86,9 +86,7 @@ import java.util.concurrent.Callable
/** SSH/SFTP connection setup dialog. */
class SftpConnectDialog : DialogFragment() {
companion object {
@JvmStatic
private val log: Logger = LoggerFactory.getLogger(SftpConnectDialog::class.java)
@@ -139,29 +137,33 @@ class SftpConnectDialog : DialogFragment() {
val accentColor = (activity as ThemedActivity).accent
// Use system provided action to get Uri to PEM.
// Use system provided action to get Uri to PEM (mostly via DocumentsUI).
binding.selectPemBTN.setOnClickListener {
val intent = Intent()
.setType(MimeTypes.ALL_MIME_TYPES)
.setAction(Intent.ACTION_GET_CONTENT)
activityResultHandlerForPemSelection.launch(intent)
val intent =
Intent()
.setType(MimeTypes.ALL_MIME_TYPES)
.setAction(Intent.ACTION_GET_CONTENT)
activityResultHandlerForPemSelection.launch(
Intent.createChooser(intent, getString(R.string.ssh_select_pem))
)
}
// Define action for buttons
val dialogBuilder = MaterialDialog.Builder(ctx.get()!!)
.title(R.string.scp_connection)
.autoDismiss(false)
.customView(binding.root, true)
.theme(utilsProvider.appTheme.getMaterialDialogTheme())
.negativeText(R.string.cancel)
.positiveText(if (edit) R.string.update else R.string.create)
.positiveColor(accentColor)
.negativeColor(accentColor)
.neutralColor(accentColor)
.onPositive(handleOnPositiveButton(edit))
.onNegative { dialog: MaterialDialog, _: DialogAction? ->
dialog.dismiss()
}
val dialogBuilder =
MaterialDialog.Builder(ctx.get()!!)
.title(R.string.scp_connection)
.autoDismiss(false)
.customView(binding.root, true)
.theme(utilsProvider.appTheme.getMaterialDialogTheme())
.negativeText(R.string.cancel)
.positiveText(if (edit) R.string.update else R.string.create)
.positiveColor(accentColor)
.negativeColor(accentColor)
.neutralColor(accentColor)
.onPositive(handleOnPositiveButton(edit))
.onNegative { dialog: MaterialDialog, _: DialogAction? ->
dialog.dismiss()
}
// If we are editing connection settings, give new actions for neutral and negative buttons
if (edit) {
@@ -181,113 +183,122 @@ class SftpConnectDialog : DialogFragment() {
return dialog
}
private fun initForm(edit: Boolean) = binding.run {
portET.apply {
filters = arrayOf(MinMaxInputFilter(VALID_PORT_RANGE))
// For convenience, so I don't need to press backspace all the time
onFocusChangeListener = View.OnFocusChangeListener { _: View?, hasFocus: Boolean ->
if (hasFocus) {
selectAll()
}
}
}
protocolDropDown.adapter = ArrayAdapter(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
requireContext().resources.getStringArray(R.array.ftpProtocols)
)
chkFtpAnonymous.setOnCheckedChangeListener { _, isChecked ->
usernameET.isEnabled = !isChecked
passwordET.isEnabled = !isChecked
if (isChecked) {
usernameET.setText("")
passwordET.setText("")
}
}
defaultPathET.filters = arrayOf(defaultPathCharFilter)
// If it's new connection setup, set some default values
// Otherwise, use given Bundle instance for filling in the blanks
if (!edit) {
connectionET.setText(R.string.scp_connection)
portET.setText(NetCopyClientConnectionPool.SSH_DEFAULT_PORT.toString())
protocolDropDown.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long
) {
portET.setText(
when (position) {
1 -> NetCopyClientConnectionPool.FTP_DEFAULT_PORT.toString()
2 -> NetCopyClientConnectionPool.FTPS_DEFAULT_PORT.toString()
else -> NetCopyClientConnectionPool.SSH_DEFAULT_PORT.toString()
@Suppress("ComplexMethod", "LongMethod")
private fun initForm(edit: Boolean) =
binding.run {
portET.apply {
filters = arrayOf(MinMaxInputFilter(VALID_PORT_RANGE))
// For convenience, so I don't need to press backspace all the time
onFocusChangeListener =
View.OnFocusChangeListener { _: View?, hasFocus: Boolean ->
if (hasFocus) {
selectAll()
}
)
chkFtpAnonymous.visibility = when (position) {
0 -> View.GONE
else -> View.VISIBLE
}
if (position == 0) {
chkFtpAnonymous.isChecked = false
}
selectPemBTN.visibility = when (position) {
0 -> View.VISIBLE
else -> View.GONE
}
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
} else {
protocolDropDown.setSelection(
when (requireArguments().getString(ARG_PROTOCOL)) {
FTP_URI_PREFIX -> 1
FTPS_URI_PREFIX -> 2
else -> 0
protocolDropDown.adapter =
ArrayAdapter(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
requireContext().resources.getStringArray(R.array.ftpProtocols)
)
chkFtpAnonymous.setOnCheckedChangeListener { _, isChecked ->
usernameET.isEnabled = !isChecked
passwordET.isEnabled = !isChecked
if (isChecked) {
usernameET.setText("")
passwordET.setText("")
}
)
connectionET.setText(requireArguments().getString(ARG_NAME))
ipET.setText(requireArguments().getString(ARG_ADDRESS))
portET.setText(requireArguments().getInt(ARG_PORT).toString())
defaultPathET.setText(requireArguments().getString(ARG_DEFAULT_PATH))
usernameET.setText(requireArguments().getString(ARG_USERNAME))
if (requireArguments().getBoolean(ARG_HAS_PASSWORD)) {
passwordET.setHint(R.string.password_unchanged)
} else {
selectedParsedKeyPairName = requireArguments().getString(ARG_KEYPAIR_NAME)
selectPemBTN.text = selectedParsedKeyPairName
}
oldPath = NetCopyClientUtils.deriveUriFrom(
requireArguments().getString(ARG_PROTOCOL)!!,
requireArguments().getString(ARG_ADDRESS)!!,
requireArguments().getInt(ARG_PORT),
requireArguments().getString(ARG_DEFAULT_PATH, ""),
requireArguments().getString(ARG_USERNAME)!!,
requireArguments().getString(ARG_PASSWORD),
edit
)
}
}
defaultPathET.filters = arrayOf(defaultPathCharFilter)
// If it's new connection setup, set some default values
// Otherwise, use given Bundle instance for filling in the blanks
if (!edit) {
connectionET.setText(R.string.scp_connection)
portET.setText(NetCopyClientConnectionPool.SSH_DEFAULT_PORT.toString())
protocolDropDown.onItemSelectedListener =
object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long
) {
portET.setText(
when (position) {
1 -> NetCopyClientConnectionPool.FTP_DEFAULT_PORT.toString()
2 -> NetCopyClientConnectionPool.FTPS_DEFAULT_PORT.toString()
else -> NetCopyClientConnectionPool.SSH_DEFAULT_PORT.toString()
}
)
chkFtpAnonymous.visibility =
when (position) {
0 -> View.GONE
else -> View.VISIBLE
}
if (position == 0) {
chkFtpAnonymous.isChecked = false
}
selectPemBTN.visibility =
when (position) {
0 -> View.VISIBLE
else -> View.GONE
}
}
private fun appendButtonListenersForEdit(
dialogBuilder: MaterialDialog.Builder
) {
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
} else {
protocolDropDown.setSelection(
when (requireArguments().getString(ARG_PROTOCOL)) {
FTP_URI_PREFIX -> 1
FTPS_URI_PREFIX -> 2
else -> 0
}
)
connectionET.setText(requireArguments().getString(ARG_NAME))
ipET.setText(requireArguments().getString(ARG_ADDRESS))
portET.setText(requireArguments().getInt(ARG_PORT).toString())
defaultPathET.setText(requireArguments().getString(ARG_DEFAULT_PATH))
usernameET.setText(requireArguments().getString(ARG_USERNAME))
if (requireArguments().getBoolean(ARG_HAS_PASSWORD)) {
passwordET.setHint(R.string.password_unchanged)
} else {
selectedParsedKeyPairName = requireArguments().getString(ARG_KEYPAIR_NAME)
selectPemBTN.text = selectedParsedKeyPairName
}
oldPath =
NetCopyClientUtils.deriveUriFrom(
requireArguments().getString(ARG_PROTOCOL)!!,
requireArguments().getString(ARG_ADDRESS)!!,
requireArguments().getInt(ARG_PORT),
requireArguments().getString(ARG_DEFAULT_PATH, ""),
requireArguments().getString(ARG_USERNAME)!!,
requireArguments().getString(ARG_PASSWORD),
edit
)
}
}
private fun appendButtonListenersForEdit(dialogBuilder: MaterialDialog.Builder) {
createConnectionSettings(edit = true).run {
dialogBuilder
.negativeText(R.string.delete)
.onNegative { dialog: MaterialDialog, _: DialogAction? ->
val path = NetCopyClientUtils.deriveUriFrom(
getProtocolPrefixFromDropdownSelection(),
hostname,
port,
defaultPath,
username,
requireArguments().getString(ARG_PASSWORD, null),
edit = true
)
val i = DataUtils.getInstance().containsServer(
arrayOf(connectionName, path)
)
val path =
NetCopyClientUtils.deriveUriFrom(
getProtocolPrefixFromDropdownSelection(),
hostname,
port,
defaultPath,
username,
requireArguments().getString(ARG_PASSWORD, null),
edit = true
)
val i =
DataUtils.getInstance().containsServer(
arrayOf(connectionName, path)
)
if (i > -1) {
DataUtils.getInstance().removeServer(i)
AppConfig.getInstance()
@@ -311,26 +322,32 @@ class SftpConnectDialog : DialogFragment() {
}
}
private fun createValidator(edit: Boolean, okBTN: MDButton): SimpleTextWatcher {
private fun createValidator(
edit: Boolean,
okBTN: MDButton
): SimpleTextWatcher {
return object : SimpleTextWatcher() {
override fun afterTextChanged(s: Editable) {
val portETValue = binding.portET.text.toString()
val port = if (portETValue.isDigitsOnly() && (portETValue.length in 1..5)) {
portETValue.toInt()
} else {
-1
}
val hasCredential: Boolean = if (edit) {
if (true == binding.passwordET.text?.isNotEmpty() ||
!TextUtils.isEmpty(requireArguments().getString(ARG_PASSWORD))
) {
true
val port =
if (portETValue.isDigitsOnly() && (portETValue.length in 1..5)) {
portETValue.toInt()
} else {
true == selectedParsedKeyPairName?.isNotEmpty()
-1
}
val hasCredential: Boolean =
if (edit) {
if (true == binding.passwordET.text?.isNotEmpty() ||
!TextUtils.isEmpty(requireArguments().getString(ARG_PASSWORD))
) {
true
} else {
true == selectedParsedKeyPairName?.isNotEmpty()
}
} else {
true == binding.passwordET.text?.isNotEmpty() ||
selectedParsedKeyPair != null
}
} else {
true == binding.passwordET.text?.isNotEmpty() || selectedParsedKeyPair != null
}
okBTN.isEnabled = (
true == binding.connectionET.text?.isNotEmpty() &&
true == binding.ipET.text?.isNotEmpty() &&
@@ -345,8 +362,7 @@ class SftpConnectDialog : DialogFragment() {
}
}
private fun handleOnPositiveButton(edit: Boolean):
MaterialDialog.SingleButtonCallback =
private fun handleOnPositiveButton(edit: Boolean): MaterialDialog.SingleButtonCallback =
MaterialDialog.SingleButtonCallback { _, _ ->
createConnectionSettings(edit).run {
when (prefix) {
@@ -356,7 +372,10 @@ class SftpConnectDialog : DialogFragment() {
}
}
private fun positiveButtonForFtp(connectionSettings: ConnectionSettings, edit: Boolean) {
private fun positiveButtonForFtp(
connectionSettings: ConnectionSettings,
edit: Boolean
) {
connectionSettings.run {
authenticateAndSaveSetup(connectionSettings = connectionSettings, isEdit = edit)
}
@@ -365,7 +384,10 @@ class SftpConnectDialog : DialogFragment() {
/*
* for SSH and FTPS, get host's cert/public key fingerprint.
*/
private fun positiveButtonForSftp(connectionSettings: ConnectionSettings, edit: Boolean) {
private fun positiveButtonForSftp(
connectionSettings: ConnectionSettings,
edit: Boolean
) {
connectionSettings.run {
// Get original SSH host key
AppConfig.getInstance().utilsHandler.getRemoteHostKey(
@@ -412,7 +434,8 @@ class SftpConnectDialog : DialogFragment() {
hostAndPort,
hostKeyAlgorithm,
hostKeyFingerprint,
hostInfo ->
hostInfo
->
AlertDialog.Builder(ctx.get())
.setTitle(R.string.ssh_host_key_verification_prompt_title)
.setMessage(
@@ -584,73 +607,77 @@ class SftpConnectDialog : DialogFragment() {
}
@Suppress("LabeledExpression")
private val activityResultHandlerForPemSelection = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (Activity.RESULT_OK == it.resultCode) {
it.data?.data?.run {
selectedPem = this
runCatching {
requireContext().contentResolver.openInputStream(this)?.let {
selectedKeyContent ->
val observable = PemToKeyPairObservable(selectedKeyContent)
create(observable).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.retryWhen { exceptions ->
exceptions.flatMap { exception ->
create<Any> { subscriber ->
observable.displayPassphraseDialog(exception, {
subscriber.onNext(Unit)
}, {
subscriber.onError(exception)
})
private val activityResultHandlerForPemSelection =
registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (Activity.RESULT_OK == it.resultCode) {
it.data?.data?.run {
selectedPem = this
runCatching {
requireContext().contentResolver.openInputStream(this)?.let {
selectedKeyContent ->
val observable = PemToKeyPairObservable(selectedKeyContent)
create(observable).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.retryWhen { exceptions ->
exceptions.flatMap { exception ->
create<Any> { subscriber ->
observable.displayPassphraseDialog(exception, {
subscriber.onNext(Unit)
}, {
subscriber.onError(exception)
})
}
}
}
}
.subscribe({ result ->
selectedParsedKeyPair = result
selectedParsedKeyPairName = this
.lastPathSegment!!
.substring(
this.lastPathSegment!!
.indexOf('/') + 1
)
val okBTN = (dialog as MaterialDialog)
.getActionButton(DialogAction.POSITIVE)
okBTN.isEnabled = okBTN.isEnabled || true
binding.selectPemBTN.text = selectedParsedKeyPairName
}, {})
.subscribe({ result ->
selectedParsedKeyPair = result
selectedParsedKeyPairName =
this
.lastPathSegment!!
.substring(
this.lastPathSegment!!
.indexOf('/') + 1
)
val okBTN =
(dialog as MaterialDialog)
.getActionButton(DialogAction.POSITIVE)
okBTN.isEnabled = okBTN.isEnabled || true
binding.selectPemBTN.text = selectedParsedKeyPairName
}, {})
}
}.onFailure {
log.error("Error reading PEM key", it)
}
}.onFailure {
log.error("Error reading PEM key", it)
}
}
}
}
private fun authenticateAndSaveSetup(
connectionSettings: ConnectionSettings,
hostKeyFingerprint: String? = null,
isEdit: Boolean
): Boolean = connectionSettings.run {
val path = this.toUriString()
val encryptedPath = NetCopyClientUtils.encryptFtpPathAsNecessary(path)
return if (!isEdit) {
saveFtpConnectionAndLoadlist(
connectionSettings,
hostKeyFingerprint,
encryptedPath,
selectedParsedKeyPairName,
selectedParsedKeyPair
)
} else {
updateFtpConnection(
connectionName,
hostKeyFingerprint,
encryptedPath
)
): Boolean =
connectionSettings.run {
val path = this.toUriString()
val encryptedPath = NetCopyClientUtils.encryptFtpPathAsNecessary(path)
return if (!isEdit) {
saveFtpConnectionAndLoadlist(
connectionSettings,
hostKeyFingerprint,
encryptedPath,
selectedParsedKeyPairName,
selectedParsedKeyPair
)
} else {
updateFtpConnection(
connectionName,
hostKeyFingerprint,
encryptedPath
)
}
}
}
@Suppress("LongParameterList")
private fun saveFtpConnectionAndLoadlist(
@@ -746,13 +773,14 @@ class SftpConnectDialog : DialogFragment() {
}
// Read the PEM content from InputStream to String.
private fun getPemContents(): String? = selectedPem?.run {
runCatching {
requireContext().contentResolver.openInputStream(this)
?.bufferedReader()
?.use(BufferedReader::readText)
}.getOrNull()
}
private fun getPemContents(): String? =
selectedPem?.run {
runCatching {
requireContext().contentResolver.openInputStream(this)
?.bufferedReader()
?.use(BufferedReader::readText)
}.getOrNull()
}
private fun getProtocolPrefixFromDropdownSelection(): String {
return when (binding.protocolDropDown.selectedItem.toString()) {
@@ -773,14 +801,15 @@ class SftpConnectDialog : DialogFragment() {
val selectedParsedKeyPairName: String? = null,
val selectedParsedKeyPair: KeyPair? = null
) {
fun toUriString() = NetCopyClientUtils.deriveUriFrom(
prefix,
hostname,
port,
defaultPath,
username,
password
)
fun toUriString() =
NetCopyClientUtils.deriveUriFrom(
prefix,
hostname,
port,
defaultPath,
username,
password
)
}
// FIXME: username/password may not need urlEncoded during edit mode
@@ -789,7 +818,8 @@ class SftpConnectDialog : DialogFragment() {
prefix = getProtocolPrefixFromDropdownSelection(),
connectionName = binding.connectionET.text.toString(),
hostname = binding.ipET.text.toString(),
port = binding.portET.text.toString().let {
port =
binding.portET.text.toString().let {
if (it.isEmpty() || it.isBlank()) {
SSH_DEFAULT_PORT
} else {
@@ -798,7 +828,8 @@ class SftpConnectDialog : DialogFragment() {
},
defaultPath = binding.defaultPathET.text.toString(),
username = binding.usernameET.text.toString().urlEncoded(),
password = if (true == binding.passwordET.text?.isEmpty()) {
password =
if (true == binding.passwordET.text?.isEmpty()) {
if (edit) {
requireArguments().getString(ARG_PASSWORD, null)?.run {
PasswordUtil.decryptPassword(AppConfig.getInstance(), this)
@@ -48,7 +48,6 @@ import java.util.concurrent.TimeUnit
@Suppress("StringLiteralDuplication")
class SftpConnectDialogTest : AbstractMainActivityTestBase() {
private lateinit var mc: MockedConstruction<SftpConnectDialog>
/**
@@ -57,12 +56,13 @@ class SftpConnectDialogTest : AbstractMainActivityTestBase() {
@Before
override fun setUp() {
super.setUp()
mc = mockConstruction(
SftpConnectDialog::class.java
) { mock: SftpConnectDialog, _: MockedConstruction.Context? ->
doCallRealMethod().`when`(mock).arguments = any()
`when`(mock.arguments).thenCallRealMethod()
}
mc =
mockConstruction(
SftpConnectDialog::class.java
) { mock: SftpConnectDialog, _: MockedConstruction.Context? ->
doCallRealMethod().`when`(mock).arguments = any()
`when`(mock.arguments).thenCallRealMethod()
}
}
/**
@@ -116,9 +116,10 @@ class SftpConnectDialogTest : AbstractMainActivityTestBase() {
*/
@Test
fun testInvokeSftpConnectionDialogWithPasswordAndDefaultPath() {
val uri = NetCopyClientUtils.encryptFtpPathAsNecessary(
"ssh://root:12345678@127.0.0.1:22/data/incoming"
)
val uri =
NetCopyClientUtils.encryptFtpPathAsNecessary(
"ssh://root:12345678@127.0.0.1:22/data/incoming"
)
val verify = Bundle()
verify.putString("address", "127.0.0.1")
verify.putInt("port", 22)
@@ -138,13 +139,15 @@ class SftpConnectDialogTest : AbstractMainActivityTestBase() {
/**
* Test invoke [SftpConnectDialog] with arguments including password and URL encoded path.
*/
@Suppress("ktlint:standard:max-line-length", "ktlint:standard:no-single-line-block-comment")
@Test
@Throws(GeneralSecurityException::class, IOException::class)
fun testInvokeSftpConnectionDialogWithPasswordAndEncodedDefaultPath() {
/* ktlint-disable max-line-length */
val uri = NetCopyClientUtils.encryptFtpPathAsNecessary(
"ssh://root:12345678@127.0.0.1:22/Users/TranceLove/My+Documents/%7BReference%7D%20Zobius%20Facro%20%24%24%20%23RFII1"
)
val uri =
NetCopyClientUtils.encryptFtpPathAsNecessary(
"ssh://root:12345678@127.0.0.1:22/Users/TranceLove/My+Documents/%7BReference%7D%20Zobius%20Facro%20%24%24%20%23RFII1"
)
/* ktlint-enable max-line-length */
val verify = Bundle()
verify.putString("address", "127.0.0.1")
@@ -166,7 +169,10 @@ class SftpConnectDialogTest : AbstractMainActivityTestBase() {
}
@Throws(GeneralSecurityException::class, IOException::class)
private fun testOpenSftpConnectDialog(uri: String, verify: Bundle) {
private fun testOpenSftpConnectDialog(
uri: String,
verify: Bundle
) {
val activity = mock(MainActivity::class.java)
val utilsHandler = mock(UtilsHandler::class.java)
`when`(utilsHandler.getSshAuthPrivateKeyName("ssh://root@127.0.0.1:22"))
@@ -186,15 +192,15 @@ class SftpConnectDialogTest : AbstractMainActivityTestBase() {
val mocked = mc.constructed()[0]
await().atMost(10, TimeUnit.SECONDS).until { mocked.arguments != null }
for (key in BUNDLE_KEYS) {
if (mocked.arguments!![key] != null) {
if (mocked.arguments?.getString(key) != null) {
if (key != "password") {
assertEquals(verify[key], mocked.arguments!![key])
assertEquals(verify.getString(key), mocked.arguments?.getString(key))
} else {
assertEquals(
verify[key],
verify.getString(key),
PasswordUtil.decryptPassword(
ApplicationProvider.getApplicationContext(),
(mocked.arguments!![key] as String?)!!,
requireNotNull(mocked.arguments?.getString(key)),
Base64.URL_SAFE
)
)
@@ -205,15 +211,16 @@ class SftpConnectDialogTest : AbstractMainActivityTestBase() {
companion object {
@JvmStatic
private val BUNDLE_KEYS = arrayOf(
"address",
"port",
"keypairName",
"name",
"username",
"password",
"edit",
"defaultPath"
)
private val BUNDLE_KEYS =
arrayOf(
"address",
"port",
"keypairName",
"name",
"username",
"password",
"edit",
"defaultPath"
)
}
}
+2 -1
View File
@@ -31,6 +31,7 @@ allprojects {
mavenCentral()
maven { url "https://jitpack.io" }
maven { url "https://jcenter.bintray.com" }
maven { url "https://repository.liferay.com/nexus/content/repositories/public/"}
}
tasks.withType(Test).tap {
configureEach {
@@ -149,7 +150,7 @@ subprojects {
}
}
dependencies {
compileOnly 'com.github.pengrad:jdk9-deps:1.0'
// compileOnly 'com.github.pengrad:jdk9-deps:1.0'
if (project.hasProperty('kapt')) {
kapt 'javax.xml.bind:jaxb-api:2.3.1'
+1 -1
View File
@@ -12,7 +12,7 @@ glide = "4.14.2"
sshj = "0.35.0"
jcifs = "2.1.9"
eventbus = "3.3.1"
fabSpeedDial = "3.1.1"
fabSpeedDial = "3.3.0"
room = "2.5.2"
bouncyCastle = "1.76"
awaitility = "3.1.6"