diff --git a/android/app/src/main/java/it/airgap/vault/plugin/isolatedmodules/FileExplorer.kt b/android/app/src/main/java/it/airgap/vault/plugin/isolatedmodules/FileExplorer.kt index d00a9e4b..e010374d 100644 --- a/android/app/src/main/java/it/airgap/vault/plugin/isolatedmodules/FileExplorer.kt +++ b/android/app/src/main/java/it/airgap/vault/plugin/isolatedmodules/FileExplorer.kt @@ -14,7 +14,12 @@ interface StaticSourcesExplorer { fun readIsolatedModulesScript(): ByteArray } -interface DynamicSourcesExplorer { +interface DynamicSourcesExplorer { + fun removeModules(identifiers: List) + fun removeAllModules() +} + +interface SourcesExplorer { fun listModules(): List fun readModuleSources(module: M): Sequence @@ -55,6 +60,14 @@ class FileExplorer private constructor( } } + fun removeInstalledModules(identifiers: List) { + filesExplorer.removeModules(identifiers) + } + + fun removeAllInstalledModules() { + filesExplorer.removeAllModules() + } + fun readModuleSources(module: JSModule): Sequence = when (module) { is JSModule.Asset -> assetsExplorer.readModuleSources(module) @@ -70,7 +83,7 @@ class FileExplorer private constructor( } private fun loadModules( - explorer: DynamicSourcesExplorer, + explorer: SourcesExplorer, constructor: (identifier: String, namespace: String?, preferredEnvironment: JSEnvironment.Type, paths: List) -> T, ): List = explorer.listModules().map { module -> val manifest = JSObject(explorer.readModuleManifest(module).decodeToString()) @@ -96,7 +109,7 @@ class FileExplorer private constructor( } } -private class AssetsExplorer(private val context: Context) : StaticSourcesExplorer, DynamicSourcesExplorer { +private class AssetsExplorer(private val context: Context) : StaticSourcesExplorer, SourcesExplorer { override fun readJavaScriptEngineUtils(): ByteArray = context.assets.readBytes(JAVA_SCRIPT_ENGINE_UTILS) override fun readIsolatedModulesScript(): ByteArray = context.assets.readBytes(SCRIPT) @@ -117,10 +130,20 @@ private class AssetsExplorer(private val context: Context) : StaticSourcesExplor } } -private class FilesExplorer(private val context: Context) : DynamicSourcesExplorer { +private class FilesExplorer(private val context: Context) : DynamicSourcesExplorer, SourcesExplorer { private val modulesDir: File get() = File(context.filesDir, MODULES_DIR) + override fun removeModules(identifiers: List) { + identifiers.forEach { + File(modulesDir, it).deleteRecursively() + } + } + + override fun removeAllModules() { + modulesDir.deleteRecursively() + } + override fun listModules(): List = modulesDir.list()?.toList() ?: emptyList() override fun readModuleSources(module: JSModule.Installed): Sequence = diff --git a/android/app/src/main/java/it/airgap/vault/plugin/isolatedmodules/IsolatedModules.kt b/android/app/src/main/java/it/airgap/vault/plugin/isolatedmodules/IsolatedModules.kt index ed1c7ac5..dd93052c 100644 --- a/android/app/src/main/java/it/airgap/vault/plugin/isolatedmodules/IsolatedModules.kt +++ b/android/app/src/main/java/it/airgap/vault/plugin/isolatedmodules/IsolatedModules.kt @@ -18,7 +18,7 @@ class IsolatedModules : Plugin() { private val fileExplorer: FileExplorer by lazy { FileExplorer(context) } @PluginMethod - fun previewModule(call: PluginCall) { + fun previewDynamicModule(call: PluginCall) { call.executeCatching { assertReceived(Param.PATH, Param.DIRECTORY) @@ -44,7 +44,7 @@ class IsolatedModules : Plugin() { } @PluginMethod - fun registerModule(call: PluginCall) { + fun registerDynamicModule(call: PluginCall) { call.executeCatching { assertReceived(Param.IDENTIFIER, Param.PROTOCOL_IDENTIFIERS) @@ -60,7 +60,26 @@ class IsolatedModules : Plugin() { } @PluginMethod - fun loadModules(call: PluginCall) { + fun removeDynamicModules(call: PluginCall) { + activity.lifecycleScope.launch { + call.executeCatching { + val jsEvaluator = jsEvaluator.await() + + identifiers?.let { + fileExplorer.removeInstalledModules(it) + jsEvaluator.deregisterModules(it) + } ?: run { + fileExplorer.removeAllInstalledModules() + jsEvaluator.deregisterAllModules() + } + + resolve() + } + } + } + + @PluginMethod + fun loadAllModules(call: PluginCall) { activity.lifecycleScope.launch { call.executeCatching { val modules = fileExplorer.loadAssetModules() + fileExplorer.loadInstalledModules() @@ -117,6 +136,9 @@ class IsolatedModules : Plugin() { private val PluginCall.identifier: String get() = getString(Param.IDENTIFIER)!! + private val PluginCall.identifiers: List? + get() = getArray(Param.PROTOCOL_IDENTIFIERS, null)?.toList() + private val PluginCall.protocolIdentifiers: List get() = getArray(Param.PROTOCOL_IDENTIFIERS).toList() @@ -145,6 +167,7 @@ class IsolatedModules : Plugin() { const val PATH = "path" const val DIRECTORY = "directory" const val IDENTIFIER = "identifier" + const val IDENTIFIERS = "identifiers" const val PROTOCOL_IDENTIFIERS = "protocolIdentifiers" const val PROTOCOL_TYPE = "protocolType" const val TARGET = "target" diff --git a/android/app/src/main/java/it/airgap/vault/plugin/isolatedmodules/js/JSEvaluator.kt b/android/app/src/main/java/it/airgap/vault/plugin/isolatedmodules/js/JSEvaluator.kt index ae31f440..d6b8566c 100644 --- a/android/app/src/main/java/it/airgap/vault/plugin/isolatedmodules/js/JSEvaluator.kt +++ b/android/app/src/main/java/it/airgap/vault/plugin/isolatedmodules/js/JSEvaluator.kt @@ -22,6 +22,14 @@ class JSEvaluator constructor( module.registerFor(protocolIdentifiers) } + fun deregisterModules(identifiers: List) { + identifiers.forEach { modules.remove(it) } + } + + fun deregisterAllModules() { + modules.clear() + } + suspend fun evaluatePreviewModule(module: JSModule): JSObject = module.environment.run(module, JSModuleAction.Load(null)).also { module.appendType(it) diff --git a/ios/App/App/IsolatedModules/FileExplorer.swift b/ios/App/App/IsolatedModules/FileExplorer.swift index 96fa4ed9..4362c8a3 100644 --- a/ios/App/App/IsolatedModules/FileExplorer.swift +++ b/ios/App/App/IsolatedModules/FileExplorer.swift @@ -55,6 +55,14 @@ struct FileExplorer { }) } + func removeModules(_ identifiers: [String]) throws { + try documentExplorer.removeModules(identifiers) + } + + func removeAllModules() throws { + try documentExplorer.removeAllModules() + } + func readModuleSources(_ module: JSModule) throws -> [Data] { switch module { case .asset(let asset): @@ -77,7 +85,7 @@ struct FileExplorer { } } - private func loadModules( + private func loadModules( using explorer: E, creatingModuleWith moduleInit: (_ identifier: String, _ namespace: String?, _ preferredEnvironment: JSEnvironmentKind, _ sources: [String]) -> T ) throws -> [T] where E.T == T { @@ -107,7 +115,7 @@ struct FileExplorer { // MARK: AssetsExplorer -private struct AssetsExplorer: DynamicSourcesExplorer { +private struct AssetsExplorer: SourcesExplorer { typealias T = JSModule.Asset static let assetsURL: URL = Bundle.main.url(forResource: "public", withExtension: nil)!.appendingPathComponent("assets") @@ -149,23 +157,49 @@ private struct AssetsExplorer: DynamicSourcesExplorer { // MARK: DocumentExplorer -private struct DocumentExplorer: DynamicSourcesExplorer { +private struct DocumentExplorer: SourcesExplorer, DynamicSourcesExplorer { typealias T = JSModule.Instsalled private static let modulesDir: String = "protocol_modules" private let fileManager: FileManager + private var documentsURL: URL? { fileManager.urls(for: .documentDirectory, in: .userDomainMask).first } + private var modulesDirURL: URL? { documentsURL?.appendingPathComponent(Self.modulesDir) } + init(fileManager: FileManager) { self.fileManager = fileManager } + func removeModules(_ identifiers: [String]) throws { + guard let modulesDirURL = modulesDirURL else { + return + } + + var isDirectory: ObjCBool = true + try identifiers.forEach { + if fileManager.fileExists(atPath: modulesDirURL.path, isDirectory: &isDirectory) { + try fileManager.removeItem(at: modulesDirURL.appendingPathComponent($0)) + } + } + } + + func removeAllModules() throws { + var isDirectory: ObjCBool = true + guard let modulesDirURL = modulesDirURL, fileManager.fileExists(atPath: modulesDirURL.path, isDirectory: &isDirectory) else { + return + } + + try fileManager.removeItem(at: modulesDirURL) + } + func listModules() throws -> [String] { - guard let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + guard let modulesDirURL = modulesDirURL else { return [] } - let modulesDirPath = url.appendingPathComponent(Self.modulesDir).path + let modulesDirPath = modulesDirURL.path + guard fileManager.fileExists(atPath: modulesDirPath) else { return [] } @@ -194,9 +228,9 @@ private struct DocumentExplorer: DynamicSourcesExplorer { } } -// MARK: DynamicSourcesExplorer +// MARK: SourcesExplorer -private protocol DynamicSourcesExplorer { +private protocol SourcesExplorer { associatedtype T func listModules() throws -> [String] @@ -205,6 +239,11 @@ private protocol DynamicSourcesExplorer { func readModuleManifest(_ module: String) throws -> Data } +private protocol DynamicSourcesExplorer { + func removeModules(_ identifiers: [String]) throws + func removeAllModules() throws +} + // MARK: Extensions private extension FileExplorer { diff --git a/ios/App/App/IsolatedModules/IsolatedModules.m b/ios/App/App/IsolatedModules/IsolatedModules.m index de5aa560..aa716e93 100644 --- a/ios/App/App/IsolatedModules/IsolatedModules.m +++ b/ios/App/App/IsolatedModules/IsolatedModules.m @@ -10,8 +10,9 @@ // disable true isolation until it's production ready //CAP_PLUGIN(IsolatedModules, "IsolatedModules", -// CAP_PLUGIN_METHOD(previewModule, CAPPluginReturnPromise); -// CAP_PLUGIN_METHOD(registerModule, CAPPluginReturnPromise); -// CAP_PLUGIN_METHOD(loadModules, CAPPluginReturnPromise); +// CAP_PLUGIN_METHOD(previewDynamicModule, CAPPluginReturnPromise); +// CAP_PLUGIN_METHOD(registerDynamicModule, CAPPluginReturnPromise); +// CAP_PLUGIN_METHOD(removeDynamicModules, CAPPluginReturnPromise); +// CAP_PLUGIN_METHOD(loadAllModules, CAPPluginReturnPromise); // CAP_PLUGIN_METHOD(callMethod, CAPPluginReturnPromise); //) diff --git a/ios/App/App/IsolatedModules/IsolatedModules.swift b/ios/App/App/IsolatedModules/IsolatedModules.swift index f2212cc1..38dc786a 100644 --- a/ios/App/App/IsolatedModules/IsolatedModules.swift +++ b/ios/App/App/IsolatedModules/IsolatedModules.swift @@ -14,7 +14,7 @@ public class IsolatedModules: CAPPlugin { private let fileExplorer: FileExplorer = .shared private lazy var jsEvaluator: JSEvaluator = .init(fileExplorer: fileExplorer) - @objc func previewModule(_ call: CAPPluginCall) { + @objc func previewDynamicModule(_ call: CAPPluginCall) { call.assertReceived(forMethod: "previewModule", requiredParams: Param.PATH, Param.DIRECTORY) do { @@ -41,7 +41,7 @@ public class IsolatedModules: CAPPlugin { } } - @objc func registerModule(_ call: CAPPluginCall) { + @objc func registerDynamicModule(_ call: CAPPluginCall) { call.assertReceived(forMethod: "registerModule", requiredParams: Param.IDENTIFIER, Param.PROTOCOL_IDENTIFIERS) do { @@ -64,7 +64,24 @@ public class IsolatedModules: CAPPlugin { } } - @objc func loadModules(_ call: CAPPluginCall) { + @objc func removeDynamicModules(_ call: CAPPluginCall) { + Task { + do { + if let identifiers = call.identifiers { + try fileExplorer.removeModules(identifiers) + await jsEvaluator.deregisterModules(identifiers) + } else { + try fileExplorer.removeAllModules() + await jsEvaluator.deregisterAllModules() + } + call.resolve() + } catch { + call.reject("Error: \(error)") + } + } + } + + @objc func loadAllModules(_ call: CAPPluginCall) { Task { do { let protocolType = call.protocolType @@ -152,6 +169,7 @@ public class IsolatedModules: CAPPlugin { static let PATH = "path" static let DIRECTORY = "directory" static let IDENTIFIER = "identifier" + static let IDENTIFIERS = "identifiers" static let PROTOCOL_IDENTIFIERS = "protocolIdentifiers" static let PROTOCOL_TYPE = "protocolType" static let TARGET = "target" @@ -176,6 +194,15 @@ private extension CAPPluginCall { } var identifier: String? { return getString(IsolatedModules.Param.IDENTIFIER) } + var identifiers: [String]? { + return getArray(IsolatedModules.Param.IDENTIFIERS)?.compactMap { + if let string = $0 as? String { + return string + } else { + return nil + } + } + } var protocolIdentifiers: [String]? { return getArray(IsolatedModules.Param.PROTOCOL_IDENTIFIERS)?.compactMap { if let string = $0 as? String { diff --git a/ios/App/App/IsolatedModules/JS/JSEvaluator.swift b/ios/App/App/IsolatedModules/JS/JSEvaluator.swift index 2ca510f5..1957ad8f 100644 --- a/ios/App/App/IsolatedModules/JS/JSEvaluator.swift +++ b/ios/App/App/IsolatedModules/JS/JSEvaluator.swift @@ -22,6 +22,14 @@ class JSEvaluator { await modulesManager.registerModule(module, forProtocols: protocolIdentifiers) } + func deregisterModules(_ identifiers: [String]) async { + await modulesManager.deregisterModules(identifiers) + } + + func deregisterAllModules() async { + await modulesManager.deregisterAllModules() + } + func evaluatePreviewModule(_ module: JSModule) async throws -> [String: Any] { return try await self.webViewEnv.run(.load(.init(protocolType: nil)), in: module) } @@ -146,6 +154,14 @@ class JSEvaluator { modules[module.identifier] = module protocolIdentifiers.forEach { identifier in modules[identifier] = module } } + + func deregisterModules(_ identifiers: [String]) { + identifiers.forEach { modules.removeValue(forKey: $0) } + } + + func deregisterAllModules() { + modules.removeAll() + } } enum Error: Swift.Error { diff --git a/package.json b/package.json index 0fc89176..052c956a 100644 --- a/package.json +++ b/package.json @@ -37,45 +37,45 @@ "apply-diagnostic-modules": "node apply-diagnostic-modules.js" }, "resolutions": { - "@airgap/aeternity": "0.13.12", - "@airgap/astar": "0.13.12", - "@airgap/bitcoin": "0.13.12", - "@airgap/coinlib-core": "0.13.12", - "@airgap/coreum": "0.13.12", - "@airgap/cosmos": "0.13.12", - "@airgap/cosmos-core": "0.13.12", - "@airgap/crypto": "0.13.12", - "@airgap/ethereum": "0.13.12", - "@airgap/groestlcoin": "0.13.12", - "@airgap/icp": "0.13.12", - "@airgap/module-kit": "0.13.12", - "@airgap/moonbeam": "0.13.12", - "@airgap/polkadot": "0.13.12", - "@airgap/serializer": "0.13.12", - "@airgap/substrate": "0.13.12", - "@airgap/tezos": "0.13.12" + "@airgap/aeternity": "0.13.15", + "@airgap/astar": "0.13.15", + "@airgap/bitcoin": "0.13.15", + "@airgap/coinlib-core": "0.13.15", + "@airgap/coreum": "0.13.15", + "@airgap/cosmos": "0.13.15", + "@airgap/cosmos-core": "0.13.15", + "@airgap/crypto": "0.13.15", + "@airgap/ethereum": "0.13.15", + "@airgap/groestlcoin": "0.13.15", + "@airgap/icp": "0.13.15", + "@airgap/module-kit": "0.13.15", + "@airgap/moonbeam": "0.13.15", + "@airgap/polkadot": "0.13.15", + "@airgap/serializer": "0.13.15", + "@airgap/substrate": "0.13.15", + "@airgap/tezos": "0.13.15" }, "dependencies": { - "@airgap/aeternity": "0.13.12", - "@airgap/angular-core": "0.0.36", - "@airgap/angular-ngrx": "0.0.36", - "@airgap/astar": "0.13.12", - "@airgap/bitcoin": "0.13.12", - "@airgap/coinlib-core": "0.13.12", - "@airgap/coreum": "0.13.12", - "@airgap/cosmos": "0.13.12", - "@airgap/cosmos-core": "0.13.12", - "@airgap/crypto": "0.13.12", - "@airgap/ethereum": "0.13.12", - "@airgap/groestlcoin": "0.13.12", - "@airgap/icp": "0.13.12", - "@airgap/module-kit": "0.13.12", - "@airgap/moonbeam": "0.13.12", - "@airgap/polkadot": "0.13.12", + "@airgap/aeternity": "0.13.15", + "@airgap/angular-core": "0.0.37", + "@airgap/angular-ngrx": "0.0.37", + "@airgap/astar": "0.13.15", + "@airgap/bitcoin": "0.13.15", + "@airgap/coinlib-core": "0.13.15", + "@airgap/coreum": "0.13.15", + "@airgap/cosmos": "0.13.15", + "@airgap/cosmos-core": "0.13.15", + "@airgap/crypto": "0.13.15", + "@airgap/ethereum": "0.13.15", + "@airgap/groestlcoin": "0.13.15", + "@airgap/icp": "0.13.15", + "@airgap/module-kit": "0.13.15", + "@airgap/moonbeam": "0.13.15", + "@airgap/polkadot": "0.13.15", "@airgap/sapling-wasm": "0.0.7", - "@airgap/serializer": "0.13.12", - "@airgap/substrate": "0.13.12", - "@airgap/tezos": "0.13.12", + "@airgap/serializer": "0.13.15", + "@airgap/substrate": "0.13.15", + "@airgap/tezos": "0.13.15", "@angular/common": "13.2.5", "@angular/core": "13.2.5", "@angular/forms": "13.2.5", diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 11154fd7..25b5804a 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -78,6 +78,48 @@ const routes: Routes = [ loadChildren: () => import('./pages/secret-generate-onboarding/secret-generate-onboarding.module').then((m) => m.SecretGenerateOnboardingPageModule) }, + { + path: 'social-recovery-generate-intro', + loadChildren: () => + import('./pages/social-recovery-generate-intro/social-recovery-generate-intro.module').then( + (m) => m.SocialRecoveryGenerateIntroPageModule + ) + }, + { + path: 'social-recovery-generate-setup', + loadChildren: () => + import('./pages/social-recovery-generate-setup/social-recovery-generate-setup.module').then( + (m) => m.SocialRecoveryGenerateSetupPageModule + ) + }, + { + path: 'social-recovery-generate-rules', + loadChildren: () => + import('./pages/social-recovery-generate-rules/social-recovery-generate-rules.module').then( + (m) => m.SocialRecoveryGenerateRulesPageModule + ) + }, + { + path: 'social-recovery-generate-share-show', + loadChildren: () => + import('./pages/social-recovery-generate-share-show/social-recovery-generate-share-show.module').then( + (m) => m.SocialRecoveryGenerateShareShowPageModule + ) + }, + { + path: 'social-recovery-generate-share-validate', + loadChildren: () => + import('./pages/social-recovery-generate-share-validate/social-recovery-generate-share-validate.module').then( + (m) => m.SocialRecoveryGenerateShareValidatePageModule + ) + }, + { + path: 'social-recovery-generate-finish', + loadChildren: () => + import('./pages/social-recovery-generate-finish/social-recovery-generate-finish.module').then( + (m) => m.SocialRecoveryGenerateFinishPageModule + ) + }, { path: 'social-recovery-import', loadChildren: () => import('./pages/social-recovery-import/social-recovery-import.module').then((m) => m.SocialRecoveryImportPageModule) @@ -104,16 +146,24 @@ const routes: Routes = [ }, { path: 'contact-book-contacts-detail', - loadChildren: () => import('./pages/contact-book-contacts-detail/contact-book-contacts-detail.module').then((m) => m.ContactBookContactsDetailPageModule) + loadChildren: () => + import('./pages/contact-book-contacts-detail/contact-book-contacts-detail.module').then((m) => m.ContactBookContactsDetailPageModule) }, { path: 'contact-book-onboarding', - loadChildren: () => import('./pages/contact-book-onboarding/contact-book-onboarding.module').then((m) => m.ContactBookOnboardingPageModule) + loadChildren: () => + import('./pages/contact-book-onboarding/contact-book-onboarding.module').then((m) => m.ContactBookOnboardingPageModule) }, { path: 'contact-book-settings', loadChildren: () => import('./pages/contact-book-settings/contact-book-settings.module').then((m) => m.ContactBookOnboardingPageModule) }, + + { + path: 'contact-book-scan', + loadChildren: () => import('./pages/contact-book-scan/contact-book-scan.module').then((m) => m.ContactBookScanPageModule) + }, + { path: 'deserialized-detail', loadChildren: () => import('./pages/deserialized-detail/deserialized-detail.module').then((m) => m.DeserializedDetailPageModule) @@ -146,10 +196,6 @@ const routes: Routes = [ path: 'accounts-list', loadChildren: () => import('./pages/accounts-list/accounts-list.module').then((m) => m.AccountsListPageModule) }, - { - path: 'danger-zone', - loadChildren: () => import('./pages/danger-zone/danger-zone.module').then((m) => m.DangerZonePageModule) - }, { path: 'secret-generate-dice', loadChildren: () => import('./pages/secret-generate-dice/secret-generate-dice.module').then((m) => m.SecretGenerateDicePageModule) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index d85482bc..a60091d6 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,11 @@ -import { APP_PLUGIN, IACMessageTransport, IsolatedModulesService, ProtocolService, SPLASH_SCREEN_PLUGIN, STATUS_BAR_PLUGIN } from '@airgap/angular-core' +import { + APP_PLUGIN, + IACMessageTransport, + IsolatedModulesService, + ProtocolService, + SPLASH_SCREEN_PLUGIN, + STATUS_BAR_PLUGIN +} from '@airgap/angular-core' import { MainProtocolSymbols } from '@airgap/coinlib-core' import { TezosSaplingExternalMethodProvider, @@ -11,7 +18,7 @@ import { AfterViewInit, Component, Inject, NgZone } from '@angular/core' import { AppPlugin, URLOpenListenerEvent } from '@capacitor/app' import { SplashScreenPlugin } from '@capacitor/splash-screen' import { StatusBarPlugin, Style } from '@capacitor/status-bar' -import { Platform } from '@ionic/angular' +import { ModalController, Platform } from '@ionic/angular' import { TranslateService } from '@ngx-translate/core' import { first } from 'rxjs/operators' @@ -27,6 +34,7 @@ import { SaplingNativeService } from './services/sapling-native/sapling-native.s import { SecretsService } from './services/secrets/secrets.service' import { StartupChecksService } from './services/startup-checks/startup-checks.service' import { LanguagesType, VaultStorageKey, VaultStorageService } from './services/storage/storage.service' +import { Router } from '@angular/router' declare let window: Window & { airGapHasStarted: boolean } @@ -57,6 +65,9 @@ export class AppComponent implements AfterViewInit { private readonly httpClient: HttpClient, private readonly saplingNativeService: SaplingNativeService, private readonly isolatedModuleService: IsolatedModulesService, + private readonly router: Router, + private readonly modalController: ModalController, + @Inject(APP_PLUGIN) private readonly app: AppPlugin, @Inject(SECURITY_UTILS_PLUGIN) private readonly securityUtils: SecurityUtilsPlugin, @Inject(SPLASH_SCREEN_PLUGIN) private readonly splashScreen: SplashScreenPlugin, @@ -89,6 +100,19 @@ export class AppComponent implements AfterViewInit { public async ngAfterViewInit(): Promise { await this.platform.ready() + if (this.platform.is('android')) { + this.platform.backButton.subscribeWithPriority(-1, async () => { + if (this.modalController.getTop()) { + const modal = await this.modalController.getTop() + if (modal) { + this.modalController.dismiss() + return + } + } else { + this.navigationService.handleAndroidBackNavigation(this.router.url) + } + }) + } this.app.addListener('appUrlOpen', async (data: URLOpenListenerEvent) => { await this.isInitialized.promise if (data.url === DEEPLINK_VAULT_PREFIX || data.url.startsWith(DEEPLINK_VAULT_ADD_ACCOUNT)) { @@ -138,8 +162,9 @@ export class AppComponent implements AfterViewInit { private async initializeProtocols(): Promise { const protocols = await this.isolatedModuleService.loadProtocols('offline', [MainProtocolSymbols.XTZ_SHIELDED]) - const externalMethodProvider: TezosSaplingExternalMethodProvider | undefined = - await this.saplingNativeService.createExternalMethodProvider() + const externalMethodProvider: + | TezosSaplingExternalMethodProvider + | undefined = await this.saplingNativeService.createExternalMethodProvider() const shieldedTezProtocol: TezosShieldedTezProtocol = new TezosShieldedTezProtocol( new TezosSaplingProtocolOptions( diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts index 1885860d..9082e988 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -21,6 +21,7 @@ import { TouchEntropyComponent } from './touch-entropy/touch-entropy.component' import { TraceInputDirective } from './trace-input/trace-input.directive' import { TransactionWarningComponent } from './transaction-warning/transaction-warning.component' import { TransactionComponent } from './transaction/transaction.component' +import { VerifyKeyAltComponent } from './verify-key-alt/verify-key-alt.component' import { VerifyKeyComponent } from './verify-key/verify-key.component' @NgModule({ @@ -34,6 +35,7 @@ import { VerifyKeyComponent } from './verify-key/verify-key.component' TouchEntropyComponent, TraceInputDirective, VerifyKeyComponent, + VerifyKeyAltComponent, MessageSignRequestComponent, MessageSignResponseComponent, GridInputComponent, @@ -51,6 +53,7 @@ import { VerifyKeyComponent } from './verify-key/verify-key.component' TouchEntropyComponent, TraceInputDirective, VerifyKeyComponent, + VerifyKeyAltComponent, MessageSignRequestComponent, MessageSignResponseComponent, GridInputComponent, diff --git a/src/app/components/secret-item/secret-item.component.html b/src/app/components/secret-item/secret-item.component.html index 022a4fd3..406e353a 100644 --- a/src/app/components/secret-item/secret-item.component.html +++ b/src/app/components/secret-item/secret-item.component.html @@ -1,6 +1,6 @@ - + @@ -18,13 +18,17 @@ - + +{{ hasMoreWallets }} -
+ + + + + diff --git a/src/app/components/secret-item/secret-item.component.scss b/src/app/components/secret-item/secret-item.component.scss index 70031342..5a77a32c 100644 --- a/src/app/components/secret-item/secret-item.component.scss +++ b/src/app/components/secret-item/secret-item.component.scss @@ -13,8 +13,8 @@ ion-icon { } ion-button { - width: 32px; - height: 32px; + width: 45px; + height: 45px; text-align: center; --padding-end: 0; --padding-start: 0; @@ -29,3 +29,15 @@ ion-button { // 6f53a1 background: linear-gradient(#311b58, #311b58); // TODO: Variables } +ion-fab { + ion-icon{ + font-size: 20px; + margin-right: 7px; + } + ion-button { + ion-icon { + font-size: 30px; + margin-right: 0px; + } + } +} diff --git a/src/app/components/verify-key-alt/verify-key-alt.component.html b/src/app/components/verify-key-alt/verify-key-alt.component.html new file mode 100644 index 00000000..884119b5 --- /dev/null +++ b/src/app/components/verify-key-alt/verify-key-alt.component.html @@ -0,0 +1,53 @@ +
+ +

{{ i + 1 }}

+

{{ word }}

+
+ +

{{ currentSelectedWords.length + 1 }}

+

{{ '' }}

+
+
+ + + + + + +

+
+ + + + + +

{{ 'verify-key.success_text' | translate }}

+
+ + + + + + {{ word }} + + + + +
diff --git a/src/app/components/verify-key-alt/verify-key-alt.component.scss b/src/app/components/verify-key-alt/verify-key-alt.component.scss new file mode 100644 index 00000000..55a4c42b --- /dev/null +++ b/src/app/components/verify-key-alt/verify-key-alt.component.scss @@ -0,0 +1,95 @@ +.secret--container-60 { + height: calc(60% - 36px); +} +.secret--container-40 { + height: 25vh; + position: fixed; + width: 100%; + left: 0; + bottom: 56px; +} +hr { + border: 2px dashed var(--ion-color-primary); + border-style: none none dashed; + margin: 8px 0; +} +.word-placeholder { + min-width: 48px; +} +.size__xs { + font-size: 11px; +} +.typography--mono { + text-transform: lowercase; +} + +:host { + width: 100%; +} + +.tags-wrapper { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(95px, 1fr)); + width: 100%; + margin-bottom: 28vh; + + .tag { + --padding-bottom: 8px; + --padding-top: 8px; + --padding-start: 18px; + --padding-end: 18px; + --border-width: 1px + height: fit-content; + + .number { + font-size: 0.65rem; + line-height: 0.75rem; + color: rgb(192, 192, 192); + width: 100%; + text-align: start; + margin: 0px; + margin-right: 4px; + } + + .text { + font-size: 0.65rem; + line-height: 0.75rem; + text-transform: lowercase; + font-weight: 700; + margin: 0px; + } + } + + .current { + --background: transparent; + --background-hover: transparent; + --background-activated: transparent; + --background-focused: transparent; + + --border-style : dashed; + --border-width: 1px; + --border-color: var(--ion-color-primary); + } +} + +.error-wrapper { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + svg { + overflow: visible; + height: 50px; + width: 50px; + margin-bottom: 24px; + } + + p { + font-size: 0.75rem; + line-height: 0.85rem; + font-weight: 500; + margin: 0px; + text-align: center; + } +} diff --git a/src/app/components/verify-key-alt/verify-key-alt.component.spec.ts b/src/app/components/verify-key-alt/verify-key-alt.component.spec.ts new file mode 100644 index 00000000..85cab80a --- /dev/null +++ b/src/app/components/verify-key-alt/verify-key-alt.component.spec.ts @@ -0,0 +1,188 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing' + +import { UnitHelper } from '../../../../test-config/unit-test-helper' +import { VerifyKeyAltComponent } from './verify-key-alt.component' + +describe('Component: VerifyKey', () => { + let component: VerifyKeyAltComponent + let fixture: ComponentFixture + + const correctMnemonic: string = + 'usage puzzle bottom amused genuine bike brown ripple lend aware symbol genuine neutral tortoise pluck rose brown cliff sing smile appear black occur zero' + + let unitHelper: UnitHelper + beforeEach(() => { + unitHelper = new UnitHelper() + TestBed.configureTestingModule( + unitHelper.testBed({ + declarations: [] + }) + ) + .compileComponents() + .catch(console.error) + }) + + beforeEach(async () => { + fixture = TestBed.createComponent(VerifyKeyAltComponent) + component = fixture.componentInstance + }) + it('should validate a regular mnemonic, and emit correct event', waitForAsync(() => { + component.secret = correctMnemonic + fixture.detectChanges() + const words = component.secret.split(' ') + + // validate onComplete Event is True + component.onComplete.subscribe((event) => { + expect(event).toBeTruthy() + }) + + words.forEach((word: string) => { + expect(component.isFull()).toBeFalsy() + component.useWord(word) + }) + + expect(component.isFull()).toBeTruthy() + expect(component.isCorrect()).toBeTruthy() + })) + + it('should detect a wrong word in a mnemonic', waitForAsync(() => { + component.secret = correctMnemonic + fixture.detectChanges() + const words = component.secret.split(' ') + + // validate onComplete Event is False + component.onComplete.subscribe((event) => { + expect(event).toBeFalsy() + }) + + words.forEach((word: string, i: number) => { + expect(component.isFull()).toBeFalsy() + if (i === 5) { + component.useWord('wrongWord') + } else { + component.useWord(word) + } + }) + + expect(component.isFull()).toBeTruthy() + expect(component.isCorrect()).toBeFalsy() + })) + + it('should validate a mnemonic where the same word appears 2 times', waitForAsync(() => { + component.secret = correctMnemonic + fixture.detectChanges() + component.ngOnInit() + const words = component.secret.split(' ') + + words.forEach((word) => { + component.useWord(word) + }) + + expect(component.isCorrect()).toBeTruthy() + })) + + it('should not validate user input that is too short', waitForAsync(() => { + component.secret = correctMnemonic + fixture.detectChanges() + component.ngOnInit() + const words: string[] = component.secret.split(' ') + + words.forEach((word: string, i: number) => { + if (i === words.length - 1) { + return + } + component.useWord(word) + }) + + expect(component.isFull()).toBeFalsy() + expect(component.isCorrect()).toBeFalsy() + component.useWord(words[words.length - 1]) + expect(component.isFull()).toBeTruthy() + expect(component.isCorrect()).toBeTruthy() + })) + + it('should give the correct empty spots', waitForAsync(() => { + component.secret = correctMnemonic + fixture.detectChanges() + component.ngOnInit() + const words = component.secret.split(' ') + + // first empty spot is zero + expect(component.emptySpot(component.currentWords)).toEqual(0) + + component.useWord(words[0]) + + // next empty spot is one + expect(component.emptySpot(component.currentWords)).toEqual(1) + })) + + it('should let users select words to correct them', waitForAsync(() => { + component.secret = correctMnemonic + fixture.detectChanges() + component.ngOnInit() + const words = component.secret.split(' ') + + words.forEach((word: string) => { + component.useWord(word) + }) + + // now select a word + component.selectWord(5) + expect(component.selectedWordIndex).toEqual(5) + expect(component.currentWords[5]).toEqual(words[5]) + })) + + it('should give users 3 words to choose from', waitForAsync(() => { + component.secret = correctMnemonic + component.ngOnInit() + fixture.detectChanges() + + const wordSelector = fixture.nativeElement.querySelector('#wordSelector') + + // check if there are three words + expect(wordSelector.children.length).toBe(3) + + let foundWord: boolean = false + for (let i: number = 0; i < wordSelector.children.length; i++) { + if (wordSelector.children.item(i).textContent.trim() === correctMnemonic.split(' ')[0]) { + foundWord = true + } + } + + // check if one of the words is the correct one + expect(foundWord).toBeTruthy() + })) + + it('should give users 3 words to choose from if selecting a specific one', waitForAsync(() => { + component.secret = correctMnemonic + component.ngOnInit() + fixture.detectChanges() + + component.secret.split(' ').forEach((word: string, i: number) => { + if (i > 10) { + return + } + component.useWord(word) + }) + + const selectedIndex: number = 5 + component.selectWord(selectedIndex) + + fixture.detectChanges() + + const wordSelector = fixture.nativeElement.querySelector('#wordSelector') + + // check if there are three words + expect(wordSelector.children.length).toBe(3) + + let foundWord: boolean = false + for (let i: number = 0; i < wordSelector.children.length; i++) { + if (wordSelector.children.item(i).textContent.trim() === correctMnemonic.split(' ')[selectedIndex]) { + foundWord = true + } + } + + // check if one of the words is the correct one + expect(foundWord).toBeTruthy() + })) +}) diff --git a/src/app/components/verify-key-alt/verify-key-alt.component.ts b/src/app/components/verify-key-alt/verify-key-alt.component.ts new file mode 100644 index 00000000..ca01043f --- /dev/null +++ b/src/app/components/verify-key-alt/verify-key-alt.component.ts @@ -0,0 +1,166 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' +import * as bip39 from 'bip39' +import { sha3_256 } from 'js-sha3' + +type SingleWord = string + +const ADDITIONAL_WORDS: number = 2 + +@Component({ + selector: 'airgap-verify-key-alt', + templateUrl: './verify-key-alt.component.html', + styleUrls: ['./verify-key-alt.component.scss'] +}) +export class VerifyKeyAltComponent implements OnInit { + @Input() + public secret: string + + @Output() + public onNext = new EventEmitter() + + @Output() + public onComplete = new EventEmitter() + + public isCompleted: boolean = false + + public splittedSecret: SingleWord[] = [] + public currentWords: SingleWord[] = [] + public promptedWords: SingleWord[] = [] + + public selectedWordIndex: number | null = null + + public get currentSelectedWords(): string[] { + return this.currentWords.filter((word) => word != null) + } + + public ngOnInit(): void { + this.splittedSecret = this.secret.toLowerCase().split(' ') + + this.reset() + } + + public promptNextWord(): void { + if (!this.isFull()) this.onNext.emit() + this.promptedWords = [] + + const correctWord: SingleWord = this.splittedSecret[this.emptySpot(this.currentWords)] + + this.promptedWords.push(correctWord) + + const wordList: string[] = bip39.wordlists.EN + + for (let i: number = 0; i < ADDITIONAL_WORDS; i++) { + const filteredList: string[] = wordList.filter( + (originalWord: string) => !this.splittedSecret.find((word: SingleWord) => word === originalWord) + ) + + let hashedWord: string = sha3_256(correctWord) + for (let hashRuns: number = 0; hashRuns <= i; hashRuns++) { + hashedWord = sha3_256(hashedWord) + } + + const additionalWord: SingleWord = filteredList[this.stringToIntHash(hashedWord, 0, filteredList.length)] + + this.promptedWords.push(additionalWord) + } + + this.promptedWords = this.shuffle(this.promptedWords) + } + + public shuffle(a: string[]): string[] { + let counter: number = a.length + + while (counter > 0) { + const index: number = Math.floor(Math.random() * counter) + + counter-- + + const temp: string = a[counter] + a[counter] = a[index] + a[index] = temp + } + + return a + } + + public stringToIntHash(str: string, lowerbound: number, upperbound: number): number { + let result: number = 0 + + for (let i: number = 0; i < str.length; i++) { + result = result + str.charCodeAt(i) + } + + return (result % (upperbound - lowerbound)) + lowerbound + } + + public isSelectedWord(word: SingleWord): boolean { + if (this.selectedWordIndex !== null) { + return this.currentWords[this.selectedWordIndex] === word + } + + return false + } + + public selectEmptySpot(): void { + this.selectedWordIndex = null + this.promptNextWord() + } + + public useWord(word: SingleWord): void { + const index: number = this.emptySpot(this.currentWords) + + // unselect any selected words + this.selectedWordIndex = null + this.currentWords[index] = word + + // prompt next word + if (!this.isFull() && index < this.splittedSecret.length - 1) { + this.promptNextWord() + + return + } + + if (this.isFull()) { + // if all words are placed, check for correctness, else next + this.promptedWords = [] + this.setCompletedState(this.isCorrect()) + } + } + + public setCompletedState(state: boolean) { + this.isCompleted = state + this.onComplete.emit(state) + } + + public emptySpot(array: SingleWord[]): number { + if (this.selectedWordIndex !== null) { + return this.selectedWordIndex + } + + return array.findIndex((word: SingleWord) => word === null) + } + + public selectWord(index: number): void { + this.selectedWordIndex = index + this.promptNextWord() + } + + public reset(): void { + this.selectedWordIndex = null + this.currentWords = Array(this.splittedSecret.length).fill(null) + this.promptNextWord() + } + + public isFull(): boolean { + return this.currentWords.filter((word: string) => word !== null).length === this.splittedSecret.length + } + + public isCorrect(): boolean { + return ( + this.currentWords + .map((word: SingleWord) => (word ? word : '-')) + .join(' ') + .trim() === this.secret.trim() + ) + } +} diff --git a/src/app/pages/account-address/account-address.page.html b/src/app/pages/account-address/account-address.page.html index 2e363bfc..70038890 100644 --- a/src/app/pages/account-address/account-address.page.html +++ b/src/app/pages/account-address/account-address.page.html @@ -45,7 +45,7 @@

{{ protocolName }} {{ protocolSymbol }}

- +   {{option.name}} diff --git a/src/app/pages/account-address/account-address.page.ts b/src/app/pages/account-address/account-address.page.ts index e2c11f04..d5e6c938 100644 --- a/src/app/pages/account-address/account-address.page.ts +++ b/src/app/pages/account-address/account-address.page.ts @@ -37,6 +37,12 @@ const sparrowwallet = { qrType: QRType.BC_UR } +const specterwallet = { + icon: 'specterwallet.png', + name: 'Specter Wallet', + qrType: QRType.BC_UR +} + const metamask = { icon: 'metamask.webp', name: 'MetaMask', @@ -120,7 +126,7 @@ export class AccountAddressPage { switch (protocolIdentifier) { case MainProtocolSymbols.BTC_SEGWIT: - this.syncOptions = [airgapwallet, bluewallet, sparrowwallet] + this.syncOptions = [airgapwallet, bluewallet, sparrowwallet, specterwallet] break case MainProtocolSymbols.ETH: this.syncOptions = [airgapwallet] diff --git a/src/app/pages/contact-book-contacts-detail/contact-book-contacts-detail.page.html b/src/app/pages/contact-book-contacts-detail/contact-book-contacts-detail.page.html index 64e76c86..72f22b71 100644 --- a/src/app/pages/contact-book-contacts-detail/contact-book-contacts-detail.page.html +++ b/src/app/pages/contact-book-contacts-detail/contact-book-contacts-detail.page.html @@ -121,6 +121,9 @@

{{contact?.name || ""}}

+ + +