Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38db3c5054 | ||
|
|
971a21a16a | ||
|
|
8058912eee | ||
|
|
8a9e1dc9a3 | ||
|
|
cde78650b9 | ||
|
|
4ef9e58665 | ||
|
|
b6b1d9dec9 | ||
|
|
fa2dedb05a | ||
|
|
f148ccdeba | ||
|
|
9b038cb76c | ||
|
|
aa726706a4 | ||
|
|
d0017d9207 | ||
|
|
cde4b87371 | ||
|
|
431d8d4fca | ||
|
|
9fddb5f450 | ||
|
|
dbb6cf5b94 | ||
|
|
bd41507ef9 | ||
|
|
ebb0e7cf68 | ||
|
|
4603051a91 | ||
|
|
f66fb53706 | ||
|
|
b603160d99 | ||
|
|
096b0277f3 | ||
|
|
f271040ff4 | ||
|
|
f313950112 | ||
|
|
ef1ad127e3 | ||
|
|
cac691a43d | ||
|
|
4efe201224 | ||
|
|
ca477c310c | ||
|
|
77189373ba | ||
|
|
1aaa5c2d55 | ||
|
|
163e5c51c2 | ||
|
|
29895f375f | ||
|
|
2803dcf02c | ||
|
|
a8e075d932 | ||
|
|
49ba704135 | ||
|
|
9669307480 | ||
|
|
343ced5b38 | ||
|
|
8f66670804 | ||
|
|
c2d1fcfcd4 | ||
|
|
e5a340b67d | ||
|
|
6a0e8909a8 | ||
|
|
5a90b4271c | ||
|
|
f0bd837d5e | ||
|
|
de45c286b1 | ||
|
|
fac0fd5f32 | ||
|
|
5a8b6b7f29 | ||
|
|
c864bfcab5 | ||
|
|
c9c692ce6e | ||
|
|
a640e4d280 | ||
|
|
2f03db7951 | ||
|
|
9e5b733c8a | ||
|
|
09c380afdd | ||
|
|
7d9cc6118e | ||
|
|
c7ab42e9f2 | ||
|
|
1b07c5de9f | ||
|
|
84df5b7d98 | ||
|
|
347721a575 | ||
|
|
463c31641d | ||
|
|
67759a814e | ||
|
|
763a859e22 | ||
|
|
d7db5a4e76 | ||
|
|
85bb5cf944 | ||
|
|
cdc59e43a9 | ||
|
|
9d0a003b2d | ||
|
|
e430ae9f4f | ||
|
|
41ba1260d7 | ||
|
|
c7572ac3f7 | ||
|
|
fe5c50b3c4 | ||
|
|
2a8ed28ff9 | ||
|
|
f6764b2f33 | ||
|
|
1afa153381 | ||
|
|
ac59273161 | ||
|
|
551fc42de1 | ||
|
|
4b844189bc | ||
|
|
5c277e747f | ||
|
|
8cbd275134 | ||
|
|
765625b163 | ||
|
|
b3df153128 | ||
|
|
604cffc622 | ||
|
|
3b114445a3 | ||
|
|
e8942c9833 | ||
|
|
b1da32ceae | ||
|
|
ef58217ed3 | ||
|
|
e0dd04263c | ||
|
|
29c52c844f | ||
|
|
b99025c48a | ||
|
|
8ba8eb684e | ||
|
|
b736edbb68 | ||
|
|
1fa0d275cc | ||
|
|
4a05cd00e3 | ||
|
|
574b5ff693 | ||
|
|
e6b7d1afa1 | ||
|
|
cbe224385d | ||
|
|
adb2f9a3d6 | ||
|
|
6790391d37 | ||
|
|
2a7855e1dc | ||
|
|
f3e47d7e67 | ||
|
|
bc76e85a9c | ||
|
|
890025cd49 | ||
|
|
1868370d8f | ||
|
|
9a4fc7fb37 | ||
|
|
199fdebd5d | ||
|
|
d5f17ef99c | ||
|
|
3b1e039d75 | ||
|
|
01cdd28e32 | ||
|
|
95a71f6ab2 | ||
|
|
41cb92befd | ||
|
|
2cfd1a922f | ||
|
|
f30fcf4624 | ||
|
|
522eeefda4 | ||
|
|
94656c4d14 | ||
|
|
bbba8d1393 | ||
|
|
680f5ba926 | ||
|
|
04d3f80019 | ||
|
|
a4d78cf7fc | ||
|
|
9713c8ed11 | ||
|
|
2f4dbf34ba | ||
|
|
232d110e49 | ||
|
|
0af1507686 | ||
|
|
e481769198 | ||
|
|
830c390b95 | ||
|
|
c733a60571 | ||
|
|
d164d8e785 | ||
|
|
826bd23767 | ||
|
|
a70f6fca56 | ||
|
|
1480fd88d1 | ||
|
|
11a5e10f4b | ||
|
|
eecf61b8b2 | ||
|
|
6c620e34e6 | ||
|
|
aa99bbc111 | ||
|
|
e34b5f586c | ||
|
|
80c0992eb4 |
229
.github/workflows/mobile-app-build.yml
vendored
@@ -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
|
||||
|
||||
67
.github/workflows/release.yml
vendored
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
41
apps/browser-extension/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = 17;
|
||||
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.17.3;
|
||||
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 = 17;
|
||||
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.17.3;
|
||||
MARKETING_VERSION = 0.18.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
manifest: {
|
||||
name: "AliasVault",
|
||||
description: "AliasVault Browser AutoFill Extension. Keeping your personal information private.",
|
||||
version: "0.17.3",
|
||||
version: "0.18.0",
|
||||
content_security_policy: {
|
||||
extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
|
||||
},
|
||||
|
||||
21
apps/mobile-app/android/.editorconfig
Normal 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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
19
apps/mobile-app/android/app/lint.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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('.')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package net.aliasvault.app.credentialmanager
|
||||
|
||||
data class Credential(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val service: String
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
@@ -1 +1,3 @@
|
||||
<resources/>
|
||||
<resources>
|
||||
<color name="splashscreen_background">#000000</color>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
60
apps/mobile-app/android/detekt.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
177
apps/mobile-app/app/(tabs)/settings/android-autofill.tsx
Normal 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 "autofill preferred service" to "AliasVault"
|
||||
</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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,37 +1,20 @@
|
||||
import { StyleSheet, View, TouchableOpacity, Animated, ScrollView } from 'react-native';
|
||||
import { StyleSheet, View, TouchableOpacity } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRef } from 'react';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { ThemedText } from '@/components/themed/ThemedText';
|
||||
import { ThemedView } from '@/components/themed/ThemedView';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { SettingsHeader } from '@/components/ui/SettingsHeader';
|
||||
import { ThemedContainer } from '@/components/themed/ThemedContainer';
|
||||
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
|
||||
|
||||
/**
|
||||
* Security settings screen.
|
||||
*/
|
||||
export default function SecuritySettingsScreen() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
const insets = useSafeAreaInsets();
|
||||
const scrollY = useRef(new Animated.Value(0)).current;
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: insets.top,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
paddingTop: 42,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 10,
|
||||
@@ -71,18 +54,8 @@ export default function SecuritySettingsScreen() : React.ReactNode {
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<Animated.ScrollView
|
||||
ref={scrollViewRef}
|
||||
onScroll={Animated.event(
|
||||
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
|
||||
{ useNativeDriver: true }
|
||||
)}
|
||||
scrollEventThrottle={16}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
scrollIndicatorInsets={{ bottom: 40 }}
|
||||
style={styles.scrollView}
|
||||
>
|
||||
<ThemedContainer>
|
||||
<ThemedScrollView>
|
||||
<SettingsHeader title="Security" description="Manage your account and vault security settings." icon="shield-checkmark" />
|
||||
<View style={styles.section}>
|
||||
<TouchableOpacity
|
||||
@@ -140,7 +113,7 @@ export default function SecuritySettingsScreen() : React.ReactNode {
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
</ThemedView>
|
||||
</ThemedScrollView>
|
||||
</ThemedContainer>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { ThemedText } from '@/components/themed/ThemedText';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { AuthMethod, useAuth } from '@/context/AuthContext';
|
||||
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
|
||||
import { ThemedView } from '@/components/themed/ThemedView';
|
||||
import { ThemedContainer } from '@/components/themed/ThemedContainer';
|
||||
|
||||
/**
|
||||
* Vault unlock settings screen.
|
||||
@@ -16,9 +16,9 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const { setAuthMethods, getEnabledAuthMethods, getBiometricDisplayName } = useAuth();
|
||||
const [hasFaceID, setHasFaceID] = useState(false);
|
||||
const [isFaceIDEnabled, setIsFaceIDEnabled] = useState(false);
|
||||
const [biometricDisplayName, setBiometricDisplayName] = useState('Face ID / Touch ID');
|
||||
const [hasBiometrics, setHasBiometrics] = useState(false);
|
||||
const [isBiometricsEnabled, setIsBiometricsEnabled] = useState(false);
|
||||
const [biometricDisplayName, setBiometricDisplayName] = useState(Platform.OS === 'ios' ? 'Face ID / Touch ID' : 'Biometrics');
|
||||
const [_, setEnabledAuthMethods] = useState<AuthMethod[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -26,21 +26,34 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
|
||||
* Initialize the auth methods.
|
||||
*/
|
||||
const initializeAuth = async () : Promise<void> => {
|
||||
const compatible = await LocalAuthentication.hasHardwareAsync();
|
||||
const enrolled = await LocalAuthentication.isEnrolledAsync();
|
||||
setHasFaceID(compatible && enrolled);
|
||||
try {
|
||||
// Check for hardware support
|
||||
const compatible = await LocalAuthentication.hasHardwareAsync();
|
||||
|
||||
const displayName = await getBiometricDisplayName();
|
||||
setBiometricDisplayName(displayName);
|
||||
// Check if any biometrics are enrolled
|
||||
const enrolled = await LocalAuthentication.isEnrolledAsync();
|
||||
|
||||
const methods = await getEnabledAuthMethods();
|
||||
setEnabledAuthMethods(methods);
|
||||
// Set biometric availability based on all checks
|
||||
const isBiometricAvailable = compatible && enrolled;
|
||||
setHasBiometrics(isBiometricAvailable);
|
||||
|
||||
if (methods.includes('faceid') && enrolled) {
|
||||
setIsFaceIDEnabled(true);
|
||||
// Get appropriate display name
|
||||
const displayName = Platform.OS === 'ios' ? await getBiometricDisplayName() : 'Biometrics';
|
||||
setBiometricDisplayName(displayName);
|
||||
|
||||
const methods = await getEnabledAuthMethods();
|
||||
setEnabledAuthMethods(methods);
|
||||
|
||||
if (methods.includes('faceid') && enrolled) {
|
||||
setIsBiometricsEnabled(true);
|
||||
}
|
||||
|
||||
setInitialized(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
setHasBiometrics(false);
|
||||
setInitialized(true);
|
||||
}
|
||||
|
||||
setInitialized(true);
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
@@ -56,7 +69,7 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
|
||||
*/
|
||||
const updateAuthMethods = async () : Promise<void> => {
|
||||
const currentAuthMethods = await getEnabledAuthMethods();
|
||||
const newAuthMethods = isFaceIDEnabled ? ['faceid', 'password'] : ['password'];
|
||||
const newAuthMethods = isBiometricsEnabled ? ['faceid', 'password'] : ['password'];
|
||||
|
||||
if (currentAuthMethods.length === newAuthMethods.length &&
|
||||
currentAuthMethods.every(method => newAuthMethods.includes(method))) {
|
||||
@@ -67,13 +80,13 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
|
||||
};
|
||||
|
||||
updateAuthMethods();
|
||||
}, [isFaceIDEnabled, setAuthMethods, getEnabledAuthMethods, initialized]);
|
||||
}, [isBiometricsEnabled, setAuthMethods, getEnabledAuthMethods, initialized]);
|
||||
|
||||
const handleFaceIDToggle = useCallback(async (value: boolean) : Promise<void> => {
|
||||
if (value && !hasFaceID) {
|
||||
const handleBiometricsToggle = useCallback(async (value: boolean) : Promise<void> => {
|
||||
if (value && !hasBiometrics) {
|
||||
Alert.alert(
|
||||
'Face ID Not Available',
|
||||
'Face ID is disabled for AliasVault. In order to use it, please enable it in the iOS app settings first.',
|
||||
`${biometricDisplayName} Not Available`,
|
||||
`${biometricDisplayName} is disabled for AliasVault. In order to use it, please enable it in your device settings first.`,
|
||||
[
|
||||
{
|
||||
text: 'Open Settings',
|
||||
@@ -81,10 +94,12 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
|
||||
* Handle the open settings press.
|
||||
*/
|
||||
onPress: () : void => {
|
||||
setIsFaceIDEnabled(true);
|
||||
setIsBiometricsEnabled(true);
|
||||
setAuthMethods(['faceid', 'password']);
|
||||
if (Platform.OS === 'ios') {
|
||||
Linking.openURL('app-settings:');
|
||||
} else {
|
||||
Linking.openSettings();
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -95,7 +110,7 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
|
||||
* Handle the cancel press.
|
||||
*/
|
||||
onPress: () : void => {
|
||||
setIsFaceIDEnabled(false);
|
||||
setIsBiometricsEnabled(false);
|
||||
setAuthMethods(['password']);
|
||||
},
|
||||
},
|
||||
@@ -104,32 +119,25 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFaceIDEnabled(value);
|
||||
setIsBiometricsEnabled(value);
|
||||
setAuthMethods(value ? ['faceid', 'password'] : ['password']);
|
||||
|
||||
// Show toast notification only on Face ID enabled
|
||||
// Show toast notification only on biometrics enabled
|
||||
if (value) {
|
||||
Toast.show({
|
||||
type: 'success',
|
||||
text1: 'Face ID is now successfully enabled',
|
||||
text1: `${biometricDisplayName} is now successfully enabled`,
|
||||
position: 'bottom',
|
||||
visibilityTime: 1200,
|
||||
});
|
||||
}
|
||||
}, [hasFaceID, setAuthMethods]);
|
||||
}, [hasBiometrics, setAuthMethods, biometricDisplayName]);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
disabledText: {
|
||||
color: colors.textMuted,
|
||||
opacity: 0.5,
|
||||
},
|
||||
header: {
|
||||
padding: 16,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
headerText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
@@ -148,7 +156,7 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
|
||||
optionContainer: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 10,
|
||||
margin: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
optionHeader: {
|
||||
alignItems: 'center',
|
||||
@@ -166,36 +174,34 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedContainer>
|
||||
<ThemedScrollView>
|
||||
<View style={styles.header}>
|
||||
<ThemedText style={styles.headerText}>
|
||||
Choose how you want to unlock your vault.
|
||||
</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.headerText}>
|
||||
Choose how you want to unlock your vault.
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.optionContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.option}
|
||||
onPress={() => handleFaceIDToggle(!isFaceIDEnabled)}
|
||||
onPress={() => handleBiometricsToggle(!isBiometricsEnabled)}
|
||||
>
|
||||
<View style={styles.optionHeader}>
|
||||
<ThemedText style={[styles.optionText, !hasFaceID && styles.disabledText]}>
|
||||
<ThemedText style={[styles.optionText, !hasBiometrics && styles.disabledText]}>
|
||||
{biometricDisplayName}
|
||||
</ThemedText>
|
||||
<View pointerEvents="none">
|
||||
<Switch
|
||||
value={isFaceIDEnabled}
|
||||
disabled={!hasFaceID}
|
||||
value={isBiometricsEnabled}
|
||||
disabled={!hasBiometrics}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<ThemedText style={styles.helpText}>
|
||||
Your vault decryption key will be securely stored on your local device in the iOS Keychain and can be accessed securely with {biometricDisplayName}.
|
||||
Your vault decryption key will be securely stored on your local device in the {Platform.OS === 'ios' ? 'iOS Keychain' : 'Android Keystore'} and can be accessed securely with {biometricDisplayName}.
|
||||
</ThemedText>
|
||||
{!hasFaceID && (
|
||||
{!hasBiometrics && (
|
||||
<ThemedText style={[styles.helpText, { color: colors.errorBorder }]}>
|
||||
{biometricDisplayName} is blocked in iOS settings. Tap to open settings and enable it.
|
||||
{biometricDisplayName} is blocked in device settings. Tap to open settings and enable it.
|
||||
</ThemedText>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -214,6 +220,6 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
|
||||
</View>
|
||||
</View>
|
||||
</ThemedScrollView>
|
||||
</ThemedView>
|
||||
</ThemedContainer>
|
||||
);
|
||||
}
|
||||