diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 709cbd2..2d8452f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,9 @@
+
+
+
+
+
+
+
+
+
+
+
+
= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_HIGH
+ ).apply {
+ description = "Notifications asking if you want to save passwords to NexPass"
+ setShowBadge(true)
+ enableLights(true)
+ enableVibration(false)
+ }
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
+ /**
+ * Show a notification prompting to save password.
+ *
+ * @param packageName The package name of the app with the login form
+ * @param webDomain The web domain (if browser), null otherwise
+ */
+ fun showSavePasswordNotification(packageName: String, webDomain: String?) {
+ Log.d(TAG, "=== showSavePasswordNotification called ===")
+ Log.d(TAG, "Package: $packageName, Domain: $webDomain")
+
+ // Check for POST_NOTIFICATIONS permission on Android 13+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val hasPermission = ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED
+
+ if (!hasPermission) {
+ Log.w(TAG, "⚠️ POST_NOTIFICATIONS permission NOT GRANTED (Android 13+)")
+ Log.w(TAG, "User needs to grant notification permission in app settings")
+ return
+ }
+ Log.d(TAG, "✅ POST_NOTIFICATIONS permission granted (Android 13+)")
+ }
+
+ // Check if notifications are enabled (for older Android versions)
+ if (!notificationManagerCompat.areNotificationsEnabled()) {
+ Log.w(TAG, "⚠️ Notifications are DISABLED for NexPass!")
+ Log.w(TAG, "User needs to enable notifications in Settings > Apps > NexPass > Notifications")
+ return
+ }
+
+ Log.d(TAG, "✅ Notifications are enabled")
+
+ val displayName = webDomain ?: packageName
+ Log.d(TAG, "Display name: $displayName")
+
+ // Create intent to launch password input activity when notification is tapped
+ val intent = Intent(context, com.nexpass.passwordmanager.autofill.ui.NotificationPasswordInputActivity::class.java).apply {
+ putExtra(EXTRA_PACKAGE_NAME, packageName)
+ putExtra(EXTRA_WEB_DOMAIN, webDomain)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+
+ val pendingIntent = PendingIntent.getActivity(
+ context,
+ 0,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+
+ Log.d(TAG, "Building notification...")
+
+ val notification = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle("Save password to NexPass?")
+ .setContentText("Tap to save password for $displayName")
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
+ .setAutoCancel(true) // Dismiss when tapped
+ .setContentIntent(pendingIntent)
+ .build()
+
+ try {
+ Log.d(TAG, "Posting notification with ID: $NOTIFICATION_ID")
+ notificationManagerCompat.notify(NOTIFICATION_ID, notification)
+ Log.d(TAG, "✅ Notification posted successfully!")
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ Failed to post notification", e)
+ }
+ }
+
+ /**
+ * Cancel the save password notification.
+ */
+ fun cancelSavePasswordNotification() {
+ notificationManager.cancel(NOTIFICATION_ID)
+ }
+}
diff --git a/app/src/main/java/com/nexpass/passwordmanager/autofill/service/AutofillResponseBuilder.kt b/app/src/main/java/com/nexpass/passwordmanager/autofill/service/AutofillResponseBuilder.kt
index 66793cf..aece0a8 100644
--- a/app/src/main/java/com/nexpass/passwordmanager/autofill/service/AutofillResponseBuilder.kt
+++ b/app/src/main/java/com/nexpass/passwordmanager/autofill/service/AutofillResponseBuilder.kt
@@ -42,13 +42,94 @@ class AutofillResponseBuilder(private val context: Context) {
dataset?.let { fillResponseBuilder.addDataset(it) }
}
- // Add save info to allow saving new credentials
+ // Add "Save Password" manual trigger dataset
+ android.util.Log.d("AutofillResponseBuilder", "=== Attempting to build manual save dataset ===")
+ android.util.Log.d("AutofillResponseBuilder", "Fields count: ${fields.size}, Package: $packageName")
+ val saveDataset = buildManualSaveDataset(fields, packageName)
+ if (saveDataset != null) {
+ android.util.Log.d("AutofillResponseBuilder", "✅ Manual save dataset created successfully, adding to response")
+ fillResponseBuilder.addDataset(saveDataset)
+ } else {
+ android.util.Log.w("AutofillResponseBuilder", "❌ Manual save dataset is NULL - not added to response")
+ }
+
+ // Add save info to allow saving new credentials (still needed for native apps)
val saveInfo = buildSaveInfo(fields)
- saveInfo?.let { fillResponseBuilder.setSaveInfo(it) }
+ if (saveInfo != null) {
+ fillResponseBuilder.setSaveInfo(saveInfo)
+ android.util.Log.d("AutofillResponseBuilder", "SaveInfo added to FillResponse")
+ } else {
+ android.util.Log.w("AutofillResponseBuilder", "SaveInfo is NULL - won't trigger save dialog!")
+ }
return try {
fillResponseBuilder.build()
} catch (e: Exception) {
+ android.util.Log.e("AutofillResponseBuilder", "Failed to build FillResponse", e)
+ null
+ }
+ }
+
+ /**
+ * Build a special "Save Password" dataset that launches manual save flow.
+ */
+ private fun buildManualSaveDataset(
+ fields: List,
+ packageName: String
+ ): Dataset? {
+ android.util.Log.d("AutofillResponseBuilder", "buildManualSaveDataset() called for package: $packageName")
+
+ // Create intent to launch manual save capture
+ val saveIntent = Intent(context, com.nexpass.passwordmanager.autofill.ui.ManualSaveCaptureActivity::class.java).apply {
+ putExtra("packageName", packageName)
+ // NOTE: Dataset authentication does NOT provide EXTRA_ASSIST_STRUCTURE automatically!
+ // This is a known Android limitation - only FillResponse.setAuthentication() provides it
+ }
+
+ val savePendingIntent = PendingIntent.getActivity(
+ context,
+ 1, // Different request code from auth prompt
+ saveIntent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
+ )
+
+ // Create presentation for "Save Password" option
+ val presentation = RemoteViews(context.packageName, R.layout.autofill_item)
+ presentation.setTextViewText(R.id.autofill_title, "\uD83D\uDCBE Save This Password")
+ presentation.setTextViewText(R.id.autofill_username, "Tap to save current credentials")
+ presentation.setImageViewResource(R.id.autofill_icon, android.R.drawable.ic_menu_save)
+
+ val datasetBuilder = Dataset.Builder(presentation)
+
+ // Set authentication - when user selects this, Android launches our activity
+ // with EXTRA_ASSIST_STRUCTURE containing current field values
+ val usernameField = fields.firstOrNull {
+ it.fieldType == FieldType.USERNAME || it.fieldType == FieldType.EMAIL
+ }
+ val passwordField = fields.firstOrNull { it.fieldType == FieldType.PASSWORD }
+
+ // We need at least one field to attach the authentication to
+ passwordField?.let { field ->
+ android.util.Log.d("AutofillResponseBuilder", "Found password field for manual save dataset")
+ datasetBuilder.setValue(
+ field.autofillId,
+ null, // Don't actually fill any value
+ presentation
+ )
+ } ?: run {
+ android.util.Log.w("AutofillResponseBuilder", "⚠️ No password field found - manual save dataset may not work!")
+ }
+
+ // Set authentication on the dataset
+ datasetBuilder.setAuthentication(savePendingIntent.intentSender)
+ android.util.Log.d("AutofillResponseBuilder", "Set authentication on manual save dataset")
+
+ return try {
+ val dataset = datasetBuilder.build()
+ android.util.Log.d("AutofillResponseBuilder", "✅ Manual save dataset built successfully")
+ dataset
+ } catch (e: Exception) {
+ android.util.Log.e("AutofillResponseBuilder", "❌ Failed to build manual save dataset", e)
null
}
}
@@ -143,18 +224,37 @@ class AutofillResponseBuilder(private val context: Context) {
}
val passwordField = fields.firstOrNull { it.fieldType == FieldType.PASSWORD }
+ android.util.Log.d("AutofillResponseBuilder", "buildSaveInfo - usernameField: ${usernameField != null}, passwordField: ${passwordField != null}")
+
// We need at least a password field to save
if (passwordField == null) {
+ android.util.Log.w("AutofillResponseBuilder", "No password field found - cannot build SaveInfo")
return null
}
- val requiredIds = mutableListOf(passwordField.autofillId)
- usernameField?.let { requiredIds.add(it.autofillId) }
+ // Only password is required, username is optional
+ val requiredIds = arrayOf(passwordField.autofillId)
- return SaveInfo.Builder(
+ val builder = SaveInfo.Builder(
SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,
- requiredIds.toTypedArray()
- ).build()
+ requiredIds
+ )
+
+ // Add optional username field if present
+ usernameField?.let {
+ builder.setOptionalIds(arrayOf(it.autofillId))
+ android.util.Log.d("AutofillResponseBuilder", "Added optional username field to SaveInfo")
+ }
+
+ // Set flags to trigger save more aggressively
+ // FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE: Trigger save when all views become invisible (form submission/navigation)
+ // FLAG_DELAY_SAVE: Delay the save UI to better detect form submissions
+ val flags = SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE or SaveInfo.FLAG_DELAY_SAVE
+ builder.setFlags(flags)
+ android.util.Log.d("AutofillResponseBuilder", "Set SaveInfo flags: FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE | FLAG_DELAY_SAVE")
+
+ android.util.Log.d("AutofillResponseBuilder", "Successfully built SaveInfo")
+ return builder.build()
}
/**
@@ -194,6 +294,15 @@ class AutofillResponseBuilder(private val context: Context) {
presentation
)
+ // IMPORTANT: Add SaveInfo so Android triggers save dialog after form submission
+ val saveInfo = buildSaveInfo(fields)
+ if (saveInfo != null) {
+ responseBuilder.setSaveInfo(saveInfo)
+ android.util.Log.d("AutofillResponseBuilder", "SaveInfo added to authentication response")
+ } else {
+ android.util.Log.w("AutofillResponseBuilder", "SaveInfo is NULL in authentication response")
+ }
+
return responseBuilder.build()
}
}
diff --git a/app/src/main/java/com/nexpass/passwordmanager/autofill/service/PasswordAutofillService.kt b/app/src/main/java/com/nexpass/passwordmanager/autofill/service/PasswordAutofillService.kt
index b35ca11..336b4c8 100644
--- a/app/src/main/java/com/nexpass/passwordmanager/autofill/service/PasswordAutofillService.kt
+++ b/app/src/main/java/com/nexpass/passwordmanager/autofill/service/PasswordAutofillService.kt
@@ -1,5 +1,6 @@
package com.nexpass.passwordmanager.autofill.service
+import android.content.Intent
import android.os.CancellationSignal
import android.service.autofill.AutofillService
import android.service.autofill.FillCallback
@@ -14,13 +15,17 @@ import com.nexpass.passwordmanager.autofill.matcher.AutofillMatcher
import com.nexpass.passwordmanager.autofill.model.AutofillContext
import com.nexpass.passwordmanager.autofill.model.AutofillField
import com.nexpass.passwordmanager.autofill.model.FieldType
+import com.nexpass.passwordmanager.autofill.ui.AutofillSavePromptActivity
+import com.nexpass.passwordmanager.autofill.notification.AutosaveNotificationManager
import com.nexpass.passwordmanager.domain.model.PasswordEntry
import com.nexpass.passwordmanager.domain.repository.PasswordRepository
import com.nexpass.passwordmanager.data.local.preferences.SecurePreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import java.util.UUID
@@ -35,11 +40,18 @@ class PasswordAutofillService : AutofillService() {
private val passwordRepository: PasswordRepository by inject()
private val securePreferences: SecurePreferences by inject()
private val autofillResponseBuilder by lazy { AutofillResponseBuilder(this) }
+ private val notificationManager by lazy { AutosaveNotificationManager(this) }
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ // Track pending notification jobs to avoid duplicate notifications
+ private val pendingNotificationJobs = mutableMapOf()
+
companion object {
private const val TAG = "PasswordAutofillService"
+ private const val NOTIFICATION_DELAY_MS = 8000L // Wait 8 seconds after field focus before showing notification
+ private const val NEXPASS_PACKAGE_DEBUG = "com.nexpass.passwordmanager.debug"
+ private const val NEXPASS_PACKAGE_RELEASE = "com.nexpass.passwordmanager"
}
override fun onFillRequest(
@@ -121,26 +133,60 @@ class PasswordAutofillService : AutofillService() {
Log.d(TAG, "Save request - Username: $username, Package: $packageName, Domain: $webDomain")
- // Create a new password entry
- if (password.isNotEmpty()) {
- val newEntry = PasswordEntry(
- id = UUID.randomUUID().toString(),
- title = webDomain ?: packageName,
- username = username,
- password = password,
- url = webDomain?.let { "https://$it" },
- notes = null,
- folderId = null,
- tags = emptyList(),
- packageNames = listOf(packageName),
- favorite = false,
- createdAt = System.currentTimeMillis(),
- updatedAt = System.currentTimeMillis(),
- lastModified = System.currentTimeMillis()
- )
+ // Skip NexPass's own package to avoid saving master password
+ if (packageName == NEXPASS_PACKAGE_DEBUG || packageName == NEXPASS_PACKAGE_RELEASE) {
+ Log.d(TAG, "Skipping save request - this is NexPass's own package")
+ callback.onSuccess()
+ return@launch
+ }
- passwordRepository.insert(newEntry)
- Log.d(TAG, "Saved new password entry: ${newEntry.title}")
+ // Check if autosave is enabled
+ if (!securePreferences.isAutosaveEnabled()) {
+ Log.d(TAG, "Autosave is disabled in settings")
+ callback.onSuccess()
+ return@launch
+ }
+
+ // Check if this domain/package is in the never-save list
+ val identifier = webDomain ?: packageName
+ if (securePreferences.getNeverSaveDomains().contains(identifier)) {
+ Log.d(TAG, "Domain/package is in never-save list: $identifier")
+ callback.onSuccess()
+ return@launch
+ }
+
+ // Check if we have existing entries for this site
+ val existingEntries = try {
+ passwordRepository.getAll().filter { entry ->
+ // Check if entry matches by URL or package name
+ (entry.url == identifier) || entry.packageNames.contains(identifier)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not check existing entries: ${e.message}")
+ emptyList()
+ }
+
+ if (password.isNotEmpty()) {
+ if (existingEntries.isNotEmpty()) {
+ // For existing sites: Show direct save dialog (password update scenario)
+ Log.d(TAG, "💾 Launching direct save dialog for password update on existing site: $identifier")
+ val intent = Intent(this@PasswordAutofillService, AutofillSavePromptActivity::class.java).apply {
+ putExtra(AutofillSavePromptActivity.EXTRA_USERNAME, username)
+ putExtra(AutofillSavePromptActivity.EXTRA_PASSWORD, password)
+ putExtra(AutofillSavePromptActivity.EXTRA_WEB_DOMAIN, webDomain)
+ putExtra(AutofillSavePromptActivity.EXTRA_PACKAGE_NAME, packageName)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ }
+ startActivity(intent)
+ } else {
+ // For new sites: Show notification (user will manually re-enter)
+ Log.d(TAG, "📢 Showing autosave notification for new site: ${webDomain ?: packageName}")
+ notificationManager.showSavePasswordNotification(
+ packageName = packageName,
+ webDomain = webDomain
+ )
+ }
}
callback.onSuccess()
@@ -160,27 +206,71 @@ class PasswordAutofillService : AutofillService() {
Log.d(TAG, "Found ${matchingEntries.size} matching entries")
- // If no matches, show unlock prompt to allow manual selection
+ // If no matches, schedule notification and show unlock prompt
if (matchingEntries.isEmpty()) {
- Log.d(TAG, "No matches found, showing manual search prompt")
+ Log.d(TAG, "⚠️ No matches found, showing unlock/search prompt")
+
+ // Schedule delayed notification for NEW sites if autosave is enabled
+ if (securePreferences.isAutosaveEnabled() && hasPasswordField(context.detectedFields)) {
+ val packageName = context.packageName ?: ""
+
+ // Skip NexPass's own package to avoid saving master password
+ if (packageName == NEXPASS_PACKAGE_DEBUG || packageName == NEXPASS_PACKAGE_RELEASE) {
+ Log.d(TAG, "Skipping notification - this is NexPass's own package")
+ } else {
+ val identifier = context.webDomain ?: packageName
+ // Only show notification if not in never-save list
+ if (identifier.isNotEmpty() && !securePreferences.getNeverSaveDomains().contains(identifier)) {
+ scheduleDelayedNotification(
+ identifier = identifier,
+ packageName = packageName,
+ webDomain = context.webDomain
+ )
+ Log.d(TAG, "📢 Scheduled notification for NEW site: $identifier")
+ } else {
+ Log.d(TAG, "Not scheduling notification - site is in never-save list or invalid")
+ }
+ }
+ } else {
+ Log.d(TAG, "Not scheduling notification - autosave disabled or no password field")
+ }
+
return buildLockedResponse(context)
}
- // Build fill response with datasets
- return autofillResponseBuilder.buildFillResponse(
+ // Build fill response with datasets for existing entries
+ // This includes a manual "Save" option in the autofill dropdown for updating passwords
+ Log.d(TAG, "Building fill response with ${matchingEntries.size} entries and manual save option")
+ val response = autofillResponseBuilder.buildFillResponse(
entries = matchingEntries,
fields = context.detectedFields,
packageName = context.packageName ?: ""
)
+ Log.d(TAG, "Fill response built: ${if (response != null) "✅ Success" else "❌ NULL"}")
+ return response
}
/**
* Build a fill response when the vault is locked.
* This prompts the user to unlock.
+ *
+ * @param showNotification Whether to show autosave notification (true for locked vault, false when called after checking entries)
*/
- private fun buildLockedResponse(context: AutofillContext): FillResponse {
+ private fun buildLockedResponse(context: AutofillContext, showNotification: Boolean = false): FillResponse {
Log.d(TAG, "Vault is locked, returning authentication response")
+ // Show notification to save password if requested
+ if (showNotification && securePreferences.isAutosaveEnabled() && hasPasswordField(context.detectedFields)) {
+ val identifier = context.webDomain ?: context.packageName
+ if (identifier != null && !securePreferences.getNeverSaveDomains().contains(identifier)) {
+ Log.d(TAG, "📢 Showing autosave notification (vault locked): $identifier")
+ notificationManager.showSavePasswordNotification(
+ packageName = context.packageName ?: "",
+ webDomain = context.webDomain
+ )
+ }
+ }
+
return autofillResponseBuilder.buildAuthenticationResponse(
fields = context.detectedFields,
packageName = context.packageName ?: "",
@@ -240,9 +330,10 @@ class PasswordAutofillService : AutofillService() {
val idEntry = node.idEntry
val htmlInfo = node.htmlInfo
- // Skip URL bar fields
- if (idEntry != null && (idEntry.contains("url") || idEntry.contains("address") || idEntry.contains("toolbar"))) {
- // This is likely a URL bar, skip it
+ // Skip URL bar and search fields
+ val shouldSkip = shouldSkipField(idEntry, nodeHint, autofillHints)
+ if (shouldSkip) {
+ // This is likely a URL bar or search field, skip it
} else {
// Determine field type from multiple sources
val fieldType = determineFieldTypeFromNode(hint, nodeHint, inputType, idEntry, htmlInfo)
@@ -270,6 +361,47 @@ class PasswordAutofillService : AutofillService() {
}
}
+ /**
+ * Check if a field should be skipped (URL bars, search fields, etc.)
+ */
+ private fun shouldSkipField(
+ idEntry: String?,
+ nodeHint: CharSequence?,
+ autofillHints: Array?
+ ): Boolean {
+ // Patterns to skip (case-insensitive)
+ val skipPatterns = listOf(
+ "url", "address", "toolbar", // URL/address bars
+ "search", "query", "find", "lookup", // Search fields
+ "autocomplete", "suggest", "complete" // Autocomplete/suggestion fields
+ )
+
+ // Check ID entry
+ idEntry?.lowercase()?.let { id ->
+ if (skipPatterns.any { pattern -> id.contains(pattern) }) {
+ return true
+ }
+ }
+
+ // Check node hint
+ nodeHint?.toString()?.lowercase()?.let { hint ->
+ if (skipPatterns.any { pattern -> hint.contains(pattern) }) {
+ return true
+ }
+ }
+
+ // Check autofill hints
+ autofillHints?.forEach { autofillHint ->
+ autofillHint.lowercase().let { hint ->
+ if (skipPatterns.any { pattern -> hint.contains(pattern) }) {
+ return true
+ }
+ }
+ }
+
+ return false
+ }
+
/**
* Determine the field type from multiple sources.
*/
@@ -280,6 +412,51 @@ class PasswordAutofillService : AutofillService() {
idEntry: String?,
htmlInfo: android.view.ViewStructure.HtmlInfo?
): FieldType {
+ // Search field patterns to exclude
+ val searchPatterns = listOf("search", "query", "find", "lookup", "autocomplete", "suggest", "complete")
+
+ // Check if this is a search field and return UNKNOWN if so
+ // This is a safeguard in case shouldSkipField() missed it
+ autofillHint?.lowercase()?.let { hint ->
+ if (searchPatterns.any { pattern -> hint.contains(pattern) }) {
+ return FieldType.UNKNOWN
+ }
+ }
+
+ nodeHint?.toString()?.lowercase()?.let { hint ->
+ if (searchPatterns.any { pattern -> hint.contains(pattern) }) {
+ return FieldType.UNKNOWN
+ }
+ }
+
+ idEntry?.lowercase()?.let { id ->
+ if (searchPatterns.any { pattern -> id.contains(pattern) }) {
+ return FieldType.UNKNOWN
+ }
+ }
+
+ // Check HTML attributes for search fields (WebViews)
+ htmlInfo?.let { html ->
+ val htmlType = html.attributes?.firstOrNull { it.first == "type" }?.second?.lowercase()
+ if (htmlType == "search") {
+ return FieldType.UNKNOWN
+ }
+
+ val htmlName = html.attributes?.firstOrNull { it.first == "name" }?.second?.lowercase()
+ htmlName?.let { name ->
+ if (searchPatterns.any { pattern -> name.contains(pattern) }) {
+ return FieldType.UNKNOWN
+ }
+ }
+
+ val htmlId = html.attributes?.firstOrNull { it.first == "id" }?.second?.lowercase()
+ htmlId?.let { id ->
+ if (searchPatterns.any { pattern -> id.contains(pattern) }) {
+ return FieldType.UNKNOWN
+ }
+ }
+ }
+
// Check autofill hints first (most reliable)
autofillHint?.lowercase()?.let { hint ->
when {
@@ -504,8 +681,60 @@ class PasswordAutofillService : AutofillService() {
}
}
+ /**
+ * Schedule a delayed notification to save password.
+ * Delays showing the notification to give user time to enter credentials.
+ *
+ * @param identifier Unique identifier for this login form (domain or package name)
+ * @param packageName The app package name
+ * @param webDomain The web domain (if browser), null otherwise
+ */
+ private fun scheduleDelayedNotification(
+ identifier: String,
+ packageName: String,
+ webDomain: String?
+ ) {
+ // Cancel any existing job for this identifier to avoid duplicate notifications
+ pendingNotificationJobs[identifier]?.cancel()
+
+ Log.d(TAG, "⏱️ Scheduling delayed notification for $identifier (delay: ${NOTIFICATION_DELAY_MS}ms)")
+
+ // Schedule new delayed notification
+ val job = serviceScope.launch {
+ delay(NOTIFICATION_DELAY_MS)
+
+ // Double-check autosave is still enabled and site not in never-save list
+ if (securePreferences.isAutosaveEnabled() &&
+ !securePreferences.getNeverSaveDomains().contains(identifier)) {
+
+ Log.d(TAG, "📢 Showing delayed autosave notification for: $identifier")
+ notificationManager.showSavePasswordNotification(
+ packageName = packageName,
+ webDomain = webDomain
+ )
+ } else {
+ Log.d(TAG, "Cancelled notification - autosave disabled or site in never-save list")
+ }
+
+ // Remove job from map after completion
+ pendingNotificationJobs.remove(identifier)
+ }
+
+ pendingNotificationJobs[identifier] = job
+ }
+
+ /**
+ * Check if the detected fields include a password field.
+ */
+ private fun hasPasswordField(fields: List): Boolean {
+ return fields.any { it.fieldType == FieldType.PASSWORD }
+ }
+
override fun onDestroy() {
super.onDestroy()
+ // Cancel all pending notification jobs
+ pendingNotificationJobs.values.forEach { it.cancel() }
+ pendingNotificationJobs.clear()
serviceScope.cancel()
}
}
diff --git a/app/src/main/java/com/nexpass/passwordmanager/autofill/ui/AutofillSavePromptActivity.kt b/app/src/main/java/com/nexpass/passwordmanager/autofill/ui/AutofillSavePromptActivity.kt
new file mode 100644
index 0000000..4672f48
--- /dev/null
+++ b/app/src/main/java/com/nexpass/passwordmanager/autofill/ui/AutofillSavePromptActivity.kt
@@ -0,0 +1,109 @@
+package com.nexpass.passwordmanager.autofill.ui
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.nexpass.passwordmanager.ui.screens.autofill.AutofillSavePromptScreen
+import com.nexpass.passwordmanager.ui.theme.NexPassTheme
+import com.nexpass.passwordmanager.ui.viewmodel.AutofillSavePromptViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+/**
+ * Activity for prompting user to save autofilled credentials.
+ * Displayed as a dialog over the app requesting autofill.
+ */
+class AutofillSavePromptActivity : ComponentActivity() {
+
+ private val viewModel: AutofillSavePromptViewModel by viewModel()
+
+ companion object {
+ const val EXTRA_USERNAME = "username"
+ const val EXTRA_PASSWORD = "password"
+ const val EXTRA_WEB_DOMAIN = "webDomain"
+ const val EXTRA_PACKAGE_NAME = "packageName"
+ const val RESULT_SAVED = 1
+ const val RESULT_UPDATED = 2
+ const val RESULT_CANCELLED = 3
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Extract data from intent
+ val username = intent.getStringExtra(EXTRA_USERNAME) ?: ""
+ val password = intent.getStringExtra(EXTRA_PASSWORD) ?: ""
+ val webDomain = intent.getStringExtra(EXTRA_WEB_DOMAIN)
+ val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: ""
+
+ // Validate required data
+ if (password.isEmpty() || packageName.isEmpty()) {
+ Toast.makeText(
+ this,
+ "Missing required data for autosave",
+ Toast.LENGTH_SHORT
+ ).show()
+ setResult(Activity.RESULT_CANCELED)
+ finish()
+ return
+ }
+
+ setContent {
+ NexPassTheme {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)),
+ contentAlignment = Alignment.Center
+ ) {
+ AutofillSavePromptScreen(
+ packageName = packageName,
+ webDomain = webDomain,
+ viewModel = viewModel,
+ onSaveSuccess = { isNew ->
+ // Show toast notification
+ val message = if (isNew) {
+ "Password saved to NexPass"
+ } else {
+ "Password updated"
+ }
+ Toast.makeText(this@AutofillSavePromptActivity, message, Toast.LENGTH_SHORT).show()
+
+ // Return result
+ val resultCode = if (isNew) RESULT_SAVED else RESULT_UPDATED
+ setResult(resultCode)
+ finish()
+ },
+ onCancel = {
+ // Show toast for cancellation
+ Toast.makeText(
+ this@AutofillSavePromptActivity,
+ "Password not saved",
+ Toast.LENGTH_SHORT
+ ).show()
+
+ setResult(RESULT_CANCELLED)
+ finish()
+ }
+ )
+ }
+ }
+ }
+
+ // Initialize ViewModel with autofill data
+ viewModel.initializeWithAutofillData(
+ username = username,
+ password = password,
+ domain = webDomain,
+ packageName = packageName
+ )
+ }
+}
diff --git a/app/src/main/java/com/nexpass/passwordmanager/autofill/ui/ManualSaveCaptureActivity.kt b/app/src/main/java/com/nexpass/passwordmanager/autofill/ui/ManualSaveCaptureActivity.kt
new file mode 100644
index 0000000..82f3162
--- /dev/null
+++ b/app/src/main/java/com/nexpass/passwordmanager/autofill/ui/ManualSaveCaptureActivity.kt
@@ -0,0 +1,185 @@
+package com.nexpass.passwordmanager.autofill.ui
+
+import android.app.Activity
+import android.app.assist.AssistStructure
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.autofill.AutofillManager
+import androidx.activity.ComponentActivity
+import com.nexpass.passwordmanager.autofill.model.FieldType
+
+/**
+ * Activity launched when user selects "Save Password" from autofill dropdown.
+ * Captures the current field values and launches the save prompt.
+ */
+class ManualSaveCaptureActivity : ComponentActivity() {
+
+ companion object {
+ private const val TAG = "ManualSaveCaptureActivity"
+ const val EXTRA_ASSIST_STRUCTURE = "assistStructure"
+ const val EXTRA_PACKAGE_NAME = "packageName"
+ const val EXTRA_WEB_DOMAIN = "webDomain"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ Log.d(TAG, "=== ManualSaveCaptureActivity launched ===")
+
+ // Log all intent extras to debug what we receive
+ val extras = intent.extras
+ if (extras != null) {
+ Log.d(TAG, "Intent extras received:")
+ for (key in extras.keySet()) {
+ Log.d(TAG, " - $key: ${extras.get(key)}")
+ }
+ } else {
+ Log.w(TAG, "⚠️ No intent extras received at all!")
+ }
+
+ // Extract assist structure from intent
+ @Suppress("DEPRECATION")
+ val structure = intent.getParcelableExtra(EXTRA_ASSIST_STRUCTURE)
+ val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: ""
+
+ Log.d(TAG, "Package name: $packageName")
+ Log.d(TAG, "AssistStructure: ${if (structure != null) "✅ Present" else "❌ NULL"}")
+
+ // Extract web domain from structure if not provided
+ var webDomain = intent.getStringExtra(EXTRA_WEB_DOMAIN)
+ if (webDomain == null && structure != null) {
+ webDomain = extractWebDomain(structure)
+ }
+
+ if (structure == null) {
+ Log.e(TAG, "❌ CRITICAL: No AssistStructure provided - Dataset authentication doesn't provide it!")
+ Log.e(TAG, "This is a known Android limitation: Dataset.setAuthentication() does NOT include EXTRA_ASSIST_STRUCTURE")
+ Log.e(TAG, "Only FillResponse.setAuthentication() provides EXTRA_ASSIST_STRUCTURE")
+ setResult(Activity.RESULT_CANCELED)
+ finish()
+ return
+ }
+
+ // Extract username and password from the structure
+ val credentials = extractCredentials(structure)
+
+ if (credentials == null || credentials.second.isEmpty()) {
+ Log.w(TAG, "No password found in form")
+ setResult(Activity.RESULT_CANCELED)
+ finish()
+ return
+ }
+
+ val (username, password) = credentials
+
+ // Launch the save prompt activity
+ val saveIntent = Intent(this, AutofillSavePromptActivity::class.java).apply {
+ putExtra(AutofillSavePromptActivity.EXTRA_USERNAME, username)
+ putExtra(AutofillSavePromptActivity.EXTRA_PASSWORD, password)
+ putExtra(AutofillSavePromptActivity.EXTRA_WEB_DOMAIN, webDomain)
+ putExtra(AutofillSavePromptActivity.EXTRA_PACKAGE_NAME, packageName)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ startActivity(saveIntent)
+ setResult(Activity.RESULT_OK)
+ finish()
+ }
+
+ /**
+ * Extract username and password from AssistStructure.
+ */
+ private fun extractCredentials(structure: AssistStructure): Pair? {
+ var username = ""
+ var password = ""
+
+ for (i in 0 until structure.windowNodeCount) {
+ val windowNode = structure.getWindowNodeAt(i)
+ windowNode.rootViewNode?.let { rootNode ->
+ extractFromNode(rootNode) { hint, value ->
+ when {
+ hint.contains("password", ignoreCase = true) -> {
+ password = value
+ }
+ hint.contains("username", ignoreCase = true) ||
+ hint.contains("email", ignoreCase = true) -> {
+ username = value
+ }
+ }
+ }
+ }
+ }
+
+ return if (password.isNotEmpty()) {
+ Pair(username, password)
+ } else {
+ null
+ }
+ }
+
+ /**
+ * Recursively extract field values from view nodes.
+ */
+ private fun extractFromNode(
+ node: AssistStructure.ViewNode,
+ onField: (hint: String, value: String) -> Unit
+ ) {
+ // Check autofill hints
+ node.autofillHints?.forEach { hint ->
+ node.autofillValue?.textValue?.toString()?.let { value ->
+ if (value.isNotEmpty()) {
+ onField(hint, value)
+ }
+ }
+ }
+
+ // Check HTML attributes for web forms
+ node.htmlInfo?.attributes?.forEach { attr ->
+ if (attr.first == "type" || attr.first == "name" || attr.first == "id") {
+ node.autofillValue?.textValue?.toString()?.let { value ->
+ if (value.isNotEmpty()) {
+ onField(attr.second.toString(), value)
+ }
+ }
+ }
+ }
+
+ // Recursively check children
+ for (i in 0 until node.childCount) {
+ node.getChildAt(i)?.let { extractFromNode(it, onField) }
+ }
+ }
+
+ /**
+ * Extract web domain from AssistStructure.
+ */
+ private fun extractWebDomain(structure: AssistStructure): String? {
+ for (i in 0 until structure.windowNodeCount) {
+ val windowNode = structure.getWindowNodeAt(i)
+ windowNode.rootViewNode?.let { rootNode ->
+ val domain = extractDomainFromNode(rootNode)
+ if (domain != null) return domain
+ }
+ }
+ return null
+ }
+
+ /**
+ * Recursively search for web domain in view nodes.
+ */
+ private fun extractDomainFromNode(node: AssistStructure.ViewNode): String? {
+ // Check webDomain property (most reliable for browsers)
+ node.webDomain?.let { return it }
+
+ // Recursively check children
+ for (i in 0 until node.childCount) {
+ node.getChildAt(i)?.let { child ->
+ val domain = extractDomainFromNode(child)
+ if (domain != null) return domain
+ }
+ }
+
+ return null
+ }
+}
diff --git a/app/src/main/java/com/nexpass/passwordmanager/autofill/ui/NotificationPasswordInputActivity.kt b/app/src/main/java/com/nexpass/passwordmanager/autofill/ui/NotificationPasswordInputActivity.kt
new file mode 100644
index 0000000..82ecf6c
--- /dev/null
+++ b/app/src/main/java/com/nexpass/passwordmanager/autofill/ui/NotificationPasswordInputActivity.kt
@@ -0,0 +1,166 @@
+package com.nexpass.passwordmanager.autofill.ui
+
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import com.nexpass.passwordmanager.ui.theme.NexPassTheme
+
+/**
+ * Activity launched from notification to manually input password for saving.
+ * Shows a dialog with username and password fields.
+ */
+class NotificationPasswordInputActivity : ComponentActivity() {
+
+ companion object {
+ private const val TAG = "NotificationPasswordInput"
+ const val EXTRA_PACKAGE_NAME = "packageName"
+ const val EXTRA_WEB_DOMAIN = "webDomain"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: ""
+ val webDomain = intent.getStringExtra(EXTRA_WEB_DOMAIN)
+
+ Log.d(TAG, "Launched for package: $packageName, domain: $webDomain")
+
+ setContent {
+ NexPassTheme {
+ PasswordInputDialog(
+ packageName = packageName,
+ webDomain = webDomain,
+ onSave = { username, password ->
+ savePassword(username, password, packageName, webDomain)
+ },
+ onDismiss = {
+ finish()
+ }
+ )
+ }
+ }
+ }
+
+ /**
+ * Save the password and launch the save prompt activity.
+ */
+ private fun savePassword(username: String, password: String, packageName: String, webDomain: String?) {
+ if (password.isEmpty()) {
+ Log.w(TAG, "Password is empty, not saving")
+ finish()
+ return
+ }
+
+ Log.d(TAG, "Launching save prompt with username: $username, domain: $webDomain")
+
+ // Launch the autofill save prompt activity
+ val intent = android.content.Intent(this, AutofillSavePromptActivity::class.java).apply {
+ putExtra(AutofillSavePromptActivity.EXTRA_USERNAME, username)
+ putExtra(AutofillSavePromptActivity.EXTRA_PASSWORD, password)
+ putExtra(AutofillSavePromptActivity.EXTRA_WEB_DOMAIN, webDomain)
+ putExtra(AutofillSavePromptActivity.EXTRA_PACKAGE_NAME, packageName)
+ addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ startActivity(intent)
+ finish()
+ }
+}
+
+/**
+ * Composable dialog for password input.
+ */
+@Composable
+fun PasswordInputDialog(
+ packageName: String,
+ webDomain: String?,
+ onSave: (username: String, password: String) -> Unit,
+ onDismiss: () -> Unit
+) {
+ var username by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var passwordVisible by remember { mutableStateOf(false) }
+
+ val displayName = webDomain ?: packageName
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text("Save Password for $displayName")
+ },
+ text = {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "Enter the credentials you want to save:",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ OutlinedTextField(
+ value = username,
+ onValueChange = { username = it },
+ label = { Text("Username or Email") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("Password") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ visualTransformation = if (passwordVisible) {
+ VisualTransformation.None
+ } else {
+ PasswordVisualTransformation()
+ },
+ trailingIcon = {
+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
+ Icon(
+ imageVector = if (passwordVisible) {
+ Icons.Default.Visibility
+ } else {
+ Icons.Default.VisibilityOff
+ },
+ contentDescription = if (passwordVisible) "Hide password" else "Show password"
+ )
+ }
+ }
+ )
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ onSave(username, password)
+ },
+ enabled = password.isNotEmpty()
+ ) {
+ Text("Save")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/nexpass/passwordmanager/data/local/preferences/SecurePreferences.kt b/app/src/main/java/com/nexpass/passwordmanager/data/local/preferences/SecurePreferences.kt
index 651013e..f39b2c2 100644
--- a/app/src/main/java/com/nexpass/passwordmanager/data/local/preferences/SecurePreferences.kt
+++ b/app/src/main/java/com/nexpass/passwordmanager/data/local/preferences/SecurePreferences.kt
@@ -48,6 +48,11 @@ class SecurePreferences(context: Context) {
// App preferences
private const val KEY_THEME_MODE = "theme_mode"
private const val KEY_AUTO_LOCK_TIMEOUT = "auto_lock_timeout"
+
+ // Autosave preferences
+ private const val KEY_AUTOSAVE_ENABLED = "autosave_enabled"
+ private const val KEY_AUTOSAVE_DEFAULT_FOLDER = "autosave_default_folder"
+ private const val KEY_NEVER_SAVE_DOMAINS = "never_save_domains"
}
fun getLastSyncTimestamp(): Long {
@@ -201,4 +206,74 @@ class SecurePreferences(context: Context) {
fun setAutoLockTimeout(minutes: Int) {
sharedPreferences.edit().putInt(KEY_AUTO_LOCK_TIMEOUT, minutes).apply()
}
+
+ // Autosave preferences
+
+ /**
+ * Check if autosave is enabled.
+ * @return True if autosave is enabled (default: true)
+ */
+ fun isAutosaveEnabled(): Boolean {
+ return sharedPreferences.getBoolean(KEY_AUTOSAVE_ENABLED, true)
+ }
+
+ /**
+ * Set autosave enabled state.
+ * @param enabled True to enable autosave, false to disable
+ */
+ fun setAutosaveEnabled(enabled: Boolean) {
+ sharedPreferences.edit().putBoolean(KEY_AUTOSAVE_ENABLED, enabled).apply()
+ }
+
+ /**
+ * Get default folder ID for autosaved passwords.
+ * @return Folder ID or null for no default folder
+ */
+ fun getAutosaveDefaultFolder(): String? {
+ return sharedPreferences.getString(KEY_AUTOSAVE_DEFAULT_FOLDER, null)
+ }
+
+ /**
+ * Set default folder for autosaved passwords.
+ * @param folderId Folder ID or null for no default folder
+ */
+ fun setAutosaveDefaultFolder(folderId: String?) {
+ if (folderId == null) {
+ sharedPreferences.edit().remove(KEY_AUTOSAVE_DEFAULT_FOLDER).apply()
+ } else {
+ sharedPreferences.edit().putString(KEY_AUTOSAVE_DEFAULT_FOLDER, folderId).apply()
+ }
+ }
+
+ /**
+ * Get the set of domains/packages that should never be saved.
+ * @return Set of domains/packages to never save
+ */
+ fun getNeverSaveDomains(): Set {
+ val serialized = sharedPreferences.getString(KEY_NEVER_SAVE_DOMAINS, null)
+ return if (serialized != null && serialized.isNotEmpty()) {
+ serialized.split(",").toSet()
+ } else {
+ emptySet()
+ }
+ }
+
+ /**
+ * Add a domain/package to the never-save list.
+ * @param domain Domain or package name to never save
+ */
+ fun addNeverSaveDomain(domain: String) {
+ val current = getNeverSaveDomains().toMutableSet()
+ current.add(domain)
+ sharedPreferences.edit()
+ .putString(KEY_NEVER_SAVE_DOMAINS, current.joinToString(","))
+ .apply()
+ }
+
+ /**
+ * Clear the never-save domains list.
+ */
+ fun clearNeverSaveDomains() {
+ sharedPreferences.edit().remove(KEY_NEVER_SAVE_DOMAINS).apply()
+ }
}
diff --git a/app/src/main/java/com/nexpass/passwordmanager/di/ViewModelModule.kt b/app/src/main/java/com/nexpass/passwordmanager/di/ViewModelModule.kt
index 6d5ed3e..ee17138 100644
--- a/app/src/main/java/com/nexpass/passwordmanager/di/ViewModelModule.kt
+++ b/app/src/main/java/com/nexpass/passwordmanager/di/ViewModelModule.kt
@@ -84,4 +84,25 @@ val viewModelModule = module {
tagRepository = get()
)
}
+
+ // Autofill Save Prompt ViewModel
+ viewModel {
+ AutofillSavePromptViewModel(
+ passwordRepository = get(),
+ folderRepository = get(),
+ tagRepository = get(),
+ securePreferences = get()
+ )
+ }
+
+ // Autofill Prompt ViewModel (for unlock during autofill)
+ viewModel {
+ com.nexpass.passwordmanager.autofill.ui.AutofillPromptViewModel(
+ vaultKeyManager = get(),
+ securePreferences = get(),
+ biometricManager = get(),
+ autofillMatcher = get(),
+ autofillResponseBuilder = get()
+ )
+ }
}
diff --git a/app/src/main/java/com/nexpass/passwordmanager/ui/screens/autofill/AutofillSavePromptScreen.kt b/app/src/main/java/com/nexpass/passwordmanager/ui/screens/autofill/AutofillSavePromptScreen.kt
new file mode 100644
index 0000000..a11f618
--- /dev/null
+++ b/app/src/main/java/com/nexpass/passwordmanager/ui/screens/autofill/AutofillSavePromptScreen.kt
@@ -0,0 +1,333 @@
+package com.nexpass.passwordmanager.ui.screens.autofill
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import com.nexpass.passwordmanager.ui.components.*
+import com.nexpass.passwordmanager.ui.viewmodel.AutofillSavePromptViewModel
+import com.nexpass.passwordmanager.ui.viewmodel.AutofillSavePromptUiState
+import com.nexpass.passwordmanager.ui.viewmodel.AutofillSaveFormField
+
+/**
+ * Screen for prompting user to save autofilled credentials.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AutofillSavePromptScreen(
+ packageName: String,
+ webDomain: String?,
+ viewModel: AutofillSavePromptViewModel,
+ onSaveSuccess: (Boolean) -> Unit, // true for new, false for update
+ onCancel: () -> Unit
+) {
+ val formData by viewModel.formData.collectAsState()
+ val uiState by viewModel.uiState.collectAsState()
+ val folders by viewModel.folders.collectAsState()
+ val tags by viewModel.tags.collectAsState()
+ val existingEntry by viewModel.existingEntry.collectAsState()
+
+ // Handle successful save
+ LaunchedEffect(uiState) {
+ when (uiState) {
+ is AutofillSavePromptUiState.SavedNew -> onSaveSuccess(true)
+ is AutofillSavePromptUiState.SavedUpdate -> onSaveSuccess(false)
+ is AutofillSavePromptUiState.Cancelled -> onCancel()
+ else -> {}
+ }
+ }
+
+ val validationErrors = (uiState as? AutofillSavePromptUiState.ValidationError)?.errors ?: emptyList()
+ val isLoading = uiState is AutofillSavePromptUiState.Saving
+
+ // Helper function to check if field has error
+ fun hasFieldError(field: String): Boolean {
+ return validationErrors.any { error ->
+ when (error) {
+ is com.nexpass.passwordmanager.domain.model.AppError.Validation.RequiredFieldMissing ->
+ error.fieldName == field
+ is com.nexpass.passwordmanager.domain.model.AppError.Validation.InvalidInput ->
+ error.fieldName == field
+ else -> false
+ }
+ }
+ }
+
+ // Helper function to get field error message
+ fun getFieldErrorMessage(field: String): String? {
+ return validationErrors.firstOrNull { error ->
+ when (error) {
+ is com.nexpass.passwordmanager.domain.model.AppError.Validation.RequiredFieldMissing ->
+ error.fieldName == field
+ is com.nexpass.passwordmanager.domain.model.AppError.Validation.InvalidInput ->
+ error.fieldName == field
+ else -> false
+ }
+ }?.message
+ }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth(0.95f)
+ .fillMaxHeight(0.85f),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp)
+ ) {
+ // Header
+ Text(
+ text = if (existingEntry != null) "Update Password" else "Save Password",
+ style = MaterialTheme.typography.headlineSmall
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "For ${webDomain ?: packageName}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Scrollable form content
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Show update notice if updating existing entry
+ if (existingEntry != null) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Text(
+ text = "A password for this site already exists. Saving will update the existing entry.",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(12.dp)
+ )
+ }
+ }
+
+ // Title field
+ NexPassTextField(
+ value = formData.title,
+ onValueChange = { viewModel.updateField(AutofillSaveFormField.TITLE, it) },
+ label = "Title",
+ placeholder = "My Account",
+ isError = hasFieldError("Title"),
+ errorMessage = getFieldErrorMessage("Title"),
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ // Username field (read-only preview)
+ OutlinedTextField(
+ value = formData.username,
+ onValueChange = { viewModel.updateField(AutofillSaveFormField.USERNAME, it) },
+ label = { Text("Username / Email") },
+ modifier = Modifier.fillMaxWidth(),
+ isError = hasFieldError("Username")
+ )
+
+ // Password field (masked preview)
+ OutlinedTextField(
+ value = formData.password,
+ onValueChange = { viewModel.updateField(AutofillSaveFormField.PASSWORD, it) },
+ label = { Text("Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ isError = hasFieldError("Password")
+ )
+
+ // URL field
+ NexPassTextField(
+ value = formData.url,
+ onValueChange = { viewModel.updateField(AutofillSaveFormField.URL, it) },
+ label = "Website URL (Optional)",
+ placeholder = "https://example.com",
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ // Notes field
+ NexPassTextField(
+ value = formData.notes,
+ onValueChange = { viewModel.updateField(AutofillSaveFormField.NOTES, it) },
+ label = "Notes (Optional)",
+ placeholder = "Additional information...",
+ singleLine = false,
+ maxLines = 3,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ // Folder selection
+ if (folders.isNotEmpty()) {
+ var folderDropdownExpanded by remember { mutableStateOf(false) }
+ val selectedFolder = folders.find { it.id == formData.folderId }
+
+ ExposedDropdownMenuBox(
+ expanded = folderDropdownExpanded,
+ onExpandedChange = { folderDropdownExpanded = it }
+ ) {
+ OutlinedTextField(
+ value = selectedFolder?.name ?: "No folder",
+ onValueChange = {},
+ readOnly = true,
+ label = { Text("Folder (Optional)") },
+ trailingIcon = {
+ Icon(Icons.Default.ArrowDropDown, "Select folder")
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor()
+ )
+
+ ExposedDropdownMenu(
+ expanded = folderDropdownExpanded,
+ onDismissRequest = { folderDropdownExpanded = false }
+ ) {
+ // No folder option
+ DropdownMenuItem(
+ text = { Text("No folder") },
+ onClick = {
+ viewModel.updateFolder(null)
+ folderDropdownExpanded = false
+ }
+ )
+
+ HorizontalDivider()
+
+ // All folders
+ folders.forEach { folder ->
+ DropdownMenuItem(
+ text = { Text(folder.name) },
+ onClick = {
+ viewModel.updateFolder(folder.id)
+ folderDropdownExpanded = false
+ }
+ )
+ }
+ }
+ }
+ }
+
+ // Tags selection (compact)
+ if (tags.isNotEmpty()) {
+ Text(
+ text = "Tags (Optional)",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ tags.forEach { tag ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .toggleable(
+ value = formData.selectedTagIds.contains(tag.id),
+ onValueChange = { viewModel.toggleTag(tag.id) },
+ role = Role.Checkbox
+ )
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = formData.selectedTagIds.contains(tag.id),
+ onCheckedChange = null
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = tag.name,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+ }
+
+ // Never save again checkbox
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .toggleable(
+ value = formData.neverSaveAgain,
+ onValueChange = { viewModel.toggleNeverSaveAgain() },
+ role = Role.Checkbox
+ )
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = formData.neverSaveAgain,
+ onCheckedChange = null
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "Never save passwords for this site",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Error message
+ if (uiState is AutofillSavePromptUiState.Error) {
+ ErrorMessage(
+ error = (uiState as AutofillSavePromptUiState.Error).error,
+ onDismiss = { viewModel.clearError() }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ // Action buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = { viewModel.cancel() },
+ modifier = Modifier.weight(1f),
+ enabled = !isLoading
+ ) {
+ Text("Don't Save")
+ }
+
+ Button(
+ onClick = { viewModel.savePassword() },
+ modifier = Modifier.weight(1f),
+ enabled = !isLoading
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text(if (existingEntry != null) "Update" else "Save")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nexpass/passwordmanager/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/nexpass/passwordmanager/ui/screens/settings/SettingsScreen.kt
index 6407a69..322f4a4 100644
--- a/app/src/main/java/com/nexpass/passwordmanager/ui/screens/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/nexpass/passwordmanager/ui/screens/settings/SettingsScreen.kt
@@ -1,6 +1,8 @@
package com.nexpass.passwordmanager.ui.screens.settings
+import android.Manifest
import android.net.Uri
+import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
@@ -73,6 +75,16 @@ fun SettingsScreen(
val snackbarHostState = remember { SnackbarHostState() }
+ // Notification permission launcher (Android 13+)
+ val notificationPermissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ if (isGranted) {
+ snackbarMessage = "Notification permission granted"
+ }
+ // No rationale needed - just silent fail, notifications are optional
+ }
+
// Export file picker
val exportFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
@@ -145,6 +157,19 @@ fun SettingsScreen(
snackbarMessage = null
}
}
+
+ // Request notification permission on Android 13+ when autosave is enabled
+ LaunchedEffect(uiState.autosaveEnabled) {
+ if (uiState.autosaveEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ // Check if permission is not granted
+ val hasPermission = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
+ android.content.pm.PackageManager.PERMISSION_GRANTED
+ if (!hasPermission) {
+ notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+ }
+
Scaffold(
topBar = {
TopAppBar(
@@ -323,6 +348,88 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp))
+ // Autofill & Autosave Section
+ Text(
+ text = "Autofill & Autosave",
+ style = MaterialTheme.typography.headlineSmall
+ )
+
+ // Autosave toggle
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Ask to Save Passwords",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "Get notified when you log in to save new passwords or update existing ones",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Switch(
+ checked = uiState.autosaveEnabled,
+ onCheckedChange = { viewModel.toggleAutosave(it) }
+ )
+ }
+
+ if (uiState.autosaveEnabled) {
+ HorizontalDivider()
+ Text(
+ text = "How it works:",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "• When you log in to apps or websites, you'll receive a notification\n• Tap the notification to save the password to your vault\n• Works without requiring sensitive permissions",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ // Clear never-save list
+ if (uiState.neverSaveDomainsCount > 0) {
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "Never Save List",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "You've chosen not to save passwords for ${uiState.neverSaveDomainsCount} sites/apps",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ OutlinedButton(
+ onClick = { viewModel.clearNeverSaveList() },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Clear Never Save List")
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
// Data Management Section
Text(
text = "Data Management",
@@ -959,6 +1066,7 @@ fun SettingsScreen(
}
)
}
+
}
}
}
diff --git a/app/src/main/java/com/nexpass/passwordmanager/ui/viewmodel/AutofillSavePromptViewModel.kt b/app/src/main/java/com/nexpass/passwordmanager/ui/viewmodel/AutofillSavePromptViewModel.kt
new file mode 100644
index 0000000..eebe064
--- /dev/null
+++ b/app/src/main/java/com/nexpass/passwordmanager/ui/viewmodel/AutofillSavePromptViewModel.kt
@@ -0,0 +1,307 @@
+package com.nexpass.passwordmanager.ui.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.nexpass.passwordmanager.domain.model.AppError
+import com.nexpass.passwordmanager.domain.model.Folder
+import com.nexpass.passwordmanager.domain.model.PasswordEntry
+import com.nexpass.passwordmanager.domain.model.Tag
+import com.nexpass.passwordmanager.domain.repository.FolderRepository
+import com.nexpass.passwordmanager.domain.repository.PasswordRepository
+import com.nexpass.passwordmanager.domain.repository.TagRepository
+import com.nexpass.passwordmanager.data.local.preferences.SecurePreferences
+import com.nexpass.passwordmanager.util.toAppError
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import java.util.UUID
+
+/**
+ * ViewModel for autofill save prompt dialog.
+ *
+ * Handles:
+ * - Form state management for saving autofilled credentials
+ * - Duplicate detection and update logic
+ * - Folder and tag selection
+ * - Never-save-again functionality
+ */
+class AutofillSavePromptViewModel(
+ private val passwordRepository: PasswordRepository,
+ private val folderRepository: FolderRepository,
+ private val tagRepository: TagRepository,
+ private val securePreferences: SecurePreferences
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(
+ AutofillSavePromptUiState.Idle
+ )
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _formData = MutableStateFlow(AutofillSaveFormData())
+ val formData: StateFlow = _formData.asStateFlow()
+
+ private val _folders = MutableStateFlow>(emptyList())
+ val folders: StateFlow> = _folders.asStateFlow()
+
+ private val _tags = MutableStateFlow>(emptyList())
+ val tags: StateFlow> = _tags.asStateFlow()
+
+ private val _existingEntry = MutableStateFlow(null)
+ val existingEntry: StateFlow = _existingEntry.asStateFlow()
+
+ init {
+ loadFolders()
+ loadTags()
+ }
+
+ /**
+ * Load available folders.
+ */
+ private fun loadFolders() {
+ viewModelScope.launch {
+ folderRepository.getAllFlow().collect { folders ->
+ _folders.value = folders
+ }
+ }
+ }
+
+ /**
+ * Load available tags.
+ */
+ private fun loadTags() {
+ viewModelScope.launch {
+ tagRepository.getAllFlow().collect { tags ->
+ _tags.value = tags
+ }
+ }
+ }
+
+ /**
+ * Initialize the form with autofill data.
+ */
+ fun initializeWithAutofillData(
+ username: String,
+ password: String,
+ domain: String?,
+ packageName: String
+ ) {
+ viewModelScope.launch {
+ try {
+ // Check for existing entry
+ val existing = findExistingEntry(domain, packageName)
+ _existingEntry.value = existing
+
+ // Set form data
+ val defaultFolder = securePreferences.getAutosaveDefaultFolder()
+ _formData.value = AutofillSaveFormData(
+ title = domain ?: packageName,
+ username = username,
+ password = password,
+ url = domain?.let { "https://$it" } ?: "",
+ packageName = packageName,
+ webDomain = domain,
+ folderId = existing?.folderId ?: defaultFolder,
+ selectedTagIds = existing?.tags ?: emptyList(),
+ neverSaveAgain = false,
+ isUpdate = existing != null
+ )
+ } catch (e: Exception) {
+ val appError = e.toAppError()
+ _uiState.value = AutofillSavePromptUiState.Error(appError)
+ }
+ }
+ }
+
+ /**
+ * Find existing password entry for the same domain/package.
+ */
+ private suspend fun findExistingEntry(domain: String?, packageName: String): PasswordEntry? {
+ val allEntries = passwordRepository.getAll()
+
+ return allEntries.firstOrNull { entry ->
+ // Check domain match
+ val domainMatches = domain != null &&
+ (entry.url?.contains(domain, ignoreCase = true) == true)
+
+ // Check package name match
+ val packageMatches = entry.packageNames.contains(packageName)
+
+ domainMatches || packageMatches
+ }
+ }
+
+ /**
+ * Update form field.
+ */
+ fun updateField(field: AutofillSaveFormField, value: String) {
+ _formData.value = when (field) {
+ AutofillSaveFormField.TITLE -> _formData.value.copy(title = value)
+ AutofillSaveFormField.USERNAME -> _formData.value.copy(username = value)
+ AutofillSaveFormField.PASSWORD -> _formData.value.copy(password = value)
+ AutofillSaveFormField.URL -> _formData.value.copy(url = value)
+ AutofillSaveFormField.NOTES -> _formData.value.copy(notes = value)
+ }
+ }
+
+ /**
+ * Update selected folder.
+ */
+ fun updateFolder(folderId: String?) {
+ _formData.value = _formData.value.copy(folderId = folderId)
+ }
+
+ /**
+ * Toggle tag selection.
+ */
+ fun toggleTag(tagId: String) {
+ val currentTags = _formData.value.selectedTagIds.toMutableList()
+ if (currentTags.contains(tagId)) {
+ currentTags.remove(tagId)
+ } else {
+ currentTags.add(tagId)
+ }
+ _formData.value = _formData.value.copy(selectedTagIds = currentTags)
+ }
+
+ /**
+ * Toggle never save again checkbox.
+ */
+ fun toggleNeverSaveAgain() {
+ _formData.value = _formData.value.copy(
+ neverSaveAgain = !_formData.value.neverSaveAgain
+ )
+ }
+
+ /**
+ * Validate form data.
+ */
+ private fun validate(): ValidationResult {
+ val errors = mutableListOf()
+
+ if (_formData.value.title.isBlank()) {
+ errors.add(AppError.Validation.RequiredFieldMissing("Title"))
+ }
+
+ if (_formData.value.username.isBlank()) {
+ errors.add(AppError.Validation.RequiredFieldMissing("Username"))
+ }
+
+ if (_formData.value.password.isBlank()) {
+ errors.add(AppError.Validation.RequiredFieldMissing("Password"))
+ }
+
+ return ValidationResult(
+ isValid = errors.isEmpty(),
+ errors = errors
+ )
+ }
+
+ /**
+ * Save the password entry.
+ */
+ fun savePassword() {
+ viewModelScope.launch {
+ try {
+ val validation = validate()
+ if (!validation.isValid) {
+ _uiState.value = AutofillSavePromptUiState.ValidationError(validation.errors)
+ return@launch
+ }
+
+ _uiState.value = AutofillSavePromptUiState.Saving
+
+ val data = _formData.value
+
+ // Handle never-save-again
+ if (data.neverSaveAgain) {
+ val identifier = data.webDomain ?: data.packageName
+ securePreferences.addNeverSaveDomain(identifier)
+ _uiState.value = AutofillSavePromptUiState.Cancelled
+ return@launch
+ }
+
+ // Create or update password entry
+ val entry = PasswordEntry(
+ id = _existingEntry.value?.id ?: UUID.randomUUID().toString(),
+ title = data.title,
+ username = data.username,
+ password = data.password,
+ url = data.url.ifBlank { null },
+ notes = data.notes.ifBlank { null },
+ folderId = data.folderId,
+ tags = data.selectedTagIds,
+ packageNames = listOf(data.packageName),
+ favorite = _existingEntry.value?.favorite ?: false,
+ createdAt = _existingEntry.value?.createdAt ?: System.currentTimeMillis(),
+ updatedAt = System.currentTimeMillis(),
+ lastModified = System.currentTimeMillis(),
+ isQuarantined = false,
+ revisionId = _existingEntry.value?.revisionId
+ )
+
+ if (data.isUpdate) {
+ passwordRepository.update(entry)
+ _uiState.value = AutofillSavePromptUiState.SavedUpdate
+ } else {
+ passwordRepository.insert(entry)
+ _uiState.value = AutofillSavePromptUiState.SavedNew
+ }
+ } catch (e: Exception) {
+ val appError = e.toAppError()
+ _uiState.value = AutofillSavePromptUiState.Error(appError)
+ }
+ }
+ }
+
+ /**
+ * Cancel the save operation.
+ */
+ fun cancel() {
+ _uiState.value = AutofillSavePromptUiState.Cancelled
+ }
+
+ /**
+ * Clear error state.
+ */
+ fun clearError() {
+ _uiState.value = AutofillSavePromptUiState.Idle
+ }
+}
+
+/**
+ * Form data for autofill save.
+ */
+data class AutofillSaveFormData(
+ val title: String = "",
+ val username: String = "",
+ val password: String = "",
+ val url: String = "",
+ val notes: String = "",
+ val packageName: String = "",
+ val webDomain: String? = null,
+ val folderId: String? = null,
+ val selectedTagIds: List = emptyList(),
+ val neverSaveAgain: Boolean = false,
+ val isUpdate: Boolean = false
+)
+
+/**
+ * Form fields enum.
+ */
+enum class AutofillSaveFormField {
+ TITLE, USERNAME, PASSWORD, URL, NOTES
+}
+
+/**
+ * UI state for autofill save prompt.
+ */
+sealed class AutofillSavePromptUiState {
+ data object Idle : AutofillSavePromptUiState()
+ data object Saving : AutofillSavePromptUiState()
+ data object SavedNew : AutofillSavePromptUiState()
+ data object SavedUpdate : AutofillSavePromptUiState()
+ data object Cancelled : AutofillSavePromptUiState()
+ data class ValidationError(val errors: List) : AutofillSavePromptUiState()
+ data class Error(val error: AppError) : AutofillSavePromptUiState()
+}
diff --git a/app/src/main/java/com/nexpass/passwordmanager/ui/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/nexpass/passwordmanager/ui/viewmodel/SettingsViewModel.kt
index 71bd12f..778f728 100644
--- a/app/src/main/java/com/nexpass/passwordmanager/ui/viewmodel/SettingsViewModel.kt
+++ b/app/src/main/java/com/nexpass/passwordmanager/ui/viewmodel/SettingsViewModel.kt
@@ -26,6 +26,7 @@ import java.io.OutputStream
* - Auto-lock timeout
* - Theme selection
* - Clear vault data
+ * - Autosave toggle
*/
class SettingsViewModel(
private val securePreferences: SecurePreferences,
@@ -57,6 +58,8 @@ class SettingsViewModel(
val lastSyncTimestamp = securePreferences.getLastSyncTimestamp()
val themeMode = ThemeMode.fromString(securePreferences.getThemeMode())
val autoLockTimeout = securePreferences.getAutoLockTimeout()
+ val autosaveEnabled = securePreferences.isAutosaveEnabled()
+ val neverSaveDomainsCount = securePreferences.getNeverSaveDomains().size
_uiState.value = SettingsUiState(
isBiometricAvailable = isBiometricAvailable,
@@ -67,7 +70,9 @@ class SettingsViewModel(
nextcloudUsername = nextcloudUsername,
nextcloudConfigured = securePreferences.isNextcloudConfigured(),
syncEnabled = syncEnabled,
- lastSyncTimestamp = if (lastSyncTimestamp > 0) lastSyncTimestamp else null
+ lastSyncTimestamp = if (lastSyncTimestamp > 0) lastSyncTimestamp else null,
+ autosaveEnabled = autosaveEnabled,
+ neverSaveDomainsCount = neverSaveDomainsCount
)
}
@@ -184,6 +189,22 @@ class SettingsViewModel(
_uiState.value = _uiState.value.copy(autoLockTimeout = minutes)
}
+ /**
+ * Toggle autosave enabled/disabled.
+ */
+ fun toggleAutosave(enabled: Boolean) {
+ securePreferences.setAutosaveEnabled(enabled)
+ _uiState.value = _uiState.value.copy(autosaveEnabled = enabled)
+ }
+
+ /**
+ * Clear the never-save domains list.
+ */
+ fun clearNeverSaveList() {
+ securePreferences.clearNeverSaveDomains()
+ _uiState.value = _uiState.value.copy(neverSaveDomainsCount = 0)
+ }
+
// ========== Export/Import Methods ==========
/**
@@ -352,6 +373,8 @@ data class SettingsUiState(
val isBiometricEnabled: Boolean = false,
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val autoLockTimeout: Int = 5, // minutes (-1 for never)
+ val autosaveEnabled: Boolean = true,
+ val neverSaveDomainsCount: Int = 0,
val nextcloudServerUrl: String = "",
val nextcloudUsername: String = "",
val nextcloudConfigured: Boolean = false,