From 184c87d7883ce476de343886065b0201ef5cf435 Mon Sep 17 00:00:00 2001 From: Dmitry Borodin <11879032+Dmitry-Borodin@users.noreply.github.com> Date: Wed, 2 Aug 2023 16:27:54 -0500 Subject: [PATCH] feat: Android dynamic derivations support (#1943) * started wotk with derived screen * started wotk with derived screen progress 2 * add derivation screen progress * components file renamed * add derived keys screen finished * adjusted paddings * small adjustements * key details added lable for dynamic derivations * added todo * fixed size for text * network filter work in progress * network filter work in progress 2 * network filter implementation * network filter implementation 2 * dependencies updated * netfilter workin in progress * netfilter working in progress 2 ui clearing * network filters preferences added * network filters preferences added 2 * network filters preferences added 3 * keyset details refactoring * moved backup BS to keyset details screen * filters implemented, only keyset old items missing * fixed network filters * changed elements for a old ones for keyset details * key derived elements updated * removed outdated todo * feat: #1882 import dymanic derivations * fix: ios: fix building after enum change * fixed compilation errors from rust * fixed compilation errors from rust for android * implemented new view for keyset details when all keys filtered out * error messages added * animated qr code implemented for dynamic derivations * add response QR * integration dynamic derivations screen 1 * integration dynamic derivations screen 2 * removed unrelated todos * padding added * padding added 2 * add was_imported field to MKeysCard * fix grammar * fix ios * fix #1935 investigation * add was_imported to MKeyDetails * fix #1935 fixed * autoformat * implemented to show was imported label * fixed paddings for imported label as per design * removed temp notes * filter network - hide done if empty list * filter network - fixed difference between clean selection and close * fixing new rust changes to make app compile. Next will be to adjust behaviour * remove build tools version as now agp defines it * work in progress - implementation separate import keys * implementation import derivation in progress * implemented create import dynamic derevation keys * improved UX - text, password asking while still screen presented. * fix to show errors in case if keyset doesn't exist. --------- Co-authored-by: Pavel Rybalko Co-authored-by: Krzysztof Rodak --- android/build.gradle | 14 +- .../components/base/BottomSheetHeader.kt | 6 +- .../signer/components/base/FrameContainers.kt | 3 +- ...omponents.kt => ScreenHeaderComponents.kt} | 4 +- .../signer/components/items/NetworkItems.kt | 149 ++++++++++++ .../signer/dependencygraph/ServiceLocator.kt | 2 + .../java/io/parity/signer/domain/Helpers.kt | 4 +- .../java/io/parity/signer/domain/Models.kt | 18 +- .../io/parity/signer/domain/SeedExtensions.kt | 4 + .../signer/domain/backend/UniffiInteractor.kt | 9 + .../domain/storage/PreferencesRepository.kt | 28 +++ .../signer/domain/storage/SeedRepository.kt | 1 - .../DeriveKeyNetworkSelectScreen.kt | 46 +--- .../keydetails/KeyDetailsPublicKeyScreen.kt | 38 ++- .../keysetdetails/KeySetDetailsNavSubgraph.kt | 31 +-- ...Full.kt => KeySetDetailsScreenSubgraph.kt} | 62 ++++- .../keysetdetails/KeySetDetailsScreenView.kt | 156 ++++++++++--- .../keysetdetails/KeySetDetailsViewModel.kt | 50 ++++ .../export/KeySetDetailsExportScreenFull.kt | 1 - .../filtermenu/NetworkFilterMenu.kt | 139 +++++++++++ .../keysetdetails/items/KeyDerivedItem.kt | 79 ++++--- .../NewKeysetSelectNetworkScreen.kt | 42 +--- .../keysets/restore/KeysetRecoverViewModel.kt | 15 -- .../RecoverKeysetSelectNetworkScreen.kt | 2 +- .../signer/screens/scan/ScanNavSubgraph.kt | 32 ++- .../signer/screens/scan/ScanViewModel.kt | 134 ++++++++++- .../scan/bananasplit/BananaSplitViewModel.kt | 2 - .../networks/BananaNetworksViewModel.kt | 4 - .../screens/scan/camera/CameraViewModel.kt | 9 +- .../signer/screens/scan/camera/ScanScreen.kt | 13 +- .../ImportDerivedKeysRepository.kt | 58 +++++ .../AddDerivedKeysScreen.kt | 219 ++++++++++++++++++ .../settings/backup/SeedBackupViewModel.kt | 4 +- .../screens/settings/logs/LogsViewModel.kt | 8 +- .../logs/logdetails/LogsDetailsViewModel.kt | 4 +- android/src/main/res/values/strings.xml | 13 ++ 36 files changed, 1151 insertions(+), 252 deletions(-) rename android/src/main/java/io/parity/signer/components/base/{ScreenBaseComponents.kt => ScreenHeaderComponents.kt} (99%) create mode 100644 android/src/main/java/io/parity/signer/components/items/NetworkItems.kt create mode 100644 android/src/main/java/io/parity/signer/domain/storage/PreferencesRepository.kt rename android/src/main/java/io/parity/signer/screens/keysetdetails/{KeySetDetailsScreenFull.kt => KeySetDetailsScreenSubgraph.kt} (65%) create mode 100644 android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsViewModel.kt create mode 100644 android/src/main/java/io/parity/signer/screens/keysetdetails/filtermenu/NetworkFilterMenu.kt create mode 100644 android/src/main/java/io/parity/signer/screens/scan/transaction/dynamicderivations/AddDerivedKeysScreen.kt diff --git a/android/build.gradle b/android/build.gradle index b78816ec5b..a87f5bfbd8 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -51,7 +51,6 @@ android { pickFirsts += ['lib/armeabi-v7a/libc++_shared.so', 'lib/arm64-v8a/libc++_shared.so', 'lib/x86/libc++_shared.so', 'lib/x86_64/libc++_shared.so'] } } - buildToolsVersion '31.0.0' compileSdk 33 ndkVersion '24.0.8215888' namespace 'io.parity.signer' @@ -90,19 +89,21 @@ dependencies { implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.9.0' + implementation 'androidx.activity:activity-compose:1.7.2' + implementation 'androidx.biometric:biometric:1.1.0' implementation "androidx.compose.ui:ui:$compose_libs_version" implementation "androidx.compose.material:material:$compose_libs_version" implementation "androidx.compose.material:material-icons-extended:$compose_libs_version" implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version" //for flow-layout which is non-lazy grid implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_libs_version" - implementation "androidx.navigation:navigation-compose:2.5.3" + implementation "androidx.datastore:datastore-preferences:1.0.0" + implementation "androidx.navigation:navigation-compose:2.6.0" + implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.1" implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1' - implementation 'androidx.activity:activity-compose:1.7.1' implementation 'androidx.security:security-crypto:1.1.0-alpha06' implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06' - implementation 'androidx.biometric:biometric:1.1.0' implementation 'androidx.camera:camera-core:1.2.0' implementation 'androidx.camera:camera-lifecycle:1.2.0' implementation 'androidx.camera:camera-view:1.2.0' @@ -111,17 +112,18 @@ dependencies { implementation 'com.halilibo.compose-richtext:richtext-commonmark-android:0.17.0' implementation 'com.halilibo.compose-richtext:richtext-ui-material-android:0.17.0' implementation 'androidx.core:core-ktx:1.10.1' + implementation "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5" implementation "net.java.dev.jna:jna:5.13.0@aar" implementation "io.coil-kt:coil-compose:$coilVersion" implementation "io.coil-kt:coil-svg:$coilVersion" testImplementation 'junit:junit:4.13.2' testImplementation "org.mockito:mockito-core:5.4.0" testImplementation "androidx.test:core:1.5.0" - testImplementation 'androidx.test.ext:junit:1.1.5' + testImplementation "androidx.test.ext:junit:1.1.5" testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_libs_version" debugImplementation "androidx.compose.ui:ui-tooling:$compose_libs_version" } diff --git a/android/src/main/java/io/parity/signer/components/base/BottomSheetHeader.kt b/android/src/main/java/io/parity/signer/components/base/BottomSheetHeader.kt index bb80c439b6..9a6cc478b3 100644 --- a/android/src/main/java/io/parity/signer/components/base/BottomSheetHeader.kt +++ b/android/src/main/java/io/parity/signer/components/base/BottomSheetHeader.kt @@ -21,8 +21,8 @@ import io.parity.signer.ui.theme.textSecondary @Composable fun BottomSheetHeader( title: String, - subtitile: String? = null, modifier: Modifier = Modifier, + subtitile: String? = null, onCloseClicked: Callback? ) { Row( @@ -86,11 +86,11 @@ fun BottomSheetSubtitle( private fun PreviewHeaderWithClose() { SignerNewTheme { Column() { - BottomSheetHeader(title = "Title") {} + BottomSheetHeader(title = "Title", onCloseClicked = {}) Divider() BottomSheetHeader(title = "Very very very very long title Very very very very long title") {} Divider() - BottomSheetHeader(title = "Title", subtitile = "With subtitle") {} + BottomSheetHeader(title = "Title", subtitile = "With subtitle", onCloseClicked = {}) Divider() BottomSheetSubtitle(R.string.subtitle_secret_recovery_phrase) } diff --git a/android/src/main/java/io/parity/signer/components/base/FrameContainers.kt b/android/src/main/java/io/parity/signer/components/base/FrameContainers.kt index afebb042fc..e7a103347c 100644 --- a/android/src/main/java/io/parity/signer/components/base/FrameContainers.kt +++ b/android/src/main/java/io/parity/signer/components/base/FrameContainers.kt @@ -70,6 +70,7 @@ fun NotificationFrameText( fun NotificationFrameTextImportant( message: String, withBorder: Boolean = true, + textColor: Color = MaterialTheme.colors.pink300, modifier: Modifier = Modifier, ) { val BACKGROUND = Color(0x14F272B6) @@ -88,7 +89,7 @@ fun NotificationFrameTextImportant( ) { Text( text = message, - color = MaterialTheme.colors.pink300, + color = textColor, style = SignerTypeface.CaptionM, modifier = Modifier .weight(1f) diff --git a/android/src/main/java/io/parity/signer/components/base/ScreenBaseComponents.kt b/android/src/main/java/io/parity/signer/components/base/ScreenHeaderComponents.kt similarity index 99% rename from android/src/main/java/io/parity/signer/components/base/ScreenBaseComponents.kt rename to android/src/main/java/io/parity/signer/components/base/ScreenHeaderComponents.kt index cb7ba5df5b..1f8e0b0d48 100644 --- a/android/src/main/java/io/parity/signer/components/base/ScreenBaseComponents.kt +++ b/android/src/main/java/io/parity/signer/components/base/ScreenHeaderComponents.kt @@ -37,15 +37,15 @@ import io.parity.signer.ui.theme.fill12 import io.parity.signer.ui.theme.pink500 import io.parity.signer.ui.theme.textTertiary - @Composable fun ScreenHeader( title: String?, onBack: Callback? = null, onMenu: Callback? = null, + modifier: Modifier = Modifier, ) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth(1f) .defaultMinSize(minHeight = 56.dp) ) { diff --git a/android/src/main/java/io/parity/signer/components/items/NetworkItems.kt b/android/src/main/java/io/parity/signer/components/items/NetworkItems.kt new file mode 100644 index 0000000000..d44be1aea0 --- /dev/null +++ b/android/src/main/java/io/parity/signer/components/items/NetworkItems.kt @@ -0,0 +1,149 @@ +package io.parity.signer.components.items + +import SignerCheckbox +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.parity.signer.components.networkicon.NetworkIcon +import io.parity.signer.domain.NetworkModel +import io.parity.signer.ui.theme.SignerNewTheme +import io.parity.signer.ui.theme.SignerTypeface +import io.parity.signer.ui.theme.textTertiary + + +@Composable +fun NetworkItemClickable( + network: NetworkModel, + onClick: (NetworkModel) -> Unit, +) { + Row( + modifier = Modifier.clickable { onClick(network) }, + verticalAlignment = Alignment.CenterVertically + ) { + NetworkIcon( + networkLogoName = network.logo, + modifier = Modifier + .padding( + top = 16.dp, + bottom = 16.dp, + start = 16.dp, + end = 12.dp + ) + .size(36.dp), + ) + Text( + text = network.title, + color = MaterialTheme.colors.primary, + style = SignerTypeface.TitleS, + ) + Spacer(modifier = Modifier.weight(1f)) + Image( + imageVector = Icons.Filled.ChevronRight, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colors.textTertiary), + modifier = Modifier + .padding(2.dp)// because it's 28 not 32pd + .padding(end = 16.dp) + .size(28.dp) + ) + } +} + +@Composable +fun NetworkItemMultiselect( + network: NetworkModel, + isSelected: Boolean, + modifier: Modifier = Modifier, + onClick: (NetworkModel) -> Unit, +) { + Row( + modifier = modifier.clickable { onClick(network) }, + verticalAlignment = Alignment.CenterVertically + ) { + NetworkIcon( + networkLogoName = network.logo, + modifier = Modifier + .padding( + top = 16.dp, + bottom = 16.dp, + start = 16.dp, + end = 12.dp + ) + .size(36.dp), + ) + Text( + text = network.title, + color = MaterialTheme.colors.primary, + style = SignerTypeface.TitleS, + ) + Spacer(modifier = Modifier.weight(1f)) + SignerCheckbox( + isChecked = isSelected, + modifier = Modifier.padding(end = 8.dp), + uncheckedColor = MaterialTheme.colors.primary, + ) { + onClick(network) + } + } +} + + + +@Preview( + name = "light", group = "general", uiMode = Configuration.UI_MODE_NIGHT_NO, + showBackground = true, backgroundColor = 0xFFFFFFFF, +) +@Preview( + name = "dark", group = "general", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, backgroundColor = 0xFF000000, +) +@Composable +private fun PreviewNetworkItem() { + val networks = listOf( + NetworkModel( + key = "0", + logo = "polkadot", + title = "Polkadot", + pathId = "polkadot", + ), + NetworkModel( + key = "1", + logo = "Kusama", + title = "Kusama", + pathId = "kusama", + ), + ) + SignerNewTheme { + Column() { + NetworkItemClickable( + network = networks[0], + onClick = {}) + NetworkItemMultiselect( + network = networks[0], + isSelected = true, + onClick = {} + ) + NetworkItemMultiselect( + network = networks[1], + isSelected = false, + onClick = {} + ) + } + } +} diff --git a/android/src/main/java/io/parity/signer/dependencygraph/ServiceLocator.kt b/android/src/main/java/io/parity/signer/dependencygraph/ServiceLocator.kt index 925f942e1a..15a25e36f1 100644 --- a/android/src/main/java/io/parity/signer/dependencygraph/ServiceLocator.kt +++ b/android/src/main/java/io/parity/signer/dependencygraph/ServiceLocator.kt @@ -7,6 +7,7 @@ import io.parity.signer.components.networkicon.UnknownNetworkColorsGenerator import io.parity.signer.domain.Authentication import io.parity.signer.domain.NetworkExposedStateKeeper import io.parity.signer.domain.storage.DatabaseAssetsInteractor +import io.parity.signer.domain.storage.PreferencesRepository import io.parity.signer.domain.storage.SeedRepository import io.parity.signer.domain.storage.SeedStorage @@ -32,6 +33,7 @@ object ServiceLocator { val uniffiInteractor by lazy { UniffiInteractor(appContext) } val seedStorage: SeedStorage = SeedStorage() + val preferencesRepository: PreferencesRepository by lazy { PreferencesRepository(appContext) } val databaseAssetsInteractor by lazy { DatabaseAssetsInteractor(appContext, seedStorage) } val networkExposedStateKeeper by lazy { NetworkExposedStateKeeper(appContext, uniffiInteractor) } val authentication = Authentication() diff --git a/android/src/main/java/io/parity/signer/domain/Helpers.kt b/android/src/main/java/io/parity/signer/domain/Helpers.kt index 6b1aef9d40..601555be68 100644 --- a/android/src/main/java/io/parity/signer/domain/Helpers.kt +++ b/android/src/main/java/io/parity/signer/domain/Helpers.kt @@ -3,9 +3,7 @@ package io.parity.signer.domain import android.util.Log import io.parity.signer.BuildConfig import io.parity.signer.uniffi.ErrorDisplayed -import kotlinx.coroutines.Dispatchers import java.lang.RuntimeException -import java.util.concurrent.Executors fun submitErrorState(message: String) { Log.e("error state", message) @@ -15,7 +13,7 @@ fun submitErrorState(message: String) { } -fun ErrorDisplayed.getDetailedDescriptionString(): String { +fun ErrorDisplayed.getDebugDetailedDescriptionString(): String { return this.javaClass.name + "Message: " + message } diff --git a/android/src/main/java/io/parity/signer/domain/Models.kt b/android/src/main/java/io/parity/signer/domain/Models.kt index 872e9227f9..510f2927dc 100644 --- a/android/src/main/java/io/parity/signer/domain/Models.kt +++ b/android/src/main/java/io/parity/signer/domain/Models.kt @@ -47,7 +47,11 @@ fun MKeysNew.toKeySetDetailsModel() = KeySetDetailsModel( root = root?.toKeysModel(), ) -data class KeyAndNetworkModel(val key: KeyModel, val network: NetworkInfoModel) +data class KeyAndNetworkModel(val key: KeyModel, val network: NetworkInfoModel) { + companion object { + fun createStub() = KeyAndNetworkModel(KeyModel.createStub(), NetworkInfoModel.createStub()) + } +} fun MKeyAndNetworkCard.toKeyAndNetworkModel() = KeyAndNetworkModel( key = key.toKeyModel(), @@ -64,10 +68,11 @@ data class KeyModel( val base58: String, val hasPwd: Boolean, val path: String, - val secretExposed: Boolean + val secretExposed: Boolean, + val wasImported: Boolean?, ) { companion object { - fun createStub() = KeyModel( + fun createStub(wasImported: Boolean = false) = KeyModel( addressKey = "address key", base58 = "5F3sa2TJAWMqDhXG6jhV4N8ko9SxwGy8TpaNS1repo5EYjQX", identicon = PreviewData.Identicon.exampleIdenticonPng, @@ -75,6 +80,7 @@ data class KeyModel( path = "//polkadot//path2", secretExposed = false, seedName = "sdsdsd", + wasImported = wasImported, ) } } @@ -87,6 +93,7 @@ fun MAddressCard.toKeysModel() = KeyModel( path = address.path, secretExposed = address.secretExposed, seedName = address.seedName, + wasImported = null, ) /** @@ -100,6 +107,7 @@ fun MKeysCard.toKeyModel() = KeyModel( path = address.path, secretExposed = address.secretExposed, seedName = address.seedName, + wasImported = wasImported, ) /** @@ -163,6 +171,7 @@ data class KeyDetailsModel( val address: KeyCardModel, val base58: String, val secretExposed: Boolean, + val wasImported: Boolean, ) { val isRootKey = address.cardBase.path.isEmpty() @@ -179,6 +188,7 @@ data class KeyDetailsModel( address = keyCard, base58 = keyCard.cardBase.base58, secretExposed = true, + wasImported = false, ) } @@ -196,6 +206,7 @@ data class KeyDetailsModel( ), base58 = keyCard.cardBase.base58, secretExposed = true, + wasImported = false, ) } } @@ -213,6 +224,7 @@ fun MKeyDetails.toKeyDetailsModel() = ), base58 = base58, secretExposed = address.secretExposed, + wasImported = wasImported, ) diff --git a/android/src/main/java/io/parity/signer/domain/SeedExtensions.kt b/android/src/main/java/io/parity/signer/domain/SeedExtensions.kt index 7e32c667b5..744cb35f2e 100644 --- a/android/src/main/java/io/parity/signer/domain/SeedExtensions.kt +++ b/android/src/main/java/io/parity/signer/domain/SeedExtensions.kt @@ -1,5 +1,7 @@ package io.parity.signer.domain +import android.util.Log +import android.widget.Toast import io.parity.signer.dependencygraph.ServiceLocator import io.parity.signer.domain.storage.getSeed @@ -37,6 +39,8 @@ suspend fun getSeedPhraseForBackup( try { seedStorage.getSeed(seedName, showInLogs = true) } catch (e: Exception) { + Log.d("get seed failure", e.toString()) + Toast.makeText(activity, "get seed failure: $e", Toast.LENGTH_LONG).show() null } } diff --git a/android/src/main/java/io/parity/signer/domain/backend/UniffiInteractor.kt b/android/src/main/java/io/parity/signer/domain/backend/UniffiInteractor.kt index 2f40157c40..468837c221 100644 --- a/android/src/main/java/io/parity/signer/domain/backend/UniffiInteractor.kt +++ b/android/src/main/java/io/parity/signer/domain/backend/UniffiInteractor.kt @@ -204,6 +204,15 @@ class UniffiInteractor(val appContext: Context) { } } + suspend fun previewDynamicDerivations(seeds: Map, payload: String): UniffiResult = + withContext(Dispatchers.IO) { + try { + val validationResult = io.parity.signer.uniffi.previewDynamicDerivations(seeds, payload) + UniffiResult.Success(validationResult) + } catch (e: ErrorDisplayed) { + UniffiResult.Error(e) + } + } } sealed class UniffiResult { diff --git a/android/src/main/java/io/parity/signer/domain/storage/PreferencesRepository.kt b/android/src/main/java/io/parity/signer/domain/storage/PreferencesRepository.kt new file mode 100644 index 0000000000..b731a67fd8 --- /dev/null +++ b/android/src/main/java/io/parity/signer/domain/storage/PreferencesRepository.kt @@ -0,0 +1,28 @@ +package io.parity.signer.domain.storage + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "app_preferences") + +class PreferencesRepository(private val context: Context) { + + private val networksFilterKey = stringSetPreferencesKey("network_filter") + + val networksFilter = context.dataStore.data + .map { preferences -> + // No type safety. + preferences[networksFilterKey] ?: emptySet() + } + + suspend fun setNetworksFilter(newFilters: Set) { + context.dataStore.edit { settings -> + settings[networksFilterKey] = newFilters + } + } +} diff --git a/android/src/main/java/io/parity/signer/domain/storage/SeedRepository.kt b/android/src/main/java/io/parity/signer/domain/storage/SeedRepository.kt index 2a4010450c..85871bf828 100644 --- a/android/src/main/java/io/parity/signer/domain/storage/SeedRepository.kt +++ b/android/src/main/java/io/parity/signer/domain/storage/SeedRepository.kt @@ -251,7 +251,6 @@ class SeedRepository( val result = storage.checkIfSeedNameAlreadyExists(seedPhrase) result } - AuthResult.AuthError, AuthResult.AuthFailed, AuthResult.AuthUnavailable -> { diff --git a/android/src/main/java/io/parity/signer/screens/createderivation/derivationsubscreens/DeriveKeyNetworkSelectScreen.kt b/android/src/main/java/io/parity/signer/screens/createderivation/derivationsubscreens/DeriveKeyNetworkSelectScreen.kt index 04c5788a10..11e4fc036d 100644 --- a/android/src/main/java/io/parity/signer/screens/createderivation/derivationsubscreens/DeriveKeyNetworkSelectScreen.kt +++ b/android/src/main/java/io/parity/signer/screens/createderivation/derivationsubscreens/DeriveKeyNetworkSelectScreen.kt @@ -2,7 +2,6 @@ package io.parity.signer.screens.createderivation.derivationsubscreens import android.content.res.Configuration import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -10,7 +9,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -18,12 +16,10 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -31,7 +27,7 @@ import androidx.compose.ui.unit.dp import io.parity.signer.R import io.parity.signer.components.base.ScreenHeaderWithButton import io.parity.signer.components.base.SignerDivider -import io.parity.signer.components.networkicon.NetworkIcon +import io.parity.signer.components.items.NetworkItemClickable import io.parity.signer.domain.Callback import io.parity.signer.domain.NetworkModel import io.parity.signer.ui.theme.SignerNewTheme @@ -77,7 +73,7 @@ fun DeriveKeyNetworkSelectScreen( ) ) { networks.forEach { network -> - NetworkItem(network) { network -> + NetworkItemClickable(network) { network -> onNetworkSelect(network) } SignerDivider() @@ -92,44 +88,6 @@ fun DeriveKeyNetworkSelectScreen( } } -@Composable -private fun NetworkItem( - network: NetworkModel, - onClick: (NetworkModel) -> Unit, -) { - Row( - modifier = Modifier.clickable { onClick(network) }, - verticalAlignment = Alignment.CenterVertically - ) { - NetworkIcon( - networkLogoName = network.logo, - modifier = Modifier - .padding( - top = 16.dp, - bottom = 16.dp, - start = 16.dp, - end = 12.dp - ) - .size(36.dp), - ) - Text( - text = network.title, - color = MaterialTheme.colors.primary, - style = SignerTypeface.TitleS, - ) - Spacer(modifier = Modifier.weight(1f)) - Image( - imageVector = Icons.Filled.ChevronRight, - contentDescription = null, - colorFilter = ColorFilter.tint(MaterialTheme.colors.textTertiary), - modifier = Modifier - .padding(2.dp)// because it's 28 not 32pd - .padding(end = 16.dp) - .size(28.dp) - ) - } -} - @Composable fun NetworkHelpAlarm(modifier: Modifier = Modifier) { val innerShape = diff --git a/android/src/main/java/io/parity/signer/screens/keydetails/KeyDetailsPublicKeyScreen.kt b/android/src/main/java/io/parity/signer/screens/keydetails/KeyDetailsPublicKeyScreen.kt index 827723c7ae..213f185a3a 100644 --- a/android/src/main/java/io/parity/signer/screens/keydetails/KeyDetailsPublicKeyScreen.kt +++ b/android/src/main/java/io/parity/signer/screens/keydetails/KeyDetailsPublicKeyScreen.kt @@ -1,9 +1,22 @@ package io.parity.signer.screens.keydetails import android.content.res.Configuration -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -33,7 +46,14 @@ import io.parity.signer.domain.KeyDetailsModel import io.parity.signer.domain.Navigator import io.parity.signer.domain.intoImageBitmap import io.parity.signer.ui.helpers.PreviewData -import io.parity.signer.ui.theme.* +import io.parity.signer.ui.theme.SignerNewTheme +import io.parity.signer.ui.theme.SignerTypeface +import io.parity.signer.ui.theme.appliedStroke +import io.parity.signer.ui.theme.fill12 +import io.parity.signer.ui.theme.fill6 +import io.parity.signer.ui.theme.red500 +import io.parity.signer.ui.theme.red500fill12 +import io.parity.signer.ui.theme.textTertiary import io.parity.signer.uniffi.Action import io.parity.signer.uniffi.encodeToQr import kotlinx.coroutines.runBlocking @@ -121,6 +141,18 @@ fun KeyDetailsPublicKeyScreen( modifier = Modifier.padding(start = 8.dp) ) } + if (model.wasImported) { + SignerDivider() + Text( + text = stringResource(R.string.dynamic_derivation_path_label), + style = SignerTypeface.CaptionM, + color = MaterialTheme.colors.textTertiary, + modifier = Modifier + .fillMaxWidth(1f) + .padding(vertical = 12.dp, horizontal = 8.dp), + textAlign = TextAlign.Center, + ) + } } BottomKeyPlate(plateShape, model) } diff --git a/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsNavSubgraph.kt b/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsNavSubgraph.kt index 8a7590e020..2f73dc74a6 100644 --- a/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsNavSubgraph.kt +++ b/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsNavSubgraph.kt @@ -28,8 +28,8 @@ fun KeySetDetailsNavSubgraph( ) { composable(KeySetDetailsNavSubgraph.home) { - KeySetDetailsScreenFull( - model = model, + KeySetDetailsScreenSubgraph( + fullModel = model, navigator = rootNavigator, navController = navController, networkState = networkState, @@ -50,37 +50,10 @@ fun KeySetDetailsNavSubgraph( onClose = { navController.navigate(KeySetDetailsNavSubgraph.home) }, ) } - composable(KeySetDetailsNavSubgraph.backup) { - //preconditions - val backupModel = model.toSeedBackupModel() - if (backupModel == null) { - submitErrorState("navigated to backup model but without root in KeySet " + - "it's impossible to backup") - navController.navigate(KeySetDetailsNavSubgraph.home) - } else { - //background - Box(Modifier.statusBarsPadding()) { - KeySetDetailsScreenView( - model = model, - navigator = EmptyNavigator(), - networkState = networkState, - onShowPublicKey = {_,_ ->}, - onMenu = {}, - ) - } - //content - KeySetBackupFullOverlayBottomSheet( - model = backupModel, - getSeedPhraseForBackup = singleton::getSeedPhraseForBackup, - onClose = { navController.navigate(KeySetDetailsNavSubgraph.home) }, - ) - } - } } } internal object KeySetDetailsNavSubgraph { const val home = "keyset_details_home" const val multiselect = "keyset_details_multiselect" - const val backup = "keyset_details_backup" } diff --git a/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsScreenFull.kt b/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsScreenSubgraph.kt similarity index 65% rename from android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsScreenFull.kt rename to android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsScreenSubgraph.kt index e5694775a4..fc1c5a7bba 100644 --- a/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsScreenFull.kt +++ b/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsScreenSubgraph.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -16,12 +18,17 @@ import io.parity.signer.domain.Callback import io.parity.signer.domain.KeySetDetailsModel import io.parity.signer.domain.Navigator import io.parity.signer.domain.NetworkState +import io.parity.signer.domain.getSeedPhraseForBackup +import io.parity.signer.domain.submitErrorState +import io.parity.signer.screens.keysetdetails.backup.KeySetBackupFullOverlayBottomSheet +import io.parity.signer.screens.keysetdetails.backup.toSeedBackupModel +import io.parity.signer.screens.keysetdetails.filtermenu.NetworkFilterMenu import io.parity.signer.domain.submitErrorState import io.parity.signer.ui.BottomSheetWrapperRoot @Composable -fun KeySetDetailsScreenFull( - model: KeySetDetailsModel, +fun KeySetDetailsScreenSubgraph( + fullModel: KeySetDetailsModel, navigator: Navigator, navController: NavController, networkState: State, //for shield icon @@ -29,17 +36,24 @@ fun KeySetDetailsScreenFull( ) { val menuNavController = rememberNavController() + val keySetViewModel: KeySetDetailsViewModel = viewModel() + val filteredModel = keySetViewModel.makeFilteredFlow(fullModel).collectAsStateWithLifecycle() + Box(Modifier.statusBarsPadding()) { KeySetDetailsScreenView( - model = model, + model = filteredModel.value, navigator = navigator, networkState = networkState, + fullModelWasEmpty = fullModel.keysAndNetwork.isEmpty(), onMenu = { menuNavController.navigate(KeySetDetailsMenuSubgraph.keys_menu) }, onShowPublicKey = { title: String, key: String -> menuNavController.navigate("${KeySetDetailsMenuSubgraph.keys_public_key}/$title/$key") }, + onFilterClicked = { + menuNavController.navigate(KeySetDetailsMenuSubgraph.network_filter) + } ) } @@ -61,8 +75,9 @@ fun KeySetDetailsScreenFull( navController.navigate(KeySetDetailsNavSubgraph.multiselect) }, onBackupClicked = { - menuNavController.popBackStack() - navController.navigate(KeySetDetailsNavSubgraph.backup) + menuNavController.navigate(KeySetDetailsMenuSubgraph.backup) { + popUpTo(KeySetDetailsMenuSubgraph.empty) + } }, onCancel = { menuNavController.popBackStack() @@ -71,7 +86,7 @@ fun KeySetDetailsScreenFull( menuNavController.navigate(KeySetDetailsMenuSubgraph.keys_menu_delete_confirm) { popUpTo(KeySetDetailsMenuSubgraph.empty) } - } + }, ) } } @@ -109,6 +124,39 @@ fun KeySetDetailsScreenFull( ) } } + composable(KeySetDetailsMenuSubgraph.network_filter) { + val initialSelection = + keySetViewModel.filters.collectAsStateWithLifecycle() + BottomSheetWrapperRoot(onClosedAction = closeAction) { + NetworkFilterMenu( + networks = keySetViewModel.getAllNetworks(), + initialSelection = initialSelection.value, + onConfirm = { + keySetViewModel.setFilters(it) + closeAction() + }, + onCancel = closeAction, + ) + } + } + composable(KeySetDetailsMenuSubgraph.backup) { + //preconditions + val backupModel = fullModel.toSeedBackupModel() + if (backupModel == null) { + submitErrorState( + "navigated to backup model but without root in KeySet " + + "it's impossible to backup" + ) + closeAction() + } else { + //content + KeySetBackupFullOverlayBottomSheet( + model = backupModel, + getSeedPhraseForBackup = ::getSeedPhraseForBackup, + onClose = closeAction, + ) + } + } } } @@ -117,6 +165,8 @@ private object KeySetDetailsMenuSubgraph { const val empty = "keys_menu_empty" const val keys_menu = "keys_menu" const val keys_menu_delete_confirm = "keys_menu_delete_confirm" + const val network_filter = "keys_network_filters" + const val backup = "keyset_details_backup" const val keys_public_key = "keys_public_key" } diff --git a/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsScreenView.kt b/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsScreenView.kt index 89629ef3bf..e1543e089e 100644 --- a/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsScreenView.kt +++ b/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsScreenView.kt @@ -1,9 +1,23 @@ package io.parity.signer.screens.keysetdetails import android.content.res.Configuration -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text @@ -21,6 +35,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -30,10 +45,22 @@ import io.parity.signer.components.base.SecondaryButtonWide import io.parity.signer.components.exposesecurity.ExposedIcon import io.parity.signer.components.panels.BottomBar import io.parity.signer.components.panels.BottomBarState -import io.parity.signer.domain.* -import io.parity.signer.screens.keysetdetails.items.NetworkKeysExpandable +import io.parity.signer.domain.BASE58_STYLE_ABBREVIATE +import io.parity.signer.domain.Callback +import io.parity.signer.domain.EmptyNavigator +import io.parity.signer.domain.KeyModel +import io.parity.signer.domain.KeySetDetailsModel +import io.parity.signer.domain.Navigator +import io.parity.signer.domain.NetworkState +import io.parity.signer.domain.abbreviateString +import io.parity.signer.domain.conditional +import io.parity.signer.screens.keysetdetails.items.KeyDerivedItem import io.parity.signer.screens.keysetdetails.items.SeedKeyDetails -import io.parity.signer.ui.theme.* +import io.parity.signer.ui.theme.SignerNewTheme +import io.parity.signer.ui.theme.SignerTypeface +import io.parity.signer.ui.theme.pink300 +import io.parity.signer.ui.theme.textDisabled +import io.parity.signer.ui.theme.textTertiary import io.parity.signer.uniffi.Action /** @@ -46,6 +73,8 @@ fun KeySetDetailsScreenView( model: KeySetDetailsModel, navigator: Navigator, networkState: State, //for shield icon + fullModelWasEmpty: Boolean, + onFilterClicked: Callback, onMenu: Callback, onShowPublicKey: (title: String, key: String) -> Unit, ) { @@ -61,47 +90,48 @@ fun KeySetDetailsScreenView( if (model.keysAndNetwork.isNotEmpty()) { Column( modifier = Modifier - .padding(horizontal = 8.dp) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - //seed - model.root?.let { - SeedKeyDetails( - model = it, - onShowPublicKey = onShowPublicKey, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) - .padding(bottom = 16.dp) - ) - } + SeedKeyItemElement(model, onShowPublicKey) + + FilterRow(onFilterClicked) - val models = model.keysAndNetwork.groupBy { it.network } - for (networkAndKeys in models.entries) { - NetworkKeysExpandable( - network = networkAndKeys.key.toNetworkModel(), - keys = networkAndKeys.value - .map { it.key } - .sortedBy { it.path }) { key, network -> + for (networkAndKeys in model.keysAndNetwork) { + KeyDerivedItem( + model = networkAndKeys.key, + networkLogo = networkAndKeys.network.networkLogo, + ) { val selectKeyDetails = - "${key.addressKey}\n${network.key}" + "${networkAndKeys.key.addressKey}\n${networkAndKeys.network.networkSpecsKey}" navigator.navigate(Action.SELECT_KEY, selectKeyDetails) } } } - } else { + } else if (fullModelWasEmpty) { + //no derived keys at all Column() { //seed - model.root?.let { - SeedKeyDetails( - model = it, - onShowPublicKey = onShowPublicKey, - Modifier.padding(horizontal = 24.dp, vertical = 16.dp) - ) - } + SeedKeyItemElement(model, onShowPublicKey) KeySetDetailsEmptyList(onAdd = { navigator.navigate(Action.NEW_KEY, "") //new derived key }) } + } else { + Column() { + SeedKeyItemElement(model, onShowPublicKey) + //no keys because filtered + FilterRow(onFilterClicked) + Spacer(modifier = Modifier.weight(0.5f)) + Text( + text = stringResource(R.string.key_set_details_all_filtered_keys_title), + color = MaterialTheme.colors.primary, + style = SignerTypeface.TitleM, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 40.dp) + ) + Spacer(modifier = Modifier.weight(0.5f)) + } } ExposedIcon( @@ -115,6 +145,44 @@ fun KeySetDetailsScreenView( } } +@Composable +private fun SeedKeyItemElement(model: KeySetDetailsModel, + onShowPublicKey: (title: String, key: String) -> Unit, +) { + model.root?.let { + SeedKeyDetails( + model = it, + onShowPublicKey = onShowPublicKey, + Modifier + .padding(horizontal = 24.dp, vertical = 8.dp) + .padding(bottom = 16.dp) + ) + } +} + +@Composable +private fun FilterRow(onFilterClicked: Callback) { + Row( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.key_sets_details_screem_derived_subtitle), + color = MaterialTheme.colors.textTertiary, + style = SignerTypeface.BodyM, + modifier = Modifier.weight(1f), + ) + Icon( + painter = painterResource(id = R.drawable.ic_tune_28), + contentDescription = stringResource(R.string.key_sets_details_screem_filter_icon_description), + modifier = Modifier + .clickable(onClick = onFilterClicked) + .size(28.dp), + tint = MaterialTheme.colors.pink300, + ) + } +} + @Composable fun KeySetDetailsHeader( @@ -238,7 +306,6 @@ private fun KeySetDetailsEmptyList(onAdd: Callback) { style = SignerTypeface.TitleM, textAlign = TextAlign.Center, ) - SecondaryButtonWide( label = stringResource(R.string.key_sets_details_screem_create_derived_button), withBackground = true, @@ -266,7 +333,7 @@ private fun PreviewKeySetDetailsScreen() { val mockModel = KeySetDetailsModel.createStub() SignerNewTheme { Box(modifier = Modifier.size(350.dp, 550.dp)) { - KeySetDetailsScreenView(mockModel, EmptyNavigator(), state, {}, {_,_ ->}) + KeySetDetailsScreenView(mockModel, EmptyNavigator(), state, false, {}, {}, {_,_ ->}) } } } @@ -287,7 +354,28 @@ private fun PreviewKeySetDetailsScreenEmpty() { KeySetDetailsModel.createStub().copy(keysAndNetwork = emptyList()) SignerNewTheme { Box(modifier = Modifier.size(350.dp, 550.dp)) { - KeySetDetailsScreenView(mockModel, EmptyNavigator(), state, {}, {_,_ ->}) + KeySetDetailsScreenView(mockModel, EmptyNavigator(), state, true, {}, {}, {_,_ ->}) + } + } +} + +@Preview( + name = "light", group = "general", uiMode = Configuration.UI_MODE_NIGHT_NO, + showBackground = true, backgroundColor = 0xFFFFFFFF, +) +@Preview( + name = "dark", group = "general", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, backgroundColor = 0xFF000000, +) +@Composable +private fun PreviewKeySetDetailsScreenFiltered() { + val state = remember { mutableStateOf(NetworkState.Active) } + val mockModel = + KeySetDetailsModel.createStub().copy(keysAndNetwork = emptyList()) + SignerNewTheme { + Box(modifier = Modifier.size(350.dp, 550.dp)) { + KeySetDetailsScreenView(mockModel, EmptyNavigator(), state, false, {}, {}, {_,_ ->}) } } } diff --git a/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsViewModel.kt b/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsViewModel.kt new file mode 100644 index 0000000000..6a61c544cd --- /dev/null +++ b/android/src/main/java/io/parity/signer/screens/keysetdetails/KeySetDetailsViewModel.kt @@ -0,0 +1,50 @@ +package io.parity.signer.screens.keysetdetails + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.parity.signer.dependencygraph.ServiceLocator +import io.parity.signer.domain.KeySetDetailsModel +import io.parity.signer.domain.NetworkModel +import io.parity.signer.domain.usecases.AllNetworksUseCase +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + + +class KeySetDetailsViewModel : ViewModel() { + private val preferencesRepository = ServiceLocator.preferencesRepository + private val uniffiInteractor = ServiceLocator.uniffiInteractor + private val allNetworksUseCase = AllNetworksUseCase(uniffiInteractor) + + val filters = preferencesRepository.networksFilter.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + initialValue = emptySet(), + ) + + fun makeFilteredFlow(original : KeySetDetailsModel): StateFlow { + return filters.map { filterInstance -> + if (filterInstance.isEmpty()) original else { + original.copy(keysAndNetwork = original.keysAndNetwork + .filter { filterInstance.contains(it.network.networkSpecsKey) }) + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(1_000), + initialValue = original, + ) + } + + fun getAllNetworks(): List { + return allNetworksUseCase.getAllNetworks() + } + + fun setFilters(networksToFilter: Set) { + viewModelScope.launch { + preferencesRepository.setNetworksFilter(networksToFilter.map { it.key } + .toSet()) + } + } +} diff --git a/android/src/main/java/io/parity/signer/screens/keysetdetails/export/KeySetDetailsExportScreenFull.kt b/android/src/main/java/io/parity/signer/screens/keysetdetails/export/KeySetDetailsExportScreenFull.kt index 732cb67934..14358978eb 100644 --- a/android/src/main/java/io/parity/signer/screens/keysetdetails/export/KeySetDetailsExportScreenFull.kt +++ b/android/src/main/java/io/parity/signer/screens/keysetdetails/export/KeySetDetailsExportScreenFull.kt @@ -18,7 +18,6 @@ import io.parity.signer.domain.KeySetDetailsModel import io.parity.signer.domain.submitErrorState import io.parity.signer.ui.BottomSheetWrapperRoot -@OptIn(ExperimentalMaterialApi::class) @Composable fun KeySetDetailsExportScreenFull( model: KeySetDetailsModel, diff --git a/android/src/main/java/io/parity/signer/screens/keysetdetails/filtermenu/NetworkFilterMenu.kt b/android/src/main/java/io/parity/signer/screens/keysetdetails/filtermenu/NetworkFilterMenu.kt new file mode 100644 index 0000000000..e5e73b22b6 --- /dev/null +++ b/android/src/main/java/io/parity/signer/screens/keysetdetails/filtermenu/NetworkFilterMenu.kt @@ -0,0 +1,139 @@ +package io.parity.signer.screens.keysetdetails.filtermenu + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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 io.parity.signer.R +import io.parity.signer.components.base.BottomSheetHeader +import io.parity.signer.components.base.RowButtonsBottomSheet +import io.parity.signer.components.base.SignerDivider +import io.parity.signer.components.items.NetworkItemMultiselect +import io.parity.signer.domain.Callback +import io.parity.signer.domain.NetworkModel +import io.parity.signer.ui.theme.SignerNewTheme + + +@Composable +fun NetworkFilterMenu( + networks: List, + initialSelection: Set, + onConfirm: (Set) -> Unit, + onCancel: Callback, +) { + + val selected: MutableState> = + remember { + mutableStateOf( + initialSelection.mapNotNull { selected -> networks.firstOrNull { it.key == selected } } + .toSet() + ) + } + + NetworkFilterMenu( + networks = networks, + selectedNetworks = selected.value, + onClick = { network -> + if (selected.value.contains(network)) { + selected.value = selected.value - network + } else { + selected.value = selected.value + network + } + }, + onConfirm = { onConfirm(selected.value) }, + onClean = { onConfirm(emptySet()) }, + onCancel = onCancel, + ) +} + + +@Composable +private fun NetworkFilterMenu( + networks: List, + selectedNetworks: Set, + onClick: (NetworkModel) -> Unit, + onConfirm: Callback, + onClean: Callback, + onCancel: Callback, +) { + Column( + modifier = Modifier + .fillMaxWidth(), + ) { + BottomSheetHeader( + title = stringResource(R.string.network_filters_header), + onCloseClicked = onCancel + ) + SignerDivider(sidePadding = 24.dp) + networks.forEach { network -> + NetworkItemMultiselect( + modifier = Modifier.padding(start = 8.dp, end = 4.dp), + network = network, + isSelected = selectedNetworks.contains(network), + onClick = onClick + ) + } + RowButtonsBottomSheet( + modifier = Modifier + .padding(24.dp) + .padding(top = 8.dp), + isCtaEnabled = selectedNetworks.isNotEmpty(), + labelCancel = stringResource(R.string.generic_clear_selection), + labelCta = stringResource(id = R.string.generic_done), + onClickedCancel = onClean, + onClickedCta = onConfirm, + ) + } +} + + +@Preview( + name = "light", group = "general", uiMode = Configuration.UI_MODE_NIGHT_NO, + showBackground = true, backgroundColor = 0xFFFFFFFF, +) +@Preview( + name = "dark", group = "general", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, backgroundColor = 0xFF000000, +) +@Composable +private fun PreviewNetworkFilterMenu() { + val networks = listOf( + NetworkModel( + key = "0", + logo = "polkadot", + title = "Polkadot", + pathId = "polkadot", + ), + NetworkModel( + key = "1", + logo = "Kusama", + title = "Kusama", + pathId = "polkadot", + ), + NetworkModel( + key = "2", + logo = "Wastend", + title = "Wastend", + pathId = "polkadot", + ), + ) + SignerNewTheme { + NetworkFilterMenu( + networks = networks, + selectedNetworks = networks.subList(1, 1).toSet(), + onClick = {}, + onConfirm = {}, + onClean = {}, + onCancel = {}, + ) + } +} diff --git a/android/src/main/java/io/parity/signer/screens/keysetdetails/items/KeyDerivedItem.kt b/android/src/main/java/io/parity/signer/screens/keysetdetails/items/KeyDerivedItem.kt index fc2d69d1d2..fd74b08564 100644 --- a/android/src/main/java/io/parity/signer/screens/keysetdetails/items/KeyDerivedItem.kt +++ b/android/src/main/java/io/parity/signer/screens/keysetdetails/items/KeyDerivedItem.kt @@ -3,7 +3,13 @@ package io.parity.signer.screens.keysetdetails.items import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface @@ -25,36 +31,49 @@ import io.parity.signer.components.IdentIconWithNetwork import io.parity.signer.components.base.SignerDivider import io.parity.signer.components.sharedcomponents.KeyPath import io.parity.signer.components.sharedcomponents.NetworkLabel -import io.parity.signer.domain.* +import io.parity.signer.domain.BASE58_STYLE_ABBREVIATE +import io.parity.signer.domain.Callback +import io.parity.signer.domain.KeyAndNetworkModel +import io.parity.signer.domain.KeyModel +import io.parity.signer.domain.NetworkInfoModel +import io.parity.signer.domain.abbreviateString +import io.parity.signer.domain.conditional import io.parity.signer.ui.theme.SignerNewTheme import io.parity.signer.ui.theme.SignerTypeface -import io.parity.signer.ui.theme.textDisabled import io.parity.signer.ui.theme.textTertiary @Composable fun KeyDerivedItem( model: KeyModel, - network: String, - onClick: () -> Unit = {}, + networkLogo: String, + onClick: Callback? = {}, ) { Surface( shape = RoundedCornerShape(dimensionResource(id = R.dimen.innerFramesCornerRadius)), color = Color.Transparent, - modifier = Modifier.clickable(onClick = onClick), + modifier = Modifier.conditional(onClick != null) { + clickable(onClick = onClick ?: {}) + }, ) { Row( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { IdentIconWithNetwork( - identicon = model.identicon, networkLogoName = network, - size = 36.dp, modifier = Modifier.padding( - top = 16.dp, - bottom = 16.dp, - start = 16.dp, - end = 12.dp - ) + identicon = model.identicon, + networkLogoName = networkLogo, + size = 36.dp, + modifier = Modifier.padding(end = 12.dp), ) Column(Modifier.weight(1f)) { + if (model.wasImported == true) { + Text( + text = stringResource(R.string.dynamic_derivation_path_label), + style = SignerTypeface.CaptionM, + color = MaterialTheme.colors.textTertiary, + modifier = Modifier.padding(bottom = 4.dp) + ) + } if (model.path.isNotEmpty() || model.hasPwd) { KeyPath( path = model.path, @@ -72,15 +91,17 @@ fun KeyDerivedItem( style = SignerTypeface.BodyL, ) } - Image( - imageVector = Icons.Filled.ChevronRight, - contentDescription = null, - colorFilter = ColorFilter.tint(MaterialTheme.colors.textTertiary), - modifier = Modifier - .padding(2.dp)// because it's 28 not 32pd - .padding(end = 16.dp) - .size(28.dp) - ) + if (onClick != null) { + Image( + imageVector = Icons.Filled.ChevronRight, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colors.textTertiary), + modifier = Modifier + .padding(2.dp)// because it's 28 not 32pd + .padding(start = 12.dp) + .size(28.dp) + ) + } } } } @@ -138,10 +159,16 @@ fun SlimKeyItem(model: KeyAndNetworkModel) { @Composable private fun PreviewKeyDerivedItem() { SignerNewTheme { - KeyDerivedItem( - KeyModel.createStub(), - "kusama" - ) + Column { + KeyDerivedItem( + KeyModel.createStub(wasImported = false), + "kusama" + ) + KeyDerivedItem( + KeyModel.createStub(wasImported = true), + "kusama", + ) + } } } diff --git a/android/src/main/java/io/parity/signer/screens/keysets/create/backupstepscreens/NewKeysetSelectNetworkScreen.kt b/android/src/main/java/io/parity/signer/screens/keysets/create/backupstepscreens/NewKeysetSelectNetworkScreen.kt index b603d859d8..fc12939423 100644 --- a/android/src/main/java/io/parity/signer/screens/keysets/create/backupstepscreens/NewKeysetSelectNetworkScreen.kt +++ b/android/src/main/java/io/parity/signer/screens/keysets/create/backupstepscreens/NewKeysetSelectNetworkScreen.kt @@ -1,6 +1,5 @@ package io.parity.signer.screens.keysets.create.backupstepscreens -import SignerCheckbox import android.content.res.Configuration import android.widget.Toast import androidx.compose.foundation.background @@ -12,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -40,7 +38,7 @@ import io.parity.signer.components.base.NotificationFrameText import io.parity.signer.components.base.PrimaryButtonWide import io.parity.signer.components.base.ScreenHeaderProgressWithButton import io.parity.signer.components.base.SignerDivider -import io.parity.signer.components.networkicon.NetworkIcon +import io.parity.signer.components.items.NetworkItemMultiselect import io.parity.signer.domain.Callback import io.parity.signer.domain.Navigator import io.parity.signer.domain.NetworkModel @@ -211,44 +209,6 @@ private fun NewKeySetSelectNetworkScreenPrivate( } -@Composable -internal fun NetworkItemMultiselect( - network: NetworkModel, - isSelected: Boolean, - onClick: (NetworkModel) -> Unit, -) { - Row( - modifier = Modifier.clickable { onClick(network) }, - verticalAlignment = Alignment.CenterVertically - ) { - NetworkIcon( - networkLogoName = network.logo, - modifier = Modifier - .padding( - top = 16.dp, - bottom = 16.dp, - start = 16.dp, - end = 12.dp - ) - .size(36.dp), - ) - Text( - text = network.title, - color = MaterialTheme.colors.primary, - style = SignerTypeface.TitleS, - ) - Spacer(modifier = Modifier.weight(1f)) - SignerCheckbox( - isChecked = isSelected, - modifier = Modifier.padding(end = 8.dp), - uncheckedColor = MaterialTheme.colors.primary, - ) { - onClick(network) - } - } -} - - @Composable internal fun NetworkItemMultiselectAll( onClick: Callback, diff --git a/android/src/main/java/io/parity/signer/screens/keysets/restore/KeysetRecoverViewModel.kt b/android/src/main/java/io/parity/signer/screens/keysets/restore/KeysetRecoverViewModel.kt index 62cf8edc22..6c08e553d4 100644 --- a/android/src/main/java/io/parity/signer/screens/keysets/restore/KeysetRecoverViewModel.kt +++ b/android/src/main/java/io/parity/signer/screens/keysets/restore/KeysetRecoverViewModel.kt @@ -33,21 +33,6 @@ class KeysetRecoverViewModel : ViewModel() { _recoverState.value = null } - fun addSeed( - seedName: String, - seedPhrase: String, - navigator: Navigator, - ) { - viewModelScope.launch { - val repository = ServiceLocator.activityScope!!.seedRepository - repository.addSeed( - seedName = seedName, - seedPhrase = seedPhrase, - navigator = navigator, - isOptionalAuth = false - ) - } - } fun onTextEntry(newText: String) { val uniffiInteractor = ServiceLocator.uniffiInteractor diff --git a/android/src/main/java/io/parity/signer/screens/keysets/restore/recoverkeysetnetworks/RecoverKeysetSelectNetworkScreen.kt b/android/src/main/java/io/parity/signer/screens/keysets/restore/recoverkeysetnetworks/RecoverKeysetSelectNetworkScreen.kt index b400ed9a2b..187c47ab39 100644 --- a/android/src/main/java/io/parity/signer/screens/keysets/restore/recoverkeysetnetworks/RecoverKeysetSelectNetworkScreen.kt +++ b/android/src/main/java/io/parity/signer/screens/keysets/restore/recoverkeysetnetworks/RecoverKeysetSelectNetworkScreen.kt @@ -28,9 +28,9 @@ import io.parity.signer.components.base.NotificationFrameText import io.parity.signer.components.base.PrimaryButtonWide import io.parity.signer.components.base.ScreenHeaderProgressWithButton import io.parity.signer.components.base.SignerDivider +import io.parity.signer.components.items.NetworkItemMultiselect import io.parity.signer.domain.Callback import io.parity.signer.domain.NetworkModel -import io.parity.signer.screens.keysets.create.backupstepscreens.NetworkItemMultiselect import io.parity.signer.screens.keysets.create.backupstepscreens.NetworkItemMultiselectAll import io.parity.signer.ui.BottomSheetWrapperContent import io.parity.signer.ui.theme.SignerNewTheme diff --git a/android/src/main/java/io/parity/signer/screens/scan/ScanNavSubgraph.kt b/android/src/main/java/io/parity/signer/screens/scan/ScanNavSubgraph.kt index 74075e21c1..8bc4f9356a 100644 --- a/android/src/main/java/io/parity/signer/screens/scan/ScanNavSubgraph.kt +++ b/android/src/main/java/io/parity/signer/screens/scan/ScanNavSubgraph.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import io.parity.signer.R @@ -25,6 +26,7 @@ import io.parity.signer.screens.scan.errors.TransactionErrorBottomSheet import io.parity.signer.screens.scan.errors.TransactionErrorModel import io.parity.signer.screens.scan.transaction.TransactionPreviewType import io.parity.signer.screens.scan.transaction.TransactionsScreenFull +import io.parity.signer.screens.scan.transaction.dynamicderivations.AddDerivedKeysScreen import io.parity.signer.screens.scan.transaction.previewType import io.parity.signer.ui.BottomSheetWrapperRoot import io.parity.signer.uniffi.Action @@ -40,13 +42,18 @@ fun ScanNavSubgraph( ) { val scanViewModel: ScanViewModel = viewModel() - val transactions = scanViewModel.transactions.collectAsState() - val signature = scanViewModel.signature.collectAsState() - val bananaSplitPassword = scanViewModel.bananaSplitPassword.collectAsState() + val transactions = scanViewModel.transactions.collectAsStateWithLifecycle() + val signature = scanViewModel.signature.collectAsStateWithLifecycle() + val bananaSplitPassword = + scanViewModel.bananaSplitPassword.collectAsStateWithLifecycle() + val dynamicDerivations = + scanViewModel.dynamicDerivations.collectAsStateWithLifecycle() - val transactionError = scanViewModel.transactionError.collectAsState() - val passwordModel = scanViewModel.passwordModel.collectAsState() - val errorWrongPassword = scanViewModel.errorWrongPassword.collectAsState() + val transactionError = + scanViewModel.transactionError.collectAsStateWithLifecycle() + val passwordModel = scanViewModel.passwordModel.collectAsStateWithLifecycle() + val errorWrongPassword = + scanViewModel.errorWrongPassword.collectAsStateWithLifecycle() val addedNetworkName: MutableState = remember { mutableStateOf(null) } @@ -69,6 +76,7 @@ fun ScanNavSubgraph( //Full screens val transactionsValue = transactions.value val bananaQrData = bananaSplitPassword.value + val dynamicDerivationsData = dynamicDerivations.value if (bananaQrData != null) { BananaSplitSubgraph( qrData = bananaQrData, @@ -97,15 +105,25 @@ fun ScanNavSubgraph( scanViewModel.bananaSplitPassword.value = null }, ) + } else if (dynamicDerivationsData != null) { + AddDerivedKeysScreen( + model = dynamicDerivationsData, + modifier = Modifier.statusBarsPadding(), + onBack = scanViewModel::clearState, + onDone = { scanViewModel.createDynamicDerivations(dynamicDerivationsData.keySet, context) }, + ) } else if (transactionsValue == null || showingModals) { ScanScreen( onClose = { navigateToPrevious() }, performPayloads = { payloads -> - scanViewModel.performPayload(payloads, context) + scanViewModel.performTransactionPayload(payloads, context) }, onBananaSplit = { payloads -> scanViewModel.bananaSplitPassword.value = payloads + }, + onDynamicDerivations = { payload -> + scanViewModel.performDynamicDerivationPayload(payload, context) } ) } else { diff --git a/android/src/main/java/io/parity/signer/screens/scan/ScanViewModel.kt b/android/src/main/java/io/parity/signer/screens/scan/ScanViewModel.kt index ee5a722686..a3b2e65802 100644 --- a/android/src/main/java/io/parity/signer/screens/scan/ScanViewModel.kt +++ b/android/src/main/java/io/parity/signer/screens/scan/ScanViewModel.kt @@ -4,22 +4,41 @@ import android.content.Context import android.util.Log import android.widget.Toast import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import io.parity.signer.R -import io.parity.signer.domain.backend.OperationResult -import io.parity.signer.domain.backend.mapError import io.parity.signer.bottomsheets.password.EnterPasswordModel import io.parity.signer.bottomsheets.password.toEnterPasswordModel import io.parity.signer.dependencygraph.ServiceLocator import io.parity.signer.domain.FakeNavigator +import io.parity.signer.domain.backend.OperationResult +import io.parity.signer.domain.backend.UniffiResult +import io.parity.signer.domain.backend.mapError import io.parity.signer.domain.storage.RepoResult import io.parity.signer.domain.storage.SeedRepository import io.parity.signer.screens.scan.errors.TransactionErrorModel import io.parity.signer.screens.scan.errors.toBottomSheetModel -import io.parity.signer.screens.scan.importderivations.* +import io.parity.signer.screens.scan.importderivations.ImportDerivedKeysRepository +import io.parity.signer.screens.scan.importderivations.ImportDerivedKeysRepository.ImportDerivedKeyError +import io.parity.signer.screens.scan.importderivations.allImportDerivedKeys +import io.parity.signer.screens.scan.importderivations.dominantImportError +import io.parity.signer.screens.scan.importderivations.hasImportableKeys +import io.parity.signer.screens.scan.importderivations.importableSeedKeysPreviews import io.parity.signer.screens.scan.transaction.isDisplayingErrorOnly import io.parity.signer.screens.scan.transaction.transactionIssues -import io.parity.signer.uniffi.* +import io.parity.signer.uniffi.Action +import io.parity.signer.uniffi.ActionResult +import io.parity.signer.uniffi.Card +import io.parity.signer.uniffi.DdKeySet +import io.parity.signer.uniffi.DdPreview +import io.parity.signer.uniffi.DerivedKeyError +import io.parity.signer.uniffi.MSignatureReady +import io.parity.signer.uniffi.MTransaction +import io.parity.signer.uniffi.ModalData +import io.parity.signer.uniffi.ScreenData +import io.parity.signer.uniffi.SeedKeysPreview +import io.parity.signer.uniffi.TransactionType import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch private const val TAG = "ScanViewModelTag" @@ -31,7 +50,7 @@ class ScanViewModel : ViewModel() { private val uniffiInteractor = ServiceLocator.uniffiInteractor private val seedRepository: SeedRepository by lazy { ServiceLocator.activityScope!!.seedRepository } - private val importKeysService: ImportDerivedKeysRepository by lazy { + private val importKeysRepository: ImportDerivedKeysRepository by lazy { ImportDerivedKeysRepository(seedRepository) } @@ -43,6 +62,8 @@ class ScanViewModel : ViewModel() { MutableStateFlow(null) var bananaSplitPassword: MutableStateFlow?> = MutableStateFlow(null) + var dynamicDerivations: MutableStateFlow = + MutableStateFlow(null) var passwordModel: MutableStateFlow = MutableStateFlow(null) val transactionError: MutableStateFlow = @@ -51,7 +72,7 @@ class ScanViewModel : ViewModel() { private val transactionIsInProgress = MutableStateFlow(false) - suspend fun performPayload(payload: String, context: Context) { + suspend fun performTransactionPayload(payload: String, context: Context) { val fakeNavigator = FakeNavigator() if (transactionIsInProgress.value) { Log.e(TAG, "started transaction while it was in progress, ignoring") @@ -63,8 +84,10 @@ class ScanViewModel : ViewModel() { when (navigateResponse) { is OperationResult.Err -> { - transactionError.value = navigateResponse.error.toBottomSheetModel(context) + transactionError.value = + navigateResponse.error.toBottomSheetModel(context) } + is OperationResult.Ok -> { val screenData = navigateResponse.result.screenData val transactions: List = @@ -101,6 +124,7 @@ class ScanViewModel : ViewModel() { passwordModel.value = modalData.f.toEnterPasswordModel(withShowError = false) } + is ModalData.SignatureReady -> { signature.value = modalData.f } @@ -112,6 +136,7 @@ class ScanViewModel : ViewModel() { } this.transactions.value = TransactionsState(transactions) } + TransactionType.IMPORT_DERIVATIONS -> { // We always need to `.goBack` as even if camera is dismissed without import, navigation "forward" already happened fakeNavigator.navigate(Action.GO_BACK) @@ -124,6 +149,7 @@ class ScanViewModel : ViewModel() { clearState() return } + DerivedKeyError.KeySetMissing -> { transactionError.value = TransactionErrorModel( title = context.getString(R.string.scan_screen_error_missing_key_set_title), @@ -132,6 +158,7 @@ class ScanViewModel : ViewModel() { clearState() return } + DerivedKeyError.NetworkMissing -> { transactionError.value = TransactionErrorModel( title = context.getString(R.string.scan_screen_error_missing_network_title), @@ -140,6 +167,7 @@ class ScanViewModel : ViewModel() { clearState() return } + null -> { //proceed, all good, now check if we need to update for derivations keys if (transactions.hasImportableKeys()) { @@ -150,7 +178,7 @@ class ScanViewModel : ViewModel() { } when (val result = - importKeysService.updateWithSeed(importDerivedKeys)) { + importKeysRepository.updateWithSeed(importDerivedKeys)) { is RepoResult.Success -> { val updatedKeys = result.result val newTransactionsState = @@ -161,6 +189,7 @@ class ScanViewModel : ViewModel() { this.transactions.value = TransactionsState(newTransactionsState) } + is RepoResult.Failure -> { Toast.makeText( /* context = */ context, @@ -181,6 +210,7 @@ class ScanViewModel : ViewModel() { } } } + else -> { // Transaction with error OR // Transaction that does not require signing (i.e. adding network or metadata) @@ -193,6 +223,38 @@ class ScanViewModel : ViewModel() { } } + suspend fun performDynamicDerivationPayload( + payload: String, + context: Context + ) { + when (val phrases = seedRepository.getAllSeeds()) { + is RepoResult.Failure -> { + Log.e( + TAG, + "cannot get seeds to show import dynamic derivations ${phrases.error}" + ) + } + + is RepoResult.Success -> { + val previewDynDerivations = + uniffiInteractor.previewDynamicDerivations(phrases.result, payload) + + when (previewDynDerivations) { + is UniffiResult.Error -> { + transactionError.value = TransactionErrorModel( + title = context.getString(R.string.dymanic_derivation_error_custom_title), + subtitle = previewDynDerivations.error.message ?: "", + ) + } + + is UniffiResult.Success -> { + dynamicDerivations.value = previewDynDerivations.result + } + } + } + } + } + private fun updateTransactionsWithImportDerivations( transactions: List, updatedKeys: List @@ -209,6 +271,7 @@ class ScanViewModel : ViewModel() { } ?: originalKey }) } + else -> { card //don't update @@ -222,6 +285,55 @@ class ScanViewModel : ViewModel() { } } + fun createDynamicDerivations( + toImport: DdKeySet, + context: Context + ) { + viewModelScope.launch { + if (toImport.derivations.isNotEmpty()) { + val result = importKeysRepository.createDynamicDerivationKeys( + seedName = toImport.seedName, + keysToImport = toImport.derivations + ) + + clearState() + when (result) { + is OperationResult.Err -> { + val errorMessage = when (result.error) { + is ImportDerivedKeyError.KeyNotImported -> + result.error.keyToError.joinToString(separator = "\n") { + context.getString( + R.string.dymanic_derivation_error_custom_message, + it.path, + it.errorLocalized + ) + } + + is ImportDerivedKeyError.NoKeysImported -> + result.error.errors.joinToString(separator = "\n") + + ImportDerivedKeyError.AuthFailed -> { + context.getString(R.string.auth_failed_message) + } + } + transactionError.value = TransactionErrorModel( + title = context.getString(R.string.dymanic_derivation_error_custom_title), + subtitle = errorMessage, + ) + } + + is OperationResult.Ok -> { + clearState() + Toast.makeText( + context, context.getString(R.string.create_derivations_success), + Toast.LENGTH_SHORT + ).show() + } + } + } + } + } + private fun areSeedKeysTheSameButUpdated( originalKey: SeedKeysPreview, resultKey: SeedKeysPreview @@ -234,7 +346,7 @@ class ScanViewModel : ViewModel() { val importableKeys = transactions.transactions.flatMap { it.importableSeedKeysPreviews() } - val importResult = importKeysService.importDerivedKeys(importableKeys) + val importResult = importKeysRepository.importDerivedKeys(importableKeys) val derivedKeysCount = importableKeys.sumOf { it.derivedKeys.size } when (importResult) { @@ -248,6 +360,7 @@ class ScanViewModel : ViewModel() { ), /* duration = */ Toast.LENGTH_LONG ).show() } + is RepoResult.Failure -> { Toast.makeText( /* context = */ context, @@ -272,6 +385,7 @@ class ScanViewModel : ViewModel() { || transactionIsInProgress.value || errorWrongPassword.value || bananaSplitPassword.value != null + || dynamicDerivations.value != null ) { clearState() true @@ -285,6 +399,7 @@ class ScanViewModel : ViewModel() { signature.value = null passwordModel.value = null bananaSplitPassword.value = null + dynamicDerivations.value = null transactionError.value = null transactionIsInProgress.value = false errorWrongPassword.value = false @@ -299,6 +414,7 @@ class ScanViewModel : ViewModel() { Log.w(TAG, "signature transactions failure ${phrases.error}") null } + is RepoResult.Success -> { uniffiInteractor.navigate( Action.GO_FORWARD, diff --git a/android/src/main/java/io/parity/signer/screens/scan/bananasplit/BananaSplitViewModel.kt b/android/src/main/java/io/parity/signer/screens/scan/bananasplit/BananaSplitViewModel.kt index da7e704689..46d9165153 100644 --- a/android/src/main/java/io/parity/signer/screens/scan/bananasplit/BananaSplitViewModel.kt +++ b/android/src/main/java/io/parity/signer/screens/scan/bananasplit/BananaSplitViewModel.kt @@ -15,8 +15,6 @@ import io.parity.signer.uniffi.QrSequenceDecodeException import io.parity.signer.uniffi.qrparserTryDecodeQrSequence import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map class BananaSplitViewModel() : ViewModel() { diff --git a/android/src/main/java/io/parity/signer/screens/scan/bananasplit/networks/BananaNetworksViewModel.kt b/android/src/main/java/io/parity/signer/screens/scan/bananasplit/networks/BananaNetworksViewModel.kt index 67e4add3ea..02863883fb 100644 --- a/android/src/main/java/io/parity/signer/screens/scan/bananasplit/networks/BananaNetworksViewModel.kt +++ b/android/src/main/java/io/parity/signer/screens/scan/bananasplit/networks/BananaNetworksViewModel.kt @@ -1,13 +1,9 @@ package io.parity.signer.screens.scan.bananasplit.networks import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import io.parity.signer.dependencygraph.ServiceLocator -import io.parity.signer.domain.Callback import io.parity.signer.domain.NetworkModel import io.parity.signer.domain.usecases.AllNetworksUseCase -import io.parity.signer.domain.usecases.CreateKeySetUseCase -import kotlinx.coroutines.launch class BananaNetworksViewModel : ViewModel() { diff --git a/android/src/main/java/io/parity/signer/screens/scan/camera/CameraViewModel.kt b/android/src/main/java/io/parity/signer/screens/scan/camera/CameraViewModel.kt index 0ef4bc1878..b27ec6cc7c 100644 --- a/android/src/main/java/io/parity/signer/screens/scan/camera/CameraViewModel.kt +++ b/android/src/main/java/io/parity/signer/screens/scan/camera/CameraViewModel.kt @@ -27,6 +27,11 @@ class CameraViewModel() : ViewModel() { val pendingTransactionPayloads: StateFlow> = _pendingTransactionPayloads.asStateFlow() + private val _dynamicDerivationPayload = + MutableStateFlow(null) + val dynamicDerivationPayload: StateFlow = + _dynamicDerivationPayload.asStateFlow() + private val _total = MutableStateFlow(null) private val _captured = MutableStateFlow(null) @@ -119,7 +124,8 @@ class CameraViewModel() : ViewModel() { } is DecodeSequenceResult.DynamicDerivations -> { - //todo dmitry new option for dynamic derivation + resetScanValues() + _dynamicDerivationPayload.value = payload.s } } @@ -144,6 +150,7 @@ class CameraViewModel() : ViewModel() { fun resetPendingTransactions() { _pendingTransactionPayloads.value = emptySet() _bananaSplitPayload.value = null + _dynamicDerivationPayload.value = null resetScanValues() } } diff --git a/android/src/main/java/io/parity/signer/screens/scan/camera/ScanScreen.kt b/android/src/main/java/io/parity/signer/screens/scan/camera/ScanScreen.kt index c73434a43b..a11856415f 100644 --- a/android/src/main/java/io/parity/signer/screens/scan/camera/ScanScreen.kt +++ b/android/src/main/java/io/parity/signer/screens/scan/camera/ScanScreen.kt @@ -25,6 +25,7 @@ import io.parity.signer.domain.KeepScreenOn import io.parity.signer.screens.scan.camera.* import io.parity.signer.ui.theme.SignerNewTheme import io.parity.signer.ui.theme.SignerTypeface +import io.parity.signer.uniffi.DecodeSequenceResult import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch @@ -34,6 +35,7 @@ fun ScanScreen( onClose: Callback, performPayloads: suspend (String) -> Unit, onBananaSplit: (List) -> Unit, + onDynamicDerivations: suspend (String) -> Unit, ) { val viewModel: CameraViewModel = viewModel() @@ -63,6 +65,15 @@ fun ScanScreen( onBananaSplit(qrData) } } + + launch { + viewModel.dynamicDerivationPayload + .filterNotNull() + .filter { it.isNotEmpty() } + .collect { qrData -> + onDynamicDerivations(qrData) + } + } } Box( @@ -192,7 +203,7 @@ private fun CameraViewPermission(viewModel: CameraViewModel) { private fun PreviewScanScreen() { SignerNewTheme { Box(modifier = Modifier.size(350.dp, 550.dp)) { - ScanScreen({}, { _ -> }, { _ -> }) + ScanScreen({}, { _ -> }, { _ -> }, { _ -> }) } } } diff --git a/android/src/main/java/io/parity/signer/screens/scan/importderivations/ImportDerivedKeysRepository.kt b/android/src/main/java/io/parity/signer/screens/scan/importderivations/ImportDerivedKeysRepository.kt index f4146d98bf..3cda20ce97 100644 --- a/android/src/main/java/io/parity/signer/screens/scan/importderivations/ImportDerivedKeysRepository.kt +++ b/android/src/main/java/io/parity/signer/screens/scan/importderivations/ImportDerivedKeysRepository.kt @@ -1,12 +1,17 @@ package io.parity.signer.screens.scan.importderivations +import io.parity.signer.domain.backend.OperationResult +import io.parity.signer.domain.getDebugDetailedDescriptionString import io.parity.signer.domain.storage.RepoResult import io.parity.signer.domain.storage.SeedRepository import io.parity.signer.domain.storage.mapError +import io.parity.signer.uniffi.DdDetail import io.parity.signer.uniffi.DerivedKeyStatus +import io.parity.signer.uniffi.ErrorDisplayed import io.parity.signer.uniffi.SeedKeysPreview import io.parity.signer.uniffi.importDerivations import io.parity.signer.uniffi.populateDerivationsHasPwd +import io.parity.signer.uniffi.tryCreateImportedAddress class ImportDerivedKeysRepository( @@ -36,4 +41,57 @@ class ImportDerivedKeysRepository( RepoResult.Failure(e) } } + + suspend fun createDynamicDerivationKeys( + seedName: String, + keysToImport: List, + ): OperationResult { + val seedPhrase = + when (val seedResult = seedRepository.getSeedPhraseForceAuth(seedName)) { + is RepoResult.Failure -> { + return OperationResult.Err(ImportDerivedKeyError.AuthFailed) + } + is RepoResult.Success -> seedResult.result + } + val occuredErrors = mutableListOf() + keysToImport.forEach { key -> + try { + tryCreateImportedAddress( + seedName = seedName, + seedPhrase = seedPhrase, + path = key.path, + network = key.networkSpecsKey, + ) + } catch (e: ErrorDisplayed) { + occuredErrors.add( + PathToError( + key.path, + e.message ?: "" + ) + ) + } catch (e: Error) { + occuredErrors.add(PathToError(key.path, e.localizedMessage ?: "")) + } + } + + return if (occuredErrors.isEmpty()) { + OperationResult.Ok(Unit) + } else if (occuredErrors.count() == keysToImport.count()) { + OperationResult.Err(ImportDerivedKeyError.NoKeysImported(occuredErrors.map { it.errorLocalized })) + } else { + OperationResult.Err(ImportDerivedKeyError.KeyNotImported(occuredErrors)) + } + } + + sealed class ImportDerivedKeyError { + data class NoKeysImported(val errors: List) : + ImportDerivedKeyError() + + data class KeyNotImported(val keyToError: List) : + ImportDerivedKeyError() + + object AuthFailed : ImportDerivedKeyError() + } + + data class PathToError(val path: String, val errorLocalized: String) } diff --git a/android/src/main/java/io/parity/signer/screens/scan/transaction/dynamicderivations/AddDerivedKeysScreen.kt b/android/src/main/java/io/parity/signer/screens/scan/transaction/dynamicderivations/AddDerivedKeysScreen.kt new file mode 100644 index 0000000000..688932a207 --- /dev/null +++ b/android/src/main/java/io/parity/signer/screens/scan/transaction/dynamicderivations/AddDerivedKeysScreen.kt @@ -0,0 +1,219 @@ +package io.parity.signer.screens.scan.transaction.dynamicderivations + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +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.platform.LocalInspectionMode +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.parity.signer.R +import io.parity.signer.components.ImageContent +import io.parity.signer.components.base.NotificationFrameTextImportant +import io.parity.signer.components.base.ScreenHeader +import io.parity.signer.components.base.SecondaryButtonWide +import io.parity.signer.components.base.SignerDivider +import io.parity.signer.components.qrcode.AnimatedQrKeysInfo +import io.parity.signer.components.qrcode.EmptyAnimatedQrKeysProvider +import io.parity.signer.components.qrcode.EmptyQrCodeProvider +import io.parity.signer.components.toImageContent +import io.parity.signer.domain.Callback +import io.parity.signer.domain.KeyModel +import io.parity.signer.domain.getData +import io.parity.signer.screens.keysetdetails.items.KeyDerivedItem +import io.parity.signer.ui.helpers.PreviewData +import io.parity.signer.ui.theme.SignerNewTheme +import io.parity.signer.ui.theme.SignerTypeface +import io.parity.signer.ui.theme.fill6 +import io.parity.signer.uniffi.DdDetail +import io.parity.signer.uniffi.DdKeySet +import io.parity.signer.uniffi.DdPreview +import io.parity.signer.uniffi.QrData +import io.parity.signer.uniffi.SignerImage + +@Composable +fun AddDerivedKeysScreen( + model: DdPreview, + modifier: Modifier = Modifier, + onBack: Callback, + onDone: Callback, +) { + BackHandler(onBack = onBack) + + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + ) { + ScreenHeader( + onBack = onBack, + title = null, + modifier = Modifier.padding(horizontal = 8.dp) + ) + Text( + text = stringResource(R.string.add_derived_keys_screen_title), + color = MaterialTheme.colors.primary, + style = SignerTypeface.TitleL, + modifier = Modifier.padding(horizontal = 24.dp), + ) + Text( + text = stringResource(R.string.add_derived_keys_screen_subtitle), + color = MaterialTheme.colors.primary, + style = SignerTypeface.BodyL, + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 20.dp), + ) + + if (model.isSomeAlreadyImported) { + NotificationFrameTextImportant( + message = stringResource(R.string.dymanic_derivation_error_some_already_imported), + withBorder = false, + textColor = MaterialTheme.colors.primary, + modifier = Modifier + .padding(bottom = 8.dp) + .padding(horizontal = 16.dp), + ) + } + if (model.isSomeNetworkMissing) { + NotificationFrameTextImportant( + message = stringResource(R.string.dymanic_derivation_error_some_network_missing), + withBorder = false, + textColor = MaterialTheme.colors.primary, + modifier = Modifier + .padding(bottom = 8.dp) + .padding(horizontal = 16.dp), + ) + } + + KeysetItemDerivedItem(model.keySet) + + Text( + text = stringResource(R.string.add_derived_keys_screen_scan_qr_code), + color = MaterialTheme.colors.primary, + style = SignerTypeface.BodyL, + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(top = 16.dp, bottom = 20.dp), + ) + if (LocalInspectionMode.current) { + AnimatedQrKeysInfo( + input = Unit, + provider = EmptyAnimatedQrKeysProvider(), + modifier = Modifier.padding(horizontal = 24.dp) + ) + } else { + AnimatedQrKeysInfo>>( + input = model.qr.map { it.getData() }, + provider = EmptyQrCodeProvider(), + modifier = Modifier.padding(horizontal = 24.dp) + ) + } + + SecondaryButtonWide( + label = stringResource(R.string.transaction_action_done), + withBackground = true, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 32.dp), + onClicked = onDone, + ) + } +} + +@Composable +private fun KeysetItemDerivedItem(model: DdKeySet) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 4.dp) + .background( + MaterialTheme.colors.fill6, + RoundedCornerShape(dimensionResource(id = R.dimen.plateDefaultCornerRadius)) + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = model.seedName, + color = MaterialTheme.colors.primary, + style = SignerTypeface.TitleS, + modifier = Modifier.padding(16.dp) + ) + Spacer(modifier = Modifier.weight(1f)) + } + model.derivations.forEach { key -> + SignerDivider() + KeyDerivedItem(model = key.toKeyModel(), key.networkLogo, onClick = null) + } + } +} + + +private fun DdDetail.toKeyModel() = KeyModel( + identicon = identicon.toImageContent(), + addressKey = "", + seedName = "", + base58 = base58, + hasPwd = false, + path = path, + secretExposed = false, + wasImported = null +) + +private fun ddPreviewcreateStub(): DdPreview = DdPreview( + qr = listOf( + QrData.Regular(PreviewData.exampleQRData), + ), + keySet = DdKeySet( + seedName = "My special keyset", + derivations = listOf( + ddDetailcreateStub(), + ddDetailcreateStub(), + ), + ), + isSomeAlreadyImported = false, + isSomeNetworkMissing = true, +) + +@OptIn(ExperimentalUnsignedTypes::class) +private fun ddDetailcreateStub(): DdDetail = DdDetail( + base58 = "5F3sa2TJAWMqDhXG6jhV4N8ko9SxwGy8TpaNS1repo5EYjQX", + path = "//polkadot//path2", + networkLogo = "westend", + networkSpecsKey = "sdfsdfgdfg", + identicon = SignerImage.Png( + (PreviewData.Identicon.exampleIdenticonPng as ImageContent.Png) + .image.toUByteArray().toList() + ), +) + + +@Preview( + name = "light", group = "general", uiMode = Configuration.UI_MODE_NIGHT_NO, + showBackground = true, backgroundColor = 0xFFFFFFFF, +) +@Preview( + name = "dark", group = "general", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, backgroundColor = 0xFF000000, +) +@Composable +private fun PreviewAddDerivedKeysScreen() { + SignerNewTheme { + AddDerivedKeysScreen( + model = ddPreviewcreateStub(), + onBack = {}, + onDone = {}, + ) + } +} diff --git a/android/src/main/java/io/parity/signer/screens/settings/backup/SeedBackupViewModel.kt b/android/src/main/java/io/parity/signer/screens/settings/backup/SeedBackupViewModel.kt index 0edcd1295f..8ee5b9c604 100644 --- a/android/src/main/java/io/parity/signer/screens/settings/backup/SeedBackupViewModel.kt +++ b/android/src/main/java/io/parity/signer/screens/settings/backup/SeedBackupViewModel.kt @@ -1,15 +1,13 @@ package io.parity.signer.screens.settings.backup import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import io.parity.signer.dependencygraph.ServiceLocator import io.parity.signer.domain.getSeedPhraseForBackup -import kotlinx.coroutines.runBlocking internal class SeedBackupViewModel() : ViewModel() { - val seedStorage = ServiceLocator.seedStorage + private val seedStorage = ServiceLocator.seedStorage fun getSeeds(): List { return seedStorage.getSeedNames().toList() diff --git a/android/src/main/java/io/parity/signer/screens/settings/logs/LogsViewModel.kt b/android/src/main/java/io/parity/signer/screens/settings/logs/LogsViewModel.kt index 3645c0938a..9a2ab8a488 100644 --- a/android/src/main/java/io/parity/signer/screens/settings/logs/LogsViewModel.kt +++ b/android/src/main/java/io/parity/signer/screens/settings/logs/LogsViewModel.kt @@ -11,7 +11,7 @@ import io.parity.signer.domain.backend.UniffiResult import io.parity.signer.dependencygraph.ServiceLocator import io.parity.signer.domain.AuthResult import io.parity.signer.domain.findActivity -import io.parity.signer.domain.getDetailedDescriptionString +import io.parity.signer.domain.getDebugDetailedDescriptionString import io.parity.signer.uniffi.MLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -36,7 +36,7 @@ class LogsViewModel() : ViewModel() { when (val result = withContext(Dispatchers.IO) { uniffiInteractor.getLogs() }) { is UniffiResult.Error -> { - val error = result.error.getDetailedDescriptionString() + val error = result.error.getDebugDetailedDescriptionString() Log.e(TAG, "Unexpected error getLogs, $error") _logsState.value = CompletableResult.Err(error) } @@ -50,7 +50,7 @@ class LogsViewModel() : ViewModel() { return when (val result = withContext(Dispatchers.IO) { uniffiInteractor.addCommentToLogs(logNote) }) { is UniffiResult.Error -> { - val error = result.error.getDetailedDescriptionString() + val error = result.error.getDebugDetailedDescriptionString() Log.e(TAG, "Unexpected error addNote, $error") OperationResult.Err(error) } @@ -80,7 +80,7 @@ class LogsViewModel() : ViewModel() { return when (val result = withContext(Dispatchers.IO) { uniffiInteractor.clearLogHistory() }) { is UniffiResult.Error -> { - val error = result.error.getDetailedDescriptionString() + val error = result.error.getDebugDetailedDescriptionString() Log.e(TAG, "Unexpected error clear logs, $error") OperationResult.Err(error) } diff --git a/android/src/main/java/io/parity/signer/screens/settings/logs/logdetails/LogsDetailsViewModel.kt b/android/src/main/java/io/parity/signer/screens/settings/logs/logdetails/LogsDetailsViewModel.kt index 5ecf4c4967..367bb2233c 100644 --- a/android/src/main/java/io/parity/signer/screens/settings/logs/logdetails/LogsDetailsViewModel.kt +++ b/android/src/main/java/io/parity/signer/screens/settings/logs/logdetails/LogsDetailsViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.ViewModel import io.parity.signer.domain.backend.CompletableResult import io.parity.signer.domain.backend.UniffiResult import io.parity.signer.dependencygraph.ServiceLocator -import io.parity.signer.domain.getDetailedDescriptionString +import io.parity.signer.domain.getDebugDetailedDescriptionString import io.parity.signer.uniffi.MLogDetails import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -28,7 +28,7 @@ class LogsDetailsViewModel(): ViewModel() { uniffiInteractor.getLogDetails(index) }) { is UniffiResult.Error -> { - val error = result.error.getDetailedDescriptionString() + val error = result.error.getDebugDetailedDescriptionString() Log.e(TAG, "Unexpected error getLogs, $error") _logsState.value = CompletableResult.Err(error) } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 77bd670d4a..678714c986 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -414,6 +414,19 @@ Derived Keys have been created Create Derived Key Public key + Add Derived Keys + Сheck the keys and scan QR code into Omni Wallet app + Scan QR code to add the keys + Imported from Omni Wallet + Clear + Filter keys by network + Key Set has no Keys on Selected Network + Some are hidden from the list because they have already been imported. + "Some keys can not be imported until their networks are added. Please add missing networks and their metadata. " + Derived key %1$s could not be created: %2$s + There was an issue importing keys. + +