From c7ab42e9f24ee7115d8f2227e70ae6a52efa917c Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 28 May 2025 16:43:05 +0200 Subject: [PATCH] Add android linting checks and integrate in build process (#846) --- apps/mobile-app/android/.editorconfig | 54 +++++++++++ apps/mobile-app/android/app/build.gradle | 50 ++++++++++ apps/mobile-app/android/app/lint.xml | 19 ++++ .../java/net/aliasvault/app/MainActivity.kt | 70 ++++++------- .../net/aliasvault/app/MainApplication.kt | 62 ++++++------ .../app/autofill/AutofillService.kt | 93 ++++++++++++------ .../app/autofill/models/FieldType.kt | 2 +- .../app/autofill/utils/CredentialMatcher.kt | 14 ++- .../app/autofill/utils/FieldFinder.kt | 43 +++++--- .../app/autofill/utils/ImageUtils.kt | 5 +- .../nativevaultmanager/NativeVaultManager.kt | 63 ++++++++---- .../NativeVaultManagerPackage.kt | 8 +- .../aliasvault/app/vaultstore/VaultStore.kt | 97 +++++++++++-------- .../AndroidKeystoreProvider.kt | 92 ++++++++++++------ .../keystoreprovider/KeystoreProvider.kt | 11 +-- .../keystoreprovider/TestKeystoreProvider.kt | 11 +-- .../app/vaultstore/models/Credential.kt | 10 +- .../app/vaultstore/models/VaultMetadata.kt | 4 +- .../storageprovider/AndroidStorageProvider.kt | 2 +- .../storageprovider/StorageProvider.kt | 1 - .../app/nativevaultmanager/AutofillTest.kt | 40 ++++---- .../app/nativevaultmanager/VaultStoreTest.kt | 22 +++-- apps/mobile-app/android/build.gradle | 15 +++ docs/misc/dev/mobile-apps/android.md | 21 +++- 24 files changed, 541 insertions(+), 268 deletions(-) create mode 100644 apps/mobile-app/android/.editorconfig create mode 100644 apps/mobile-app/android/app/lint.xml diff --git a/apps/mobile-app/android/.editorconfig b/apps/mobile-app/android/.editorconfig new file mode 100644 index 000000000..11ec7cd16 --- /dev/null +++ b/apps/mobile-app/android/.editorconfig @@ -0,0 +1,54 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +max_line_length = 160 +trim_trailing_whitespace = true + +[*.{kt,kts}] +ktlint_standard_max-line-length = 160 +ktlint_standard_trailing-comma-on-call-site = true +ktlint_standard_trailing-comma-on-declaration-site = true +ktlint_standard_wrapping = true +ktlint_standard_filename = true +ktlint_standard_import-ordering = true +ktlint_standard_annotation = true +ktlint_standard_comment-spacing = true +ktlint_standard_curly-spacing = true +ktlint_standard_discouraged-comment-location = true +ktlint_standard_empty-blocks = true +ktlint_standard_enum-entry-name-case = true +ktlint_standard_final-newline = true +ktlint_standard_fun-keyword-spacing = true +ktlint_standard_import-ordering = true +ktlint_standard_import-spacing = true +ktlint_standard_indent = true +ktlint_standard_initializer-list = true +ktlint_standard_ktlint_standard_max-line-length = true +ktlint_standard_modifier-order = true +ktlint_standard_no-blank-line-before-rbrace = true +ktlint_standard_no-consecutive-blank-lines = true +ktlint_standard_no-empty-file = true +ktlint_standard_no-line-break-after-else = true +ktlint_standard_no-line-break-before-assignment = true +ktlint_standard_no-multi-spaces = true +ktlint_standard_no-semi = true +ktlint_standard_no-trailing-spaces = true +ktlint_standard_no-unit-return = true +ktlint_standard_no-unused-imports = true +ktlint_standard_no-wildcard-imports = true +ktlint_standard_parameter-list-wrapping = true +ktlint_standard_spacing-around-colon = true +ktlint_standard_spacing-around-comma = true +ktlint_standard_spacing-around-dot = true +ktlint_standard_spacing-around-keyword = true +ktlint_standard_spacing-around-operators = true +ktlint_standard_spacing-around-parens = true +ktlint_standard_spacing-around-range = true +ktlint_standard_string-formatting = true +ktlint_standard_trailing-comma-on-call-site = true +ktlint_standard_trailing-comma-on-declaration-site = true +ktlint_standard_trailing-spaces = true +ktlint_standard_wrapping = true \ No newline at end of file diff --git a/apps/mobile-app/android/app/build.gradle b/apps/mobile-app/android/app/build.gradle index 0614d5dbe..213dcafd9 100644 --- a/apps/mobile-app/android/app/build.gradle +++ b/apps/mobile-app/android/app/build.gradle @@ -1,6 +1,7 @@ apply plugin: "com.android.application" apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" +apply plugin: "org.jlleitschuh.gradle.ktlint" def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() @@ -124,6 +125,13 @@ android { androidResources { ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' } + lintOptions { + checkReleaseBuilds true + abortOnError true + xmlReport true + htmlReport true + lintConfig file("lint.xml") + } } // Apply static values from `gradle.properties` to the `android.packagingOptions` @@ -191,3 +199,45 @@ dependencies { } implementation "org.jetbrains.kotlin:kotlin-test:1.9.25" } + +ktlint { + android = true + verbose = true + outputToConsole = true + ignoreFailures = false + enableExperimentalRules = true + filter { + exclude("**/generated/**") + include("**/kotlin/**") + include("**/java/**") + } + + // Configure max line length + kotlinScriptAdditionalPaths { + include(fileTree("scripts/")) + } + + // Set max line length to 160 + version.set("0.50.0") +} + +task lintFixAll { + description = "Run all auto-fixers (ktlint and Android lint)" + group = "formatting" + dependsOn ktlintFormat, lintFix +} + +// Add a task dependency to make ktlint run before build +tasks.named("preBuild") { + dependsOn("ktlintCheck") +} + +// Ensure codegen completes before ktlint tasks run +afterEvaluate { + tasks.withType(org.jlleitschuh.gradle.ktlint.tasks.KtLintCheckTask).configureEach { + dependsOn("generateCodegenArtifactsFromSchema") + } + tasks.withType(org.jlleitschuh.gradle.ktlint.tasks.KtLintFormatTask).configureEach { + dependsOn("generateCodegenArtifactsFromSchema") + } +} diff --git a/apps/mobile-app/android/app/lint.xml b/apps/mobile-app/android/app/lint.xml new file mode 100644 index 000000000..2976280c8 --- /dev/null +++ b/apps/mobile-app/android/app/lint.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt index 6d1013c25..9e4f64180 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt @@ -1,52 +1,44 @@ package net.aliasvault.app -import expo.modules.splashscreen.SplashScreenManager - import android.os.Bundle -import android.content.Intent -import android.net.Uri -import android.provider.Settings -import android.view.WindowManager -import android.graphics.Color -import android.view.Window - import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled import com.facebook.react.defaults.DefaultReactActivityDelegate - import expo.modules.ReactActivityDelegateWrapper +import expo.modules.splashscreen.SplashScreenManager class MainActivity : ReactActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - // Set the theme to AppTheme BEFORE onCreate to support - // coloring the background, status bar, and navigation bar. - // This is required for expo-splash-screen. - // setTheme(R.style.AppTheme); - // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af - SplashScreenManager.registerOnActivity(this) - // @generated end expo-splashscreen + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + // setTheme(R.style.AppTheme); + // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af + SplashScreenManager.registerOnActivity(this) + // @generated end expo-splashscreen - super.onCreate(null) - } + super.onCreate(null) + } - /** - * Returns the name of the main component registered from JavaScript. This is used to schedule - * rendering of the component. - */ - override fun getMainComponentName(): String = "main" + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" - /** - * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] - * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] - */ - override fun createReactActivityDelegate(): ReactActivityDelegate { - return ReactActivityDelegateWrapper( - this, - BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, - object : DefaultReactActivityDelegate( - this, - mainComponentName, - fabricEnabled - ){}) - } + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled, + ) {}, + ) + } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainApplication.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainApplication.kt index dfabb2eb5..03dd14032 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainApplication.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainApplication.kt @@ -2,58 +2,56 @@ package net.aliasvault.app import android.app.Application import android.content.res.Configuration - import com.facebook.react.PackageList import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost import com.facebook.react.ReactNativeHost import com.facebook.react.ReactPackage -import com.facebook.react.ReactHost import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load import com.facebook.react.defaults.DefaultReactNativeHost import com.facebook.react.soloader.OpenSourceMergedSoMapping import com.facebook.soloader.SoLoader -import net.aliasvault.app.nativevaultmanager.NativeVaultManagerPackage - import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ReactNativeHostWrapper +import net.aliasvault.app.nativevaultmanager.NativeVaultManagerPackage class MainApplication : Application(), ReactApplication { - override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( + override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( this, object : DefaultReactNativeHost(this) { - override fun getPackages(): List { - val packages = PackageList(this).packages - // Packages that cannot be autolinked yet can be added manually here, for example: - // packages.add(new MyReactNativePackage()); - packages.add(NativeVaultManagerPackage()) - return packages - } + override fun getPackages(): List { + val packages = PackageList(this).packages + // Packages that cannot be autolinked yet can be added manually here, for example: + // packages.add(new MyReactNativePackage()); + packages.add(NativeVaultManagerPackage()) + return packages + } - override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" + override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" - override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG - override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED - override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED - } - ) + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED + }, + ) - override val reactHost: ReactHost - get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + override val reactHost: ReactHost + get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) - override fun onCreate() { - super.onCreate() - SoLoader.init(this, OpenSourceMergedSoMapping) - if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - // If you opted-in for the New Architecture, we load the native entry point for this app. - load() + override fun onCreate() { + super.onCreate() + SoLoader.init(this, OpenSourceMergedSoMapping) + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // If you opted-in for the New Architecture, we load the native entry point for this app. + load() + } + ApplicationLifecycleDispatcher.onApplicationCreate(this) } - ApplicationLifecycleDispatcher.onApplicationCreate(this) - } - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) - } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt index ce5a00977..8c191375f 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt @@ -8,6 +8,7 @@ */ package net.aliasvault.app.autofill +import android.app.PendingIntent import android.content.Intent import android.os.CancellationSignal import android.service.autofill.AutofillService @@ -20,14 +21,15 @@ import android.service.autofill.SaveRequest import android.util.Log import android.view.autofill.AutofillValue import android.widget.RemoteViews +import net.aliasvault.app.MainActivity +import net.aliasvault.app.R +import net.aliasvault.app.autofill.models.FieldType +import net.aliasvault.app.autofill.utils.CredentialMatcher +import net.aliasvault.app.autofill.utils.FieldFinder +import net.aliasvault.app.autofill.utils.ImageUtils import net.aliasvault.app.vaultstore.VaultStore import net.aliasvault.app.vaultstore.VaultStore.CredentialOperationCallback import net.aliasvault.app.vaultstore.models.Credential -import android.app.PendingIntent -import net.aliasvault.app.MainActivity -import net.aliasvault.app.R -import net.aliasvault.app.autofill.utils.* -import net.aliasvault.app.autofill.models.FieldType class AutofillService : AutofillService() { private val TAG = "AliasVaultAutofill" @@ -36,7 +38,7 @@ class AutofillService : AutofillService() { override fun onFillRequest( request: FillRequest, cancellationSignal: CancellationSignal, - callback: FillCallback + callback: FillCallback, ) { Log.d(TAG, "onFillRequest called") @@ -119,19 +121,26 @@ class AutofillService : AutofillService() { result } - Log.d(TAG, "Amount of credentials filtered with this app info: ${filteredCredentials.size}") + Log.d( + TAG, + "Amount of credentials filtered with this app info: ${filteredCredentials.size}", + ) val responseBuilder = FillResponse.Builder() // If there are no results, return "no matches" placeholder option. if (filteredCredentials.isEmpty()) { - Log.d(TAG, "No credentials found for this app, showing 'no matches' option") + Log.d( + TAG, + "No credentials found for this app, showing 'no matches' option", + ) responseBuilder.addDataset(createNoMatchesDataset(fieldFinder)) - } - else { + } else { // If there are matches, add them to the dataset for (credential in filteredCredentials) { - responseBuilder.addDataset(createCredentialDataset(fieldFinder, credential)) + responseBuilder.addDataset( + createCredentialDataset(fieldFinder, credential), + ) } // Add "Open AliasVault app" as the last option @@ -149,7 +158,8 @@ class AutofillService : AutofillService() { Log.e(TAG, "Error getting credentials", e) callback.onSuccess(null) } - })) { + }) + ) { // Successfully used cached key - method returns true Log.d(TAG, "Successfully retrieved credentials with unlocked vault") return @@ -166,10 +176,7 @@ class AutofillService : AutofillService() { } // Helper method to create a dataset from a credential - private fun createCredentialDataset( - fieldFinder: FieldFinder, - credential: Credential - ) : Dataset { + private fun createCredentialDataset(fieldFinder: FieldFinder, credential: Credential): Dataset { // Choose layout based on whether we have a logo val layoutId = if (credential.service.logo != null) { R.layout.autofill_dataset_item_icon @@ -189,34 +196,55 @@ class AutofillService : AutofillService() { when (fieldType) { FieldType.PASSWORD -> { if (credential.password != null) { - dataSetBuilder.setValue(field.first, AutofillValue.forText(credential.password.value as CharSequence)) + dataSetBuilder.setValue( + field.first, + AutofillValue.forText(credential.password.value as CharSequence), + ) } } FieldType.EMAIL -> { if (credential.alias?.email != null) { - dataSetBuilder.setValue(field.first, AutofillValue.forText(credential.alias.email)) + dataSetBuilder.setValue( + field.first, + AutofillValue.forText(credential.alias.email), + ) presentationDisplayValue = "${credential.service.name} (${credential.alias.email})" } else if (credential.username != null) { - dataSetBuilder.setValue(field.first, AutofillValue.forText(credential.username)) + dataSetBuilder.setValue( + field.first, + AutofillValue.forText(credential.username), + ) presentationDisplayValue = "${credential.service.name} (${credential.username})" } } FieldType.USERNAME -> { if (credential.username != null) { - dataSetBuilder.setValue(field.first, AutofillValue.forText(credential.username)) + dataSetBuilder.setValue( + field.first, + AutofillValue.forText(credential.username), + ) presentationDisplayValue = "${credential.service.name} (${credential.username})" } else if (credential.alias?.email != null) { - dataSetBuilder.setValue(field.first, AutofillValue.forText(credential.alias.email)) + dataSetBuilder.setValue( + field.first, + AutofillValue.forText(credential.alias.email), + ) presentationDisplayValue = "${credential.service.name} (${credential.alias.email})" } } else -> { // For unknown field types, try both email and username if (credential.alias?.email != null) { - dataSetBuilder.setValue(field.first, AutofillValue.forText(credential.alias.email)) + dataSetBuilder.setValue( + field.first, + AutofillValue.forText(credential.alias.email), + ) presentationDisplayValue = "${credential.service.name} (${credential.alias.email})" } else if (credential.username != null) { - dataSetBuilder.setValue(field.first, AutofillValue.forText(credential.username)) + dataSetBuilder.setValue( + field.first, + AutofillValue.forText(credential.username), + ) presentationDisplayValue = "${credential.service.name} (${credential.username})" } } @@ -226,7 +254,7 @@ class AutofillService : AutofillService() { // Set the display value of the dropdown item. presentation.setTextViewText( R.id.text, - presentationDisplayValue + presentationDisplayValue, ) // Set the logo if available @@ -246,7 +274,7 @@ class AutofillService : AutofillService() { val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_logo) presentation.setTextViewText( R.id.text, - "No match found, create new?" + "No match found, create new?", ) val dataSetBuilder = Dataset.Builder(presentation) @@ -267,7 +295,7 @@ class AutofillService : AutofillService() { this@AutofillService, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) dataSetBuilder.setAuthentication(pendingIntent.intentSender) @@ -285,7 +313,7 @@ class AutofillService : AutofillService() { val openAppPresentation = RemoteViews(packageName, R.layout.autofill_dataset_item_logo) openAppPresentation.setTextViewText( R.id.text, - "Open app" + "Open app", ) val dataSetBuilder = Dataset.Builder(openAppPresentation) @@ -306,7 +334,7 @@ class AutofillService : AutofillService() { this@AutofillService, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) dataSetBuilder.setAuthentication(pendingIntent.intentSender) @@ -325,7 +353,7 @@ class AutofillService : AutofillService() { val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_logo) presentation.setTextViewText( R.id.text, - "Vault locked" + "Vault locked", ) val dataSetBuilder = Dataset.Builder(presentation) @@ -339,7 +367,7 @@ class AutofillService : AutofillService() { this@AutofillService, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) dataSetBuilder.setAuthentication(pendingIntent.intentSender) @@ -353,7 +381,10 @@ class AutofillService : AutofillService() { return dataSetBuilder.build() } - private fun filterCredentialsByAppInfo(credentials: List, appInfo: String): List { + private fun filterCredentialsByAppInfo( + credentials: List, + appInfo: String, + ): List { return credentialMatcher.filterCredentialsByAppInfo(credentials, appInfo) } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/models/FieldType.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/models/FieldType.kt index 3d2f0d128..0a576db9f 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/models/FieldType.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/models/FieldType.kt @@ -7,5 +7,5 @@ enum class FieldType { EMAIL, USERNAME, PASSWORD, - UNKNOWN + UNKNOWN, } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt index a31e55052..36478e7a3 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt @@ -18,7 +18,10 @@ class CredentialMatcher { * 4. Domain key match (package middle segment or domain without TLD) * 5. General text matching on service name, username, and URL */ - fun filterCredentialsByAppInfo(credentials: List, appInfo: String): List { + fun filterCredentialsByAppInfo( + credentials: List, + appInfo: String, + ): List { if (appInfo.isBlank()) return credentials val input = appInfo.trim().lowercase() @@ -27,7 +30,12 @@ class CredentialMatcher { val rootDomain: String? val domainKey: String - if (isUrlLike && (input.startsWith("http://") || input.startsWith("https://") || input.startsWith("www."))) { + if (isUrlLike && ( + input.startsWith("http://") || input.startsWith("https://") || input.startsWith( + "www.", + ) + ) + ) { // Treat as full or partial URL val cleaned = input .removePrefix("https://") @@ -57,7 +65,7 @@ class CredentialMatcher { cred.service.url?.trim()?.lowercase() in listOf( input, "https://$host", - "http://$host" + "http://$host", ) } // 2. Base URL match diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/FieldFinder.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/FieldFinder.kt index 41f5a9a22..1c1cd6526 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/FieldFinder.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/FieldFinder.kt @@ -1,9 +1,9 @@ package net.aliasvault.app.autofill.utils import android.app.assist.AssistStructure +import android.util.Log import android.view.View import android.view.autofill.AutofillId -import android.util.Log import androidx.core.net.toUri import net.aliasvault.app.autofill.models.FieldType @@ -126,7 +126,8 @@ class FieldFinder(var structure: AssistStructure) { for (hint in hints) { if (hint.contains("web", ignoreCase = true) || hint.contains("url", ignoreCase = true) || - hint.contains("domain", ignoreCase = true)) { + hint.contains("domain", ignoreCase = true) + ) { return hint } } @@ -208,7 +209,8 @@ class FieldFinder(var structure: AssistStructure) { val idEntry = node.idEntry val hint = node.hint if (idEntry?.contains("email", ignoreCase = true) == true || - hint?.contains("email", ignoreCase = true) == true) { + hint?.contains("email", ignoreCase = true) == true + ) { return true } @@ -221,7 +223,8 @@ class FieldFinder(var structure: AssistStructure) { if (hints != null) { for (hint in hints) { if (hint == View.AUTOFILL_HINT_USERNAME || - hint.contains("username", ignoreCase = true)) { + hint.contains("username", ignoreCase = true) + ) { return true } } @@ -248,7 +251,8 @@ class FieldFinder(var structure: AssistStructure) { if (idEntry?.contains("username", ignoreCase = true) == true || idEntry?.contains("user", ignoreCase = true) == true || hint?.contains("username", ignoreCase = true) == true || - hint?.contains("user", ignoreCase = true) == true) { + hint?.contains("user", ignoreCase = true) == true + ) { return true } @@ -268,7 +272,11 @@ class FieldFinder(var structure: AssistStructure) { val hints = node.autofillHints if (hints != null) { for (hint in hints) { - if (hint == View.AUTOFILL_HINT_PASSWORD || hint.contains("password", ignoreCase = true)) { + if (hint == View.AUTOFILL_HINT_PASSWORD || hint.contains( + "password", + ignoreCase = true, + ) + ) { return true } } @@ -276,12 +284,18 @@ class FieldFinder(var structure: AssistStructure) { // Check by input type val inputType = node.inputType - val isPasswordType = (inputType and android.text.InputType.TYPE_MASK_CLASS == android.text.InputType.TYPE_CLASS_TEXT && - (inputType and android.text.InputType.TYPE_MASK_VARIATION == android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD || - inputType and android.text.InputType.TYPE_MASK_VARIATION == android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD || - inputType and android.text.InputType.TYPE_MASK_VARIATION == android.text.InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD)) || - (inputType and android.text.InputType.TYPE_MASK_CLASS == android.text.InputType.TYPE_CLASS_NUMBER && - inputType and android.text.InputType.TYPE_MASK_VARIATION == android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD) + val isPasswordType = ( + inputType and android.text.InputType.TYPE_MASK_CLASS == android.text.InputType.TYPE_CLASS_TEXT && + ( + inputType and android.text.InputType.TYPE_MASK_VARIATION == android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD || + inputType and android.text.InputType.TYPE_MASK_VARIATION == android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD || + inputType and android.text.InputType.TYPE_MASK_VARIATION == android.text.InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + ) + ) || + ( + inputType and android.text.InputType.TYPE_MASK_CLASS == android.text.InputType.TYPE_CLASS_NUMBER && + inputType and android.text.InputType.TYPE_MASK_VARIATION == android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD + ) if (isPasswordType) { return true } @@ -323,7 +337,10 @@ class FieldFinder(var structure: AssistStructure) { return null } - private fun findNodeByIdRecursive(node: AssistStructure.ViewNode, fieldId: AutofillId): AssistStructure.ViewNode? { + private fun findNodeByIdRecursive( + node: AssistStructure.ViewNode, + fieldId: AutofillId, + ): AssistStructure.ViewNode? { if (node.autofillId == fieldId) { return node } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/ImageUtils.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/ImageUtils.kt index e4c071ee1..de9fd1a2a 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/ImageUtils.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/ImageUtils.kt @@ -1,11 +1,11 @@ package net.aliasvault.app.autofill.utils +import android.content.res.Resources import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas import android.util.Base64 import android.util.Log -import android.content.res.Resources import com.caverock.androidsvg.SVG import java.io.ByteArrayOutputStream @@ -88,6 +88,5 @@ object ImageUtils { } fun base64ToBytes(base64: String): ByteArray? = - try { Base64.decode(base64, Base64.DEFAULT) } - catch (e: Exception) { null } + try { Base64.decode(base64, Base64.DEFAULT) } catch (e: Exception) { null } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt index 6fc4871da..17a99cff4 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt @@ -1,21 +1,24 @@ package net.aliasvault.app.nativevaultmanager -import com.facebook.react.module.annotations.ReactModule -import com.facebook.react.turbomodule.core.interfaces.TurboModule -import com.facebook.react.bridge.ReactApplicationContext - +import android.content.Intent +import android.provider.Settings import android.util.Log +import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity import com.aliasvault.nativevaultmanager.NativeVaultManagerSpec -import com.facebook.react.bridge.* -import org.json.JSONArray +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableType +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.turbomodule.core.interfaces.TurboModule import net.aliasvault.app.vaultstore.VaultStore -import net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider import net.aliasvault.app.vaultstore.keystoreprovider.AndroidKeystoreProvider -import android.content.Intent -import android.net.Uri -import android.provider.Settings -import androidx.core.net.toUri +import net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider +import org.json.JSONArray @ReactModule(name = NativeVaultManager.NAME) class NativeVaultManager(reactContext: ReactApplicationContext) : @@ -28,7 +31,7 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : private val vaultStore = VaultStore.getInstance( AndroidKeystoreProvider(reactContext) { getFragmentActivity() }, - AndroidStorageProvider(reactContext) + AndroidStorageProvider(reactContext), ) init { @@ -159,7 +162,11 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : promise.resolve(null) } catch (e: Exception) { Log.e(TAG, "Error storing key derivation params", e) - promise.reject("ERR_STORE_KEY_PARAMS", "Failed to store key derivation params: ${e.message}", e) + promise.reject( + "ERR_STORE_KEY_PARAMS", + "Failed to store key derivation params: ${e.message}", + e, + ) } } @@ -170,7 +177,11 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : promise.resolve(params) } catch (e: Exception) { Log.e(TAG, "Error getting key derivation params", e) - promise.reject("ERR_GET_KEY_PARAMS", "Failed to get key derivation params: ${e.message}", e) + promise.reject( + "ERR_GET_KEY_PARAMS", + "Failed to get key derivation params: ${e.message}", + e, + ) } } @@ -246,7 +257,10 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : is Float -> rowMap.putDouble(key, value.toDouble()) is Double -> rowMap.putDouble(key, value) is String -> rowMap.putString(key, value) - is ByteArray -> rowMap.putString(key, android.util.Base64.encodeToString(value, android.util.Base64.NO_WRAP)) + is ByteArray -> rowMap.putString( + key, + android.util.Base64.encodeToString(value, android.util.Base64.NO_WRAP), + ) else -> rowMap.putString(key, value.toString()) } } @@ -299,7 +313,11 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : promise.resolve(null) } catch (e: Exception) { Log.e(TAG, "Error committing transaction", e) - promise.reject("ERR_COMMIT_TRANSACTION", "Failed to commit transaction: ${e.message}", e) + promise.reject( + "ERR_COMMIT_TRANSACTION", + "Failed to commit transaction: ${e.message}", + e, + ) } } @@ -310,7 +328,11 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : promise.resolve(null) } catch (e: Exception) { Log.e(TAG, "Error rolling back transaction", e) - promise.reject("ERR_ROLLBACK_TRANSACTION", "Failed to rollback transaction: ${e.message}", e) + promise.reject( + "ERR_ROLLBACK_TRANSACTION", + "Failed to rollback transaction: ${e.message}", + e, + ) } } @@ -374,10 +396,13 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : } promise.resolve(null) - } catch (e: Exception) { Log.e(TAG, "Error opening autofill settings", e) - promise.reject("ERR_OPEN_AUTOFILL_SETTINGS", "Failed to open autofill settings: ${e.message}", e) + promise.reject( + "ERR_OPEN_AUTOFILL_SETTINGS", + "Failed to open autofill settings: ${e.message}", + e, + ) } } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManagerPackage.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManagerPackage.kt index 39baabede..0ec05dacb 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManagerPackage.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManagerPackage.kt @@ -22,12 +22,12 @@ class NativeVaultManagerPackage : TurboReactPackage() { NativeVaultManager.NAME, NativeVaultManager::class.java.name, false, // canOverrideExistingModule - true, // needsEagerInit - true, // hasConstants + true, // needsEagerInit + true, // hasConstants false, // isCxxModule - true // isTurboModule + true, // isTurboModule ) moduleMap } } -} \ No newline at end of file +} diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt index 422629ae0..e0b7608f0 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt @@ -2,23 +2,27 @@ package net.aliasvault.app.vaultstore import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteException +import android.os.Handler +import android.os.Looper import android.util.Base64 import android.util.Log -import net.aliasvault.app.vaultstore.models.* +import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreOperationCallback +import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreProvider +import net.aliasvault.app.vaultstore.models.Alias +import net.aliasvault.app.vaultstore.models.Credential +import net.aliasvault.app.vaultstore.models.Password +import net.aliasvault.app.vaultstore.models.Service +import net.aliasvault.app.vaultstore.models.VaultMetadata +import net.aliasvault.app.vaultstore.storageprovider.StorageProvider +import org.json.JSONArray import org.json.JSONObject +import java.io.File +import java.security.SecureRandom +import java.text.SimpleDateFormat +import java.util.* import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec -import java.security.SecureRandom -import java.io.File -import java.text.SimpleDateFormat -import java.util.* -import net.aliasvault.app.vaultstore.storageprovider.StorageProvider -import org.json.JSONArray -import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreProvider -import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreOperationCallback -import android.os.Handler -import android.os.Looper class VaultStore( private val storageProvider: StorageProvider, @@ -72,7 +76,7 @@ class VaultStore( error = e latch.countDown() } - } + }, ) // Wait for the callback to complete @@ -111,7 +115,7 @@ class VaultStore( Log.e(TAG, "Error retrieving key", e) callback.onError(e) } - } + }, ) } else { callback.onError(Exception("No encryption key found")) @@ -136,7 +140,7 @@ class VaultStore( * Get the encrypted database from the storage provider * @return The encrypted database as a base64 encoded string */ - fun getEncryptedDatabase() : String { + fun getEncryptedDatabase(): String { val encryptedDbBase64 = storageProvider.getEncryptedDatabaseFile().readText() return encryptedDbBase64 } @@ -145,7 +149,7 @@ class VaultStore( * Check if the encrypted database exists in the storage provider * @return True if the encrypted database exists, false otherwise */ - fun hasEncryptedDatabase() : Boolean { + fun hasEncryptedDatabase(): Boolean { return storageProvider.getEncryptedDatabaseFile().exists() } @@ -161,7 +165,7 @@ class VaultStore( * Get the metadata from the storage provider * @return The metadata as a string */ - fun getMetadata() : String { + fun getMetadata(): String { return storageProvider.getMetadata() } @@ -200,10 +204,18 @@ class VaultStore( for (columnName in columnNames) { when (it.getType(it.getColumnIndexOrThrow(columnName))) { android.database.Cursor.FIELD_TYPE_NULL -> row[columnName] = null - android.database.Cursor.FIELD_TYPE_INTEGER -> row[columnName] = it.getLong(it.getColumnIndexOrThrow(columnName)) - android.database.Cursor.FIELD_TYPE_FLOAT -> row[columnName] = it.getDouble(it.getColumnIndexOrThrow(columnName)) - android.database.Cursor.FIELD_TYPE_STRING -> row[columnName] = it.getString(it.getColumnIndexOrThrow(columnName)) - android.database.Cursor.FIELD_TYPE_BLOB -> row[columnName] = it.getBlob(it.getColumnIndexOrThrow(columnName)) + android.database.Cursor.FIELD_TYPE_INTEGER -> row[columnName] = it.getLong( + it.getColumnIndexOrThrow(columnName), + ) + android.database.Cursor.FIELD_TYPE_FLOAT -> row[columnName] = it.getDouble( + it.getColumnIndexOrThrow(columnName), + ) + android.database.Cursor.FIELD_TYPE_STRING -> row[columnName] = it.getString( + it.getColumnIndexOrThrow(columnName), + ) + android.database.Cursor.FIELD_TYPE_BLOB -> row[columnName] = it.getBlob( + it.getColumnIndexOrThrow(columnName), + ) } } results.add(row) @@ -261,14 +273,16 @@ class VaultStore( // Get all table names from the main database val cursor = dbConnection?.rawQuery( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'android_%'", - null + null, ) cursor?.use { while (it.moveToNext()) { val tableName = it.getString(0) // Create table and copy data - dbConnection?.execSQL("CREATE TABLE target.$tableName AS SELECT * FROM main.$tableName") + dbConnection?.execSQL( + "CREATE TABLE target.$tableName AS SELECT * FROM main.$tableName", + ) } } @@ -296,8 +310,7 @@ class VaultStore( } catch (e: Exception) { Log.e(TAG, "Error exporting and encrypting database", e) throw e - } - finally { + } finally { // Remove the temporary file tempDbFile.delete() } @@ -339,11 +352,13 @@ class VaultStore( fun setVaultRevisionNumber(revisionNumber: Int) { val metadata = getVaultMetadataObject() ?: VaultMetadata() val updatedMetadata = metadata.copy(vaultRevisionNumber = revisionNumber) - storeMetadata(JSONObject().apply { - put("publicEmailDomains", JSONArray(updatedMetadata.publicEmailDomains)) - put("privateEmailDomains", JSONArray(updatedMetadata.privateEmailDomains)) - put("vaultRevisionNumber", updatedMetadata.vaultRevisionNumber) - }.toString()) + storeMetadata( + JSONObject().apply { + put("publicEmailDomains", JSONArray(updatedMetadata.publicEmailDomains)) + put("privateEmailDomains", JSONArray(updatedMetadata.privateEmailDomains)) + put("vaultRevisionNumber", updatedMetadata.vaultRevisionNumber) + }.toString(), + ) } fun getVaultRevisionNumber(): Int { @@ -364,7 +379,7 @@ class VaultStore( privateEmailDomains = json.optJSONArray("privateEmailDomains")?.let { array -> List(array.length()) { i -> array.getString(i) } } ?: emptyList(), - vaultRevisionNumber = json.optInt("vaultRevisionNumber", 0) + vaultRevisionNumber = json.optInt("vaultRevisionNumber", 0), ) } catch (e: Exception) { Log.e(TAG, "Error parsing vault metadata", e) @@ -528,7 +543,7 @@ class VaultStore( // Verify the attachment worked by checking if we can access the source database val verifyCursor = dbConnection?.rawQuery( "SELECT name FROM source.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", - null + null, ) if (verifyCursor == null) { @@ -543,7 +558,9 @@ class VaultStore( do { val tableName = it.getString(0) // Create table and copy data using rawQuery - dbConnection?.execSQL("CREATE TABLE $tableName AS SELECT * FROM source.$tableName") + dbConnection?.execSQL( + "CREATE TABLE $tableName AS SELECT * FROM source.$tableName", + ) } while (it.moveToNext()) } @@ -562,7 +579,6 @@ class VaultStore( dbConnection?.rawQuery("PRAGMA foreign_keys = ON", null) lastUnlockTime = System.currentTimeMillis() - } catch (e: Exception) { Log.e(TAG, "Error setting up database with decrypted data", e) throw e @@ -648,7 +664,7 @@ class VaultStore( logo = it.getBlob(10), createdAt = parseDateString(it.getString(11)) ?: MIN_DATE, updatedAt = parseDateString(it.getString(12)) ?: MIN_DATE, - isDeleted = it.getInt(13) == 1 + isDeleted = it.getInt(13) == 1, ) // Password @@ -660,7 +676,7 @@ class VaultStore( value = it.getString(15), createdAt = parseDateString(it.getString(16)) ?: MIN_DATE, updatedAt = parseDateString(it.getString(17)) ?: MIN_DATE, - isDeleted = it.getInt(18) == 1 + isDeleted = it.getInt(18) == 1, ) } @@ -677,7 +693,7 @@ class VaultStore( email = it.getString(25), createdAt = parseDateString(it.getString(26)) ?: MIN_DATE, updatedAt = parseDateString(it.getString(27)) ?: MIN_DATE, - isDeleted = it.getInt(28) == 1 + isDeleted = it.getInt(28) == 1, ) } @@ -690,7 +706,7 @@ class VaultStore( password = password, createdAt = parseDateString(it.getString(4)) ?: MIN_DATE, updatedAt = parseDateString(it.getString(5)) ?: MIN_DATE, - isDeleted = isDeleted + isDeleted = isDeleted, ) result.add(credential) } catch (e: Exception) { @@ -720,7 +736,7 @@ class VaultStore( }, SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") - } + }, ) for (format in formats) { @@ -773,7 +789,10 @@ class VaultStore( private var instance: VaultStore? = null @JvmStatic - fun getInstance(keystoreProvider: KeystoreProvider, storageProvider: StorageProvider): VaultStore { + fun getInstance( + keystoreProvider: KeystoreProvider, + storageProvider: StorageProvider, + ): VaultStore { return instance ?: synchronized(this) { instance ?: VaultStore(storageProvider, keystoreProvider).also { instance = it } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/AndroidKeystoreProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/AndroidKeystoreProvider.kt index 63e806c0f..9fa6e1da1 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/AndroidKeystoreProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/AndroidKeystoreProvider.kt @@ -2,7 +2,6 @@ package net.aliasvault.app.vaultstore.keystoreprovider import android.app.Activity import android.content.Context -import android.content.SharedPreferences import android.os.Handler import android.os.Looper import android.security.keystore.KeyGenParameterSpec @@ -26,7 +25,7 @@ import javax.crypto.spec.GCMParameterSpec */ class AndroidKeystoreProvider( private val context: Context, - private val getCurrentActivity: () -> Activity? + private val getCurrentActivity: () -> Activity?, ) : KeystoreProvider { private val biometricManager = BiometricManager.from(context) private val executor: Executor = Executors.newSingleThreadExecutor() @@ -39,8 +38,8 @@ class AndroidKeystoreProvider( override fun isBiometricAvailable(): Boolean { return biometricManager.canAuthenticate( BiometricManager.Authenticators.BIOMETRIC_WEAK or - BiometricManager.Authenticators.BIOMETRIC_STRONG or - BiometricManager.Authenticators.DEVICE_CREDENTIAL + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL, ) == BiometricManager.BIOMETRIC_SUCCESS } @@ -49,7 +48,9 @@ class AndroidKeystoreProvider( try { val currentActivity = getCurrentActivity() if (currentActivity == null || !(currentActivity is FragmentActivity)) { - callback.onError(Exception("No activity available for biometric authentication")) + callback.onError( + Exception("No activity available for biometric authentication"), + ) return@post } @@ -60,12 +61,13 @@ class AndroidKeystoreProvider( // Create or get biometric key if (!keyStore.containsAlias(KEYSTORE_ALIAS)) { val keyGenerator = KeyGenerator.getInstance( - KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore" + KeyProperties.KEY_ALGORITHM_AES, + "AndroidKeyStore", ) val keySpec = KeyGenParameterSpec.Builder( KEYSTORE_ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) @@ -82,22 +84,28 @@ class AndroidKeystoreProvider( // Create BiometricPrompt val promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle("Store Encryption Key") - .setSubtitle("Authenticate to securely store your encryption key in the Android Keystore. This enables secure access to your vault.") + .setSubtitle( + "Authenticate to securely store your encryption key in the Android Keystore. This enables secure access to your vault.", + ) .setAllowedAuthenticators( BiometricManager.Authenticators.BIOMETRIC_STRONG or - BiometricManager.Authenticators.DEVICE_CREDENTIAL + BiometricManager.Authenticators.DEVICE_CREDENTIAL, ) .build() - val biometricPrompt = BiometricPrompt(currentActivity, executor, + val biometricPrompt = BiometricPrompt( + currentActivity, + executor, object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult, + ) { try { // Initialize cipher for encryption val cipher = Cipher.getInstance( "${KeyProperties.KEY_ALGORITHM_AES}/" + - "${KeyProperties.BLOCK_MODE_GCM}/" + - KeyProperties.ENCRYPTION_PADDING_NONE + "${KeyProperties.BLOCK_MODE_GCM}/" + + KeyProperties.ENCRYPTION_PADDING_NONE, ) // Initialize cipher with the secret key @@ -114,31 +122,44 @@ class AndroidKeystoreProvider( val combined = byteBuffer.array() // Store encrypted key in SharedPreferences - val encryptedKeyB64 = Base64.encodeToString(combined, Base64.NO_WRAP) - val prefs = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE) + val encryptedKeyB64 = Base64.encodeToString( + combined, + Base64.NO_WRAP, + ) + val prefs = context.getSharedPreferences( + SHARED_PREFS_NAME, + Context.MODE_PRIVATE, + ) prefs.edit().putString(ENCRYPTED_KEY_PREF, encryptedKeyB64).apply() Log.d(TAG, "Encryption key stored successfully") callback.onSuccess("Key stored successfully") } catch (e: Exception) { Log.e(TAG, "Error storing encryption key", e) - callback.onError(Exception("Failed to store encryption key: ${e.message}")) + callback.onError( + Exception("Failed to store encryption key: ${e.message}"), + ) } } - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence, + ) { Log.e(TAG, "Authentication error: $errorCode - $errString") - callback.onError(Exception("Authentication error: $errString (code: $errorCode)")) + callback.onError( + Exception("Authentication error: $errString (code: $errorCode)"), + ) } override fun onAuthenticationFailed() { Log.e(TAG, "Authentication failed") } - }) + }, + ) // Show biometric prompt without crypto object for device credentials biometricPrompt.authenticate(promptInfo) - } catch (e: Exception) { Log.e(TAG, "Error in biometric key storage", e) callback.onError(Exception("Failed to initialize key storage: ${e.message}")) @@ -151,7 +172,9 @@ class AndroidKeystoreProvider( try { val currentActivity = getCurrentActivity() if (currentActivity == null || !(currentActivity is FragmentActivity)) { - callback.onError(Exception("No activity available for biometric authentication")) + callback.onError( + Exception("No activity available for biometric authentication"), + ) return@post } @@ -184,16 +207,22 @@ class AndroidKeystoreProvider( .setSubtitle("Authenticate to access your vault") .setAllowedAuthenticators( BiometricManager.Authenticators.BIOMETRIC_STRONG or - BiometricManager.Authenticators.DEVICE_CREDENTIAL + BiometricManager.Authenticators.DEVICE_CREDENTIAL, ) .build() - val biometricPrompt = BiometricPrompt(currentActivity, executor, + val biometricPrompt = BiometricPrompt( + currentActivity, + executor, object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult, + ) { try { // Get the cipher from the result - val cipher = result.cryptoObject?.cipher ?: throw Exception("Cipher is null") + val cipher = result.cryptoObject?.cipher ?: throw Exception( + "Cipher is null", + ) // Decode combined data val combined = Base64.decode(encryptedKeyB64, Base64.NO_WRAP) @@ -220,7 +249,10 @@ class AndroidKeystoreProvider( } } - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence, + ) { Log.e(TAG, "Authentication error: $errString") callback.onError(Exception("Authentication error: $errString")) } @@ -228,7 +260,8 @@ class AndroidKeystoreProvider( override fun onAuthenticationFailed() { Log.e(TAG, "Authentication failed") } - }) + }, + ) // Initialize cipher for decryption with IV from stored encrypted key val combined = Base64.decode(encryptedKeyB64, Base64.NO_WRAP) @@ -238,15 +271,14 @@ class AndroidKeystoreProvider( val cipher = Cipher.getInstance( "${KeyProperties.KEY_ALGORITHM_AES}/" + - "${KeyProperties.BLOCK_MODE_GCM}/" + - KeyProperties.ENCRYPTION_PADDING_NONE + "${KeyProperties.BLOCK_MODE_GCM}/" + + KeyProperties.ENCRYPTION_PADDING_NONE, ) val spec = GCMParameterSpec(128, iv) cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) // Show biometric prompt biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) - } catch (e: Exception) { Log.e(TAG, "Error in biometric key retrieval", e) callback.onError(e) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/KeystoreProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/KeystoreProvider.kt index 2a9b878cf..67bd9eb5a 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/KeystoreProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/KeystoreProvider.kt @@ -1,7 +1,5 @@ package net.aliasvault.app.vaultstore.keystoreprovider -import androidx.fragment.app.FragmentActivity - /** * Interface for keystore providers that handle secure storage of encryption keys with biometric protection. * This allows for different implementations for real devices and testing. @@ -19,19 +17,14 @@ interface KeystoreProvider { * @param key The encryption key to store * @param callback The callback to handle the result */ - fun storeKey( - key: String, - callback: KeystoreOperationCallback - ) + fun storeKey(key: String, callback: KeystoreOperationCallback) /** * Retrieve an encryption key using biometric authentication * @param activity The activity to show the biometric prompt on * @param callback The callback to handle the result */ - fun retrieveKey( - callback: KeystoreOperationCallback - ) + fun retrieveKey(callback: KeystoreOperationCallback) /** * Clear all stored keys diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/TestKeystoreProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/TestKeystoreProvider.kt index 9bb5270a7..599d53ecc 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/TestKeystoreProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/TestKeystoreProvider.kt @@ -1,7 +1,5 @@ package net.aliasvault.app.vaultstore.keystoreprovider -import androidx.fragment.app.FragmentActivity - /** * Test implementation of the keystore provider that does nothing and always returns false for biometric availability. * This is used for testing when biometrics are not available. @@ -11,17 +9,12 @@ class TestKeystoreProvider : KeystoreProvider { return false } - override fun storeKey( - key: String, - callback: KeystoreOperationCallback - ) { + override fun storeKey(key: String, callback: KeystoreOperationCallback) { // Do nothing in test implementation callback.onSuccess("Key stored successfully (test)") } - override fun retrieveKey( - callback: KeystoreOperationCallback - ) { + override fun retrieveKey(callback: KeystoreOperationCallback) { // Do nothing in test implementation callback.onError(Exception("No key found (test)")) } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/Credential.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/Credential.kt index 660ca722f..3e9e236f6 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/Credential.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/Credential.kt @@ -12,7 +12,7 @@ data class Credential( val password: Password?, val createdAt: Date, val updatedAt: Date, - val isDeleted: Boolean + val isDeleted: Boolean, ) data class Service( @@ -22,7 +22,7 @@ data class Service( val logo: ByteArray?, val createdAt: Date, val updatedAt: Date, - val isDeleted: Boolean + val isDeleted: Boolean, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -59,7 +59,7 @@ data class Password( val value: String, val createdAt: Date, val updatedAt: Date, - val isDeleted: Boolean + val isDeleted: Boolean, ) data class Alias( @@ -72,7 +72,7 @@ data class Alias( val email: String?, val createdAt: Date, val updatedAt: Date, - val isDeleted: Boolean + val isDeleted: Boolean, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -107,4 +107,4 @@ data class Alias( result = 31 * result + isDeleted.hashCode() return result } -} \ No newline at end of file +} diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/VaultMetadata.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/VaultMetadata.kt index 353950d12..4fb5b5c53 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/VaultMetadata.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/VaultMetadata.kt @@ -3,5 +3,5 @@ package net.aliasvault.app.vaultstore.models data class VaultMetadata( val publicEmailDomains: List = emptyList(), val privateEmailDomains: List = emptyList(), - val vaultRevisionNumber: Int = 0 -) \ No newline at end of file + val vaultRevisionNumber: Int = 0, +) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/AndroidStorageProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/AndroidStorageProvider.kt index 4fb4add72..fbc21b1c2 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/AndroidStorageProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/AndroidStorageProvider.kt @@ -1,8 +1,8 @@ package net.aliasvault.app.vaultstore.storageprovider import android.content.Context -import java.io.File import androidx.core.content.edit +import java.io.File /** * A file provider that returns the encrypted database file from the Android filesystem. diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/StorageProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/StorageProvider.kt index 866ce5d86..b93e2d4ee 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/StorageProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/StorageProvider.kt @@ -1,6 +1,5 @@ package net.aliasvault.app.vaultstore.storageprovider -import android.content.Context import java.io.File /** diff --git a/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt b/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt index 6d4e7e8ea..dcf8acba4 100644 --- a/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt +++ b/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt @@ -1,16 +1,18 @@ package net.aliasvault.app.nativevaultmanager +import net.aliasvault.app.autofill.utils.CredentialMatcher +import net.aliasvault.app.vaultstore.models.Credential +import net.aliasvault.app.vaultstore.models.Service import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import net.aliasvault.app.autofill.CredentialMatcher -import net.aliasvault.app.vaultstore.models.Credential -import net.aliasvault.app.vaultstore.models.Service import java.util.Date import java.util.UUID -import kotlin.test.* +import kotlin.test.DefaultAsserter.assertEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], manifest = Config.NONE) @@ -27,28 +29,28 @@ class AutofillTest { createTestCredential( "Gmail", "https://gmail.com", - "user@gmail.com" + "user@gmail.com", ), createTestCredential( "Google", "https://google.com", - "user@gmail.com" + "user@gmail.com", ), createTestCredential( "Coolblue", "https://www.coolblue.nl", - "user@coolblue.nl" + "user@coolblue.nl", ), createTestCredential( "Amazon", "https://amazon.com", - "user@amazon.com" + "user@amazon.com", ), createTestCredential( "Coolblue App", "com.coolblue.app", - "user@coolblue.nl" - ) + "user@coolblue.nl", + ), ) } @@ -56,7 +58,7 @@ class AutofillTest { fun testExactUrlMatch() { val matches = credentialMatcher.filterCredentialsByAppInfo( testCredentials, - "www.coolblue.nl" + "www.coolblue.nl", ) assertEquals(1, matches.size) @@ -67,7 +69,7 @@ class AutofillTest { fun testBaseUrlMatch() { val matches = credentialMatcher.filterCredentialsByAppInfo( testCredentials, - "https://gmail.com/signin" + "https://gmail.com/signin", ) assertEquals(1, matches.size) @@ -78,7 +80,7 @@ class AutofillTest { fun testRootDomainMatch() { val matches = credentialMatcher.filterCredentialsByAppInfo( testCredentials, - "https://mail.google.com" + "https://mail.google.com", ) assertEquals(1, matches.size) @@ -89,7 +91,7 @@ class AutofillTest { fun testDomainNamePartMatch() { val matches = credentialMatcher.filterCredentialsByAppInfo( testCredentials, - "https://coolblue.be" + "https://coolblue.be", ) assertEquals(2, matches.size) @@ -101,7 +103,7 @@ class AutofillTest { fun testPackageNameMatch() { val matches = credentialMatcher.filterCredentialsByAppInfo( testCredentials, - "com.coolblue.app" + "com.coolblue.app", ) assertEquals(2, matches.size) @@ -113,7 +115,7 @@ class AutofillTest { fun testNoMatches() { val matches = credentialMatcher.filterCredentialsByAppInfo( testCredentials, - "https://nonexistent.com" + "https://nonexistent.com", ) assertTrue(matches.isEmpty()) @@ -123,7 +125,7 @@ class AutofillTest { fun testInvalidUrl() { val matches = credentialMatcher.filterCredentialsByAppInfo( testCredentials, - "not a url" + "not a url", ) assertTrue(matches.isEmpty()) @@ -132,7 +134,7 @@ class AutofillTest { private fun createTestCredential( serviceName: String, serviceUrl: String, - username: String + username: String, ): Credential { return Credential( id = UUID.randomUUID(), @@ -143,7 +145,7 @@ class AutofillTest { logo = null, createdAt = Date(), updatedAt = Date(), - isDeleted = false + isDeleted = false, ), username = username, password = null, diff --git a/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/VaultStoreTest.kt b/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/VaultStoreTest.kt index 33d727830..e808dc6ac 100644 --- a/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/VaultStoreTest.kt +++ b/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/VaultStoreTest.kt @@ -1,14 +1,17 @@ package net.aliasvault.app.nativevaultmanager +import junit.framework.TestCase.assertEquals +import net.aliasvault.app.vaultstore.VaultStore +import net.aliasvault.app.vaultstore.keystoreprovider.TestKeystoreProvider +import net.aliasvault.app.vaultstore.storageprovider.TestStorageProvider import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import net.aliasvault.app.vaultstore.VaultStore -import net.aliasvault.app.vaultstore.storageprovider.TestStorageProvider -import net.aliasvault.app.vaultstore.keystoreprovider.TestKeystoreProvider -import kotlin.test.* +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], manifest = Config.NONE) @@ -97,7 +100,10 @@ class VaultStoreTest { try { // Insert the setting using raw SQL with parameters val insertSql = "INSERT INTO Settings (Key, Value, CreatedAt, UpdatedAt, IsDeleted) VALUES (?, ?, ?, ?, ?)" - val insertResult = vaultStore.executeUpdate(insertSql, arrayOf(testKey, testValue, "2025-01-01 00:00:00", "2025-01-01 00:00:00", 0)) + val insertResult = vaultStore.executeUpdate( + insertSql, + arrayOf(testKey, testValue, "2025-01-01 00:00:00", "2025-01-01 00:00:00", 0), + ) assertTrue(insertResult > 0, "Setting insertion should succeed") // Verify the setting was inserted by querying it @@ -119,8 +125,10 @@ class VaultStoreTest { val querySql2 = "SELECT MigrationId FROM __EFMigrationsHistory" val results2 = vaultStore.executeQuery(querySql2, arrayOf()) - assertTrue(results2.isNotEmpty(), "Should get a result (migration history table contents)") - + assertTrue( + results2.isNotEmpty(), + "Should get a result (migration history table contents)", + ) } catch (e: Exception) { // If anything fails, rollback the transaction throw e diff --git a/apps/mobile-app/android/build.gradle b/apps/mobile-app/android/build.gradle index 74d0cd7a6..fb5c7869d 100644 --- a/apps/mobile-app/android/build.gradle +++ b/apps/mobile-app/android/build.gradle @@ -19,10 +19,12 @@ buildscript { classpath('com.android.tools.build:gradle') classpath('com.facebook.react:react-native-gradle-plugin') classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') + classpath('org.jlleitschuh.gradle:ktlint-gradle:11.6.1') } } apply plugin: "com.facebook.react.rootproject" +apply plugin: "org.jlleitschuh.gradle.ktlint" allprojects { repositories { @@ -40,3 +42,16 @@ allprojects { maven { url 'https://www.jitpack.io' } } } + +// Configure ktlint +ktlint { + android = true + verbose = true + outputToConsole = true + ignoreFailures = false + enableExperimentalRules = true + filter { + exclude("**/generated/**") + include("**/kotlin/**") + } +} diff --git a/docs/misc/dev/mobile-apps/android.md b/docs/misc/dev/mobile-apps/android.md index 742eefc37..807169569 100644 --- a/docs/misc/dev/mobile-apps/android.md +++ b/docs/misc/dev/mobile-apps/android.md @@ -43,4 +43,23 @@ In order to run the Android unit tests: ./gradlew test ``` -You can also open up the project in Android Studio, navigate to the `VaultStoreTest.kt` file and run/debug individual tests. \ No newline at end of file +You can also open up the project in Android Studio, navigate to the `VaultStoreTest.kt` file and run/debug individual tests. + +## Linting +Linting is ran automatically during normal Android app build. You can however also run the linting checks manually: + +### Kotlin +```bash +./gradlew ktlintCheck +``` + +### Java +```bash +./gradlew lint +``` + +### Auto fix linting issues +To automatically fix linting issues (where possible), run this command: +```bash +./gradlew lintFixAll +```