diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinUnlockActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinUnlockActivity.kt index edf92082b..585c33561 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinUnlockActivity.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinUnlockActivity.kt @@ -446,6 +446,9 @@ class PinUnlockActivity : AppCompatActivity() { private suspend fun handlePinResult(result: PinResult) { when (result) { is PinResult.Success -> { + // Trigger success haptic feedback + triggerSuccessFeedback() + // Success - return result val resultIntent = Intent() result.encryptionKey?.let { @@ -532,6 +535,18 @@ class PinUnlockActivity : AppCompatActivity() { } } + private fun triggerSuccessFeedback() { + // Trigger haptic feedback for success + val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + // Use a lighter, shorter vibration for success (50ms) + vibrator?.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + vibrator?.vibrate(50) + } + } + private fun triggerErrorFeedback() { // Trigger haptic feedback for error val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator diff --git a/apps/mobile-app/app/(tabs)/items/add-edit.tsx b/apps/mobile-app/app/(tabs)/items/add-edit.tsx index d8cc0c2b7..28f171453 100644 --- a/apps/mobile-app/app/(tabs)/items/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/items/add-edit.tsx @@ -914,6 +914,11 @@ export default function AddEditItemScreen(): React.ReactNode { setIsSaving(false); setIsSaveDisabled(false); + // Haptic feedback for successful save + if (Platform.OS === 'ios' || Platform.OS === 'android') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + // Navigate immediately - sync continues in background if (itemUrl && !isEditMode) { router.replace('/items/autofill-item-created'); @@ -976,6 +981,11 @@ export default function AddEditItemScreen(): React.ReactNode { emitter.emit('credentialChanged', id); + // Haptic feedback for delete action (warning type for destructive action) + if (Platform.OS === 'ios' || Platform.OS === 'android') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + } + setTimeout(() => { Toast.show({ type: 'success', diff --git a/apps/mobile-app/app/unlock.tsx b/apps/mobile-app/app/unlock.tsx index 71154db63..1bc79d066 100644 --- a/apps/mobile-app/app/unlock.tsx +++ b/apps/mobile-app/app/unlock.tsx @@ -1,3 +1,4 @@ +import * as Haptics from 'expo-haptics'; import { LinearGradient } from 'expo-linear-gradient'; import { router } from 'expo-router'; import { useState, useEffect, useCallback } from 'react'; @@ -122,6 +123,11 @@ export default function UnlockScreen() : React.ReactNode { return; } + // Haptic feedback for successful unlock + if (Platform.OS === 'ios' || Platform.OS === 'android') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + router.replace('/reinitialize'); } catch (err) { if (err instanceof VaultVersionIncompatibleError) { @@ -132,6 +138,11 @@ export default function UnlockScreen() : React.ReactNode { console.error('Unlock error:', err); const errorCode = getAppErrorCode(err); + // Haptic feedback for authentication error + if (Platform.OS === 'ios' || Platform.OS === 'android') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + } + if (!errorCode || errorCode === AppErrorCode.VAULT_DECRYPT_FAILED) { setError(t('auth.errors.incorrectPassword')); } else { @@ -192,6 +203,11 @@ export default function UnlockScreen() : React.ReactNode { return; } + // Haptic feedback for successful unlock + if (Platform.OS === 'ios' || Platform.OS === 'android') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + /* * Navigate to reinitialize which will sync vault with server * and then navigate to the appropriate destination. @@ -207,6 +223,11 @@ export default function UnlockScreen() : React.ReactNode { // Try to extract error code from the error const errorCode = getAppErrorCode(err); + // Haptic feedback for authentication error + if (Platform.OS === 'ios' || Platform.OS === 'android') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + } + /* * During unlock, VAULT_DECRYPT_FAILED indicates wrong password. * This is thrown when decryption fails due to incorrect encryption key. @@ -240,6 +261,12 @@ export default function UnlockScreen() : React.ReactNode { router.replace('/upgrade'); return true; } + + // Haptic feedback for successful unlock + if (Platform.OS === 'ios' || Platform.OS === 'android') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + router.replace('/reinitialize'); return true; } @@ -272,6 +299,12 @@ export default function UnlockScreen() : React.ReactNode { router.replace('/upgrade'); return; } + + // Haptic feedback for successful unlock + if (Platform.OS === 'ios' || Platform.OS === 'android') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + router.replace('/reinitialize'); } catch (err) { console.error('Biometric retry error:', err); @@ -287,6 +320,11 @@ export default function UnlockScreen() : React.ReactNode { if (pinAvailable) { await performPinUnlock(); } else if (errorCode) { + // Haptic feedback for authentication error + if (Platform.OS === 'ios' || Platform.OS === 'android') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + } + // Show the error with code if no PIN fallback const translationKey = getErrorTranslationKey(errorCode); setError(formatErrorWithCode(t(translationKey), errorCode)); diff --git a/apps/mobile-app/components/folders/FolderModal.tsx b/apps/mobile-app/components/folders/FolderModal.tsx index 3edf75eb2..0d081004d 100644 --- a/apps/mobile-app/components/folders/FolderModal.tsx +++ b/apps/mobile-app/components/folders/FolderModal.tsx @@ -1,3 +1,4 @@ +import * as Haptics from 'expo-haptics'; import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -7,6 +8,7 @@ import { TouchableOpacity, View, ActivityIndicator, + Platform, } from 'react-native'; import { useColors } from '@/hooks/useColorScheme'; @@ -58,6 +60,12 @@ export const FolderModal: React.FC = ({ try { await onSave(trimmedName); + + // Haptic feedback for successful folder creation/edit + if (Platform.OS === 'ios' || Platform.OS === 'android') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + onClose(); } catch (err) { setError(t('common.errors.unknownErrorTryAgain')); diff --git a/apps/mobile-app/components/form/AdvancedPasswordField.tsx b/apps/mobile-app/components/form/AdvancedPasswordField.tsx index fd105fb82..4eb88511a 100644 --- a/apps/mobile-app/components/form/AdvancedPasswordField.tsx +++ b/apps/mobile-app/components/form/AdvancedPasswordField.tsx @@ -1,5 +1,6 @@ import { MaterialIcons } from '@expo/vector-icons'; import Slider from '@react-native-community/slider'; +import * as Haptics from 'expo-haptics'; import React, { forwardRef, useImperativeHandle, useMemo, useRef, useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { View, TextInput, TextInputProps, StyleSheet, TouchableOpacity, Platform, ScrollView, Switch } from 'react-native'; @@ -142,6 +143,11 @@ const AdvancedPasswordFieldComponent = forwardRef = ({ // Use centralized clipboard utility await copyToClipboardWithExpiration(value, timeoutSeconds); + // Haptic feedback for successful copy + if (Platform.OS === 'ios' || Platform.OS === 'android') { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + // Handle animation state if (timeoutSeconds > 0) { // Clear any existing active field and set this one as active diff --git a/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift b/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift index 30be4ae30..74a173971 100644 --- a/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift +++ b/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift @@ -282,6 +282,11 @@ public class PinUnlockViewModel: ObservableObject { // Call the injected unlock handler with the PIN // This will perform Argon2 key derivation which may take 500ms-1s try await unlockHandler(pin) + + // Success - trigger success haptic feedback + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + // Success - the handler will navigate away or complete the flow // Keep loading state active since we're navigating } catch let pinError as PinUnlockError {