mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 21:40:41 -04:00
Merge branch 'main' of https://github.com/lanedirt/AliasVault
* '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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"type": "modules",
|
||||
"jsSrcsDir": "specs",
|
||||
"android": {
|
||||
"javaPackageName": "com.aliasvault.nativevaultmanager"
|
||||
"javaPackageName": "net.aliasvault.nativevaultmanager"
|
||||
},
|
||||
"ios": {
|
||||
"modulesProvider": {
|
||||
|
||||
@@ -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');
|
||||
|
||||
29
apps/mobile-app/utils/ClipboardUtility.ts
Normal file
29
apps/mobile-app/utils/ClipboardUtility.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user