Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
<!-- Biometric authentication -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />

<!-- Notification permissions for autosave prompts (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
android:name=".PasswordManagerApplication"
android:allowBackup="false"
Expand Down Expand Up @@ -59,6 +62,33 @@
android:taskAffinity=""
android:launchMode="singleTask" />

<!-- Autofill Save Prompt Activity - For prompting to save passwords -->
<activity
android:name=".autofill.ui.AutofillSavePromptActivity"
android:exported="false"
android:theme="@style/Theme.NexPass.Transparent"
android:excludeFromRecents="true"
android:taskAffinity=""
android:launchMode="singleTask" />

<!-- Manual Save Capture Activity - For capturing credentials to save manually -->
<activity
android:name=".autofill.ui.ManualSaveCaptureActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:excludeFromRecents="true"
android:taskAffinity=""
android:launchMode="singleTask" />

<!-- Notification Password Input Activity - Launched from save password notifications -->
<activity
android:name=".autofill.ui.NotificationPasswordInputActivity"
android:exported="false"
android:theme="@style/Theme.NexPass.Transparent"
android:excludeFromRecents="true"
android:taskAffinity=""
android:launchMode="singleTask" />

<!-- Security Settings -->
<meta-data
android:name="android.security.KEYSTORE_PATH_RESTRICTIONS_ENABLED"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package com.nexpass.passwordmanager.autofill.notification

import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.nexpass.passwordmanager.R

/**
* Manages notifications for autosave feature.
* Shows notifications prompting users to save passwords.
*/
class AutosaveNotificationManager(private val context: Context) {

companion object {
private const val TAG = "AutosaveNotificationMgr"
private const val CHANNEL_ID = "autosave_channel"
private const val CHANNEL_NAME = "Password Save Requests"
private const val NOTIFICATION_ID = 1001
const val EXTRA_PACKAGE_NAME = "packageName"
const val EXTRA_WEB_DOMAIN = "webDomain"
}

private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val notificationManagerCompat = NotificationManagerCompat.from(context)

init {
createNotificationChannel()
}

/**
* Create notification channel for autosave (Android 8.0+)
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<AutofillField>,
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
}
}
Expand Down Expand Up @@ -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<AutofillId>(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()
}

/**
Expand Down Expand Up @@ -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()
}
}
Loading