/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.people; import static com.android.systemui.people.NotificationHelper.getContactUri; import static com.android.systemui.people.NotificationHelper.getMessagingStyleMessages; import static com.android.systemui.people.NotificationHelper.getSenderIfGroupConversation; import static com.android.systemui.people.NotificationHelper.hasReadContactsPermission; import static com.android.systemui.people.NotificationHelper.isMissedCall; import static com.android.systemui.people.NotificationHelper.shouldMatchNotificationByUri; import android.annotation.Nullable; import android.app.Notification; import android.app.backup.BackupManager; import android.app.people.ConversationChannel; import android.app.people.IPeopleManager; import android.app.people.PeopleSpaceTile; import android.content.Context; import android.content.SharedPreferences; import android.content.pm.LauncherApps; import android.content.pm.PackageManager; import android.content.pm.ShortcutInfo; import android.database.Cursor; import android.database.SQLException; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.UserManager; import android.provider.ContactsContract; import android.service.notification.StatusBarNotification; import android.text.TextUtils; import android.util.Log; import androidx.preference.PreferenceManager; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.UiEvent; import com.android.internal.logging.UiEventLogger; import com.android.internal.util.ArrayUtils; import com.android.internal.widget.MessagingMessage; import com.android.settingslib.utils.ThreadUtils; import com.android.systemui.R; import com.android.systemui.people.widget.PeopleSpaceWidgetManager; import com.android.systemui.people.widget.PeopleTileKey; import com.android.systemui.statusbar.notification.collection.NotificationEntry; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; /** Utils class for People Space. */ public class PeopleSpaceUtils { /** Turns on debugging information about People Space. */ public static final boolean DEBUG = false; public static final String PACKAGE_NAME = "package_name"; public static final String USER_ID = "user_id"; public static final String SHORTCUT_ID = "shortcut_id"; public static final String EMPTY_STRING = ""; public static final int INVALID_USER_ID = -1; public static final PeopleTileKey EMPTY_KEY = new PeopleTileKey(EMPTY_STRING, INVALID_USER_ID, EMPTY_STRING); static final float STARRED_CONTACT = 1f; static final float VALID_CONTACT = .5f; static final float DEFAULT_AFFINITY = 0f; private static final String TAG = "PeopleSpaceUtils"; /** Returns stored widgets for the conversation specified. */ public static Set getStoredWidgetIds(SharedPreferences sp, PeopleTileKey key) { if (!PeopleTileKey.isValid(key)) { return new HashSet<>(); } return new HashSet<>(sp.getStringSet(key.toString(), new HashSet<>())); } /** Sets all relevant storage for {@code appWidgetId} association to {@code tile}. */ public static void setSharedPreferencesStorageForTile(Context context, PeopleTileKey key, int appWidgetId, Uri contactUri, BackupManager backupManager) { if (!PeopleTileKey.isValid(key)) { Log.e(TAG, "Not storing for invalid key"); return; } // Write relevant persisted storage. SharedPreferences widgetSp = context.getSharedPreferences(String.valueOf(appWidgetId), Context.MODE_PRIVATE); SharedPreferencesHelper.setPeopleTileKey(widgetSp, key); SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); SharedPreferences.Editor editor = sp.edit(); String contactUriString = contactUri == null ? EMPTY_STRING : contactUri.toString(); editor.putString(String.valueOf(appWidgetId), contactUriString); // Don't overwrite existing widgets with the same key. addAppWidgetIdForKey(sp, editor, appWidgetId, key.toString()); if (!TextUtils.isEmpty(contactUriString)) { addAppWidgetIdForKey(sp, editor, appWidgetId, contactUriString); } editor.apply(); backupManager.dataChanged(); } /** Removes stored data when tile is deleted. */ public static void removeSharedPreferencesStorageForTile(Context context, PeopleTileKey key, int widgetId, String contactUriString) { // Delete widgetId mapping to key. if (DEBUG) Log.d(TAG, "Removing widget info from sharedPrefs"); SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); SharedPreferences.Editor editor = sp.edit(); editor.remove(String.valueOf(widgetId)); removeAppWidgetIdForKey(sp, editor, widgetId, key.toString()); removeAppWidgetIdForKey(sp, editor, widgetId, contactUriString); editor.apply(); // Delete all data specifically mapped to widgetId. SharedPreferences widgetSp = context.getSharedPreferences(String.valueOf(widgetId), Context.MODE_PRIVATE); SharedPreferences.Editor widgetEditor = widgetSp.edit(); widgetEditor.remove(PACKAGE_NAME); widgetEditor.remove(USER_ID); widgetEditor.remove(SHORTCUT_ID); widgetEditor.apply(); } private static void addAppWidgetIdForKey(SharedPreferences sp, SharedPreferences.Editor editor, int widgetId, String storageKey) { Set storedWidgetIdsByKey = new HashSet<>( sp.getStringSet(storageKey, new HashSet<>())); storedWidgetIdsByKey.add(String.valueOf(widgetId)); editor.putStringSet(storageKey, storedWidgetIdsByKey); } private static void removeAppWidgetIdForKey(SharedPreferences sp, SharedPreferences.Editor editor, int widgetId, String storageKey) { Set storedWidgetIds = new HashSet<>( sp.getStringSet(storageKey, new HashSet<>())); storedWidgetIds.remove(String.valueOf(widgetId)); editor.putStringSet(storageKey, storedWidgetIds); } /** Returns notifications that match provided {@code contactUri}. */ public static List getNotificationsByUri( PackageManager packageManager, String contactUri, Map> notifications) { if (DEBUG) Log.d(TAG, "Getting notifications by contact URI."); if (TextUtils.isEmpty(contactUri)) { return new ArrayList<>(); } return notifications.entrySet().stream().flatMap(e -> e.getValue().stream()) .filter(e -> hasReadContactsPermission(packageManager, e.getSbn()) && shouldMatchNotificationByUri(e.getSbn()) && Objects.equals(contactUri, getContactUri(e.getSbn())) ) .collect(Collectors.toList()); } /** Returns the total messages in {@code notificationEntries}. */ public static int getMessagesCount(Set notificationEntries) { if (DEBUG) { Log.d(TAG, "Calculating messages count from " + notificationEntries.size() + " notifications."); } int messagesCount = 0; for (NotificationEntry entry : notificationEntries) { Notification notification = entry.getSbn().getNotification(); // Should not count messages from missed call notifications. if (isMissedCall(notification)) { continue; } List messages = getMessagingStyleMessages(notification); if (messages != null) { messagesCount += messages.size(); } } return messagesCount; } /** Removes all notification related fields from {@code tile}. */ public static PeopleSpaceTile removeNotificationFields(PeopleSpaceTile tile) { if (DEBUG) { Log.i(TAG, "Removing any notification stored for tile Id: " + tile.getId()); } PeopleSpaceTile.Builder updatedTile = tile .toBuilder() // Reset notification content. .setNotificationKey(null) .setNotificationContent(null) .setNotificationSender(null) .setNotificationDataUri(null) .setMessagesCount(0) // Reset missed calls category. .setNotificationCategory(null); // Only set last interaction to now if we are clearing a notification. if (!TextUtils.isEmpty(tile.getNotificationKey())) { long currentTimeMillis = System.currentTimeMillis(); if (DEBUG) Log.d(TAG, "Set last interaction on clear: " + currentTimeMillis); updatedTile.setLastInteractionTimestamp(currentTimeMillis); } return updatedTile.build(); } /** * Augments {@code tile} with the notification content from {@code notificationEntry} and * {@code messagesCount}. */ public static PeopleSpaceTile augmentTileFromNotification(Context context, PeopleSpaceTile tile, PeopleTileKey key, NotificationEntry notificationEntry, int messagesCount, Optional appWidgetId, BackupManager backupManager) { if (notificationEntry == null || notificationEntry.getSbn().getNotification() == null) { if (DEBUG) Log.d(TAG, "Tile key: " + key.toString() + ". Notification is null"); return removeNotificationFields(tile); } StatusBarNotification sbn = notificationEntry.getSbn(); Notification notification = sbn.getNotification(); PeopleSpaceTile.Builder updatedTile = tile.toBuilder(); String uriFromNotification = getContactUri(sbn); if (appWidgetId.isPresent() && tile.getContactUri() == null && !TextUtils.isEmpty( uriFromNotification)) { if (DEBUG) Log.d(TAG, "Add uri from notification to tile: " + uriFromNotification); Uri contactUri = Uri.parse(uriFromNotification); // Update storage. setSharedPreferencesStorageForTile(context, new PeopleTileKey(tile), appWidgetId.get(), contactUri, backupManager); // Update cached tile in-memory. updatedTile.setContactUri(contactUri); } boolean isMissedCall = isMissedCall(notification); List messages = getMessagingStyleMessages(notification); if (!isMissedCall && ArrayUtils.isEmpty(messages)) { if (DEBUG) Log.d(TAG, "Tile key: " + key.toString() + ". Notification has no content"); return removeNotificationFields(updatedTile.build()); } // messages are in chronological order from most recent to least. Notification.MessagingStyle.Message message = messages != null ? messages.get(0) : null; // If it's a missed call notification and it doesn't include content, use fallback value, // otherwise, use notification content. boolean hasMessageText = message != null && !TextUtils.isEmpty(message.getText()); CharSequence content = (isMissedCall && !hasMessageText) ? context.getString(R.string.missed_call) : message.getText(); // We only use the URI if it's an image, otherwise we fallback to text (for example, with an // audio URI) Uri imageUri = message != null && MessagingMessage.hasImage(message) ? message.getDataUri() : null; if (DEBUG) { Log.d(TAG, "Tile key: " + key.toString() + ". Notification message has text: " + hasMessageText + ". Image URI: " + imageUri + ". Has last interaction: " + sbn.getPostTime()); } CharSequence sender = getSenderIfGroupConversation(notification, message); return updatedTile .setLastInteractionTimestamp(sbn.getPostTime()) .setNotificationKey(sbn.getKey()) .setNotificationCategory(notification.category) .setNotificationContent(content) .setNotificationSender(sender) .setNotificationDataUri(imageUri) .setMessagesCount(messagesCount) .build(); } /** Returns a list sorted by ascending last interaction time from {@code stream}. */ public static List getSortedTiles(IPeopleManager peopleManager, LauncherApps launcherApps, UserManager userManager, Stream stream) { return stream .filter(Objects::nonNull) .filter(c -> !userManager.isQuietModeEnabled(c.getUserHandle())) .map(c -> new PeopleSpaceTile.Builder(c, launcherApps).build()) .filter(c -> shouldKeepConversation(c)) .map(c -> c.toBuilder().setLastInteractionTimestamp( getLastInteraction(peopleManager, c)).build()) .sorted((c1, c2) -> new Long(c2.getLastInteractionTimestamp()).compareTo( new Long(c1.getLastInteractionTimestamp()))) .collect(Collectors.toList()); } /** Returns {@code PeopleSpaceTile} based on provided {@ConversationChannel}. */ public static PeopleSpaceTile getTile(ConversationChannel channel, LauncherApps launcherApps) { if (channel == null) { Log.i(TAG, "ConversationChannel is null"); return null; } PeopleSpaceTile tile = new PeopleSpaceTile.Builder(channel, launcherApps).build(); if (!PeopleSpaceUtils.shouldKeepConversation(tile)) { Log.i(TAG, "PeopleSpaceTile is not valid"); return null; } return tile; } /** Returns the last interaction time with the user specified by {@code PeopleSpaceTile}. */ private static Long getLastInteraction(IPeopleManager peopleManager, PeopleSpaceTile tile) { try { int userId = getUserId(tile); String pkg = tile.getPackageName(); return peopleManager.getLastInteraction(pkg, userId, tile.getId()); } catch (Exception e) { Log.e(TAG, "Couldn't retrieve last interaction time", e); return 0L; } } /** Converts {@code drawable} to a {@link Bitmap}. */ public static Bitmap convertDrawableToBitmap(Drawable drawable) { if (drawable == null) { return null; } if (drawable instanceof BitmapDrawable) { BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable; if (bitmapDrawable.getBitmap() != null) { return bitmapDrawable.getBitmap(); } } Bitmap bitmap; if (drawable.getIntrinsicWidth() <= 0 || drawable.getIntrinsicHeight() <= 0) { bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); // Single color bitmap will be created of 1x1 pixel } else { bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); } Canvas canvas = new Canvas(bitmap); drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); drawable.draw(canvas); return bitmap; } /** * Returns whether the {@code conversation} should be kept for display in the People Space. * *

A valid {@code conversation} must: *

    *
  • Have a non-null {@link PeopleSpaceTile} *
  • Have an associated label in the {@link PeopleSpaceTile} *
* */ public static boolean shouldKeepConversation(PeopleSpaceTile tile) { return tile != null && !TextUtils.isEmpty(tile.getUserName()); } private static boolean hasBirthdayStatus(PeopleSpaceTile tile, Context context) { return tile.getBirthdayText() != null && tile.getBirthdayText().equals( context.getString(R.string.birthday_status)); } /** Calls to retrieve birthdays & contact affinity on a background thread. */ public static void getDataFromContactsOnBackgroundThread(Context context, PeopleSpaceWidgetManager manager, Map peopleSpaceTiles, int[] appWidgetIds) { ThreadUtils.postOnBackgroundThread( () -> getDataFromContacts(context, manager, peopleSpaceTiles, appWidgetIds)); } /** Queries the Contacts DB for any birthdays today & updates contact affinity. */ @VisibleForTesting public static void getDataFromContacts(Context context, PeopleSpaceWidgetManager peopleSpaceWidgetManager, Map widgetIdToTile, int[] appWidgetIds) { if (DEBUG) Log.d(TAG, "Get birthdays"); if (appWidgetIds.length == 0) return; List lookupKeysWithBirthdaysToday = getContactLookupKeysWithBirthdaysToday(context); for (int appWidgetId : appWidgetIds) { PeopleSpaceTile storedTile = widgetIdToTile.get(appWidgetId); if (storedTile == null || storedTile.getContactUri() == null) { if (DEBUG) Log.d(TAG, "No contact uri for: " + storedTile); updateTileContactFields(peopleSpaceWidgetManager, context, storedTile, appWidgetId, DEFAULT_AFFINITY, /* birthdayString= */ null); continue; } updateTileWithBirthdayAndUpdateAffinity(context, peopleSpaceWidgetManager, lookupKeysWithBirthdaysToday, storedTile, appWidgetId); } } /** * Updates the {@code storedTile} with {@code affinity} & {@code birthdayString} if * necessary. */ private static void updateTileContactFields(PeopleSpaceWidgetManager manager, Context context, PeopleSpaceTile storedTile, int appWidgetId, float affinity, @Nullable String birthdayString) { boolean outdatedBirthdayStatus = hasBirthdayStatus(storedTile, context) && birthdayString == null; boolean addBirthdayStatus = !hasBirthdayStatus(storedTile, context) && birthdayString != null; boolean shouldUpdate = storedTile.getContactAffinity() != affinity || outdatedBirthdayStatus || addBirthdayStatus; if (shouldUpdate) { if (DEBUG) Log.d(TAG, "Update " + storedTile.getUserName() + " from contacts"); manager.updateAppWidgetOptionsAndView(appWidgetId, storedTile.toBuilder() .setBirthdayText(birthdayString) .setContactAffinity(affinity) .build()); } } /** * Update {@code storedTile} if the contact has a lookup key matched to any {@code * lookupKeysWithBirthdays}. */ private static void updateTileWithBirthdayAndUpdateAffinity(Context context, PeopleSpaceWidgetManager manager, List lookupKeysWithBirthdaysToday, PeopleSpaceTile storedTile, int appWidgetId) { Cursor cursor = null; try { cursor = context.getContentResolver().query(storedTile.getContactUri(), null, null, null, null); while (cursor != null && cursor.moveToNext()) { String storedLookupKey = cursor.getString( cursor.getColumnIndex(ContactsContract.CommonDataKinds.Event.LOOKUP_KEY)); float affinity = getContactAffinity(cursor); if (!storedLookupKey.isEmpty() && lookupKeysWithBirthdaysToday.contains( storedLookupKey)) { if (DEBUG) Log.d(TAG, storedTile.getUserName() + "'s birthday today!"); updateTileContactFields(manager, context, storedTile, appWidgetId, affinity, /* birthdayString= */ context.getString(R.string.birthday_status)); } else { updateTileContactFields(manager, context, storedTile, appWidgetId, affinity, /* birthdayString= */ null); } } } catch (SQLException e) { Log.e(TAG, "Failed to query contact: " + e); } finally { if (cursor != null) { cursor.close(); } } } /** Pulls the contact affinity from {@code cursor}. */ private static float getContactAffinity(Cursor cursor) { float affinity = VALID_CONTACT; int starIdx = cursor.getColumnIndex(ContactsContract.Contacts.STARRED); if (starIdx >= 0) { boolean isStarred = cursor.getInt(starIdx) != 0; if (isStarred) { affinity = Math.max(affinity, STARRED_CONTACT); } } if (DEBUG) Log.d(TAG, "Affinity is: " + affinity); return affinity; } /** * Returns lookup keys for all contacts with a birthday today. * *

Birthdays are queried from a different table within the Contacts DB than the table for * the Contact Uri provided by most messaging apps. Matching by the contact ID is then quite * fragile as the row IDs across the different tables are not guaranteed to stay aligned, so we * match the data by {@link ContactsContract.ContactsColumns#LOOKUP_KEY} key to ensure proper * matching across all the Contacts DB tables. */ @VisibleForTesting public static List getContactLookupKeysWithBirthdaysToday(Context context) { List lookupKeysWithBirthdaysToday = new ArrayList<>(1); String today = new SimpleDateFormat("MM-dd").format(new Date()); String[] projection = new String[]{ ContactsContract.CommonDataKinds.Event.LOOKUP_KEY, ContactsContract.CommonDataKinds.Event.START_DATE}; String where = ContactsContract.Data.MIMETYPE + "= ? AND " + ContactsContract.CommonDataKinds.Event.TYPE + "=" + ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY + " AND (substr(" // Birthdays stored with years will match this format + ContactsContract.CommonDataKinds.Event.START_DATE + ",6) = ? OR substr(" // Birthdays stored without years will match this format + ContactsContract.CommonDataKinds.Event.START_DATE + ",3) = ? )"; String[] selection = new String[]{ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE, today, today}; Cursor cursor = null; try { cursor = context .getContentResolver() .query(ContactsContract.Data.CONTENT_URI, projection, where, selection, null); while (cursor != null && cursor.moveToNext()) { String lookupKey = cursor.getString( cursor.getColumnIndex(ContactsContract.CommonDataKinds.Event.LOOKUP_KEY)); lookupKeysWithBirthdaysToday.add(lookupKey); } } catch (SQLException e) { Log.e(TAG, "Failed to query birthdays: " + e); } finally { if (cursor != null) { cursor.close(); } } return lookupKeysWithBirthdaysToday; } /** Returns the userId associated with a {@link PeopleSpaceTile} */ public static int getUserId(PeopleSpaceTile tile) { return tile.getUserHandle().getIdentifier(); } /** Represents whether {@link StatusBarNotification} was posted or removed. */ public enum NotificationAction { POSTED, REMOVED } /** * The UiEvent enums that this class can log. */ public enum PeopleSpaceWidgetEvent implements UiEventLogger.UiEventEnum { @UiEvent(doc = "People space widget deleted") PEOPLE_SPACE_WIDGET_DELETED(666), @UiEvent(doc = "People space widget added") PEOPLE_SPACE_WIDGET_ADDED(667), @UiEvent(doc = "People space widget clicked to launch conversation") PEOPLE_SPACE_WIDGET_CLICKED(668); private final int mId; PeopleSpaceWidgetEvent(int id) { mId = id; } @Override public int getId() { return mId; } } }