Skip to content

Commit 93759e3

Browse files
authored
Merge pull request #3 from codegax/feat/autosave
Feat/autosave
2 parents 1579ade + 4f1abd2 commit 93759e3

File tree

13 files changed

+1866
-35
lines changed

13 files changed

+1866
-35
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
<!-- Biometric authentication -->
1010
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
1111

12+
<!-- Notification permissions for autosave prompts (Android 13+) -->
13+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
14+
1215
<application
1316
android:name=".PasswordManagerApplication"
1417
android:allowBackup="false"
@@ -59,6 +62,33 @@
5962
android:taskAffinity=""
6063
android:launchMode="singleTask" />
6164

65+
<!-- Autofill Save Prompt Activity - For prompting to save passwords -->
66+
<activity
67+
android:name=".autofill.ui.AutofillSavePromptActivity"
68+
android:exported="false"
69+
android:theme="@style/Theme.NexPass.Transparent"
70+
android:excludeFromRecents="true"
71+
android:taskAffinity=""
72+
android:launchMode="singleTask" />
73+
74+
<!-- Manual Save Capture Activity - For capturing credentials to save manually -->
75+
<activity
76+
android:name=".autofill.ui.ManualSaveCaptureActivity"
77+
android:exported="false"
78+
android:theme="@android:style/Theme.Translucent.NoTitleBar"
79+
android:excludeFromRecents="true"
80+
android:taskAffinity=""
81+
android:launchMode="singleTask" />
82+
83+
<!-- Notification Password Input Activity - Launched from save password notifications -->
84+
<activity
85+
android:name=".autofill.ui.NotificationPasswordInputActivity"
86+
android:exported="false"
87+
android:theme="@style/Theme.NexPass.Transparent"
88+
android:excludeFromRecents="true"
89+
android:taskAffinity=""
90+
android:launchMode="singleTask" />
91+
6292
<!-- Security Settings -->
6393
<meta-data
6494
android:name="android.security.KEYSTORE_PATH_RESTRICTIONS_ENABLED"
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package com.nexpass.passwordmanager.autofill.notification
2+
3+
import android.Manifest
4+
import android.app.NotificationChannel
5+
import android.app.NotificationManager
6+
import android.app.PendingIntent
7+
import android.content.Context
8+
import android.content.Intent
9+
import android.content.pm.PackageManager
10+
import android.os.Build
11+
import android.util.Log
12+
import androidx.core.app.NotificationCompat
13+
import androidx.core.app.NotificationManagerCompat
14+
import androidx.core.content.ContextCompat
15+
import com.nexpass.passwordmanager.R
16+
17+
/**
18+
* Manages notifications for autosave feature.
19+
* Shows notifications prompting users to save passwords.
20+
*/
21+
class AutosaveNotificationManager(private val context: Context) {
22+
23+
companion object {
24+
private const val TAG = "AutosaveNotificationMgr"
25+
private const val CHANNEL_ID = "autosave_channel"
26+
private const val CHANNEL_NAME = "Password Save Requests"
27+
private const val NOTIFICATION_ID = 1001
28+
const val EXTRA_PACKAGE_NAME = "packageName"
29+
const val EXTRA_WEB_DOMAIN = "webDomain"
30+
}
31+
32+
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
33+
private val notificationManagerCompat = NotificationManagerCompat.from(context)
34+
35+
init {
36+
createNotificationChannel()
37+
}
38+
39+
/**
40+
* Create notification channel for autosave (Android 8.0+)
41+
*/
42+
private fun createNotificationChannel() {
43+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
44+
val channel = NotificationChannel(
45+
CHANNEL_ID,
46+
CHANNEL_NAME,
47+
NotificationManager.IMPORTANCE_HIGH
48+
).apply {
49+
description = "Notifications asking if you want to save passwords to NexPass"
50+
setShowBadge(true)
51+
enableLights(true)
52+
enableVibration(false)
53+
}
54+
notificationManager.createNotificationChannel(channel)
55+
}
56+
}
57+
58+
/**
59+
* Show a notification prompting to save password.
60+
*
61+
* @param packageName The package name of the app with the login form
62+
* @param webDomain The web domain (if browser), null otherwise
63+
*/
64+
fun showSavePasswordNotification(packageName: String, webDomain: String?) {
65+
Log.d(TAG, "=== showSavePasswordNotification called ===")
66+
Log.d(TAG, "Package: $packageName, Domain: $webDomain")
67+
68+
// Check for POST_NOTIFICATIONS permission on Android 13+
69+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
70+
val hasPermission = ContextCompat.checkSelfPermission(
71+
context,
72+
Manifest.permission.POST_NOTIFICATIONS
73+
) == PackageManager.PERMISSION_GRANTED
74+
75+
if (!hasPermission) {
76+
Log.w(TAG, "⚠️ POST_NOTIFICATIONS permission NOT GRANTED (Android 13+)")
77+
Log.w(TAG, "User needs to grant notification permission in app settings")
78+
return
79+
}
80+
Log.d(TAG, "✅ POST_NOTIFICATIONS permission granted (Android 13+)")
81+
}
82+
83+
// Check if notifications are enabled (for older Android versions)
84+
if (!notificationManagerCompat.areNotificationsEnabled()) {
85+
Log.w(TAG, "⚠️ Notifications are DISABLED for NexPass!")
86+
Log.w(TAG, "User needs to enable notifications in Settings > Apps > NexPass > Notifications")
87+
return
88+
}
89+
90+
Log.d(TAG, "✅ Notifications are enabled")
91+
92+
val displayName = webDomain ?: packageName
93+
Log.d(TAG, "Display name: $displayName")
94+
95+
// Create intent to launch password input activity when notification is tapped
96+
val intent = Intent(context, com.nexpass.passwordmanager.autofill.ui.NotificationPasswordInputActivity::class.java).apply {
97+
putExtra(EXTRA_PACKAGE_NAME, packageName)
98+
putExtra(EXTRA_WEB_DOMAIN, webDomain)
99+
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
100+
}
101+
102+
val pendingIntent = PendingIntent.getActivity(
103+
context,
104+
0,
105+
intent,
106+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
107+
)
108+
109+
Log.d(TAG, "Building notification...")
110+
111+
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
112+
.setSmallIcon(R.drawable.ic_launcher_foreground)
113+
.setContentTitle("Save password to NexPass?")
114+
.setContentText("Tap to save password for $displayName")
115+
.setPriority(NotificationCompat.PRIORITY_HIGH)
116+
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
117+
.setAutoCancel(true) // Dismiss when tapped
118+
.setContentIntent(pendingIntent)
119+
.build()
120+
121+
try {
122+
Log.d(TAG, "Posting notification with ID: $NOTIFICATION_ID")
123+
notificationManagerCompat.notify(NOTIFICATION_ID, notification)
124+
Log.d(TAG, "✅ Notification posted successfully!")
125+
} catch (e: Exception) {
126+
Log.e(TAG, "❌ Failed to post notification", e)
127+
}
128+
}
129+
130+
/**
131+
* Cancel the save password notification.
132+
*/
133+
fun cancelSavePasswordNotification() {
134+
notificationManager.cancel(NOTIFICATION_ID)
135+
}
136+
}

app/src/main/java/com/nexpass/passwordmanager/autofill/service/AutofillResponseBuilder.kt

Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,94 @@ class AutofillResponseBuilder(private val context: Context) {
4242
dataset?.let { fillResponseBuilder.addDataset(it) }
4343
}
4444

45-
// Add save info to allow saving new credentials
45+
// Add "Save Password" manual trigger dataset
46+
android.util.Log.d("AutofillResponseBuilder", "=== Attempting to build manual save dataset ===")
47+
android.util.Log.d("AutofillResponseBuilder", "Fields count: ${fields.size}, Package: $packageName")
48+
val saveDataset = buildManualSaveDataset(fields, packageName)
49+
if (saveDataset != null) {
50+
android.util.Log.d("AutofillResponseBuilder", "✅ Manual save dataset created successfully, adding to response")
51+
fillResponseBuilder.addDataset(saveDataset)
52+
} else {
53+
android.util.Log.w("AutofillResponseBuilder", "❌ Manual save dataset is NULL - not added to response")
54+
}
55+
56+
// Add save info to allow saving new credentials (still needed for native apps)
4657
val saveInfo = buildSaveInfo(fields)
47-
saveInfo?.let { fillResponseBuilder.setSaveInfo(it) }
58+
if (saveInfo != null) {
59+
fillResponseBuilder.setSaveInfo(saveInfo)
60+
android.util.Log.d("AutofillResponseBuilder", "SaveInfo added to FillResponse")
61+
} else {
62+
android.util.Log.w("AutofillResponseBuilder", "SaveInfo is NULL - won't trigger save dialog!")
63+
}
4864

4965
return try {
5066
fillResponseBuilder.build()
5167
} catch (e: Exception) {
68+
android.util.Log.e("AutofillResponseBuilder", "Failed to build FillResponse", e)
69+
null
70+
}
71+
}
72+
73+
/**
74+
* Build a special "Save Password" dataset that launches manual save flow.
75+
*/
76+
private fun buildManualSaveDataset(
77+
fields: List<AutofillField>,
78+
packageName: String
79+
): Dataset? {
80+
android.util.Log.d("AutofillResponseBuilder", "buildManualSaveDataset() called for package: $packageName")
81+
82+
// Create intent to launch manual save capture
83+
val saveIntent = Intent(context, com.nexpass.passwordmanager.autofill.ui.ManualSaveCaptureActivity::class.java).apply {
84+
putExtra("packageName", packageName)
85+
// NOTE: Dataset authentication does NOT provide EXTRA_ASSIST_STRUCTURE automatically!
86+
// This is a known Android limitation - only FillResponse.setAuthentication() provides it
87+
}
88+
89+
val savePendingIntent = PendingIntent.getActivity(
90+
context,
91+
1, // Different request code from auth prompt
92+
saveIntent,
93+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
94+
)
95+
96+
// Create presentation for "Save Password" option
97+
val presentation = RemoteViews(context.packageName, R.layout.autofill_item)
98+
presentation.setTextViewText(R.id.autofill_title, "\uD83D\uDCBE Save This Password")
99+
presentation.setTextViewText(R.id.autofill_username, "Tap to save current credentials")
100+
presentation.setImageViewResource(R.id.autofill_icon, android.R.drawable.ic_menu_save)
101+
102+
val datasetBuilder = Dataset.Builder(presentation)
103+
104+
// Set authentication - when user selects this, Android launches our activity
105+
// with EXTRA_ASSIST_STRUCTURE containing current field values
106+
val usernameField = fields.firstOrNull {
107+
it.fieldType == FieldType.USERNAME || it.fieldType == FieldType.EMAIL
108+
}
109+
val passwordField = fields.firstOrNull { it.fieldType == FieldType.PASSWORD }
110+
111+
// We need at least one field to attach the authentication to
112+
passwordField?.let { field ->
113+
android.util.Log.d("AutofillResponseBuilder", "Found password field for manual save dataset")
114+
datasetBuilder.setValue(
115+
field.autofillId,
116+
null, // Don't actually fill any value
117+
presentation
118+
)
119+
} ?: run {
120+
android.util.Log.w("AutofillResponseBuilder", "⚠️ No password field found - manual save dataset may not work!")
121+
}
122+
123+
// Set authentication on the dataset
124+
datasetBuilder.setAuthentication(savePendingIntent.intentSender)
125+
android.util.Log.d("AutofillResponseBuilder", "Set authentication on manual save dataset")
126+
127+
return try {
128+
val dataset = datasetBuilder.build()
129+
android.util.Log.d("AutofillResponseBuilder", "✅ Manual save dataset built successfully")
130+
dataset
131+
} catch (e: Exception) {
132+
android.util.Log.e("AutofillResponseBuilder", "❌ Failed to build manual save dataset", e)
52133
null
53134
}
54135
}
@@ -143,18 +224,37 @@ class AutofillResponseBuilder(private val context: Context) {
143224
}
144225
val passwordField = fields.firstOrNull { it.fieldType == FieldType.PASSWORD }
145226

227+
android.util.Log.d("AutofillResponseBuilder", "buildSaveInfo - usernameField: ${usernameField != null}, passwordField: ${passwordField != null}")
228+
146229
// We need at least a password field to save
147230
if (passwordField == null) {
231+
android.util.Log.w("AutofillResponseBuilder", "No password field found - cannot build SaveInfo")
148232
return null
149233
}
150234

151-
val requiredIds = mutableListOf<AutofillId>(passwordField.autofillId)
152-
usernameField?.let { requiredIds.add(it.autofillId) }
235+
// Only password is required, username is optional
236+
val requiredIds = arrayOf(passwordField.autofillId)
153237

154-
return SaveInfo.Builder(
238+
val builder = SaveInfo.Builder(
155239
SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,
156-
requiredIds.toTypedArray()
157-
).build()
240+
requiredIds
241+
)
242+
243+
// Add optional username field if present
244+
usernameField?.let {
245+
builder.setOptionalIds(arrayOf(it.autofillId))
246+
android.util.Log.d("AutofillResponseBuilder", "Added optional username field to SaveInfo")
247+
}
248+
249+
// Set flags to trigger save more aggressively
250+
// FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE: Trigger save when all views become invisible (form submission/navigation)
251+
// FLAG_DELAY_SAVE: Delay the save UI to better detect form submissions
252+
val flags = SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE or SaveInfo.FLAG_DELAY_SAVE
253+
builder.setFlags(flags)
254+
android.util.Log.d("AutofillResponseBuilder", "Set SaveInfo flags: FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE | FLAG_DELAY_SAVE")
255+
256+
android.util.Log.d("AutofillResponseBuilder", "Successfully built SaveInfo")
257+
return builder.build()
158258
}
159259

160260
/**
@@ -194,6 +294,15 @@ class AutofillResponseBuilder(private val context: Context) {
194294
presentation
195295
)
196296

297+
// IMPORTANT: Add SaveInfo so Android triggers save dialog after form submission
298+
val saveInfo = buildSaveInfo(fields)
299+
if (saveInfo != null) {
300+
responseBuilder.setSaveInfo(saveInfo)
301+
android.util.Log.d("AutofillResponseBuilder", "SaveInfo added to authentication response")
302+
} else {
303+
android.util.Log.w("AutofillResponseBuilder", "SaveInfo is NULL in authentication response")
304+
}
305+
197306
return responseBuilder.build()
198307
}
199308
}

0 commit comments

Comments
 (0)