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,