Add the reason why a save dialog was not shown to FillEventHistory.

In order to provide a better user experience, it would better know the
reason why a save dialog was not shown and improve the autofill experience.
So it is necessary to add the reason why a save dialog was not shown to
FillEventHistory.

Bug: 158328375
Test: atest FillEventHistoryTest
Test: atest InlineFillEventHistoryTest
Change-Id: I50c1e40334066d3f4844426c4b03294079a967b2
diff --git a/api/current.txt b/api/current.txt
index 9b9e258..11ca10c 100644
--- a/api/current.txt
+++ b/api/current.txt
@@ -43168,8 +43168,16 @@
     method @NonNull public java.util.Map<android.view.autofill.AutofillId,android.service.autofill.FieldClassification> getFieldsClassification();
     method @NonNull public java.util.Set<java.lang.String> getIgnoredDatasetIds();
     method @NonNull public java.util.Map<android.view.autofill.AutofillId,java.util.Set<java.lang.String>> getManuallyEnteredField();
+    method public int getNoSaveReason();
     method @NonNull public java.util.Set<java.lang.String> getSelectedDatasetIds();
     method public int getType();
+    field public static final int NO_SAVE_REASON_DATASET_MATCH = 6; // 0x6
+    field public static final int NO_SAVE_REASON_FIELD_VALIDATION_FAILED = 5; // 0x5
+    field public static final int NO_SAVE_REASON_HAS_EMPTY_REQUIRED = 3; // 0x3
+    field public static final int NO_SAVE_REASON_NONE = 0; // 0x0
+    field public static final int NO_SAVE_REASON_NO_SAVE_INFO = 1; // 0x1
+    field public static final int NO_SAVE_REASON_NO_VALUE_CHANGED = 4; // 0x4
+    field public static final int NO_SAVE_REASON_WITH_DELAY_SAVE_FLAG = 2; // 0x2
     field public static final int TYPE_AUTHENTICATION_SELECTED = 2; // 0x2
     field public static final int TYPE_CONTEXT_COMMITTED = 4; // 0x4
     field public static final int TYPE_DATASETS_SHOWN = 5; // 0x5
diff --git a/core/java/android/service/autofill/FillEventHistory.java b/core/java/android/service/autofill/FillEventHistory.java
index 1cd2d62..f226528 100644
--- a/core/java/android/service/autofill/FillEventHistory.java
+++ b/core/java/android/service/autofill/FillEventHistory.java
@@ -159,6 +159,7 @@
                     FieldClassification.writeArrayToParcel(parcel,
                             event.mDetectedFieldClassifications);
                 }
+                parcel.writeInt(event.mSaveDialogNotShowReason);
             }
         }
     }
@@ -243,6 +244,40 @@
         @Retention(RetentionPolicy.SOURCE)
         @interface EventIds{}
 
+        /** No reason for save dialog. */
+        public static final int NO_SAVE_REASON_NONE = 0;
+
+        /** The SaveInfo associated with the FillResponse is null. */
+        public static final int NO_SAVE_REASON_NO_SAVE_INFO = 1;
+
+        /** The service asked to delay save. */
+        public static final int NO_SAVE_REASON_WITH_DELAY_SAVE_FLAG = 2;
+
+        /** There was empty value for required ids. */
+        public static final int NO_SAVE_REASON_HAS_EMPTY_REQUIRED = 3;
+
+        /** No value has been changed. */
+        public static final int NO_SAVE_REASON_NO_VALUE_CHANGED = 4;
+
+        /** Fields failed validation. */
+        public static final int NO_SAVE_REASON_FIELD_VALIDATION_FAILED = 5;
+
+        /** All fields matched contents of datasets. */
+        public static final int NO_SAVE_REASON_DATASET_MATCH = 6;
+
+        /** @hide */
+        @IntDef(prefix = { "NO_SAVE_REASON_" }, value = {
+                NO_SAVE_REASON_NONE,
+                NO_SAVE_REASON_NO_SAVE_INFO,
+                NO_SAVE_REASON_WITH_DELAY_SAVE_FLAG,
+                NO_SAVE_REASON_HAS_EMPTY_REQUIRED,
+                NO_SAVE_REASON_NO_VALUE_CHANGED,
+                NO_SAVE_REASON_FIELD_VALIDATION_FAILED,
+                NO_SAVE_REASON_DATASET_MATCH
+        })
+        @Retention(RetentionPolicy.SOURCE)
+        public @interface NoSaveReason{}
+
         @EventIds private final int mEventType;
         @Nullable private final String mDatasetId;
         @Nullable private final Bundle mClientState;
@@ -261,6 +296,8 @@
         @Nullable private final AutofillId[] mDetectedFieldIds;
         @Nullable private final FieldClassification[] mDetectedFieldClassifications;
 
+        @NoSaveReason private final int mSaveDialogNotShowReason;
+
         /**
          * Returns the type of the event.
          *
@@ -448,6 +485,18 @@
         }
 
         /**
+         * Returns the reason why a save dialog was not shown.
+         *
+         * <p><b>Note: </b>Only set on events of type {@link #TYPE_CONTEXT_COMMITTED}. For the other
+         * event types, the reason is set to NO_SAVE_REASON_NONE.
+         *
+         * @return The reason why a save dialog was not shown.
+         */
+        public int getNoSaveReason() {
+            return mSaveDialogNotShowReason;
+        }
+
+        /**
          * Creates a new event.
          *
          * @param eventType The type of the event
@@ -481,6 +530,48 @@
                 @Nullable ArrayList<ArrayList<String>> manuallyFilledDatasetIds,
                 @Nullable AutofillId[] detectedFieldIds,
                 @Nullable FieldClassification[] detectedFieldClassifications) {
+            this(eventType, datasetId, clientState, selectedDatasetIds, ignoredDatasetIds,
+                    changedFieldIds, changedDatasetIds, manuallyFilledFieldIds,
+                    manuallyFilledDatasetIds, detectedFieldIds, detectedFieldClassifications,
+                    NO_SAVE_REASON_NONE);
+        }
+
+        /**
+         * Creates a new event.
+         *
+         * @param eventType The type of the event
+         * @param datasetId The dataset the event was on, or {@code null} if the event was on the
+         *                  whole response.
+         * @param clientState The client state associated with the event.
+         * @param selectedDatasetIds The ids of datasets selected by the user.
+         * @param ignoredDatasetIds The ids of datasets NOT select by the user.
+         * @param changedFieldIds The ids of fields changed by the user.
+         * @param changedDatasetIds The ids of the datasets that havd values matching the
+         * respective entry on {@code changedFieldIds}.
+         * @param manuallyFilledFieldIds The ids of fields that were manually entered by the user
+         * and belonged to datasets.
+         * @param manuallyFilledDatasetIds The ids of datasets that had values matching the
+         * respective entry on {@code manuallyFilledFieldIds}.
+         * @param detectedFieldClassifications the field classification matches.
+         * @param saveDialogNotShowReason The reason why a save dialog was not shown.
+         *
+         * @throws IllegalArgumentException If the length of {@code changedFieldIds} and
+         * {@code changedDatasetIds} doesn't match.
+         * @throws IllegalArgumentException If the length of {@code manuallyFilledFieldIds} and
+         * {@code manuallyFilledDatasetIds} doesn't match.
+         *
+         * @hide
+         */
+        public Event(int eventType, @Nullable String datasetId, @Nullable Bundle clientState,
+                @Nullable List<String> selectedDatasetIds,
+                @Nullable ArraySet<String> ignoredDatasetIds,
+                @Nullable ArrayList<AutofillId> changedFieldIds,
+                @Nullable ArrayList<String> changedDatasetIds,
+                @Nullable ArrayList<AutofillId> manuallyFilledFieldIds,
+                @Nullable ArrayList<ArrayList<String>> manuallyFilledDatasetIds,
+                @Nullable AutofillId[] detectedFieldIds,
+                @Nullable FieldClassification[] detectedFieldClassifications,
+                int saveDialogNotShowReason) {
             mEventType = Preconditions.checkArgumentInRange(eventType, 0, TYPE_DATASETS_SHOWN,
                     "eventType");
             mDatasetId = datasetId;
@@ -506,6 +597,10 @@
 
             mDetectedFieldIds = detectedFieldIds;
             mDetectedFieldClassifications = detectedFieldClassifications;
+
+            mSaveDialogNotShowReason = Preconditions.checkArgumentInRange(saveDialogNotShowReason,
+                    NO_SAVE_REASON_NONE, NO_SAVE_REASON_DATASET_MATCH,
+                    "saveDialogNotShowReason");
         }
 
         @Override
@@ -521,6 +616,7 @@
                     + ", detectedFieldIds=" + Arrays.toString(mDetectedFieldIds)
                     + ", detectedFieldClassifications ="
                         + Arrays.toString(mDetectedFieldClassifications)
+                    + ", saveDialogNotShowReason=" + mSaveDialogNotShowReason
                     + "]";
         }
     }
@@ -562,12 +658,14 @@
                                 (detectedFieldIds != null)
                                 ? FieldClassification.readArrayFromParcel(parcel)
                                 : null;
+                        final int saveDialogNotShowReason = parcel.readInt();
 
                         selection.addEvent(new Event(eventType, datasetId, clientState,
                                 selectedDatasetIds, ignoredDatasets,
                                 changedFieldIds, changedDatasetIds,
                                 manuallyFilledFieldIds, manuallyFilledDatasetIds,
-                                detectedFieldIds, detectedFieldClassifications));
+                                detectedFieldIds, detectedFieldClassifications,
+                                saveDialogNotShowReason));
                     }
                     return selection;
                 }
diff --git a/non-updatable-api/current.txt b/non-updatable-api/current.txt
index d62e132..ff301b6 100644
--- a/non-updatable-api/current.txt
+++ b/non-updatable-api/current.txt
@@ -41311,8 +41311,16 @@
     method @NonNull public java.util.Map<android.view.autofill.AutofillId,android.service.autofill.FieldClassification> getFieldsClassification();
     method @NonNull public java.util.Set<java.lang.String> getIgnoredDatasetIds();
     method @NonNull public java.util.Map<android.view.autofill.AutofillId,java.util.Set<java.lang.String>> getManuallyEnteredField();
+    method public int getNoSaveReason();
     method @NonNull public java.util.Set<java.lang.String> getSelectedDatasetIds();
     method public int getType();
+    field public static final int NO_SAVE_REASON_DATASET_MATCH = 6; // 0x6
+    field public static final int NO_SAVE_REASON_FIELD_VALIDATION_FAILED = 5; // 0x5
+    field public static final int NO_SAVE_REASON_HAS_EMPTY_REQUIRED = 3; // 0x3
+    field public static final int NO_SAVE_REASON_NONE = 0; // 0x0
+    field public static final int NO_SAVE_REASON_NO_SAVE_INFO = 1; // 0x1
+    field public static final int NO_SAVE_REASON_NO_VALUE_CHANGED = 4; // 0x4
+    field public static final int NO_SAVE_REASON_WITH_DELAY_SAVE_FLAG = 2; // 0x2
     field public static final int TYPE_AUTHENTICATION_SELECTED = 2; // 0x2
     field public static final int TYPE_CONTEXT_COMMITTED = 4; // 0x4
     field public static final int TYPE_DATASETS_SHOWN = 5; // 0x5
diff --git a/services/autofill/java/com/android/server/autofill/AutofillManagerServiceImpl.java b/services/autofill/java/com/android/server/autofill/AutofillManagerServiceImpl.java
index 57ffe04..a6b82f4 100644
--- a/services/autofill/java/com/android/server/autofill/AutofillManagerServiceImpl.java
+++ b/services/autofill/java/com/android/server/autofill/AutofillManagerServiceImpl.java
@@ -55,6 +55,7 @@
 import android.service.autofill.FieldClassification.Match;
 import android.service.autofill.FillEventHistory;
 import android.service.autofill.FillEventHistory.Event;
+import android.service.autofill.FillEventHistory.Event.NoSaveReason;
 import android.service.autofill.FillResponse;
 import android.service.autofill.IAutoFillService;
 import android.service.autofill.InlineSuggestionRenderService;
@@ -429,9 +430,15 @@
             return;
         }
 
-        session.logContextCommitted();
+        final Session.SaveResult saveResult = session.showSaveLocked();
 
-        final boolean finished = session.showSaveLocked();
+        session.logContextCommitted(saveResult.getNoSaveReason());
+
+        if (saveResult.isLogSaveShown()) {
+            session.logSaveUiShown();
+        }
+
+        final boolean finished = saveResult.isRemoveSession();
         if (sVerbose) Slog.v(TAG, "finishSessionLocked(): session finished on save? " + finished);
 
         if (finished) {
@@ -868,7 +875,9 @@
             @NonNull ComponentName appComponentName, boolean compatMode) {
         logContextCommittedLocked(sessionId, clientState, selectedDatasets, ignoredDatasets,
                 changedFieldIds, changedDatasetIds, manuallyFilledFieldIds,
-                manuallyFilledDatasetIds, null, null, appComponentName, compatMode);
+                manuallyFilledDatasetIds, /* detectedFieldIdsList= */ null,
+                /* detectedFieldClassificationsList= */ null, appComponentName, compatMode,
+                Event.NO_SAVE_REASON_NONE);
     }
 
     @GuardedBy("mLock")
@@ -881,7 +890,8 @@
             @Nullable ArrayList<ArrayList<String>> manuallyFilledDatasetIds,
             @Nullable ArrayList<AutofillId> detectedFieldIdsList,
             @Nullable ArrayList<FieldClassification> detectedFieldClassificationsList,
-            @NonNull ComponentName appComponentName, boolean compatMode) {
+            @NonNull ComponentName appComponentName, boolean compatMode,
+            @NoSaveReason int saveDialogNotShowReason) {
         if (isValidEventLocked("logDatasetNotSelected()", sessionId)) {
             if (sVerbose) {
                 Slog.v(TAG, "logContextCommitted() with FieldClassification: id=" + sessionId
@@ -893,7 +903,8 @@
                         + ", detectedFieldIds=" + detectedFieldIdsList
                         + ", detectedFieldClassifications=" + detectedFieldClassificationsList
                         + ", appComponentName=" + appComponentName.toShortString()
-                        + ", compatMode=" + compatMode);
+                        + ", compatMode=" + compatMode
+                        + ", saveDialogNotShowReason=" + saveDialogNotShowReason);
             }
             AutofillId[] detectedFieldsIds = null;
             FieldClassification[] detectedFieldClassifications = null;
@@ -929,7 +940,7 @@
                     clientState, selectedDatasets, ignoredDatasets,
                     changedFieldIds, changedDatasetIds,
                     manuallyFilledFieldIds, manuallyFilledDatasetIds,
-                    detectedFieldsIds, detectedFieldClassifications));
+                    detectedFieldsIds, detectedFieldClassifications, saveDialogNotShowReason));
         }
     }
 
diff --git a/services/autofill/java/com/android/server/autofill/Session.java b/services/autofill/java/com/android/server/autofill/Session.java
index 1970b57..0e2eef8 100644
--- a/services/autofill/java/com/android/server/autofill/Session.java
+++ b/services/autofill/java/com/android/server/autofill/Session.java
@@ -71,6 +71,8 @@
 import android.service.autofill.FieldClassification.Match;
 import android.service.autofill.FieldClassificationUserData;
 import android.service.autofill.FillContext;
+import android.service.autofill.FillEventHistory.Event;
+import android.service.autofill.FillEventHistory.Event.NoSaveReason;
 import android.service.autofill.FillRequest;
 import android.service.autofill.FillResponse;
 import android.service.autofill.InlinePresentation;
@@ -1596,10 +1598,22 @@
      * when necessary.
      */
     public void logContextCommitted() {
-        mHandler.sendMessage(obtainMessage(Session::handleLogContextCommitted, this));
+        mHandler.sendMessage(obtainMessage(Session::handleLogContextCommitted, this,
+                Event.NO_SAVE_REASON_NONE));
     }
 
-    private void handleLogContextCommitted() {
+    /**
+     * Generates a {@link android.service.autofill.FillEventHistory.Event#TYPE_CONTEXT_COMMITTED}
+     * when necessary.
+     *
+     * @param saveDialogNotShowReason The reason why a save dialog was not shown.
+     */
+    public void logContextCommitted(@NoSaveReason int saveDialogNotShowReason) {
+        mHandler.sendMessage(obtainMessage(Session::handleLogContextCommitted, this,
+                saveDialogNotShowReason));
+    }
+
+    private void handleLogContextCommitted(@NoSaveReason int saveDialogNotShowReason) {
         final FillResponse lastResponse;
         synchronized (mLock) {
             lastResponse = getLastResponseLocked("logContextCommited(%s)");
@@ -1629,22 +1643,25 @@
 
         // Sets field classification scores
         if (userData != null && fcStrategy != null) {
-            logFieldClassificationScore(fcStrategy, userData);
+            logFieldClassificationScore(fcStrategy, userData, saveDialogNotShowReason);
         } else {
-            logContextCommitted(null, null);
+            logContextCommitted(null, null, saveDialogNotShowReason);
         }
     }
 
     private void logContextCommitted(@Nullable ArrayList<AutofillId> detectedFieldIds,
-            @Nullable ArrayList<FieldClassification> detectedFieldClassifications) {
+            @Nullable ArrayList<FieldClassification> detectedFieldClassifications,
+            @NoSaveReason int saveDialogNotShowReason) {
         synchronized (mLock) {
-            logContextCommittedLocked(detectedFieldIds, detectedFieldClassifications);
+            logContextCommittedLocked(detectedFieldIds, detectedFieldClassifications,
+                    saveDialogNotShowReason);
         }
     }
 
     @GuardedBy("mLock")
     private void logContextCommittedLocked(@Nullable ArrayList<AutofillId> detectedFieldIds,
-            @Nullable ArrayList<FieldClassification> detectedFieldClassifications) {
+            @Nullable ArrayList<FieldClassification> detectedFieldClassifications,
+            @NoSaveReason int saveDialogNotShowReason) {
         final FillResponse lastResponse = getLastResponseLocked("logContextCommited(%s)");
         if (lastResponse == null) return;
 
@@ -1822,10 +1839,10 @@
             }
         }
 
-        mService.logContextCommittedLocked(id, mClientState, mSelectedDatasetIds,
-                ignoredDatasets, changedFieldIds, changedDatasetIds,
-                manuallyFilledFieldIds, manuallyFilledDatasetIds, detectedFieldIds,
-                detectedFieldClassifications, mComponentName, mCompatMode);
+        mService.logContextCommittedLocked(id, mClientState, mSelectedDatasetIds, ignoredDatasets,
+                changedFieldIds, changedDatasetIds, manuallyFilledFieldIds,
+                manuallyFilledDatasetIds, detectedFieldIds, detectedFieldClassifications,
+                mComponentName, mCompatMode, saveDialogNotShowReason);
     }
 
     /**
@@ -1833,7 +1850,8 @@
      * {@code fieldId} based on its {@code currentValue} and {@code userData}.
      */
     private void logFieldClassificationScore(@NonNull FieldClassificationStrategy fcStrategy,
-            @NonNull FieldClassificationUserData userData) {
+            @NonNull FieldClassificationUserData userData,
+            @NoSaveReason int saveDialogNotShowReason) {
 
         final String[] userValues = userData.getValues();
         final String[] categoryIds = userData.getCategoryIds();
@@ -1879,7 +1897,7 @@
         final RemoteCallback callback = new RemoteCallback((result) -> {
             if (result == null) {
                 if (sDebug) Slog.d(TAG, "setFieldClassificationScore(): no results");
-                logContextCommitted(null, null);
+                logContextCommitted(null, null, saveDialogNotShowReason);
                 return;
             }
             final Scores scores = result.getParcelable(EXTRA_SCORES);
@@ -1940,7 +1958,8 @@
                 return;
             }
 
-            logContextCommitted(detectedFieldIds, detectedFieldClassifications);
+            logContextCommitted(detectedFieldIds, detectedFieldClassifications,
+                    saveDialogNotShowReason);
         });
 
         fcStrategy.calculateScores(callback, currentValues, userValues, categoryIds,
@@ -1948,17 +1967,28 @@
     }
 
     /**
+     * Generates a {@link android.service.autofill.FillEventHistory.Event#TYPE_SAVE_SHOWN}
+     * when necessary.
+     *
+     * <p>Note: It is necessary to call logContextCommitted() first before calling this method.
+     */
+    public void logSaveUiShown() {
+        mHandler.sendMessage(obtainMessage(Session::logSaveShown, this));
+    }
+
+    /**
      * Shows the save UI, when session can be saved.
      *
-     * @return {@code true} if session is done and could be removed, or {@code false} if it's
-     * pending user action or the service asked to keep it alive (for multi-screens workflow).
+     * @return {@link SaveResult} that contains the save ui display status information.
      */
     @GuardedBy("mLock")
-    public boolean showSaveLocked() {
+    @NonNull
+    public SaveResult showSaveLocked() {
         if (mDestroyed) {
             Slog.w(TAG, "Call to Session#showSaveLocked() rejected - session: "
                     + id + " destroyed");
-            return false;
+            return new SaveResult(/* logSaveShown= */ false, /* removeSession= */ false,
+                    Event.NO_SAVE_REASON_NONE);
         }
         final FillResponse response = getLastResponseLocked("showSaveLocked(%s)");
         final SaveInfo saveInfo = response == null ? null : response.getSaveInfo();
@@ -1975,13 +2005,15 @@
          */
         if (saveInfo == null) {
             if (sVerbose) Slog.v(TAG, "showSaveLocked(" + this.id + "): no saveInfo from service");
-            return true;
+            return new SaveResult(/* logSaveShown= */ false, /* removeSession= */ true,
+                    Event.NO_SAVE_REASON_NO_SAVE_INFO);
         }
 
         if ((saveInfo.getFlags() & SaveInfo.FLAG_DELAY_SAVE) != 0) {
             // TODO(b/113281366): log metrics
             if (sDebug) Slog.v(TAG, "showSaveLocked(" + this.id + "): service asked to delay save");
-            return false;
+            return new SaveResult(/* logSaveShown= */ false, /* removeSession= */ false,
+                    Event.NO_SAVE_REASON_WITH_DELAY_SAVE_FLAG);
         }
 
         final ArrayMap<AutofillId, InternalSanitizer> sanitizers = createSanitizers(saveInfo);
@@ -2073,7 +2105,10 @@
             Slog.v(TAG, "allRequiredAreNotEmpty: " + allRequiredAreNotEmpty + " hasOptional: "
                     + (optionalIds != null));
         }
-        if (allRequiredAreNotEmpty) {
+        int saveDialogNotShowReason;
+        if (!allRequiredAreNotEmpty) {
+            saveDialogNotShowReason = Event.NO_SAVE_REASON_HAS_EMPTY_REQUIRED;
+        } else {
             // Must look up all optional ids in 2 scenarios:
             // - if no required id changed but an optional id did, it should trigger save / update
             // - if at least one required id changed but it was not part of a filled dataset, we
@@ -2124,7 +2159,9 @@
                     }
                 }
             }
-            if (atLeastOneChanged) {
+            if (!atLeastOneChanged) {
+                saveDialogNotShowReason = Event.NO_SAVE_REASON_NO_VALUE_CHANGED;
+            } else {
                 if (sDebug) {
                     Slog.d(TAG, "at least one field changed, validate fields for save UI");
                 }
@@ -2142,13 +2179,15 @@
                         Slog.e(TAG, "Not showing save UI because validation failed:", e);
                         log.setType(MetricsEvent.TYPE_FAILURE);
                         mMetricsLogger.write(log);
-                        return true;
+                        return new SaveResult(/* logSaveShown= */ false, /* removeSession= */ true,
+                                Event.NO_SAVE_REASON_FIELD_VALIDATION_FAILED);
                     }
 
                     mMetricsLogger.write(log);
                     if (!isValid) {
                         Slog.i(TAG, "not showing save UI because fields failed validation");
-                        return true;
+                        return new SaveResult(/* logSaveShown= */ false, /* removeSession= */ true,
+                                Event.NO_SAVE_REASON_FIELD_VALIDATION_FAILED);
                     }
                 }
 
@@ -2187,7 +2226,8 @@
                             Slog.d(TAG, "ignoring Save UI because all fields match contents of "
                                     + "dataset #" + i + ": " + dataset);
                         }
-                        return true;
+                        return new SaveResult(/* logSaveShown= */ false, /* removeSession= */ true,
+                                Event.NO_SAVE_REASON_DATASET_MATCH);
                     }
                 }
 
@@ -2196,9 +2236,6 @@
                             + id + "!");
                 }
 
-                // Use handler so logContextCommitted() is logged first
-                mHandler.sendMessage(obtainMessage(Session::logSaveShown, this));
-
                 final IAutoFillManagerClient client = getClient();
                 mPendingSaveUi = new PendingUi(new Binder(), id, client);
 
@@ -2210,8 +2247,10 @@
                 }
                 if (serviceLabel == null || serviceIcon == null) {
                     wtf(null, "showSaveLocked(): no service label or icon");
-                    return true;
+                    return new SaveResult(/* logSaveShown= */ false, /* removeSession= */ true,
+                            Event.NO_SAVE_REASON_NONE);
                 }
+
                 getUiForShowing().showSaveUi(serviceLabel, serviceIcon,
                         mService.getServicePackageName(), saveInfo, this,
                         mComponentName, this, mPendingSaveUi, isUpdate, mCompatMode);
@@ -2223,7 +2262,8 @@
                     }
                 }
                 mIsSaving = true;
-                return false;
+                return new SaveResult(/* logSaveShown= */ true, /* removeSession= */ false,
+                        Event.NO_SAVE_REASON_NONE);
             }
         }
         // Nothing changed...
@@ -2232,7 +2272,8 @@
                     + "allRequiredAreNotNull=" + allRequiredAreNotEmpty
                     + ", atLeastOneChanged=" + atLeastOneChanged);
         }
-        return true;
+        return new SaveResult(/* logSaveShown= */ false, /* removeSession= */ true,
+                saveDialogNotShowReason);
     }
 
     private void logSaveShown() {
@@ -3586,6 +3627,97 @@
         }
     }
 
+    /**
+     * The result of checking whether to show the save dialog, when session can be saved.
+     *
+     * @hide
+     */
+    static final class SaveResult {
+        /**
+         * Whether to record the save dialog has been shown.
+         */
+        private boolean mLogSaveShown;
+
+        /**
+         * Whether to remove the session.
+         */
+        private boolean mRemoveSession;
+
+        /**
+         * The reason why a save dialog was not shown.
+         */
+        @NoSaveReason private int mSaveDialogNotShowReason;
+
+        SaveResult(boolean logSaveShown, boolean removeSession,
+                @NoSaveReason int saveDialogNotShowReason) {
+            mLogSaveShown = logSaveShown;
+            mRemoveSession = removeSession;
+            mSaveDialogNotShowReason = saveDialogNotShowReason;
+        }
+
+        /**
+         * Returns whether to record the save dialog has been shown.
+         *
+         * @return Whether to record the save dialog has been shown.
+         */
+        public boolean isLogSaveShown() {
+            return mLogSaveShown;
+        }
+
+        /**
+         * Sets whether to record the save dialog has been shown.
+         *
+         * @param logSaveShown Whether to record the save dialog has been shown.
+         */
+        public void setLogSaveShown(boolean logSaveShown) {
+            mLogSaveShown = logSaveShown;
+        }
+
+        /**
+         * Returns whether to remove the session.
+         *
+         * @return Whether to remove the session.
+         */
+        public boolean isRemoveSession() {
+            return mRemoveSession;
+        }
+
+        /**
+         * Sets whether to remove the session.
+         *
+         * @param removeSession Whether to remove the session.
+         */
+        public void setRemoveSession(boolean removeSession) {
+            mRemoveSession = removeSession;
+        }
+
+        /**
+         * Returns the reason why a save dialog was not shown.
+         *
+         * @return The reason why a save dialog was not shown.
+         */
+        @NoSaveReason
+        public int getNoSaveReason() {
+            return mSaveDialogNotShowReason;
+        }
+
+        /**
+         * Sets the reason why a save dialog was not shown.
+         *
+         * @param saveDialogNotShowReason The reason why a save dialog was not shown.
+         */
+        public void setSaveDialogNotShowReason(@NoSaveReason int saveDialogNotShowReason) {
+            mSaveDialogNotShowReason = saveDialogNotShowReason;
+        }
+
+        @Override
+        public String toString() {
+            return "SaveResult: [logSaveShown=" + mLogSaveShown
+                    + ", removeSession=" + mRemoveSession
+                    + ", saveDialogNotShowReason=" + mSaveDialogNotShowReason + "]";
+        }
+    }
+
     @Override
     public String toString() {
         return "Session: [id=" + id + ", component=" + mComponentName + "]";