Home | History | Annotate | Download | only in ui
      1 /*
      2  * Copyright (C) 2017 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.server.autofill.ui;
     18 
     19 import static com.android.server.autofill.Helper.sDebug;
     20 import static com.android.server.autofill.Helper.sVerbose;
     21 
     22 import android.annotation.NonNull;
     23 import android.annotation.Nullable;
     24 import android.app.Dialog;
     25 import android.app.PendingIntent;
     26 import android.content.ComponentName;
     27 import android.content.Context;
     28 import android.content.Intent;
     29 import android.content.IntentSender;
     30 import android.content.res.Resources;
     31 import android.graphics.drawable.Drawable;
     32 import android.metrics.LogMaker;
     33 import android.os.Handler;
     34 import android.os.IBinder;
     35 import android.os.RemoteException;
     36 import android.service.autofill.BatchUpdates;
     37 import android.service.autofill.CustomDescription;
     38 import android.service.autofill.InternalTransformation;
     39 import android.service.autofill.InternalValidator;
     40 import android.service.autofill.SaveInfo;
     41 import android.service.autofill.ValueFinder;
     42 import android.text.Html;
     43 import android.util.ArraySet;
     44 import android.util.Pair;
     45 import android.util.Slog;
     46 import android.view.ContextThemeWrapper;
     47 import android.view.Gravity;
     48 import android.view.LayoutInflater;
     49 import android.view.View;
     50 import android.view.ViewGroup;
     51 import android.view.ViewGroup.LayoutParams;
     52 import android.view.Window;
     53 import android.view.WindowManager;
     54 import android.view.autofill.AutofillManager;
     55 import android.widget.ImageView;
     56 import android.widget.RemoteViews;
     57 import android.widget.TextView;
     58 
     59 import com.android.internal.R;
     60 import com.android.internal.logging.MetricsLogger;
     61 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
     62 import com.android.server.UiThread;
     63 import com.android.server.autofill.Helper;
     64 
     65 import java.io.PrintWriter;
     66 import java.util.ArrayList;
     67 
     68 /**
     69  * Autofill Save Prompt
     70  */
     71 final class SaveUi {
     72 
     73     private static final String TAG = "AutofillSaveUi";
     74 
     75     private static final int THEME_ID =
     76             com.android.internal.R.style.Theme_DeviceDefault_Autofill_Save;
     77 
     78     public interface OnSaveListener {
     79         void onSave();
     80         void onCancel(IntentSender listener);
     81         void onDestroy();
     82     }
     83 
     84     private class OneTimeListener implements OnSaveListener {
     85 
     86         private final OnSaveListener mRealListener;
     87         private boolean mDone;
     88 
     89         OneTimeListener(OnSaveListener realListener) {
     90             mRealListener = realListener;
     91         }
     92 
     93         @Override
     94         public void onSave() {
     95             if (sDebug) Slog.d(TAG, "OneTimeListener.onSave(): " + mDone);
     96             if (mDone) {
     97                 return;
     98             }
     99             mDone = true;
    100             mRealListener.onSave();
    101         }
    102 
    103         @Override
    104         public void onCancel(IntentSender listener) {
    105             if (sDebug) Slog.d(TAG, "OneTimeListener.onCancel(): " + mDone);
    106             if (mDone) {
    107                 return;
    108             }
    109             mDone = true;
    110             mRealListener.onCancel(listener);
    111         }
    112 
    113         @Override
    114         public void onDestroy() {
    115             if (sDebug) Slog.d(TAG, "OneTimeListener.onDestroy(): " + mDone);
    116             if (mDone) {
    117                 return;
    118             }
    119             mDone = true;
    120             mRealListener.onDestroy();
    121         }
    122     }
    123 
    124     private final Handler mHandler = UiThread.getHandler();
    125     private final MetricsLogger mMetricsLogger = new MetricsLogger();
    126 
    127     private final @NonNull Dialog mDialog;
    128 
    129     private final @NonNull OneTimeListener mListener;
    130 
    131     private final @NonNull OverlayControl mOverlayControl;
    132 
    133     private final CharSequence mTitle;
    134     private final CharSequence mSubTitle;
    135     private final PendingUi mPendingUi;
    136     private final String mServicePackageName;
    137     private final ComponentName mComponentName;
    138     private final boolean mCompatMode;
    139 
    140     private boolean mDestroyed;
    141 
    142     SaveUi(@NonNull Context context, @NonNull PendingUi pendingUi,
    143            @NonNull CharSequence serviceLabel, @NonNull Drawable serviceIcon,
    144            @Nullable String servicePackageName, @NonNull ComponentName componentName,
    145            @NonNull SaveInfo info, @NonNull ValueFinder valueFinder,
    146            @NonNull OverlayControl overlayControl, @NonNull OnSaveListener listener,
    147            boolean compatMode) {
    148         mPendingUi= pendingUi;
    149         mListener = new OneTimeListener(listener);
    150         mOverlayControl = overlayControl;
    151         mServicePackageName = servicePackageName;
    152         mComponentName = componentName;
    153         mCompatMode = compatMode;
    154 
    155         context = new ContextThemeWrapper(context, THEME_ID);
    156         final LayoutInflater inflater = LayoutInflater.from(context);
    157         final View view = inflater.inflate(R.layout.autofill_save, null);
    158 
    159         final TextView titleView = view.findViewById(R.id.autofill_save_title);
    160 
    161         final ArraySet<String> types = new ArraySet<>(3);
    162         final int type = info.getType();
    163 
    164         if ((type & SaveInfo.SAVE_DATA_TYPE_PASSWORD) != 0) {
    165             types.add(context.getString(R.string.autofill_save_type_password));
    166         }
    167         if ((type & SaveInfo.SAVE_DATA_TYPE_ADDRESS) != 0) {
    168             types.add(context.getString(R.string.autofill_save_type_address));
    169         }
    170         if ((type & SaveInfo.SAVE_DATA_TYPE_CREDIT_CARD) != 0) {
    171             types.add(context.getString(R.string.autofill_save_type_credit_card));
    172         }
    173         if ((type & SaveInfo.SAVE_DATA_TYPE_USERNAME) != 0) {
    174             types.add(context.getString(R.string.autofill_save_type_username));
    175         }
    176         if ((type & SaveInfo.SAVE_DATA_TYPE_EMAIL_ADDRESS) != 0) {
    177             types.add(context.getString(R.string.autofill_save_type_email_address));
    178         }
    179 
    180         switch (types.size()) {
    181             case 1:
    182                 mTitle = Html.fromHtml(context.getString(R.string.autofill_save_title_with_type,
    183                         types.valueAt(0), serviceLabel), 0);
    184                 break;
    185             case 2:
    186                 mTitle = Html.fromHtml(context.getString(R.string.autofill_save_title_with_2types,
    187                         types.valueAt(0), types.valueAt(1), serviceLabel), 0);
    188                 break;
    189             case 3:
    190                 mTitle = Html.fromHtml(context.getString(R.string.autofill_save_title_with_3types,
    191                         types.valueAt(0), types.valueAt(1), types.valueAt(2), serviceLabel), 0);
    192                 break;
    193             default:
    194                 // Use generic if more than 3 or invalid type (size 0).
    195                 mTitle = Html.fromHtml(
    196                         context.getString(R.string.autofill_save_title, serviceLabel), 0);
    197         }
    198         titleView.setText(mTitle);
    199 
    200         setServiceIcon(context, view, serviceIcon);
    201 
    202         final boolean hasCustomDescription =
    203                 applyCustomDescription(context, view, valueFinder, info);
    204         if (hasCustomDescription) {
    205             mSubTitle = null;
    206             if (sDebug) Slog.d(TAG, "on constructor: applied custom description");
    207         } else {
    208             mSubTitle = info.getDescription();
    209             if (mSubTitle != null) {
    210                 writeLog(MetricsEvent.AUTOFILL_SAVE_CUSTOM_SUBTITLE, type);
    211                 final ViewGroup subtitleContainer =
    212                         view.findViewById(R.id.autofill_save_custom_subtitle);
    213                 final TextView subtitleView = new TextView(context);
    214                 subtitleView.setText(mSubTitle);
    215                 subtitleContainer.addView(subtitleView,
    216                         new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
    217                                 ViewGroup.LayoutParams.WRAP_CONTENT));
    218                 subtitleContainer.setVisibility(View.VISIBLE);
    219             }
    220             if (sDebug) Slog.d(TAG, "on constructor: title=" + mTitle + ", subTitle=" + mSubTitle);
    221         }
    222 
    223         final TextView noButton = view.findViewById(R.id.autofill_save_no);
    224         if (info.getNegativeActionStyle() == SaveInfo.NEGATIVE_BUTTON_STYLE_REJECT) {
    225             noButton.setText(R.string.save_password_notnow);
    226         } else {
    227             noButton.setText(R.string.autofill_save_no);
    228         }
    229         noButton.setOnClickListener((v) -> mListener.onCancel(info.getNegativeActionListener()));
    230 
    231         final View yesButton = view.findViewById(R.id.autofill_save_yes);
    232         yesButton.setOnClickListener((v) -> mListener.onSave());
    233 
    234         mDialog = new Dialog(context, THEME_ID);
    235         mDialog.setContentView(view);
    236 
    237         // Dialog can be dismissed when touched outside, but the negative listener should not be
    238         // notified (hence the null argument).
    239         mDialog.setOnDismissListener((d) -> mListener.onCancel(null));
    240 
    241         final Window window = mDialog.getWindow();
    242         window.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY);
    243         window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
    244                 | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
    245                 | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH);
    246         window.addPrivateFlags(WindowManager.LayoutParams.PRIVATE_FLAG_SHOW_FOR_ALL_USERS);
    247         window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN);
    248         window.setGravity(Gravity.BOTTOM | Gravity.CENTER);
    249         window.setCloseOnTouchOutside(true);
    250         final WindowManager.LayoutParams params = window.getAttributes();
    251         params.width = WindowManager.LayoutParams.MATCH_PARENT;
    252         params.accessibilityTitle = context.getString(R.string.autofill_save_accessibility_title);
    253         params.windowAnimations = R.style.AutofillSaveAnimation;
    254 
    255         show();
    256     }
    257 
    258     private boolean applyCustomDescription(@NonNull Context context, @NonNull View saveUiView,
    259             @NonNull ValueFinder valueFinder, @NonNull SaveInfo info) {
    260         final CustomDescription customDescription = info.getCustomDescription();
    261         if (customDescription == null) {
    262             return false;
    263         }
    264         final int type = info.getType();
    265         writeLog(MetricsEvent.AUTOFILL_SAVE_CUSTOM_DESCRIPTION, type);
    266 
    267         final RemoteViews template = customDescription.getPresentation();
    268         if (template == null) {
    269             Slog.w(TAG, "No remote view on custom description");
    270             return false;
    271         }
    272 
    273         // First apply the unconditional transformations (if any) to the templates.
    274         final ArrayList<Pair<Integer, InternalTransformation>> transformations =
    275                 customDescription.getTransformations();
    276         if (transformations != null) {
    277             if (!InternalTransformation.batchApply(valueFinder, template, transformations)) {
    278                 Slog.w(TAG, "could not apply main transformations on custom description");
    279                 return false;
    280             }
    281         }
    282 
    283         final RemoteViews.OnClickHandler handler = new RemoteViews.OnClickHandler() {
    284             @Override
    285             public boolean onClickHandler(View view, PendingIntent pendingIntent,
    286                     Intent intent) {
    287                 final LogMaker log =
    288                         newLogMaker(MetricsEvent.AUTOFILL_SAVE_LINK_TAPPED, type);
    289                 // We need to hide the Save UI before launching the pending intent, and
    290                 // restore back it once the activity is finished, and that's achieved by
    291                 // adding a custom extra in the activity intent.
    292                 final boolean isValid = isValidLink(pendingIntent, intent);
    293                 if (!isValid) {
    294                     log.setType(MetricsEvent.TYPE_UNKNOWN);
    295                     mMetricsLogger.write(log);
    296                     return false;
    297                 }
    298                 if (sVerbose) Slog.v(TAG, "Intercepting custom description intent");
    299                 final IBinder token = mPendingUi.getToken();
    300                 intent.putExtra(AutofillManager.EXTRA_RESTORE_SESSION_TOKEN, token);
    301                 try {
    302                     mPendingUi.client.startIntentSender(pendingIntent.getIntentSender(),
    303                             intent);
    304                     mPendingUi.setState(PendingUi.STATE_PENDING);
    305                     if (sDebug) Slog.d(TAG, "hiding UI until restored with token " + token);
    306                     hide();
    307                     log.setType(MetricsEvent.TYPE_OPEN);
    308                     mMetricsLogger.write(log);
    309                     return true;
    310                 } catch (RemoteException e) {
    311                     Slog.w(TAG, "error triggering pending intent: " + intent);
    312                     log.setType(MetricsEvent.TYPE_FAILURE);
    313                     mMetricsLogger.write(log);
    314                     return false;
    315                 }
    316             }
    317         };
    318 
    319         try {
    320             // Create the remote view peer.
    321             template.setApplyTheme(THEME_ID);
    322             final View customSubtitleView = template.apply(context, null, handler);
    323 
    324             // And apply batch updates (if any).
    325             final ArrayList<Pair<InternalValidator, BatchUpdates>> updates =
    326                     customDescription.getUpdates();
    327             if (updates != null) {
    328                 final int size = updates.size();
    329                 if (sDebug) Slog.d(TAG, "custom description has " + size + " batch updates");
    330                 for (int i = 0; i < size; i++) {
    331                     final Pair<InternalValidator, BatchUpdates> pair = updates.get(i);
    332                     final InternalValidator condition = pair.first;
    333                     if (condition == null || !condition.isValid(valueFinder)) {
    334                         if (sDebug) Slog.d(TAG, "Skipping batch update #" + i );
    335                         continue;
    336                     }
    337                     final BatchUpdates batchUpdates = pair.second;
    338                     // First apply the updates...
    339                     final RemoteViews templateUpdates = batchUpdates.getUpdates();
    340                     if (templateUpdates != null) {
    341                         if (sDebug) Slog.d(TAG, "Applying template updates for batch update #" + i);
    342                         templateUpdates.reapply(context, customSubtitleView);
    343                     }
    344                     // Then the transformations...
    345                     final ArrayList<Pair<Integer, InternalTransformation>> batchTransformations =
    346                             batchUpdates.getTransformations();
    347                     if (batchTransformations != null) {
    348                         if (sDebug) {
    349                             Slog.d(TAG, "Applying child transformation for batch update #" + i
    350                                     + ": " + batchTransformations);
    351                         }
    352                         if (!InternalTransformation.batchApply(valueFinder, template,
    353                                 batchTransformations)) {
    354                             Slog.w(TAG, "Could not apply child transformation for batch update "
    355                                     + "#" + i + ": " + batchTransformations);
    356                             return false;
    357                         }
    358                         template.reapply(context, customSubtitleView);
    359                     }
    360                 }
    361             }
    362 
    363             // Finally, add the custom description to the save UI.
    364             final ViewGroup subtitleContainer =
    365                     saveUiView.findViewById(R.id.autofill_save_custom_subtitle);
    366             subtitleContainer.addView(customSubtitleView);
    367             subtitleContainer.setVisibility(View.VISIBLE);
    368             return true;
    369         } catch (Exception e) {
    370             Slog.e(TAG, "Error applying custom description. ", e);
    371         }
    372         return false;
    373     }
    374 
    375     private void setServiceIcon(Context context, View view, Drawable serviceIcon) {
    376         final ImageView iconView = view.findViewById(R.id.autofill_save_icon);
    377         final Resources res = context.getResources();
    378 
    379         final int maxWidth = res.getDimensionPixelSize(R.dimen.autofill_save_icon_max_size);
    380         final int maxHeight = maxWidth;
    381         final int actualWidth = serviceIcon.getMinimumWidth();
    382         final int actualHeight = serviceIcon.getMinimumHeight();
    383 
    384         if (actualWidth <= maxWidth && actualHeight <= maxHeight) {
    385             if (sDebug) {
    386                 Slog.d(TAG, "Adding service icon "
    387                         + "(" + actualWidth + "x" + actualHeight + ") as it's less than maximum "
    388                         + "(" + maxWidth + "x" + maxHeight + ").");
    389             }
    390             iconView.setImageDrawable(serviceIcon);
    391         } else {
    392             Slog.w(TAG, "Not adding service icon of size "
    393                     + "(" + actualWidth + "x" + actualHeight + ") because maximum is "
    394                     + "(" + maxWidth + "x" + maxHeight + ").");
    395             ((ViewGroup)iconView.getParent()).removeView(iconView);
    396         }
    397     }
    398 
    399     private static boolean isValidLink(PendingIntent pendingIntent, Intent intent) {
    400         if (pendingIntent == null) {
    401             Slog.w(TAG, "isValidLink(): custom description without pending intent");
    402             return false;
    403         }
    404         if (!pendingIntent.isActivity()) {
    405             Slog.w(TAG, "isValidLink(): pending intent not for activity");
    406             return false;
    407         }
    408         if (intent == null) {
    409             Slog.w(TAG, "isValidLink(): no intent");
    410             return false;
    411         }
    412         return true;
    413     }
    414 
    415     private LogMaker newLogMaker(int category, int saveType) {
    416         return newLogMaker(category).addTaggedData(MetricsEvent.FIELD_AUTOFILL_SAVE_TYPE, saveType);
    417     }
    418 
    419     private LogMaker newLogMaker(int category) {
    420         return Helper.newLogMaker(category, mComponentName, mServicePackageName,
    421                 mPendingUi.sessionId, mCompatMode);
    422     }
    423 
    424     private void writeLog(int category, int saveType) {
    425         mMetricsLogger.write(newLogMaker(category, saveType));
    426     }
    427 
    428     /**
    429      * Update the pending UI, if any.
    430      *
    431      * @param operation how to update it.
    432      * @param token token associated with the pending UI - if it doesn't match the pending token,
    433      * the operation will be ignored.
    434      */
    435     void onPendingUi(int operation, @NonNull IBinder token) {
    436         if (!mPendingUi.matches(token)) {
    437             Slog.w(TAG, "restore(" + operation + "): got token " + token + " instead of "
    438                     + mPendingUi.getToken());
    439             return;
    440         }
    441         final LogMaker log = newLogMaker(MetricsEvent.AUTOFILL_PENDING_SAVE_UI_OPERATION);
    442         try {
    443             switch (operation) {
    444                 case AutofillManager.PENDING_UI_OPERATION_RESTORE:
    445                     if (sDebug) Slog.d(TAG, "Restoring save dialog for " + token);
    446                     log.setType(MetricsEvent.TYPE_OPEN);
    447                     show();
    448                     break;
    449                 case AutofillManager.PENDING_UI_OPERATION_CANCEL:
    450                     log.setType(MetricsEvent.TYPE_DISMISS);
    451                     if (sDebug) Slog.d(TAG, "Cancelling pending save dialog for " + token);
    452                     hide();
    453                     break;
    454                 default:
    455                     log.setType(MetricsEvent.TYPE_FAILURE);
    456                     Slog.w(TAG, "restore(): invalid operation " + operation);
    457             }
    458         } finally {
    459             mMetricsLogger.write(log);
    460         }
    461         mPendingUi.setState(PendingUi.STATE_FINISHED);
    462     }
    463 
    464     private void show() {
    465         Slog.i(TAG, "Showing save dialog: " + mTitle);
    466         mDialog.show();
    467         mOverlayControl.hideOverlays();
    468    }
    469 
    470     PendingUi hide() {
    471         if (sVerbose) Slog.v(TAG, "Hiding save dialog.");
    472         try {
    473             mDialog.hide();
    474         } finally {
    475             mOverlayControl.showOverlays();
    476         }
    477         return mPendingUi;
    478     }
    479 
    480     void destroy() {
    481         try {
    482             if (sDebug) Slog.d(TAG, "destroy()");
    483             throwIfDestroyed();
    484             mListener.onDestroy();
    485             mHandler.removeCallbacksAndMessages(mListener);
    486             mDialog.dismiss();
    487             mDestroyed = true;
    488         } finally {
    489             mOverlayControl.showOverlays();
    490         }
    491     }
    492 
    493     private void throwIfDestroyed() {
    494         if (mDestroyed) {
    495             throw new IllegalStateException("cannot interact with a destroyed instance");
    496         }
    497     }
    498 
    499     @Override
    500     public String toString() {
    501         return mTitle == null ? "NO TITLE" : mTitle.toString();
    502     }
    503 
    504     void dump(PrintWriter pw, String prefix) {
    505         pw.print(prefix); pw.print("title: "); pw.println(mTitle);
    506         pw.print(prefix); pw.print("subtitle: "); pw.println(mSubTitle);
    507         pw.print(prefix); pw.print("pendingUi: "); pw.println(mPendingUi);
    508         pw.print(prefix); pw.print("service: "); pw.println(mServicePackageName);
    509         pw.print(prefix); pw.print("app: "); pw.println(mComponentName.toShortString());
    510         pw.print(prefix); pw.print("compat mode: "); pw.println(mCompatMode);
    511 
    512         final View view = mDialog.getWindow().getDecorView();
    513         final int[] loc = view.getLocationOnScreen();
    514         pw.print(prefix); pw.print("coordinates: ");
    515             pw.print('('); pw.print(loc[0]); pw.print(','); pw.print(loc[1]);pw.print(')');
    516             pw.print('(');
    517                 pw.print(loc[0] + view.getWidth()); pw.print(',');
    518                 pw.print(loc[1] + view.getHeight());pw.println(')');
    519         pw.print(prefix); pw.print("destroyed: "); pw.println(mDestroyed);
    520     }
    521 }
    522