Messaging: Add scheduled send, location share, app lock, and settings polish

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-27 08:13:21 +09:00
co-authored by Cursor
parent 42148360e0
commit 74fb80f783
114 changed files with 6230 additions and 606 deletions
@@ -36,6 +36,7 @@ import com.android.messaging.sms.BugleApnSettingsLoader;
import com.android.messaging.sms.BugleUserAgentInfoLoader;
import com.android.messaging.sms.MmsConfig;
import com.android.messaging.ui.ConversationDrawables;
import com.android.messaging.ui.home.HomeAppLock;
import com.android.messaging.ui.home.HomeToast;
import com.android.messaging.util.BuglePrefsKeys;
import com.android.messaging.util.LogUtil;
@@ -58,6 +59,7 @@ public class BugleApplication extends Application implements UncaughtExceptionHa
super.onCreate();
FactoryImpl.register(getApplicationContext(), this);
HomeAppLock.install(this);
HomeToast.install(this);
sSystemUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
@@ -722,7 +722,9 @@ public class BugleNotifications {
setWearableGroupOptions(notifBuilder, notificationState);
if (notificationState instanceof MultiMessageNotificationState) {
if (attachmentBitmap != null) {
if (attachmentBitmap != null
&& notificationState.mNotificationStyle
instanceof NotificationCompat.BigPictureStyle) {
// When we've got a picture attachment, we do some switcheroo trickery. When
// the notification is expanded, we show the picture as a bigPicture. The small
// icon shows the sender's avatar. When that same notification is collapsed, the
@@ -787,28 +789,34 @@ public class BugleNotifications {
final int requestCode = multiMessageNotificationState.getReplyIntentRequestCode();
final PendingIntent replyPendingIntent = UIIntents.get()
.getPendingIntentForSendingMessageToConversation(context,
conversationId, selfId, requiresMms, requestCode);
conversationId, selfId, requiresMms,
multiMessageNotificationState.mTitle, requestCode);
final int replyLabelRes = requiresMms ? R.string.notification_reply_via_mms :
R.string.notification_reply_via_sms;
final NotificationCompat.Action.Builder actionBuilder =
// Phone: open the Home bottom-sheet composer. Wear keeps RemoteInput.
final NotificationCompat.Action replyAction =
new NotificationCompat.Action.Builder(R.drawable.ic_wear_reply,
context.getString(replyLabelRes), replyPendingIntent);
final RemoteInput.Builder remoteInputBuilder = new RemoteInput.Builder(Intent.EXTRA_TEXT);
remoteInputBuilder.setLabel(context.getString(R.string.notification_reply_prompt));
actionBuilder.addRemoteInput(remoteInputBuilder.build());
notifBuilder.addAction(actionBuilder.build());
context.getString(R.string.home_notification_reply), replyPendingIntent)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(true)
.build();
notifBuilder.addAction(replyAction);
// Support the action on a wearable device
final NotificationCompat.Action.Builder wearActionBuilder =
new NotificationCompat.Action.Builder(R.drawable.ic_wear_reply,
context.getString(replyLabelRes), replyPendingIntent);
final String[] choices = context.getResources().getStringArray(
R.array.notification_reply_choices);
remoteInputBuilder.setChoices(choices);
wearActionBuilder.addRemoteInput(remoteInputBuilder.build());
wearableExtender.addAction(wearActionBuilder.build());
final RemoteInput wearRemoteInput = new RemoteInput.Builder(Intent.EXTRA_TEXT)
.setLabel(context.getString(R.string.home_notification_reply_hint))
.setAllowFreeFormInput(true)
.setChoices(choices)
.build();
final int wearLabelRes = requiresMms ? R.string.notification_reply_via_mms :
R.string.notification_reply_via_sms;
wearableExtender.addAction(new NotificationCompat.Action.Builder(R.drawable.ic_wear_reply,
context.getString(wearLabelRes), replyPendingIntent)
.addRemoteInput(wearRemoteInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build());
}
private static void addDownloadMmsAction(final NotificationCompat.Builder notifBuilder,
@@ -120,6 +120,8 @@ public class FrequentContactsCursorBuilder {
mAllContactsCursor.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE);
row[ContactUtil.INDEX_PHONE_EMAIL_LABEL] =
mAllContactsCursor.getString(ContactUtil.INDEX_PHONE_EMAIL_LABEL);
row[ContactUtil.INDEX_SORT_KEY] =
mAllContactsCursor.getString(ContactUtil.INDEX_SORT_KEY);
boolean numberAlreadyAdded = false;
for (Object[] oldRow : rows) {
@@ -46,7 +46,7 @@ public class MediaScratchFileProvider extends FileProvider {
private static final SimpleArrayMap<Uri, String> sUriToDisplayNameMap = new SimpleArrayMap<>();
public static final String AUTHORITY =
"com.bnkode.notie.datamodel.MediaScratchFileProvider";
"com.filabcorp.msgq.datamodel.MediaScratchFileProvider";
private static final String MEDIA_SCRATCH_SPACE_DIR = "mediascratchspace";
public static boolean isMediaScratchSpaceUri(final Uri uri) {
@@ -39,6 +39,7 @@ import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationCompat.Builder;
import androidx.core.app.NotificationCompat.WearableExtender;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.Person;
import com.android.messaging.Factory;
import com.android.messaging.R;
@@ -494,85 +495,62 @@ public abstract class MessageNotificationState extends NotificationState {
@Override
protected NotificationCompat.Style build(final Builder builder) {
builder.setContentTitle(mTitle)
.setTicker(getTicker());
.setTicker(getTicker())
.setContentText(mContent);
NotificationCompat.Style notifStyle;
final Context context = Factory.get().getApplicationContext();
final ConversationLineInfo convInfo = mConvList.mConvInfos.get(0);
final List<NotificationLineInfo> lineInfos = convInfo.mLineInfos;
final int messageCount = lineInfos.size();
// At this point, all the messages come from the same conversation. We need to load
// the sender's avatar and then finish building the notification on a callback.
builder.setContentText(mContent); // for collapsed state
if (messageCount == 1) {
final boolean shouldShowImage = ContentType.isImageType(mAttachmentType)
|| (ContentType.isVideoType(mAttachmentType));
if (mAttachmentUri != null && shouldShowImage) {
// Show "Picture" as the content
final MessageLineInfo messageLineInfo = (MessageLineInfo) lineInfos.get(0);
String authorFirstName = messageLineInfo.mAuthorFirstName;
// For the collapsed state, just show "picture" unless this is a
// group conversation. If it's a group, show the sender name and
// "picture".
final CharSequence tickerTag =
BugleNotifications.formatAttachmentTag(authorFirstName,
mAttachmentType);
// For 1:1 notifications don't show first name in the notification, but
// do show it in the ticker text
CharSequence pictureTag = tickerTag;
if (!convInfo.mIsGroup) {
authorFirstName = null;
pictureTag = BugleNotifications.formatAttachmentTag(authorFirstName,
mAttachmentType);
}
builder.setContentText(pictureTag);
builder.setTicker(tickerTag);
notifStyle = new NotificationCompat.BigPictureStyle(builder)
.setSummaryText(BugleNotifications.formatInboxMessage(
authorFirstName,
null, null,
null)); // expanded state, just show sender
} else {
notifStyle = new NotificationCompat.BigTextStyle(builder)
.bigText(mContent);
final Person user = new Person.Builder()
.setName(context.getString(R.string.home_notification_you))
.build();
final NotificationCompat.MessagingStyle style =
new NotificationCompat.MessagingStyle(user);
if (convInfo.mIsGroup) {
style.setGroupConversation(true);
if (!TextUtils.isEmpty(convInfo.mGroupConversationName)) {
style.setConversationTitle(convInfo.mGroupConversationName);
}
} else {
// We've got multiple messages for the same sender.
// Starting with the oldest new message, display the full text of each message.
// Begin a line for each subsequent message.
final SpannableStringBuilder buf = new SpannableStringBuilder();
for (int i = lineInfos.size() - 1; i >= 0; --i) {
final NotificationLineInfo info = lineInfos.get(i);
final MessageLineInfo messageLineInfo = (MessageLineInfo) info;
mAttachmentUri = messageLineInfo.mAttachmentUri;
mAttachmentType = messageLineInfo.mAttachmentType;
CharSequence text = messageLineInfo.mText;
if (!TextUtils.isEmpty(text) || mAttachmentUri != null) {
if (convInfo.mIsGroup) {
// append the bold name to the front of the message
text = BugleNotifications.buildSpaceSeparatedMessage(
messageLineInfo.mAuthorFullName, text, mAttachmentUri,
mAttachmentType);
} else {
text = BugleNotifications.buildSpaceSeparatedMessage(
null, text, mAttachmentUri, mAttachmentType);
}
buf.append(text);
if (i > 0) {
buf.append('\n');
}
}
}
// Show a single notification -- big style with the text of all the messages
notifStyle = new NotificationCompat.BigTextStyle(builder).bigText(buf);
}
// MessagingStyle wants chronological order (oldest first). Latest is at index 0.
for (int i = lineInfos.size() - 1; i >= 0; --i) {
final NotificationLineInfo info = lineInfos.get(i);
if (!(info instanceof MessageLineInfo)) {
continue;
}
final MessageLineInfo messageLineInfo = (MessageLineInfo) info;
CharSequence text = messageLineInfo.mText;
if (TextUtils.isEmpty(text) && messageLineInfo.mAttachmentUri != null) {
text = BugleNotifications.formatAttachmentTag(
convInfo.mIsGroup ? messageLineInfo.mAuthorFirstName : null,
messageLineInfo.mAttachmentType);
}
if (TextUtils.isEmpty(text)) {
continue;
}
final Person.Builder personBuilder = new Person.Builder()
.setName(!TextUtils.isEmpty(messageLineInfo.mAuthorFullName)
? messageLineInfo.mAuthorFullName : mTitle);
if (convInfo.mContactUri != null) {
personBuilder.setUri(convInfo.mContactUri.toString());
}
final long timestamp = Math.max(0, convInfo.mReceivedTimestamp - i);
final NotificationCompat.MessagingStyle.Message message =
new NotificationCompat.MessagingStyle.Message(
text, timestamp, personBuilder.build());
if (messageLineInfo.mAttachmentUri != null
&& !TextUtils.isEmpty(messageLineInfo.mAttachmentType)
&& (ContentType.isImageType(messageLineInfo.mAttachmentType)
|| ContentType.isVideoType(messageLineInfo.mAttachmentType))) {
message.setData(messageLineInfo.mAttachmentType,
messageLineInfo.mAttachmentUri);
}
style.addMessage(message);
}
builder.setWhen(convInfo.mReceivedTimestamp);
return notifStyle;
return style;
}
}
@@ -53,7 +53,7 @@ public class MessagingContentProvider extends ContentProvider {
private static final String TAG = LogUtil.BUGLE_TAG;
public static final String AUTHORITY =
"com.bnkode.notie.datamodel.MessagingContentProvider";
"com.filabcorp.msgq.datamodel.MessagingContentProvider";
private static final String CONTENT_AUTHORITY = "content://" + AUTHORITY + '/';
// Conversations query
@@ -32,7 +32,7 @@ import java.io.IOException;
public class MmsFileProvider extends FileProvider {
private static final String TAG = LogUtil.BUGLE_TAG;
static final String AUTHORITY = "com.bnkode.notie.datamodel.MmsFileProvider";
static final String AUTHORITY = "com.filabcorp.msgq.datamodel.MmsFileProvider";
private static final String RAW_MMS_DIR = "rawmms";
/**
@@ -0,0 +1,236 @@
package com.android.messaging.datamodel;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Parcel;
import android.text.TextUtils;
import android.util.Base64;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.action.InsertNewMessageAction;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.receiver.ScheduledMessageReceiver;
import com.android.messaging.util.LogUtil;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/** Persists compose-time scheduled sends and arms AlarmManager to deliver them. */
public final class ScheduledMessageStore {
private static final String PREFS = "home_scheduled_messages";
private static final String KEY_ENTRIES = "entries";
private static final String JSON_ID = "id";
private static final String JSON_SEND_AT = "sendAt";
private static final String JSON_PAYLOAD = "payload";
private ScheduledMessageStore() {
}
public static void schedule(@NonNull final Context context, @NonNull final MessageData message,
final long sendAt) {
final Entry entry = new Entry();
entry.id = UUID.randomUUID().toString();
entry.sendAt = sendAt;
entry.payload = encode(message);
if (TextUtils.isEmpty(entry.payload)) {
LogUtil.e(LogUtil.BUGLE_TAG, "Scheduled message encode failed");
return;
}
final List<Entry> entries = loadLocked(context);
entries.add(entry);
saveLocked(context, entries);
setAlarm(context, entry);
}
@WorkerThread
public static void sendNow(@NonNull final Context context, @Nullable final String id) {
if (TextUtils.isEmpty(id)) {
return;
}
Entry match = null;
synchronized (ScheduledMessageStore.class) {
final List<Entry> entries = loadLocked(context);
for (int i = 0; i < entries.size(); i++) {
if (id.equals(entries.get(i).id)) {
match = entries.remove(i);
break;
}
}
if (match != null) {
saveLocked(context, entries);
}
}
if (match == null) {
return;
}
cancelAlarm(context, match.id);
final MessageData message = decode(match.payload);
if (message == null || !message.hasContent()) {
LogUtil.e(LogUtil.BUGLE_TAG, "Scheduled message payload missing for " + id);
return;
}
InsertNewMessageAction.insertNewMessage(message);
}
public static void rescheduleAll(@NonNull final Context context) {
final List<Entry> entries = loadLocked(context);
final long now = System.currentTimeMillis();
final List<Entry> due = new ArrayList<>();
synchronized (ScheduledMessageStore.class) {
final List<Entry> remaining = new ArrayList<>();
for (final Entry entry : entries) {
if (entry.sendAt <= now) {
due.add(entry);
} else {
remaining.add(entry);
setAlarm(context, entry);
}
}
if (due.size() != entries.size()) {
saveLocked(context, remaining);
} else if (!due.isEmpty()) {
saveLocked(context, new ArrayList<>());
}
}
for (final Entry entry : due) {
final MessageData message = decode(entry.payload);
if (message != null && message.hasContent()) {
InsertNewMessageAction.insertNewMessage(message);
}
}
}
private static void setAlarm(@NonNull final Context context, @NonNull final Entry entry) {
final AlarmManager alarms = context.getSystemService(AlarmManager.class);
if (alarms == null) {
return;
}
final PendingIntent pending = pendingIntent(context, entry.id, true);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarms.canScheduleExactAlarms()) {
alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, entry.sendAt, pending);
return;
}
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, entry.sendAt, pending);
}
private static void cancelAlarm(@NonNull final Context context, @NonNull final String id) {
final AlarmManager alarms = context.getSystemService(AlarmManager.class);
if (alarms == null) {
return;
}
final PendingIntent pending = pendingIntent(context, id, false);
if (pending != null) {
alarms.cancel(pending);
}
}
private static PendingIntent pendingIntent(@NonNull final Context context,
@NonNull final String id, final boolean update) {
final Intent intent = new Intent(context, ScheduledMessageReceiver.class);
intent.setAction(ScheduledMessageReceiver.ACTION_SEND);
intent.setData(Uri.parse("msgq-schedule:" + id));
intent.putExtra(ScheduledMessageReceiver.EXTRA_ID, id);
final int flags = PendingIntent.FLAG_IMMUTABLE
| (update ? PendingIntent.FLAG_UPDATE_CURRENT : PendingIntent.FLAG_NO_CREATE);
return PendingIntent.getBroadcast(context.getApplicationContext(), id.hashCode(),
intent, flags);
}
private static synchronized List<Entry> loadLocked(@NonNull final Context context) {
final SharedPreferences prefs = prefs(context);
final String raw = prefs.getString(KEY_ENTRIES, "[]");
final List<Entry> entries = new ArrayList<>();
try {
final JSONArray array = new JSONArray(raw);
for (int i = 0; i < array.length(); i++) {
final JSONObject object = array.getJSONObject(i);
final Entry entry = new Entry();
entry.id = object.optString(JSON_ID);
entry.sendAt = object.optLong(JSON_SEND_AT);
entry.payload = object.optString(JSON_PAYLOAD);
if (!TextUtils.isEmpty(entry.id) && !TextUtils.isEmpty(entry.payload)) {
entries.add(entry);
}
}
} catch (final Exception exception) {
LogUtil.e(LogUtil.BUGLE_TAG, "Failed to load scheduled messages", exception);
}
return entries;
}
private static synchronized void saveLocked(@NonNull final Context context,
@NonNull final List<Entry> entries) {
final JSONArray array = new JSONArray();
try {
for (final Entry entry : entries) {
final JSONObject object = new JSONObject();
object.put(JSON_ID, entry.id);
object.put(JSON_SEND_AT, entry.sendAt);
object.put(JSON_PAYLOAD, entry.payload);
array.put(object);
}
prefs(context).edit().putString(KEY_ENTRIES, array.toString()).apply();
} catch (final Exception exception) {
LogUtil.e(LogUtil.BUGLE_TAG, "Failed to save scheduled messages", exception);
}
}
private static SharedPreferences prefs(@NonNull final Context context) {
final Context app = context.getApplicationContext() != null
? context.getApplicationContext()
: Factory.get().getApplicationContext();
return app.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
@Nullable
private static String encode(@NonNull final MessageData message) {
final Parcel parcel = Parcel.obtain();
try {
message.writeToParcel(parcel, 0);
return Base64.encodeToString(parcel.marshall(), Base64.NO_WRAP);
} catch (final Exception exception) {
LogUtil.e(LogUtil.BUGLE_TAG, "Failed to encode scheduled message", exception);
return null;
} finally {
parcel.recycle();
}
}
@Nullable
private static MessageData decode(@Nullable final String payload) {
if (TextUtils.isEmpty(payload)) {
return null;
}
final Parcel parcel = Parcel.obtain();
try {
final byte[] bytes = Base64.decode(payload, Base64.NO_WRAP);
parcel.unmarshall(bytes, 0, bytes.length);
parcel.setDataPosition(0);
return MessageData.CREATOR.createFromParcel(parcel);
} catch (final Exception exception) {
LogUtil.e(LogUtil.BUGLE_TAG, "Failed to decode scheduled message", exception);
return null;
} finally {
parcel.recycle();
}
}
private static final class Entry {
String id;
long sendAt;
String payload;
}
}
@@ -137,7 +137,7 @@ public class ActionServiceImpl extends JobIntentService {
* Broadcast receiver for alarms scheduled through ActionService.
*/
public static class PendingActionReceiver extends BroadcastReceiver {
static final String ACTION = "com.bnkode.notie.datamodel.PENDING_ACTION";
static final String ACTION = "com.filabcorp.msgq.datamodel.PENDING_ACTION";
/**
* Allocate an intent with a specific opcode and alarm action.
@@ -62,6 +62,7 @@ public class ContactPickerData extends BindableData implements
private ContactPickerDataListener mListener;
private final FrequentContactsCursorBuilder mFrequentContactsCursorBuilder;
private String mSearchText;
private ContactUtil.AccountFilter mAccountFilter;
public ContactPickerData(final Context context, final ContactPickerDataListener listener) {
mListener = listener;
@@ -82,7 +83,7 @@ public class ContactPickerData extends BindableData implements
if (isBound(bindingId)) {
switch (id) {
case ALL_CONTACTS_LOADER:
return ContactUtil.getPhones(mContext)
return ContactUtil.getPhones(mContext, mAccountFilter)
.createBoundCursorLoader(bindingId);
case FREQUENT_CONTACTS_LOADER:
return ContactUtil.getFrequentContacts(mContext)
@@ -92,7 +93,7 @@ public class ContactPickerData extends BindableData implements
MessagingContentProvider.PARTICIPANTS_URI,
ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
case FILTERED_CONTACTS_LOADER:
return ContactUtil.filterDestination(mContext, mSearchText)
return ContactUtil.filterDestination(mContext, mSearchText, mAccountFilter)
.createBoundCursorLoader(bindingId);
default:
Assert.fail("Unknown loader id for contact picker!");
@@ -217,6 +218,29 @@ public class ContactPickerData extends BindableData implements
mLoaderManager.restartLoader(FILTERED_CONTACTS_LOADER, args, this);
}
/**
* Reloads the list for one contacts account, or every account when {@code filter} is null.
*/
public void setAccountFilter(final BindingBase<ContactPickerData> binding,
final ContactUtil.AccountFilter filter) {
if (mLoaderManager == null) {
mAccountFilter = filter;
return;
}
final boolean same = (filter == null && mAccountFilter == null)
|| (filter != null && filter.matches(mAccountFilter));
if (same) {
return;
}
mAccountFilter = filter;
final Bundle args = new Bundle();
args.putString(BINDING_ID, binding.getBindingId());
mLoaderManager.restartLoader(ALL_CONTACTS_LOADER, args, this);
if (mSearchText != null) {
mLoaderManager.restartLoader(FILTERED_CONTACTS_LOADER, args, this);
}
}
@Override
protected void unregisterListeners() {
mListener = null;
@@ -22,6 +22,7 @@ import android.content.Intent;
import com.android.messaging.BugleApplication;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.ScheduledMessageStore;
import com.android.messaging.datamodel.action.UpdateMessageNotificationAction;
import com.android.messaging.util.BuglePrefsKeys;
import com.android.messaging.util.LogUtil;
@@ -40,6 +41,7 @@ public class BootAndPackageReplacedReceiver extends BroadcastReceiver {
UpdateMessageNotificationAction.updateMessageNotification();
BugleApplication.updateAppConfig(context);
ScheduledMessageStore.rescheduleAll(context);
} else {
LogUtil.i(LogUtil.BUGLE_TAG, "BootAndPackageReplacedReceiver got unexpected action: "
+ intent.getAction());
@@ -0,0 +1,35 @@
package com.android.messaging.receiver;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.text.TextUtils;
import com.android.messaging.datamodel.ScheduledMessageStore;
import java.util.concurrent.Executors;
/** Fires a previously scheduled compose message when its AlarmManager time is reached. */
public class ScheduledMessageReceiver extends BroadcastReceiver {
public static final String ACTION_SEND = "com.filabcorp.msgq.SEND_SCHEDULED_MESSAGE";
public static final String EXTRA_ID = "scheduled_id";
@Override
public void onReceive(final Context context, final Intent intent) {
if (intent == null || !ACTION_SEND.equals(intent.getAction())) {
return;
}
final String id = intent.getStringExtra(EXTRA_ID);
if (TextUtils.isEmpty(id)) {
return;
}
final PendingResult pending = goAsync();
Executors.newSingleThreadExecutor().execute(() -> {
try {
ScheduledMessageStore.sendNow(context.getApplicationContext(), id);
} finally {
pending.finish();
}
});
}
}
@@ -38,13 +38,13 @@ import com.android.messaging.util.LogUtil;
*/
public class SendStatusReceiver extends BroadcastReceiver {
public static final String MESSAGE_SENT_ACTION =
"com.bnkode.notie.receiver.SendStatusReceiver.MESSAGE_SENT";
"com.filabcorp.msgq.receiver.SendStatusReceiver.MESSAGE_SENT";
public static final String MESSAGE_DELIVERED_ACTION =
"com.bnkode.notie.receiver.SendStatusReceiver.MESSAGE_DELIVERED";
"com.filabcorp.msgq.receiver.SendStatusReceiver.MESSAGE_DELIVERED";
public static final String MMS_SENT_ACTION =
"com.bnkode.notie.receiver.SendStatusReceiver.MMS_SENT";
"com.filabcorp.msgq.receiver.SendStatusReceiver.MMS_SENT";
public static final String MMS_DOWNLOADED_ACTION =
"com.bnkode.notie.receiver.SendStatusReceiver.MMS_DOWNLOADED";
"com.filabcorp.msgq.receiver.SendStatusReceiver.MMS_DOWNLOADED";
// Defined by platform, but no constant provided. See docs for SmsManager.sendTextMessage.
public static final String EXTRA_ERROR_CODE = "errorCode";
@@ -18,9 +18,23 @@ package com.android.messaging.ui;
import android.content.Intent;
import android.os.Bundle;
import android.telephony.TelephonyManager;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.ImageButton;
import androidx.core.app.RemoteInput;
import com.android.messaging.R;
import com.android.messaging.datamodel.NoConfirmationSmsSendService;
import com.android.messaging.ui.home.HomeBottomSheet;
import com.android.messaging.util.LogUtil;
import com.google.android.material.bottomsheet.BottomSheetDialog;
public class RemoteInputEntrypointActivity extends BaseBugleActivity {
private static final String TAG = LogUtil.BUGLE_TAG;
@@ -37,22 +51,111 @@ public class RemoteInputEntrypointActivity extends BaseBugleActivity {
return;
}
// Perform some action depending on the intent
String action = intent.getAction();
if (Intent.ACTION_SENDTO.equals(action)) {
// Build and send the intent
final Intent sendIntent = new Intent(this, NoConfirmationSmsSendService.class);
sendIntent.setAction(TelephonyManager.ACTION_RESPOND_VIA_MESSAGE);
sendIntent.putExtras(intent);
// Wear apparently passes all of its extras via the clip data. Must pass it along.
sendIntent.setClipData(intent.getClipData());
startService(sendIntent);
setResult(RESULT_OK);
} else {
if (!Intent.ACTION_SENDTO.equals(action)) {
LogUtil.w(TAG, "Unrecognized intent action: " + action);
setResult(RESULT_CANCELED);
finish();
return;
}
// This activity should never stick around after processing the intent
final CharSequence remoteText = readRemoteInputText(intent);
if (!TextUtils.isEmpty(remoteText)) {
sendReply(intent, remoteText.toString());
setResult(RESULT_OK);
finish();
return;
}
showReplySheet(intent);
}
private static CharSequence readRemoteInputText(final Intent intent) {
final Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
if (remoteInput != null) {
return remoteInput.getCharSequence(Intent.EXTRA_TEXT);
}
return intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
}
private void showReplySheet(final Intent sourceIntent) {
final View custom = LayoutInflater.from(this)
.inflate(R.layout.home_notification_reply_sheet, null, false);
final EditText input = custom.findViewById(R.id.home_notification_reply_input);
final ImageButton send = custom.findViewById(R.id.home_notification_reply_send);
if (send != null) {
send.setEnabled(false);
send.setAlpha(0.4f);
}
if (input != null && send != null) {
input.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(final CharSequence s, final int start,
final int count, final int after) {
}
@Override
public void onTextChanged(final CharSequence s, final int start, final int before,
final int count) {
final boolean hasText = !TextUtils.isEmpty(s.toString().trim());
send.setEnabled(hasText);
send.setAlpha(hasText ? 1f : 0.4f);
}
@Override
public void afterTextChanged(final Editable s) {
}
});
input.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_SEND) {
submitReply(sourceIntent, input);
return true;
}
return false;
});
send.setOnClickListener(v -> submitReply(sourceIntent, input));
}
CharSequence title = sourceIntent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_NAME);
if (TextUtils.isEmpty(title)) {
title = getString(R.string.home_notification_reply);
}
final BottomSheetDialog dialog = HomeBottomSheet.builder(this)
.title(title)
.custom(custom)
.show();
dialog.setOnDismissListener(d -> finish());
if (input != null) {
input.post(() -> {
input.requestFocus();
final InputMethodManager imm =
(InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
if (imm != null) {
imm.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT);
}
});
}
setResult(RESULT_OK);
}
private void submitReply(final Intent sourceIntent, final EditText input) {
if (input == null) {
return;
}
final String message = input.getText() == null ? "" : input.getText().toString().trim();
if (TextUtils.isEmpty(message)) {
return;
}
sendReply(sourceIntent, message);
finish();
}
private void sendReply(final Intent sourceIntent, final String message) {
final Intent sendIntent = new Intent(this, NoConfirmationSmsSendService.class);
sendIntent.setAction(TelephonyManager.ACTION_RESPOND_VIA_MESSAGE);
sendIntent.putExtras(sourceIntent);
sendIntent.putExtra(Intent.EXTRA_TEXT, message);
sendIntent.setClipData(sourceIntent.getClipData());
startService(sendIntent);
}
}
@@ -26,6 +26,7 @@ import android.graphics.Rect;
import android.net.Uri;
import android.os.Bundle;
import androidx.activity.result.ActivityResultLauncher;
import androidx.fragment.app.Fragment;
import com.android.messaging.Factory;
@@ -69,7 +70,7 @@ public abstract class UIIntents {
public static final String UI_INTENT_EXTRA_WITH_CUSTOM_TRANSITION = "with_custom_transition";
public static final String ACTION_RESET_NOTIFICATIONS =
"com.bnkode.notie.reset_notifications";
"com.filabcorp.msgq.reset_notifications";
// Sending VCard uri to VCard detail activity
public static final String UI_INTENT_EXTRA_VCARD_URI = "vcard_uri";
@@ -102,13 +103,18 @@ public abstract class UIIntents {
// Sending attachment content type from widget
public static final String UI_INTENT_EXTRA_ATTACHMENT_TYPE = "attachment_type";
public static final String UI_INTENT_EXTRA_LOCATION_TEXT = "location_text";
public static final String UI_INTENT_EXTRA_LOCATION_IMAGE_URI = "location_image_uri";
public static final String ACTION_WIDGET_CONVERSATION =
"com.bnkode.notie.widget_conversation:";
"com.filabcorp.msgq.widget_conversation:";
public static final String UI_INTENT_EXTRA_REQUIRES_MMS = "requires_mms";
public static final String UI_INTENT_EXTRA_SELF_ID = "self_id";
public static final String UI_INTENT_EXTRA_CONVERSATION_NAME = "conversation_name";
// Message position to scroll to.
public static final String UI_INTENT_EXTRA_MESSAGE_POSITION = "message_position";
@@ -220,6 +226,12 @@ public abstract class UIIntents {
*/
public abstract void launchHomeAutomationCreateActivity(final Context context);
/**
* Launch home UI to pick a location to share in compose.
*/
public abstract void launchHomeLocationActivity(final Activity activity,
final ActivityResultLauncher<Intent> launcher);
/**
* Launch an activity to show a class zero message
*/
@@ -315,7 +327,8 @@ public abstract class UIIntents {
*/
public abstract PendingIntent getPendingIntentForSendingMessageToConversation(
final Context context, final String conversationId, final String selfId,
final boolean requiresMms, final int requestCode);
final boolean requiresMms, final CharSequence conversationName,
final int requestCode);
/**
* Get a PendingIntent for clearing notifications.
@@ -35,6 +35,7 @@ import android.provider.MediaStore;
import android.support.v7.mms.pdu.ContentType;
import android.text.TextUtils;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.Nullable;
import androidx.core.app.TaskStackBuilder;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
@@ -248,6 +249,19 @@ public class UIIntentsImpl extends UIIntents {
context.startActivity(intent);
}
@Override
public void launchHomeLocationActivity(final Activity activity,
final ActivityResultLauncher<Intent> launcher) {
final Intent intent = new Intent(activity,
com.android.messaging.ui.home.HomeLocationActivity.class);
if (launcher != null) {
launcher.launch(intent);
} else {
activity.startActivity(intent);
}
activity.overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold);
}
@Override
public void launchHomeProfileActivity(final Activity activity, final String conversationId) {
final Intent intent = new Intent(activity,
@@ -396,7 +410,7 @@ public class UIIntentsImpl extends UIIntents {
@Override
public PendingIntent getPendingIntentForSendingMessageToConversation(final Context context,
final String conversationId, final String selfId, final boolean requiresMms,
final int requestCode) {
final CharSequence conversationName, final int requestCode) {
final Intent intent = new Intent(context, RemoteInputEntrypointActivity.class);
intent.setAction(Intent.ACTION_SENDTO);
// Ensure that the platform doesn't reuse PendingIntents across conversations
@@ -404,6 +418,10 @@ public class UIIntentsImpl extends UIIntents {
intent.putExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID, conversationId);
intent.putExtra(UIIntents.UI_INTENT_EXTRA_SELF_ID, selfId);
intent.putExtra(UIIntents.UI_INTENT_EXTRA_REQUIRES_MMS, requiresMms);
if (!TextUtils.isEmpty(conversationName)) {
intent.putExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_NAME,
conversationName.toString());
}
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
return getPendingIntentWithParentStack(context, intent, requestCode);
}
@@ -24,24 +24,37 @@ import android.content.Intent;
import android.os.Bundle;
import android.provider.Settings;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import com.android.messaging.R;
import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.LicenseActivity;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.home.HomeAppLock;
import com.android.messaging.ui.home.HomeBottomSheet;
import com.android.messaging.ui.home.HomeSecuritySettingsActivity;
import com.android.messaging.ui.home.HomeSettingsRow;
import com.android.messaging.ui.home.HomeSubHeader;
import com.android.messaging.ui.home.HomeToast;
import com.android.messaging.util.BuglePrefs;
import com.android.messaging.util.ChangeDefaultSmsAppHelper;
import com.android.messaging.util.PhoneUtils;
import com.google.android.material.bottomsheet.BottomSheetDialog;
public class ApplicationSettingsActivity extends BugleActionBarActivity {
private static final String LANGUAGE_SYSTEM = "system";
private static final String LANGUAGE_EN = "en";
private static final String LANGUAGE_KO = "ko";
private View mSmsRow;
private View mLanguageRow;
private String mLanguageTag = LANGUAGE_SYSTEM;
private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper;
private final ActivityResultLauncher<Intent> mLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(), result -> {
@@ -64,6 +77,9 @@ public class ApplicationSettingsActivity extends BugleActionBarActivity {
HomeSubHeader.bind(this, R.string.home_settings_title, v -> finish());
mSmsRow = findViewById(R.id.home_settings_sms);
mLanguageRow = findViewById(R.id.home_settings_language);
mLanguageTag = BuglePrefs.getApplicationPrefs().getString(
getString(R.string.language_pref_key), LANGUAGE_SYSTEM);
bindStaticRows();
updateSmsRow();
}
@@ -72,6 +88,7 @@ public class ApplicationSettingsActivity extends BugleActionBarActivity {
protected void onResume() {
super.onResume();
updateSmsRow();
bindAppLockRow();
}
@Override
@@ -82,8 +99,9 @@ public class ApplicationSettingsActivity extends BugleActionBarActivity {
private void bindStaticRows() {
final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
final boolean topLevel = getIntent().getBooleanExtra(
UIIntents.UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS, false);
bindComingSoon(R.id.home_settings_premium, R.drawable.ic_home_star_line,
R.string.home_settings_premium, R.string.home_settings_premium_subtitle);
HomeSettingsRow.bind(findViewById(R.id.home_settings_notifications),
R.drawable.ic_home_bell,
@@ -96,6 +114,36 @@ public class ApplicationSettingsActivity extends BugleActionBarActivity {
startActivity(intent);
}, null);
bindRow(R.id.home_settings_sms_mms, R.drawable.ic_home_nav_messages,
R.string.home_settings_sms_mms, R.string.home_settings_sms_mms_subtitle,
v -> {
startActivity(UIIntents.get().getAdvancedSettingsIntent(this));
overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold);
});
bindComingSoon(R.id.home_settings_appearance, R.drawable.ic_home_edit,
R.string.home_settings_appearance, R.string.home_settings_appearance_subtitle);
bindLanguageRow();
bindAppLockRow();
final String automationKey = getString(R.string.automation_only_pref_key);
final boolean automationOnly = prefs.getBoolean(automationKey,
getResources().getBoolean(R.bool.automation_only_pref_default));
HomeSettingsRow.bind(findViewById(R.id.home_settings_automation_only),
R.drawable.ic_home_nav_automate,
getString(R.string.home_settings_automation_only),
getString(R.string.home_settings_automation_only_subtitle),
false, true, automationOnly,
null, (button, checked) -> prefs.putBoolean(automationKey, checked));
bindRow(R.id.home_settings_automations, R.drawable.ic_home_checklist,
R.string.home_settings_automations, R.string.home_settings_automations_subtitle,
v -> {
UIIntents.get().launchHomeAutomationActivity(this);
overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold);
});
final String sendKey = getString(R.string.send_sound_pref_key);
final boolean sendSound = prefs.getBoolean(sendKey,
getResources().getBoolean(R.bool.send_sound_pref_default));
@@ -105,41 +153,119 @@ public class ApplicationSettingsActivity extends BugleActionBarActivity {
null, false, true, sendSound,
null, (button, checked) -> prefs.putBoolean(sendKey, checked));
final String swipeKey = getString(R.string.swipe_right_deletes_conversation_key);
final boolean swipeDelete = prefs.getBoolean(swipeKey, false);
HomeSettingsRow.bind(findViewById(R.id.home_settings_swipe_delete),
R.drawable.ic_home_menu_trash,
getString(R.string.home_settings_swipe_delete),
getString(R.string.home_settings_swipe_delete_subtitle),
false, true, swipeDelete,
null, (button, checked) -> prefs.putBoolean(swipeKey, checked));
bindRow(R.id.home_settings_blocked, R.drawable.ic_home_menu_spam,
R.string.home_settings_blocked, R.string.home_settings_blocked_subtitle,
v -> {
UIIntents.get().launchHomeSpamActivity(this);
overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold);
});
final View advancedCard = findViewById(R.id.home_settings_advanced_card);
if (topLevel) {
advancedCard.setVisibility(View.VISIBLE);
HomeSettingsRow.bind(findViewById(R.id.home_settings_advanced),
R.drawable.ic_home_nav_settings,
getString(R.string.home_settings_advanced),
null, true, false, false,
v -> {
startActivity(UIIntents.get().getAdvancedSettingsIntent(this));
overridePendingTransition(R.anim.home_activity_open,
R.anim.home_activity_hold);
},
null);
} else {
advancedCard.setVisibility(View.GONE);
}
bindComingSoon(R.id.home_settings_privacy, R.drawable.ic_home_menu_person,
R.string.home_settings_privacy, R.string.home_settings_privacy_subtitle);
bindComingSoon(R.id.home_settings_backup, R.drawable.ic_home_archive_line,
R.string.home_settings_backup, R.string.home_settings_backup_subtitle);
bindComingSoon(R.id.home_settings_data, R.drawable.ic_home_file_line,
R.string.home_settings_data, R.string.home_settings_data_subtitle);
bindComingSoon(R.id.home_settings_help, R.drawable.ic_home_info,
R.string.home_settings_help, R.string.home_settings_help_subtitle);
HomeSettingsRow.bind(findViewById(R.id.home_settings_licenses),
R.drawable.ic_info_light,
getString(R.string.home_settings_licenses),
null, true, false, false,
bindRow(R.id.home_settings_licenses, R.drawable.ic_info_light,
R.string.home_settings_licenses, 0,
v -> {
startActivity(new Intent(this, LicenseActivity.class));
overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold);
},
null);
});
}
private void bindLanguageRow() {
if (mLanguageRow == null) {
return;
}
HomeSettingsRow.bind(mLanguageRow, R.drawable.ic_home_language,
getString(R.string.home_settings_language), null, true, false, false,
v -> showLanguageSheet(), null);
HomeSettingsRow.setValue(mLanguageRow, languageLabel(mLanguageTag));
}
private void bindAppLockRow() {
final View row = findViewById(R.id.home_settings_app_lock);
if (row == null) {
return;
}
HomeSettingsRow.bind(row, R.drawable.ic_home_lock,
getString(R.string.home_settings_app_lock),
getString(R.string.home_settings_app_lock_subtitle),
true, false, false,
v -> {
startActivity(new Intent(this, HomeSecuritySettingsActivity.class));
overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold);
}, null);
HomeSettingsRow.setValue(row, getString(HomeAppLock.isEnabled()
? R.string.home_lock_on : R.string.home_lock_off));
}
private String languageLabel(final String tag) {
if (LANGUAGE_EN.equals(tag)) {
return getString(R.string.home_language_english);
}
if (LANGUAGE_KO.equals(tag)) {
return getString(R.string.home_language_korean);
}
return getString(R.string.home_language_system);
}
private void showLanguageSheet() {
final LayoutInflater inflater = LayoutInflater.from(this);
final LinearLayout list = (LinearLayout) inflater.inflate(
R.layout.home_language_sheet, null, false);
final BottomSheetDialog[] dialog = new BottomSheetDialog[1];
addLanguageOption(inflater, list, LANGUAGE_SYSTEM, R.string.home_language_system, dialog);
addLanguageOption(inflater, list, LANGUAGE_EN, R.string.home_language_english, dialog);
addLanguageOption(inflater, list, LANGUAGE_KO, R.string.home_language_korean, dialog);
dialog[0] = HomeBottomSheet.builder(this)
.title(R.string.home_settings_language)
.custom(list)
.show();
}
private void addLanguageOption(final LayoutInflater inflater, final LinearLayout parent,
final String tag, final int titleRes, final BottomSheetDialog[] dialog) {
final View row = inflater.inflate(R.layout.home_language_row, parent, false);
((TextView) row.findViewById(R.id.home_language_title)).setText(titleRes);
row.findViewById(R.id.home_language_check)
.setVisibility(tag.equals(mLanguageTag) ? View.VISIBLE : View.GONE);
row.setOnClickListener(v -> {
mLanguageTag = tag;
BuglePrefs.getApplicationPrefs().putString(
getString(R.string.language_pref_key), tag);
bindLanguageRow();
if (dialog[0] != null) {
dialog[0].dismiss();
}
});
final LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
params.topMargin = parent.getChildCount() == 0
? 0 : getResources().getDimensionPixelSize(R.dimen.looply_space_sm);
row.setLayoutParams(params);
parent.addView(row);
}
private void bindComingSoon(final int rowId, final int iconRes, final int titleRes,
final int subtitleRes) {
bindRow(rowId, iconRes, titleRes, subtitleRes,
v -> HomeToast.show(this, R.string.home_popover_coming_soon));
}
private void bindRow(final int rowId, final int iconRes, final int titleRes,
final int subtitleRes, final View.OnClickListener clickListener) {
final View row = findViewById(rowId);
if (row == null) {
return;
}
HomeSettingsRow.bind(row, iconRes, getString(titleRes),
subtitleRes == 0 ? null : getString(subtitleRes),
true, false, false, clickListener, null);
}
private void updateSmsRow() {
@@ -20,6 +20,7 @@ import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
import android.view.View.OnClickListener;
@@ -53,9 +54,19 @@ public class ContactListItemView extends LinearLayout implements OnClickListener
private ContactIconView mContactIconView;
private ImageView mContactCheckmarkView;
private ImageView mWorkProfileIcon;
private View mSectionCard;
private View mSectionDivider;
private View mSectionGapTop;
private View mSectionGapBottom;
private HostInterface mHostInterface;
private boolean mShouldShowAlphabetHeader;
public static final int SECTION_NONE = 0;
public static final int SECTION_SINGLE = 1;
public static final int SECTION_FIRST = 2;
public static final int SECTION_MIDDLE = 3;
public static final int SECTION_LAST = 4;
public ContactListItemView(final Context context, final AttributeSet attrs) {
super(context, attrs);
mData = DataModel.get().createContactListItemData();
@@ -71,6 +82,55 @@ public class ContactListItemView extends LinearLayout implements OnClickListener
mContactIconView = findViewById(R.id.contact_icon);
mContactCheckmarkView = findViewById(R.id.contact_checkmark);
mWorkProfileIcon = findViewById(R.id.work_profile_icon);
mSectionCard = findViewById(R.id.contact_section_card);
mSectionDivider = findViewById(R.id.contact_section_divider);
mSectionGapTop = findViewById(R.id.contact_section_gap_top);
mSectionGapBottom = findViewById(R.id.contact_section_gap_bottom);
}
/** Draws this row as part of a letter-grouped card, or clears the chrome. */
public void setSectionBox(final int role) {
if (mSectionCard == null) {
return;
}
if (role == SECTION_NONE) {
mSectionCard.setBackground(null);
mSectionCard.setClipToOutline(false);
if (mSectionDivider != null) {
mSectionDivider.setVisibility(GONE);
}
if (mSectionGapTop != null) {
mSectionGapTop.setVisibility(GONE);
}
if (mSectionGapBottom != null) {
mSectionGapBottom.setVisibility(GONE);
}
return;
}
final int background;
if (role == SECTION_SINGLE) {
background = R.drawable.home_bg_contact_section_single;
} else if (role == SECTION_FIRST) {
background = R.drawable.home_bg_contact_section_top;
} else if (role == SECTION_LAST) {
background = R.drawable.home_bg_contact_section_bottom;
} else {
background = R.drawable.home_bg_contact_section_middle;
}
mSectionCard.setBackgroundResource(background);
mSectionCard.setClipToOutline(true);
if (mSectionDivider != null) {
mSectionDivider.setVisibility(
role == SECTION_FIRST || role == SECTION_MIDDLE ? VISIBLE : GONE);
}
if (mSectionGapTop != null) {
mSectionGapTop.setVisibility(
role == SECTION_FIRST || role == SECTION_SINGLE ? VISIBLE : GONE);
}
if (mSectionGapBottom != null) {
mSectionGapBottom.setVisibility(
role == SECTION_LAST || role == SECTION_SINGLE ? VISIBLE : GONE);
}
}
/**
@@ -160,9 +220,11 @@ public class ContactListItemView extends LinearLayout implements OnClickListener
} else {
mWorkProfileIcon.setVisibility(GONE);
}
if (mShouldShowAlphabetHeader) {
final String headerLabel = mData.getAlphabetHeader();
if (mShouldShowAlphabetHeader && !TextUtils.isEmpty(headerLabel)
&& !TextUtils.equals(headerLabel, " ")) {
mAlphabetHeaderTextView.setVisibility(VISIBLE);
mAlphabetHeaderTextView.setText(mData.getAlphabetHeader());
mAlphabetHeaderTextView.setText(headerLabel);
} else {
mAlphabetHeaderTextView.setVisibility(GONE);
}
@@ -118,7 +118,7 @@ public class ContactSectionIndexer implements SectionIndexer {
if (TextUtils.isEmpty(mSections[i])) {
mSections[i] = BLANK_HEADER_STRING;
} else if (!mSections[i].equals(BLANK_HEADER_STRING)) {
mSections[i] = mSections[i].trim();
mSections[i] = normalizeProviderSection(mSections[i].trim());
}
mSectionStartingPositions.add(position);
@@ -141,9 +141,11 @@ public class ContactSectionIndexer implements SectionIndexer {
// The sort key is typically the contact's display name, so for example, a contact
// named "Bob" will go into section "B". The Contacts provider generally uses a
// a slightly more sophisticated heuristic, but as a fallback this is good enough.
final String sortKey = cursor.getString(ContactUtil.INDEX_SORT_KEY);
final String section = TextUtils.isEmpty(sortKey) ? BLANK_HEADER_STRING :
sortKey.substring(0, 1).toUpperCase();
String sortKey = cursor.getString(ContactUtil.INDEX_SORT_KEY);
if (TextUtils.isEmpty(sortKey)) {
sortKey = cursor.getString(ContactUtil.INDEX_DISPLAY_NAME);
}
final String section = sectionLabelFor(sortKey);
final int lastIndex = sections.size() - 1;
final String currentSection = lastIndex >= 0 ? sections.get(lastIndex) : null;
@@ -158,6 +160,94 @@ public class ContactSectionIndexer implements SectionIndexer {
sections.toArray(mSections);
}
/** Maps a Hangul syllable section from the provider to its choseong; leaves other labels. */
private static String normalizeProviderSection(final String title) {
if (title.codePointCount(0, title.length()) != 1) {
return title;
}
final int codePoint = title.codePointAt(0);
if (codePoint >= 0xAC00 && codePoint <= 0xD7A3) {
return sectionLabelFor(title);
}
return title;
}
/**
* Locale-aware bucket for a sort key: Latin A–Z, Hangul choseong (ㄱ…ㅎ), Japanese
* gojuon rows, other letters uppercased, and # for digits or symbols.
*/
static String sectionLabelFor(final String sortKey) {
if (TextUtils.isEmpty(sortKey)) {
return BLANK_HEADER_STRING;
}
int index = 0;
while (index < sortKey.length() && Character.isWhitespace(sortKey.charAt(index))) {
index++;
}
if (index >= sortKey.length()) {
return BLANK_HEADER_STRING;
}
final int codePoint = sortKey.codePointAt(index);
if (codePoint >= 0xAC00 && codePoint <= 0xD7A3) {
final int choseongIndex = (codePoint - 0xAC00) / (21 * 28);
return String.valueOf(HANGUL_CHOSEONG[choseongIndex]);
}
if ((codePoint >= 0x3131 && codePoint <= 0x314E)
|| (codePoint >= 0x1100 && codePoint <= 0x1112)) {
return new String(Character.toChars(codePoint));
}
final int kana = toHiragana(codePoint);
if (kana >= 0x3041 && kana <= 0x3096) {
return String.valueOf(japaneseRow(kana));
}
if (Character.isDigit(codePoint) || isCjkIdeograph(codePoint)
|| !Character.isLetter(codePoint)) {
return "#";
}
return new String(Character.toChars(Character.toUpperCase(codePoint)));
}
private static final char[] HANGUL_CHOSEONG = {
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
};
private static int toHiragana(final int codePoint) {
if (codePoint >= 0x30A1 && codePoint <= 0x30F6) {
return codePoint - 0x60;
}
return codePoint;
}
private static char japaneseRow(final int hiragana) {
if (hiragana <= 0x304A) {
return 'あ';
} else if (hiragana <= 0x3054) {
return 'か';
} else if (hiragana <= 0x305E) {
return 'さ';
} else if (hiragana <= 0x3069) {
return 'た';
} else if (hiragana <= 0x306E) {
return 'な';
} else if (hiragana <= 0x307D) {
return 'は';
} else if (hiragana <= 0x3082) {
return 'ま';
} else if (hiragana <= 0x3088) {
return 'や';
} else if (hiragana <= 0x308D) {
return 'ら';
}
return 'わ';
}
private static boolean isCjkIdeograph(final int codePoint) {
return (codePoint >= 0x4E00 && codePoint <= 0x9FFF)
|| (codePoint >= 0x3400 && codePoint <= 0x4DBF)
|| (codePoint >= 0x20000 && codePoint <= 0x2A6DF);
}
private void buildIndexer(final Cursor cursor) {
// First check if we get indexer label extras from the contact provider; if not, fall back
// to building from display names.
@@ -18,6 +18,7 @@ package com.android.messaging.ui.conversation;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.graphics.Rect;
import android.net.Uri;
@@ -53,6 +54,7 @@ import androidx.core.view.WindowInsetsControllerCompat;
import com.android.messaging.Factory;
import com.android.messaging.R;
import com.android.messaging.datamodel.ScheduledMessageStore;
import com.android.messaging.datamodel.binding.Binding;
import com.android.messaging.datamodel.binding.BindingBase;
import com.android.messaging.datamodel.binding.ImmutableBindingRef;
@@ -72,7 +74,9 @@ import com.android.messaging.ui.AttachmentPreview;
import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.PlainTextEditText;
import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputSink;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.home.HomeComposeAttachPanel;
import com.android.messaging.ui.home.HomeSchedulePicker;
import com.android.messaging.ui.home.HomeToast;
import com.android.messaging.ui.home.HomeUiMotion;
import com.android.messaging.util.AccessibilityUtil;
@@ -116,6 +120,8 @@ public class ComposeMessageView extends LinearLayout
void showAttachmentChooser();
void launchSystemGalleryPicker();
void requestMediaPermission(Runnable onGranted);
void requestLocationPermission(Runnable onGranted);
void launchLocationShare();
boolean shouldShowSubjectEditor();
boolean shouldHideAttachmentsWhenSimSelectorShown();
Uri getSelfSendButtonIconUri();
@@ -124,6 +130,7 @@ public class ComposeMessageView extends LinearLayout
}
public static final int CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN = 10;
private static final String KEY_SCHEDULED_SEND_AT = "home_scheduled_send_at";
// There is no draft and there is no need for the SIM selector
private static final int SEND_WIDGET_MODE_SELF_AVATAR = 1;
@@ -150,6 +157,9 @@ public class ComposeMessageView extends LinearLayout
private int mComposeBarPaddingBottom;
private ImageButton mVoiceButton;
private View mSendColumn;
private View mScheduleChipRow;
private TextView mScheduleChipText;
private long mScheduledSendAt;
private final Binding<DraftMessageData> mBinding;
private IComposeMessageViewHost mHost;
@@ -322,6 +332,16 @@ public class ComposeMessageView extends LinearLayout
mComposeBarPaddingBottom = mComposeBar.getPaddingBottom();
}
mAttachHost = createComposeAttachHost();
mScheduleChipRow = findViewById(R.id.home_schedule_chip_row);
mScheduleChipText = findViewById(R.id.home_schedule_chip_text);
final View scheduleChip = findViewById(R.id.home_schedule_chip);
if (scheduleChip != null) {
scheduleChip.setOnClickListener(v -> showSchedulePicker());
}
final View scheduleClear = findViewById(R.id.home_schedule_chip_clear);
if (scheduleClear != null) {
scheduleClear.setOnClickListener(v -> setScheduledSendAt(0L));
}
mAttachMediaButton.setOnClickListener(clickView -> toggleComposeAttachmentSheet());
mAttachMediaButton.setSelected(mComposeEditText.hasFocus());
@@ -433,6 +453,9 @@ public class ComposeMessageView extends LinearLayout
mBinding.ensureBound(data);
switch (result) {
case CheckDraftForSendTask.RESULT_PASSED:
if (queueScheduledMessageIfNeeded()) {
break;
}
// Continue sending after check succeeded.
final MessageData message = mBinding.getData()
.prepareMessageForSending(mBinding);
@@ -1040,6 +1063,35 @@ public class ComposeMessageView extends LinearLayout
public void saveInputState(final Bundle outState) {
mInputManager.onSaveInputState(outState);
outState.putLong(KEY_SCHEDULED_SEND_AT, mScheduledSendAt);
}
public void restoreScheduledSendAt(final Bundle state) {
if (state == null) {
return;
}
setScheduledSendAt(state.getLong(KEY_SCHEDULED_SEND_AT, 0L));
}
public void applySharedLocation(final Intent data) {
if (data == null) {
return;
}
final String text = data.getStringExtra(UIIntents.UI_INTENT_EXTRA_LOCATION_TEXT);
if (!TextUtils.isEmpty(text)) {
mComposeEditText.setText(text);
mComposeEditText.setSelection(text.length());
if (mBinding.isBound()) {
mBinding.getData().setMessageText(text);
}
}
final Uri image = data.getParcelableExtra(
UIIntents.UI_INTENT_EXTRA_LOCATION_IMAGE_URI, Uri.class);
if (image != null) {
onPendingAttachmentAdded(PendingAttachmentData.createPendingAttachmentData(
"image/jpeg", image));
}
resumeComposeMessage();
}
public void resetMediaPickerState() {
@@ -1135,6 +1187,18 @@ public class ComposeMessageView extends LinearLayout
openMediaFromAttachmentSheet(ConversationInputManager.MEDIA_PICKER_TARGET_CONTACT);
}
@Override
public void onShareLocation() {
if (mHost != null) {
mHost.requestLocationPermission(() -> mHost.launchLocationShare());
}
}
@Override
public void onScheduleMessage() {
showSchedulePicker();
}
@Override
public void onRequestMediaPermission() {
if (mHost != null) {
@@ -1245,6 +1309,58 @@ public class ComposeMessageView extends LinearLayout
}
}
private boolean queueScheduledMessageIfNeeded() {
if (mScheduledSendAt <= System.currentTimeMillis()) {
if (mScheduledSendAt > 0L) {
setScheduledSendAt(0L);
}
return false;
}
final DraftMessageData draft = mBinding.getData();
final boolean empty = TextUtils.isEmpty(draft.getMessageText())
&& TextUtils.isEmpty(draft.getMessageSubject())
&& draft.getReadOnlyAttachments().isEmpty();
if (empty) {
HomeToast.show(getContext(), R.string.home_schedule_need_message);
return true;
}
final MessageData message = draft.prepareMessageForSending(mBinding);
if (message == null || !message.hasContent()) {
HomeToast.show(getContext(), R.string.home_schedule_need_message);
return true;
}
ScheduledMessageStore.schedule(getContext(), message, mScheduledSendAt);
hideSubjectEditor();
HomeToast.show(getContext(), getResources().getString(
R.string.home_schedule_queued, HomeSchedulePicker.formatChip(
getContext(), mScheduledSendAt)));
setScheduledSendAt(0L);
return true;
}
private void showSchedulePicker() {
final Activity activity = resolveActivity();
if (activity == null) {
return;
}
HomeSchedulePicker.show(activity, mScheduledSendAt, this::setScheduledSendAt);
}
private void setScheduledSendAt(final long sendAt) {
mScheduledSendAt = sendAt > System.currentTimeMillis() ? sendAt : 0L;
if (mScheduleChipRow == null) {
return;
}
if (mScheduledSendAt <= 0L) {
mScheduleChipRow.setVisibility(GONE);
return;
}
if (mScheduleChipText != null) {
mScheduleChipText.setText(HomeSchedulePicker.formatChip(getContext(), mScheduledSendAt));
}
mScheduleChipRow.setVisibility(VISIBLE);
}
private Activity resolveActivity() {
if (mOriginalContext instanceof Activity) {
return (Activity) mOriginalContext;
@@ -111,6 +111,7 @@ import com.android.messaging.ui.ConversationDrawables;
import com.android.messaging.ui.SnackBar;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.animation.PopupTransitionAnimation;
import com.android.messaging.ui.home.HomeBottomSheet;
import com.android.messaging.ui.home.HomeComposeAttachPanel;
import com.android.messaging.ui.home.HomeEmptyState;
import com.android.messaging.ui.home.HomeGallerySheet;
@@ -131,10 +132,12 @@ import com.android.messaging.util.ChangeDefaultSmsAppHelper;
import com.android.messaging.util.ContactUtil;
import com.android.messaging.util.ImeUtil;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.OsUtil;
import com.android.messaging.util.PhoneUtils;
import com.android.messaging.util.TextUtil;
import com.android.messaging.util.UiUtils;
import com.android.messaging.util.UriUtil;
import com.google.android.material.bottomsheet.BottomSheetDialog;
import java.io.File;
import java.util.ArrayList;
@@ -336,6 +339,26 @@ public class ConversationFragment extends Fragment implements ConversationDataLi
}
});
private Runnable mOnLocationPermissionGranted;
private final ActivityResultLauncher<String[]> mLocationPermissionLauncher =
registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(),
result -> {
final Runnable next = mOnLocationPermissionGranted;
mOnLocationPermissionGranted = null;
if (next != null) {
next.run();
}
});
private final ActivityResultLauncher<Intent> mLocationShareLauncher =
registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK
&& result.getData() != null
&& mComposeMessageView != null) {
mComposeMessageView.applySharedLocation(result.getData());
}
});
public ConversationFragment getConversationFragment() {
return (ConversationFragment) getParentFragmentManager().findFragmentByTag(
ConversationFragment.FRAGMENT_TAG);
@@ -544,6 +567,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi
// Bind the compose message view to the DraftMessageData
mComposeMessageView.bind(DataModel.get().createDraftMessageData(
mBinding.getData().getConversationId()), this);
mComposeMessageView.restoreScheduledSendAt(savedInstanceState);
return view;
}
@@ -1508,13 +1532,9 @@ public class ConversationFragment extends Fragment implements ConversationDataLi
if (messageView != mSelectedMessage) {
final ConversationMessageData data = messageView.getData();
final boolean isReadyToSend = isReadyForAction();
if (data.getOneClickResendMessage()) {
// Directly resend the message on tap if it's failed
retrySend(data.getMessageId());
if (data.getOneClickResendMessage() || data.getShowResendMessage()) {
// Resend / delete is offered from the status row under the bubble.
selectMessage(null);
} else if (data.getShowResendMessage() && isReadyToSend) {
// Select the message to show the resend/download/delete options
selectMessage(messageView);
} else if (data.getShowDownloadMessage() && isReadyToSend) {
// Directly download the message on tap
retryDownload(data.getMessageId());
@@ -1530,6 +1550,43 @@ public class ConversationFragment extends Fragment implements ConversationDataLi
}
}
@Override
public void onMessageSendOptionsClick(final ConversationMessageView messageView) {
if (getActivity() == null || messageView == null) {
return;
}
final ConversationMessageData data = messageView.getData();
final String messageId = data.getMessageId();
if (TextUtils.isEmpty(messageId)) {
return;
}
final View custom = LayoutInflater.from(requireContext())
.inflate(R.layout.home_message_status_sheet, null, false);
final BottomSheetDialog[] dialog = { null };
final View resend = custom.findViewById(R.id.home_message_status_resend);
final View delete = custom.findViewById(R.id.home_message_status_delete);
if (resend != null) {
resend.setOnClickListener(v -> {
if (dialog[0] != null) {
dialog[0].dismiss();
}
retrySend(messageId);
});
}
if (delete != null) {
delete.setOnClickListener(v -> {
if (dialog[0] != null) {
dialog[0].dismiss();
}
deleteMessage(messageId);
});
}
dialog[0] = HomeBottomSheet.builder(requireContext())
.title(R.string.home_message_status_options_title)
.custom(custom)
.show();
}
private static class AttachmentToSave {
public final Uri uri;
public final String contentType;
@@ -1762,6 +1819,23 @@ public class ConversationFragment extends Fragment implements ConversationDataLi
}
}
@Override
public void requestLocationPermission(final Runnable onGranted) {
if (OsUtil.hasLocationPermission()) {
if (onGranted != null) {
onGranted.run();
}
return;
}
mOnLocationPermissionGranted = onGranted;
mLocationPermissionLauncher.launch(OsUtil.getLocationPermissions());
}
@Override
public void launchLocationShare() {
UIIntents.get().launchHomeLocationActivity(requireActivity(), mLocationShareLauncher);
}
@Override
public void showAttachmentChooser() {
showAttachmentChooser(mConversationId, getActivity());
@@ -19,6 +19,7 @@ package com.android.messaging.ui.conversation;
import android.content.Context;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.net.Uri;
@@ -37,6 +38,7 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.ImageView.ScaleType;
import android.widget.LinearLayout;
import android.widget.TextView;
@@ -87,6 +89,7 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
Rect imageBounds, boolean longPress);
SubscriptionListEntry getSubscriptionEntryForSelfParticipant(String selfParticipantId,
boolean excludeDefault);
void onMessageSendOptionsClick(ConversationMessageView view);
}
private final ConversationMessageData mData;
@@ -98,6 +101,8 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
private boolean mMessageTextHasLinks;
private boolean mMessageHasYouTubeLink;
private TextView mStatusTextView;
private TextView mStatusActionView;
private ImageView mStatusIconView;
private TextView mTitleTextView;
private TextView mMmsInfoTextView;
private LinearLayout mMessageTitleLayout;
@@ -144,6 +149,16 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
IgnoreLinkLongClickHelper.ignoreLinkLongClick(mMessageTextView, this);
mStatusTextView = findViewById(R.id.message_status);
mStatusActionView = findViewById(R.id.message_status_action);
mStatusIconView = findViewById(R.id.message_status_icon);
if (mStatusActionView != null) {
mStatusActionView.setPaintFlags(
mStatusActionView.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG);
mStatusActionView.setOnClickListener(v -> notifySendOptionsClick());
}
if (mStatusIconView != null) {
mStatusIconView.setOnClickListener(v -> notifySendOptionsClick());
}
mTitleTextView = findViewById(R.id.message_title);
mMmsInfoTextView = findViewById(R.id.mms_info);
mMessageTitleLayout = findViewById(R.id.message_title_layout);
@@ -298,6 +313,31 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
|| status == MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER;
}
private boolean isOutgoingWaitingStatus() {
if (mData == null || mData.getIsIncoming() || isOptimisticSending()) {
return false;
}
final int status = mData.getStatus();
return status == MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY;
}
private boolean shouldShowSendOptions() {
if (mData == null || mData.getIsIncoming() || isOptimisticSending()) {
return false;
}
if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
return false;
}
return isOutgoingWaitingStatus()
|| mData.getStatus() == MessageData.BUGLE_STATUS_OUTGOING_FAILED;
}
private void notifySendOptionsClick() {
if (mHost != null && shouldShowSendOptions()) {
mHost.onMessageSendOptionsClick(this);
}
}
/**
* Returns whether we should show simplified visual style for the message view (i.e. hide the
* avatar and bubble arrow, reduce padding).
@@ -367,10 +407,13 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND:
case MessageData.BUGLE_STATUS_OUTGOING_SENDING:
case MessageData.BUGLE_STATUS_OUTGOING_RESENDING:
case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
statusResId = R.string.message_status_sending;
break;
case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
statusText = getResources().getString(R.string.home_message_status_waiting);
break;
case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER:
statusResId = R.string.message_status_send_failed_emergency_number;
break;
@@ -382,12 +425,7 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
break;
}
if (PhoneUtils.getDefault().isDefaultSmsApp()) {
if (isSelected()) {
statusResId = R.string.message_status_resend;
} else {
statusResId = MmsUtils.mapRawStatusToErrorResourceId(
mData.getStatus(), mData.getRawTelephonyStatus());
}
statusText = getResources().getString(R.string.home_message_status_not_sent);
break;
}
// FALL THROUGH HERE
@@ -443,10 +481,15 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
// We set the text even if the view will be GONE for accessibility
mStatusTextView.setText(statusText);
final boolean statusVisible = !TextUtils.isEmpty(statusText);
if (statusVisible) {
mStatusTextView.setVisibility(View.VISIBLE);
} else {
mStatusTextView.setVisibility(View.GONE);
mStatusTextView.setVisibility(statusVisible ? View.VISIBLE : View.GONE);
final boolean sendOptionsVisible = shouldShowSendOptions();
if (mStatusActionView != null) {
mStatusActionView.setVisibility(sendOptionsVisible ? View.VISIBLE : View.GONE);
}
if (mStatusIconView != null) {
mStatusIconView.setVisibility(
isOutgoingWaitingStatus() ? View.VISIBLE : View.GONE);
}
final boolean deliveredBadgeVisible =
@@ -477,11 +520,11 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
}
final boolean metadataVisible = senderNameVisible || statusVisible
|| deliveredBadgeVisible || simNameVisible;
|| sendOptionsVisible || deliveredBadgeVisible || simNameVisible;
mMessageMetadataView.setVisibility(metadataVisible ? View.VISIBLE : View.GONE);
final boolean messageTextAndOrInfoVisible = titleVisible || subjectVisible
|| mData.hasText() || metadataVisible;
|| mData.hasText();
mMessageTextAndInfoView.setVisibility(
messageTextAndOrInfoVisible ? View.VISIBLE : View.GONE);
@@ -787,6 +830,9 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
updateMessageAttachmentsAppearance(gravity);
mMessageMetadataView.setPadding(0, metadataTopPadding, 0, 0);
if (mMessageMetadataView.getVisibility() == View.VISIBLE) {
setPadding(getPaddingStart(), getPaddingTop(), getPaddingEnd(), metadataTopPadding);
}
updateTextAppearance();
@@ -844,6 +890,10 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
description.append(separator);
description.append(mStatusTextView.getText());
}
if (mStatusActionView != null && mStatusActionView.getVisibility() == View.VISIBLE) {
description.append(separator);
description.append(mStatusActionView.getText());
}
if (mSimNameView.getVisibility() == View.VISIBLE) {
description.append(separator);
@@ -995,10 +1045,30 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
mData.hasAttachments() && !shouldShowMessageTextBubble()) {
timestampColorResId = R.color.timestamp_text_outgoing;
}
mStatusTextView.setTextColor(getResources().getColor(timestampColorResId, theme));
// Status captions sit outside the bubble, so keep them readable against the canvas.
final int captionColorResId;
if (shouldShowSendOptions() && !isOutgoingWaitingStatus()) {
captionColorResId = R.color.home_destructive;
} else if (isOutgoingWaitingStatus() || (!mData.getIsIncoming()
&& (mData.getStatus() == MessageData.BUGLE_STATUS_OUTGOING_SENDING
|| mData.getStatus() == MessageData.BUGLE_STATUS_OUTGOING_RESENDING
|| isOptimisticSending()))) {
captionColorResId = R.color.home_text_secondary;
} else {
captionColorResId = mData.getIsIncoming()
? R.color.timestamp_text_incoming : R.color.timestamp_text_outgoing;
}
final int captionColor = getResources().getColor(captionColorResId, theme);
mStatusTextView.setTextColor(captionColor);
if (mStatusActionView != null) {
mStatusActionView.setTextColor(captionColor);
}
if (mStatusIconView != null) {
mStatusIconView.setColorFilter(captionColor);
}
mSubjectLabel.setTextColor(getResources().getColor(subjectLabelColorResId, theme));
mSenderNameTextView.setTextColor(getResources().getColor(timestampColorResId, theme));
mSenderNameTextView.setTextColor(captionColor);
}
/**
@@ -63,7 +63,7 @@ public class ConversationListActivity extends AbstractConversationListActivity {
if (actionBar == null) {
return;
}
// Custom Message+ chrome replaces the ActionBar on the home screen.
// Custom Message Q chrome replaces the ActionBar on the home screen.
actionBar.hide();
super.updateActionBar(actionBar);
}
@@ -19,6 +19,7 @@ package com.android.messaging.ui.conversationlist;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Canvas;
@@ -136,6 +137,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
private TextView mHomeReadAllButton;
private int mHomeSelectionCount;
private View mHomeMessagesTop;
private View mHomeCategoryCollapse;
private View mHomeCategoryStrip;
private ViewGroup mHomeCategoryAll;
private View mHomeCategoryScroll;
@@ -162,6 +164,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
private int mHomeNavAutomateNaturalWidth;
private int mHomeTab = HOME_TAB_MESSAGES;
private View mHomeListProfilePopup;
private PopupWindow mHomeListProfileWindow;
private View mHomeListProfileScrim;
private ImageView mHomeListProfileBlur;
private Bitmap mHomeListProfileBlurBitmap;
@@ -189,12 +192,18 @@ public class ConversationListFragment extends Fragment implements ConversationLi
private static final int HOME_TAB_MESSAGES = 0;
private static final int HOME_TAB_AUTOMATE = 1;
private static final int SEARCH_NEWEST_EDGE_SLOP_DP = 48;
private static final int SEARCH_BAR_ANIM_MS = 280;
private static final int SEARCH_BAR_ANIM_MS = 220;
private static final int SEARCH_BAR_SLIDE_FALLBACK_DP = 72;
private static final int CHROME_SCROLL_THRESHOLD_DP = 10;
private Parcelable mListState;
private View mSearchBar;
private View mListTopFade;
private View mListBottomFade;
private boolean mSearchBarShown;
private boolean mCategoryStripShown = true;
private int mCategoryNaturalHeight;
private int mChromeScrollAccum;
final Binding<ConversationListData> mListBinding = BindingBase.createBinding(this);
private final ConversationListItemData mPinnedRowProbe = new ConversationListItemData();
@@ -259,6 +268,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
setScrolledToNewestConversationIfNeeded();
applySearchBarVisibility();
resetSearchBarScrollTracking();
updateListTopFade();
updateHomeMainNavVisibility();
refreshPinnedStrip();
updateUi();
@@ -273,6 +283,50 @@ public class ConversationListFragment extends Fragment implements ConversationLi
}
}
private boolean isListFlushAtTop() {
if (mRecyclerView == null || mAdapter == null || mAdapter.getItemCount() == 0) {
return true;
}
final LinearLayoutManager manager =
(LinearLayoutManager) mRecyclerView.getLayoutManager();
if (manager == null) {
return true;
}
if (manager.findFirstVisibleItemPosition() > 0) {
return false;
}
final View first = manager.findViewByPosition(0);
if (first == null) {
return !mRecyclerView.canScrollVertically(-1);
}
return first.getTop() >= mRecyclerView.getPaddingTop() - 1;
}
private boolean isListFlushAtBottom() {
if (mRecyclerView == null || mAdapter == null || mAdapter.getItemCount() == 0) {
return true;
}
return !mRecyclerView.canScrollVertically(1);
}
private void setFadeVisible(final View fade, final boolean show) {
if (fade == null) {
return;
}
final int visibility = show ? View.VISIBLE : View.GONE;
if (fade.getVisibility() != visibility) {
fade.setVisibility(visibility);
}
}
private void updateListTopFade() {
final boolean listActive = mHomeTab == HOME_TAB_MESSAGES
&& !mArchiveMode
&& !mForwardMessageMode;
setFadeVisible(mListTopFade, listActive && !isListFlushAtTop());
setFadeVisible(mListBottomFade, listActive && !isListFlushAtBottom());
}
private boolean isScrolledToFirstConversation() {
int firstItemPosition = ((LinearLayoutManager) mRecyclerView.getLayoutManager())
.findFirstCompletelyVisibleItemPosition();
@@ -287,6 +341,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
*/
@Override
public void onDestroy() {
dismissListProfileWindowImmediate();
super.onDestroy();
mListBinding.unbind();
mHost = null;
@@ -301,6 +356,8 @@ public class ConversationListFragment extends Fragment implements ConversationLi
final ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.conversation_list_fragment,
container, false);
mRecyclerView = rootView.findViewById(android.R.id.list);
mListTopFade = rootView.findViewById(R.id.home_list_top_fade);
mListBottomFade = rootView.findViewById(R.id.home_list_bottom_fade);
mEmptyListMessageView = rootView.findViewById(R.id.no_conversations_view);
// The default behavior for default layout param generation by LinearLayoutManager is to
// provide width and height of WRAP_CONTENT, but this is not desirable for
@@ -343,6 +400,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
if (!mArchiveMode && !mForwardMessageMode) {
handleSearchBarByScrollOffset(dy);
}
updateListTopFade();
}
@Override
@@ -398,6 +456,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
}
setupHomeChrome(rootView);
mHomeMessagesTop = rootView.findViewById(R.id.home_messages_top);
mHomeCategoryCollapse = rootView.findViewById(R.id.home_category_collapse);
mHomeCategoryStrip = rootView.findViewById(R.id.home_category_strip);
mHomeCategoryAll = rootView.findViewById(R.id.home_category_all);
mHomeCategoryScroll = rootView.findViewById(R.id.home_category_scroll);
@@ -588,13 +647,8 @@ public class ConversationListFragment extends Fragment implements ConversationLi
}
private void setupSearchBarScroll(final View searchBar) {
searchBar.addOnLayoutChangeListener(
(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
if (bottom - top != oldBottom - oldTop && mSearchBarShown) {
updateListPaddingForSearchBar();
}
});
searchBar.post(() -> {
updateListPaddingForSearchBar();
applySearchBarVisibility();
resetSearchBarScrollTracking();
});
@@ -604,15 +658,24 @@ public class ConversationListFragment extends Fragment implements ConversationLi
// No scroll state is kept; visibility is derived from the live scroll position.
}
/** Toward older rows → keep search visible. Toward recent rows → hide. */
/** Toward older rows → search. Toward recent rows → categories. */
private void handleSearchBarByScrollOffset(final int dy) {
if (mHomeTab != HOME_TAB_MESSAGES || isSelectionMode() || mHomePinnedEditMode
|| mSearchBar == null || mRecyclerView == null || dy == 0) {
return;
}
if (dy > 0) {
if (isScrolledToFirstConversation() && dy < 0) {
mChromeScrollAccum = 0;
hideSearchBar();
return;
}
mChromeScrollAccum += dy;
final int threshold = dpToPx(CHROME_SCROLL_THRESHOLD_DP);
if (mChromeScrollAccum > threshold) {
mChromeScrollAccum = 0;
showSearchBar();
} else {
} else if (mChromeScrollAccum < -threshold) {
mChromeScrollAccum = 0;
hideSearchBar();
}
}
@@ -621,73 +684,122 @@ public class ConversationListFragment extends Fragment implements ConversationLi
if (mSearchBar != null) {
mSearchBar.animate().cancel();
}
if (mHomeCategoryStrip != null) {
mHomeCategoryStrip.animate().cancel();
}
}
private float getSearchBarSlideDistancePx() {
if (mSearchBar == null) {
return dpToPx(SEARCH_BAR_SLIDE_FALLBACK_DP);
private int getChromeSlotHeight() {
int height = 0;
if (mHomeCategoryStrip != null && mHomeCategoryStrip.getHeight() > 0) {
height = Math.max(height, mHomeCategoryStrip.getHeight());
mCategoryNaturalHeight = mHomeCategoryStrip.getHeight();
}
final int height = mSearchBar.getHeight();
return height > 0 ? height : dpToPx(SEARCH_BAR_SLIDE_FALLBACK_DP);
if (mSearchBar != null && mSearchBar.getHeight() > 0) {
height = Math.max(height, mSearchBar.getHeight());
}
if (height > 0) {
return height;
}
if (mCategoryNaturalHeight > 0) {
return mCategoryNaturalHeight;
}
return dpToPx(SEARCH_BAR_SLIDE_FALLBACK_DP);
}
private void setChromeInteractive(final View view, final boolean interactive) {
if (view == null) {
return;
}
view.setClickable(interactive);
view.setImportantForAccessibility(interactive
? View.IMPORTANT_FOR_ACCESSIBILITY_YES
: View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
if (view instanceof ViewGroup) {
((ViewGroup) view).setDescendantFocusability(interactive
? ViewGroup.FOCUS_AFTER_DESCENDANTS
: ViewGroup.FOCUS_BLOCK_DESCENDANTS);
}
view.setOnTouchListener(interactive ? null : (v, event) -> true);
}
private void applyChromeSlot(final boolean searchShown, final boolean animate) {
if (mHomeCategoryCollapse == null) {
return;
}
if (mHomeTab != HOME_TAB_MESSAGES) {
cancelSearchBarAnimation();
mSearchBarShown = false;
mCategoryStripShown = false;
mHomeCategoryCollapse.setVisibility(View.GONE);
return;
}
mHomeCategoryCollapse.setVisibility(View.VISIBLE);
mSearchBarShown = searchShown;
mCategoryStripShown = !searchShown;
if (mSearchBar == null || mHomeCategoryStrip == null) {
return;
}
final float slot = getChromeSlotHeight();
mHomeCategoryCollapse.setMinimumHeight((int) slot);
final float searchY = searchShown ? 0f : -slot;
cancelSearchBarAnimation();
mSearchBar.setAlpha(1f);
mHomeCategoryStrip.setAlpha(1f);
mHomeCategoryStrip.setTranslationY(0f);
if (!animate) {
mSearchBar.setTranslationY(searchY);
mSearchBar.setVisibility(searchShown ? View.VISIBLE : View.INVISIBLE);
mHomeCategoryStrip.setVisibility(searchShown ? View.INVISIBLE : View.VISIBLE);
setChromeInteractive(mSearchBar, searchShown);
setChromeInteractive(mHomeCategoryStrip, !searchShown);
return;
}
mHomeCategoryStrip.setVisibility(View.VISIBLE);
mSearchBar.setVisibility(View.VISIBLE);
setChromeInteractive(mSearchBar, searchShown);
setChromeInteractive(mHomeCategoryStrip, !searchShown);
mSearchBar.animate()
.translationY(searchY)
.setDuration(SEARCH_BAR_ANIM_MS)
.setInterpolator(UiUtils.DEFAULT_INTERPOLATOR)
.withEndAction(() -> {
if (mSearchBar == null || mHomeCategoryStrip == null) {
return;
}
if (mSearchBarShown) {
mHomeCategoryStrip.setVisibility(View.INVISIBLE);
} else {
mSearchBar.setVisibility(View.INVISIBLE);
}
});
}
private void setCategoryStripShown(final boolean shown, final boolean animate) {
applyChromeSlot(!shown, animate);
}
private void updateListPaddingForSearchBar() {
if (mRecyclerView == null) {
return;
}
int top = 0;
if (mSearchBarShown && mSearchBar != null
&& mSearchBar.getVisibility() != View.GONE) {
top = mSearchBar.getHeight();
if (top == 0) {
top = dpToPx(SEARCH_BAR_SLIDE_FALLBACK_DP);
}
}
mRecyclerView.setPadding(
mRecyclerView.getPaddingLeft(),
top,
0,
mRecyclerView.getPaddingRight(),
mRecyclerView.getPaddingBottom());
}
private void applySearchBarVisibility() {
if (mSearchBar == null || mHomeTab != HOME_TAB_MESSAGES) {
if (mHomeTab != HOME_TAB_MESSAGES) {
applyChromeSlot(false, false);
return;
}
if (mHomePinnedEditMode) {
cancelSearchBarAnimation();
mSearchBar.setVisibility(View.GONE);
mSearchBar.setAlpha(1f);
mSearchBar.setTranslationY(0f);
updateListPaddingForSearchBar();
applyChromeSlot(false, false);
return;
}
cancelSearchBarAnimation();
if (mSearchBarShown) {
mSearchBar.setVisibility(View.VISIBLE);
mSearchBar.setAlpha(1f);
mSearchBar.setTranslationY(0f);
updateListPaddingForSearchBar();
mSearchBar.post(this::updateListPaddingForSearchBar);
} else {
mSearchBar.setVisibility(View.GONE);
mSearchBar.setAlpha(1f);
mSearchBar.setTranslationY(0f);
updateListPaddingForSearchBar();
}
}
private void runSearchBarShowAnimation() {
updateListPaddingForSearchBar();
final float slide = getSearchBarSlideDistancePx();
mSearchBar.setTranslationY(-slide);
mSearchBar.setAlpha(0f);
mSearchBar.animate()
.translationY(0f)
.alpha(1f)
.setDuration(SEARCH_BAR_ANIM_MS)
.setInterpolator(UiUtils.DEFAULT_INTERPOLATOR)
.withEndAction(this::updateListPaddingForSearchBar);
applyChromeSlot(mSearchBarShown, false);
}
private void showSearchBar() {
@@ -695,40 +807,14 @@ public class ConversationListFragment extends Fragment implements ConversationLi
|| mSearchBar == null || mSearchBarShown) {
return;
}
mSearchBarShown = true;
cancelSearchBarAnimation();
mSearchBar.setVisibility(View.VISIBLE);
updateListPaddingForSearchBar();
if (mSearchBar.getHeight() == 0) {
mSearchBar.post(() -> {
updateListPaddingForSearchBar();
runSearchBarShowAnimation();
});
} else {
runSearchBarShowAnimation();
}
applyChromeSlot(true, true);
}
private void hideSearchBar() {
if (mSearchBar == null || !mSearchBarShown) {
return;
}
mSearchBarShown = false;
cancelSearchBarAnimation();
final float slide = getSearchBarSlideDistancePx();
mSearchBar.animate()
.translationY(-slide)
.alpha(0f)
.setDuration(SEARCH_BAR_ANIM_MS)
.setInterpolator(UiUtils.DEFAULT_INTERPOLATOR)
.withEndAction(() -> {
if (!mSearchBarShown && mSearchBar != null) {
mSearchBar.setVisibility(View.GONE);
mSearchBar.setTranslationY(0f);
mSearchBar.setAlpha(1f);
updateListPaddingForSearchBar();
}
});
applyChromeSlot(false, true);
}
private void setupHomeChrome(final View rootView) {
@@ -838,7 +924,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
? mHomeListProfileData.getConversationId() : null;
final boolean sameProfile = data.getConversationId() != null
&& data.getConversationId().equals(previousProfileId)
&& mHomeListProfilePopup.getVisibility() == View.VISIBLE;
&& isListProfilePopupShowing();
if (sameProfile) {
return;
}
@@ -874,13 +960,81 @@ public class ConversationListFragment extends Fragment implements ConversationLi
mHomeListProfileContent.setAlpha(0f);
mHomeListProfilePopup.setVisibility(View.INVISIBLE);
bindListProfileBlur();
ensureListProfileWindow();
mHomeListProfilePopup.setVisibility(View.VISIBLE);
showListProfileWindow();
positionListProfilePopup(anchorView, () -> {
mHomeListProfilePopup.setVisibility(View.VISIBLE);
HomeUiMotion.listProfilePopupEnter(mHomeListProfileScrim, mHomeListProfileContent,
anchorView);
});
}
private boolean isListProfilePopupShowing() {
return mHomeListProfileWindow != null && mHomeListProfileWindow.isShowing();
}
private void ensureListProfileWindow() {
if (mHomeListProfilePopup == null || mHomeListProfileWindow != null) {
return;
}
if (mHomeListProfilePopup.getParent() instanceof ViewGroup) {
((ViewGroup) mHomeListProfilePopup.getParent()).removeView(mHomeListProfilePopup);
}
mHomeListProfileWindow = new PopupWindow(mHomeListProfilePopup,
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
true);
mHomeListProfileWindow.setOutsideTouchable(true);
mHomeListProfileWindow.setFocusable(true);
mHomeListProfileWindow.setClippingEnabled(false);
mHomeListProfileWindow.setElevation(32f);
mHomeListProfileWindow.setBackgroundDrawable(
new android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT));
mHomeListProfileWindow.setOnDismissListener(() -> {
if (mHomeListProfilePopup != null) {
mHomeListProfilePopup.setVisibility(View.GONE);
}
recycleListProfileBlur();
mHomeListProfileData = null;
});
}
private void showListProfileWindow() {
final Activity activity = getActivity();
if (activity == null || mHomeListProfileWindow == null) {
return;
}
final View decor = activity.getWindow().getDecorView();
if (decor.getWidth() > 0 && decor.getHeight() > 0) {
mHomeListProfileWindow.setWidth(decor.getWidth());
mHomeListProfileWindow.setHeight(decor.getHeight());
}
if (!mHomeListProfileWindow.isShowing()) {
mHomeListProfileWindow.showAtLocation(decor, Gravity.NO_GRAVITY, 0, 0);
}
}
private void dismissListProfileWindowImmediate() {
if (mHomeListProfileWindow == null) {
recycleListProfileBlur();
mHomeListProfileData = null;
if (mHomeListProfilePopup != null) {
mHomeListProfilePopup.setVisibility(View.GONE);
}
return;
}
mHomeListProfileWindow.setOnDismissListener(null);
if (mHomeListProfileWindow.isShowing()) {
mHomeListProfileWindow.dismiss();
}
mHomeListProfileWindow = null;
if (mHomeListProfilePopup != null) {
mHomeListProfilePopup.setVisibility(View.GONE);
}
recycleListProfileBlur();
mHomeListProfileData = null;
}
private void updateListProfileActionsPillWidth() {
if (mHomeListProfileActions == null) {
return;
@@ -1003,17 +1157,19 @@ public class ConversationListFragment extends Fragment implements ConversationLi
}
private void dismissHomeListProfilePopup(final boolean animate) {
if (mHomeListProfilePopup == null
|| mHomeListProfilePopup.getVisibility() != View.VISIBLE) {
if (!isListProfilePopupShowing()) {
recycleListProfileBlur();
mHomeListProfileData = null;
if (mHomeListProfilePopup != null) {
mHomeListProfilePopup.setVisibility(View.GONE);
}
return;
}
final Runnable finish = () -> {
mHomeListProfilePopup.setVisibility(View.GONE);
resetListProfilePopupPosition();
recycleListProfileBlur();
mHomeListProfileData = null;
if (mHomeListProfileWindow != null && mHomeListProfileWindow.isShowing()) {
mHomeListProfileWindow.dismiss();
}
};
if (animate) {
HomeUiMotion.listProfilePopupExit(mHomeListProfileScrim, mHomeListProfileContent,
@@ -1079,14 +1235,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
}
if (enabled) {
setPinnedEditMode(false, false);
mSearchBarShown = false;
cancelSearchBarAnimation();
if (mSearchBar != null) {
mSearchBar.setVisibility(View.GONE);
mSearchBar.setAlpha(1f);
mSearchBar.setTranslationY(0f);
}
updateListPaddingForSearchBar();
applyChromeSlot(false, false);
}
updateHomeSelectionBar(0);
updateHomeComposeButtonVisibility();
@@ -1129,10 +1278,10 @@ public class ConversationListFragment extends Fragment implements ConversationLi
if (mHomeMessagesTop != null) {
mHomeMessagesTop.setVisibility(View.VISIBLE);
}
if (mHomeCategoryStrip != null) {
mHomeCategoryStrip.setVisibility(showMessages ? View.VISIBLE : View.GONE);
} else if (mHomeCategoryScroll != null) {
mHomeCategoryScroll.setVisibility(showMessages ? View.VISIBLE : View.GONE);
if (showMessages) {
setCategoryStripShown(!mSearchBarShown, false);
} else {
setCategoryStripShown(false, false);
}
final CharSequence title = getString(showMessages
? R.string.home_title : R.string.home_automation_title);
@@ -1156,14 +1305,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
}
}
if (!showMessages) {
mSearchBarShown = false;
cancelSearchBarAnimation();
if (mSearchBar != null) {
mSearchBar.setVisibility(View.GONE);
mSearchBar.setAlpha(1f);
mSearchBar.setTranslationY(0f);
}
updateListPaddingForSearchBar();
applyChromeSlot(false, false);
if (mEmptyListMessageView != null) {
mEmptyListMessageView.setVisibility(View.GONE);
}
@@ -1175,10 +1317,15 @@ public class ConversationListFragment extends Fragment implements ConversationLi
updateHomeNavHighlight(animate);
updateHomeMainNavVisibility();
refreshPinnedStrip();
updateListTopFade();
mHomeTabReady = true;
}
public boolean onHomeBackPressed() {
if (isListProfilePopupShowing()) {
dismissHomeListProfilePopup(true);
return true;
}
if (mHomeTab == HOME_TAB_AUTOMATE) {
selectHomeTab(HOME_TAB_MESSAGES);
return true;
@@ -1415,12 +1562,15 @@ public class ConversationListFragment extends Fragment implements ConversationLi
return;
}
mHomeListProfileBlurBitmap = captureDecorSnapshot();
if (mHomeListProfileBlurBitmap == null) {
mHomeListProfileBlurBitmap = captureViewSnapshot(getView());
}
if (mHomeListProfileBlurBitmap == null) {
return;
}
mHomeListProfileBlur.setImageBitmap(mHomeListProfileBlurBitmap);
mHomeListProfileBlur.setRenderEffect(RenderEffect.createBlurEffect(
36f, 36f, Shader.TileMode.CLAMP));
64f, 64f, Shader.TileMode.CLAMP));
}
private void recycleListProfileBlur() {
@@ -1700,8 +1850,11 @@ public class ConversationListFragment extends Fragment implements ConversationLi
if (activity == null) {
return null;
}
final View root = activity.getWindow().getDecorView();
if (root.getWidth() <= 0 || root.getHeight() <= 0) {
return captureViewSnapshot(activity.getWindow().getDecorView());
}
private Bitmap captureViewSnapshot(final View root) {
if (root == null || root.getWidth() <= 0 || root.getHeight() <= 0) {
return null;
}
final Bitmap bitmap = Bitmap.createBitmap(
@@ -1787,24 +1940,26 @@ public class ConversationListFragment extends Fragment implements ConversationLi
mHomePopover = null;
}
});
final int xOff;
if (alignEnd) {
final int endInset = getResources().getDimensionPixelSize(
R.dimen.looply_screen_padding);
final int[] anchorLoc = new int[2];
anchor.getLocationOnScreen(anchorLoc);
final View root = getView();
final int windowRight;
if (root != null) {
final int[] rootLoc = new int[2];
root.getLocationOnScreen(rootLoc);
windowRight = rootLoc[0] + root.getWidth();
} else {
windowRight = getResources().getDisplayMetrics().widthPixels;
}
xOff = windowRight - endInset - content.getMeasuredWidth() - anchorLoc[0];
final int endInset = getResources().getDimensionPixelSize(
R.dimen.looply_screen_padding);
final int[] anchorLoc = new int[2];
anchor.getLocationOnScreen(anchorLoc);
final View root = getView();
final int windowRight;
if (root != null) {
final int[] rootLoc = new int[2];
root.getLocationOnScreen(rootLoc);
windowRight = rootLoc[0] + root.getWidth();
} else {
xOff = 0;
windowRight = getResources().getDisplayMetrics().widthPixels;
}
int xOff = alignEnd
? windowRight - endInset - content.getMeasuredWidth() - anchorLoc[0]
: 0;
final int overflow = (anchorLoc[0] + xOff + content.getMeasuredWidth())
- (windowRight - endInset);
if (overflow > 0) {
xOff -= overflow;
}
popup.showAsDropDown(anchor, xOff, 8);
HomeUiMotion.popoverEnter(content, alignEnd);
@@ -1840,6 +1995,10 @@ public class ConversationListFragment extends Fragment implements ConversationLi
selectHomeChip(ConversationListAdapter.FILTER_ALL);
dismissHomePopover();
});
content.findViewById(R.id.popover_share_app).setOnClickListener(v -> {
dismissHomePopover();
shareThisApp();
});
content.findViewById(R.id.popover_settings).setOnClickListener(v -> {
dismissHomePopover();
launchHomeSubScreen(() -> {
@@ -1850,8 +2009,21 @@ public class ConversationListFragment extends Fragment implements ConversationLi
});
}
private void shareThisApp() {
final Context context = getContext();
if (context == null) {
return;
}
final String storeUrl = "https://play.google.com/store/apps/details?id="
+ context.getPackageName();
final Intent share = new Intent(Intent.ACTION_SEND);
share.setType("text/plain");
share.putExtra(Intent.EXTRA_TEXT, getString(R.string.home_share_app_text, storeUrl));
startActivity(Intent.createChooser(share, getString(R.string.home_popover_share_app)));
}
private void showHomeEditPopover(final View anchor) {
final PopupWindow popup = showHomePopover(anchor, R.layout.home_popover_edit, false);
final PopupWindow popup = showHomePopover(anchor, R.layout.home_popover_edit, true);
final View content = popup.getContentView();
content.findViewById(R.id.popover_select_messages).setOnClickListener(v -> {
dismissHomePopoverImmediate();
@@ -2107,6 +2279,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
@Override
public void onPause() {
super.onPause();
dismissHomeListProfilePopup(false);
dismissHomePopover();
if (mHomeBottomNav != null && !mArchiveMode && !mForwardMessageMode) {
mHomeBottomNav.setVisibility(View.GONE);
@@ -2139,6 +2312,9 @@ public class ConversationListFragment extends Fragment implements ConversationLi
public void updateUi() {
mAdapter.notifyDataSetChanged();
if (mRecyclerView != null) {
mRecyclerView.post(this::updateListTopFade);
}
}
@Override
@@ -2382,13 +2558,8 @@ public class ConversationListFragment extends Fragment implements ConversationLi
if (mSearchBar == null) {
return;
}
cancelSearchBarAnimation();
if (mHomePinnedEditMode) {
mSearchBarShown = false;
mSearchBar.setVisibility(View.GONE);
mSearchBar.setAlpha(1f);
mSearchBar.setTranslationY(0f);
updateListPaddingForSearchBar();
applyChromeSlot(false, false);
} else {
applySearchBarVisibility();
}
@@ -0,0 +1,294 @@
package com.android.messaging.ui.home;
import android.app.Activity;
import android.app.Application;
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleEventObserver;
import androidx.lifecycle.ProcessLifecycleOwner;
import com.android.messaging.util.BuglePrefs;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/** App lock credentials, session, and process-level unlock gate (File Plus behavior). */
public final class HomeAppLock {
public enum Method {
NONE, PIN, PATTERN, PASSWORD;
static Method fromStored(@Nullable final String value) {
if (TextUtils.isEmpty(value)) {
return NONE;
}
try {
return Method.valueOf(value);
} catch (final IllegalArgumentException ignored) {
return NONE;
}
}
}
public static final String EXTRA_MODE = "home_app_lock_mode";
public static final int MODE_UNLOCK = 0;
public static final int MODE_SET_PIN = 1;
public static final int MODE_SET_PATTERN = 2;
public static final int MODE_SET_PASSWORD = 3;
private static final String KEY_ENABLED = "home_app_lock_enabled";
private static final String KEY_METHOD = "home_app_lock_method";
private static final String KEY_HASH = "home_app_lock_hash";
private static final String HASH_SALT = "Messaging.Security.v1";
private static boolean sUnlocked;
private static boolean sShowingUnlock;
private HomeAppLock() {
}
public static void install(@NonNull final Application application) {
ProcessLifecycleOwner.get().getLifecycle().addObserver(
(LifecycleEventObserver) (source, event) -> {
if (event == Lifecycle.Event.ON_STOP && requiresUnlock()) {
sUnlocked = false;
}
});
application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(@NonNull final Activity activity,
@Nullable final Bundle savedInstanceState) {
}
@Override
public void onActivityStarted(@NonNull final Activity activity) {
}
@Override
public void onActivityResumed(@NonNull final Activity activity) {
maybeLock(activity);
}
@Override
public void onActivityPaused(@NonNull final Activity activity) {
}
@Override
public void onActivityStopped(@NonNull final Activity activity) {
}
@Override
public void onActivitySaveInstanceState(@NonNull final Activity activity,
@NonNull final Bundle outState) {
}
@Override
public void onActivityDestroyed(@NonNull final Activity activity) {
}
});
}
public static boolean isEnabled() {
return prefs().getBoolean(KEY_ENABLED, false);
}
public static void setEnabled(final boolean enabled) {
prefs().putBoolean(KEY_ENABLED, enabled);
if (enabled) {
markUnlocked();
}
}
public static Method getMethod() {
return Method.fromStored(prefs().getString(KEY_METHOD, Method.NONE.name()));
}
public static boolean hasLockConfigured() {
return getMethod() != Method.NONE && !TextUtils.isEmpty(prefs().getString(KEY_HASH, null));
}
public static boolean requiresUnlock() {
return isEnabled() && hasLockConfigured();
}
public static void setPin(@NonNull final String pin) {
if (pin.length() != 6 || !pin.matches("\\d{6}")) {
throw new IllegalArgumentException("PIN must be 6 digits");
}
store(Method.PIN, pin);
}
public static void setPattern(@NonNull final List<Integer> cells) {
if (cells.size() < 4) {
throw new IllegalArgumentException("Pattern too short");
}
store(Method.PATTERN, joinCells(cells));
}
public static void setPassword(@NonNull final String password) {
if (password.length() < 8) {
throw new IllegalArgumentException("Password too short");
}
store(Method.PASSWORD, password);
}
public static boolean verifyPin(@NonNull final String pin) {
return verify(Method.PIN, pin);
}
public static boolean verifyPattern(@NonNull final List<Integer> cells) {
return verify(Method.PATTERN, joinCells(cells));
}
public static boolean verifyPassword(@NonNull final String password) {
return verify(Method.PASSWORD, password);
}
public static boolean isUnlockedThisSession() {
return sUnlocked;
}
public static void markUnlocked() {
sUnlocked = true;
}
public static void clearUnlocked() {
sUnlocked = false;
}
static void markUnlockShowing(final boolean showing) {
sShowingUnlock = showing;
}
public static Intent unlockIntent(@NonNull final Activity activity) {
return createIntent(activity, MODE_UNLOCK);
}
public static Intent createIntent(@NonNull final Activity activity, final int mode) {
final Intent intent = new Intent(activity, HomeAppLockActivity.class);
intent.putExtra(EXTRA_MODE, mode);
return intent;
}
static List<Integer> appendPatternCells(@NonNull final List<Integer> current, final int next) {
if (current.isEmpty()) {
final List<Integer> start = new ArrayList<>();
start.add(next);
return start;
}
final int last = current.get(current.size() - 1);
if (next == last) {
return current;
}
final Integer bridge = bridgedCellIndex(last, next);
final List<Integer> out = new ArrayList<>(current);
if (bridge != null && !current.contains(bridge)) {
out.add(bridge);
}
out.add(next);
return out;
}
@Nullable
static Integer bridgedCellIndex(final int from, final int to) {
final int fromRow = from / 3;
final int fromCol = from % 3;
final int toRow = to / 3;
final int toCol = to % 3;
final int dRow = toRow - fromRow;
final int dCol = toCol - fromCol;
if (dRow == 0 && dCol == 0) {
return null;
}
if (dRow != 0 && dCol != 0 && Math.abs(dRow) != Math.abs(dCol)) {
return null;
}
if (dRow != 0 && dRow % 2 != 0 && Math.abs(dRow) > 1) {
return null;
}
if (dCol != 0 && dCol % 2 != 0 && Math.abs(dCol) > 1) {
return null;
}
final int midRow = fromRow + dRow / 2;
final int midCol = fromCol + dCol / 2;
if (midRow == fromRow && midCol == fromCol) {
return null;
}
if (midRow == toRow && midCol == toCol) {
return null;
}
return midRow * 3 + midCol;
}
private static void maybeLock(@NonNull final Activity activity) {
if (!requiresUnlock() || sUnlocked || sShowingUnlock) {
return;
}
if (activity instanceof HomeAppLockActivity) {
return;
}
activity.getWindow().getDecorView().post(() -> {
if (activity.isFinishing() || activity.isDestroyed()
|| !requiresUnlock() || sUnlocked || sShowingUnlock) {
return;
}
sShowingUnlock = true;
activity.startActivity(unlockIntent(activity));
activity.overridePendingTransition(0, 0);
});
}
private static void store(@NonNull final Method method, @NonNull final String secret) {
final BuglePrefs prefs = prefs();
prefs.putString(KEY_HASH, hash(secret));
prefs.putString(KEY_METHOD, method.name());
prefs.putBoolean(KEY_ENABLED, true);
markUnlocked();
}
private static boolean verify(@NonNull final Method method, @NonNull final String secret) {
if (getMethod() != method) {
return false;
}
final String stored = prefs().getString(KEY_HASH, null);
return !TextUtils.isEmpty(stored) && stored.equals(hash(secret));
}
private static String joinCells(@NonNull final List<Integer> cells) {
final StringBuilder builder = new StringBuilder();
for (int i = 0; i < cells.size(); i++) {
if (i > 0) {
builder.append(',');
}
builder.append(cells.get(i));
}
return builder.toString();
}
private static String hash(@NonNull final String value) {
try {
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
final byte[] bytes = digest.digest(
(HASH_SALT + "|" + value).getBytes(StandardCharsets.UTF_8));
final StringBuilder hex = new StringBuilder(bytes.length * 2);
for (final byte b : bytes) {
hex.append(String.format(Locale.US, "%02x", b));
}
return hex.toString();
} catch (final Exception e) {
throw new IllegalStateException("Unable to hash lock secret", e);
}
}
private static BuglePrefs prefs() {
return BuglePrefs.getApplicationPrefs();
}
}
@@ -0,0 +1,483 @@
package com.android.messaging.ui.home;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.method.PasswordTransformationMethod;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.activity.OnBackPressedCallback;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import com.android.messaging.R;
import com.android.messaging.ui.BugleActionBarActivity;
import java.util.ArrayList;
import java.util.List;
/** Unlock overlay and PIN / pattern / password setup screens. */
public class HomeAppLockActivity extends BugleActionBarActivity {
private int mMode;
private int mStep;
private String mFirstSecret = "";
private final List<Integer> mFirstPattern = new ArrayList<>();
private String mPin = "";
private TextView mSubtitle;
private TextView mError;
private ViewGroup mPinDots;
private HomePatternLockView mPatternView;
private View mPatternRecorded;
private EditText mPassword;
private ImageView mPasswordToggle;
private View mPasswordRules;
private TextView mPasswordLabel;
private boolean mPasswordVisible;
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
mMode = getIntent().getIntExtra(HomeAppLock.EXTRA_MODE, HomeAppLock.MODE_UNLOCK);
if (mMode == HomeAppLock.MODE_UNLOCK) {
HomeAppLock.markUnlockShowing(true);
if (!HomeAppLock.requiresUnlock()) {
HomeAppLock.markUnlocked();
finish();
return;
}
setContentView(R.layout.home_lock_unlock);
} else {
setContentView(R.layout.home_lock_setup);
HomeSubHeader.bind(this, headerTitle(), v -> finish());
}
mSubtitle = findViewById(R.id.home_lock_subtitle);
mError = findViewById(R.id.home_lock_error);
final FrameLayout body = findViewById(R.id.home_lock_body);
final LayoutInflater inflater = LayoutInflater.from(this);
if (isPinMode()) {
inflater.inflate(R.layout.home_lock_pin_body, body, true);
bindPinPad(body);
} else if (isPatternMode()) {
inflater.inflate(R.layout.home_lock_pattern_body, body, true);
bindPattern(body);
} else {
inflater.inflate(R.layout.home_lock_password_body, body, true);
bindPassword(body);
}
refreshCopy();
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
if (mMode == HomeAppLock.MODE_UNLOCK) {
moveTaskToBack(true);
} else {
finish();
}
}
});
}
@Override
protected void onDestroy() {
if (mMode == HomeAppLock.MODE_UNLOCK && isFinishing()) {
HomeAppLock.markUnlockShowing(false);
}
super.onDestroy();
}
@Override
public void finish() {
super.finish();
if (mMode != HomeAppLock.MODE_UNLOCK) {
overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
} else {
overridePendingTransition(0, 0);
}
}
private void bindPinPad(final View host) {
mPinDots = host.findViewById(R.id.home_lock_pin_dots);
final ViewGroup keypad = host.findViewById(R.id.home_lock_pin_keypad);
bindDigitKeys(keypad);
final View backspace = host.findViewById(R.id.home_lock_pin_backspace);
if (backspace != null) {
backspace.setOnClickListener(v -> {
if (!mPin.isEmpty()) {
mPin = mPin.substring(0, mPin.length() - 1);
updatePinDots();
hideError();
}
});
}
updatePinDots();
}
private void bindDigitKeys(final ViewGroup parent) {
for (int i = 0; i < parent.getChildCount(); i++) {
final View child = parent.getChildAt(i);
if (child instanceof ViewGroup) {
bindDigitKeys((ViewGroup) child);
} else if (child instanceof TextView) {
final String text = ((TextView) child).getText().toString();
if (text.length() == 1 && Character.isDigit(text.charAt(0))) {
child.setOnClickListener(v -> appendPin(text));
}
}
}
}
private void appendPin(final String digit) {
if (mPin.length() >= 6) {
return;
}
mPin += digit;
updatePinDots();
hideError();
if (mPin.length() < 6) {
return;
}
if (mMode == HomeAppLock.MODE_UNLOCK) {
if (HomeAppLock.verifyPin(mPin)) {
unlockAndFinish();
} else {
showError(R.string.home_lock_incorrect_pin);
mPin = "";
updatePinDots();
}
return;
}
if (mStep == 0) {
mFirstSecret = mPin;
mPin = "";
mStep = 1;
updatePinDots();
refreshCopy();
} else if (mFirstSecret.equals(mPin)) {
HomeAppLock.setPin(mPin);
finish();
} else {
showMismatch();
}
}
private void updatePinDots() {
if (mPinDots == null) {
return;
}
for (int i = 0; i < mPinDots.getChildCount(); i++) {
mPinDots.getChildAt(i).setSelected(i < mPin.length());
}
}
private void bindPattern(final View host) {
mPatternView = host.findViewById(R.id.home_lock_pattern);
mPatternRecorded = host.findViewById(R.id.home_lock_pattern_recorded);
host.findViewById(R.id.home_lock_pattern_clear).setOnClickListener(v -> {
if (mPatternView != null) {
mPatternView.clear();
}
if (mPatternRecorded != null) {
mPatternRecorded.setVisibility(View.GONE);
}
hideError();
});
mPatternView.setListener(new HomePatternLockView.Listener() {
@Override
public void onPatternChanged(final List<Integer> cells) {
hideError();
if (mPatternRecorded != null) {
mPatternRecorded.setVisibility(
cells.size() >= 4 ? View.VISIBLE : View.GONE);
}
}
@Override
public void onPatternComplete(final List<Integer> cells) {
if (mMode == HomeAppLock.MODE_UNLOCK) {
if (HomeAppLock.verifyPattern(cells)) {
unlockAndFinish();
} else {
showError(R.string.home_lock_incorrect_pattern);
mPatternView.clear();
}
return;
}
if (cells.size() < 4) {
mPatternView.clear();
return;
}
if (mStep == 0) {
mFirstPattern.clear();
mFirstPattern.addAll(cells);
mPatternView.clear();
mStep = 1;
if (mPatternRecorded != null) {
mPatternRecorded.setVisibility(View.GONE);
}
refreshCopy();
} else if (mFirstPattern.equals(cells)) {
HomeAppLock.setPattern(cells);
finish();
} else {
showMismatch();
}
}
});
}
private void bindPassword(final View host) {
mPassword = host.findViewById(R.id.home_lock_password);
mPasswordToggle = host.findViewById(R.id.home_lock_password_toggle);
mPasswordRules = host.findViewById(R.id.home_lock_password_rules);
mPasswordLabel = host.findViewById(R.id.home_lock_password_label);
if (mMode != HomeAppLock.MODE_UNLOCK) {
mPasswordRules.setVisibility(View.VISIBLE);
}
mPasswordToggle.setOnClickListener(v -> {
mPasswordVisible = !mPasswordVisible;
final int start = mPassword.getSelectionStart();
mPassword.setTransformationMethod(mPasswordVisible
? null : PasswordTransformationMethod.getInstance());
mPasswordToggle.setImageResource(mPasswordVisible
? R.drawable.ic_home_visibility_off : R.drawable.ic_home_visibility);
if (start >= 0) {
mPassword.setSelection(start);
}
});
mPassword.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE
|| (event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER)) {
attemptPassword();
return true;
}
return false;
});
if (mMode != HomeAppLock.MODE_UNLOCK) {
mPassword.requestFocus();
}
mPassword.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(final CharSequence s, final int start, final int count,
final int after) {
}
@Override
public void onTextChanged(final CharSequence s, final int start, final int before,
final int count) {
}
@Override
public void afterTextChanged(final Editable s) {
hideError();
final String value = s.toString();
if (mMode == HomeAppLock.MODE_UNLOCK) {
if (value.length() >= 8 && HomeAppLock.verifyPassword(value)) {
unlockAndFinish();
}
return;
}
if (mStep == 0) {
updatePasswordRules(value);
if (isPasswordValid(value)) {
mFirstSecret = value;
mStep = 1;
mPassword.setText("");
refreshCopy();
}
} else if (value.length() >= mFirstSecret.length()) {
if (mFirstSecret.equals(value)) {
HomeAppLock.setPassword(value);
finish();
} else {
showMismatch();
}
}
}
});
}
private void attemptPassword() {
final String value = mPassword.getText() == null ? "" : mPassword.getText().toString();
if (HomeAppLock.verifyPassword(value)) {
unlockAndFinish();
} else if (!TextUtils.isEmpty(value)) {
showError(R.string.home_lock_incorrect_password);
mPassword.setText("");
}
}
private void updatePasswordRules(final String password) {
setRule(R.id.home_lock_rule_length, password.length() >= 8);
setRule(R.id.home_lock_rule_number, containsDigit(password));
setRule(R.id.home_lock_rule_symbol, containsSymbol(password));
setRule(R.id.home_lock_rule_upper, containsUpper(password));
}
private void setRule(final int id, final boolean ok) {
final TextView view = findViewById(id);
if (view == null) {
return;
}
view.setTextColor(getColor(ok ? R.color.home_text_primary : R.color.home_text_secondary));
view.setCompoundDrawableTintList(ContextCompat.getColorStateList(this,
ok ? R.color.home_accent : R.color.home_text_tertiary));
}
private static boolean isPasswordValid(final String password) {
return password.length() >= 8 && containsDigit(password)
&& containsSymbol(password) && containsUpper(password);
}
private static boolean containsDigit(final String value) {
for (int i = 0; i < value.length(); i++) {
if (Character.isDigit(value.charAt(i))) {
return true;
}
}
return false;
}
private static boolean containsSymbol(final String value) {
for (int i = 0; i < value.length(); i++) {
if (!Character.isLetterOrDigit(value.charAt(i))) {
return true;
}
}
return false;
}
private static boolean containsUpper(final String value) {
for (int i = 0; i < value.length(); i++) {
if (Character.isUpperCase(value.charAt(i))) {
return true;
}
}
return false;
}
private void refreshCopy() {
if (mSubtitle != null) {
mSubtitle.setText(subtitle());
}
if (mPasswordLabel != null) {
mPasswordLabel.setText(mStep == 0
? R.string.home_lock_password_hint
: R.string.home_lock_password_confirm_hint);
}
if (mPasswordRules != null) {
mPasswordRules.setVisibility(mMode != HomeAppLock.MODE_UNLOCK && mStep == 0
? View.VISIBLE : View.GONE);
}
}
private int headerTitle() {
switch (mMode) {
case HomeAppLock.MODE_SET_PIN:
return R.string.home_lock_set_pin;
case HomeAppLock.MODE_SET_PATTERN:
return R.string.home_lock_set_pattern;
case HomeAppLock.MODE_SET_PASSWORD:
return R.string.home_lock_set_password;
default:
return R.string.home_title;
}
}
private int subtitle() {
if (mMode == HomeAppLock.MODE_UNLOCK) {
switch (HomeAppLock.getMethod()) {
case PIN:
return R.string.home_lock_enter_pin;
case PATTERN:
return R.string.home_lock_enter_pattern;
case PASSWORD:
return R.string.home_lock_enter_password;
default:
return R.string.home_lock_enter_title;
}
}
if (mMode == HomeAppLock.MODE_SET_PIN) {
return mStep == 0 ? R.string.home_lock_create_pin_subtitle
: R.string.home_lock_confirm_pin;
}
if (mMode == HomeAppLock.MODE_SET_PATTERN) {
return mStep == 0 ? R.string.home_lock_create_pattern_subtitle
: R.string.home_lock_confirm_pattern;
}
return mStep == 0 ? R.string.home_lock_create_password_subtitle
: R.string.home_lock_confirm_password;
}
private boolean isPinMode() {
return mMode == HomeAppLock.MODE_SET_PIN
|| (mMode == HomeAppLock.MODE_UNLOCK
&& HomeAppLock.getMethod() == HomeAppLock.Method.PIN);
}
private boolean isPatternMode() {
return mMode == HomeAppLock.MODE_SET_PATTERN
|| (mMode == HomeAppLock.MODE_UNLOCK
&& HomeAppLock.getMethod() == HomeAppLock.Method.PATTERN);
}
private void showError(final int messageRes) {
if (mError == null) {
return;
}
mError.setText(messageRes);
mError.setVisibility(View.VISIBLE);
}
private void hideError() {
if (mError != null) {
mError.setVisibility(View.GONE);
}
}
private void showMismatch() {
new AlertDialog.Builder(this, R.style.Home_AlertDialog)
.setTitle(R.string.home_lock_mismatch_title)
.setMessage(R.string.home_lock_mismatch_message)
.setPositiveButton(android.R.string.ok, (dialog, which) -> resetSetup())
.setOnDismissListener(dialog -> resetSetup())
.show();
}
private void resetSetup() {
mStep = 0;
mPin = "";
mFirstSecret = "";
mFirstPattern.clear();
updatePinDots();
if (mPatternView != null) {
mPatternView.clear();
}
if (mPatternRecorded != null) {
mPatternRecorded.setVisibility(View.GONE);
}
if (mPassword != null) {
mPassword.setText("");
}
refreshCopy();
hideError();
}
private void unlockAndFinish() {
HomeAppLock.markUnlocked();
HomeAppLock.markUnlockShowing(false);
finish();
}
}
@@ -18,6 +18,8 @@ package com.android.messaging.ui.home;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
@@ -36,10 +38,14 @@ import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.ContactIconView;
import com.android.messaging.util.AvatarUriUtil;
import com.android.messaging.util.ContactRecipientEntryUtils;
import com.android.messaging.util.ContactUtil;
import com.android.messaging.util.ImeUtil;
import com.google.android.material.bottomsheet.BottomSheetDialog;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** Home-style "new chat" screen: recipient field, group entry point and contact list. */
public class HomeComposeActivity extends BugleActionBarActivity
@@ -57,7 +63,11 @@ public class HomeComposeActivity extends BugleActionBarActivity
private LinearLayout mChipContainer;
private TextView mChipHint;
private TextView mNextButton;
private TextView mAccountLabel;
private boolean mGroupMode;
private ContactUtil.AccountFilter mAccountFilter;
private final ExecutorService mAccountExecutor = Executors.newSingleThreadExecutor();
private final Handler mMainHandler = new Handler(Looper.getMainLooper());
@Override
protected void onCreate(final Bundle savedInstanceState) {
@@ -125,6 +135,10 @@ public class HomeComposeActivity extends BugleActionBarActivity
mCreateGroupButton = findViewById(R.id.home_compose_create_group);
mCreateGroupButton.setOnClickListener(v -> enterGroupMode());
mAccountLabel = findViewById(R.id.home_compose_account_label);
final View accountChip = findViewById(R.id.home_compose_account_chip);
accountChip.setOnClickListener(v -> showAccountSheet());
}
private HomeContactsFragment getContactsFragment() {
@@ -132,6 +146,93 @@ public class HomeComposeActivity extends BugleActionBarActivity
.findFragmentById(R.id.home_compose_fragment_host);
}
private void showAccountSheet() {
mAccountExecutor.execute(() -> {
final List<ContactUtil.ContactAccount> accounts =
ContactUtil.getContactAccounts(this);
mMainHandler.post(() -> presentAccountSheet(accounts));
});
}
private void presentAccountSheet(final List<ContactUtil.ContactAccount> accounts) {
if (isFinishing() || isDestroyed()) {
return;
}
final LayoutInflater inflater = LayoutInflater.from(this);
final LinearLayout list = (LinearLayout) inflater.inflate(
R.layout.home_compose_account_sheet, null, false);
final BottomSheetDialog[] dialog = new BottomSheetDialog[1];
int allCount = 0;
for (final ContactUtil.ContactAccount account : accounts) {
allCount += account.contactCount;
}
list.addView(createAccountRow(inflater, list,
getString(R.string.home_compose_all_contacts), null, allCount,
mAccountFilter == null, () -> {
applyAccountFilter(null, getString(R.string.home_compose_all_contacts));
if (dialog[0] != null) {
dialog[0].dismiss();
}
}));
for (final ContactUtil.ContactAccount account : accounts) {
final String title = account.deviceOnly
? getString(R.string.home_compose_device_contacts)
: (!TextUtils.isEmpty(account.title) ? account.title
: getString(R.string.home_compose_device_contacts));
final String subtitle = account.deviceOnly ? null : account.subtitle;
final boolean selected = mAccountFilter != null
&& account.toFilter().matches(mAccountFilter);
list.addView(createAccountRow(inflater, list, title, subtitle, account.contactCount,
selected, () -> {
applyAccountFilter(account.toFilter(), title);
if (dialog[0] != null) {
dialog[0].dismiss();
}
}));
}
dialog[0] = HomeBottomSheet.builder(this)
.title(R.string.home_compose_contacts_source_title)
.custom(list)
.show();
}
private View createAccountRow(final LayoutInflater inflater, final LinearLayout parent,
final String title, final String subtitle, final int count, final boolean selected,
final Runnable onClick) {
final View row = inflater.inflate(R.layout.home_compose_account_row, parent, false);
((TextView) row.findViewById(R.id.home_compose_account_title)).setText(title);
final TextView subtitleView = row.findViewById(R.id.home_compose_account_subtitle);
if (TextUtils.isEmpty(subtitle)) {
subtitleView.setVisibility(View.GONE);
} else {
subtitleView.setVisibility(View.VISIBLE);
subtitleView.setText(subtitle);
}
((TextView) row.findViewById(R.id.home_compose_account_count))
.setText(String.valueOf(count));
row.findViewById(R.id.home_compose_account_check)
.setVisibility(selected ? View.VISIBLE : View.GONE);
row.setOnClickListener(v -> onClick.run());
final LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
params.topMargin = parent.getChildCount() == 0
? 0 : getResources().getDimensionPixelSize(R.dimen.looply_space_sm);
row.setLayoutParams(params);
return row;
}
private void applyAccountFilter(final ContactUtil.AccountFilter filter, final String label) {
mAccountFilter = filter;
mAccountLabel.setText(label);
final HomeContactsFragment fragment = getContactsFragment();
if (fragment != null) {
fragment.setAccountFilter(filter);
}
}
private void enterGroupMode() {
mGroupMode = true;
ImeUtil.get().hideImeKeyboard(this, mRecipientField);
@@ -226,6 +327,12 @@ public class HomeComposeActivity extends BugleActionBarActivity
super.onBackPressed();
}
@Override
protected void onDestroy() {
mAccountExecutor.shutdownNow();
super.onDestroy();
}
@Override
public void finish() {
if (mRecipientField != null && !TextUtils.isEmpty(mRecipientField.getText())) {
@@ -20,6 +20,7 @@ import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.net.Uri;
import android.os.Handler;
@@ -44,6 +45,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsAnimationCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@@ -77,6 +79,8 @@ public final class HomeComposeAttachPanel {
void onTakePhoto();
void onRecordVideo();
void onOpenContact();
void onShareLocation();
void onScheduleMessage();
void onRequestMediaPermission();
void onComingSoon();
void onDismiss();
@@ -247,6 +251,7 @@ public final class HomeComposeAttachPanel {
float peekY;
int statusBarTop;
int navigationBarBottom;
int imeBottom;
final int dockBaseBottomPadding;
final int originalStatusBarColor;
boolean expanded;
@@ -328,6 +333,12 @@ public final class HomeComposeAttachPanel {
if (send != null) {
send.setOnClickListener(v -> sendSelection());
}
overlay.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
if (closing) {
return;
}
applyImeLift(inferredImeHeight());
});
if (caption != null) {
caption.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEND) {
@@ -367,12 +378,21 @@ public final class HomeComposeAttachPanel {
overlay.setLayoutParams(new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
ViewCompat.setOnApplyWindowInsetsListener(overlay, (v, insets) -> {
statusBarTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top;
navigationBarBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom;
applyDockInsets();
applyExpandHeaderInsets(expanded);
applyWindowInsets(insets);
return insets;
});
ViewCompat.setWindowInsetsAnimationCallback(overlay,
new WindowInsetsAnimationCompat.Callback(
WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
@NonNull
@Override
public WindowInsetsCompat onProgress(
@NonNull final WindowInsetsCompat insets,
@NonNull final java.util.List<WindowInsetsAnimationCompat> running) {
applyWindowInsets(insets);
return insets;
}
});
ViewCompat.requestApplyInsets(overlay);
bindHandleDrag(handleArea, this);
bindHandleDrag(expandHeader, this);
@@ -568,9 +588,41 @@ public final class HomeComposeAttachPanel {
}
}
void applyWindowInsets(final WindowInsetsCompat insets) {
statusBarTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top;
navigationBarBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom;
applyImeLift(Math.max(
insets.getInsets(WindowInsetsCompat.Type.ime()).bottom,
inferredImeHeight()));
}
int inferredImeHeight() {
final View decor = activity.getWindow() == null
? null : activity.getWindow().getDecorView();
if (decor == null) {
return 0;
}
final Rect visible = new Rect();
decor.getWindowVisibleDisplayFrame(visible);
final int covered = decor.getHeight() - visible.bottom;
return covered > 80 ? covered : 0;
}
void applyImeLift(final int ime) {
if (imeBottom == ime) {
return;
}
imeBottom = ime;
applyBottomMargin(sheet, imeBottom);
applyBottomMargin(dock, imeBottom);
applyDockInsets();
applyExpandHeaderInsets(expanded);
}
void applyDockInsets() {
if (dock != null) {
final int bottom = dockBaseBottomPadding + navigationBarBottom;
final int bottom = dockBaseBottomPadding
+ (imeBottom > 0 ? 0 : navigationBarBottom);
if (dock.getPaddingBottom() != bottom) {
dock.setPaddingRelative(
dock.getPaddingStart(),
@@ -590,6 +642,18 @@ public final class HomeComposeAttachPanel {
}
}
void applyBottomMargin(final View view, final int bottom) {
if (view == null || !(view.getLayoutParams() instanceof ViewGroup.MarginLayoutParams)) {
return;
}
final ViewGroup.MarginLayoutParams lp =
(ViewGroup.MarginLayoutParams) view.getLayoutParams();
if (lp.bottomMargin != bottom) {
lp.bottomMargin = bottom;
view.setLayoutParams(lp);
}
}
void applyGridDockPadding() {
if (grid == null) {
return;
@@ -982,6 +1046,12 @@ public final class HomeComposeAttachPanel {
} else if (index == TAB_CONTACT) {
host.onOpenContact();
dismissAnimated.run();
} else if (index == TAB_LOCATION) {
host.onShareLocation();
dismissAnimated.run();
} else if (index == TAB_SCHEDULE) {
host.onScheduleMessage();
dismissAnimated.run();
} else if (index != TAB_GALLERY) {
host.onComingSoon();
}
@@ -6,24 +6,74 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CursorAdapter;
import android.widget.SectionIndexer;
import com.android.messaging.R;
import com.android.messaging.ui.contact.ContactListItemView;
import com.android.messaging.ui.contact.ContactSectionIndexer;
import com.android.messaging.util.Assert;
public class HomeContactListAdapter extends CursorAdapter {
public class HomeContactListAdapter extends CursorAdapter implements SectionIndexer {
private final ContactListItemView.HostInterface mHost;
private ContactSectionIndexer mSectionIndexer;
private boolean mNeedAlphabetHeader = true;
public HomeContactListAdapter(final Context context, final Cursor cursor,
final ContactListItemView.HostInterface host) {
super(context, cursor, 0);
mHost = host;
mSectionIndexer = new ContactSectionIndexer(cursor);
}
public void setNeedAlphabetHeader(final boolean needAlphabetHeader) {
if (mNeedAlphabetHeader == needAlphabetHeader) {
return;
}
mNeedAlphabetHeader = needAlphabetHeader;
notifyDataSetChanged();
}
@Override
public void bindView(final View view, final Context context, final Cursor cursor) {
Assert.isTrue(view instanceof ContactListItemView);
((ContactListItemView) view).bind(cursor, mHost, false, null);
String alphabetHeader = null;
if (mNeedAlphabetHeader && mSectionIndexer.getSections().length > 0) {
final int position = cursor.getPosition();
final int section = mSectionIndexer.getSectionForPosition(position);
if (section >= 0 && mSectionIndexer.getPositionForSection(section) == position) {
alphabetHeader = (String) mSectionIndexer.getSections()[section];
}
}
final ContactListItemView itemView = (ContactListItemView) view;
itemView.bind(cursor, mHost, mNeedAlphabetHeader, alphabetHeader);
itemView.setSectionBox(sectionRole(cursor.getPosition()));
}
private int sectionRole(final int position) {
if (!mNeedAlphabetHeader || mSectionIndexer.getSections().length == 0) {
return ContactListItemView.SECTION_SINGLE;
}
final int section = mSectionIndexer.getSectionForPosition(position);
if (section < 0) {
return ContactListItemView.SECTION_SINGLE;
}
final int first = mSectionIndexer.getPositionForSection(section);
final int last;
if (section + 1 < mSectionIndexer.getSections().length) {
last = mSectionIndexer.getPositionForSection(section + 1) - 1;
} else {
last = getCount() - 1;
}
if (position == first && position == last) {
return ContactListItemView.SECTION_SINGLE;
}
if (position == first) {
return ContactListItemView.SECTION_FIRST;
}
if (position == last) {
return ContactListItemView.SECTION_LAST;
}
return ContactListItemView.SECTION_MIDDLE;
}
@Override
@@ -31,4 +81,25 @@ public class HomeContactListAdapter extends CursorAdapter {
return LayoutInflater.from(context).inflate(R.layout.home_contacts_list_item, parent,
false);
}
@Override
public Cursor swapCursor(final Cursor newCursor) {
mSectionIndexer = new ContactSectionIndexer(newCursor);
return super.swapCursor(newCursor);
}
@Override
public Object[] getSections() {
return mSectionIndexer.getSections();
}
@Override
public int getPositionForSection(final int sectionIndex) {
return mSectionIndexer.getPositionForSection(sectionIndex);
}
@Override
public int getSectionForPosition(final int position) {
return mSectionIndexer.getSectionForPosition(position);
}
}
@@ -114,12 +114,22 @@ public class HomeContactsFragment extends Fragment implements ContactPickerDataL
/** Narrows the list as the host screen's recipient field is typed into. */
public void setFilterText(final String filterText) {
mFilterText = filterText;
if (mAdapter != null) {
mAdapter.setNeedAlphabetHeader(TextUtils.isEmpty(filterText));
}
if (mBinding.isBound()) {
mBinding.getData().setSearchText(mBinding, filterText);
}
updateResultViews();
}
/** Limits the list to one contacts account, or every account when {@code filter} is null. */
public void setAccountFilter(final ContactUtil.AccountFilter filter) {
if (mBinding.isBound()) {
mBinding.getData().setAccountFilter(mBinding, filter);
}
}
/**
* Starts a conversation with the raw text currently typed, if it can be messaged.
*
@@ -1,6 +1,5 @@
package com.android.messaging.ui.home;
import android.graphics.Rect;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
@@ -43,14 +42,20 @@ public final class HomeInputFocus {
if (isInside(box, ev)) {
return false;
}
root.setFocusable(true);
root.setFocusableInTouchMode(true);
final View sink = focusSink(root);
sink.setFocusable(true);
sink.setFocusableInTouchMode(true);
sink.requestFocus();
focused.clearFocus();
root.requestFocus();
ImeUtil.get().hideImeKeyboard(focused.getContext(), focused);
return true;
}
private static View focusSink(@NonNull final View root) {
final View content = root.findViewById(android.R.id.content);
return content != null ? content : root;
}
private static View findBox(final View focused) {
ViewParent parent = focused.getParent();
while (parent instanceof ViewGroup) {
@@ -64,11 +69,13 @@ public final class HomeInputFocus {
}
private static boolean isInside(final View view, final MotionEvent ev) {
final Rect rect = new Rect();
if (!view.getGlobalVisibleRect(rect)) {
return false;
}
return rect.contains(Math.round(ev.getRawX()), Math.round(ev.getRawY()));
final int[] loc = new int[2];
view.getLocationOnScreen(loc);
final int x = Math.round(ev.getRawX());
final int y = Math.round(ev.getRawY());
return x >= loc[0] && y >= loc[1]
&& x < loc[0] + view.getWidth()
&& y < loc[1] + view.getHeight();
}
private static final class Installed extends WindowCallbackWrapper {
@@ -0,0 +1,278 @@
package com.android.messaging.ui.home;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.location.Address;
import android.location.Geocoder;
import android.location.Location;
import android.location.LocationManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.webkit.JavascriptInterface;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.ImageButton;
import android.widget.TextView;
import androidx.annotation.Nullable;
import com.android.messaging.R;
import com.android.messaging.datamodel.MediaScratchFileProvider;
import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.OsUtil;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** Full-screen map picker that returns a maps URL and optional preview image. */
public class HomeLocationActivity extends BugleActionBarActivity {
private static final double DEFAULT_LAT = 37.5665;
private static final double DEFAULT_LNG = 126.9780;
private static final int STATIC_MAP_TIMEOUT_MS = 8000;
private WebView mMap;
private TextView mAddressView;
private ImageButton mSendButton;
private final Handler mMain = new Handler(Looper.getMainLooper());
private final ExecutorService mIo = Executors.newSingleThreadExecutor();
private double mLat = DEFAULT_LAT;
private double mLng = DEFAULT_LNG;
private String mAddress = "";
private boolean mMapReady;
private boolean mSending;
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.home_location_activity);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
HomeSubHeader.bind(this, R.string.home_location_title, v -> finish());
mAddressView = findViewById(R.id.home_location_address);
mSendButton = findViewById(R.id.home_location_send);
mMap = findViewById(R.id.home_location_map);
mSendButton.setOnClickListener(v -> sendCurrentLocation());
findViewById(R.id.home_location_my_location).setOnClickListener(
v -> moveToMyLocation(true));
setupMap();
moveToMyLocation(false);
}
@SuppressLint({"SetJavaScriptEnabled", "AddJavascriptInterface"})
private void setupMap() {
final WebSettings settings = mMap.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(true);
mMap.addJavascriptInterface(new MapBridge(), "LocationBridge");
mMap.setWebViewClient(new WebViewClient() {
@Override
public void onPageFinished(final WebView view, final String url) {
mMapReady = true;
applyMapCenter(mLat, mLng);
}
});
mMap.loadUrl("file:///android_asset/home_location_map.html");
}
private void moveToMyLocation(final boolean userRequested) {
if (!OsUtil.hasLocationPermission()) {
if (userRequested) {
HomeToast.show(this, R.string.home_location_permission_denied);
}
return;
}
final LocationManager manager = getSystemService(LocationManager.class);
if (manager == null) {
return;
}
Location best = null;
try {
best = manager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
if (best == null) {
best = manager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER);
}
if (best == null) {
best = manager.getLastKnownLocation(LocationManager.PASSIVE_PROVIDER);
}
} catch (final SecurityException exception) {
LogUtil.w(LogUtil.BUGLE_TAG, "Location lookup failed", exception);
}
if (best != null) {
mLat = best.getLatitude();
mLng = best.getLongitude();
applyMapCenter(mLat, mLng);
reverseGeocode(mLat, mLng);
return;
}
if (userRequested) {
HomeToast.show(this, R.string.home_location_unavailable);
}
}
private void applyMapCenter(final double lat, final double lng) {
if (!mMapReady) {
return;
}
mMap.evaluateJavascript("setCenter(" + lat + "," + lng + ")", null);
}
private Runnable mPendingGeocode;
private void reverseGeocode(final double lat, final double lng) {
if (mPendingGeocode != null) {
mMain.removeCallbacks(mPendingGeocode);
}
mPendingGeocode = () -> reverseGeocodeNow(lat, lng);
mMain.postDelayed(mPendingGeocode, 350);
}
private void reverseGeocodeNow(final double lat, final double lng) {
mAddressView.setText(R.string.home_location_finding);
mIo.execute(() -> {
String address = formatLatLng(lat, lng);
try {
if (Geocoder.isPresent()) {
final List<Address> results = new Geocoder(this, Locale.getDefault())
.getFromLocation(lat, lng, 1);
if (results != null && !results.isEmpty()) {
final String line = results.get(0).getAddressLine(0);
if (!TextUtils.isEmpty(line)) {
address = line;
}
}
}
} catch (final Exception exception) {
LogUtil.w(LogUtil.BUGLE_TAG, "Geocoder failed", exception);
}
final String resolved = address;
mMain.post(() -> {
mAddress = resolved;
mAddressView.setText(resolved);
});
});
}
private void sendCurrentLocation() {
if (mSending) {
return;
}
mSending = true;
mSendButton.setEnabled(false);
final double lat = mLat;
final double lng = mLng;
final String address = TextUtils.isEmpty(mAddress) ? formatLatLng(lat, lng) : mAddress;
final String mapsUrl = "https://maps.google.com/maps?q=" + lat + "," + lng;
final String text = address + "\n" + mapsUrl;
mIo.execute(() -> {
final Uri image = downloadStaticMap(lat, lng);
mMain.post(() -> {
final Intent data = new Intent();
data.putExtra(UIIntents.UI_INTENT_EXTRA_LOCATION_TEXT, text);
if (image != null) {
data.putExtra(UIIntents.UI_INTENT_EXTRA_LOCATION_IMAGE_URI, image);
}
setResult(RESULT_OK, data);
finish();
overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
});
});
}
@Nullable
private Uri downloadStaticMap(final double lat, final double lng) {
HttpURLConnection connection = null;
try {
final String url = "https://staticmap.openstreetmap.de/staticmap.php?center="
+ lat + "," + lng + "&zoom=16&size=640x360&markers=" + lat + "," + lng
+ ",red-pushpin";
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(STATIC_MAP_TIMEOUT_MS);
connection.setReadTimeout(STATIC_MAP_TIMEOUT_MS);
connection.connect();
if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
return null;
}
final String contentType = connection.getContentType();
if (contentType == null || !contentType.contains("image")) {
return null;
}
final Uri uri = MediaScratchFileProvider.buildMediaScratchSpaceUri("jpg");
final File file = MediaScratchFileProvider.getFileFromUri(uri);
if (file == null) {
return null;
}
try (InputStream in = connection.getInputStream();
FileOutputStream out = new FileOutputStream(file)) {
final byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
}
}
return uri;
} catch (final Exception exception) {
LogUtil.w(LogUtil.BUGLE_TAG, "Static map download failed", exception);
return null;
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private static String formatLatLng(final double lat, final double lng) {
return String.format(Locale.US, "%.5f, %.5f", lat, lng);
}
@Override
protected void onResume() {
super.onResume();
if (mMap != null) {
mMap.onResume();
}
}
@Override
protected void onPause() {
if (mMap != null) {
mMap.onPause();
}
super.onPause();
}
@Override
protected void onDestroy() {
if (mMap != null) {
mMap.destroy();
}
mIo.shutdownNow();
super.onDestroy();
}
public final class MapBridge {
@JavascriptInterface
public void onMapMoved(final double lat, final double lng) {
mMain.post(() -> {
mLat = lat;
mLng = lng;
reverseGeocode(lat, lng);
});
}
}
}
@@ -0,0 +1,27 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
/** Overlay that draws but never takes touches. */
public class HomePassThroughView extends View {
public HomePassThroughView(final Context context) {
super(context);
}
public HomePassThroughView(final Context context, final AttributeSet attrs) {
super(context, attrs);
}
public HomePassThroughView(final Context context, final AttributeSet attrs,
final int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean dispatchTouchEvent(final MotionEvent event) {
return false;
}
}
@@ -0,0 +1,226 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.messaging.R;
import java.util.ArrayList;
import java.util.List;
/** 3x3 pattern lock grid with mid-cell bridging (File Plus behavior). */
public class HomePatternLockView extends View {
public interface Listener {
void onPatternChanged(@NonNull List<Integer> cells);
void onPatternComplete(@NonNull List<Integer> cells);
}
private final Paint mLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint mDragPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint mHaloPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint mDotPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint mInnerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path mPath = new Path();
private final float[] mCentersX = new float[9];
private final float[] mCentersY = new float[9];
private final List<Integer> mCells = new ArrayList<>();
private Listener mListener;
private boolean mDragging;
private float mDragX;
private float mDragY;
private float mHitRadius;
private float mCoreRadius;
private float mSelectedRadius;
private float mInnerRadius;
private float mHaloGap;
private int mAccent;
private int mIdle;
public HomePatternLockView(final Context context) {
super(context);
init();
}
public HomePatternLockView(final Context context, @Nullable final AttributeSet attrs) {
super(context, attrs);
init();
}
public HomePatternLockView(final Context context, @Nullable final AttributeSet attrs,
final int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
mAccent = getContext().getColor(R.color.home_accent);
mIdle = getContext().getColor(R.color.home_divider);
mLinePaint.setStyle(Paint.Style.STROKE);
mLinePaint.setStrokeCap(Paint.Cap.ROUND);
mLinePaint.setStrokeJoin(Paint.Join.ROUND);
mLinePaint.setColor(mAccent);
mLinePaint.setStrokeWidth(dp(4));
mDragPaint.set(mLinePaint);
mDragPaint.setAlpha(217);
mHaloPaint.setStyle(Paint.Style.STROKE);
mHaloPaint.setStrokeWidth(dp(3));
mHaloPaint.setColor(mAccent);
mDotPaint.setStyle(Paint.Style.FILL);
mInnerPaint.setStyle(Paint.Style.FILL);
mInnerPaint.setColor(0xFFFFFFFF);
mHitRadius = dp(32);
mCoreRadius = dp(6);
mSelectedRadius = dp(10);
mInnerRadius = dp(3.5f);
mHaloGap = dp(5);
setClickable(true);
}
public void setListener(@Nullable final Listener listener) {
mListener = listener;
}
public void clear() {
mCells.clear();
mDragging = false;
invalidate();
}
@NonNull
public List<Integer> getCells() {
return new ArrayList<>(mCells);
}
@Override
protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
final int width = MeasureSpec.getSize(widthMeasureSpec);
final int height = MeasureSpec.getSize(heightMeasureSpec);
final int size;
if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.UNSPECIFIED) {
size = width;
} else {
size = Math.min(width, height);
}
setMeasuredDimension(size, size);
}
@Override
protected void onSizeChanged(final int w, final int h, final int oldw, final int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
final float spacingX = w / 4f;
final float spacingY = h / 4f;
final float cell = Math.min(spacingX, spacingY);
mHitRadius = cell * 0.42f;
mCoreRadius = cell * 0.12f;
mSelectedRadius = cell * 0.18f;
mInnerRadius = cell * 0.07f;
mHaloGap = cell * 0.08f;
mLinePaint.setStrokeWidth(Math.max(dp(4), cell * 0.08f));
mDragPaint.setStrokeWidth(mLinePaint.getStrokeWidth());
for (int i = 0; i < 9; i++) {
mCentersX[i] = spacingX * ((i % 3) + 1);
mCentersY[i] = spacingY * ((i / 3) + 1);
}
}
@Override
protected void onDraw(@NonNull final Canvas canvas) {
super.onDraw(canvas);
if (mCells.size() >= 2) {
mPath.reset();
mPath.moveTo(mCentersX[mCells.get(0)], mCentersY[mCells.get(0)]);
for (int i = 1; i < mCells.size(); i++) {
final int cell = mCells.get(i);
mPath.lineTo(mCentersX[cell], mCentersY[cell]);
}
canvas.drawPath(mPath, mLinePaint);
}
if (mDragging && !mCells.isEmpty()) {
final int last = mCells.get(mCells.size() - 1);
canvas.drawLine(mCentersX[last], mCentersY[last], mDragX, mDragY, mDragPaint);
}
for (int i = 0; i < 9; i++) {
final boolean selected = mCells.contains(i);
final float core = selected ? mSelectedRadius : mCoreRadius;
mHaloPaint.setAlpha(selected ? 97 : 51);
canvas.drawCircle(mCentersX[i], mCentersY[i], core + mHaloGap, mHaloPaint);
mDotPaint.setColor(selected ? mAccent : mIdle);
canvas.drawCircle(mCentersX[i], mCentersY[i], core, mDotPaint);
if (selected) {
canvas.drawCircle(mCentersX[i], mCentersY[i], mInnerRadius, mInnerPaint);
}
}
}
@Override
public boolean onTouchEvent(final MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mDragging = true;
mDragX = event.getX();
mDragY = event.getY();
mCells.clear();
addCellAt(mDragX, mDragY);
invalidate();
return true;
case MotionEvent.ACTION_MOVE:
mDragX = event.getX();
mDragY = event.getY();
addCellAt(mDragX, mDragY);
invalidate();
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mDragging = false;
invalidate();
if (mListener != null && !mCells.isEmpty()) {
mListener.onPatternComplete(getCells());
}
return true;
default:
return super.onTouchEvent(event);
}
}
private void addCellAt(final float x, final float y) {
final Integer hit = cellAt(x, y);
if (hit == null || mCells.contains(hit)) {
return;
}
final List<Integer> next = HomeAppLock.appendPatternCells(mCells, hit);
mCells.clear();
mCells.addAll(next);
if (mListener != null) {
mListener.onPatternChanged(getCells());
}
}
@Nullable
private Integer cellAt(final float x, final float y) {
for (int i = 0; i < 9; i++) {
final float dx = x - mCentersX[i];
final float dy = y - mCentersY[i];
if (dx * dx + dy * dy <= mHitRadius * mHitRadius) {
return i;
}
}
return null;
}
private float dp(final float value) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value,
getResources().getDisplayMetrics());
}
}
@@ -0,0 +1,137 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.text.format.DateFormat;
import android.text.format.DateUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.DatePicker;
import android.widget.TextView;
import android.widget.TimePicker;
import androidx.annotation.NonNull;
import com.android.messaging.R;
import com.google.android.material.bottomsheet.BottomSheetDialog;
import java.util.Calendar;
/** Home date then time sheets used by Schedule in the compose attach panel. */
public final class HomeSchedulePicker {
public interface Callback {
void onScheduled(long sendAtMillis);
}
private HomeSchedulePicker() {
}
public static void show(@NonNull final Context context, final long existingSendAt,
@NonNull final Callback callback) {
final Calendar calendar = Calendar.getInstance();
if (existingSendAt > System.currentTimeMillis()) {
calendar.setTimeInMillis(existingSendAt);
} else {
calendar.add(Calendar.HOUR_OF_DAY, 1);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
}
showDate(context, calendar, callback);
}
public static String formatChip(@NonNull final Context context, final long sendAtMillis) {
final String time = DateFormat.getTimeFormat(context).format(sendAtMillis);
if (DateUtils.isToday(sendAtMillis)) {
return context.getString(R.string.home_schedule_today, time);
}
final Calendar tomorrow = Calendar.getInstance();
tomorrow.add(Calendar.DAY_OF_YEAR, 1);
final Calendar target = Calendar.getInstance();
target.setTimeInMillis(sendAtMillis);
if (tomorrow.get(Calendar.YEAR) == target.get(Calendar.YEAR)
&& tomorrow.get(Calendar.DAY_OF_YEAR) == target.get(Calendar.DAY_OF_YEAR)) {
return context.getString(R.string.home_schedule_tomorrow, time);
}
final String date = DateUtils.formatDateTime(context, sendAtMillis,
DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_ABBREV_MONTH
| DateUtils.FORMAT_NO_YEAR);
return date + " " + time;
}
private static void showDate(@NonNull final Context context, @NonNull final Calendar calendar,
@NonNull final Callback callback) {
final View body = LayoutInflater.from(context).inflate(
R.layout.home_schedule_date_sheet, null, false);
final DatePicker picker = body.findViewById(R.id.home_schedule_date);
picker.setMinDate(System.currentTimeMillis() - 1000L);
picker.updateDate(
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH),
calendar.get(Calendar.DAY_OF_MONTH));
final BottomSheetDialog[] dialogHolder = new BottomSheetDialog[1];
dialogHolder[0] = HomeBottomSheet.builder(context)
.title(R.string.home_schedule_select_date)
.custom(body)
.show();
bindActions(body,
v -> dialogHolder[0].dismiss(),
v -> {
calendar.set(Calendar.YEAR, picker.getYear());
calendar.set(Calendar.MONTH, picker.getMonth());
calendar.set(Calendar.DAY_OF_MONTH, picker.getDayOfMonth());
dialogHolder[0].dismiss();
showTime(context, calendar, callback);
},
R.string.home_schedule_next);
}
private static void showTime(@NonNull final Context context, @NonNull final Calendar calendar,
@NonNull final Callback callback) {
final View body = LayoutInflater.from(context).inflate(
R.layout.home_schedule_time_sheet, null, false);
final TimePicker picker = body.findViewById(R.id.home_schedule_time);
picker.setIs24HourView(DateFormat.is24HourFormat(context));
picker.setHour(calendar.get(Calendar.HOUR_OF_DAY));
picker.setMinute(calendar.get(Calendar.MINUTE));
final BottomSheetDialog[] dialogHolder = new BottomSheetDialog[1];
dialogHolder[0] = HomeBottomSheet.builder(context)
.title(R.string.home_schedule_select_time)
.custom(body)
.show();
bindActions(body,
v -> {
dialogHolder[0].dismiss();
showDate(context, calendar, callback);
},
v -> {
calendar.set(Calendar.HOUR_OF_DAY, picker.getHour());
calendar.set(Calendar.MINUTE, picker.getMinute());
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
final long sendAt = calendar.getTimeInMillis();
if (sendAt <= System.currentTimeMillis()) {
HomeToast.show(context, R.string.home_schedule_past);
return;
}
dialogHolder[0].dismiss();
callback.onScheduled(sendAt);
},
R.string.home_schedule_done);
}
private static void bindActions(@NonNull final View body,
@NonNull final View.OnClickListener onCancel,
@NonNull final View.OnClickListener onPositive,
final int positiveLabel) {
final TextView cancel = body.findViewById(R.id.home_schedule_cancel);
final TextView next = body.findViewById(R.id.home_schedule_next);
if (cancel != null) {
cancel.setOnClickListener(onCancel);
}
if (next != null) {
next.setText(positiveLabel);
next.setOnClickListener(onPositive);
}
}
}
@@ -0,0 +1,127 @@
package com.android.messaging.ui.home;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.LinearLayout;
import com.android.messaging.R;
import com.android.messaging.ui.BugleActionBarActivity;
import com.google.android.material.bottomsheet.BottomSheetDialog;
/** File Plus-style security settings: App Lock toggle plus PIN / pattern / password. */
public class HomeSecuritySettingsActivity extends BugleActionBarActivity {
private View mLockRow;
private View mPinRow;
private View mPatternRow;
private View mPasswordRow;
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.home_security_settings_activity);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
HomeSubHeader.bind(this, R.string.home_security_title, v -> finish());
mLockRow = findViewById(R.id.home_security_app_lock);
mPinRow = findViewById(R.id.home_security_set_pin);
mPatternRow = findViewById(R.id.home_security_set_pattern);
mPasswordRow = findViewById(R.id.home_security_set_password);
bindRows();
}
@Override
protected void onResume() {
super.onResume();
bindRows();
}
@Override
public void finish() {
super.finish();
overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
}
private void bindRows() {
final boolean enabled = HomeAppLock.isEnabled();
final HomeAppLock.Method method = HomeAppLock.getMethod();
HomeSettingsRow.bind(mLockRow, R.drawable.ic_home_lock,
getString(R.string.home_settings_app_lock),
getString(R.string.home_settings_app_lock_subtitle),
false, true, enabled, null, (button, checked) -> onLockToggled(checked));
bindMethodRow(mPinRow, R.drawable.ic_home_pin, R.string.home_lock_set_pin,
method == HomeAppLock.Method.PIN, enabled, HomeAppLock.MODE_SET_PIN);
bindMethodRow(mPatternRow, R.drawable.ic_home_pattern, R.string.home_lock_set_pattern,
method == HomeAppLock.Method.PATTERN, enabled, HomeAppLock.MODE_SET_PATTERN);
bindMethodRow(mPasswordRow, R.drawable.ic_home_password, R.string.home_lock_set_password,
method == HomeAppLock.Method.PASSWORD, enabled, HomeAppLock.MODE_SET_PASSWORD);
}
private void bindMethodRow(final View row, final int iconRes, final int titleRes,
final boolean current, final boolean enabled, final int mode) {
HomeSettingsRow.bind(row, iconRes, getString(titleRes),
current ? getString(R.string.home_lock_current_method) : null,
true, false, false,
v -> startActivity(HomeAppLock.createIntent(this, mode)), null);
HomeSettingsRow.setEnabled(row, enabled);
}
private void onLockToggled(final boolean checked) {
if (checked) {
if (HomeAppLock.hasLockConfigured()) {
HomeAppLock.setEnabled(true);
bindRows();
} else {
bindRows();
showChooseMethodSheet();
}
} else {
HomeAppLock.setEnabled(false);
bindRows();
}
}
private void showChooseMethodSheet() {
final LayoutInflater inflater = LayoutInflater.from(this);
final LinearLayout content = (LinearLayout) inflater.inflate(
R.layout.home_lock_method_sheet, null, false);
final View pin = content.findViewById(R.id.home_lock_method_pin);
final View pattern = content.findViewById(R.id.home_lock_method_pattern);
final View password = content.findViewById(R.id.home_lock_method_password);
final int[] selected = { HomeAppLock.MODE_SET_PIN };
final BottomSheetDialog[] dialog = new BottomSheetDialog[1];
selectMethod(pin, pattern, password, selected[0]);
pin.setOnClickListener(v -> {
selected[0] = HomeAppLock.MODE_SET_PIN;
selectMethod(pin, pattern, password, selected[0]);
});
pattern.setOnClickListener(v -> {
selected[0] = HomeAppLock.MODE_SET_PATTERN;
selectMethod(pin, pattern, password, selected[0]);
});
password.setOnClickListener(v -> {
selected[0] = HomeAppLock.MODE_SET_PASSWORD;
selectMethod(pin, pattern, password, selected[0]);
});
content.findViewById(R.id.home_lock_method_continue).setOnClickListener(v -> {
if (dialog[0] != null) {
dialog[0].dismiss();
}
startActivity(HomeAppLock.createIntent(this, selected[0]));
});
dialog[0] = HomeBottomSheet.builder(this)
.title(R.string.home_lock_choose_method)
.custom(content)
.show();
}
private static void selectMethod(final View pin, final View pattern, final View password,
final int mode) {
pin.setSelected(mode == HomeAppLock.MODE_SET_PIN);
pattern.setSelected(mode == HomeAppLock.MODE_SET_PATTERN);
password.setSelected(mode == HomeAppLock.MODE_SET_PASSWORD);
}
}
@@ -10,7 +10,6 @@ import android.widget.TextView;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.SwitchCompat;
import com.android.messaging.R;
@@ -28,7 +27,7 @@ public final class HomeSettingsRow {
final ImageView icon = row.findViewById(R.id.home_settings_row_icon);
final TextView titleView = row.findViewById(R.id.home_settings_row_title);
final TextView subtitleView = row.findViewById(R.id.home_settings_row_subtitle);
final SwitchCompat toggle = row.findViewById(R.id.home_settings_row_switch);
final ImageView toggle = row.findViewById(R.id.home_settings_row_switch);
final View chevron = row.findViewById(R.id.home_settings_row_chevron);
icon.setImageResource(iconRes);
@@ -48,14 +47,16 @@ public final class HomeSettingsRow {
chevron.setVisibility(showChevron ? View.VISIBLE : View.GONE);
}
if (toggle != null) {
toggle.setOnCheckedChangeListener(null);
toggle.setVisibility(showSwitch ? View.VISIBLE : View.GONE);
toggle.setChecked(switchOn);
toggle.setOnCheckedChangeListener(switchListener);
toggle.setSelected(switchOn);
}
row.setOnClickListener(v -> {
if (showSwitch && toggle != null) {
toggle.toggle();
final boolean checked = !toggle.isSelected();
toggle.setSelected(checked);
if (switchListener != null) {
switchListener.onCheckedChanged(null, checked);
}
}
if (clickListener != null) {
clickListener.onClick(v);
@@ -98,7 +99,7 @@ public final class HomeSettingsRow {
public static void setEnabled(@NonNull final View row, final boolean enabled) {
row.setEnabled(enabled);
row.setAlpha(enabled ? 1f : 0.4f);
final SwitchCompat toggle = row.findViewById(R.id.home_settings_row_switch);
final View toggle = row.findViewById(R.id.home_settings_row_switch);
if (toggle != null) {
toggle.setEnabled(enabled);
}
@@ -18,6 +18,8 @@
package com.android.messaging.util;
import android.Manifest;
import android.accounts.AccountManager;
import android.accounts.AuthenticatorDescription;
import android.app.Activity;
import android.content.ContentUris;
import android.content.Context;
@@ -33,11 +35,18 @@ import android.provider.ContactsContract.Directory;
import android.provider.ContactsContract.DisplayNameSources;
import android.provider.ContactsContract.PhoneLookup;
import android.provider.ContactsContract.Profile;
import android.provider.ContactsContract.RawContacts;
import android.text.TextUtils;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.view.View;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.android.ex.chips.RecipientEntry;
import com.android.messaging.datamodel.CursorQueryData;
import com.android.messaging.datamodel.FrequentContactsCursorQueryData;
@@ -361,6 +370,13 @@ public class ContactUtil {
* In that case, each phone will be returned as a separate record in the result cursor.
*/
public static CursorQueryData getPhones(final Context context) {
return getPhones(context, null);
}
/**
* Same as {@link #getPhones(Context)} but limited to one account, or device-only contacts.
*/
public static CursorQueryData getPhones(final Context context, final AccountFilter filter) {
if (!ContactUtil.hasReadContactsPermission()) {
return CursorQueryData.getEmptyQueryData();
}
@@ -373,8 +389,173 @@ public class ContactUtil {
.appendQueryParameter(Contacts.EXTRA_ADDRESS_BOOK_INDEX, "true")
.build();
return new CursorQueryData(context, uri, PhoneQuery.PROJECTION, null, null,
PhoneQuery.SORT_KEY);
return new CursorQueryData(context, uri, PhoneQuery.PROJECTION,
accountSelection(filter), accountSelectionArgs(filter), PhoneQuery.SORT_KEY);
}
/** Restricts a phone/email query to one contacts account. Null means every account. */
public static final class AccountFilter {
public final String accountName;
public final String accountType;
public final boolean deviceOnly;
private AccountFilter(final String accountName, final String accountType,
final boolean deviceOnly) {
this.accountName = accountName;
this.accountType = accountType;
this.deviceOnly = deviceOnly;
}
public static AccountFilter account(final String accountName, final String accountType) {
if (TextUtils.isEmpty(accountName) && TextUtils.isEmpty(accountType)) {
return device();
}
return new AccountFilter(accountName, accountType == null ? "" : accountType, false);
}
public static AccountFilter device() {
return new AccountFilter(null, null, true);
}
public boolean matches(final AccountFilter other) {
if (other == null) {
return false;
}
return deviceOnly == other.deviceOnly
&& TextUtils.equals(accountName, other.accountName)
&& TextUtils.equals(accountType, other.accountType);
}
}
/** One source shown in the compose "All contacts" sheet. */
public static final class ContactAccount {
public final String accountName;
public final String accountType;
public final String title;
public final String subtitle;
public final int contactCount;
public final boolean deviceOnly;
ContactAccount(final String accountName, final String accountType, final String title,
final String subtitle, final int contactCount, final boolean deviceOnly) {
this.accountName = accountName;
this.accountType = accountType;
this.title = title;
this.subtitle = subtitle;
this.contactCount = contactCount;
this.deviceOnly = deviceOnly;
}
public AccountFilter toFilter() {
return deviceOnly ? AccountFilter.device() : AccountFilter.account(accountName,
accountType);
}
}
private static String accountSelection(final AccountFilter filter) {
if (filter == null) {
return null;
}
if (filter.deviceOnly) {
return "(" + RawContacts.ACCOUNT_NAME + " IS NULL OR " + RawContacts.ACCOUNT_NAME
+ "='') AND (" + RawContacts.ACCOUNT_TYPE + " IS NULL OR "
+ RawContacts.ACCOUNT_TYPE + "='')";
}
return RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?";
}
private static String[] accountSelectionArgs(final AccountFilter filter) {
if (filter == null || filter.deviceOnly) {
return null;
}
return new String[] { filter.accountName, filter.accountType };
}
/**
* Accounts that currently have at least one raw contact, for the compose source sheet.
*/
public static List<ContactAccount> getContactAccounts(final Context context) {
final List<ContactAccount> accounts = new ArrayList<>();
if (!hasReadContactsPermission() || context == null) {
return accounts;
}
final Map<String, AccountBucket> buckets = new LinkedHashMap<>();
Cursor cursor = null;
try {
cursor = context.getContentResolver().query(RawContacts.CONTENT_URI,
new String[] {
RawContacts.ACCOUNT_NAME,
RawContacts.ACCOUNT_TYPE,
RawContacts.CONTACT_ID
},
RawContacts.DELETED + "=0",
null,
RawContacts.ACCOUNT_NAME);
if (cursor != null) {
while (cursor.moveToNext()) {
final String name = cursor.getString(0);
final String type = cursor.getString(1);
final long contactId = cursor.getLong(2);
final boolean device = TextUtils.isEmpty(name) && TextUtils.isEmpty(type);
final String key = device ? "device" : name + "\0" + type;
AccountBucket bucket = buckets.get(key);
if (bucket == null) {
bucket = new AccountBucket(name, type, device);
buckets.put(key, bucket);
}
bucket.contactIds.add(contactId);
}
}
} catch (final SecurityException ignored) {
return accounts;
} finally {
if (cursor != null) {
cursor.close();
}
}
for (final AccountBucket bucket : buckets.values()) {
accounts.add(new ContactAccount(bucket.accountName, bucket.accountType,
bucket.deviceOnly ? null : bucket.accountName,
authenticatorLabel(context, bucket.accountType, bucket.deviceOnly),
bucket.contactIds.size(), bucket.deviceOnly));
}
return accounts;
}
private static final class AccountBucket {
final String accountName;
final String accountType;
final boolean deviceOnly;
final HashSet<Long> contactIds = new HashSet<>();
AccountBucket(final String accountName, final String accountType,
final boolean deviceOnly) {
this.accountName = accountName;
this.accountType = accountType;
this.deviceOnly = deviceOnly;
}
}
private static String authenticatorLabel(final Context context, final String accountType,
final boolean deviceOnly) {
if (deviceOnly || TextUtils.isEmpty(accountType)) {
return null;
}
try {
final AuthenticatorDescription[] types =
AccountManager.get(context).getAuthenticatorTypes();
for (final AuthenticatorDescription description : types) {
if (TextUtils.equals(description.type, accountType)) {
final CharSequence label = context.getPackageManager().getText(
description.packageName, description.labelId, null);
if (!TextUtils.isEmpty(label)) {
return label.toString();
}
}
}
} catch (final RuntimeException ignored) {
}
return accountType;
}
/**
@@ -403,10 +584,15 @@ public class ContactUtil {
*/
public static CursorQueryData filterDestination(final Context context,
final String destination) {
return filterDestination(context, destination, null);
}
public static CursorQueryData filterDestination(final Context context,
final String destination, final AccountFilter filter) {
if (shouldFilterForEmail(destination)) {
return ContactUtil.filterEmails(context, destination);
return ContactUtil.filterEmails(context, destination, filter);
} else {
return ContactUtil.filterPhones(context, destination);
return ContactUtil.filterPhones(context, destination, filter);
}
}
@@ -430,7 +616,13 @@ public class ContactUtil {
* we support email addresses as well.
*/
public static CursorQueryData filterPhones(final Context context, final String query) {
return filterPhonesInternal(context, Phone.CONTENT_FILTER_URI, query, Directory.DEFAULT);
return filterPhones(context, query, null);
}
public static CursorQueryData filterPhones(final Context context, final String query,
final AccountFilter filter) {
return filterPhonesInternal(context, Phone.CONTENT_FILTER_URI, query, Directory.DEFAULT,
filter);
}
/**
@@ -439,18 +631,19 @@ public class ContactUtil {
public static CursorQueryData filterPhonesEnterprise(final Context context,
final String query) {
return filterPhonesInternal(context, Phone.ENTERPRISE_CONTENT_FILTER_URI, query,
Directory.ENTERPRISE_DEFAULT);
Directory.ENTERPRISE_DEFAULT, null);
}
private static CursorQueryData filterPhonesInternal(final Context context,
final Uri phoneFilterBaseUri, final String query, final long directoryId) {
final Uri phoneFilterBaseUri, final String query, final long directoryId,
final AccountFilter filter) {
if (!ContactUtil.hasReadContactsPermission()) {
return CursorQueryData.getEmptyQueryData();
}
Uri phoneFilterUri = buildDirectorySearchUri(phoneFilterBaseUri, query, directoryId);
return new CursorQueryData(context,
phoneFilterUri,
PhoneQuery.PROJECTION, null, null,
PhoneQuery.PROJECTION, accountSelection(filter), accountSelectionArgs(filter),
PhoneQuery.SORT_KEY);
}
/**
@@ -492,7 +685,13 @@ public class ContactUtil {
* we support email addresses as well.
*/
public static CursorQueryData filterEmails(final Context context, final String query) {
return filterEmailsInternal(context, Email.CONTENT_FILTER_URI, query, Directory.DEFAULT);
return filterEmails(context, query, null);
}
public static CursorQueryData filterEmails(final Context context, final String query,
final AccountFilter filter) {
return filterEmailsInternal(context, Email.CONTENT_FILTER_URI, query, Directory.DEFAULT,
filter);
}
/**
@@ -501,11 +700,12 @@ public class ContactUtil {
public static CursorQueryData filterEmailsEnterprise(final Context context,
final String query) {
return filterEmailsInternal(context, Email.ENTERPRISE_CONTENT_FILTER_URI, query,
Directory.ENTERPRISE_DEFAULT);
Directory.ENTERPRISE_DEFAULT, null);
}
private static CursorQueryData filterEmailsInternal(final Context context,
final Uri filterEmailsBaseUri, final String query, final long directoryId) {
final Uri filterEmailsBaseUri, final String query, final long directoryId,
final AccountFilter filter) {
if (!ContactUtil.hasReadContactsPermission()) {
return CursorQueryData.getEmptyQueryData();
}
@@ -513,7 +713,7 @@ public class ContactUtil {
directoryId);
return new CursorQueryData(context,
filterEmailsUri,
PhoneQuery.PROJECTION, null, null,
PhoneQuery.PROJECTION, accountSelection(filter), accountSelectionArgs(filter),
PhoneQuery.SORT_KEY);
}
@@ -116,7 +116,15 @@ public class OsUtil {
}
public static boolean hasLocationPermission() {
return OsUtil.hasPermission(Manifest.permission.ACCESS_FINE_LOCATION);
return hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)
|| hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION);
}
public static String[] getLocationPermissions() {
return new String[] {
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
};
}
public static boolean hasRecordAudioPermission() {