Compare commits

...

140 Commits

Author SHA1 Message Date
Leendert de Borst
38db3c5054 Update docs 2025-05-31 15:56:50 +02:00
Leendert de Borst
971a21a16a Update README.md 2025-05-31 15:39:59 +02:00
Leendert de Borst
8058912eee Bump iOS app version and tweak bump version script (#878) 2025-05-31 15:39:59 +02:00
Leendert de Borst
8a9e1dc9a3 Update create-new-release docs (#878) 2025-05-31 15:39:59 +02:00
Leendert de Borst
cde78650b9 Bump version to 0.18.0 (#878) 2025-05-31 15:39:59 +02:00
Leendert de Borst
4ef9e58665 Update StartupTasks.cs (#876) 2025-05-31 12:12:55 +02:00
Leendert de Borst
b6b1d9dec9 Add amount of emails stored per user to admin user listing (#876) 2025-05-31 12:12:55 +02:00
Leendert de Borst
fa2dedb05a Unblock admin user when a password request has been requested (#876) 2025-05-31 12:12:55 +02:00
Leendert de Borst
f148ccdeba Add revoke all option to admin user refresh tokens (#874) 2025-05-31 11:42:43 +02:00
Leendert de Borst
9b038cb76c Truncate credential name/preview if too long (#872) 2025-05-31 08:47:41 +02:00
Leendert de Borst
aa726706a4 Make browser extension auth settings less strict (#872) 2025-05-31 08:47:41 +02:00
Leendert de Borst
d0017d9207 Add android app download link (#870) 2025-05-31 08:37:42 +02:00
Leendert de Borst
cde4b87371 Return fake login response if username is invalid (#868) 2025-05-31 07:45:40 +02:00
Leendert de Borst
431d8d4fca Only trigger autofill popup on username/email/password field types (#866) 2025-05-30 23:41:50 +02:00
Leendert de Borst
9fddb5f450 Reset client url on wrong input (#858) 2025-05-30 22:59:16 +02:00
Leendert de Borst
dbb6cf5b94 Add yup validation schema to auth settings (#858) 2025-05-30 22:59:16 +02:00
Leendert de Borst
bd41507ef9 Use absolute path for docker volume bind mounts (#859) 2025-05-30 18:03:53 +02:00
Leendert de Borst
ebb0e7cf68 Merge pull request #863 from lanedirt/846-add-native-android-app
Add native Android app
2025-05-30 18:03:42 +02:00
Leendert de Borst
4603051a91 Build and push docker images even if other optional steps fail (#846) 2025-05-30 18:01:26 +02:00
Leendert de Borst
f66fb53706 Update mobile-app-build.yml (#846) 2025-05-30 17:54:45 +02:00
Leendert de Borst
b603160d99 Add autofill screenshots to Android docs (#846) 2025-05-30 16:42:11 +02:00
Leendert de Borst
096b0277f3 Update mobile-app-build.yml (#846) 2025-05-30 15:52:05 +02:00
Leendert de Borst
f271040ff4 Improve android autofill settings open, bump version (#846) 2025-05-30 15:35:41 +02:00
Leendert de Borst
f313950112 Make safari extension project version the same for all projects (#846) 2025-05-30 15:11:24 +02:00
Leendert de Borst
ef1ad127e3 Update mobile-app-build.yml (#846) 2025-05-30 15:06:33 +02:00
Leendert de Borst
cac691a43d Delete lowercase duplicate validationSchema.ts (#846) 2025-05-30 13:50:36 +02:00
Leendert de Borst
4efe201224 Add iOS app build (#846) 2025-05-30 13:37:14 +02:00
Leendert de Borst
ca477c310c Make android app signed build manual dispatch (#846) 2025-05-30 12:40:21 +02:00
Leendert de Borst
77189373ba Add signed android app build (#846) 2025-05-30 11:57:36 +02:00
Leendert de Borst
1aaa5c2d55 Update mobile-app-build.yml (#846) 2025-05-30 11:04:51 +02:00
Leendert de Borst
163e5c51c2 Merge branch '846-add-native-android-app' of https://github.com/lanedirt/AliasVault into 846-add-native-android-app
* '846-add-native-android-app' of https://github.com/lanedirt/AliasVault:
  Make unit tests work from CLI (#846)
2025-05-30 10:58:59 +02:00
Leendert de Borst
29895f375f Split tasks in mobile-app-build.yml (#846) 2025-05-30 10:58:56 +02:00
Leendert de Borst
2803dcf02c Add bump-version.sh script (#846) 2025-05-30 10:56:32 +02:00
Leendert de Borst
a8e075d932 Update version to be equal for all subprojects (#846) 2025-05-30 09:59:30 +02:00
Leendert de Borst
49ba704135 Update docs (#846) 2025-05-30 09:38:59 +02:00
Leendert de Borst
9669307480 Make unit tests work from CLI (#846) 2025-05-29 21:22:09 +02:00
Leendert de Borst
343ced5b38 Make unit tests work from CLI (#846) 2025-05-29 21:08:38 +02:00
Leendert de Borst
8f66670804 Update mobile-app-build.yml (#846) 2025-05-29 20:12:33 +02:00
Leendert de Borst
c2d1fcfcd4 Update linting (#846) 2025-05-29 20:01:49 +02:00
Leendert de Borst
e5a340b67d Add android build to workflow (#846) 2025-05-29 20:00:48 +02:00
Leendert de Borst
6a0e8909a8 Refactor default auth method setting to be part of login flow (#846) 2025-05-29 18:40:10 +02:00
Leendert de Borst
5a90b4271c Fix android crash on back button (#846) 2025-05-29 18:39:41 +02:00
Leendert de Borst
f0bd837d5e Improve security (#846) 2025-05-29 17:16:55 +02:00
Leendert de Borst
de45c286b1 Fix android header issues (#846) 2025-05-29 16:26:13 +02:00
Leendert de Borst
fac0fd5f32 Add android edge-to-edge module to fix menu bar height issues (#846) 2025-05-29 16:05:42 +02:00
Leendert de Borst
5a8b6b7f29 Refactor android to satisfy linting rules (#846) 2025-05-29 13:48:19 +02:00
Leendert de Borst
c864bfcab5 Npx expo-doctor fixes (#846) 2025-05-28 20:23:02 +02:00
Leendert de Borst
c9c692ce6e Add detekt.yml for kotlin code style analysis (#846) 2025-05-28 20:20:07 +02:00
Leendert de Borst
a640e4d280 Update kotlin linting settings (#846) 2025-05-28 19:55:17 +02:00
Leendert de Borst
2f03db7951 Remove unnecessary call (#846) 2025-05-28 19:26:32 +02:00
Leendert de Borst
9e5b733c8a Update logo icons (#846) 2025-05-28 18:44:45 +02:00
Leendert de Borst
09c380afdd Rebuild Android via npx expo rebuild (#846) 2025-05-28 18:04:39 +02:00
Leendert de Borst
7d9cc6118e Rebuild iOS via npx expo prebuild to standardize (#846) 2025-05-28 17:17:49 +02:00
Leendert de Borst
c7ab42e9f2 Add android linting checks and integrate in build process (#846) 2025-05-28 16:43:05 +02:00
Leendert de Borst
1b07c5de9f Update Android UI (#846) 2025-05-28 16:00:49 +02:00
Leendert de Borst
84df5b7d98 Add native settings page open callback for android (#846) 2025-05-28 15:32:27 +02:00
Leendert de Borst
347721a575 Update docs (#846) 2025-05-28 13:51:10 +02:00
Leendert de Borst
463c31641d Make system bar transparent on android (#846) 2025-05-28 13:30:04 +02:00
Leendert de Borst
67759a814e Linting fixes (#846) 2025-05-28 12:33:46 +02:00
Leendert de Borst
763a859e22 Update UI margins to work with Android and iOS (#846) 2025-05-28 12:32:21 +02:00
Leendert de Borst
d7db5a4e76 Refactor UrlUtility to be app-specific (#846) 2025-05-28 10:37:44 +02:00
Leendert de Borst
85bb5cf944 Optimize create new credential for Android (#846) 2025-05-28 10:30:07 +02:00
Leendert de Borst
cdc59e43a9 Update android-autofill.tsx (#846) 2025-05-28 09:19:46 +02:00
Leendert de Borst
9d0a003b2d Refactor (#846) 2025-05-27 17:16:12 +02:00
Leendert de Borst
e430ae9f4f Refactor FieldFinder to separate file (#846) 2025-05-27 16:58:48 +02:00
Leendert de Borst
41ba1260d7 Add SVG icon support (#846) 2025-05-27 16:49:44 +02:00
Leendert de Borst
c7572ac3f7 Fix issue where open app was not displayed always (#846) 2025-05-27 16:32:45 +02:00
Leendert de Borst
fe5c50b3c4 Add vault locked notice (#846) 2025-05-27 15:53:48 +02:00
Leendert de Borst
2a8ed28ff9 Improve password field type detection (#846) 2025-05-27 15:35:21 +02:00
Leendert de Borst
f6764b2f33 Simplify logic (#846) 2025-05-27 15:16:40 +02:00
Leendert de Borst
1afa153381 Improve field type detection (#846) 2025-05-27 14:52:09 +02:00
Leendert de Borst
ac59273161 Trigger on both password and likely username fields (#846) 2025-05-27 13:55:00 +02:00
Leendert de Borst
551fc42de1 Show service logo if it has one in autofill suggestion (#846) 2025-05-27 13:50:46 +02:00
Leendert de Borst
4b844189bc Add aliasvault logo to autofill list item (#846) 2025-05-27 13:05:39 +02:00
Leendert de Borst
5c277e747f Refactor FieldFinder (#846) 2025-05-27 12:00:49 +02:00
Leendert de Borst
8cbd275134 Improve credential matching (#846) 2025-05-27 11:21:27 +02:00
Leendert de Borst
765625b163 Add credentialmatcher and autofill test scaffolding (#846) 2025-05-26 20:16:28 +02:00
Leendert de Borst
b3df153128 Remove obsolete sharedcredentialstore (#846) 2025-05-26 20:15:57 +02:00
Leendert de Borst
604cffc622 Add autofill docs (#846) 2025-05-26 19:34:25 +02:00
Leendert de Borst
3b114445a3 Add android docs (#846) 2025-05-26 19:34:17 +02:00
Leendert de Borst
e8942c9833 Make basic autofill dropdown work in chrome (#846) 2025-05-26 14:46:50 +02:00
Leendert de Borst
b1da32ceae Add inline suggestion flag (#846) 2025-05-26 13:12:35 +02:00
Leendert de Borst
ef58217ed3 Update autocomplete logic to only trigger for username or password fields (#846) 2025-05-26 12:07:36 +02:00
Leendert de Borst
e0dd04263c Refactor AutofillService to use VaultStore (#846) 2025-05-26 11:53:26 +02:00
Leendert de Borst
29c52c844f Add vaultstore generic instance for sharing main app and autofill component (#846) 2025-05-26 11:41:01 +02:00
Leendert de Borst
b99025c48a Remove deprecated files (#846) 2025-05-26 11:40:02 +02:00
Leendert de Borst
8ba8eb684e Add android autofill instructions page (#846) 2025-05-26 09:49:40 +02:00
Leendert de Borst
b736edbb68 Update skeleton loader color for light mode (#846) 2025-05-25 12:16:52 +02:00
Leendert de Borst
1fa0d275cc Update search input style (#846) 2025-05-24 19:21:52 +02:00
Leendert de Borst
4a05cd00e3 Fix add-edit on Android (#846) 2025-05-23 16:50:17 +02:00
Leendert de Borst
574b5ff693 Add generic ThemedContainer component (#846) 2025-05-23 16:32:35 +02:00
Leendert de Borst
e6b7d1afa1 Display add button on android (#846) 2025-05-23 15:44:56 +02:00
Leendert de Borst
cbe224385d Refactor function naming (#846) 2025-05-23 15:08:07 +02:00
Leendert de Borst
adb2f9a3d6 Add Android specific header style (#846) 2025-05-23 14:05:55 +02:00
Leendert de Borst
6790391d37 Use Base64.NO_WRAP for android to be compatible with other RFC 4648 clients (#846) 2025-05-23 12:35:14 +02:00
Leendert de Borst
2a7855e1dc Refactor (#846) 2025-05-23 11:50:07 +02:00
Leendert de Borst
f3e47d7e67 Add autolock timer to Android logic (#846) 2025-05-22 18:09:17 +02:00
Leendert de Borst
bc76e85a9c Update function naming (#846) 2025-05-22 16:54:37 +02:00
Leendert de Borst
890025cd49 Allow PIN fallback on Android unlock flow (#846) 2025-05-22 13:41:08 +02:00
Leendert de Borst
1868370d8f Make basic biometric keystore flow work (#846) 2025-05-22 13:01:38 +02:00
Leendert de Borst
9a4fc7fb37 Update vault unlock page for android (#846) 2025-05-21 17:56:16 +02:00
Leendert de Borst
199fdebd5d Add KeystoreProvider scaffolding (#846) 2025-05-21 16:11:35 +02:00
Leendert de Borst
d5f17ef99c Add base64 conversion logic (#846) 2025-05-21 14:56:18 +02:00
Leendert de Borst
3b1e039d75 Implement commitTransaction (#846) 2025-05-21 14:05:55 +02:00
Leendert de Borst
01cdd28e32 Add .code-workspace to .vscode folder (#846) 2025-05-20 22:39:00 +02:00
Leendert de Borst
95a71f6ab2 Merge pull request #855 from lanedirt/854-prepare-0173-release
Prepare 0.17.3 release
2025-05-20 15:42:46 +02:00
Leendert de Borst
41cb92befd Merge branch 'main' into 854-prepare-0173-release 2025-05-20 15:42:32 +02:00
Leendert de Borst
2cfd1a922f Merge pull request #853 from lanedirt/852-bug-vault-import-fails-if-one-or-more-2fa-tokens-cannot-be-read
Vault import fails if one or more 2FA tokens cannot be parsed
2025-05-20 15:37:30 +02:00
Leendert de Borst
511ec31d17 Bump version to 0.17.3 (#854) 2025-05-20 15:31:22 +02:00
Leendert de Borst
080e505991 Merge branch '850-prepare-0172-release' into 854-prepare-0173-release
* 850-prepare-0172-release:
  Bump version to 0.17.2 (#850)
2025-05-20 15:29:11 +02:00
Leendert de Borst
461c1a042d Silently fail incorrect 2FA codes during import instead of throwing exception (#852) 2025-05-20 15:22:09 +02:00
Leendert de Borst
f30fcf4624 Make SQLite in-memory writable, add test to verify (#846) 2025-05-20 12:57:57 +02:00
Leendert de Borst
522eeefda4 Update docs (#846) 2025-05-20 12:19:22 +02:00
Leendert de Borst
94656c4d14 Update iOS podfile (#846) 2025-05-20 11:48:14 +02:00
Leendert de Borst
bbba8d1393 Make icon symbols generic between Android and iOS platforms (#846) 2025-05-20 11:47:48 +02:00
Leendert de Borst
680f5ba926 Proxy all calls from NativeVaultManager to VaultStore (#846) 2025-05-20 11:24:23 +02:00
Leendert de Borst
04d3f80019 Add getMetadata call (#846) 2025-05-20 11:06:14 +02:00
Leendert de Borst
a4d78cf7fc Make login and vault store/get flow work (#846) 2025-05-20 10:43:35 +02:00
Leendert de Borst
9713c8ed11 Implement getAllCredentials in kotlin, make all unit tests work (#846) 2025-05-19 10:04:39 +02:00
Leendert de Borst
2f4dbf34ba Update formatting (#846) 2025-05-19 10:04:10 +02:00
Leendert de Borst
232d110e49 Update license in index.template.html (#846) 2025-05-18 16:30:01 +02:00
Leendert de Borst
0af1507686 Implement basic vault decrypt/unlock flow (#846) 2025-05-18 16:18:27 +02:00
Leendert de Borst
e481769198 Add storage provider abstraction, move vaultstore its own namespace (#846) 2025-05-18 15:47:02 +02:00
Leendert de Borst
830c390b95 Update Android unit test docs (#846) 2025-05-18 13:51:02 +02:00
Leendert de Borst
c733a60571 Refactor query specific logic to VaultStore instead of NativeVaultManager (#846) 2025-05-18 13:45:22 +02:00
Leendert de Borst
d164d8e785 Merge pull request #851 from lanedirt/850-prepare-0172-release
Prepare 0.17.2 release
2025-05-17 17:41:30 +02:00
Leendert de Borst
79221f35c6 Bump version to 0.17.2 (#850) 2025-05-17 17:39:16 +02:00
Leendert de Borst
826bd23767 Restore docker-compose.yml container versions to :latest (#848) 2025-05-17 17:35:51 +02:00
Leendert de Borst
baf81392eb Restore docker-compose.yml container versions to :latest (#848) 2025-05-17 17:14:01 +02:00
Leendert de Borst
a70f6fca56 Add Android native vault manager unit test scaffolding (#846) 2025-05-17 12:00:12 +02:00
Leendert de Borst
1480fd88d1 Implement NativeVaultManager kotlin scaffolding (#846) 2025-05-17 11:04:22 +02:00
Leendert de Borst
11a5e10f4b Update comments (#846) 2025-05-17 11:00:39 +02:00
Leendert de Borst
eecf61b8b2 Fix packages to make android buildable (#846) 2025-05-16 17:46:55 +02:00
Leendert de Borst
6c620e34e6 Update docs (#846) 2025-05-16 17:14:05 +02:00
Leendert de Borst
aa99bbc111 Remove sqlite migration scripts (#494) 2025-05-15 16:37:39 +02:00
Leendert de Borst
e34b5f586c Remove SQLite server database implementation in code (#494) 2025-05-15 16:37:39 +02:00
Leendert de Borst
80c0992eb4 Update docs (#494) 2025-05-15 16:37:39 +02:00
Leendert de Borst
1fe7f7d8dc Bump version to 0.17.1 (#843) 2025-05-14 11:23:03 +02:00
Leendert de Borst
e41552a2c0 Fix credential edit password existence check (#840) 2025-05-14 11:05:45 +02:00
Leendert de Borst
8e9c100eac Fix browser extension popup manual search/filter bug (#839) 2025-05-14 11:05:36 +02:00
286 changed files with 9084 additions and 31703 deletions

View File

@@ -6,18 +6,28 @@ on:
pull_request:
branches: [ "main" ]
workflow_dispatch:
inputs:
build_android_signed:
description: 'Build signed Android APK/AAB'
required: true
type: boolean
default: false
build_ios_signed:
description: 'Build signed iOS IPA'
required: true
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-react-native-app:
setup:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/mobile-app
steps:
- uses: actions/checkout@v4
@@ -69,6 +79,31 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test
- name: Run linting
run: npm run lint
build-ios:
needs: setup
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/mobile-app
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: apps/mobile-app/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build JS bundle (iOS - Expo)
run: |
mkdir -p build
@@ -77,8 +112,190 @@ jobs:
--output-dir ./build \
--platform ios
- name: Run tests
run: npm run test
build-android:
needs: setup
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/mobile-app
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: apps/mobile-app/package-lock.json
- name: Install dependencies
run: npm ci
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Build JS bundle (Android - Expo)
run: |
mkdir -p build
npx expo export \
--dev \
--output-dir ./build \
--platform android
- name: Run Android Unit Tests
run: |
cd android
./gradlew :app:testDebugUnitTest --tests "net.aliasvault.app.*"
- name: Upload Android Test Reports
if: always()
uses: actions/upload-artifact@v4
with:
name: android-test-reports
path: apps/mobile-app/android/app/build/reports/tests/testDebugUnitTest/
retention-days: 7
build-android-signed:
needs: setup
if: github.event_name == 'workflow_dispatch' && github.event.inputs.build_android_signed == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/mobile-app
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: apps/mobile-app/package-lock.json
- name: Install dependencies
run: npm ci
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Decode keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
- name: Configure signing
run: |
cat >> android/gradle.properties <<EOF
ALIASVAULT_UPLOAD_STORE_FILE=keystore.jks
ALIASVAULT_UPLOAD_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}
ALIASVAULT_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ALIASVAULT_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
EOF
- name: Build Signed APK & AAB
run: |
cd android
./gradlew bundleRelease
./gradlew assembleRelease
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: signed-apk
path: apps/mobile-app/android/app/build/outputs/apk/release/app-release.apk
retention-days: 14
- name: Upload AAB
uses: actions/upload-artifact@v4
with:
name: signed-aab
path: apps/mobile-app/android/app/build/outputs/bundle/release/app-release.aab
retention-days: 14
build-ios-signed:
needs: setup
if: github.event_name == 'workflow_dispatch' && github.event.inputs.build_ios_signed == 'true'
runs-on: macos-15
defaults:
run:
working-directory: apps/mobile-app
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: apps/mobile-app/package-lock.json
- name: Install dependencies
run: npm ci
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Install Fastlane
run: gem install fastlane
- name: Create ASC private key file
run: |
mkdir -p $RUNNER_TEMP/asc
echo "${{ secrets.ASC_PRIVATE_KEY_BASE64 }}" | base64 --decode > $RUNNER_TEMP/asc/AuthKey.p8
- name: Install CocoaPods
run: |
cd ios
pod install
- name: Build iOS IPA
env:
IDEFileSystemSynchronizedGroupsAreEnabled: NO
XCODE_WORKSPACE: AliasVault.xcworkspace
XCODE_SCHEME: AliasVault
XCODE_CONFIGURATION: Release
XCODE_ARCHIVE_PATH: AliasVault.xcarchive
XCODE_EXPORT_PATH: ./build
XCODE_SKIP_FILESYSTEM_SYNC: true
run: |
cd ios
xcodebuild clean -workspace "$XCODE_WORKSPACE" \
-scheme "$XCODE_SCHEME" \
-configuration "$XCODE_CONFIGURATION"
xcodebuild -workspace "$XCODE_WORKSPACE" \
-scheme "$XCODE_SCHEME" \
-configuration "$XCODE_CONFIGURATION" \
-archivePath "$XCODE_ARCHIVE_PATH" \
-destination 'generic/platform=iOS' \
-allowProvisioningUpdates \
-authenticationKeyPath $RUNNER_TEMP/asc/AuthKey.p8 \
-authenticationKeyID ${{ secrets.ASC_KEY_ID }} \
-authenticationKeyIssuerID ${{ secrets.ASC_ISSUER_ID }} \
archive
xcodebuild -exportArchive \
-archivePath "$XCODE_ARCHIVE_PATH" \
-exportOptionsPlist ../exportOptions.plist \
-exportPath "$XCODE_EXPORT_PATH"
- name: Upload IPA
uses: actions/upload-artifact@v4
with:
name: signed-ipa
path: apps/mobile-app/ios/build/AliasVault.ipa
retention-days: 14
- name: Run linting
run: npm run lint

View File

@@ -58,8 +58,73 @@ jobs:
apps/browser-extension/dist/aliasvault-browser-extension-*-sources.zip
token: ${{ secrets.GITHUB_TOKEN }}
build-android-release:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/mobile-app
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: apps/mobile-app/package-lock.json
- name: Install dependencies
run: npm ci
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Decode keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
- name: Configure signing
run: |
cat >> android/gradle.properties <<EOF
ALIASVAULT_UPLOAD_STORE_FILE=keystore.jks
ALIASVAULT_UPLOAD_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}
ALIASVAULT_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ALIASVAULT_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
EOF
- name: Build JS bundle (Android - Expo)
run: |
mkdir -p build
npx expo export \
--dev \
--output-dir ./build \
--platform android
- name: Build Signed APK & AAB
run: |
cd android
./gradlew bundleRelease
./gradlew assembleRelease
- name: Upload APK to release
uses: softprops/action-gh-release@v2
with:
files: android/app/build/outputs/apk/release/app-release.apk
token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload AAB to release
uses: softprops/action-gh-release@v2
with:
files: android/app/build/outputs/bundle/release/app-release.aab
token: ${{ secrets.GITHUB_TOKEN }}
build-and-push-docker:
needs: [upload-install-script, package-browser-extensions]
runs-on: ubuntu-latest
permissions:
contents: read

6
.gitignore vendored
View File

@@ -9,7 +9,6 @@
*.user
*.userosscache
*.sln.docstates
*.code-workspace
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
@@ -419,4 +418,7 @@ temp
# libraries and copied to the application so they can be used for debugging, but we don't need
# to check them in as it's not needed for the applications to actually run.
**/*.js.map
**/*.mjs.map
**/*.mjs.map
# Android keystore file (for publishing to Google Play)
*.keystore

24
.vscode/AliasVault.code-workspace vendored Normal file
View File

@@ -0,0 +1,24 @@
{
"folders": [
{
"name": "AliasVault",
"path": "../"
},
{
"name": "server",
"path": "../apps/server"
},
{
"name": "browser-extension",
"path": "../apps/browser-extension"
},
{
"name": "mobile-app",
"path": "../apps/mobile-app"
},
{
"path": "../docs"
}
],
"settings": {}
}

4
.vscode/tasks.json vendored
View File

@@ -155,10 +155,10 @@
}
},
{
"label": "Run Android App",
"label": "Run release Android App (device)",
"type": "shell",
"command": "npx",
"args": ["expo", "run:android"],
"args": ["expo", "run:android", "--device", "--variant", "release"],
"problemMatcher": [],
"group": {
"kind": "build",

View File

@@ -114,8 +114,8 @@ Core features that are being worked on:
- [x] Built-in TOTP authenticator
- [x] Import passwords from traditional password managers
- [x] iOS native app
- [ ] Android native app
- [ ] Data model improvements to support reusable identities in combination with aliases
- [x] Android native app
- [ ] Data model and usability improvements (more flexible aliases and credential types, folder support, editing in browser extension, bulk selecting etc.)
- [ ] Support for FIDO2/WebAuthn hardware keys and passkeys
- [ ] Adding support for family/team sharing (organization features)

View File

@@ -19,7 +19,8 @@
"secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0",
"sql.js": "^1.12.0",
"vitest": "^3.0.8",
"webext-bridge": "^6.0.1"
"webext-bridge": "^6.0.1",
"yup": "^1.6.1"
},
"devDependencies": {
"@types/chrome": "^0.0.280",
@@ -28,6 +29,7 @@
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@types/sql.js": "^1.4.9",
"@types/yup": "^0.29.14",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"@typescript-eslint/parser": "^8.21.0",
"@vitest/coverage-v8": "^3.0.8",
@@ -2085,6 +2087,13 @@
"@types/node": "*"
}
},
"node_modules/@types/yup": {
"version": "0.29.14",
"resolved": "https://registry.npmjs.org/@types/yup/-/yup-0.29.14.tgz",
"integrity": "sha512-Ynb/CjHhE/Xp/4bhHmQC4U1Ox+I2OpfRYF3dnNgQqn1cHa6LK3H1wJMNPT02tSVZA6FYuXE2ITORfbnb6zBCSA==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz",
@@ -9702,6 +9711,12 @@
"react-is": "^16.13.1"
}
},
"node_modules/property-expr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
"license": "MIT"
},
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
@@ -11833,6 +11848,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/tiny-case": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
"license": "MIT"
},
"node_modules/tiny-uid": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tiny-uid/-/tiny-uid-1.1.2.tgz",
@@ -11986,6 +12007,12 @@
"node": ">=0.6"
}
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
@@ -13472,6 +13499,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yup": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz",
"integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==",
"license": "MIT",
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
"toposort": "^2.0.2",
"type-fest": "^2.19.0"
}
},
"node_modules/zip-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/zip-dir/-/zip-dir-2.0.0.tgz",

View File

@@ -35,7 +35,8 @@
"secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0",
"sql.js": "^1.12.0",
"vitest": "^3.0.8",
"webext-bridge": "^6.0.1"
"webext-bridge": "^6.0.1",
"yup": "^1.6.1"
},
"devDependencies": {
"@types/chrome": "^0.0.280",
@@ -44,6 +45,7 @@
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@types/sql.js": "^1.4.9",
"@types/yup": "^0.29.14",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"@typescript-eslint/parser": "^8.21.0",
"@vitest/coverage-v8": "^3.0.8",

View File

@@ -447,7 +447,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -460,7 +460,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 0.18.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -479,7 +479,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "AliasVault Extension/AliasVault_Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -492,7 +492,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 0.18.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -515,7 +515,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 15;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -530,7 +530,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 0.16.2;
MARKETING_VERSION = 0.18.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -554,7 +554,7 @@
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 15;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 0.16.2;
MARKETING_VERSION = 0.18.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,

View File

@@ -55,6 +55,11 @@ export default defineContentScript({
return;
}
// Only show popup for autofill-triggerable fields
if (!formDetector.isAutofillTriggerableField()) {
return;
}
// Only inject icon and show popup if autofill popup is enabled
if (await isAutoShowPopupEnabled()) {
injectIcon(inputElement, container);
@@ -117,10 +122,10 @@ export default defineContentScript({
}
/**
* By default we check if the popup is not disabled (for current site)
* By default we check if the popup is not disabled (for current site) and if the field is autofill-triggerable
* but if forceShow is true, we show the popup regardless.
*/
const canShowPopup = forceShow || (await isAutoShowPopupEnabled());
const canShowPopup = forceShow || (await isAutoShowPopupEnabled() && formDetector.isAutofillTriggerableField());
if (canShowPopup) {
injectIcon(inputElement, container);

View File

@@ -531,7 +531,7 @@ function handleSearchInput(searchInput: HTMLInputElement, credentials: Credentia
const searchTerm = searchInput.value.toLowerCase();
// Ensure we have unique credentials
const uniqueCredentials = Array.from(new Map(credentials.map(cred => [cred.id, cred])).values());
const uniqueCredentials = Array.from(new Map(credentials.map(cred => [cred.Id, cred])).values());
let filteredCredentials;
if (searchTerm === '') {
@@ -1339,7 +1339,12 @@ async function fetchAndProcessFavicon(url: string, maxSize: number, targetWidth:
*/
async function resizeImage(imageData: Uint8Array, contentType: string, targetWidth: number): Promise<Blob | null> {
return new Promise((resolve) => {
const blob = new Blob([imageData], { type: contentType });
// Convert Uint8Array to ArrayBuffer to ensure compatibility with Blob
const arrayBuffer = imageData.buffer.slice(
imageData.byteOffset,
imageData.byteOffset + imageData.byteLength
) as ArrayBuffer; // Assert as ArrayBuffer to ensure type compatibility
const blob = new Blob([arrayBuffer], { type: contentType });
const img = new Image();
/**

View File

@@ -23,18 +23,34 @@ const CredentialCard: React.FC<CredentialCardProps> = ({ credential }) => {
* @returns The display text for the credential
*/
const getDisplayText = (cred: Credential): string => {
let returnValue = '';
// Show username if available
if (cred.Username) {
return cred.Username;
returnValue = cred.Username;
}
// Show email if username is not available
if (cred.Alias?.Email) {
return cred.Alias.Email;
returnValue = cred.Alias.Email;
}
// Show empty string if neither username nor email is available
return '';
// Trim the return value to max. 33 characters.
return returnValue.length > 33 ? returnValue.slice(0, 30) + '...' : returnValue;
};
/**
* Get the service name for a credential, trimming it to maximum length so it doesn't overflow the UI.
*/
const getCredentialServiceName = (cred: Credential): string => {
let returnValue = 'Untitled';
if (cred.ServiceName) {
returnValue = cred.ServiceName;
}
// Trim the return value to max. 33 characters.
return returnValue.length > 33 ? returnValue.slice(0, 30) + '...' : returnValue;
};
return (
@@ -53,7 +69,7 @@ const CredentialCard: React.FC<CredentialCardProps> = ({ credential }) => {
}}
/>
<div className="text-left">
<p className="font-medium text-gray-900 dark:text-white">{credential.ServiceName}</p>
<p className="font-medium text-gray-900 dark:text-white">{getCredentialServiceName(credential)}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">{getDisplayText(credential)}</p>
</div>
</button>

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { AppInfo } from '@/utils/AppInfo';
import { storage } from '#imports';
import { GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, DISABLED_SITES_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY } from '@/utils/Constants';
import * as Yup from 'yup';
type ApiOption = {
label: string;
@@ -13,6 +14,36 @@ const DEFAULT_OPTIONS: ApiOption[] = [
{ label: 'Self-hosted', value: 'custom' }
];
// Validation schema for URLs
const urlSchema = Yup.object().shape({
apiUrl: Yup.string()
.required('API URL is required')
.test('is-valid-api-url', 'Please enter a valid API URL', (value: string | undefined) => {
if (!value) {
return true; // Allow empty for non-custom option
}
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}),
clientUrl: Yup.string()
.required('Client URL is required')
.test('is-valid-client-url', 'Please enter a valid client URL', (value: string | undefined) => {
if (!value) {
return true; // Allow empty for non-custom option
}
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
})
});
/**
* Auth settings page only shown when user is not logged in.
*/
@@ -21,6 +52,7 @@ const AuthSettings: React.FC = () => {
const [customUrl, setCustomUrl] = useState<string>('');
const [customClientUrl, setCustomClientUrl] = useState<string>('');
const [isGloballyEnabled, setIsGloballyEnabled] = useState<boolean>(true);
const [errors, setErrors] = useState<{ apiUrl?: string; clientUrl?: string }>({});
useEffect(() => {
/**
@@ -63,6 +95,9 @@ const AuthSettings: React.FC = () => {
if (value !== 'custom') {
await storage.setItem('local:apiUrl', '');
await storage.setItem('local:clientUrl', '');
setCustomUrl('');
setCustomClientUrl('');
setErrors({});
}
};
@@ -72,17 +107,37 @@ const AuthSettings: React.FC = () => {
const handleCustomUrlChange = async (e: React.ChangeEvent<HTMLInputElement>) : Promise<void> => {
const value = e.target.value;
setCustomUrl(value);
await storage.setItem('local:apiUrl', value);
try {
await urlSchema.validateAt('apiUrl', { apiUrl: value });
setErrors(prev => ({ ...prev, apiUrl: undefined }));
await storage.setItem('local:apiUrl', value);
} catch (error: unknown) {
if (error instanceof Yup.ValidationError) {
setErrors(prev => ({ ...prev, apiUrl: error.message }));
// On error we revert back to the aliasvault.net official hosted instance.
await storage.setItem('local:apiUrl', AppInfo.DEFAULT_API_URL);
await storage.setItem('local:clientUrl', AppInfo.DEFAULT_CLIENT_URL);
}
}
};
/**
* Handle custom client URL change
* @param e
*/
const handleCustomClientUrlChange = async (e: React.ChangeEvent<HTMLInputElement>) : Promise<void> => {
const value = e.target.value;
setCustomClientUrl(value);
await storage.setItem('local:clientUrl', value);
try {
await urlSchema.validateAt('clientUrl', { clientUrl: value });
setErrors(prev => ({ ...prev, clientUrl: undefined }));
await storage.setItem('local:clientUrl', value);
} catch (error: unknown) {
if (error instanceof Yup.ValidationError) {
setErrors(prev => ({ ...prev, clientUrl: error.message }));
}
}
};
/**
@@ -133,8 +188,11 @@ const AuthSettings: React.FC = () => {
value={customClientUrl}
onChange={handleCustomClientUrlChange}
placeholder="https://my-aliasvault-instance.com"
className="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
className={`w-full bg-gray-50 border ${errors.clientUrl ? 'border-red-500' : 'border-gray-300'} text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white`}
/>
{errors.clientUrl && (
<p className="mt-1 text-sm text-red-500">{errors.clientUrl}</p>
)}
</div>
<div className="mb-6">
<label htmlFor="custom-api-url" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
@@ -146,8 +204,11 @@ const AuthSettings: React.FC = () => {
value={customUrl}
onChange={handleCustomUrlChange}
placeholder="https://my-aliasvault-instance.com/api"
className="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
className={`w-full bg-gray-50 border ${errors.apiUrl ? 'border-red-500' : 'border-gray-300'} text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white`}
/>
{errors.apiUrl && (
<p className="mt-1 text-sm text-red-500">{errors.apiUrl}</p>
)}
</div>
</>
)}

View File

@@ -6,7 +6,7 @@ export class AppInfo {
/**
* The current extension version. This should be updated with each release of the extension.
*/
public static readonly VERSION = '0.17.0';
public static readonly VERSION = '0.17.3';
/**
* The minimum supported AliasVault server (API) version. If the server version is below this, the

View File

@@ -22,7 +22,7 @@ export class FormDetector {
* Detect login forms on the page based on the clicked element.
*/
public containsLoginForm(): boolean {
let formWrapper = this.clickedElement?.closest('form, [role="dialog"]') as HTMLElement | null;
let formWrapper = this.getFormWrapper();
if (formWrapper?.getAttribute('role') === 'dialog') {
// If we hit a dialog, search for form only within the dialog
formWrapper = formWrapper.querySelector('form') as HTMLElement | null ?? formWrapper;
@@ -58,7 +58,7 @@ export class FormDetector {
return null;
}
const formWrapper = this.clickedElement.closest('form') ?? this.document.body;
const formWrapper = this.getFormWrapper();
return this.detectFormFields(formWrapper);
}
@@ -162,6 +162,13 @@ export class FormDetector {
return [domainSuggestion];
}
/**
* Get the form wrapper element.
*/
private getFormWrapper(): HTMLElement | null {
return this.clickedElement?.closest('form, [role="dialog"]') as HTMLElement | null;
}
/**
* Check if an element and all its parents are visible.
* This checks for display:none, visibility:hidden, and opacity:0
@@ -242,73 +249,76 @@ export class FormDetector {
}
/**
* Find an input field based on common patterns in its attributes.
* Find all input/select elements matching patterns and types, ordered by best match.
*/
private findInputField(
private findAllInputFields(
form: HTMLFormElement | null,
patterns: string[],
types: string[],
excludeElements: HTMLInputElement[] = []
): HTMLInputElement | null {
): HTMLInputElement[] {
// Query for both standard input elements and any element with a type attribute
const candidates = form
? form.querySelectorAll<HTMLInputElement>('input, select')
: this.document.querySelectorAll<HTMLInputElement>('input, select');
? form.querySelectorAll<HTMLElement>('input, select, [type]')
: this.document.querySelectorAll<HTMLElement>('input, select, [type]');
// Track best match and its pattern index
let bestMatch: HTMLInputElement | null = null;
let bestMatchIndex = patterns.length;
const matches: { input: HTMLInputElement; score: number }[] = [];
for (const input of Array.from(candidates)) {
// Skip if this element is already used
if (excludeElements.includes(input)) {
if (excludeElements.includes(input as HTMLInputElement)) {
continue;
}
// Skip if element is not visible
if (!this.isElementVisible(input)) {
continue;
}
// Handle both input and select elements
const type = input.tagName.toLowerCase() === 'select' ? 'select' : input.type.toLowerCase();
// Get type from either the element's type property or its type attribute
const type = input.tagName.toLowerCase() === 'select'
? 'select'
: (input as HTMLInputElement).type?.toLowerCase() || input.getAttribute('type')?.toLowerCase() || '';
if (!types.includes(type)) {
continue;
}
// Check for exact type match if types contains email, as that most likely is the email field.
if (types.includes('email') && input.type.toLowerCase() === 'email') {
return input;
if (types.includes('email') && type === 'email') {
matches.push({ input: input as HTMLInputElement, score: -1 });
continue;
}
// Collect all text attributes to check
const attributes = [
const attributesToCheck = [
input.id,
input.name,
input.placeholder
].map(attr => attr?.toLowerCase() ?? '');
input.getAttribute('name'),
input.getAttribute('placeholder')
]
.map(a => a?.toLowerCase() ?? '');
// Check for associated labels if input has an ID or name
if (input.id || input.name) {
const label = this.document.querySelector(`label[for="${input.id || input.name}"]`);
if (input.id || input.getAttribute('name')) {
const label = this.document.querySelector(`label[for="${input.id || input.getAttribute('name')}"]`);
if (label) {
attributes.push(label.textContent?.toLowerCase() ?? '');
attributesToCheck.push(label.textContent?.toLowerCase() ?? '');
}
}
// Check for sibling elements with class containing "label"
const parent = input.parentElement;
if (parent) {
const siblings = Array.from(parent.children);
for (const sibling of siblings) {
if (sibling !== input && Array.from(sibling.classList).some(c => c.toLowerCase().includes('label'))) {
attributes.push(sibling.textContent?.toLowerCase() ?? '');
for (const sib of Array.from(parent.children)) {
if (
sib !== input &&
Array.from(sib.classList).some(c => c.toLowerCase().includes('label'))
) {
attributesToCheck.push(sib.textContent?.toLowerCase() ?? '');
}
}
}
// Check for parent label and table cell structure
let currentElement = input;
for (let i = 0; i < 5; i++) {
let currentElement: HTMLElement | null = input;
for (let depth = 0; depth < 5 && currentElement; depth++) {
// Stop if we have too many child elements (near body)
if (currentElement.children.length > 15) {
break;
@@ -317,48 +327,65 @@ export class FormDetector {
// Check for label - search both parent and child elements
const childLabel = currentElement.querySelector('label');
if (childLabel) {
attributes.push(childLabel.textContent?.toLowerCase() ?? '');
attributesToCheck.push(childLabel.textContent?.toLowerCase() ?? '');
break;
}
// Check for table cell structure
const parentTd = currentElement.closest('td');
if (parentTd) {
const td = currentElement.closest('td');
if (td) {
// Get the parent row
const parentTr = parentTd.closest('tr');
if (parentTr) {
const row = td.closest('tr');
if (row) {
// Check all sibling cells in the row
const siblingTds = parentTr.querySelectorAll('td');
for (const td of siblingTds) {
if (td !== parentTd) { // Skip the cell containing the input
attributes.push(td.textContent?.toLowerCase() ?? '');
for (const cell of Array.from(row.querySelectorAll('td'))) {
if (cell !== td) {
attributesToCheck.push(cell.textContent?.toLowerCase() ?? '');
break;
}
}
}
break; // Found table structure, no need to continue up the tree
break;
}
if (currentElement.parentElement) {
currentElement = currentElement.parentElement as HTMLInputElement;
} else {
currentElement = currentElement.parentElement;
}
let bestIndex = patterns.length;
for (let i = 0; i < patterns.length; i++) {
if (attributesToCheck.some(a => a.includes(patterns[i]))) {
bestIndex = i;
break;
}
}
// Find the earliest matching pattern
for (let i = 0; i < patterns.length; i++) {
if (i >= bestMatchIndex) {
break;
} // Skip if we already have a better match
if (attributes.some(attr => attr.includes(patterns[i]))) {
bestMatch = input;
bestMatchIndex = i;
break; // Found the best possible match for this input
}
if (bestIndex < patterns.length) {
matches.push({ input: input as HTMLInputElement, score: bestIndex });
}
}
return bestMatch;
return matches
.sort((a, b) => a.score - b.score)
.map(m => m.input);
}
/**
* Find a single input/select element based on common patterns in its attributes.
*/
private findInputField(
form: HTMLFormElement | null,
patterns: string[],
types: string[],
excludeElements: HTMLInputElement[] = []
): HTMLInputElement | null {
const all = this.findAllInputFields(form, patterns, types, excludeElements);
// if email type explicitly requested, prefer actual <input type="email">
if (types.includes('email')) {
const emailMatch = all.find(i => (i.type || '').toLowerCase() === 'email');
if (emailMatch) {
return emailMatch;
}
}
return all.length > 0 ? all[0] : null;
}
/**
@@ -546,15 +573,11 @@ export class FormDetector {
primary: HTMLInputElement | null,
confirm: HTMLInputElement | null
} {
const candidates = form
? form.querySelectorAll<HTMLInputElement>('input[type="password"]')
: this.document.querySelectorAll<HTMLInputElement>('input[type="password"]');
const visibleCandidates = Array.from(candidates).filter(input => this.isElementVisible(input));
const passwordFields = this.findAllInputFields(form, CombinedFieldPatterns.password, ['password']);
return {
primary: visibleCandidates[0] ?? null,
confirm: visibleCandidates[1] ?? null
primary: passwordFields[0] ?? null,
confirm: passwordFields[1] ?? null
};
}
@@ -601,6 +624,34 @@ export class FormDetector {
return false;
}
/**
* Check if a field is an autofill-triggerable field (username, email, or password).
*/
public isAutofillTriggerableField(): boolean {
// Check if it's a username, email or password field by reusing the existing detection logic
const formWrapper = this.getFormWrapper();
// Check if the clicked element is a username field.
const usernameFields = this.findAllInputFields(formWrapper as HTMLFormElement | null, CombinedFieldPatterns.username, ['text']);
if (usernameFields.some(input => input === this.clickedElement)) {
return true;
}
// Check if the clicked element is a password field.
const passwordField = this.findPasswordField(formWrapper as HTMLFormElement | null);
if (passwordField.primary === this.clickedElement || passwordField.confirm === this.clickedElement) {
return true;
}
// Check if the clicked element is an email field.
const emailFields = this.findAllInputFields(formWrapper as HTMLFormElement | null, CombinedFieldPatterns.email, ['text', 'email']);
if (emailFields.some(input => input === this.clickedElement)) {
return true;
}
return false;
}
/**
* Create a form entry.
*/

View File

@@ -6,7 +6,7 @@ export default defineConfig({
manifest: {
name: "AliasVault",
description: "AliasVault Browser AutoFill Extension. Keeping your personal information private.",
version: "0.17.0",
version: "0.18.0",
content_security_policy: {
extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
},

View File

@@ -0,0 +1,21 @@
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_no-wildcard-imports = true
ktlint_standard_filename = true
ktlint_standard_trailing-comma-on-call-site = true
ktlint_standard_trailing-comma-on-declaration-site = true
ktlint_standard_import-ordering = true
ktlint_standard_comment-wrapping = true
ktlint_standard_annotation = true
ktlint_standard_public-method-documentation = true
# Detekt rules
detekt.style.PublicFunctionDocumentation = enabled

View File

@@ -1,54 +0,0 @@
# Android CredentialManager Implementation
This document describes how the Android implementation of the CredentialManager works.
## Overview
The Android implementation uses the Android Keystore system to securely store and retrieve encryption keys for credentials. It also leverages the BiometricPrompt API to require user authentication for sensitive operations.
## Key Components
1. **CredentialManagerModule**: React Native bridge module exposing JS methods for credential management.
2. **SharedCredentialStore**: Core class handling secure storage and retrieval of credentials.
3. **Credential**: Simple model class representing a credential.
4. **CredentialManagerPackage**: Package registration for React Native.
## Security Features
- **AES-256 Encryption**: All credentials are encrypted using AES-256 in GCM mode.
- **Biometric Authentication**: User must authenticate with fingerprint/face/PIN to access credentials.
- **Android Keystore**: Encryption keys are stored in the Android Keystore system, making them hardware-backed on supported devices.
- **Secure Preferences**: IVs (Initialization Vectors) are stored in SharedPreferences.
## Biometric Authentication
The implementation requires biometric authentication for:
- Adding credentials
- Retrieving credentials
The user will be prompted with a biometric dialog when performing these operations.
## Key Technical Details
- The encryption key is generated with a 30-second authentication validity period, which means once the user authenticates, they won't need to authenticate again for 30 seconds.
- Each credential operation creates a new BiometricPrompt and requires fresh authentication.
- The user can cancel the authentication if desired.
- **Main Thread Requirement**: BiometricPrompt must be shown on the main UI thread. We handle this by using `activity.runOnUiThread()` in the SharedCredentialStore and `UiThreadUtil.runOnUiThread()` in the CredentialManagerModule.
## Troubleshooting
If you encounter any of these common issues:
1. **IllegalBlockSizeException**: This typically means the authentication failed or was canceled. The user should be prompted to authenticate again.
2. **Activity not available**: The biometric prompt requires a FragmentActivity. Ensure your React Native app is using a compatible activity.
3. **Biometric hardware not available**: Some devices may not have biometric hardware. The implementation should gracefully fail with an appropriate error message.
4. **IllegalStateException: Must be called from main thread of fragment host**: This indicates that BiometricPrompt was not shown on the main UI thread. The current implementation handles this by ensuring all UI operations happen on the main thread.
## Compatibility
- Minimum SDK: 24 (Android 7.0)
- Required Permissions: USE_BIOMETRIC (added to AndroidManifest.xml)
- Required Dependencies: androidx.biometric:biometric:1.1.0 (added to build.gradle)

View File

@@ -1,6 +1,8 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
apply plugin: "org.jlleitschuh.gradle.ktlint"
apply plugin: "io.gitlab.arturbosch.detekt"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
@@ -91,8 +93,8 @@ android {
applicationId 'net.aliasvault.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
versionCode 3
versionName "0.18.0"
}
signingConfigs {
debug {
@@ -101,6 +103,14 @@ android {
keyAlias 'androiddebugkey'
keyPassword 'android'
}
release {
if (project.hasProperty('ALIASVAULT_UPLOAD_STORE_FILE')) {
storeFile file(ALIASVAULT_UPLOAD_STORE_FILE)
storePassword ALIASVAULT_UPLOAD_STORE_PASSWORD
keyAlias ALIASVAULT_UPLOAD_KEY_ALIAS
keyPassword ALIASVAULT_UPLOAD_KEY_PASSWORD
}
}
}
buildTypes {
debug {
@@ -110,6 +120,7 @@ android {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
signingConfig signingConfigs.release
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
@@ -124,6 +135,25 @@ 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")
// Focus only on app/src and exclude expo modules
disable 'InvalidPackage'
}
testOptions {
unitTests.all {
useJUnitPlatform()
testLogging {
events "passed", "failed", "skipped"
exceptionFormat "full"
showStandardStreams true
}
}
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
@@ -153,6 +183,22 @@ dependencies {
// Add biometric dependency for credential management
implementation("androidx.biometric:biometric:1.1.0")
// Add vector drawable support for SVG
implementation 'com.caverock:androidsvg:1.4'
// Test dependencies
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:4.0.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:4.0.0'
testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.test:runner:1.5.2'
testImplementation 'androidx.test:rules:1.5.0'
testImplementation 'androidx.test.ext:junit:1.1.5'
testImplementation 'org.robolectric:robolectric:4.9'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.2'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2'
testImplementation 'org.junit.vintage:junit-vintage-engine:5.9.2'
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
@@ -176,4 +222,69 @@ dependencies {
} else {
implementation jscFlavor
}
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 lintFormat {
description = "Run all auto-fixers (ktlint and Android lint)"
group = "formatting"
dependsOn 'ktlintFormat'
dependsOn 'detekt'
}
task lintCheck {
description = "Run all linting checks (ktlint, Detekt and Android lint)"
group = "formatting"
dependsOn 'ktlintCheck'
dependsOn 'detekt'
}
// 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")
}
}
detekt {
buildUponDefaultConfig = true
config = files("$rootProject.projectDir/detekt.yml")
baseline = file("$rootProject.projectDir/baseline.xml")
source.setFrom(files("src/main/java"))
reports {
html.required.set(true)
xml.required.set(true)
txt.required.set(false)
sarif.required.set(false)
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!-- Disable some rules that are too strict or not relevant -->
<issue id="ObsoleteLintCustomCheck" severity="ignore" />
<issue id="GradleDependency" severity="ignore" />
<!-- Enable and configure important rules -->
<issue id="NewApi" severity="error" />
<issue id="InlinedApi" severity="error" />
<issue id="MissingPermission" severity="error" />
<issue id="HardcodedText" severity="warning" />
<issue id="UnusedResources" severity="warning" />
<issue id="ContentDescription" severity="warning" />
<issue id="ClickableViewAccessibility" severity="warning" />
<!-- Kotlin specific rules -->
<issue id="KotlinPropertyAccess" severity="warning" />
<issue id="KotlinNullness" severity="error" />
</lint>

View File

@@ -1,10 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
@@ -12,10 +10,16 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@drawable/ic_launcher" android:roundIcon="@drawable/ic_launcher" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@drawable/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:usesCleartextTraffic="true">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<service android:name=".autofill.AutofillService" android:permission="android.permission.BIND_AUTOFILL_SERVICE" android:exported="true">
<intent-filter>
<action android:name="android.service.autofill.AutofillService"/>
</intent-filter>
<meta-data android:name="android.autofill" android:resource="@xml/autofill_service"/>
</service>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@@ -28,19 +32,8 @@
<data android:scheme="aliasvault"/>
<data android:scheme="net.aliasvault.app"/>
<data android:scheme="exp+aliasvault"/>
<data android:scheme="net.aliasvault.app"/>
</intent-filter>
</activity>
<service
android:name=".AutofillService"
android:permission="android.permission.BIND_AUTOFILL_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.service.autofill.AutofillService" />
</intent-filter>
<meta-data
android:name="android.autofill"
android:resource="@xml/autofill_service" />
</service>
</application>
</manifest>

View File

@@ -1,275 +0,0 @@
/**
* AliasVault Autofill Service Implementation
*
* This service implements the Android Autofill framework to provide AliasVault credentials
* to forms. It identifies username and password fields in apps and websites,
* then offers stored credentials from AliasVault.
*
* IMPORTANT IMPLEMENTATION NOTES:
* 1. Since autofill services don't have direct access to activities, we need a way to
* authenticate the user. The current implementation:
* - Shows a Toast indicating authentication is needed
* - In a real implementation, would launch an activity for authentication
*
* 2. To complete this implementation, you need to:
* - Register this service in AndroidManifest.xml with proper metadata
* - Add a way to communicate between the launched activity and this service
* - Implement credential storage/retrieval with proper authentication
*
* 3. For full production implementation, consider:
* - Adding a specific autofill activity for authentication
* - Implementing dataset presentation customization
* - Adding support for save functionality
* - Implementing field detection heuristics for apps without autofill hints
*/
package net.aliasvault.app
import android.app.assist.AssistStructure
import android.content.Intent
import android.os.CancellationSignal
import android.service.autofill.AutofillService
import android.service.autofill.Dataset
import android.service.autofill.FillCallback
import android.service.autofill.FillRequest
import android.service.autofill.FillResponse
import android.service.autofill.SaveCallback
import android.service.autofill.SaveRequest
import android.util.Log
import android.view.View
import android.view.autofill.AutofillId
import android.view.autofill.AutofillValue
import android.widget.RemoteViews
import net.aliasvault.app.credentialmanager.Credential
import net.aliasvault.app.credentialmanager.SharedCredentialStore
import net.aliasvault.app.credentialmanager.SharedCredentialStore.CryptoOperationCallback
import org.json.JSONArray
class AutofillService : AutofillService() {
private val TAG = "AliasVaultAutofill"
override fun onFillRequest(
request: FillRequest,
cancellationSignal: CancellationSignal,
callback: FillCallback
) {
Log.d(TAG, "onFillRequest called")
// Check if request was cancelled
if (cancellationSignal.isCanceled) {
return
}
// Get the autofill contexts for this request
val contexts = request.fillContexts
val context = contexts.last()
val structure = context.structure
// Find any autofillable fields in the form
val fieldFinder = FieldFinder()
parseStructure(structure, fieldFinder)
// If no fields were found, return an empty response
if (fieldFinder.autofillableFields.isEmpty()) {
Log.d(TAG, "No autofillable fields found")
callback.onSuccess(null)
return
}
launchActivityForAutofill(fieldFinder, callback)
}
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
// In a full implementation, you would:
// 1. Extract the username/password from the SaveRequest
// 2. Launch an activity to let the user confirm saving
// 3. Save the credential using the SharedCredentialStore
// For now, just acknowledge the request
callback.onSuccess()
}
private fun launchActivityForAutofill(fieldFinder: FieldFinder, callback: FillCallback) {
Log.d(TAG, "Launching activity for autofill authentication")
// Get the shared credential store
val store = SharedCredentialStore.getInstance(applicationContext)
// Try to retrieve all credentials using the cached key first, which if available
// does not require biometric authentication.
if (store.tryGetAllCredentialsWithCachedKey(object : CryptoOperationCallback {
override fun onSuccess(jsonString: String) {
try {
val jsonArray = JSONArray(jsonString)
Log.d(TAG, "Retrieved ${jsonArray.length()} credentials")
if (jsonArray.length() == 0) {
// No credentials available
Log.d(TAG, "No credentials available")
callback.onSuccess(null)
return
}
// Create a response with all credentials
val responseBuilder = FillResponse.Builder()
// Add each credential as a dataset
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val username = jsonObject.getString("username")
val password = jsonObject.getString("password")
val service = jsonObject.getString("service")
// Create a credential object
val credential = Credential(username, password, service)
// Create a dataset for this credential
addDatasetForCredential(responseBuilder, fieldFinder, credential)
}
// Send the response back
callback.onSuccess(responseBuilder.build())
} catch (e: Exception) {
Log.e(TAG, "Error parsing credentials", e)
callback.onSuccess(null)
}
}
override fun onError(e: Exception) {
Log.e(TAG, "Error getting credentials", e)
callback.onSuccess(null)
}
})) {
// Successfully used cached key - method returns true
Log.d(TAG, "Successfully retrieved credentials with cached key")
} else {
// No cached key available, we need to launch the AliasVault app in order to
// load the encryption key from biometric keystore.
Log.d(TAG, "No cached key available, launching activity for authentication")
// Create an intent to launch MainActivity with autofill flags
// TODO: detect "AUTOFILL_REQUEST" when app opens to show proper help text to
// indicate vault should be unlocked in order for autofill to work. With dismiss
// close buttons etc for better UX.
val intent = Intent(this, MainActivity::class.java).apply {
// Add flags to launch as a new task
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// Add extra data to indicate this is for autofill
putExtra("AUTOFILL_REQUEST", true)
}
// Start the activity
startActivity(intent)
}
}
// Helper method to create a dataset from a credential
private fun addDatasetForCredential(
responseBuilder: FillResponse.Builder,
fieldFinder: FieldFinder,
credential: Credential
) {
// Create presentation for this credential
val presentation = RemoteViews(packageName, android.R.layout.simple_list_item_1)
presentation.setTextViewText(
android.R.id.text1,
"AliasVault: ${credential.username} (${credential.service})"
)
val dataSetBuilder = Dataset.Builder(presentation)
// Add autofill values for all fields
for (field in fieldFinder.autofillableFields) {
val isPassword = field.second
val value = if (isPassword) credential.password else credential.username
dataSetBuilder.setValue(field.first, AutofillValue.forText(value))
}
// Add this dataset to the response
responseBuilder.addDataset(dataSetBuilder.build())
}
private fun parseStructure(structure: AssistStructure, fieldFinder: FieldFinder) {
val nodeCount = structure.windowNodeCount
for (i in 0 until nodeCount) {
val windowNode = structure.getWindowNodeAt(i)
val rootNode = windowNode.rootViewNode
parseNode(rootNode, fieldFinder)
}
}
private fun parseNode(node: AssistStructure.ViewNode, fieldFinder: FieldFinder) {
val viewId = node.autofillId
// Consider any editable field as an autofillable field
if (viewId != null && isEditableField(node)) {
// Check if it's likely a password field
val isPasswordField = isLikelyPasswordField(node)
fieldFinder.autofillableFields.add(Pair(viewId, isPasswordField))
Log.d(TAG, "Found autofillable field: $viewId, isPassword: $isPasswordField")
}
// Recursively parse child nodes
val childCount = node.childCount
for (i in 0 until childCount) {
parseNode(node.getChildAt(i), fieldFinder)
}
}
private fun isEditableField(node: AssistStructure.ViewNode): Boolean {
// Check if the node is editable in any way
return node.inputType > 0 ||
node.className?.contains("EditText") == true ||
node.className?.contains("Input") == true ||
node.htmlInfo?.tag?.equals("input", ignoreCase = true) == true
}
private fun isLikelyPasswordField(node: AssistStructure.ViewNode): Boolean {
// Try to determine if this is a password field
val hints = node.autofillHints
if (hints != null) {
for (hint in hints) {
if (hint == View.AUTOFILL_HINT_PASSWORD || hint.contains("password", ignoreCase = true)) {
return true
}
}
}
// Check by input type
if ((node.inputType and android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD) != 0 ||
(node.inputType and android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) != 0) {
return true
}
// Check by ID or text
val idEntry = node.idEntry
if (idEntry != null && idEntry.contains("pass", ignoreCase = true)) {
return true
}
// Check by HTML attributes
val htmlInfo = node.htmlInfo
if (htmlInfo != null) {
val attributes = htmlInfo.attributes
if (attributes != null) {
for (i in 0 until attributes.size) {
val name = attributes.get(i)?.first
val value = attributes.get(i)?.second
if (name == "type" && value == "password") {
return true
}
}
}
}
return false
}
private class FieldFinder {
// Store pairs of (AutofillId, isPasswordField)
val autofillableFields = mutableListOf<Pair<AutofillId, Boolean>>()
}
companion object {
private const val TAG = "AliasVaultAutofill"
}
}

View File

@@ -1,77 +1,50 @@
package net.aliasvault.app
import expo.modules.splashscreen.SplashScreenManager
import android.os.Build
import android.os.Bundle
import android.content.Intent
import android.net.Uri
import android.provider.Settings
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
/**
* The main activity of the app.
*/
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
// Initialize autofill service, this opens the set_autofill_service setting screen
// to instruct user to enable AliasVault as autofill provider.
// TODO: this should be triggerable from React Native instead so we can better control flow
// of when to ask the user to enable native system autofill.
startActivity(Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
data = Uri.parse("package:net.aliasvault.app")
})
super.onCreate(null)
}
/**
* Called when the activity is created.
*/
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
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
super.onCreate(null)
}
/**
* 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 name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
/**
* 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,
) {},
)
}
}

View File

@@ -2,58 +2,62 @@ 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.credentialmanager.CredentialManagerPackage
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
import net.aliasvault.app.nativevaultmanager.NativeVaultManagerPackage
/**
* The main application class.
*/
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
/**
* The react native host.
*/
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> {
val packages = PackageList(this).packages
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
packages.add(CredentialManagerPackage())
return packages
}
override fun getPackages(): List<ReactPackage> {
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)
}
}

View File

@@ -0,0 +1,409 @@
/**
* AliasVault Autofill Service Implementation
*
* This service implements the Android Autofill framework to provide AliasVault credentials
* to forms. It identifies username and password fields in apps and websites,
* then offers stored credentials from AliasVault.
*
*/
package net.aliasvault.app.autofill
import android.app.PendingIntent
import android.content.Intent
import android.os.CancellationSignal
import android.service.autofill.AutofillService
import android.service.autofill.Dataset
import android.service.autofill.FillCallback
import android.service.autofill.FillRequest
import android.service.autofill.FillResponse
import android.service.autofill.SaveCallback
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.interfaces.CredentialOperationCallback
import net.aliasvault.app.vaultstore.models.Credential
/**
* The AutofillService class.
*/
class AutofillService : AutofillService() {
companion object {
/**
* The tag for logging.
*/
private const val TAG = "AliasVaultAutofill"
}
override fun onFillRequest(
request: FillRequest,
cancellationSignal: CancellationSignal,
callback: FillCallback,
) {
Log.d(TAG, "onFillRequest called")
// Check if request was cancelled
if (cancellationSignal.isCanceled) {
return
}
// Get the autofill contexts for this request
val contexts = request.fillContexts
val context = contexts.last()
val structure = context.structure
// Find any autofillable fields in the form
val fieldFinder = FieldFinder(structure)
fieldFinder.parseStructure()
// If no password field was found, return an empty response
if (!fieldFinder.foundPasswordField && !fieldFinder.foundUsernameField) {
Log.d(TAG, "No password or username field found, skipping autofill")
callback.onSuccess(null)
return
}
// If we found a password field but no username field, and we have a last field,
// assume it's the username field
/*if (!fieldFinder.foundUsernameField && fieldFinder.lastField != null) {
fieldFinder.autofillableFields.add(Pair(fieldFinder.lastField!!, FieldType.USERNAME))
Log.d(TAG, "Using last field as username field: ${fieldFinder.lastField}")
}*/
launchActivityForAutofill(fieldFinder, callback)
}
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
// In a full implementation, you would:
// 1. Extract the username/password from the SaveRequest
// 2. Launch an activity to let the user confirm saving
// 3. Save the credential using the VaultStore
// For now, just acknowledge the request
callback.onSuccess()
}
private fun launchActivityForAutofill(fieldFinder: FieldFinder, callback: FillCallback) {
Log.d(TAG, "Launching activity for autofill authentication")
// Get the app/website information from assist structure.
val appInfo = fieldFinder.getAppInfo()
Log.d(TAG, "Autofill request from: $appInfo")
// Ignore requests from our own unlock page as this would cause a loop
if (appInfo == "net.aliasvault.app") {
Log.d(TAG, "Skipping autofill request from AliasVault app itself")
callback.onSuccess(null)
return
}
// First try to get an existing instance
val store = VaultStore.getExistingInstance()
if (store != null) {
// We have an existing instance, try to get credentials
if (store.tryGetAllCredentials(object : CredentialOperationCallback {
override fun onSuccess(result: List<Credential>) {
try {
Log.d(TAG, "Retrieved ${result.size} credentials")
if (result.isEmpty()) {
// No credentials available
Log.d(TAG, "No credentials available")
callback.onSuccess(null)
return
}
// Filter credentials based on app/website info
val filteredCredentials = if (appInfo != null) {
CredentialMatcher.filterCredentialsByAppInfo(result, appInfo)
} else {
result
}
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",
)
responseBuilder.addDataset(createNoMatchesDataset(fieldFinder))
} else {
// If there are matches, add them to the dataset
for (credential in filteredCredentials) {
responseBuilder.addDataset(
createCredentialDataset(fieldFinder, credential),
)
}
// Add "Open AliasVault app" as the last option
responseBuilder.addDataset(createOpenAppDataset(fieldFinder))
}
callback.onSuccess(responseBuilder.build())
} catch (e: Exception) {
Log.e(TAG, "Error parsing credentials", e)
callback.onSuccess(null)
}
}
override fun onError(e: Exception) {
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
}
}
// If we get here, either there was no instance or the vault wasn't unlocked
// Show a "vault locked" placeholder instead of launching the activity
Log.d(TAG, "Vault is locked, showing placeholder")
val responseBuilder = FillResponse.Builder()
responseBuilder.addDataset(createVaultLockedDataset(fieldFinder))
callback.onSuccess(responseBuilder.build())
}
/**
* Create a dataset from a credential.
* @param fieldFinder The field finder
* @param credential The credential
* @return The 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
} else {
R.layout.autofill_dataset_item
}
// Create presentation for this credential using our custom layout
val presentation = RemoteViews(packageName, layoutId)
val dataSetBuilder = Dataset.Builder(presentation)
// Add autofill values for all fields
var presentationDisplayValue = credential.service.name
for (field in fieldFinder.autofillableFields) {
val fieldType = field.second
when (fieldType) {
FieldType.PASSWORD -> {
if (credential.password != null) {
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),
)
presentationDisplayValue = "${credential.service.name} (${credential.alias.email})"
} else if (credential.username != null) {
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),
)
presentationDisplayValue = "${credential.service.name} (${credential.username})"
} else if (credential.alias?.email != null) {
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),
)
presentationDisplayValue = "${credential.service.name} (${credential.alias.email})"
} else if (credential.username != null) {
dataSetBuilder.setValue(
field.first,
AutofillValue.forText(credential.username),
)
presentationDisplayValue = "${credential.service.name} (${credential.username})"
}
}
}
}
// Set the display value of the dropdown item.
presentation.setTextViewText(
R.id.text,
presentationDisplayValue,
)
// Set the logo if available
val logoBytes = credential.service.logo
if (logoBytes != null) {
val bitmap = ImageUtils.bytesToBitmap(logoBytes)
if (bitmap != null) {
presentation.setImageViewBitmap(R.id.icon, bitmap)
}
}
return dataSetBuilder.build()
}
/**
* Create a dataset for the "no matches" option.
* @param fieldFinder The field finder
* @return The dataset
*/
private fun createNoMatchesDataset(fieldFinder: FieldFinder): Dataset {
// Create presentation for the "no matches" option
val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_logo)
presentation.setTextViewText(
R.id.text,
"No match found, create new?",
)
val dataSetBuilder = Dataset.Builder(presentation)
// Get the app/website information to use as service URL
val appInfo = fieldFinder.getAppInfo()
val encodedUrl = appInfo?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: ""
// Create deep link URL
val deepLinkUrl = "net.aliasvault.app://credentials/add-edit-page?serviceUrl=$encodedUrl"
// Add a click listener to open AliasVault app with deep link
val intent = Intent(Intent.ACTION_VIEW).apply {
data = android.net.Uri.parse(deepLinkUrl)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val pendingIntent = PendingIntent.getActivity(
this@AutofillService,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
dataSetBuilder.setAuthentication(pendingIntent.intentSender)
// Add a placeholder value to both username and password fields to satisfy the requirement that at least one value must be set
if (fieldFinder.autofillableFields.isNotEmpty()) {
for (field in fieldFinder.autofillableFields) {
dataSetBuilder.setValue(field.first, AutofillValue.forText(""))
}
}
return dataSetBuilder.build()
}
/**
* Create a dataset for the "open app" option.
* @param fieldFinder The field finder
* @return The dataset
*/
private fun createOpenAppDataset(fieldFinder: FieldFinder): Dataset {
val openAppPresentation = RemoteViews(packageName, R.layout.autofill_dataset_item_logo)
openAppPresentation.setTextViewText(
R.id.text,
"Open app",
)
val dataSetBuilder = Dataset.Builder(openAppPresentation)
// Get the app/website information to use as service URL
val appInfo = fieldFinder.getAppInfo()
val encodedUrl = appInfo?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: ""
// Create deep link URL to credentials page with service URL
val deepLinkUrl = "net.aliasvault.app://credentials?serviceUrl=$encodedUrl"
// Add a click listener to open AliasVault app with deep link
val intent = Intent(Intent.ACTION_VIEW).apply {
data = android.net.Uri.parse(deepLinkUrl)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val pendingIntent = PendingIntent.getActivity(
this@AutofillService,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
dataSetBuilder.setAuthentication(pendingIntent.intentSender)
// Set an empty value for the field to satisfy the requirement
if (fieldFinder.autofillableFields.isNotEmpty()) {
for (field in fieldFinder.autofillableFields) {
dataSetBuilder.setValue(field.first, AutofillValue.forText(""))
}
}
return dataSetBuilder.build()
}
/**
* Create a dataset for the "vault locked" option.
* @param fieldFinder The field finder
* @return The dataset
*/
private fun createVaultLockedDataset(fieldFinder: FieldFinder): Dataset {
// Create presentation for the "vault locked" option
val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_logo)
presentation.setTextViewText(
R.id.text,
"Vault locked",
)
val dataSetBuilder = Dataset.Builder(presentation)
// Add a click listener to open AliasVault app
val intent = Intent(this@AutofillService, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra("OPEN_CREDENTIALS", true)
}
val pendingIntent = PendingIntent.getActivity(
this@AutofillService,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
dataSetBuilder.setAuthentication(pendingIntent.intentSender)
// Add a placeholder value to both username and password fields to satisfy the requirement that at least one value must be set
if (fieldFinder.autofillableFields.isNotEmpty()) {
for (field in fieldFinder.autofillableFields) {
dataSetBuilder.setValue(field.first, AutofillValue.forText(""))
}
}
return dataSetBuilder.build()
}
}

View File

@@ -0,0 +1,11 @@
package net.aliasvault.app.autofill.models
/**
* Field types that can be extracted from AssistStructure.
*/
enum class FieldType {
EMAIL,
USERNAME,
PASSWORD,
UNKNOWN,
}

View File

@@ -0,0 +1,116 @@
package net.aliasvault.app.autofill.utils
import net.aliasvault.app.vaultstore.models.Credential
/**
* Helper class to match credentials against app/website information for autofill.
* This class handles the logic of determining which credentials are relevant
* for a given app or website context.
*/
object CredentialMatcher {
/**
* Filters a list of credentials based on app/package name or URL.
* Matching strategies in order of specificity:
* 1. Exact URL match
* 2. Base URL match (excluding path/query)
* 3. Root domain match
* 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<Credential>,
appInfo: String,
): List<Credential> {
if (appInfo.isBlank()) return credentials
val input = appInfo.trim().lowercase()
val isUrlLike = input.contains('.') && !input.contains(' ')
val host: String?
val rootDomain: String?
val domainKey: String
if (isUrlLike && listOf("http://", "https://", "www.").any { input.startsWith(it) }) {
// Treat as full or partial URL
val cleaned = input
.removePrefix("https://")
.removePrefix("http://")
.removePrefix("www.")
host = cleaned.substringBefore("/").substringBefore("?")
rootDomain = extractRootDomain(host)
domainKey = extractDomainWithoutExtension(rootDomain)
} else if (isUrlLike && !input.contains('/')) {
// Treat as package name (e.g., com.coolblue.app)
val parts = input.split('.')
host = null
rootDomain = null
domainKey = if (parts.size >= 3) parts[1] else parts.first()
} else {
// Plain text search
host = null
rootDomain = null
domainKey = input
}
val matches = mutableListOf<Credential>()
if (host != null) {
// 1. Exact URL match (with or without scheme)
matches += credentials.filter { cred ->
cred.service.url?.trim()?.lowercase() in listOf(
input,
"https://$host",
"http://$host",
)
}
// 2. Base URL match
if (matches.isEmpty()) {
matches += credentials.filter { cred ->
cred.service.url?.trim()?.lowercase()?.let { url ->
url.startsWith("https://$host") || url.startsWith("http://$host")
} == true
}
}
}
if (matches.isEmpty() && rootDomain != null) {
// 3. Root domain match
matches += credentials.filter { cred ->
cred.service.url?.trim()?.lowercase()?.let { url ->
val u = url.removePrefix("https://")
.removePrefix("http://")
.removePrefix("www.")
.substringBefore("/")
extractRootDomain(u) == rootDomain
} == true
}
}
// 4. Domain key match against service name
if (matches.isEmpty()) {
matches += credentials.filter { cred ->
cred.service.name?.lowercase()?.let { name ->
name.contains(domainKey) || domainKey.contains(name)
} == true
}
}
return matches
}
/**
* Extracts the root domain from a host string.
* E.g., "sub.example.com" -> "example.com"
*/
private fun extractRootDomain(host: String): String {
val parts = host.split('.')
return if (parts.size >= 2) parts.takeLast(2).joinToString(".") else host
}
/**
* Extracts the domain key (name without extension/TLD).
* E.g., "example.com" -> "example"
*/
private fun extractDomainWithoutExtension(domain: String): String {
return domain.substringBefore('.')
}
}

View File

@@ -0,0 +1,459 @@
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 androidx.core.net.toUri
import net.aliasvault.app.autofill.models.FieldType
/**
* Helper class to find fields in the assist structure.
* @param structure The assist structure to parse
*/
class FieldFinder(var structure: AssistStructure) {
companion object {
/**
* The tag for logging.
*/
private const val TAG = "AliasVaultAutofill"
}
/**
* The list of autofillable fields.
*/
val autofillableFields = mutableListOf<Pair<AutofillId, FieldType>>()
/**
* Whether a username field has been found.
*/
var foundUsernameField = false
/**
* Whether a password field has been found.
*/
var foundPasswordField = false
/**
* Whether a username field has been found.
*/
var lastField: AutofillId? = null
/**
* Parse the structure.
*/
fun parseStructure() {
val nodeCount = structure.windowNodeCount
for (i in 0 until nodeCount) {
val windowNode = structure.getWindowNodeAt(i)
val rootNode = windowNode.rootViewNode
parseNode(rootNode)
}
}
/**
* Get the current app or website information from the assist structure to know
* what credential suggestions to show.
*/
fun getAppInfo(): String? {
// First check if this is web content
val nodeCount = structure.windowNodeCount
for (i in 0 until nodeCount) {
val windowNode = structure.getWindowNodeAt(i)
val rootNode = windowNode.rootViewNode
// Check for web-specific information
val webInfo = findWebInfoInNode(rootNode)
if (webInfo != null) {
return webInfo
}
}
// If no web info found, fall back to package name
val packageName = structure.activityComponent?.packageName
if (packageName != null) {
return packageName
}
return null
}
/**
* Determines if a field is most likely an email field, username field, password field, or unknown.
*/
fun determineFieldType(fieldId: AutofillId): FieldType {
// Find the node in the structure
val node = findNodeById(fieldId) ?: return FieldType.UNKNOWN
// Check for password field first
if (isLikelyPasswordField(node)) {
return FieldType.PASSWORD
}
// Check for email-specific indicators
if (isEmailField(node)) {
return FieldType.EMAIL
}
// Check for username-specific indicators
if (isUsernameField(node)) {
return FieldType.USERNAME
}
return FieldType.UNKNOWN
}
/**
* Attempt to find the web domain or URL in the assist structure.
*/
private fun findWebInfoInNode(node: AssistStructure.ViewNode): String? {
return findWebInfoFromDomainAndScheme(node)
?: findWebInfoFromUrl(node)
?: findWebInfoFromHtmlAttributes(node)
?: findWebInfoFromHints(node)
?: findWebInfoFromChildren(node)
}
/**
* Find the web domain or URL in the assist structure.
* @param node The node to search in
* @return The web domain or URL
*/
private fun findWebInfoFromDomainAndScheme(node: AssistStructure.ViewNode): String? {
val webDomain = node.webDomain
val webScheme = node.webScheme
if (webDomain != null && webScheme != null) {
return "$webScheme://$webDomain"
}
return null
}
/**
* Find the web domain or URL in the assist structure.
* @param node The node to search in
* @return The web domain or URL
*/
private fun findWebInfoFromUrl(node: AssistStructure.ViewNode): String? {
val webUrl = node.webDomain
if (webUrl != null) {
try {
val uri = webUrl.toUri()
val host = uri.host
if (host != null) {
return host
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing web URL: $webUrl", e)
}
}
return null
}
/**
* Find the web domain or URL in the assist structure.
* @param node The node to search in
* @return The web domain or URL
*/
private fun findWebInfoFromHtmlAttributes(node: AssistStructure.ViewNode): String? {
val htmlInfo = node.htmlInfo
if (htmlInfo != null) {
val attributes = htmlInfo.attributes
if (attributes != null) {
for (i in 0 until attributes.size) {
val name = attributes.get(i)?.first
val value = attributes.get(i)?.second
if (name == "domain" || name == "host" || name == "url") {
return value
}
}
}
}
return null
}
/**
* Find the web domain or URL in the assist structure.
* @param node The node to search in
* @return The web domain or URL
*/
private fun findWebInfoFromHints(node: AssistStructure.ViewNode): String? {
val hints = node.autofillHints
if (hints != null) {
for (hint in hints) {
if (hint.contains("web", ignoreCase = true) ||
hint.contains("url", ignoreCase = true) ||
hint.contains("domain", ignoreCase = true)
) {
return hint
}
}
}
return null
}
/**
* Find the web domain or URL in the assist structure.
* @param node The node to search in
* @return The web domain or URL
*/
private fun findWebInfoFromChildren(node: AssistStructure.ViewNode): String? {
val childCount = node.childCount
for (i in 0 until childCount) {
val webInfo = findWebInfoInNode(node.getChildAt(i))
if (webInfo != null) {
return webInfo
}
}
return null
}
/**
* Parse a node.
* @param node The node to parse
*/
private fun parseNode(node: AssistStructure.ViewNode) {
val viewId = node.autofillId
// Consider any editable field as a potential field
if (viewId != null && isEditableField(node)) {
val fieldType = determineFieldType(viewId)
if (fieldType == FieldType.PASSWORD) {
foundPasswordField = true
autofillableFields.add(Pair(viewId, fieldType))
} else if (fieldType == FieldType.USERNAME || fieldType == FieldType.EMAIL) {
foundUsernameField = true
autofillableFields.add(Pair(viewId, fieldType))
} else {
// Store the last field we saw in case we need it for username detection
lastField = viewId
}
}
// Recursively parse child nodes
val childCount = node.childCount
for (i in 0 until childCount) {
parseNode(node.getChildAt(i))
}
}
/**
* Check if a node is an email field.
* @param node The node to check
* @return Whether the node is an email field
*/
private fun isEmailField(node: AssistStructure.ViewNode): Boolean {
// Check input type for email
if ((node.inputType and android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS) != 0) {
return true
}
// Check autofill hints
val hints = node.autofillHints
if (hints != null) {
for (hint in hints) {
if (hint.contains("email", ignoreCase = true)) {
return true
}
}
}
// Check HTML attributes
val htmlInfo = node.htmlInfo
if (htmlInfo != null) {
val attributes = htmlInfo.attributes
if (attributes != null) {
for (i in 0 until attributes.size) {
val name = attributes.get(i)?.first
val value = attributes.get(i)?.second
if (name == "type" && value == "email") {
return true
}
if (name == "name" && value?.contains("email", ignoreCase = true) == true) {
return true
}
}
}
}
// Check ID and hint text
val idEntry = node.idEntry
val hint = node.hint
if (idEntry?.contains("email", ignoreCase = true) == true ||
hint?.contains("email", ignoreCase = true) == true
) {
return true
}
return false
}
/**
* Check if a node is a username field.
* @param node The node to check
* @return Whether the node is a username field
*/
private fun isUsernameField(node: AssistStructure.ViewNode): Boolean {
val searchTerms = listOf("username", "user")
// Check autofill hints
val hints = node.autofillHints
if (hints != null) {
for (hint in hints) {
if (hint == View.AUTOFILL_HINT_USERNAME ||
searchTerms.any { term -> hint.contains(term, ignoreCase = true) }
) {
return true
}
}
}
// Check HTML attributes
val htmlInfo = node.htmlInfo
if (htmlInfo != null) {
val attributes = htmlInfo.attributes
if (attributes != null) {
for (i in 0 until attributes.size) {
val name = attributes.get(i)?.first
val value = attributes.get(i)?.second
if (name == "name" && value != null && searchTerms.any { term ->
value.equals(term, ignoreCase = true)
}
) {
return true
}
}
}
}
// Check if ID or hint text contains one of the search terms
val idEntry = node.idEntry
val hint = node.hint
if (searchTerms.any { term ->
idEntry?.contains(term, ignoreCase = true) == true ||
hint?.contains(term, ignoreCase = true) == true
}
) {
return true
}
return false
}
/**
* Check if a node is an editable field.
* @param node The node to check
* @return Whether the node is an editable field
*/
private fun isEditableField(node: AssistStructure.ViewNode): Boolean {
// Check if the node is editable in any way
return node.inputType > 0 ||
node.className?.contains("EditText") == true ||
node.className?.contains("Input") == true ||
node.htmlInfo?.tag?.equals("input", ignoreCase = true) == true
}
/**
* Check if a node is a password field.
* @param node The node to check
* @return Whether the node is a password field
*/
private fun isLikelyPasswordField(node: AssistStructure.ViewNode): Boolean {
// Try to determine if this is a password field
val hints = node.autofillHints
if (hints != null) {
for (hint in hints) {
if (hint == View.AUTOFILL_HINT_PASSWORD || hint.contains(
"password",
ignoreCase = true,
)
) {
return true
}
}
}
// 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
)
if (isPasswordType) {
return true
}
// Check by ID or text
val idEntry = node.idEntry
if (idEntry != null && idEntry.contains("pass", ignoreCase = true)) {
return true
}
// Check by HTML attributes
val htmlInfo = node.htmlInfo
if (htmlInfo != null) {
val attributes = htmlInfo.attributes
if (attributes != null) {
for (i in 0 until attributes.size) {
val name = attributes.get(i)?.first
val value = attributes.get(i)?.second
if (name == "type" && value == "password") {
return true
}
}
}
}
return false
}
/**
* Find a node by ID.
* @param fieldId The ID of the field to find
* @return The node
*/
private fun findNodeById(fieldId: AutofillId): AssistStructure.ViewNode? {
val nodeCount = structure.windowNodeCount
for (i in 0 until nodeCount) {
val windowNode = structure.getWindowNodeAt(i)
val rootNode = windowNode.rootViewNode
val node = findNodeByIdRecursive(rootNode, fieldId)
if (node != null) {
return node
}
}
return null
}
/**
* Find a node by ID recursively.
* @param node The node to start searching from
* @param fieldId The ID of the field to find
* @return The node
*/
private fun findNodeByIdRecursive(
node: AssistStructure.ViewNode,
fieldId: AutofillId,
): AssistStructure.ViewNode? {
if (node.autofillId == fieldId) {
return node
}
val childCount = node.childCount
for (i in 0 until childCount) {
val child = node.getChildAt(i)
val result = findNodeByIdRecursive(child, fieldId)
if (result != null) {
return result
}
}
return null
}
}

View File

@@ -0,0 +1,121 @@
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 com.caverock.androidsvg.SVG
import java.io.ByteArrayOutputStream
/**
* Utility class for image operations.
*/
object ImageUtils {
private const val TAG = "ImageUtils"
private const val TARGET_SIZE_DP = 24
private const val RENDER_SCALE_FACTOR = 4
/**
* Convert bytes to a bitmap.
* @param bytes The bytes to convert
* @return The bitmap
*/
fun bytesToBitmap(bytes: ByteArray): Bitmap? {
return try {
when (detectMimeType(bytes)) {
"image/svg+xml" -> svgToBitmap(bytes)
else -> BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}
} catch (e: Exception) {
Log.e(TAG, "Error converting bytes to bitmap", e)
null
}
}
/**
* Detect the mime type of the bytes.
* @param bytes The bytes to detect the mime type of
* @return The mime type
*/
private fun detectMimeType(bytes: ByteArray): String {
// SVG heuristic
if (bytes.size >= 5) {
val header = String(bytes, 0, 5).lowercase()
if (header.contains("<?xml") || header.contains("<svg")) {
return "image/svg+xml"
}
}
// PNG
if (bytes.size >= 4 &&
bytes[0] == 0x89.toByte() &&
bytes[1] == 0x50.toByte() &&
bytes[2] == 0x4E.toByte() &&
bytes[3] == 0x47.toByte()
) {
return "image/png"
}
// ICO
if (bytes.size >= 4 &&
bytes[0] == 0x00.toByte() &&
bytes[1] == 0x00.toByte() &&
bytes[2] == 0x01.toByte() &&
bytes[3] == 0x00.toByte()
) {
return "image/x-icon"
}
return "image/x-icon"
}
/**
* Convert SVG bytes to a bitmap.
* @param bytes The bytes to convert
* @return The bitmap
*/
private fun svgToBitmap(bytes: ByteArray): Bitmap? {
return try {
val svg = SVG.getFromString(String(bytes, Charsets.UTF_8))
// Convert dp to pixels based on screen density
val density = Resources.getSystem().displayMetrics.density
val targetSizePx = (TARGET_SIZE_DP * density).toInt()
val renderSize = targetSizePx * RENDER_SCALE_FACTOR
svg.setDocumentWidth(renderSize.toFloat())
svg.setDocumentHeight(renderSize.toFloat())
// Create bitmap & canvas at larger size
val largeBitmap = Bitmap.createBitmap(renderSize, renderSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(largeBitmap)
svg.renderToCanvas(canvas)
// Scale down to target size with better quality
Bitmap.createScaledBitmap(largeBitmap, targetSizePx, targetSizePx, true)
} catch (e: Exception) {
Log.e(TAG, "Error rendering SVG to bitmap", e)
null
}
}
/**
* Convert a bitmap to bytes.
* @param bitmap The bitmap to convert
* @return The bytes
*/
fun bitmapToBytes(bitmap: Bitmap): ByteArray {
return ByteArrayOutputStream().use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.toByteArray()
}
}
/**
* Convert a base64 string to bytes.
* @param base64 The base64 string to convert
* @return The bytes
*/
fun base64ToBytes(base64: String): ByteArray {
return Base64.decode(base64, Base64.DEFAULT)
}
}

View File

@@ -1,7 +0,0 @@
package net.aliasvault.app.credentialmanager
data class Credential(
val username: String,
val password: String,
val service: String
)

View File

@@ -1,100 +0,0 @@
package net.aliasvault.app.credentialmanager;
import android.app.Activity;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.fragment.app.FragmentActivity;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.UiThreadUtil;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.List;
class CredentialManagerModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
companion object {
private const val TAG = "CredentialManagerModule"
}
override fun getName(): String {
return "CredentialManager"
}
private fun getFragmentActivity(): FragmentActivity? {
val activity = currentActivity
return if (activity is FragmentActivity) {
activity
} else {
null
}
}
@ReactMethod
fun getCredentials(promise: Promise) {
UiThreadUtil.runOnUiThread {
try {
val activity = getFragmentActivity()
if (activity == null) {
promise.reject("ERR_ACTIVITY", "Activity is not available")
return@runOnUiThread
}
val store = SharedCredentialStore.getInstance(reactContext)
store.getAllCredentials(activity, object : SharedCredentialStore.CryptoOperationCallback {
override fun onSuccess(jsonString: String) {
try {
val jsonArray = JSONArray(jsonString)
val credentialsArray = Arguments.createArray()
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val credentialMap = Arguments.createMap()
credentialMap.putString("username", jsonObject.getString("username"))
credentialMap.putString("password", jsonObject.getString("password"))
credentialMap.putString("service", jsonObject.getString("service"))
credentialsArray.pushMap(credentialMap)
}
promise.resolve(credentialsArray)
} catch (e: Exception) {
Log.e(TAG, "Error parsing credentials", e)
promise.reject("ERR_PARSE_CREDENTIALS", "Failed to parse credentials: ${e.message}", e)
}
}
override fun onError(e: Exception) {
Log.e(TAG, "Error getting credentials", e)
promise.reject("ERR_GET_CREDENTIALS", "Failed to get credentials: ${e.message}", e)
}
})
} catch (e: Exception) {
Log.e(TAG, "Error preparing to get credentials", e)
promise.reject("ERR_GET_CREDENTIALS", "Failed to prepare getting credentials: ${e.message}", e)
}
}
}
@ReactMethod
fun clearCredentials(promise: Promise) {
try {
val store = SharedCredentialStore.getInstance(reactContext)
store.clearAllData()
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "Error clearing credentials", e)
promise.reject("ERR_CLEAR_CREDENTIALS", "Failed to clear credentials: ${e.message}", e)
}
}
}

View File

@@ -1,16 +0,0 @@
package net.aliasvault.app.credentialmanager
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class CredentialManagerPackage : ReactPackage {
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(CredentialManagerModule(reactContext))
}
}

View File

@@ -1,565 +0,0 @@
package net.aliasvault.app.credentialmanager
import android.content.Context
import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
import java.security.KeyStore
import java.security.SecureRandom
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
class SharedCredentialStore private constructor(context: Context) {
private val appContext = context.applicationContext
private val executor: Executor = Executors.newSingleThreadExecutor()
// Cache for encryption key during the lifetime of this instance
private var encryptionKey: ByteArray? = null
// Interface for operations that need callbacks
interface CryptoOperationCallback {
fun onSuccess(result: String)
fun onError(e: Exception)
}
/**
* Get or create encryption key using biometric authentication
*/
fun getEncryptionKey(activity: FragmentActivity, callback: CryptoOperationCallback) {
// If key is already in memory, use it
encryptionKey?.let {
Log.d(TAG, "Using cached encryption key")
callback.onSuccess("Key available")
return
}
// Check if we have a stored key
val prefs = appContext.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
val encryptedKeyB64 = prefs.getString(ENCRYPTED_KEY_PREF, null)
if (encryptedKeyB64 == null) {
// No key exists, create a new one
createNewEncryptionKey(activity, callback)
} else {
// Key exists, retrieve it with biometric auth
retrieveEncryptionKey(activity, encryptedKeyB64, callback)
}
}
/**
* Create a new random encryption key and protect it with biometrics
*/
private fun createNewEncryptionKey(activity: FragmentActivity, callback: CryptoOperationCallback) {
try {
// Generate a random 32-byte key for AES-256
val secureRandom = SecureRandom()
val randomKey = ByteArray(32)
secureRandom.nextBytes(randomKey)
// Cache the key
encryptionKey = randomKey
// Store the key protected by biometric authentication
storeKeyWithBiometricProtection(activity, randomKey, callback)
} catch (e: Exception) {
Log.e(TAG, "Error creating encryption key", e)
callback.onError(e)
}
}
/**
* Store the encryption key protected by biometric authentication
*/
private fun storeKeyWithBiometricProtection(
activity: FragmentActivity,
keyToStore: ByteArray,
callback: CryptoOperationCallback
) {
try {
// Set up KeyStore
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
// Create or get biometric key
if (!keyStore.containsAlias(KEYSTORE_ALIAS)) {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
val keySpec = KeyGenParameterSpec.Builder(
KEYSTORE_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.build()
keyGenerator.init(keySpec)
keyGenerator.generateKey()
}
// Get the created key
val secretKey = keyStore.getKey(KEYSTORE_ALIAS, null) as SecretKey
// Create BiometricPrompt
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Remember AliasVault password")
.setSubtitle("Protect your AliasVault decryption key with your biometrics.")
.setNegativeButtonText("Cancel")
.build()
val biometricPrompt = BiometricPrompt(activity, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
try {
// Get the cipher from the result
val cipher = result.cryptoObject?.cipher ?: throw Exception("Cipher is null")
// Encrypt the key
val encryptedKey = cipher.doFinal(keyToStore)
val iv = cipher.iv
// Combine IV and encrypted key
val byteBuffer = ByteBuffer.allocate(iv.size + encryptedKey.size)
byteBuffer.put(iv)
byteBuffer.put(encryptedKey)
val combined = byteBuffer.array()
// Store encrypted key in SharedPreferences
val encryptedKeyB64 = Base64.encodeToString(combined, Base64.DEFAULT)
val prefs = appContext.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(e)
}
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
Log.e(TAG, "Authentication error: $errString")
callback.onError(Exception("Authentication error: $errString"))
}
override fun onAuthenticationFailed() {
Log.e(TAG, "Authentication failed")
}
})
// Initialize cipher for encryption
val cipher = Cipher.getInstance(
"${KeyProperties.KEY_ALGORITHM_AES}/" +
"${KeyProperties.BLOCK_MODE_GCM}/" +
KeyProperties.ENCRYPTION_PADDING_NONE
)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
// Show biometric prompt
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
} catch (e: Exception) {
Log.e(TAG, "Error in biometric key storage", e)
callback.onError(e)
}
}
/**
* Retrieve the encryption key using biometric authentication
*/
private fun retrieveEncryptionKey(
activity: FragmentActivity,
encryptedKeyB64: String,
callback: CryptoOperationCallback
) {
try {
// Set up KeyStore
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
// Check if key exists
if (!keyStore.containsAlias(KEYSTORE_ALIAS)) {
Log.e(TAG, "Keystore key not found")
createNewEncryptionKey(activity, callback)
return
}
// Get the key
val secretKey = keyStore.getKey(KEYSTORE_ALIAS, null) as SecretKey
// Create BiometricPrompt
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock Vault")
.setSubtitle("Unlock your protected AliasVault contents")
.setNegativeButtonText("Cancel")
.build()
val biometricPrompt = BiometricPrompt(activity, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
try {
// Get the cipher from the result
val cipher = result.cryptoObject?.cipher ?: throw Exception("Cipher is null")
// Decode combined data
val combined = Base64.decode(encryptedKeyB64, Base64.DEFAULT)
// Extract IV and encrypted data
val byteBuffer = ByteBuffer.wrap(combined)
// GCM typically uses 12 bytes for IV
val iv = ByteArray(12)
byteBuffer.get(iv)
// Get remaining bytes as ciphertext
val encryptedBytes = ByteArray(byteBuffer.remaining())
byteBuffer.get(encryptedBytes)
// Decrypt the key
val decryptedKey = cipher.doFinal(encryptedBytes)
// Cache the key
encryptionKey = decryptedKey
Log.d(TAG, "Encryption key retrieved successfully")
callback.onSuccess("Key retrieved successfully")
} catch (e: Exception) {
Log.e(TAG, "Error retrieving encryption key", e)
callback.onError(e)
}
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
Log.e(TAG, "Authentication error: $errString")
callback.onError(Exception("Authentication error: $errString"))
}
override fun onAuthenticationFailed() {
Log.e(TAG, "Authentication failed")
}
})
// Initialize cipher for decryption with IV from stored encrypted key
val combined = Base64.decode(encryptedKeyB64, Base64.DEFAULT)
val byteBuffer = ByteBuffer.wrap(combined)
val iv = ByteArray(12)
byteBuffer.get(iv)
val cipher = Cipher.getInstance(
"${KeyProperties.KEY_ALGORITHM_AES}/" +
"${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)
}
}
/**
* Encrypts data using AES/GCM/NoPadding
*/
@Throws(Exception::class)
private fun encryptData(plaintext: String): String {
val key = encryptionKey ?: throw Exception("Encryption key not available")
// Create cipher
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
// Create secret key from retrieved bytes
val secretKeySpec = SecretKeySpec(key, "AES")
// Initialize cipher for encryption
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec)
// Get IV
val iv = cipher.iv
// Encrypt data
val encryptedBytes = cipher.doFinal(plaintext.toByteArray(StandardCharsets.UTF_8))
// Combine IV and encrypted data
val byteBuffer = ByteBuffer.allocate(iv.size + encryptedBytes.size)
byteBuffer.put(iv)
byteBuffer.put(encryptedBytes)
val combined = byteBuffer.array()
// Return Base64 encoded combined data
return Base64.encodeToString(combined, Base64.DEFAULT)
}
/**
* Decrypts data using AES/GCM/NoPadding
*/
@Throws(Exception::class)
private fun decryptData(encryptedData: String): String {
val key = encryptionKey ?: throw Exception("Encryption key not available")
// Decode combined data
val combined = Base64.decode(encryptedData, Base64.DEFAULT)
// Extract IV and encrypted data
val byteBuffer = ByteBuffer.wrap(combined)
// GCM typically uses 12 bytes for IV
val iv = ByteArray(12)
byteBuffer.get(iv)
// Get remaining bytes as ciphertext
val encryptedBytes = ByteArray(byteBuffer.remaining())
byteBuffer.get(encryptedBytes)
// Create cipher
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
// Create secret key from retrieved bytes
val secretKeySpec = SecretKeySpec(key, "AES")
// Create GCM parameter spec with IV
val gcmParameterSpec = GCMParameterSpec(128, iv)
// Initialize cipher for decryption
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, gcmParameterSpec)
// Decrypt data
val decryptedBytes = cipher.doFinal(encryptedBytes)
// Return decrypted string
return String(decryptedBytes, StandardCharsets.UTF_8)
}
/**
* Save a credential to SharedPreferences with encryption
*/
fun saveCredential(
activity: FragmentActivity,
credential: Credential,
callback: CryptoOperationCallback
) {
// First ensure we have the encryption key
getEncryptionKey(activity, object : CryptoOperationCallback {
override fun onSuccess(result: String) {
try {
Log.d(TAG, "Saving credential for: ${credential.service}")
// Get current credentials from SharedPreferences
val prefs = appContext.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
val encryptedCredentialsJson = prefs.getString(CREDENTIALS_KEY, null)
val credentials = if (encryptedCredentialsJson != null) {
// Decrypt and parse existing credentials
val decryptedJson = decryptData(encryptedCredentialsJson)
parseCredentialsFromJson(decryptedJson)
} else {
// No existing credentials
mutableListOf()
}
// Add new credential
credentials.add(credential)
// Convert to JSON
val jsonData = credentialsToJson(credentials)
// Encrypt
val encryptedJson = encryptData(jsonData)
// Save encrypted data
prefs.edit().putString(CREDENTIALS_KEY, encryptedJson).apply()
callback.onSuccess("Credential saved successfully")
} catch (e: Exception) {
Log.e(TAG, "Error saving credential", e)
callback.onError(e)
}
}
override fun onError(e: Exception) {
Log.e(TAG, "Failed to get encryption key", e)
callback.onError(e)
}
})
}
/**
* Get all credentials from SharedPreferences with decryption
*/
fun getAllCredentials(activity: FragmentActivity, callback: CryptoOperationCallback) {
// First check if credentials exist
val prefs = appContext.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
val encryptedCredentialsJson = prefs.getString(CREDENTIALS_KEY, null)
if (encryptedCredentialsJson == null) {
// No credentials found, return empty array without triggering biometric authentication
Log.d(TAG, "No credentials found, returning empty array without key retrieval")
callback.onSuccess(JSONArray().toString())
return
}
// Credentials exist, ensure we have the encryption key
getEncryptionKey(activity, object : CryptoOperationCallback {
override fun onSuccess(result: String) {
try {
Log.d(TAG, "Retrieving credentials from SharedPreferences")
// Decrypt credentials
val decryptedJson = decryptData(encryptedCredentialsJson)
callback.onSuccess(decryptedJson)
} catch (e: Exception) {
Log.e(TAG, "Error retrieving credentials", e)
callback.onError(e)
}
}
override fun onError(e: Exception) {
Log.e(TAG, "Failed to get encryption key", e)
callback.onError(e)
}
})
}
/**
* Attempts to get all credentials using only the cached encryption key.
* Returns false if the key isn't in memory, which signals the caller to authenticate.
*/
fun tryGetAllCredentialsWithCachedKey(callback: CryptoOperationCallback): Boolean {
// Check if the encryption key is already in memory
if (encryptionKey == null) {
Log.d(TAG, "Encryption key not in memory, authentication required")
return false
}
// Check if credentials exist
val prefs = appContext.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
val encryptedCredentialsJson = prefs.getString(CREDENTIALS_KEY, null)
if (encryptedCredentialsJson == null) {
// No credentials found, return empty array
Log.d(TAG, "No credentials found, returning empty array")
callback.onSuccess(JSONArray().toString())
return true
}
try {
Log.d(TAG, "Retrieving credentials using cached key")
// Decrypt credentials directly with cached key
val decryptedJson = decryptData(encryptedCredentialsJson)
callback.onSuccess(decryptedJson)
return true
} catch (e: Exception) {
Log.e(TAG, "Error retrieving credentials with cached key", e)
callback.onError(e)
return true // Still return true since we attempted with a cached key
}
}
/**
* Clear all credentials from SharedPreferences
*/
fun clearAllData() {
Log.d(TAG, "Clearing all credentials from SharedPreferences")
val prefs = appContext.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit()
.remove(CREDENTIALS_KEY)
.remove(ENCRYPTED_KEY_PREF)
.apply()
// Clear the cached encryption key
encryptionKey = null
// Remove the key from Android Keystore if it exists
try {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
if (keyStore.containsAlias(KEYSTORE_ALIAS)) {
keyStore.deleteEntry(KEYSTORE_ALIAS)
Log.d(TAG, "Removed encryption key from Android Keystore")
}
} catch (e: Exception) {
Log.e(TAG, "Error removing encryption key from Keystore", e)
}
}
@Throws(JSONException::class)
private fun parseCredentialsFromJson(json: String?): MutableList<Credential> {
val credentials = mutableListOf<Credential>()
if (json.isNullOrEmpty()) {
return credentials
}
val jsonArray = JSONArray(json)
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val username = jsonObject.getString("username")
val password = jsonObject.getString("password")
val service = jsonObject.getString("service")
credentials.add(Credential(username, password, service))
}
return credentials
}
@Throws(JSONException::class)
private fun credentialsToJson(credentials: List<Credential>): String {
val jsonArray = JSONArray()
for (credential in credentials) {
val jsonObject = JSONObject().apply {
put("username", credential.username)
put("password", credential.password)
put("service", credential.service)
}
jsonArray.put(jsonObject)
}
return jsonArray.toString()
}
companion object {
private const val TAG = "SharedCredentialStore"
private const val SHARED_PREFS_NAME = "net.aliasvault.credentials"
private const val CREDENTIALS_KEY = "stored_credentials"
private const val KEYSTORE_ALIAS = "net.aliasvault.encryption_key"
private const val ENCRYPTED_KEY_PREF = "encrypted_key"
@Volatile
private var instance: SharedCredentialStore? = null
@JvmStatic
fun getInstance(context: Context): SharedCredentialStore {
return instance ?: synchronized(this) {
instance ?: SharedCredentialStore(context).also { instance = it }
}
}
}
}

View File

@@ -0,0 +1,540 @@
package net.aliasvault.app.nativevaultmanager
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.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.keystoreprovider.AndroidKeystoreProvider
import net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider
import org.json.JSONArray
/**
* The native vault manager that manages the vault store and all input/output operations on it.
* This class implements the NativeVaultManagerSpec React Native interface and then calls the
* VaultStore class to perform the actual operations.
*
* @param reactContext The React context
*/
@ReactModule(name = NativeVaultManager.NAME)
class NativeVaultManager(reactContext: ReactApplicationContext) :
NativeVaultManagerSpec(reactContext), TurboModule, LifecycleEventListener {
companion object {
/**
* The name of the module.
*/
const val NAME = "NativeVaultManager"
/**
* The tag for logging.
*/
private const val TAG = "NativeVaultManager"
}
private val vaultStore = VaultStore.getInstance(
AndroidKeystoreProvider(reactContext) { getFragmentActivity() },
AndroidStorageProvider(reactContext),
)
init {
// Register for lifecycle callbacks
reactContext.addLifecycleEventListener(this)
}
/**
* Called when the app enters the background.
*/
override fun onHostPause() {
Log.d(TAG, "App entered background")
vaultStore.onAppBackgrounded()
}
/**
* Called when the app enters the foreground.
*/
override fun onHostResume() {
Log.d(TAG, "App entered foreground")
vaultStore.onAppForegrounded()
}
/**
* Called when the app is destroyed.
*/
override fun onHostDestroy() {
// Not needed
}
/**
* Get the name of the module.
* @return The name of the module
*/
override fun getName(): String {
return NAME
}
/**
* Clear the vault.
* @param promise The promise to resolve
*/
@ReactMethod
override fun clearVault(promise: Promise) {
try {
vaultStore.clearVault()
promise.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error clearing vault", e)
promise.reject("ERR_CLEAR_VAULT", "Failed to clear vault: ${e.message}", e)
}
}
/**
* Check if the vault is unlocked.
* @param promise The promise to resolve
*/
@ReactMethod
override fun isVaultUnlocked(promise: Promise) {
promise.resolve(vaultStore.isVaultUnlocked())
}
/**
* Get the vault metadata.
* @param promise The promise to resolve
*/
@ReactMethod
override fun getVaultMetadata(promise: Promise) {
try {
val metadata = vaultStore.getMetadata()
promise.resolve(metadata)
} catch (e: Exception) {
Log.e(TAG, "Error getting vault metadata", e)
promise.reject("ERR_GET_METADATA", "Failed to get vault metadata: ${e.message}", e)
}
}
/**
* Unlock the vault.
* @param promise The promise to resolve
*/
@ReactMethod
override fun unlockVault(promise: Promise) {
try {
vaultStore.unlockVault()
promise.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error storing encryption key", e)
promise.reject("ERR_STORE_KEY", "Failed to store encryption key: ${e.message}", e)
}
}
/**
* Store the encrypted database.
* @param base64EncryptedDb The encrypted database as a base64 encoded string
* @param promise The promise to resolve
*/
@ReactMethod
override fun storeDatabase(base64EncryptedDb: String, promise: Promise) {
try {
vaultStore.storeEncryptedDatabase(base64EncryptedDb)
promise.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error storing database", e)
promise.reject("ERR_STORE_DB", "Failed to store database: ${e.message}", e)
}
}
/**
* Store the metadata.
* @param metadata The metadata as a string
* @param promise The promise to resolve
*/
@ReactMethod
override fun storeMetadata(metadata: String, promise: Promise) {
try {
vaultStore.storeMetadata(metadata)
promise.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error storing metadata", e)
promise.reject("ERR_STORE_METADATA", "Failed to store metadata: ${e.message}", e)
}
}
/**
* Set the auth methods.
* @param authMethods The auth methods
* @param promise The promise to resolve
*/
@ReactMethod
override fun setAuthMethods(authMethods: ReadableArray, promise: Promise) {
try {
val jsonArray = JSONArray()
for (i in 0 until authMethods.size()) {
jsonArray.put(authMethods.getString(i))
}
vaultStore.setAuthMethods(jsonArray.toString())
promise.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error setting auth methods", e)
promise.reject("ERR_SET_AUTH_METHODS", "Failed to set auth methods: ${e.message}", e)
}
}
/**
* Store the encryption key.
* @param base64EncryptionKey The encryption key as a base64 encoded string
* @param promise The promise to resolve
*/
@ReactMethod
override fun storeEncryptionKey(base64EncryptionKey: String, promise: Promise) {
try {
vaultStore.storeEncryptionKey(base64EncryptionKey)
promise.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error storing encryption key", e)
promise.reject("ERR_STORE_KEY", "Failed to store encryption key: ${e.message}", e)
}
}
/**
* Store the encryption key derivation parameters.
* @param keyDerivationParams The encryption key derivation parameters
* @param promise The promise to resolve
*/
@ReactMethod
override fun storeEncryptionKeyDerivationParams(keyDerivationParams: String, promise: Promise) {
try {
vaultStore.storeEncryptionKeyDerivationParams(keyDerivationParams)
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,
)
}
}
/**
* Get the encryption key derivation parameters.
* @param promise The promise to resolve
*/
@ReactMethod
override fun getEncryptionKeyDerivationParams(promise: Promise) {
try {
val params = vaultStore.getEncryptionKeyDerivationParams()
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,
)
}
}
/**
* Check if the encrypted database exists.
* @param promise The promise to resolve
*/
@ReactMethod
override fun hasEncryptedDatabase(promise: Promise) {
try {
val hasDb = vaultStore.hasEncryptedDatabase()
promise.resolve(hasDb)
} catch (e: Exception) {
Log.e(TAG, "Error checking encrypted database", e)
promise.reject("ERR_CHECK_DB", "Failed to check encrypted database: ${e.message}", e)
}
}
/**
* Get the encrypted database.
* @param promise The promise to resolve
*/
@ReactMethod
override fun getEncryptedDatabase(promise: Promise) {
try {
val encryptedDb = vaultStore.getEncryptedDatabase()
promise.resolve(encryptedDb)
} catch (e: Exception) {
Log.e(TAG, "Error getting encrypted database", e)
promise.reject("ERR_GET_DB", "Failed to get encrypted database: ${e.message}", e)
}
}
/**
* Get the current vault revision number.
* @param promise The promise to resolve
*/
@ReactMethod
override fun getCurrentVaultRevisionNumber(promise: Promise) {
try {
val revision = vaultStore.getVaultRevisionNumber()
promise.resolve(revision)
} catch (e: Exception) {
Log.e(TAG, "Error getting vault revision", e)
promise.reject("ERR_GET_REVISION", "Failed to get vault revision: ${e.message}", e)
}
}
/**
* Set the current vault revision number.
* @param revisionNumber The revision number
* @param promise The promise to resolve
*/
@ReactMethod
override fun setCurrentVaultRevisionNumber(revisionNumber: Double, promise: Promise?) {
try {
vaultStore.setVaultRevisionNumber(revisionNumber.toInt())
promise?.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error setting vault revision", e)
promise?.reject("ERR_SET_REVISION", "Failed to set vault revision: ${e.message}", e)
}
}
/**
* Execute a query on the vault.
* @param query The query
* @param params The parameters to the query
* @param promise The promise to resolve
*/
@ReactMethod
override fun executeQuery(query: String, params: ReadableArray, promise: Promise) {
try {
val paramsArray = Array<Any?>(params.size()) { i ->
when (params.getType(i)) {
ReadableType.Null -> null
ReadableType.Boolean -> params.getBoolean(i)
ReadableType.Number -> params.getDouble(i)
ReadableType.String -> params.getString(i)
else -> null
}
}
val results = vaultStore.executeQuery(query, paramsArray)
val resultArray = Arguments.createArray()
for (row in results) {
val rowMap = Arguments.createMap()
for ((key, value) in row) {
when (value) {
null -> rowMap.putNull(key)
is Boolean -> rowMap.putBoolean(key, value)
is Int -> rowMap.putInt(key, value)
is Long -> rowMap.putDouble(key, value.toDouble())
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),
)
else -> rowMap.putString(key, value.toString())
}
}
resultArray.pushMap(rowMap)
}
promise.resolve(resultArray)
} catch (e: Exception) {
Log.e(TAG, "Error executing query", e)
promise.reject("ERR_EXECUTE_QUERY", "Failed to execute query: ${e.message}", e)
}
}
/**
* Execute an update on the vault.
* @param query The query
* @param params The parameters to the query
* @param promise The promise to resolve
*/
@ReactMethod
override fun executeUpdate(query: String, params: ReadableArray, promise: Promise) {
try {
val paramsArray = Array<Any?>(params.size()) { i ->
when (params.getType(i)) {
ReadableType.Null -> null
ReadableType.Boolean -> params.getBoolean(i)
ReadableType.Number -> params.getDouble(i)
ReadableType.String -> params.getString(i)
else -> null
}
}
val affectedRows = vaultStore.executeUpdate(query, paramsArray)
promise.resolve(affectedRows)
} catch (e: Exception) {
Log.e(TAG, "Error executing update", e)
promise.reject("ERR_EXECUTE_UPDATE", "Failed to execute update: ${e.message}", e)
}
}
/**
* Begin a transaction on the vault.
* @param promise The promise to resolve
*/
@ReactMethod
override fun beginTransaction(promise: Promise) {
try {
vaultStore.beginTransaction()
promise.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error beginning transaction", e)
promise.reject("ERR_BEGIN_TRANSACTION", "Failed to begin transaction: ${e.message}", e)
}
}
/**
* Commit a transaction on the vault.
* @param promise The promise to resolve
*/
@ReactMethod
override fun commitTransaction(promise: Promise) {
try {
vaultStore.commitTransaction()
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,
)
}
}
/**
* Rollback a transaction on the vault.
* @param promise The promise to resolve
*/
@ReactMethod
override fun rollbackTransaction(promise: Promise) {
try {
vaultStore.rollbackTransaction()
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,
)
}
}
/**
* Set the auto-lock timeout.
* @param timeout The timeout in seconds
* @param promise The promise to resolve
*/
@ReactMethod
override fun setAutoLockTimeout(timeout: Double, promise: Promise?) {
try {
vaultStore.setAutoLockTimeout(timeout.toInt())
promise?.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error setting auto-lock timeout", e)
promise?.reject("ERR_SET_TIMEOUT", "Failed to set auto-lock timeout: ${e.message}", e)
}
}
/**
* Get the auto-lock timeout.
* @param promise The promise to resolve
*/
@ReactMethod
override fun getAutoLockTimeout(promise: Promise) {
try {
val timeout = vaultStore.getAutoLockTimeout()
promise.resolve(timeout)
} catch (e: Exception) {
Log.e(TAG, "Error getting auto-lock timeout", e)
promise.reject("ERR_GET_TIMEOUT", "Failed to get auto-lock timeout: ${e.message}", e)
}
}
/**
* Get the auth methods.
* @param promise The promise to resolve
*/
@ReactMethod
override fun getAuthMethods(promise: Promise) {
try {
val methodsJson = vaultStore.getAuthMethods()
val jsonArray = JSONArray(methodsJson)
val methods = Arguments.createArray()
for (i in 0 until jsonArray.length()) {
methods.pushString(jsonArray.getString(i))
}
promise.resolve(methods)
} catch (e: Exception) {
Log.e(TAG, "Error getting auth methods", e)
promise.reject("ERR_GET_AUTH_METHODS", "Failed to get auth methods: ${e.message}", e)
}
}
/**
* Open the autofill settings page.
* @param promise The promise to resolve
*/
@ReactMethod
override fun openAutofillSettingsPage(promise: Promise) {
try {
// Note: we add a 2 to the packageUri on purpose because if we don't,
// when the user has configured AliasVault as the autofill service already
// this action won't open the settings anymore, making the button in the UI
// become broken and not do anything anymore. This is not good UX so instead
// we append a "2" so Android will always open the page as it does not equal
// the actual chosen option.
val packageUri = "package:${reactApplicationContext.packageName}2".toUri()
val autofillIntent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
data = packageUri
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// Try to resolve the intent first
if (autofillIntent.resolveActivity(reactApplicationContext.packageManager) != null) {
reactApplicationContext.startActivity(autofillIntent)
} else {
// Fallback to privacy settings (may contain Autofill on Samsung)
val fallbackIntent = Intent(Settings.ACTION_PRIVACY_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
reactApplicationContext.startActivity(fallbackIntent)
}
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,
)
}
}
/**
* Get the current fragment activity.
* @return The fragment activity
*/
private fun getFragmentActivity(): FragmentActivity? {
return currentActivity as? FragmentActivity
}
}

View File

@@ -0,0 +1,43 @@
package net.aliasvault.app.nativevaultmanager
import com.facebook.react.TurboReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import java.util.HashMap
/**
* The package for the NativeVaultManager module.
*/
class NativeVaultManagerPackage : TurboReactPackage() {
/**
* Get the module for the given name.
*/
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
return when (name) {
NativeVaultManager.NAME -> NativeVaultManager(reactContext)
else -> null
}
}
/**
* Get the React module info provider.
* @return The React module info provider
*/
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
return ReactModuleInfoProvider {
val moduleMap: MutableMap<String, ReactModuleInfo> = HashMap()
moduleMap[NativeVaultManager.NAME] = ReactModuleInfo(
NativeVaultManager.NAME,
NativeVaultManager::class.java.name,
false, // canOverrideExistingModule
true, // needsEagerInit
true, // hasConstants
false, // isCxxModule
true, // isTurboModule
)
moduleMap
}
}
}

View File

@@ -0,0 +1,953 @@
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.interfaces.CredentialOperationCallback
import net.aliasvault.app.vaultstore.interfaces.CryptoOperationCallback
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
/**
* The vault store that manages the encrypted vault and all input/output operations on it.
* This class is used both by React Native and by the native Android autofill service.
*
* @param storageProvider The storage provider
* @param keystoreProvider The keystore provider
*/
class VaultStore(
private val storageProvider: StorageProvider,
private val keystoreProvider: KeystoreProvider,
) {
companion object {
/**
* The tag for logging.
*/
private const val TAG = "VaultStore"
/**
* The biometrics auth method.
*/
private const val BIOMETRICS_AUTH_METHOD = "faceid"
/**
* Minimum date definition.
*/
private val MIN_DATE: Date = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply {
set(Calendar.YEAR, 1)
set(Calendar.MONTH, Calendar.JANUARY)
set(Calendar.DAY_OF_MONTH, 1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.time
/**
* The instance of the vault store.
*/
@Volatile
private var instance: VaultStore? = null
/**
* Get the instance of the vault store.
* @param keystoreProvider The keystore provider
* @param storageProvider The storage provider
* @return The instance of the vault store
*/
@JvmStatic
fun getInstance(
keystoreProvider: KeystoreProvider,
storageProvider: StorageProvider,
): VaultStore {
return instance ?: synchronized(this) {
instance ?: VaultStore(storageProvider, keystoreProvider).also { instance = it }
}
}
/**
* Get the existing instance of the vault store.
* @return The existing instance of the vault store
*/
@JvmStatic
fun getExistingInstance(): VaultStore? {
return instance
}
}
/**
* The encryption key.
*/
private var encryptionKey: ByteArray? = null
/**
* The database connection.
*/
private var dbConnection: SQLiteDatabase? = null
/**
* The auto-lock handler.
*/
private var autoLockHandler: Handler? = null
/**
* The auto-lock runnable.
*/
private var autoLockRunnable: Runnable? = null
/**
* The last unlock time.
*/
private var lastUnlockTime: Long = 0
init {
// Initialize the auto-lock handler on the main thread
autoLockHandler = Handler(Looper.getMainLooper())
}
/**
* Called when the app enters the background.
*/
fun onAppBackgrounded() {
Log.d(TAG, "App entered background, starting auto-lock timer with ${getAutoLockTimeout()}s")
if (getAutoLockTimeout() > 0) {
// Cancel any existing auto-lock timer
autoLockRunnable?.let { autoLockHandler?.removeCallbacks(it) }
// Create and schedule new auto-lock timer
autoLockRunnable = Runnable {
Log.d(TAG, "Auto-lock timer fired, clearing cache")
clearCache()
}
autoLockHandler?.postDelayed(autoLockRunnable!!, getAutoLockTimeout().toLong() * 1000)
}
}
/**
* Called when the app enters the foreground.
*/
fun onAppForegrounded() {
Log.d(TAG, "App entered foreground, canceling auto-lock timer")
// Cancel the auto-lock timer
autoLockRunnable?.let { autoLockHandler?.removeCallbacks(it) }
autoLockRunnable = null
}
/**
* Store the encryption key.
* @param base64EncryptionKey The encryption key as a base64 encoded string
*/
fun storeEncryptionKey(base64EncryptionKey: String) {
this.encryptionKey = Base64.decode(base64EncryptionKey, Base64.NO_WRAP)
// Check if biometric auth is enabled in auth methods
val authMethods = getAuthMethods()
if (authMethods.contains(BIOMETRICS_AUTH_METHOD) && keystoreProvider.isBiometricAvailable()) {
val latch = java.util.concurrent.CountDownLatch(1)
var error: Exception? = null
keystoreProvider.storeKey(
key = base64EncryptionKey,
object : KeystoreOperationCallback {
override fun onSuccess(result: String) {
Log.d(TAG, "Encryption key stored successfully with biometric protection")
latch.countDown()
}
override fun onError(e: Exception) {
Log.e(TAG, "Error storing encryption key with biometric protection", e)
error = e
latch.countDown()
}
},
)
latch.await()
error?.let { throw it }
}
}
/**
* Get the encryption key.
* @param callback The callback to call when the key is retrieved
*/
fun getEncryptionKey(callback: CryptoOperationCallback) {
// If key is already in memory, use it
encryptionKey?.let {
Log.d(TAG, "Using cached encryption key")
callback.onSuccess(Base64.encodeToString(it, Base64.NO_WRAP))
return
}
// Check if biometric auth is enabled in auth methods
val authMethods = getAuthMethods()
if (authMethods.contains(BIOMETRICS_AUTH_METHOD) && keystoreProvider.isBiometricAvailable()) {
keystoreProvider.retrieveKey(
object : KeystoreOperationCallback {
override fun onSuccess(result: String) {
try {
// Cache the key
encryptionKey = Base64.decode(result, Base64.NO_WRAP)
callback.onSuccess(result)
} catch (e: Exception) {
Log.e(TAG, "Error decoding retrieved key", e)
callback.onError(e)
}
}
override fun onError(e: Exception) {
Log.e(TAG, "Error retrieving key", e)
callback.onError(e)
}
},
)
} else {
callback.onError(Exception("No encryption key found"))
}
}
/**
* Store the encryption key derivation parameters.
* @param keyDerivationParams The encryption key derivation parameters
*/
fun storeEncryptionKeyDerivationParams(keyDerivationParams: String) {
this.storageProvider.setKeyDerivationParams(keyDerivationParams)
}
/**
* Get the encryption key derivation parameters.
* @return The encryption key derivation parameters
*/
fun getEncryptionKeyDerivationParams(): String {
return this.storageProvider.getKeyDerivationParams()
}
/**
* Store the encrypted database in the storage provider.
* @param encryptedData The encrypted database as a base64 encoded string
*/
fun storeEncryptedDatabase(encryptedData: String) {
// Write the encrypted blob to the filesystem via the supplied file provider
// which can either be the real Android file system or a mock file system for testing
storageProvider.setEncryptedDatabaseFile(encryptedData)
}
/**
* Get the encrypted database from the storage provider.
* @return The encrypted database as a base64 encoded string
*/
fun getEncryptedDatabase(): String {
val encryptedDbBase64 = storageProvider.getEncryptedDatabaseFile().readText()
return encryptedDbBase64
}
/**
* Check if the encrypted database exists in the storage provider.
* @return True if the encrypted database exists, false otherwise
*/
fun hasEncryptedDatabase(): Boolean {
return storageProvider.getEncryptedDatabaseFile().exists()
}
/**
* Store the metadata in the storage provider.
* @param metadata The metadata to store
*/
fun storeMetadata(metadata: String) {
storageProvider.setMetadata(metadata)
}
/**
* Get the metadata from the storage provider.
* @return The metadata as a string
*/
fun getMetadata(): String {
return storageProvider.getMetadata()
}
/**
* Unlock the vault. This can trigger biometric authentication.
*/
fun unlockVault() {
val encryptedDbBase64 = getEncryptedDatabase()
val decryptedDbBase64 = decryptData(encryptedDbBase64)
try {
setupDatabaseWithDecryptedData(decryptedDbBase64)
} catch (e: Exception) {
Log.e(TAG, "Error unlocking vault", e)
throw e
}
}
/**
* Execute a read-only SQL query (SELECT) on the vault.
* @param query The SQL query
* @param params The parameters to the query
* @return The results of the query
*/
@Suppress("NestedBlockDepth")
fun executeQuery(query: String, params: Array<Any?>): List<Map<String, Any?>> {
val results = mutableListOf<Map<String, Any?>>()
dbConnection?.let { db ->
// Convert any base64 strings with the special flag to blobs
val convertedParams = params.map { param ->
if (param is String && param.startsWith("av-base64-to-blob:")) {
val base64 = param.substring("av-base64-to-blob:".length)
Base64.decode(base64, Base64.NO_WRAP)
} else {
param
}
}.toTypedArray()
val cursor = db.rawQuery(query, convertedParams.map { it?.toString() }.toTypedArray())
cursor.use {
val columnNames = it.columnNames
while (it.moveToNext()) {
val row = mutableMapOf<String, Any?>()
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),
)
}
}
results.add(row)
}
}
}
return results
}
/**
* Execute an SQL update on the vault that mutates it.
* @param query The SQL query
* @param params The parameters to the query
* @return The number of affected rows
*/
fun executeUpdate(query: String, params: Array<Any?>): Int {
dbConnection?.let { db ->
// Convert any base64 strings with the special flag to blobs
val convertedParams = params.map { param ->
if (param is String && param.startsWith("av-base64-to-blob:")) {
val base64 = param.substring("av-base64-to-blob:".length)
Base64.decode(base64, Base64.NO_WRAP)
} else {
param
}
}.toTypedArray()
db.execSQL(query, convertedParams)
// Get the number of affected rows
val cursor = db.rawQuery("SELECT changes()", null)
cursor.use {
if (it.moveToFirst()) {
return it.getInt(0)
}
}
}
return 0
}
/**
* Begin a SQL transaction on the vault.
*/
fun beginTransaction() {
dbConnection?.beginTransaction()
}
/**
* Commit a SQL transaction on the vault. This also persists the new version of the encrypted vault from memory to the filesystem.
*/
fun commitTransaction() {
dbConnection?.setTransactionSuccessful()
dbConnection?.endTransaction()
// Create a temporary file in app-specific storage
val tempDbFile = File.createTempFile("temp_db", ".sqlite")
tempDbFile.deleteOnExit() // Ensure cleanup on JVM exit
try {
// Attach the temporary file as target database
dbConnection?.execSQL("ATTACH DATABASE '${tempDbFile.path}' AS target")
// Begin transaction for copying data
dbConnection?.beginTransaction()
try {
// 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,
)
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",
)
}
}
// Commit the copy transaction
dbConnection?.setTransactionSuccessful()
} finally {
dbConnection?.endTransaction()
}
// Detach the target database
dbConnection?.execSQL("DETACH DATABASE target")
// Read the temporary database file
val rawData = tempDbFile.readBytes()
// Convert to base64 and encrypt
val base64String = Base64.encodeToString(rawData, Base64.NO_WRAP)
val encryptedBase64Data = encryptData(base64String)
// Store the encrypted database
storeEncryptedDatabase(encryptedBase64Data)
} catch (e: Exception) {
Log.e(TAG, "Error exporting and encrypting database", e)
throw e
} finally {
// Securely delete the temporary file
if (tempDbFile.exists()) {
tempDbFile.setWritable(true, true) // Temporarily enable write for deletion
tempDbFile.delete()
}
}
}
/**
* Rollback a SQL transaction on the vault.
*/
fun rollbackTransaction() {
dbConnection?.endTransaction()
}
/**
* Check if the vault is unlocked.
* @return True if the vault is unlocked, false otherwise
*/
fun isVaultUnlocked(): Boolean {
if (encryptionKey == null) {
return false
}
return true
}
/**
* Set the auto-lock timeout.
* @param timeout The timeout in seconds
*/
fun setAutoLockTimeout(timeout: Int) {
storageProvider.setAutoLockTimeout(timeout)
}
/**
* Get the auto-lock timeout.
* @return The timeout in seconds
*/
fun getAutoLockTimeout(): Int {
return storageProvider.getAutoLockTimeout()
}
/**
* Set the auth methods.
* @param authMethods The auth methods
*/
fun setAuthMethods(authMethods: String) {
storageProvider.setAuthMethods(authMethods)
// If the new auth methods no longer include biometrics, clear the biometric key.
if (!authMethods.contains(BIOMETRICS_AUTH_METHOD)) {
keystoreProvider.clearKeys()
}
}
/**
* Get the auth methods.
* @return The auth methods
*/
fun getAuthMethods(): String {
return storageProvider.getAuthMethods()
}
/**
* Set the vault revision number.
* @param revisionNumber The revision number
*/
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(),
)
}
/**
* Get the vault revision number.
* @return The revision number
*/
fun getVaultRevisionNumber(): Int {
return getVaultMetadataObject()?.vaultRevisionNumber ?: 0
}
/**
* Get the vault metadata object.
* @return The vault metadata object
*/
private fun getVaultMetadataObject(): VaultMetadata? {
val metadataJson = getMetadata()
if (metadataJson.isBlank()) {
return null
}
return try {
val json = JSONObject(metadataJson)
VaultMetadata(
publicEmailDomains = json.optJSONArray("publicEmailDomains")?.let { array ->
List(array.length()) { i -> array.getString(i) }
} ?: emptyList(),
privateEmailDomains = json.optJSONArray("privateEmailDomains")?.let { array ->
List(array.length()) { i -> array.getString(i) }
} ?: emptyList(),
vaultRevisionNumber = json.optInt("vaultRevisionNumber", 0),
)
} catch (e: Exception) {
Log.e(TAG, "Error parsing vault metadata", e)
null
}
}
/**
* Clear the memory, removing the encryption key and decrypted database from memory.
*/
fun clearCache() {
Log.d(TAG, "Clearing cache - removing encryption key and decrypted database from memory")
dbConnection?.close()
encryptionKey = null
dbConnection = null
}
/**
* Clear all vault data including from persisted storage. This removes all data from the local device.
*/
fun clearVault() {
// Remove cached data from memory
clearCache()
// Remove the encryption key stored in the keystore
keystoreProvider.clearKeys()
// Remove all data from the storage provider
storageProvider.clearStorage()
}
/**
* Attempts to get all credentials using only the cached encryption key.
* Returns false if the key isn't in memory, which signals the caller to authenticate.
*/
fun tryGetAllCredentials(callback: CredentialOperationCallback): Boolean {
// Check if the encryption key is already in memory
if (encryptionKey == null) {
Log.d(TAG, "Encryption key not in memory, authentication required")
return false
}
try {
Log.d(TAG, "Unlocking vault and retrieving all credentials")
// Unlock vault if it's locked
if (!isVaultUnlocked()) {
unlockVault()
}
// Return all credentials
callback.onSuccess(getAllCredentials())
return true
} catch (e: Exception) {
Log.e(TAG, "Error retrieving credentials", e)
callback.onError(e)
return false
}
}
/**
* Decrypt data.
* @param encryptedData The encrypted data
* @return The decrypted data
*/
private fun decryptData(encryptedData: String): String {
var decryptedResult: String? = null
var error: Exception? = null
// Create a latch to wait for the callback
val latch = java.util.concurrent.CountDownLatch(1)
getEncryptionKey(object : CryptoOperationCallback {
override fun onSuccess(result: String) {
try {
val decoded = Base64.decode(encryptedData, Base64.NO_WRAP)
// Extract IV from the first 12 bytes
val iv = decoded.copyOfRange(0, 12)
val encryptedContent = decoded.copyOfRange(12, decoded.size)
// Create cipher
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val keySpec = SecretKeySpec(encryptionKey!!, "AES")
val gcmSpec = GCMParameterSpec(128, iv)
// Initialize cipher for decryption
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec)
// Decrypt
val decrypted = cipher.doFinal(encryptedContent)
decryptedResult = String(decrypted, Charsets.UTF_8)
} catch (e: Exception) {
error = e
Log.e(TAG, "Error decrypting data", e)
} finally {
latch.countDown()
}
}
override fun onError(e: Exception) {
error = e
Log.e(TAG, "Error getting encryption key", e)
latch.countDown()
}
})
// Wait for the callback to complete
latch.await()
// Throw any error that occurred or return the result
error?.let { throw it }
return decryptedResult ?: error("Decryption failed")
}
/**
* Get all credentials from the vault.
* @return The list of credentials
*/
fun getAllCredentials(): List<Credential> {
if (dbConnection == null) {
error("Database not initialized")
}
Log.d(TAG, "Executing get all credentials query..")
val query = """
WITH LatestPasswords AS (
SELECT
p.Id as password_id,
p.CredentialId,
p.Value,
p.CreatedAt,
p.UpdatedAt,
p.IsDeleted,
ROW_NUMBER() OVER (PARTITION BY p.CredentialId ORDER BY p.CreatedAt DESC) as rn
FROM Passwords p
WHERE p.IsDeleted = 0
)
SELECT
c.Id,
c.AliasId,
c.Username,
c.Notes,
c.CreatedAt,
c.UpdatedAt,
c.IsDeleted,
s.Id as service_id,
s.Name as service_name,
s.Url as service_url,
s.Logo as service_logo,
s.CreatedAt as service_created_at,
s.UpdatedAt as service_updated_at,
s.IsDeleted as service_is_deleted,
lp.password_id,
lp.Value as password_value,
lp.CreatedAt as password_created_at,
lp.UpdatedAt as password_updated_at,
lp.IsDeleted as password_is_deleted,
a.Id as alias_id,
a.Gender as alias_gender,
a.FirstName as alias_first_name,
a.LastName as alias_last_name,
a.NickName as alias_nick_name,
a.BirthDate as alias_birth_date,
a.Email as alias_email,
a.CreatedAt as alias_created_at,
a.UpdatedAt as alias_updated_at,
a.IsDeleted as alias_is_deleted
FROM Credentials c
LEFT JOIN Services s ON s.Id = c.ServiceId AND s.IsDeleted = 0
LEFT JOIN LatestPasswords lp ON lp.CredentialId = c.Id AND lp.rn = 1
LEFT JOIN Aliases a ON a.Id = c.AliasId AND a.IsDeleted = 0
WHERE c.IsDeleted = 0
ORDER BY c.CreatedAt DESC
"""
val result = mutableListOf<Credential>()
val cursor = dbConnection?.rawQuery(query, null)
cursor?.use {
while (it.moveToNext()) {
try {
val id = UUID.fromString(it.getString(0))
val isDeleted = it.getInt(6) == 1
// Service
val serviceId = UUID.fromString(it.getString(7))
val service = Service(
id = serviceId,
name = it.getString(8),
url = it.getString(9),
logo = it.getBlob(10),
createdAt = parseDateString(it.getString(11)) ?: MIN_DATE,
updatedAt = parseDateString(it.getString(12)) ?: MIN_DATE,
isDeleted = it.getInt(13) == 1,
)
// Password
var password: Password? = null
if (!it.isNull(14)) {
password = Password(
id = UUID.fromString(it.getString(14)),
credentialId = id,
value = it.getString(15),
createdAt = parseDateString(it.getString(16)) ?: MIN_DATE,
updatedAt = parseDateString(it.getString(17)) ?: MIN_DATE,
isDeleted = it.getInt(18) == 1,
)
}
// Alias
var alias: Alias? = null
if (!it.isNull(19)) {
alias = Alias(
id = UUID.fromString(it.getString(19)),
gender = it.getString(20),
firstName = it.getString(21),
lastName = it.getString(22),
nickName = it.getString(23),
birthDate = parseDateString(it.getString(24)) ?: MIN_DATE,
email = it.getString(25),
createdAt = parseDateString(it.getString(26)) ?: MIN_DATE,
updatedAt = parseDateString(it.getString(27)) ?: MIN_DATE,
isDeleted = it.getInt(28) == 1,
)
}
val credential = Credential(
id = id,
alias = alias,
service = service,
username = it.getString(2),
notes = it.getString(3),
password = password,
createdAt = parseDateString(it.getString(4)) ?: MIN_DATE,
updatedAt = parseDateString(it.getString(5)) ?: MIN_DATE,
isDeleted = isDeleted,
)
result.add(credential)
} catch (e: Exception) {
Log.e(TAG, "Error parsing credential row", e)
}
}
}
Log.d(TAG, "Found ${result.size} credentials")
return result
}
/**
* Encrypt data.
* @param data The data to encrypt
* @return The encrypted data
*/
private fun encryptData(data: String): String {
try {
// Generate random IV
val iv = ByteArray(12)
SecureRandom().nextBytes(iv)
// Create cipher
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val keySpec = SecretKeySpec(encryptionKey!!, "AES")
val gcmSpec = GCMParameterSpec(128, iv)
// Initialize cipher for encryption
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec)
// Encrypt
val encrypted = cipher.doFinal(data.toByteArray(Charsets.UTF_8))
// Combine IV and encrypted content
val result = ByteArray(iv.size + encrypted.size)
System.arraycopy(iv, 0, result, 0, iv.size)
System.arraycopy(encrypted, 0, result, iv.size, encrypted.size)
return Base64.encodeToString(result, Base64.NO_WRAP)
} catch (e: Exception) {
Log.e(TAG, "Error encrypting data", e)
throw e
}
}
/**
* Setup the database with decrypted data. This initializes the decrypted database in memory.
* @param decryptedDbBase64 The decrypted database as a base64 encoded string
*/
private fun setupDatabaseWithDecryptedData(decryptedDbBase64: String) {
var tempDbFile: File? = null
try {
// Decode the base64 data
val decryptedDbData = Base64.decode(decryptedDbBase64, Base64.NO_WRAP)
// Create a temporary file in app-specific storage
tempDbFile = File.createTempFile("temp_db", ".sqlite")
tempDbFile.deleteOnExit() // Ensure cleanup on JVM exit
tempDbFile.writeBytes(decryptedDbData)
// Close any existing connection if it exists
dbConnection?.close()
// Create an in-memory database
dbConnection = SQLiteDatabase.create(null)
// Begin transaction
dbConnection?.beginTransaction()
try {
// Attach the temporary database
val attachQuery = "ATTACH DATABASE '${tempDbFile.path}' AS source"
dbConnection?.execSQL(attachQuery)
// 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,
)
if (verifyCursor == null) {
throw SQLiteException("Failed to attach source database")
}
verifyCursor.use {
if (!it.moveToFirst()) {
throw SQLiteException("No tables found in source database")
}
do {
val tableName = it.getString(0)
// Create table and copy data using rawQuery
dbConnection?.execSQL(
"CREATE TABLE $tableName AS SELECT * FROM source.$tableName",
)
} while (it.moveToNext())
}
// Commit transaction
dbConnection?.setTransactionSuccessful()
} finally {
dbConnection?.endTransaction()
}
// Detach the source database
dbConnection?.rawQuery("DETACH DATABASE source", null)
// Set database pragmas using rawQuery
dbConnection?.rawQuery("PRAGMA journal_mode = WAL", null)
dbConnection?.rawQuery("PRAGMA synchronous = NORMAL", null)
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
} finally {
// Securely delete the temporary file
tempDbFile?.let {
if (it.exists()) {
it.setWritable(true, true) // Temporarily enable write for deletion
it.delete()
}
}
}
}
/**
* Parse a date string from the database into a Date object.
*
* @param dateString The date string to parse
* @return The parsed Date object or null if the date string is null or cannot be parsed
*/
private fun parseDateString(dateString: String?): Date? {
if (dateString == null) {
return null
}
val formats = listOf(
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
},
SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
},
)
for (format in formats) {
try {
return format.parse(dateString)
} catch (e: Exception) {
// Log the parsing error for this format
Log.d(TAG, "Failed to parse date '$dateString' with format '${format.toPattern()}': ${e.message}")
continue
}
}
Log.e(TAG, "Error parsing date: $dateString")
return null
}
}

View File

@@ -0,0 +1,20 @@
package net.aliasvault.app.vaultstore.interfaces
import net.aliasvault.app.vaultstore.models.Credential
/**
* Interface for operations that need callbacks for credentials.
*/
interface CredentialOperationCallback {
/**
* Called when the operation is successful.
* @param result The result of the operation
*/
fun onSuccess(result: List<Credential>)
/**
* Called when the operation fails.
* @param e The exception that occurred
*/
fun onError(e: Exception)
}

View File

@@ -0,0 +1,18 @@
package net.aliasvault.app.vaultstore.interfaces
/**
* Interface for operations that need callbacks.
*/
interface CryptoOperationCallback {
/**
* Called when the operation is successful.
* @param result The result of the operation
*/
fun onSuccess(result: String)
/**
* Called when the operation fails.
* @param e The exception that occurred
*/
fun onError(e: Exception)
}

View File

@@ -0,0 +1,338 @@
package net.aliasvault.app.vaultstore.keystoreprovider
import android.app.Activity
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import java.io.File
import java.nio.ByteBuffer
import java.security.KeyStore
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Android implementation of the keystore provider that uses Android's Keystore and Biometric APIs.
*/
class AndroidKeystoreProvider(
private val context: Context,
private val getCurrentActivity: () -> Activity?,
) : KeystoreProvider {
companion object {
/**
* The tag for logging.
*/
private const val TAG = "AndroidKeystoreProvider"
/**
* The alias for the keystore.
*/
private const val KEYSTORE_ALIAS = "alias_vault_key"
/**
* The filename for the encrypted key.
*/
private const val ENCRYPTED_KEY_FILE = "encrypted_vault_key"
}
/**
* The biometric manager.
*/
private val _biometricManager = BiometricManager.from(context)
/**
* The executor.
*/
private val _executor: Executor = Executors.newSingleThreadExecutor()
/**
* The main handler.
*/
private val _mainHandler = Handler(Looper.getMainLooper())
/**
* Whether the biometric is available.
* @return Whether the biometric is available
*/
override fun isBiometricAvailable(): Boolean {
return _biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL,
) == BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Store the key in the keystore.
* @param key The key to store
* @param callback The callback to call when the operation is complete
*/
override fun storeKey(key: String, callback: KeystoreOperationCallback) {
_mainHandler.post {
try {
val currentActivity = getCurrentActivity()
if (currentActivity == null || !(currentActivity is FragmentActivity)) {
callback.onError(
Exception("No activity available for biometric authentication"),
)
return@post
}
// Set up KeyStore
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
// Create or get biometric key
if (!keyStore.containsAlias(KEYSTORE_ALIAS)) {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore",
)
val keySpec = KeyGenParameterSpec.Builder(
KEYSTORE_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(false)
.build()
keyGenerator.init(keySpec)
keyGenerator.generateKey()
}
// Get the created key
val secretKey = keyStore.getKey(KEYSTORE_ALIAS, null) as SecretKey
// 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.",
)
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL,
)
.build()
val biometricPrompt = BiometricPrompt(
currentActivity,
_executor,
object : BiometricPrompt.AuthenticationCallback() {
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,
)
// Initialize cipher with the secret key
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
// Encrypt the key
val encryptedKey = cipher.doFinal(key.toByteArray())
val iv = cipher.iv
// Combine IV and encrypted key
val byteBuffer = ByteBuffer.allocate(iv.size + encryptedKey.size)
byteBuffer.put(iv)
byteBuffer.put(encryptedKey)
val combined = byteBuffer.array()
// Store encrypted key in private file
val encryptedKeyB64 = Base64.encodeToString(
combined,
Base64.NO_WRAP,
)
val keyFile = File(context.filesDir, ENCRYPTED_KEY_FILE)
keyFile.writeText(encryptedKeyB64)
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}"),
)
}
}
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence,
) {
Log.e(TAG, "Authentication error: $errorCode - $errString")
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}"))
}
}
}
override fun retrieveKey(callback: KeystoreOperationCallback) {
_mainHandler.post {
try {
val currentActivity = getCurrentActivity()
if (currentActivity == null || !(currentActivity is FragmentActivity)) {
callback.onError(
Exception("No activity available for biometric authentication"),
)
return@post
}
// Check if we have a stored key
val keyFile = File(context.filesDir, ENCRYPTED_KEY_FILE)
if (!keyFile.exists()) {
callback.onError(Exception("No encryption key found"))
return@post
}
val encryptedKeyB64 = keyFile.readText()
// Set up KeyStore
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
// Check if key exists
if (!keyStore.containsAlias(KEYSTORE_ALIAS)) {
Log.e(TAG, "Keystore key not found")
callback.onError(Exception("Keystore key not found"))
return@post
}
// Get the key
val secretKey = keyStore.getKey(KEYSTORE_ALIAS, null) as SecretKey
// Create BiometricPrompt
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock Vault")
.setSubtitle("Authenticate to access your vault")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL,
)
.build()
val biometricPrompt = BiometricPrompt(
currentActivity,
_executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult,
) {
try {
// Get the cipher from the result
val cipher = result.cryptoObject?.cipher ?: error("Cipher is null")
// Decode combined data
val combined = Base64.decode(encryptedKeyB64, Base64.NO_WRAP)
// Extract IV and encrypted data
val byteBuffer = ByteBuffer.wrap(combined)
// GCM typically uses 12 bytes for IV
val iv = ByteArray(12)
byteBuffer.get(iv)
// Get remaining bytes as ciphertext
val encryptedBytes = ByteArray(byteBuffer.remaining())
byteBuffer.get(encryptedBytes)
// Decrypt the key
val decryptedKey = cipher.doFinal(encryptedBytes)
Log.d(TAG, "Encryption key retrieved successfully")
callback.onSuccess(String(decryptedKey))
} catch (e: Exception) {
Log.e(TAG, "Error retrieving encryption key", e)
callback.onError(e)
}
}
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence,
) {
Log.e(TAG, "Authentication error: $errString")
callback.onError(Exception("Authentication error: $errString"))
}
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)
val byteBuffer = ByteBuffer.wrap(combined)
val iv = ByteArray(12)
byteBuffer.get(iv)
val cipher = Cipher.getInstance(
"${KeyProperties.KEY_ALGORITHM_AES}/" +
"${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)
}
}
}
override fun clearKeys() {
try {
// Clear from private file storage
val keyFile = File(context.filesDir, ENCRYPTED_KEY_FILE)
if (keyFile.exists()) {
keyFile.delete()
Log.d(TAG, "Removed encryption key from private storage")
}
// Remove from Android Keystore
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
if (keyStore.containsAlias(KEYSTORE_ALIAS)) {
keyStore.deleteEntry(KEYSTORE_ALIAS)
Log.d(TAG, "Removed encryption key from Android Keystore")
}
} catch (e: Exception) {
Log.e(TAG, "Error clearing keys", e)
}
}
}

View File

@@ -0,0 +1,50 @@
package net.aliasvault.app.vaultstore.keystoreprovider
/**
* Interface for keystore providers that handle secure storage of encryption keys with biometric protection.
* This allows for different implementations for real devices and testing.
*/
interface KeystoreProvider {
/**
* Check if biometric authentication is available on the device.
* @return true if biometric authentication is available, false otherwise
*/
fun isBiometricAvailable(): Boolean
/**
* Store an encryption key with biometric protection.
* @param activity The activity to show the biometric prompt on
* @param key The encryption key to store
* @param callback The callback to handle the result
*/
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)
/**
* Clear all stored keys.
*/
fun clearKeys()
}
/**
* Callback interface for keystore operations.
*/
interface KeystoreOperationCallback {
/**
* Called when the operation is successful.
* @param result The result of the operation
*/
fun onSuccess(result: String)
/**
* Called when the operation fails.
* @param e The exception that occurred
*/
fun onError(e: Exception)
}

View File

@@ -0,0 +1,25 @@
package net.aliasvault.app.vaultstore.keystoreprovider
/**
* 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.
*/
class TestKeystoreProvider : KeystoreProvider {
override fun isBiometricAvailable(): Boolean {
return false
}
override fun storeKey(key: String, callback: KeystoreOperationCallback) {
// Do nothing in test implementation
callback.onSuccess("Key stored successfully (test)")
}
override fun retrieveKey(callback: KeystoreOperationCallback) {
// Do nothing in test implementation
callback.onError(Exception("No key found (test)"))
}
override fun clearKeys() {
// Do nothing in test implementation
}
}

View File

@@ -0,0 +1,246 @@
package net.aliasvault.app.vaultstore.models
import java.util.Date
import java.util.UUID
/**
* Credential object.
*/
data class Credential(
/**
* The ID of the credential.
*/
val id: UUID,
/**
* The alias of the credential.
*/
val alias: Alias?,
/**
* The service of the credential.
*/
val service: Service,
/**
* The username of the credential.
*/
val username: String?,
/**
* The notes of the credential.
*/
val notes: String?,
/**
* The password of the credential.
*/
val password: Password?,
/**
* The creation date of the credential.
*/
val createdAt: Date,
/**
* The update date of the credential.
*/
val updatedAt: Date,
/**
* Whether the credential is deleted.
*/
val isDeleted: Boolean,
)
/**
* Service object.
*/
data class Service(
/**
* The ID of the service.
*/
val id: UUID,
/**
* The name of the service.
*/
val name: String?,
/**
* The URL of the service.
*/
val url: String?,
/**
* The logo of the service.
*/
val logo: ByteArray?,
/**
* The creation date of the service.
*/
val createdAt: Date,
/**
* The update date of the service.
*/
val updatedAt: Date,
/**
* Whether the service is deleted.
*/
val isDeleted: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Service
if (id != other.id) return false
if (name != other.name) return false
if (url != other.url) return false
if (!logo.contentEquals(other.logo)) return false
if (createdAt != other.createdAt) return false
if (updatedAt != other.updatedAt) return false
if (isDeleted != other.isDeleted) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (name?.hashCode() ?: 0)
result = 31 * result + (url?.hashCode() ?: 0)
result = 31 * result + (logo?.contentHashCode() ?: 0)
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()
result = 31 * result + isDeleted.hashCode()
return result
}
}
/**
* Password object.
*/
data class Password(
/**
* The ID of the password.
*/
val id: UUID,
/**
* The credential ID of the password.
*/
val credentialId: UUID,
/**
* The value of the password.
*/
val value: String,
/**
* The creation date of the password.
*/
val createdAt: Date,
/**
* The update date of the password.
*/
val updatedAt: Date,
/**
* Whether the password is deleted.
*/
val isDeleted: Boolean,
)
/**
* Alias object.
*/
data class Alias(
/**
* The ID of the alias.
*/
val id: UUID,
/**
* The gender of the alias.
*/
val gender: String?,
/**
* The first name of the alias.
*/
val firstName: String?,
/**
* The last name of the alias.
*/
val lastName: String?,
/**
* The nick name of the alias.
*/
val nickName: String?,
/**
* The birth date of the alias.
*/
val birthDate: Date,
/**
* The email of the alias.
*/
val email: String?,
/**
* The creation date of the alias.
*/
val createdAt: Date,
/**
* The update date of the alias.
*/
val updatedAt: Date,
/**
* Whether the alias is deleted.
*/
val isDeleted: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Alias
if (id != other.id) return false
if (gender != other.gender) return false
if (firstName != other.firstName) return false
if (lastName != other.lastName) return false
if (nickName != other.nickName) return false
if (birthDate != other.birthDate) return false
if (email != other.email) return false
if (createdAt != other.createdAt) return false
if (updatedAt != other.updatedAt) return false
if (isDeleted != other.isDeleted) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (gender?.hashCode() ?: 0)
result = 31 * result + (firstName?.hashCode() ?: 0)
result = 31 * result + (lastName?.hashCode() ?: 0)
result = 31 * result + (nickName?.hashCode() ?: 0)
result = 31 * result + birthDate.hashCode()
result = 31 * result + (email?.hashCode() ?: 0)
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()
result = 31 * result + isDeleted.hashCode()
return result
}
}

View File

@@ -0,0 +1,21 @@
package net.aliasvault.app.vaultstore.models
/**
* Vault metadata object.
*/
data class VaultMetadata(
/**
* The public email domains of the vault.
*/
val publicEmailDomains: List<String> = emptyList(),
/**
* The private email domains of the vault.
*/
val privateEmailDomains: List<String> = emptyList(),
/**
* The revision number of the vault.
*/
val vaultRevisionNumber: Int = 0,
)

View File

@@ -0,0 +1,81 @@
package net.aliasvault.app.vaultstore.storageprovider
import android.content.Context
import androidx.core.content.edit
import java.io.File
/**
* A file provider that returns the encrypted database file from the Android filesystem.
*/
class AndroidStorageProvider(private val context: Context) : StorageProvider {
private var defaultAutoLockTimeout = 3600 // 1 hour default
override fun getEncryptedDatabaseFile(): File {
return File(context.filesDir, "encrypted_database.db")
}
override fun setEncryptedDatabaseFile(encryptedData: String) {
val file = File(context.filesDir, "encrypted_database.db")
file.writeText(encryptedData)
}
override fun setMetadata(metadata: String) {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
sharedPreferences.edit {
putString("metadata", metadata)
}
}
override fun getMetadata(): String {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
return sharedPreferences.getString("metadata", "") ?: ""
}
override fun setKeyDerivationParams(keyDerivationParams: String) {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
sharedPreferences.edit {
putString("key_derivation_params", keyDerivationParams)
}
}
override fun getKeyDerivationParams(): String {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
return sharedPreferences.getString("key_derivation_params", "") ?: ""
}
override fun setAuthMethods(authMethods: String) {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
sharedPreferences.edit {
putString("auth_methods", authMethods)
}
}
override fun getAuthMethods(): String {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
return sharedPreferences.getString("auth_methods", "[]") ?: "[]"
}
override fun setAutoLockTimeout(timeout: Int) {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putInt("auto_lock_timeout", timeout)
editor.apply()
}
override fun getAutoLockTimeout(): Int {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
return sharedPreferences.getInt("auto_lock_timeout", defaultAutoLockTimeout)
}
override fun clearStorage() {
// Clear all shared preferences
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
sharedPreferences.edit { clear() }
// Clear encrypted database file
val encryptedDatabaseFile = File(context.filesDir, "encrypted_database.db")
if (encryptedDatabaseFile.exists()) {
encryptedDatabaseFile.delete()
}
}
}

View File

@@ -0,0 +1,74 @@
package net.aliasvault.app.vaultstore.storageprovider
import java.io.File
/**
* Interface for storage providers that can store and retrieve data.
* This allows for different implementations for real devices and testing.
*/
interface StorageProvider {
/**
* Get the encrypted database file.
* @return The encrypted database file
*/
fun getEncryptedDatabaseFile(): File
/**
* Set the encrypted database file.
* @param encryptedData The encrypted database data as a base64 encoded string
*/
fun setEncryptedDatabaseFile(encryptedData: String)
/**
* Get the key derivation parameters.
* @return The key derivation parameters as a string
*/
fun getKeyDerivationParams(): String
/**
* Set the key derivation parameters.
* @param keyDerivationParams The key derivation parameters as a string
*/
fun setKeyDerivationParams(keyDerivationParams: String)
/**
* Get the metadata.
* @return The metadata as a string
*/
fun getMetadata(): String
/**
* Set the metadata.
* @param metadata The metadata as a string
*/
fun setMetadata(metadata: String)
/**
* Get the auto-lock timeout.
* @return The auto-lock timeout in seconds
*/
fun getAutoLockTimeout(): Int
/**
* Set the auto-lock timeout.
* @param timeout The auto-lock timeout in seconds
*/
fun setAutoLockTimeout(timeout: Int)
/**
* Get the authentication methods.
* @return The authentication methods as a string
*/
fun getAuthMethods(): String
/**
* Set the authentication methods.
* @param authMethods The authentication methods as a string
*/
fun setAuthMethods(authMethods: String)
/**
* Clear all data from the storage provider.
*/
fun clearStorage()
}

View File

@@ -0,0 +1,62 @@
package net.aliasvault.app.vaultstore.storageprovider
import java.io.File
/**
* A fake file provider that mocks the storage of the encrypted database file and metadata.
*/
class TestStorageProvider : StorageProvider {
private var defaultAutoLockTimeout = 3600 // 1 hour default
private val tempFile = File.createTempFile("encrypted_database", ".db")
private var tempMetadata = String()
private var tempKeyDerivationParams = String()
private var tempAuthMethods = "[]"
private var tempAutoLockTimeout = defaultAutoLockTimeout
override fun getEncryptedDatabaseFile(): File = tempFile
override fun setEncryptedDatabaseFile(encryptedData: String) {
tempFile.writeText(encryptedData)
}
override fun setMetadata(metadata: String) {
tempMetadata = metadata
}
override fun getMetadata(): String {
return tempMetadata
}
override fun setKeyDerivationParams(keyDerivationParams: String) {
tempKeyDerivationParams = keyDerivationParams
}
override fun getKeyDerivationParams(): String {
return tempKeyDerivationParams
}
override fun setAuthMethods(authMethods: String) {
tempAuthMethods = authMethods
}
override fun getAuthMethods(): String {
return tempAuthMethods
}
override fun setAutoLockTimeout(timeout: Int) {
defaultAutoLockTimeout = timeout
}
override fun getAutoLockTimeout(): Int {
return defaultAutoLockTimeout
}
override fun clearStorage() {
tempFile.delete()
tempMetadata = ""
tempKeyDerivationParams = ""
tempAuthMethods = "[]"
tempAutoLockTimeout = defaultAutoLockTimeout
}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,10 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M0,0h108v108h-108z" />
</vector>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="4dp" />
<solid android:color="#FFFFFF" />
<stroke
android:width="1dp"
android:color="#E0E0E0" />
</shape>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary" />
</LinearLayout>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:scaleType="fitCenter"
android:background="@drawable/rounded_corner_background" />
<TextView
android:id="@+id/text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary" />
</LinearLayout>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_launcher_foreground"
android:contentDescription="@string/aliasvault_icon" />
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary" />
</LinearLayout>

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -1 +1,3 @@
<resources/>
<resources>
<color name="splashscreen_background">#000000</color>
</resources>

View File

@@ -0,0 +1,19 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.EdgeToEdge">
<item name="android:textColor">@android:color/white</item>
<item name="android:editTextStyle">@style/ResetEditText</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#000000</item>
<item name="android:navigationBarColor">#000000</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
</style>
<style name="ResetEditText" parent="@android:style/Widget.EditText">
<item name="android:padding">0dp</item>
<item name="android:textColorHint">#9BA1A6</item>
<item name="android:textColor">@android:color/white</item>
</style>
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
</style>
</resources>

View File

@@ -1,6 +1,4 @@
<resources>
<color name="splashscreen_background">#ffffff</color>
<color name="iconBackground">#ffffff</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#ffffff</color>
</resources>

View File

@@ -4,4 +4,5 @@
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="autofill_service_description" translatable="true">AliasVault AutoFill</string>
</resources>
<string name="aliasvault_icon">AliasVault icon</string>
</resources>

View File

@@ -1,9 +1,11 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<style name="AppTheme" parent="Theme.EdgeToEdge.Light">
<item name="android:textColor">@android:color/black</item>
<item name="android:editTextStyle">@style/ResetEditText</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#ffffff</item>
<item name="android:statusBarColor">#fff</item>
<item name="android:navigationBarColor">#fff</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
</style>
<style name="ResetEditText" parent="@android:style/Widget.EditText">
<item name="android:padding">0dp</item>
@@ -12,6 +14,7 @@
</style>
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
</style>
</resources>
</resources>

View File

@@ -1,3 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android"
android:supportsInlineSuggestions="true"
android:settingsActivity="net.aliasvault.app.MainActivity"
android:description="@string/autofill_service_description"/>

View File

@@ -0,0 +1,156 @@
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 java.util.Date
import java.util.UUID
import kotlin.test.DefaultAsserter.assertEquals
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class AutofillTest {
private lateinit var testCredentials: List<Credential>
@Before
fun setup() {
// Create test credentials
testCredentials = listOf(
createTestCredential(
"Gmail",
"https://gmail.com",
"user@gmail.com",
),
createTestCredential(
"Google",
"https://google.com",
"user@gmail.com",
),
createTestCredential(
"Coolblue",
"https://www.coolblue.nl",
"user@coolblue.nl",
),
createTestCredential(
"Amazon",
"https://amazon.com",
"user@amazon.com",
),
createTestCredential(
"Coolblue App",
"com.coolblue.app",
"user@coolblue.nl",
),
)
}
@Test
fun testExactUrlMatch() {
val matches = CredentialMatcher.filterCredentialsByAppInfo(
testCredentials,
"www.coolblue.nl",
)
assertEquals(1, matches.size)
assertEquals("Coolblue", matches[0].service.name)
}
@Test
fun testBaseUrlMatch() {
val matches = CredentialMatcher.filterCredentialsByAppInfo(
testCredentials,
"https://gmail.com/signin",
)
assertEquals(1, matches.size)
assertEquals("Gmail", matches[0].service.name)
}
@Test
fun testRootDomainMatch() {
val matches = CredentialMatcher.filterCredentialsByAppInfo(
testCredentials,
"https://mail.google.com",
)
assertEquals(1, matches.size)
assertEquals("Google", matches[0].service.name)
}
@Test
fun testDomainNamePartMatch() {
val matches = CredentialMatcher.filterCredentialsByAppInfo(
testCredentials,
"https://coolblue.be",
)
assertEquals(2, matches.size)
assertTrue(matches.any { it.service.name == "Coolblue" })
assertTrue(matches.any { it.service.name == "Coolblue App" })
}
@Test
fun testPackageNameMatch() {
val matches = CredentialMatcher.filterCredentialsByAppInfo(
testCredentials,
"com.coolblue.app",
)
assertEquals(2, matches.size)
assertTrue(matches.any { it.service.name == "Coolblue" })
assertTrue(matches.any { it.service.name == "Coolblue App" })
}
@Test
fun testNoMatches() {
val matches = CredentialMatcher.filterCredentialsByAppInfo(
testCredentials,
"https://nonexistent.com",
)
assertTrue(matches.isEmpty())
}
@Test
fun testInvalidUrl() {
val matches = CredentialMatcher.filterCredentialsByAppInfo(
testCredentials,
"not a url",
)
assertTrue(matches.isEmpty())
}
private fun createTestCredential(
serviceName: String,
serviceUrl: String,
username: String,
): Credential {
return Credential(
id = UUID.randomUUID(),
service = Service(
id = UUID.randomUUID(),
name = serviceName,
url = serviceUrl,
logo = null,
createdAt = Date(),
updatedAt = Date(),
isDeleted = false,
),
username = username,
password = null,
alias = null,
notes = null,
createdAt = Date(),
updatedAt = Date(),
isDeleted = false,
)
}
}

View File

@@ -0,0 +1,145 @@
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 kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class VaultStoreTest {
private lateinit var vaultStore: VaultStore
private val testEncryptionKeyBase64 = "/9So3C83JLDIfjsF0VQOc4rz1uAFtIseW7yrUuztAD0=" // 32 bytes for AES-256
@Before
fun setup() {
// Store test data
val encryptedDb = loadTestDatabase()
// Initialize the VaultStore instance with a mock file provider that
// is only used for testing purposes
vaultStore = VaultStore(TestStorageProvider(), TestKeystoreProvider())
vaultStore.storeEncryptionKey(testEncryptionKeyBase64)
vaultStore.storeEncryptedDatabase(encryptedDb)
val metadata = """
{
"publicEmailDomains": ["spamok.com", "spamok.nl"],
"privateEmailDomains": ["aliasvault.net", "main.aliasvault.net"],
"vaultRevisionNumber": 1
}
"""
vaultStore.storeMetadata(metadata)
vaultStore.unlockVault()
}
@Test
fun testDatabaseInitialization() {
assertTrue(vaultStore.isVaultUnlocked())
}
@Test
fun testGetAllCredentials() {
val credentials = vaultStore.getAllCredentials()
// Verify we got some credentials back
assertFalse(credentials.isEmpty(), "Should have retrieved some credentials")
// Verify the structure of the first credential
if (credentials.isNotEmpty()) {
val firstCredential = credentials.first()
assertNotNull(firstCredential.id, "Credential should have an ID")
assertNotNull(firstCredential.service, "Credential should have a service")
assertNotNull(firstCredential.password, "Credential should have a password")
assertNotNull(firstCredential.username, "Credential should have a username")
assertNotNull(firstCredential.createdAt, "Credential should have a creation date")
assertNotNull(firstCredential.updatedAt, "Credential should have an update date")
}
}
@Test
fun testGetGmailCredentialDetails() {
// Get all credentials
val credentials = vaultStore.getAllCredentials()
// Find the Gmail credential
val gmailCredential = credentials.find { it.service.name == "Gmail Test Account" }
assertNotNull(gmailCredential, "Gmail Test Account credential should exist")
// Verify all expected properties
assertEquals("Gmail Test Account", gmailCredential.service.name)
assertEquals("https://google.com", gmailCredential.service.url)
assertEquals("test.user@gmail.com", gmailCredential.username)
assertEquals("Test", gmailCredential.alias?.firstName)
assertEquals("User", gmailCredential.alias?.lastName)
assertEquals("Test Gmail account for unit testing", gmailCredential.notes)
// Verify logo exists and has sufficient size
val logo = gmailCredential.service.logo
assertNotNull(logo, "Service logo should not be nil")
assertTrue(logo.size > 1024, "Logo data should exceed 1KB in size")
}
@Test
fun testDatabaseWriteOperation() {
// Create a test setting
val testKey = "test_setting_key"
val testValue = "test_setting_value"
// Begin transaction
vaultStore.beginTransaction()
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),
)
assertTrue(insertResult > 0, "Setting insertion should succeed")
// Verify the setting was inserted by querying it
val querySql = "SELECT Value FROM Settings WHERE Key = ?"
val results = vaultStore.executeQuery(querySql, arrayOf(testKey))
assertTrue(results.isNotEmpty(), "Should get a result (amount of updated rows)")
// If everything succeeded, commit the transaction
vaultStore.commitTransaction()
// Then, try to re-load the database and ensure the __EFMigrationsHistory table still exists.
// This asserts that the database commit results in a properly exported and encrypted database file.
vaultStore.clearCache()
vaultStore.storeEncryptionKey(testEncryptionKeyBase64)
vaultStore.unlockVault()
// Do a query
val querySql2 = "SELECT MigrationId FROM __EFMigrationsHistory"
val results2 = vaultStore.executeQuery(querySql2, arrayOf<Any?>())
assertTrue(
results2.isNotEmpty(),
"Should get a result (migration history table contents)",
)
} catch (e: Exception) {
// If anything fails, rollback the transaction
throw e
}
}
private fun loadTestDatabase(): String {
// Load the test database file from resources
val inputStream = javaClass.classLoader?.getResourceAsStream("test-encrypted-vault.txt")
?: throw IllegalStateException("Test database file not found")
return inputStream.bufferedReader().use { it.readText() }
}
}

View File

File diff suppressed because one or more lines are too long

View File

@@ -3,14 +3,16 @@
buildscript {
ext {
buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0'
minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '26')
minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '30')
compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35')
targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
detektVersion = '1.23.5'
ndkVersion = "26.1.10909125"
}
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
@@ -18,10 +20,27 @@ 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')
classpath("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:$detektVersion")
}
}
apply plugin: "com.facebook.react.rootproject"
apply plugin: "org.jlleitschuh.gradle.ktlint"
apply plugin: "io.gitlab.arturbosch.detekt"
detekt {
buildUponDefaultConfig = true
config = files("$projectDir/detekt.yml")
baseline = file("$projectDir/baseline.xml")
}
tasks.register('lintCheck') {
dependsOn 'ktlintCheck'
dependsOn 'detekt'
group = 'verification'
description = 'Run all linting checks'
}
allprojects {
repositories {

View File

@@ -0,0 +1,60 @@
build:
maxIssues: 0
weights:
complexity: 2
formatting: 1
LongParameterList: 1
comments: 1
complexity:
active: true
LongParameterList:
active: true
functionThreshold: 6
constructorThreshold: 6
TooManyFunctions:
active: true
thresholdInFiles: 40
thresholdInClasses: 40
thresholdInInterfaces: 40
thresholdInObjects: 40
CyclomaticComplexMethod:
active: true
threshold: 26
NestedBlockDepth:
active: true
threshold: 5
LongMethod:
active: true
threshold: 120
ComplexCondition:
active: true
threshold: 6
comments:
UndocumentedPublicClass:
active: true
UndocumentedPublicFunction:
active: true
UndocumentedPublicProperty:
active: true
EndOfSentenceFormat:
active: true
endOfSentenceFormat: ([.?!][ \t\n\r\f<])|([.?!:]$)
style:
MaxLineLength:
active: true
maxLineLength: 160
ReturnCount:
active: true
max: 6
MagicNumber:
active: false
ThrowsCount:
active: true
max: 3
exceptions:
TooGenericExceptionCaught:
active: false

View File

@@ -2,17 +2,22 @@
"expo": {
"name": "AliasVault",
"slug": "AliasVault",
"version": "0.17.0",
"version": "0.18.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "net.aliasvault.app",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"deepLinking": true,
"platforms": ["ios", "android", "web"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "net.aliasvault.app"
"bundleIdentifier": "net.aliasvault.app",
"splash": {
"image": "./assets/images/adaptive-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
},
"android": {
"adaptiveIcon": {
@@ -24,20 +29,29 @@
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
"favicon": "./assets/images/icon.png"
},
"plugins": [
"expo-router",
"expo-sqlite",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"image": "./assets/images/adaptive-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
"expo-sqlite"
[
"react-native-edge-to-edge",
{
"android": {
"parentTheme": "Default",
"enforceNavigationBarContrast": false
}
}
]
],
"experiments": {
"typedRoutes": true

View File

@@ -3,6 +3,7 @@ import React, { useEffect } from 'react';
import { Platform, StyleSheet, View } from 'react-native';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { IconSymbolName } from '@/components/ui/IconSymbolName';
import TabBarBackground from '@/components/ui/TabBarBackground';
import { useColors } from '@/hooks/useColorScheme';
import { useAuth } from '@/context/AuthContext';
@@ -82,7 +83,9 @@ export default function TabLayout() : React.ReactNode {
tabBarStyle: Platform.select({
ios: {
position: 'absolute',
// backgroundColor: colors.tabBarBackground,
},
android: {
backgroundColor: colors.tabBarBackground,
},
default: {},
}),
@@ -94,7 +97,7 @@ export default function TabLayout() : React.ReactNode {
/**
* Icon for the credentials tab.
*/
tabBarIcon: ({ color }) => <IconSymbol size={28} name="key.fill" color={color} />,
tabBarIcon: ({ color }) => <IconSymbol size={28} name={IconSymbolName.Key} color={color} />,
}}
/>
<Tabs.Screen
@@ -104,7 +107,7 @@ export default function TabLayout() : React.ReactNode {
/**
* Icon for the emails tab.
*/
tabBarIcon: ({ color }) => <IconSymbol size={28} name="envelope.fill" color={color} />,
tabBarIcon: ({ color }) => <IconSymbol size={28} name={IconSymbolName.Envelope} color={color} />,
}}
/>
<Tabs.Screen
@@ -116,8 +119,8 @@ export default function TabLayout() : React.ReactNode {
*/
tabBarIcon: ({ color }) => (
<View style={styles.iconContainer}>
<IconSymbol size={28} name="gear" color={color} />
{Platform.OS === 'ios' && authContext.shouldShowIosAutofillReminder && (
<IconSymbol size={28} name={IconSymbolName.Gear} color={color} />
{authContext.shouldShowAutofillReminder && (
<View style={styles.iconNotificationContainer}>
<ThemedText style={styles.iconNotificationText}>1</ThemedText>
</View>

View File

@@ -1,6 +1,6 @@
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, View, Text, StyleSheet, TouchableOpacity, Linking } from 'react-native';
import { ActivityIndicator, View, Text, StyleSheet, TouchableOpacity, Linking, Pressable } from 'react-native';
import Toast from 'react-native-toast-message';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
@@ -17,6 +17,7 @@ import { EmailPreview } from '@/components/credentials/details/EmailPreview';
import { TotpSection } from '@/components/credentials/details/TotpSection';
import { useColors } from '@/hooks/useColorScheme';
import emitter from '@/utils/EventEmitter';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
/**
* Credential details screen.
@@ -45,8 +46,11 @@ export default function CredentialDetailsScreen() : React.ReactNode {
*/
headerRight: () => (
<View style={styles.headerRightContainer}>
<TouchableOpacity
onPress={handleEdit}
<Pressable
onPressIn={handleEdit}
android_ripple={{ color: 'lightgray' }}
pressRetentionOffset={100}
hitSlop={100}
style={styles.headerRightButton}
>
<MaterialIcons
@@ -54,7 +58,7 @@ export default function CredentialDetailsScreen() : React.ReactNode {
size={24}
color={colors.primary}
/>
</TouchableOpacity>
</Pressable>
</View>
),
});
@@ -107,28 +111,30 @@ export default function CredentialDetailsScreen() : React.ReactNode {
}
return (
<ThemedScrollView>
<ThemedView style={styles.header}>
<CredentialIcon logo={credential.Logo} style={styles.logo} />
<View style={styles.headerText}>
<ThemedText type="title" style={styles.serviceName}>
{credential.ServiceName}
</ThemedText>
{credential.ServiceUrl && (
<TouchableOpacity onPress={() => Linking.openURL(credential.ServiceUrl!)}>
<Text style={[styles.serviceUrl, { color: colors.primary }]}>
{credential.ServiceUrl}
</Text>
</TouchableOpacity>
)}
</View>
</ThemedView>
<EmailPreview email={credential.Alias.Email} />
<TotpSection credential={credential} />
<NotesSection credential={credential} />
<LoginCredentials credential={credential} />
<AliasDetails credential={credential} />
</ThemedScrollView>
<ThemedContainer>
<ThemedScrollView>
<ThemedView style={styles.header}>
<CredentialIcon logo={credential.Logo} style={styles.logo} />
<View style={styles.headerText}>
<ThemedText type="title" style={styles.serviceName}>
{credential.ServiceName}
</ThemedText>
{credential.ServiceUrl && (
<TouchableOpacity onPress={() => Linking.openURL(credential.ServiceUrl!)}>
<Text style={[styles.serviceUrl, { color: colors.primary }]}>
{credential.ServiceUrl}
</Text>
</TouchableOpacity>
)}
</View>
</ThemedView>
<EmailPreview email={credential.Alias.Email} />
<TotpSection credential={credential} />
<NotesSection credential={credential} />
<LoginCredentials credential={credential} />
<AliasDetails credential={credential} />
</ThemedScrollView>
</ThemedContainer>
);
}
@@ -137,8 +143,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
flexDirection: 'row',
gap: 12,
marginTop: 6,
padding: 16,
},
headerRightButton: {
padding: 10,
@@ -155,7 +159,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
logo: {
borderRadius: 8,
borderRadius: 4,
height: 48,
width: 48,
},

View File

@@ -5,6 +5,7 @@ import { defaultHeaderOptions } from '@/components/themed/ThemedHeader';
/**
* Credentials layout.
* @returns {React.ReactNode} The credentials layout component
*/
export default function CredentialsLayout(): React.ReactNode {
return (
@@ -12,7 +13,9 @@ export default function CredentialsLayout(): React.ReactNode {
<Stack.Screen
name="index"
options={{
headerShown: false,
title: 'Credentials',
headerShown: Platform.OS === 'android',
...defaultHeaderOptions,
}}
/>
<Stack.Screen

View File

@@ -1,6 +1,6 @@
import { StyleSheet, View, TouchableOpacity, Alert, Keyboard, KeyboardAvoidingView, Platform } from 'react-native';
import { StyleSheet, View, TouchableOpacity, Alert, Keyboard, KeyboardAvoidingView, Platform, Pressable } from 'react-native';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
import { Stack, useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import Toast from 'react-native-toast-message';
import { Resolver, useForm } from 'react-hook-form';
@@ -9,7 +9,6 @@ import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view
import * as Haptics from 'expo-haptics';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useColors } from '@/hooks/useColorScheme';
import { useDb } from '@/context/DbContext';
import { useWebApi } from '@/context/WebApiContext';
@@ -21,9 +20,11 @@ import { useVaultMutate } from '@/hooks/useVaultMutate';
import { IdentityGeneratorEn, IdentityGeneratorNl, IdentityHelperUtils, BaseIdentityGenerator } from '@/utils/shared/identity-generator';
import { PasswordGenerator } from '@/utils/shared/password-generator';
import { ValidatedFormField, ValidatedFormFieldRef } from '@/components/form/ValidatedFormField';
import { credentialSchema } from '@/utils/validationSchema';
import { credentialSchema } from '@/utils/ValidationSchema';
import LoadingOverlay from '@/components/LoadingOverlay';
import { useAuth } from '@/context/AuthContext';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { extractServiceNameFromUrl } from '@/utils/UrlUtility';
type CredentialMode = 'random' | 'manual';
@@ -43,6 +44,7 @@ export default function AddEditCredentialScreen() : React.ReactNode {
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const serviceNameRef = useRef<ValidatedFormFieldRef>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaveDisabled, setIsSaveDisabled] = useState(false);
const { control, handleSubmit, setValue, watch } = useForm<Credential>({
resolver: yupResolver(credentialSchema) as Resolver<Credential>,
@@ -205,6 +207,14 @@ export default function AddEditCredentialScreen() : React.ReactNode {
* @param {Credential} data - The form data.
*/
const onSubmit = useCallback(async (data: Credential) : Promise<void> => {
// Prevent multiple submissions
if (isSaveDisabled) {
return;
}
// Disable save button to prevent multiple submissions
setIsSaveDisabled(true);
Keyboard.dismiss();
setIsLoading(true);
@@ -300,36 +310,7 @@ export default function AddEditCredentialScreen() : React.ReactNode {
setIsLoading(false);
}
}, [isEditMode, id, serviceUrl, router, executeVaultMutation, dbContext.sqliteClient, mode, generateRandomAlias, webApi, watch]);
/**
* Extract the service name from the service URL.
*/
function extractServiceNameFromUrl(url: string): string {
try {
const urlObj = new URL(url);
const hostParts = urlObj.hostname.split('.');
// Remove common subdomains
const commonSubdomains = ['www', 'app', 'login', 'auth', 'account', 'portal'];
while (hostParts.length > 2 && commonSubdomains.includes(hostParts[0].toLowerCase())) {
hostParts.shift();
}
// For domains like google.com, return Google.com
if (hostParts.length <= 2) {
const domain = hostParts.join('.');
return domain.charAt(0).toUpperCase() + domain.slice(1);
}
// For domains like app.example.com, return Example.com
const mainDomain = hostParts.slice(-2).join('.');
return mainDomain.charAt(0).toUpperCase() + mainDomain.slice(1);
} catch {
// If URL parsing fails, return the original URL
return url;
}
}
}, [isEditMode, id, serviceUrl, router, executeVaultMutation, dbContext.sqliteClient, mode, generateRandomAlias, webApi, watch, setIsSaveDisabled, setIsLoading, isSaveDisabled]);
/**
* Generate a random username.
@@ -411,10 +392,12 @@ export default function AddEditCredentialScreen() : React.ReactNode {
setIsLoading(false);
/*
* Hard navigate back to the credentials list as the credential that was
* shown in the previous screen is now deleted.
* Navigate back to the root of the navigation stack.
* On Android, we need to go back twice since we're two levels deep.
* On iOS, this will dismiss the modal.
*/
router.replace('/credentials');
router.back();
router.back();
}
}
]
@@ -424,15 +407,11 @@ export default function AddEditCredentialScreen() : React.ReactNode {
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flex: 1,
padding: 16,
paddingTop: 0,
paddingTop: Platform.OS === 'ios' ? 52 : 0,
},
contentContainer: {
paddingBottom: 40,
paddingTop: Platform.OS === 'ios' ? 76 : 56,
paddingTop: 16,
},
deleteButton: {
alignItems: 'center',
@@ -470,13 +449,18 @@ export default function AddEditCredentialScreen() : React.ReactNode {
},
headerRightButton: {
padding: 10,
paddingRight: 0,
},
headerRightButtonDisabled: {
opacity: 0.5,
},
keyboardContainer: {
flex: 1,
},
modeButton: {
alignItems: 'center',
borderRadius: 6,
flex: 1,
padding: 12,
padding: 8,
},
modeButtonActive: {
backgroundColor: colors.primary,
@@ -505,49 +489,70 @@ export default function AddEditCredentialScreen() : React.ReactNode {
color: colors.text,
fontSize: 18,
fontWeight: '600',
marginBottom: 16,
marginBottom: 10,
},
});
// Set header buttons
useEffect(() => {
navigation.setOptions({
title: isEditMode ? 'Edit Credential' : 'Add Credential',
/**
* Header left button.
*/
headerLeft: () => (
<TouchableOpacity
onPress={() => router.back()}
style={styles.headerLeftButton}
>
<ThemedText style={styles.headerLeftButtonText}>Cancel</ThemedText>
</TouchableOpacity>
),
/**
* Header right button.
*/
headerRight: () => (
<TouchableOpacity
onPress={handleSubmit(onSubmit)}
style={styles.headerRightButton}
>
<MaterialIcons name="save" size={24} color={colors.primary} />
</TouchableOpacity>
),
});
}, [navigation, mode, handleSubmit, onSubmit, colors.primary, isEditMode, router, styles.headerLeftButton, styles.headerLeftButtonText, styles.headerRightButton]);
if (Platform.OS === 'ios') {
navigation.setOptions({
/**
* Header left button.
*/
headerLeft: () => (
<TouchableOpacity
onPress={() => router.back()}
style={styles.headerLeftButton}
>
<ThemedText style={styles.headerLeftButtonText}>Cancel</ThemedText>
</TouchableOpacity>
),
/**
* Header right button.
*/
headerRight: () => (
<TouchableOpacity
onPress={handleSubmit(onSubmit)}
style={[styles.headerRightButton, isSaveDisabled && styles.headerRightButtonDisabled]}
disabled={isSaveDisabled}
>
<MaterialIcons name="save" size={22} color={colors.primary} />
</TouchableOpacity>
),
});
} else {
navigation.setOptions({
/**
* Header right button.
*/
headerRight: () => (
<Pressable
onPress={handleSubmit(onSubmit)}
style={[styles.headerRightButton, isSaveDisabled && styles.headerRightButtonDisabled]}
android_ripple={{ color: 'lightgray' }}
pressRetentionOffset={100}
hitSlop={100}
disabled={isSaveDisabled}
>
<MaterialIcons name="save" size={24} color={colors.primary} />
</Pressable>
),
});
}
}, [navigation, mode, handleSubmit, onSubmit, colors.primary, isEditMode, router, styles.headerLeftButton, styles.headerLeftButtonText, styles.headerRightButton, styles.headerRightButtonDisabled, isSaveDisabled]);
return (
<>
<Stack.Screen options={{ title: isEditMode ? 'Edit Credential' : 'Add Credential' }} />
{(isLoading) && (
<LoadingOverlay status={syncStatus} />
)}
<KeyboardAvoidingView
style={styles.container}
style={styles.keyboardContainer}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ThemedView style={styles.content}>
<ThemedContainer style={styles.container}>
<KeyboardAwareScrollView
enableOnAndroid={true}
contentContainerStyle={styles.contentContainer}
@@ -691,7 +696,7 @@ export default function AddEditCredentialScreen() : React.ReactNode {
</>
)}
</KeyboardAwareScrollView>
</ThemedView>
</ThemedContainer>
<AliasVaultToast />
</KeyboardAvoidingView>
</>

View File

@@ -1,11 +1,11 @@
import { StyleSheet, Text, FlatList, TouchableOpacity, TextInput, RefreshControl, Platform, Animated, Alert } from 'react-native';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigation } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import Toast from 'react-native-toast-message';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Haptics from 'expo-haptics';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
@@ -18,9 +18,12 @@ import { CredentialCard } from '@/components/credentials/CredentialCard';
import { TitleContainer } from '@/components/ui/TitleContainer';
import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader';
import { SkeletonLoader } from '@/components/ui/SkeletonLoader';
import { AndroidHeader } from '@/components/ui/AndroidHeader';
import emitter from '@/utils/EventEmitter';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import { useWebApi } from '@/context/WebApiContext';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ServiceUrlNotice } from '@/components/credentials/ServiceUrlNotice';
/**
* Credentials screen.
@@ -35,9 +38,11 @@ export default function CredentialsScreen() : React.ReactNode {
const navigation = useNavigation();
const [isTabFocused, setIsTabFocused] = useState(false);
const router = useRouter();
const { serviceUrl: serviceUrlParam } = useLocalSearchParams<{ serviceUrl?: string }>();
const [credentialsList, setCredentialsList] = useState<Credential[]>([]);
const [isLoadingCredentials, setIsLoadingCredentials] = useMinDurationLoading(false, 200);
const [refreshing, setRefreshing] = useMinDurationLoading(false, 200);
const [serviceUrl, setServiceUrl] = useState<string | null>(null);
const insets = useSafeAreaInsets();
const authContext = useAuth();
@@ -65,14 +70,14 @@ export default function CredentialsScreen() : React.ReactNode {
}
}, [dbContext.sqliteClient, setIsLoadingCredentials]);
const headerButtons = [{
const headerButtons = useMemo(() => [{
icon: 'add' as const,
position: 'right' as const,
/**
* Add credential.
*/
onPress: () : void => router.push('/(tabs)/credentials/add-edit')
}];
}], [router]);
useEffect(() => {
const unsubscribeFocus = navigation.addListener('focus', () => {
@@ -211,22 +216,15 @@ export default function CredentialsScreen() : React.ReactNode {
padding: 4,
position: 'absolute',
right: 8,
top: '50%',
transform: [{ translateY: -12 }],
top: 4,
},
clearButtonText: {
color: colors.textMuted,
fontSize: 20,
},
container: {
flex: 1,
paddingBottom: insets.bottom,
paddingHorizontal: 14,
paddingTop: insets.top,
},
contentContainer: {
paddingBottom: 40,
paddingTop: 42,
paddingBottom: Platform.OS === 'ios' ? insets.bottom + 60 : 10,
paddingTop: Platform.OS === 'ios' ? 42 : 16,
},
emptyText: {
color: colors.textMuted,
@@ -250,8 +248,8 @@ export default function CredentialsScreen() : React.ReactNode {
color: colors.text,
fontSize: 16,
height: 40,
lineHeight: 20,
marginBottom: 16,
padding: 12,
paddingLeft: 40,
paddingRight: Platform.OS === 'android' ? 40 : 12,
},
@@ -261,8 +259,28 @@ export default function CredentialsScreen() : React.ReactNode {
},
});
// Set header buttons
useEffect(() => {
navigation.setOptions({
/**
* Define custom header which is shown on Android. iOS displays the custom CollapsibleHeader component instead.
* @returns
*/
headerTitle: (): React.ReactNode => Platform.OS === 'android' ? <AndroidHeader title="Credentials" headerButtons={headerButtons} /> : <Text>Credentials</Text>,
});
}, [navigation, headerButtons]);
// Handle deep link parameters
useFocusEffect(
useCallback(() => {
// Always check the current serviceUrlParam when screen comes into focus
const currentServiceUrl = serviceUrlParam ? decodeURIComponent(serviceUrlParam) : null;
setServiceUrl(currentServiceUrl);
}, [serviceUrlParam])
);
return (
<ThemedView style={styles.container}>
<ThemedContainer>
<CollapsibleHeader
title="Credentials"
scrollY={scrollY}
@@ -286,10 +304,16 @@ export default function CredentialsScreen() : React.ReactNode {
initialNumToRender={14}
maxToRenderPerBatch={14}
windowSize={7}
removeClippedSubviews={true}
removeClippedSubviews={false}
ListHeaderComponent={
<ThemedView>
<TitleContainer title="Credentials" />
{serviceUrl && (
<ServiceUrlNotice
serviceUrl={serviceUrl}
onDismiss={() => setServiceUrl(null)}
/>
)}
<ThemedView style={styles.searchContainer}>
<MaterialIcons
name="search"
@@ -342,6 +366,6 @@ export default function CredentialsScreen() : React.ReactNode {
}
/>
</ThemedView>
</ThemedView>
</ThemedContainer>
);
}

View File

@@ -13,6 +13,7 @@ import { ThemedText } from '@/components/themed/ThemedText';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { useColors } from '@/hooks/useColorScheme';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { IconSymbolName } from '@/components/ui/IconSymbolName';
import emitter from '@/utils/EventEmitter';
import { ThemedView } from '@/components/themed/ThemedView';
@@ -384,7 +385,7 @@ export default function EmailDetailsScreen() : React.ReactNode {
{associatedCredential && (
<>
<TouchableOpacity onPress={handleOpenCredential} style={styles.metadataCredential}>
<IconSymbol size={16} name="key.fill" color={colors.primary} style={styles.metadataCredentialIcon} />
<IconSymbol size={16} name={IconSymbolName.Key} color={colors.primary} style={styles.metadataCredentialIcon} />
<ThemedText style={[styles.metadataText, { color: colors.primary }]}>
{associatedCredential.ServiceName}
</ThemedText>

View File

@@ -1,4 +1,8 @@
import { Stack } from 'expo-router';
import { Platform, Text } from 'react-native';
import { defaultHeaderOptions } from '@/components/themed/ThemedHeader';
import { AndroidHeader } from '@/components/ui/AndroidHeader';
/**
* Emails layout.
@@ -9,13 +13,23 @@ export default function EmailsLayout(): React.ReactNode {
<Stack.Screen
name="index"
options={{
headerShown: false,
title: 'Emails',
headerShown: Platform.OS === 'android',
/**
* On Android, we use a custom header component that includes the AliasVault logo.
* On iOS, we don't show the header as a custom collapsible header is used.
* @returns {React.ReactNode} The header component
*/
headerTitle: (): React.ReactNode => Platform.OS === 'android' ? <AndroidHeader title="Emails" /> : <Text>Emails</Text>,
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="[id]"
options={{
title: 'Email',
...defaultHeaderOptions,
headerTransparent: false,
}}
/>
</Stack>

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { StyleSheet, View, ScrollView, RefreshControl, Animated , Platform } from 'react-native';
import { useNavigation } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Toast from 'react-native-toast-message';
import * as Haptics from 'expo-haptics';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MailboxEmail } from '@/utils/types/webapi/MailboxEmail';
import { useDb } from '@/context/DbContext';
@@ -14,12 +14,13 @@ import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader';
import { MailboxBulkRequest, MailboxBulkResponse } from '@/utils/types/webapi/MailboxBulk';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { useColors } from '@/hooks/useColorScheme';
import { ThemedView } from '@/components/themed/ThemedView';
import { EmailCard } from '@/components/EmailCard';
import { SkeletonLoader } from '@/components/ui/SkeletonLoader';
import emitter from '@/utils/EventEmitter';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import { useAuth } from '@/context/AuthContext';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
/**
* Emails screen.
*/
@@ -155,15 +156,9 @@ export default function EmailsScreen() : React.ReactNode {
justifyContent: 'center',
padding: 20,
},
container: {
flex: 1,
paddingBottom: insets.bottom,
paddingHorizontal: 14,
paddingTop: insets.top,
},
contentContainer: {
paddingBottom: 40,
paddingTop: 42,
paddingBottom: Platform.OS === 'ios' ? insets.bottom + 60 : 10,
paddingTop: Platform.OS === 'ios' ? 42 : 16,
},
emptyText: {
color: colors.textMuted,
@@ -223,7 +218,7 @@ export default function EmailsScreen() : React.ReactNode {
};
return (
<ThemedView style={styles.container}>
<ThemedContainer>
<CollapsibleHeader
title="Emails"
scrollY={scrollY}
@@ -250,6 +245,6 @@ export default function EmailsScreen() : React.ReactNode {
<TitleContainer title="Emails" />
{renderContent()}
</Animated.ScrollView>
</ThemedView>
</ThemedContainer>
);
}

View File

@@ -1,6 +1,8 @@
import { Stack } from 'expo-router';
import { Platform, Text } from 'react-native';
import { defaultHeaderOptions } from '@/components/themed/ThemedHeader';
import { AndroidHeader } from '@/components/ui/AndroidHeader';
/**
* Settings layout.
@@ -11,7 +13,15 @@ export default function SettingsLayout(): React.ReactNode {
<Stack.Screen
name="index"
options={{
headerShown: false,
title: 'Settings',
headerShown: Platform.OS === 'android',
/**
* On Android, we use a custom header component that includes the AliasVault logo.
* On iOS, we don't show the header as a custom collapsible header is used.
* @returns {React.ReactNode} The header component
*/
headerTitle: (): React.ReactNode => Platform.OS === 'android' ? <AndroidHeader title="Settings" /> : <Text>Settings</Text>,
...defaultHeaderOptions,
}}
/>
<Stack.Screen
@@ -22,6 +32,14 @@ export default function SettingsLayout(): React.ReactNode {
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="android-autofill"
options={{
title: 'Android Autofill',
headerBackTitle: 'Settings',
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="vault-unlock"
options={{

View File

@@ -0,0 +1,177 @@
import { StyleSheet, View, TouchableOpacity, Linking } from 'react-native';
import { router } from 'expo-router';
import NativeVaultManager from '@/specs/NativeVaultManager';
import { ThemedText } from '@/components/themed/ThemedText';
import { useColors } from '@/hooks/useColorScheme';
import { useAuth } from '@/context/AuthContext';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
/**
* Android autofill screen.
*/
export default function AndroidAutofillScreen() : React.ReactNode {
const colors = useColors();
const { markAutofillConfigured, shouldShowAutofillReminder } = useAuth();
/**
* Handle the configure press.
*/
const handleConfigurePress = async () : Promise<void> => {
await markAutofillConfigured();
try {
await NativeVaultManager.openAutofillSettingsPage();
} catch (err) {
console.warn('Failed to open settings:', err);
}
};
/**
* Handle the already configured press.
*/
const handleAlreadyConfigured = async () : Promise<void> => {
await markAutofillConfigured();
router.back();
};
/**
* Handle opening the documentation link.
*/
const handleOpenDocs = () : void => {
Linking.openURL('https://docs.aliasvault.net/mobile-apps/android/autofill.html');
};
const styles = StyleSheet.create({
buttonContainer: {
padding: 16,
paddingBottom: 32,
},
configureButton: {
alignItems: 'center',
backgroundColor: colors.primary,
borderRadius: 10,
paddingVertical: 16,
},
configureButtonText: {
color: colors.primarySurfaceText,
fontSize: 16,
fontWeight: '600',
},
headerText: {
color: colors.textMuted,
fontSize: 13,
},
instructionContainer: {
paddingTop: 16,
},
instructionStep: {
color: colors.text,
fontSize: 15,
lineHeight: 22,
marginBottom: 8,
},
instructionTitle: {
color: colors.text,
fontSize: 17,
fontWeight: '600',
marginBottom: 8,
},
secondaryButton: {
alignItems: 'center',
backgroundColor: colors.accentBackground,
borderRadius: 10,
marginTop: 12,
paddingVertical: 16,
},
secondaryButtonText: {
color: colors.text,
fontSize: 16,
fontWeight: '600',
},
tipStep: {
color: colors.textMuted,
fontSize: 13,
lineHeight: 20,
marginTop: 8,
},
warningContainer: {
backgroundColor: colors.accentBackground,
marginBottom: 16,
padding: 16,
},
warningDescription: {
color: colors.text,
fontSize: 14,
lineHeight: 20,
},
warningLink: {
color: colors.primary,
fontSize: 14,
textDecorationLine: 'underline',
},
warningTitle: {
color: colors.text,
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
});
return (
<ThemedContainer>
<ThemedScrollView>
<View style={styles.warningContainer}>
<ThemedText style={styles.warningTitle}> Experimental Feature</ThemedText>
<ThemedText style={styles.warningDescription}>
Autofill support for Android is currently in an experimental state.{' '}
<ThemedText style={styles.warningLink} onPress={handleOpenDocs}>
Read more about it here
</ThemedText>
</ThemedText>
</View>
<View>
<ThemedText style={styles.headerText}>
You can configure AliasVault to provide native password autofill functionality in Android. Follow the instructions below to enable it.
</ThemedText>
</View>
<View style={styles.instructionContainer}>
<ThemedText style={styles.instructionTitle}>How to enable:</ThemedText>
<ThemedText style={styles.instructionStep}>
1. Open Android Settings via the button below, and change the &quot;autofill preferred service&quot; to &quot;AliasVault&quot;
</ThemedText>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={styles.configureButton}
onPress={handleConfigurePress}
>
<ThemedText style={styles.configureButtonText}>
Open Autofill Settings
</ThemedText>
</TouchableOpacity>
<ThemedText style={styles.tipStep}>
If the button above doesn&apos;t work it might be blocked because of security settings. You can manually go to Android Settings General Management Passwords and autofill.
</ThemedText>
</View>
<ThemedText style={styles.instructionStep}>
2. Some apps, e.g. Google Chrome, may require manual configuration in their settings to allow third-party autofill apps. However, most apps should work with autofill by default.
</ThemedText>
<View style={styles.buttonContainer}>
{shouldShowAutofillReminder && (
<TouchableOpacity
style={styles.secondaryButton}
onPress={handleAlreadyConfigured}
>
<ThemedText style={styles.secondaryButtonText}>
I already configured it
</ThemedText>
</TouchableOpacity>
)}
</View>
</View>
</ThemedScrollView>
</ThemedContainer>
);
}

View File

@@ -3,10 +3,10 @@ import { Ionicons } from '@expo/vector-icons';
import { useEffect, useState } from 'react';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useColors } from '@/hooks/useColorScheme';
import { useAuth } from '@/context/AuthContext';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
/**
* Auto-lock screen.
@@ -40,13 +40,6 @@ export default function AutoLockScreen() : React.ReactNode {
];
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
padding: 16,
paddingBottom: 0,
},
headerText: {
color: colors.textMuted,
fontSize: 13,
@@ -62,7 +55,7 @@ export default function AutoLockScreen() : React.ReactNode {
optionContainer: {
backgroundColor: colors.accentBackground,
borderRadius: 10,
margin: 16,
marginTop: 16,
},
optionLast: {
borderBottomWidth: 0,
@@ -79,13 +72,11 @@ export default function AutoLockScreen() : React.ReactNode {
});
return (
<ThemedView style={styles.container}>
<ThemedContainer>
<ThemedScrollView>
<View style={styles.header}>
<ThemedText style={styles.headerText}>
Choose how long the app can stay in the background before requiring re-authentication. You&apos;ll need to use Face ID or enter your password to unlock the vault again.
</ThemedText>
</View>
<ThemedText style={styles.headerText}>
Choose how long the app can stay in the background before requiring re-authentication. You&apos;ll need to use Face ID or enter your password to unlock the vault again.
</ThemedText>
<View style={styles.optionContainer}>
{timeoutOptions.map((option, index) => {
const isLast = index === timeoutOptions.length - 1;
@@ -107,6 +98,6 @@ export default function AutoLockScreen() : React.ReactNode {
})}
</View>
</ThemedScrollView>
</ThemedView>
</ThemedContainer>
);
}

View File

@@ -2,10 +2,8 @@ import { StyleSheet, View, ScrollView, TouchableOpacity, Animated, Platform, Ale
import { router, useFocusEffect } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useRef, useState, useCallback } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useWebApi } from '@/context/WebApiContext';
import { AppInfo } from '@/utils/AppInfo';
import { useColors } from '@/hooks/useColorScheme';
@@ -15,6 +13,7 @@ import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader';
import { InlineSkeletonLoader } from '@/components/ui/InlineSkeletonLoader';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import { UsernameDisplay } from '@/components/ui/UsernameDisplay';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
/**
* Settings screen.
@@ -22,14 +21,13 @@ import { UsernameDisplay } from '@/components/ui/UsernameDisplay';
export default function SettingsScreen() : React.ReactNode {
const webApi = useWebApi();
const colors = useColors();
const { getAuthMethodDisplay, shouldShowIosAutofillReminder } = useAuth();
const { getAuthMethodDisplay, shouldShowAutofillReminder } = useAuth();
const { getAutoLockTimeout } = useAuth();
const scrollY = useRef(new Animated.Value(0)).current;
const scrollViewRef = useRef<ScrollView>(null);
const [autoLockDisplay, setAutoLockDisplay] = useState<string>('');
const [authMethodDisplay, setAuthMethodDisplay] = useState<string>('');
const [isFirstLoad, setIsFirstLoad] = useMinDurationLoading(true, 100);
const insets = useSafeAreaInsets();
useFocusEffect(
useCallback(() => {
@@ -125,16 +123,17 @@ export default function SettingsScreen() : React.ReactNode {
router.push('/(tabs)/settings/ios-autofill');
};
/**
* Handle the Android autofill press.
*/
const handleAndroidAutofillPress = () : void => {
router.push('/(tabs)/settings/android-autofill');
};
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: insets.bottom,
paddingHorizontal: 14,
paddingTop: insets.top,
},
scrollContent: {
paddingBottom: 40,
paddingTop: 42,
paddingTop: Platform.OS === 'ios' ? 42 : 16,
},
scrollView: {
flex: 1,
@@ -212,7 +211,7 @@ export default function SettingsScreen() : React.ReactNode {
});
return (
<ThemedView style={styles.container}>
<ThemedContainer>
<CollapsibleHeader
title="Settings"
scrollY={scrollY}
@@ -243,7 +242,29 @@ export default function SettingsScreen() : React.ReactNode {
</View>
<View style={styles.settingItemContent}>
<ThemedText style={styles.settingItemText}>iOS Autofill</ThemedText>
{shouldShowIosAutofillReminder && (
{shouldShowAutofillReminder && (
<View style={styles.settingItemBadge}>
<ThemedText style={styles.settingItemBadgeText}>1</ThemedText>
</View>
)}
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
</View>
</TouchableOpacity>
<View style={styles.separator} />
</>
)}
{Platform.OS === 'android' && (
<>
<TouchableOpacity
style={styles.settingItem}
onPress={handleAndroidAutofillPress}
>
<View style={styles.settingItemIcon}>
<Ionicons name="key-outline" size={20} color={colors.text} />
</View>
<View style={styles.settingItemContent}>
<ThemedText style={styles.settingItemText}>Android Autofill</ThemedText>
{shouldShowAutofillReminder && (
<View style={styles.settingItemBadge}>
<ThemedText style={styles.settingItemBadgeText}>1</ThemedText>
</View>
@@ -324,6 +345,6 @@ export default function SettingsScreen() : React.ReactNode {
<ThemedText style={styles.versionText}>App version {AppInfo.VERSION}</ThemedText>
</View>
</Animated.ScrollView>
</ThemedView>
</ThemedContainer>
);
}

View File

@@ -1,33 +1,37 @@
import { StyleSheet, View, TouchableOpacity, Linking } from 'react-native';
import { StyleSheet, View, TouchableOpacity } from 'react-native';
import { router } from 'expo-router';
import NativeVaultManager from '@/specs/NativeVaultManager';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useColors } from '@/hooks/useColorScheme';
import { useAuth } from '@/context/AuthContext';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
/**
* iOS autofill screen.
*/
export default function IosAutofillScreen() : React.ReactNode {
const colors = useColors();
const { markIosAutofillConfigured, shouldShowIosAutofillReminder } = useAuth();
const { markAutofillConfigured, shouldShowAutofillReminder } = useAuth();
/**
* Handle the configure press.
*/
const handleConfigurePress = async () : Promise<void> => {
await markIosAutofillConfigured();
await Linking.openURL('App-Prefs:root');
router.back();
await markAutofillConfigured();
try {
await NativeVaultManager.openAutofillSettingsPage();
} catch (err) {
console.warn('Failed to open settings:', err);
}
};
/**
* Handle the already configured press.
*/
const handleAlreadyConfigured = async () : Promise<void> => {
await markIosAutofillConfigured();
await markAutofillConfigured();
router.back();
};
@@ -47,19 +51,13 @@ export default function IosAutofillScreen() : React.ReactNode {
fontSize: 16,
fontWeight: '600',
},
container: {
flex: 1,
},
header: {
padding: 16,
paddingBottom: 0,
},
headerText: {
color: colors.textMuted,
fontSize: 13,
textAlignVertical: 'top',
},
instructionContainer: {
padding: 16,
paddingTop: 16,
},
instructionStep: {
color: colors.text,
@@ -94,13 +92,11 @@ export default function IosAutofillScreen() : React.ReactNode {
});
return (
<ThemedView style={styles.container}>
<ThemedContainer>
<ThemedScrollView>
<View style={styles.header}>
<ThemedText style={styles.headerText}>
You can configure AliasVault to provide native password autofill functionality in iOS. Follow the instructions below to enable it.
</ThemedText>
</View>
<ThemedText style={styles.headerText}>
You can configure AliasVault to provide native password autofill functionality in iOS. Follow the instructions below to enable it.
</ThemedText>
<View style={styles.instructionContainer}>
<ThemedText style={styles.instructionTitle}>How to enable:</ThemedText>
@@ -133,7 +129,7 @@ export default function IosAutofillScreen() : React.ReactNode {
Note: You&apos;ll need to authenticate with Face ID/Touch ID or your device passcode when using autofill.
</ThemedText>
<View style={styles.buttonContainer}>
{shouldShowIosAutofillReminder && (
{shouldShowAutofillReminder && (
<TouchableOpacity
style={styles.secondaryButton}
onPress={handleAlreadyConfigured}
@@ -146,6 +142,6 @@ export default function IosAutofillScreen() : React.ReactNode {
</View>
</View>
</ThemedScrollView>
</ThemedView>
</ThemedContainer>
);
}

View File

@@ -1,22 +1,21 @@
import { StyleSheet, View, TouchableOpacity, Alert, ScrollView, RefreshControl, Platform } from 'react-native';
import { StyleSheet, View, TouchableOpacity, Alert, RefreshControl, Platform } from 'react-native';
import { useState, useEffect, useCallback } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Haptics from 'expo-haptics';
import Toast from 'react-native-toast-message';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useColors } from '@/hooks/useColorScheme';
import { useWebApi } from '@/context/WebApiContext';
import { RefreshToken } from '@/utils/types/webapi/RefreshToken';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import { SkeletonLoader } from '@/components/ui/SkeletonLoader';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
/**
* Active sessions screen.
*/
export default function ActiveSessionsScreen() : React.ReactNode {
const colors = useColors();
const insets = useSafeAreaInsets();
const webApi = useWebApi();
const [refreshTokens, setRefreshTokens] = useState<RefreshToken[]>([]);
@@ -24,16 +23,6 @@ export default function ActiveSessionsScreen() : React.ReactNode {
const [isRefreshing, setIsRefreshing] = useMinDurationLoading(false, 200);
const styles = StyleSheet.create({
container: {
flex: 1,
marginTop: 42,
paddingBottom: insets.bottom,
paddingHorizontal: 14,
paddingTop: insets.top,
},
contentContainer: {
paddingBottom: 40,
},
detailText: {
color: colors.textMuted,
fontSize: 14,
@@ -56,9 +45,6 @@ export default function ActiveSessionsScreen() : React.ReactNode {
fontSize: 16,
textAlign: 'center',
},
header: {
paddingTop: 16
},
headerText: {
color: colors.textMuted,
fontSize: 13,
@@ -175,9 +161,8 @@ export default function ActiveSessionsScreen() : React.ReactNode {
};
return (
<ThemedView style={styles.container}>
<ScrollView
contentContainerStyle={styles.contentContainer}
<ThemedContainer>
<ThemedScrollView
refreshControl={
<RefreshControl
refreshing={isRefreshing}
@@ -187,11 +172,9 @@ export default function ActiveSessionsScreen() : React.ReactNode {
/>
}
>
<View style={styles.header}>
<ThemedText style={styles.headerText}>
Below is a list of devices where your account is currently logged in or has an active session. You can log out from any of these sessions here.
</ThemedText>
</View>
<ThemedText style={styles.headerText}>
Below is a list of devices where your account is currently logged in or has an active session. You can log out from any of these sessions here.
</ThemedText>
<View style={styles.section}>
{isLoading ? (
<SkeletonLoader count={1} height={100} parts={3} />
@@ -216,7 +199,7 @@ export default function ActiveSessionsScreen() : React.ReactNode {
))
)}
</View>
</ScrollView>
</ThemedView>
</ThemedScrollView>
</ThemedContainer>
);
}

View File

@@ -1,24 +1,23 @@
import { StyleSheet, View, ScrollView, RefreshControl, Platform } from 'react-native';
import { StyleSheet, View, RefreshControl, Platform } from 'react-native';
import { useState, useEffect, useCallback } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Haptics from 'expo-haptics';
import Toast from 'react-native-toast-message';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useColors } from '@/hooks/useColorScheme';
import { useWebApi } from '@/context/WebApiContext';
import { SkeletonLoader } from '@/components/ui/SkeletonLoader';
import { AuthLogModel } from '@/utils/types/webapi/AuthLog';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import { AuthEventType } from '@/utils/types/webapi/AuthEventType';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
/**
* Auth logs screen.
*/
export default function AuthLogsScreen() : React.ReactNode {
const colors = useColors();
const insets = useSafeAreaInsets();
const webApi = useWebApi();
const [logs, setLogs] = useState<AuthLogModel[]>([]);
@@ -26,16 +25,6 @@ export default function AuthLogsScreen() : React.ReactNode {
const [isRefreshing, setIsRefreshing] = useMinDurationLoading(false, 200);
const styles = StyleSheet.create({
container: {
flex: 1,
marginTop: 42,
paddingBottom: insets.bottom,
paddingHorizontal: 14,
paddingTop: insets.top,
},
contentContainer: {
paddingBottom: 40,
},
detailText: {
color: colors.textMuted,
fontSize: 14,
@@ -56,9 +45,6 @@ export default function AuthLogsScreen() : React.ReactNode {
fontSize: 16,
fontWeight: '600',
},
header: {
paddingTop: 16,
},
headerText: {
color: colors.textMuted,
fontSize: 13,
@@ -188,9 +174,8 @@ export default function AuthLogsScreen() : React.ReactNode {
};
return (
<ThemedView style={styles.container}>
<ScrollView
contentContainerStyle={styles.contentContainer}
<ThemedContainer>
<ThemedScrollView
refreshControl={
<RefreshControl
refreshing={isRefreshing}
@@ -200,15 +185,13 @@ export default function AuthLogsScreen() : React.ReactNode {
/>
}
>
<View style={styles.header}>
<ThemedText style={styles.headerText}>
Below you can find an overview of recent login attempts to your account.
</ThemedText>
</View>
<ThemedText style={styles.headerText}>
Below you can find an overview of recent login attempts to your account.
</ThemedText>
<View style={styles.section}>
{renderContent()}
</View>
</ScrollView>
</ThemedView>
</ThemedScrollView>
</ThemedContainer>
);
}

View File

@@ -1,10 +1,8 @@
import { StyleSheet, View, Alert, ScrollView, KeyboardAvoidingView, Platform } from 'react-native';
import { StyleSheet, View, Alert, KeyboardAvoidingView, Platform } from 'react-native';
import { useState } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useColors } from '@/hooks/useColorScheme';
import { ThemedTextInput } from '@/components/themed/ThemedTextInput';
import { ThemedButton } from '@/components/themed/ThemedButton';
@@ -12,6 +10,8 @@ import { useAuth } from '@/context/AuthContext';
import { useVaultMutate } from '@/hooks/useVaultMutate';
import LoadingOverlay from '@/components/LoadingOverlay';
import { UsernameDisplay } from '@/components/ui/UsernameDisplay';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
/**
* Change password screen.
@@ -19,7 +19,6 @@ import { UsernameDisplay } from '@/components/ui/UsernameDisplay';
*/
export default function ChangePasswordScreen(): React.ReactNode {
const colors = useColors();
const insets = useSafeAreaInsets();
const authContext = useAuth();
const { executeVaultPasswordChange, syncStatus } = useVaultMutate();
@@ -33,28 +32,16 @@ export default function ChangePasswordScreen(): React.ReactNode {
button: {
marginTop: 8,
},
container: {
flex: 1,
marginTop: 42,
paddingBottom: insets.bottom,
paddingHorizontal: 14,
paddingTop: insets.top,
},
contentContainer: {
paddingBottom: 40,
},
form: {
backgroundColor: colors.accentBackground,
borderRadius: 10,
marginTop: 20,
padding: 16,
},
header: {
paddingTop: 16,
},
headerText: {
color: colors.textMuted,
fontSize: 13,
marginBottom: 16,
},
inputContainer: {
marginBottom: 16,
@@ -132,16 +119,11 @@ export default function ChangePasswordScreen(): React.ReactNode {
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardAvoidingView}
>
<ThemedView style={styles.container}>
<ScrollView
contentContainerStyle={styles.contentContainer}
keyboardShouldPersistTaps="handled"
>
<View style={styles.header}>
<ThemedText style={styles.headerText}>
<ThemedContainer>
<ThemedScrollView>
<ThemedText style={styles.headerText}>
Changing your master password also changes the vault encryption keys. It is advised to periodically change your master password to keep your vaults secure.
</ThemedText>
</View>
</ThemedText>
<UsernameDisplay />
<View style={styles.form}>
<View style={styles.inputContainer}>
@@ -181,8 +163,8 @@ export default function ChangePasswordScreen(): React.ReactNode {
style={styles.button}
/>
</View>
</ScrollView>
</ThemedView>
</ThemedScrollView>
</ThemedContainer>
</KeyboardAvoidingView>
</>
);

View File

@@ -1,11 +1,9 @@
import { StyleSheet, View, Alert, ScrollView, KeyboardAvoidingView, Platform } from 'react-native';
import { StyleSheet, View, Alert, KeyboardAvoidingView, Platform } from 'react-native';
import { router } from 'expo-router';
import { useState } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import srp from 'secure-remote-password/client';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useColors } from '@/hooks/useColorScheme';
import { ThemedTextInput } from '@/components/themed/ThemedTextInput';
import { ThemedButton } from '@/components/themed/ThemedButton';
@@ -15,13 +13,14 @@ import { DeleteAccountInitiateRequest, DeleteAccountInitiateResponse } from '@/u
import { DeleteAccountRequest } from '@/utils/types/webapi/DeleteAccountRequest';
import { UsernameDisplay } from '@/components/ui/UsernameDisplay';
import LoadingOverlay from '@/components/LoadingOverlay';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
/**
* Delete account screen.
*/
export default function DeleteAccountScreen(): React.ReactNode {
const colors = useColors();
const insets = useSafeAreaInsets();
const webApi = useWebApi();
const { username, verifyPassword, logout } = useAuth();
@@ -35,16 +34,6 @@ export default function DeleteAccountScreen(): React.ReactNode {
button: {
marginTop: 16,
},
container: {
flex: 1,
marginTop: 42,
paddingBottom: insets.bottom,
paddingHorizontal: 16,
paddingTop: insets.top,
},
contentContainer: {
paddingBottom: 40,
},
form: {
backgroundColor: colors.accentBackground,
borderColor: colors.accentBorder,
@@ -53,12 +42,10 @@ export default function DeleteAccountScreen(): React.ReactNode {
marginTop: 20,
padding: 20,
},
header: {
paddingTop: 16,
},
headerText: {
color: colors.textMuted,
fontSize: 13,
marginBottom: 16,
},
inputContainer: {
marginTop: 10,
@@ -238,16 +225,11 @@ export default function DeleteAccountScreen(): React.ReactNode {
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardAvoidingView}
>
<ThemedView style={styles.container}>
<ScrollView
contentContainerStyle={styles.contentContainer}
keyboardShouldPersistTaps="handled"
>
<View style={styles.header}>
<ThemedText style={styles.headerText}>
Deleting your account will immediately and permanently delete all of your data.
</ThemedText>
</View>
<ThemedContainer>
<ThemedScrollView>
<ThemedText style={styles.headerText}>
Deleting your account will immediately and permanently delete all of your data.
</ThemedText>
<UsernameDisplay />
<View style={styles.form}>
{step === 'username' ? (
@@ -297,8 +279,8 @@ export default function DeleteAccountScreen(): React.ReactNode {
</>
)}
</View>
</ScrollView>
</ThemedView>
</ThemedScrollView>
</ThemedContainer>
</KeyboardAvoidingView>
</>
);

Some files were not shown because too many files have changed in this diff Show More