Allow insertion of images from IMEs into notification quick replies.

Test: Unit tests pass. Creating a Notification with the Notify app allows access to rich media insertion via gboard, and inserted images show up in the Notify app upon sending.

Bug: 137398133
Change-Id: I65218dfaa083f7c24512430e647d8ca79058dff9
This commit is contained in:
Aran Ink
2019-10-04 16:30:01 -04:00
parent bfd56df443
commit fd2bfd34bf
6 changed files with 270 additions and 9 deletions

View File

@@ -17,6 +17,7 @@
package com.android.internal.statusbar;
import android.app.Notification;
import android.net.Uri;
import android.content.ComponentName;
import android.graphics.Rect;
import android.os.Bundle;
@@ -77,6 +78,7 @@ interface IStatusBarService
void onNotificationSettingsViewed(String key);
void setSystemUiVisibility(int displayId, int vis, int mask, String cause);
void onNotificationBubbleChanged(String key, boolean isBubble);
void grantInlineReplyUriPermission(String key, in Uri uri);
void onGlobalActionsShown();
void onGlobalActionsHidden();

View File

@@ -23,12 +23,16 @@ import android.app.ActivityManager;
import android.app.Notification;
import android.app.PendingIntent;
import android.app.RemoteInput;
import android.content.ClipDescription;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ShortcutManager;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.ServiceManager;
import android.os.SystemClock;
import android.os.UserHandle;
import android.text.Editable;
@@ -53,8 +57,13 @@ import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.core.view.inputmethod.EditorInfoCompat;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto;
import com.android.internal.statusbar.IStatusBarService;
import com.android.systemui.Dependency;
import com.android.systemui.Interpolators;
import com.android.systemui.R;
@@ -65,6 +74,7 @@ import com.android.systemui.statusbar.notification.row.wrapper.NotificationViewW
import com.android.systemui.statusbar.notification.stack.StackStateAnimator;
import com.android.systemui.statusbar.phone.LightBarController;
import java.util.HashMap;
import java.util.function.Consumer;
/**
@@ -88,6 +98,8 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
private RemoteInputController mController;
private RemoteInputQuickSettingsDisabler mRemoteInputQuickSettingsDisabler;
private IStatusBarService mStatusBarManagerService;
private NotificationEntry mEntry;
private boolean mRemoved;
@@ -103,6 +115,8 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
public RemoteInputView(Context context, AttributeSet attrs) {
super(context, attrs);
mRemoteInputQuickSettingsDisabler = Dependency.get(RemoteInputQuickSettingsDisabler.class);
mStatusBarManagerService = IStatusBarService.Stub.asInterface(
ServiceManager.getService(Context.STATUS_BAR_SERVICE));
}
@Override
@@ -128,7 +142,7 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
if (isSoftImeEvent || isKeyboardEnterKey) {
if (mEditText.length() > 0) {
sendRemoteInput();
sendRemoteInput(prepareRemoteInputFromText());
}
// Consume action to prevent IME from closing.
return true;
@@ -141,7 +155,7 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
mEditText.mRemoteInputView = this;
}
private void sendRemoteInput() {
protected Intent prepareRemoteInputFromText() {
Bundle results = new Bundle();
results.putString(mRemoteInput.getResultKey(), mEditText.getText().toString());
Intent fillInIntent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
@@ -153,6 +167,25 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
RemoteInput.setResultsSource(fillInIntent, RemoteInput.SOURCE_CHOICE);
}
return fillInIntent;
}
protected Intent prepareRemoteInputFromData(String contentType, Uri data) {
HashMap<String, Uri> results = new HashMap<>();
results.put(contentType, data);
try {
mStatusBarManagerService.grantInlineReplyUriPermission(
mEntry.notification.getKey(), data);
} catch (Exception e) {
Log.e(TAG, "Failed to grant URI permissions:" + e.getMessage(), e);
}
Intent fillInIntent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
RemoteInput.addDataResultToIntent(mRemoteInput, fillInIntent, results);
return fillInIntent;
}
private void sendRemoteInput(Intent intent) {
mEditText.setEnabled(false);
mSendButton.setVisibility(INVISIBLE);
mProgressBar.setVisibility(VISIBLE);
@@ -176,7 +209,7 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
MetricsLogger.action(mContext, MetricsProto.MetricsEvent.ACTION_REMOTE_INPUT_SEND,
mEntry.notification.getPackageName());
try {
mPendingIntent.send(mContext, 0, fillInIntent);
mPendingIntent.send(mContext, 0, intent);
} catch (PendingIntent.CanceledException e) {
Log.i(TAG, "Unable to send remote input result", e);
MetricsLogger.action(mContext, MetricsProto.MetricsEvent.ACTION_REMOTE_INPUT_FAIL,
@@ -195,7 +228,9 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
LayoutInflater.from(context).inflate(R.layout.remote_input, root, false);
v.mController = controller;
v.mEntry = entry;
v.mEditText.setTextOperationUser(computeTextOperationUser(entry.notification.getUser()));
UserHandle user = computeTextOperationUser(entry.notification.getUser());
v.mEditText.mUser = user;
v.mEditText.setTextOperationUser(user);
v.setTag(VIEW_TAG);
return v;
@@ -204,7 +239,7 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
@Override
public void onClick(View v) {
if (v == mSendButton) {
sendRemoteInput();
sendRemoteInput(prepareRemoteInputFromText());
}
}
@@ -518,6 +553,7 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
private RemoteInputView mRemoteInputView;
boolean mShowImeOnInputConnection;
private LightBarController mLightBarController;
UserHandle mUser;
public RemoteEditText(Context context, AttributeSet attrs) {
super(context, attrs);
@@ -617,11 +653,47 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
String[] allowedDataTypes = mRemoteInputView.mRemoteInput.getAllowedDataTypes()
.toArray(new String[0]);
EditorInfoCompat.setContentMimeTypes(outAttrs, allowedDataTypes);
final InputConnection inputConnection = super.onCreateInputConnection(outAttrs);
if (mShowImeOnInputConnection && inputConnection != null) {
final InputConnectionCompat.OnCommitContentListener callback =
new InputConnectionCompat.OnCommitContentListener() {
@Override
public boolean onCommitContent(
InputContentInfoCompat inputContentInfoCompat, int i,
Bundle bundle) {
Uri contentUri = inputContentInfoCompat.getContentUri();
ClipDescription description = inputContentInfoCompat.getDescription();
String mimeType = null;
if (description != null && description.getMimeTypeCount() > 0) {
mimeType = description.getMimeType(0);
}
if (mimeType != null) {
Intent dataIntent = mRemoteInputView.prepareRemoteInputFromData(
mimeType, contentUri);
mRemoteInputView.sendRemoteInput(dataIntent);
}
return true;
}
};
InputConnection ic = InputConnectionCompat.createWrapper(
inputConnection, outAttrs, callback);
Context userContext = null;
try {
userContext = mContext.createPackageContextAsUser(
mContext.getPackageName(), 0, mUser);
} catch (PackageManager.NameNotFoundException e) {
Log.e(TAG, "Unable to create user context:" + e.getMessage(), e);
}
if (mShowImeOnInputConnection && ic != null) {
Context targetContext = userContext != null ? userContext : getContext();
final InputMethodManager imm =
getContext().getSystemService(InputMethodManager.class);
targetContext.getSystemService(InputMethodManager.class);
if (imm != null) {
// onCreateInputConnection is called by InputMethodManager in the middle of
// setting up the connection to the IME; wait with requesting the IME until that
@@ -636,7 +708,7 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
}
}
return inputConnection;
return ic;
}
@Override

View File

@@ -17,6 +17,7 @@
package com.android.server.notification;
import android.app.Notification;
import android.net.Uri;
import android.service.notification.NotificationStats;
import com.android.internal.statusbar.NotificationVisibility;
@@ -48,6 +49,12 @@ public interface NotificationDelegate {
void onNotificationSettingsViewed(String key);
void onNotificationBubbleChanged(String key, boolean isBubble);
/**
* Grant permission to read the specified URI to the package associated with the
* NotificationRecord associated with the given key.
*/
void grantInlineReplyUriPermission(String key, Uri uri, int callingUid);
/**
* Notifies that smart replies and actions have been added to the UI.
*/

View File

@@ -1078,6 +1078,56 @@ public class NotificationManagerService extends SystemService {
}
}
}
@Override
/**
* Grant permission to read the specified URI to the package specified in the
* NotificationRecord associated with the given key. The callingUid represents the UID of
* SystemUI from which this method is being called.
*
* For this to work, SystemUI must have permission to read the URI when running under the
* user associated with the NotificationRecord, and this grant will fail when trying
* to grant URI permissions across users.
*/
public void grantInlineReplyUriPermission(String key, Uri uri, int callingUid) {
synchronized (mNotificationLock) {
NotificationRecord r = mNotificationsByKey.get(key);
if (r != null) {
IBinder owner = r.permissionOwner;
if (owner == null) {
r.permissionOwner = mUgmInternal.newUriPermissionOwner("NOTIF:" + key);
owner = r.permissionOwner;
}
int uid = callingUid;
int userId = r.sbn.getUserId();
if (userId == UserHandle.USER_ALL) {
userId = USER_SYSTEM;
}
if (UserHandle.getUserId(uid) != userId) {
try {
final String[] pkgs = mPackageManager.getPackagesForUid(callingUid);
if (pkgs == null) {
Log.e(TAG, "Cannot grant uri permission to unknown UID: "
+ callingUid);
}
final String pkg = pkgs[0]; // Get the SystemUI package
// Find the UID for SystemUI for the correct user
uid = mPackageManager.getPackageUid(pkg, 0, userId);
} catch (RemoteException re) {
Log.e(TAG, "Cannot talk to package manager", re);
}
}
grantUriPermission(owner, uri, uid, r.sbn.getPackageName(), userId);
} else {
Log.w(TAG, "No record found for notification key:" + key);
// TODO: figure out cancel story. I think it's: sysui needs to tell us
// whenever noitifications held by a lifetimextender go away
// IBinder owner = mUgmInternal.newUriPermissionOwner("InlineReply:" + key);
// pass in userId and package as well as key (key for logging purposes)
}
}
}
};
@VisibleForTesting
@@ -6785,7 +6835,6 @@ public class NotificationManagerService extends SystemService {
private void grantUriPermission(IBinder owner, Uri uri, int sourceUid, String targetPkg,
int targetUserId) {
if (uri == null || !ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) return;
final long ident = Binder.clearCallingIdentity();
try {
mUgm.grantUriPermissionFromOwner(owner, sourceUid, targetPkg,

View File

@@ -29,6 +29,7 @@ import android.graphics.Rect;
import android.hardware.biometrics.IBiometricServiceReceiverInternal;
import android.hardware.display.DisplayManager;
import android.hardware.display.DisplayManager.DisplayListener;
import android.net.Uri;
import android.os.Binder;
import android.os.Bundle;
import android.os.Handler;
@@ -1333,6 +1334,18 @@ public class StatusBarManagerService extends IStatusBarService.Stub implements D
}
}
@Override
public void grantInlineReplyUriPermission(String key, Uri uri) {
enforceStatusBarService();
int callingUid = Binder.getCallingUid();
long identity = Binder.clearCallingIdentity();
try {
mNotificationDelegate.grantInlineReplyUriPermission(key, uri, callingUid);
} finally {
Binder.restoreCallingIdentity(identity);
}
}
@Override
public void onShellCommand(FileDescriptor in, FileDescriptor out, FileDescriptor err,
String[] args, ShellCallback callback, ResultReceiver resultReceiver) {

View File

@@ -506,6 +506,18 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
return new NotificationRecord(mContext, sbn, channel);
}
private NotificationRecord generateNotificationRecord(NotificationChannel channel, int userId) {
if (channel == null) {
channel = mTestNotificationChannel;
}
Notification.Builder nb = new Notification.Builder(mContext, channel.getId())
.setContentTitle("foo")
.setSmallIcon(android.R.drawable.sym_def_app_icon);
StatusBarNotification sbn = new StatusBarNotification(PKG, PKG, 1, "tag", mUid, 0,
nb.build(), new UserHandle(userId), null, 0);
return new NotificationRecord(mContext, sbn, channel);
}
private Map<String, Answer> getSignalExtractorSideEffects() {
Map<String, Answer> answers = new ArrayMap<>();
@@ -5227,6 +5239,112 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
assertEquals((notifsAfter[0].getNotification().flags & FLAG_BUBBLE), 0);
}
@Test
public void testGrantInlineReplyUriPermission_recordExists() throws Exception {
NotificationRecord nr = generateNotificationRecord(mTestNotificationChannel, 0);
mBinderService.enqueueNotificationWithTag(PKG, PKG, "tag",
nr.sbn.getId(), nr.sbn.getNotification(), nr.sbn.getUserId());
waitForIdle();
// A notification exists for the given record
StatusBarNotification[] notifsBefore = mBinderService.getActiveNotifications(PKG);
assertEquals(1, notifsBefore.length);
reset(mPackageManager);
Uri uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, 1);
mService.mNotificationDelegate.grantInlineReplyUriPermission(
nr.getKey(), uri, nr.sbn.getUid());
// Grant permission called for the UID of SystemUI under the target user ID
verify(mUgm, times(1)).grantUriPermissionFromOwner(any(),
eq(nr.sbn.getUid()), eq(nr.sbn.getPackageName()), eq(uri), anyInt(), anyInt(),
eq(nr.sbn.getUserId()));
}
@Test
public void testGrantInlineReplyUriPermission_userAll() throws Exception {
// generate a NotificationRecord for USER_ALL to make sure it's converted into USER_SYSTEM
NotificationRecord nr =
generateNotificationRecord(mTestNotificationChannel, UserHandle.USER_ALL);
mBinderService.enqueueNotificationWithTag(PKG, PKG, "tag",
nr.sbn.getId(), nr.sbn.getNotification(), nr.sbn.getUserId());
waitForIdle();
// A notification exists for the given record
StatusBarNotification[] notifsBefore = mBinderService.getActiveNotifications(PKG);
assertEquals(1, notifsBefore.length);
reset(mPackageManager);
Uri uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, 1);
mService.mNotificationDelegate.grantInlineReplyUriPermission(
nr.getKey(), uri, nr.sbn.getUid());
// Target user for the grant is USER_ALL instead of USER_SYSTEM
verify(mUgm, times(1)).grantUriPermissionFromOwner(any(),
eq(nr.sbn.getUid()), eq(nr.sbn.getPackageName()), eq(uri), anyInt(), anyInt(),
eq(UserHandle.USER_SYSTEM));
}
@Test
public void testGrantInlineReplyUriPermission_acrossUsers() throws Exception {
// generate a NotificationRecord for USER_ALL to make sure it's converted into USER_SYSTEM
int otherUserId = 11;
NotificationRecord nr =
generateNotificationRecord(mTestNotificationChannel, otherUserId);
mBinderService.enqueueNotificationWithTag(PKG, PKG, "tag",
nr.sbn.getId(), nr.sbn.getNotification(), nr.sbn.getUserId());
waitForIdle();
// A notification exists for the given record
StatusBarNotification[] notifsBefore = mBinderService.getActiveNotifications(PKG);
assertEquals(1, notifsBefore.length);
reset(mPackageManager);
Uri uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, 1);
int uid = 0; // sysui on primary user
int otherUserUid = (otherUserId * 100000) + 1; // SystemUI as a different user
String sysuiPackage = "sysui";
final String[] sysuiPackages = new String[] { sysuiPackage };
when(mPackageManager.getPackagesForUid(uid)).thenReturn(sysuiPackages);
// Make sure to mock call for USER_SYSTEM and not USER_ALL, since it's been replaced by the
// time this is called
when(mPackageManager.getPackageUid(sysuiPackage, 0, otherUserId))
.thenReturn(otherUserUid);
mService.mNotificationDelegate.grantInlineReplyUriPermission(nr.getKey(), uri, uid);
// Target user for the grant is USER_ALL instead of USER_SYSTEM
verify(mUgm, times(1)).grantUriPermissionFromOwner(any(),
eq(otherUserUid), eq(nr.sbn.getPackageName()), eq(uri), anyInt(), anyInt(),
eq(otherUserId));
}
@Test
public void testGrantInlineReplyUriPermission_noRecordExists() throws Exception {
NotificationRecord nr = generateNotificationRecord(mTestNotificationChannel);
waitForIdle();
// No notifications exist for the given record
StatusBarNotification[] notifsBefore = mBinderService.getActiveNotifications(PKG);
assertEquals(0, notifsBefore.length);
Uri uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, 1);
int uid = 0; // sysui on primary user
mService.mNotificationDelegate.grantInlineReplyUriPermission(nr.getKey(), uri, uid);
// Grant permission not called if no record exists for the given key
verify(mUgm, times(0)).grantUriPermissionFromOwner(any(), anyInt(), any(),
eq(uri), anyInt(), anyInt(), anyInt());
}
@Test
public void testNotificationBubbles_disabled_lowRamDevice() throws Exception {
// Bubbles are allowed!