* 'main' of https://github.com/lanedirt/AliasVault:
  Style refactor (#1157)
  Update ClipboardUtility.ts (#1157)
  Add missing translation (#1157)
  Add stubs for new NativeVaultManager spec for iOS (#1157)
  Add ignore battery optimization check for Android clipboard clear (#1157)
  Update native vault manager package namespace (#1157)
  Add android precise alarm timing implementation for clipboard clear (#1157)
  Implement native iOS clipboard clear after delay (#1157)
This commit is contained in:
Leendert de Borst
2025-08-29 20:39:10 +02:00
14 changed files with 549 additions and 44 deletions

View File

@@ -3,6 +3,8 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
@@ -35,5 +37,6 @@
<data android:scheme="net.aliasvault.app"/>
</intent-filter>
</activity>
<receiver android:name=".nativevaultmanager.ClipboardClearReceiver" android:exported="false" />
</application>
</manifest>

View File

@@ -0,0 +1,30 @@
package net.aliasvault.app.nativevaultmanager
import android.content.BroadcastReceiver
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.util.Log
/**
* BroadcastReceiver to clear the clipboard when triggered by AlarmManager.
* This ensures clipboard clearing works even when the app is in the background.
*/
class ClipboardClearReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "ClipboardClearReceiver"
}
override fun onReceive(context: Context, intent: Intent) {
try {
Log.d(TAG, "Received broadcast to clear clipboard")
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.clearPrimaryClip()
Log.d(TAG, "Clipboard cleared successfully from broadcast receiver")
} catch (e: Exception) {
Log.e(TAG, "Error clearing clipboard from broadcast receiver", e)
}
}
}

View File

@@ -1,11 +1,12 @@
package net.aliasvault.app.nativevaultmanager
import android.content.Intent
import android.net.Uri
import android.os.PowerManager
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
@@ -18,6 +19,7 @@ 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 net.aliasvault.nativevaultmanager.NativeVaultManagerSpec
import org.json.JSONArray
/**
@@ -498,23 +500,262 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
return
}
val handler = android.os.Handler(android.os.Looper.getMainLooper())
val delayMs = (delayInSeconds * 1000).toLong()
// Use AlarmManager to ensure execution even if app is backgrounded
try {
val alarmManager = reactApplicationContext.getSystemService(android.content.Context.ALARM_SERVICE) as android.app.AlarmManager
handler.postDelayed({
try {
Log.d(TAG, "Clearing clipboard after $delayInSeconds seconds delay")
val clipboardManager = reactApplicationContext.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
clipboardManager.clearPrimaryClip()
Log.d(TAG, "Clipboard cleared successfully")
} catch (e: Exception) {
Log.e(TAG, "Error clearing clipboard", e)
// Check if we can schedule exact alarms (Android 12+/API 31+)
val canScheduleExactAlarms = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
alarmManager.canScheduleExactAlarms()
} else {
true // Pre-Android 12 doesn't require permission
}
}, delayMs)
if (!canScheduleExactAlarms) {
Log.w(TAG, "Cannot schedule exact alarms - permission denied. Falling back to Handler.")
throw SecurityException("Exact alarm permission not granted")
}
val intent = Intent(reactApplicationContext, ClipboardClearReceiver::class.java)
val pendingIntent = android.app.PendingIntent.getBroadcast(
reactApplicationContext,
0,
intent,
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE,
)
// Cancel any existing alarm
alarmManager.cancel(pendingIntent)
// Set new alarm
val triggerTime = System.currentTimeMillis() + (delayInSeconds * 1000).toLong()
try {
alarmManager.setExactAndAllowWhileIdle(
android.app.AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent,
)
Log.d(TAG, "Scheduled clipboard clear using AlarmManager for $delayInSeconds seconds")
} catch (securityException: SecurityException) {
Log.w(TAG, "SecurityException when scheduling exact alarm: ${securityException.message}")
throw securityException
}
} catch (e: Exception) {
when (e) {
is SecurityException -> {
Log.w(TAG, "Exact alarm permission denied. Using inexact alarm fallback.")
// Try inexact alarm as fallback
try {
val alarmManager = reactApplicationContext.getSystemService(android.content.Context.ALARM_SERVICE) as android.app.AlarmManager
val intent = Intent(reactApplicationContext, ClipboardClearReceiver::class.java)
val pendingIntent = android.app.PendingIntent.getBroadcast(
reactApplicationContext,
0,
intent,
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE,
)
val triggerTime = System.currentTimeMillis() + (delayInSeconds * 1000).toLong()
alarmManager.set(android.app.AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
Log.d(TAG, "Scheduled inexact clipboard clear using AlarmManager for ~$delayInSeconds seconds")
} catch (fallbackException: Exception) {
Log.e(TAG, "Fallback inexact alarm also failed, using Handler: ${fallbackException.message}")
useHandlerFallback(delayInSeconds)
}
}
else -> {
Log.e(TAG, "Error scheduling clipboard clear with AlarmManager, using Handler: ${e.message}")
useHandlerFallback(delayInSeconds)
}
}
}
promise?.resolve(null)
}
/**
* Fallback method to clear clipboard using Handler when AlarmManager fails.
*/
private fun useHandlerFallback(delayInSeconds: Double) {
val handler = android.os.Handler(android.os.Looper.getMainLooper())
val delayMs = (delayInSeconds * 1000).toLong()
handler.postDelayed({
try {
val clipboardManager = reactApplicationContext.getSystemService(
android.content.Context.CLIPBOARD_SERVICE,
) as android.content.ClipboardManager
clipboardManager.clearPrimaryClip()
Log.d(TAG, "Clipboard cleared using Handler fallback after $delayInSeconds seconds")
} catch (e: Exception) {
Log.e(TAG, "Error clearing clipboard with Handler fallback", e)
}
}, delayMs)
}
/**
* Copy text to clipboard with automatic expiration.
* @param text The text to copy to clipboard
* @param expirationSeconds The number of seconds after which to clear the clipboard
* @param promise The promise to resolve
*/
@ReactMethod
override fun copyToClipboardWithExpiration(text: String, expirationSeconds: Double, promise: Promise?) {
try {
Log.d(TAG, "Copying to clipboard with expiration of $expirationSeconds seconds")
val clipboardManager = reactApplicationContext.getSystemService(
android.content.Context.CLIPBOARD_SERVICE,
) as android.content.ClipboardManager
val clip = android.content.ClipData.newPlainText("AliasVault", text)
// Android 13+ handling
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU && expirationSeconds > 0) {
// Mark as sensitive to prevent preview display
val persistableBundle = android.os.PersistableBundle()
persistableBundle.putBoolean(android.content.ClipDescription.EXTRA_IS_SENSITIVE, true)
clip.description.extras = persistableBundle
// Android 13+ automatically clears clipboard after 1 hour (3600 seconds)
val androidAutoClearSeconds = 3600.0
if (expirationSeconds <= androidAutoClearSeconds) {
// For shorter delays, we still need manual clearing for precision
Log.d(TAG, "Using manual clearing for $expirationSeconds seconds (Android 13+ with sensitive flag)")
clipboardManager.setPrimaryClip(clip)
clearClipboardAfterDelay(expirationSeconds, null)
} else {
// For longer delays, rely on Android's automatic clearing
Log.d(TAG, "Relying on Android 13+ automatic clipboard clearing (${androidAutoClearSeconds}s)")
clipboardManager.setPrimaryClip(clip)
// No manual clearing needed - Android will handle it
}
} else {
// Pre-Android 13 or no expiration
clipboardManager.setPrimaryClip(clip)
if (expirationSeconds > 0) {
Log.d(TAG, "Using manual clearing for $expirationSeconds seconds (pre-Android 13)")
clearClipboardAfterDelay(expirationSeconds, null)
}
}
Log.d(TAG, "Text copied to clipboard successfully")
promise?.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error copying to clipboard", e)
promise?.reject("ERR_CLIPBOARD", "Failed to copy to clipboard: ${e.message}", e)
}
}
/**
* Check if the app can schedule exact alarms.
* @param promise The promise to resolve with boolean result
*/
@ReactMethod
override fun canScheduleExactAlarms(promise: Promise?) {
try {
val canSchedule = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
val alarmManager = reactApplicationContext.getSystemService(android.content.Context.ALARM_SERVICE) as android.app.AlarmManager
alarmManager.canScheduleExactAlarms()
} else {
true // Pre-Android 12 doesn't require permission
}
Log.d(TAG, "Can schedule exact alarms: $canSchedule")
promise?.resolve(canSchedule)
} catch (e: Exception) {
Log.e(TAG, "Error checking exact alarm permission", e)
promise?.reject("ERR_EXACT_ALARM_CHECK", "Failed to check exact alarm permission: ${e.message}", e)
}
}
/**
* Request exact alarm permission by opening system settings.
* @param promise The promise to resolve
*/
@ReactMethod
override fun requestExactAlarmPermission(promise: Promise?) {
try {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
val alarmManager = reactApplicationContext.getSystemService(android.content.Context.ALARM_SERVICE) as android.app.AlarmManager
if (!alarmManager.canScheduleExactAlarms()) {
Log.d(TAG, "Requesting exact alarm permission via system settings")
val intent = Intent().apply {
action = Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
data = Uri.parse("package:${reactApplicationContext.packageName}")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
reactApplicationContext.startActivity(intent)
promise?.resolve("Permission request sent - user will be taken to settings")
} else {
Log.d(TAG, "Exact alarm permission already granted")
promise?.resolve("Permission already granted")
}
} else {
Log.d(TAG, "Exact alarm permission not required on this Android version")
promise?.resolve("Permission not required on this Android version")
}
} catch (e: Exception) {
Log.e(TAG, "Error requesting exact alarm permission", e)
promise?.reject("ERR_EXACT_ALARM_REQUEST", "Failed to request exact alarm permission: ${e.message}", e)
}
}
/**
* Check if the app is ignoring battery optimizations.
* @param promise The promise to resolve with boolean result
*/
@ReactMethod
override fun isIgnoringBatteryOptimizations(promise: Promise?) {
try {
val isIgnoring = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
val powerManager = reactApplicationContext.getSystemService(android.content.Context.POWER_SERVICE) as PowerManager
powerManager.isIgnoringBatteryOptimizations(reactApplicationContext.packageName)
} else {
true // Pre-Android 6.0 doesn't have battery optimization
}
Log.d(TAG, "Is ignoring battery optimizations: $isIgnoring")
promise?.resolve(isIgnoring)
} catch (e: Exception) {
Log.e(TAG, "Error checking battery optimization status", e)
promise?.reject("ERR_BATTERY_OPTIMIZATION_CHECK", "Failed to check battery optimization status: ${e.message}", e)
}
}
/**
* Request battery optimization exemption by opening system settings.
* @param promise The promise to resolve
*/
@ReactMethod
override fun requestIgnoreBatteryOptimizations(promise: Promise?) {
try {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
val powerManager = reactApplicationContext.getSystemService(android.content.Context.POWER_SERVICE) as PowerManager
if (!powerManager.isIgnoringBatteryOptimizations(reactApplicationContext.packageName)) {
Log.d(TAG, "Requesting battery optimization exemption via system settings")
val intent = Intent().apply {
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${reactApplicationContext.packageName}")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
reactApplicationContext.startActivity(intent)
promise?.resolve("Battery optimization exemption request sent - user will be taken to settings")
} else {
Log.d(TAG, "App is already ignoring battery optimizations")
promise?.resolve("App is already ignoring battery optimizations")
}
} else {
Log.d(TAG, "Battery optimization not applicable on this Android version")
promise?.resolve("Battery optimization not applicable on this Android version")
}
} catch (e: Exception) {
Log.e(TAG, "Error requesting battery optimization exemption", e)
promise?.reject("ERR_BATTERY_OPTIMIZATION_REQUEST", "Failed to request battery optimization exemption: ${e.message}", e)
}
}
/**
* Get the auth methods.
* @param promise The promise to resolve

View File

@@ -1,6 +1,6 @@
import { Ionicons } from '@expo/vector-icons';
import { useState, useEffect } from 'react';
import { StyleSheet, View, TouchableOpacity, Platform } from 'react-native';
import { useState, useEffect, useRef } from 'react';
import { StyleSheet, View, TouchableOpacity, Platform, AppState } from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
import { useTranslation } from '@/hooks/useTranslation';
@@ -9,6 +9,7 @@ import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
import { ThemedText } from '@/components/themed/ThemedText';
import { useAuth } from '@/context/AuthContext';
import NativeVaultManager from '@/specs/NativeVaultManager';
const TIMEOUT_OPTIONS = [
{ value: 0, label: 'settings.clipboardClearOptions.never' },
@@ -26,6 +27,8 @@ export default function ClipboardClearScreen(): React.ReactNode {
const { t } = useTranslation();
const { getClipboardClearTimeout, setClipboardClearTimeout } = useAuth();
const [selectedTimeout, setSelectedTimeout] = useState<number>(10);
const [isIgnoringBatteryOptimizations, setIsIgnoringBatteryOptimizations] = useState<boolean>(true);
const appState = useRef(AppState.currentState);
useEffect(() => {
/**
@@ -36,7 +39,39 @@ export default function ClipboardClearScreen(): React.ReactNode {
setSelectedTimeout(timeout);
};
/**
* Check battery optimization exemption status on Android.
*/
const checkBatteryOptimization = async (): Promise<void> => {
if (Platform.OS === 'android') {
try {
const isIgnoring = await NativeVaultManager.isIgnoringBatteryOptimizations();
setIsIgnoringBatteryOptimizations(isIgnoring);
} catch (error) {
console.error('Error checking battery optimization status:', error);
// Default to true to avoid showing the help section unnecessarily
setIsIgnoringBatteryOptimizations(true);
}
}
};
loadCurrentTimeout();
checkBatteryOptimization();
// Listen for app state changes to re-check permission when app comes to foreground
const subscription = AppState.addEventListener('change', async (nextAppState) => {
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
// App coming to foreground, re-check battery optimization
if (Platform.OS === 'android') {
await checkBatteryOptimization();
}
}
appState.current = nextAppState;
});
return (): void => {
subscription.remove();
};
}, [getClipboardClearTimeout]);
/**
@@ -47,11 +82,58 @@ export default function ClipboardClearScreen(): React.ReactNode {
setSelectedTimeout(timeout);
};
/**
* Handle battery optimization exemption request.
*/
const handleRequestBatteryOptimizationExemption = async (): Promise<void> => {
if (Platform.OS !== 'android') {
return;
}
try {
await NativeVaultManager.requestIgnoreBatteryOptimizations();
} catch (error) {
console.error('Error handling battery optimization exemption request:', error);
}
};
const styles = StyleSheet.create({
headerText: {
color: colors.textMuted,
fontSize: 13,
},
helpButton: {
alignItems: 'center',
backgroundColor: colors.primary,
borderRadius: 8,
flexDirection: 'row',
justifyContent: 'center',
paddingVertical: 12,
},
helpButtonText: {
color: colors.background,
fontSize: 14,
fontWeight: '600',
marginLeft: 8,
},
helpContainer: {
backgroundColor: colors.accentBackground,
borderRadius: 10,
marginTop: 16,
padding: 16,
},
helpDescription: {
color: colors.textMuted,
fontSize: 13,
lineHeight: 20,
marginBottom: 12,
},
helpTitle: {
color: colors.text,
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
option: {
alignItems: 'center',
borderBottomColor: colors.accentBorder,
@@ -73,6 +155,20 @@ export default function ClipboardClearScreen(): React.ReactNode {
flex: 1,
fontSize: 16,
},
permissionDenied: {
color: colors.destructive || '#EF4444',
},
permissionGranted: {
color: colors.success || '#10B981',
},
permissionStatusContainer: {
alignItems: 'center',
flexDirection: 'row',
},
permissionStatusText: {
fontSize: 13,
marginLeft: 8,
},
selectedIcon: {
color: colors.primary,
marginLeft: 8,
@@ -117,6 +213,49 @@ export default function ClipboardClearScreen(): React.ReactNode {
</ThemedText>
</View>
)}
{Platform.OS === 'android' && !isIgnoringBatteryOptimizations && selectedTimeout > 0 && (
<View style={styles.helpContainer}>
<ThemedText style={styles.helpTitle}>
{t('settings.batteryOptimizationHelpTitle')}
</ThemedText>
<View style={styles.permissionStatusContainer}>
<Ionicons
name="warning"
size={16}
color={colors.destructive || '#EF4444'}
/>
<ThemedText style={[styles.permissionStatusText, styles.permissionDenied]}>
{t('settings.batteryOptimizationActive')}
</ThemedText>
</View>
<ThemedText style={styles.helpDescription}>
{t('settings.batteryOptimizationHelpDescription')}
</ThemedText>
<TouchableOpacity
style={styles.helpButton}
onPress={handleRequestBatteryOptimizationExemption}
>
<Ionicons name="battery-charging" size={16} color={colors.background} />
<ThemedText style={styles.helpButtonText}>
{t('settings.disableBatteryOptimization')}
</ThemedText>
</TouchableOpacity>
</View>
)}
{Platform.OS === 'android' && isIgnoringBatteryOptimizations && selectedTimeout > 0 && (
<View style={styles.helpContainer}>
<View style={styles.permissionStatusContainer}>
<Ionicons
name="checkmark-circle"
size={16}
color={colors.success || '#10B981'}
/>
<ThemedText style={[styles.permissionStatusText, styles.permissionGranted]}>
{t('settings.batteryOptimizationDisabled')}
</ThemedText>
</View>
</View>
)}
</ThemedScrollView>
</ThemedContainer>
);

View File

@@ -1,17 +1,16 @@
import * as Clipboard from 'expo-clipboard';
import { router } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { StyleSheet, View, Text, TouchableOpacity, Keyboard, Platform, Alert } from 'react-native';
import ContextMenu, { OnPressMenuItemEvent } from 'react-native-context-menu-view';
import Toast from 'react-native-toast-message';
import { copyToClipboardWithExpiration } from '@/utils/ClipboardUtility';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { useColors } from '@/hooks/useColorScheme';
import { CredentialIcon } from '@/components/credentials/CredentialIcon';
import { useAuth } from '@/context/AuthContext';
import NativeVaultManager from '@/specs/NativeVaultManager';
type CredentialCardProps = {
credential: Credential;
@@ -66,15 +65,11 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar
*/
const copyToClipboard = async (text: string): Promise<void> => {
try {
await Clipboard.setStringAsync(text);
// Get clipboard clear timeout from settings
const timeoutSeconds = await getClipboardClearTimeout();
// Schedule clipboard clear if timeout is set
if (timeoutSeconds > 0) {
await NativeVaultManager.clearClipboardAfterDelay(timeoutSeconds);
}
// Use centralized clipboard utility
await copyToClipboardWithExpiration(text, timeoutSeconds);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}

View File

@@ -1,10 +1,10 @@
import * as Clipboard from 'expo-clipboard';
import * as OTPAuth from 'otpauth';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { View, StyleSheet, TouchableOpacity, Platform } from 'react-native';
import Toast from 'react-native-toast-message';
import { copyToClipboardWithExpiration } from '@/utils/ClipboardUtility';
import type { Credential, TotpCode } from '@/utils/dist/shared/models/vault';
import { useColors } from '@/hooks/useColorScheme';
@@ -13,7 +13,6 @@ import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useAuth } from '@/context/AuthContext';
import { useDb } from '@/context/DbContext';
import NativeVaultManager from '@/specs/NativeVaultManager';
type TotpSectionProps = {
credential: Credential;
@@ -74,15 +73,11 @@ export const TotpSection: React.FC<TotpSectionProps> = ({ credential }) : React.
*/
const copyToClipboardWithClear = async (code: string): Promise<void> => {
try {
await Clipboard.setStringAsync(code);
// Get clipboard clear timeout from settings
const timeoutSeconds = await getClipboardClearTimeout();
// Schedule clipboard clear if timeout is set
if (timeoutSeconds > 0) {
await NativeVaultManager.clearClipboardAfterDelay(timeoutSeconds);
}
// Use centralized clipboard utility
await copyToClipboardWithExpiration(code, timeoutSeconds);
if (Platform.OS !== 'android') {
// Only show toast on iOS, Android already shows a native toast on clipboard interactions.

View File

@@ -1,15 +1,15 @@
import { MaterialIcons } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard';
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { View, Text, TouchableOpacity, StyleSheet, Platform, Animated, Easing } from 'react-native';
import Toast from 'react-native-toast-message';
import { copyToClipboardWithExpiration } from '@/utils/ClipboardUtility';
import { useColors } from '@/hooks/useColorScheme';
import { useAuth } from '@/context/AuthContext';
import { useClipboardCountdown } from '@/context/ClipboardCountdownContext';
import NativeVaultManager from '@/specs/NativeVaultManager';
type FormInputCopyToClipboardProps = {
label: string;
@@ -30,7 +30,7 @@ const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> = ({
const { t } = useTranslation();
const { getClipboardClearTimeout } = useAuth();
const { activeFieldId, setActiveField } = useClipboardCountdown();
const animatedWidth = useRef(new Animated.Value(0)).current;
// Create a stable unique ID based on label and value
const fieldId = useRef(`${label}-${value}-${Math.random().toString(36).substring(2, 11)}`).current;
@@ -49,7 +49,7 @@ const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> = ({
// This field is now active - reset and start animation
animatedWidth.stopAnimation();
animatedWidth.setValue(100);
// Get timeout and start animation
getClipboardClearTimeout().then((timeoutSeconds) => {
if (timeoutSeconds > 0 && activeFieldId === fieldId) {
@@ -78,20 +78,17 @@ const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> = ({
const copyToClipboard = async () : Promise<void> => {
if (value) {
try {
// Copy to clipboard using expo-clipboard
await Clipboard.setStringAsync(value);
// Get clipboard clear timeout from settings
const timeoutSeconds = await getClipboardClearTimeout();
// Schedule clipboard clear if timeout is set
// Use centralized clipboard utility
await copyToClipboardWithExpiration(value, timeoutSeconds);
// Handle animation state
if (timeoutSeconds > 0) {
// Clear any existing active field first (this will cancel its animation)
setActiveField(null);
// Schedule the clipboard clear
await NativeVaultManager.clearClipboardAfterDelay(timeoutSeconds);
/*
* Now set this field as active - animation will be handled by the effect
* Use setTimeout to ensure state update happens in next tick

View File

@@ -31,6 +31,8 @@ export const Colors = {
red: '#ff0000',
black: '#000000',
modalBackground: 'rgba(0, 0, 0, 0.5)',
success: '#10B981',
destructive: '#EF4444',
},
dark: {
white: '#ffffff',
@@ -60,6 +62,8 @@ export const Colors = {
red: '#ff0000',
black: '#000000',
modalBackground: 'rgba(88, 88, 88, 0.5)',
success: '#10B981',
destructive: '#EF4444',
},
} as const;

View File

@@ -155,7 +155,8 @@
"deleteAttachment": "Delete",
"toasts": {
"credentialUpdated": "Credential updated successfully",
"credentialCreated": "Credential created successfully"
"credentialCreated": "Credential created successfully",
"credentialDeleted": "Credential deleted successfully"
},
"createNewAliasFor": "Create new alias for",
"errors": {
@@ -213,6 +214,11 @@
"15seconds": "15 seconds",
"30seconds": "30 seconds"
},
"batteryOptimizationHelpTitle": "Enable Background Clipboard Clearing",
"batteryOptimizationActive": "Battery optimization is blocking background tasks",
"batteryOptimizationDisabled": "Background clipboard clearing enabled",
"batteryOptimizationHelpDescription": "Android's battery optimization prevents reliable clipboard clearing when the app is in the background. Disabling battery optimization for AliasVault allows precise background clipboard clearing and automatically grants necessary alarm permissions.",
"disableBatteryOptimization": "Disable battery optimization",
"identityGenerator": "Identity Generator",
"security": "Security",
"appVersion": "App version {{version}} ({{url}})",

View File

@@ -137,4 +137,30 @@
[vaultManager openAutofillSettingsPage:resolve rejecter:reject];
}
- (void)copyToClipboardWithExpiration:(NSString *)text expirationSeconds:(double)expirationSeconds resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager copyToClipboardWithExpiration:text expirationSeconds:expirationSeconds resolver:resolve rejecter:reject];
}
// MARK: - Android-specific methods (stubs for iOS)
- (void)canScheduleExactAlarms:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
// Only used by Android, return true.
resolve(@(YES));
}
- (void)requestExactAlarmPermission:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
// Only used by Android, return true.
resolve(@"Not applicable on iOS");
}
- (void)isIgnoringBatteryOptimizations:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
// Only used by Android, return true.
resolve(@(YES));
}
- (void)requestIgnoreBatteryOptimizations:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
// Only used by Android, return true.
resolve(@"Not applicable on iOS");
}
@end

View File

@@ -380,6 +380,37 @@ public class VaultManager: NSObject {
}
}
@objc
func copyToClipboardWithExpiration(_ text: String,
expirationSeconds: Double,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
NSLog("VaultManager: Copying to clipboard with expiration of %.0f seconds", expirationSeconds)
DispatchQueue.main.async {
if expirationSeconds > 0 {
// Create expiration date
let expirationDate = Date().addingTimeInterval(expirationSeconds)
// Set clipboard with expiration and local-only options
UIPasteboard.general.setItems(
[[UIPasteboard.typeAutomatic: text]],
options: [
.expirationDate: expirationDate,
.localOnly: true // Prevent sync to Universal Clipboard/iCloud
]
)
NSLog("VaultManager: Text copied to clipboard with expiration at %@", expirationDate.description)
} else {
// No expiration, just copy normally
UIPasteboard.general.string = text
NSLog("VaultManager: Text copied to clipboard without expiration")
}
resolve(nil)
}
}
@objc
func requiresMainQueueSetup() -> Bool {
return false

View File

@@ -20,7 +20,7 @@
"type": "modules",
"jsSrcsDir": "specs",
"android": {
"javaPackageName": "com.aliasvault.nativevaultmanager"
"javaPackageName": "net.aliasvault.nativevaultmanager"
},
"ios": {
"modulesProvider": {

View File

@@ -40,6 +40,15 @@ export interface Spec extends TurboModule {
// Clipboard management
clearClipboardAfterDelay(delayInSeconds: number): Promise<void>;
copyToClipboardWithExpiration(text: string, expirationSeconds: number): Promise<void>;
// Exact alarm permission management
canScheduleExactAlarms(): Promise<boolean>;
requestExactAlarmPermission(): Promise<string>;
// Battery optimization management
isIgnoringBatteryOptimizations(): Promise<boolean>;
requestIgnoreBatteryOptimizations(): Promise<string>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeVaultManager');

View File

@@ -0,0 +1,29 @@
import NativeVaultManager from '@/specs/NativeVaultManager';
/**
* Copy text to clipboard with automatic expiration based on platform capabilities.
*
* On iOS: Uses native clipboard expiration via UIPasteboard.setItems with expirationDate.
* On Android: Uses native method that combines clipboard copy with automatic clearing:
* - Uses AlarmManager (works even when app is backgrounded)
* - Android 13+: Also marks clipboard content as sensitive
*
* @param text - The text to copy to clipboard
* @param expirationSeconds - Number of seconds after which clipboard should be cleared (0 = no expiration)
*/
export async function copyToClipboardWithExpiration(
text: string,
expirationSeconds: number
): Promise<void> {
// Both platforms now use native methods for reliable clipboard management
await NativeVaultManager.copyToClipboardWithExpiration(text, expirationSeconds);
}
/**
* Copy text to clipboard without expiration.
*
* @param text - The text to copy to clipboard
*/
export async function copyToClipboard(text: string): Promise<void> {
await copyToClipboardWithExpiration(text, 0);
}