diff --git a/Android.bp b/Android.bp
index e2f0213..3250281 100644
--- a/Android.bp
+++ b/Android.bp
@@ -87,11 +87,13 @@ android_app {
"1.0.001",
"--version-code",
"10001040",
+ "--rename-manifest-package",
+ "com.filabcorp.msgq",
],
required: [
"libframesequence",
"libgiftranscode",
- "com.bnkode.notie.allowlist",
+ "com.filabcorp.msgq.allowlist",
],
optimize: {
obfuscate: true,
@@ -105,9 +107,9 @@ android_app {
}
prebuilt_etc {
- name: "com.bnkode.notie.allowlist",
+ name: "com.filabcorp.msgq.allowlist",
product_specific: true,
sub_dir: "sysconfig",
- src: "com.bnkode.notie.allowlist.xml",
+ src: "com.filabcorp.msgq.allowlist.xml",
filename_from_src: true,
}
diff --git a/app/build.gradle b/app/build.gradle
index 429a08f..ece4bc9 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -13,13 +13,13 @@ android {
compileSdk 35
defaultConfig {
- applicationId 'com.bnkode.notie'
+ applicationId 'com.filabcorp.msgq'
minSdk 33
targetSdk 34
versionCode 10001040
versionName '1.0.001'
vectorDrawables.useSupportLibrary true
- resourceConfigurations += ['en']
+ resourceConfigurations += ['en', 'ko']
}
compileOptions {
@@ -78,6 +78,7 @@ dependencies {
implementation 'androidx.core:core:1.15.0'
implementation 'androidx.exifinterface:exifinterface:1.3.7'
implementation 'androidx.fragment:fragment:1.8.5'
+ implementation 'androidx.lifecycle:lifecycle-process:2.8.7'
implementation 'androidx.media:media:1.7.0'
implementation 'androidx.palette:palette:1.0.0'
implementation 'androidx.preference:preference:1.2.1'
@@ -104,5 +105,5 @@ tasks.register('launchMessaging', Exec) {
description = "Launch Messaging on ${messagingAvdSerial}"
dependsOn 'installMessaging'
commandLine adbExecutable.absolutePath, '-s', messagingAvdSerial, 'shell', 'am', 'start',
- '-n', 'com.bnkode.notie/com.android.messaging.ui.conversationlist.ConversationListActivity'
+ '-n', 'com.filabcorp.msgq/com.android.messaging.ui.conversationlist.ConversationListActivity'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 69f8466..d40ae4b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -46,6 +46,10 @@
+
+
+
+
-
+
+
+
@@ -82,6 +92,7 @@
android:appCategory="social"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
+ android:localeConfig="@xml/locales_config"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true"
android:theme="@style/BugleTheme">
@@ -206,6 +217,32 @@
android:theme="@style/Home.BottomSheet"
android:windowSoftInputMode="adjustResize|stateHidden" />
+
+
+
+
+
+
+ android:theme="@style/Home.TransparentSheetHost"
+ android:windowSoftInputMode="adjustResize">
@@ -343,20 +381,20 @@
@@ -412,18 +450,18 @@
android:name=".receiver.SendStatusReceiver"
android:exported="false">
-
+
-
+
-
+
-
+
@@ -462,6 +500,14 @@
+
+
+
+
+
+
-
+
diff --git a/app/src/main/assets/home_location_map.html b/app/src/main/assets/home_location_map.html
new file mode 100644
index 0000000..a153c3a
--- /dev/null
+++ b/app/src/main/assets/home_location_map.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/android/messaging/BugleApplication.java b/app/src/main/java/com/android/messaging/BugleApplication.java
index fda6d36..4c2d796 100644
--- a/app/src/main/java/com/android/messaging/BugleApplication.java
+++ b/app/src/main/java/com/android/messaging/BugleApplication.java
@@ -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();
diff --git a/app/src/main/java/com/android/messaging/datamodel/BugleNotifications.java b/app/src/main/java/com/android/messaging/datamodel/BugleNotifications.java
index 30b810c..4ef4305 100644
--- a/app/src/main/java/com/android/messaging/datamodel/BugleNotifications.java
+++ b/app/src/main/java/com/android/messaging/datamodel/BugleNotifications.java
@@ -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,
diff --git a/app/src/main/java/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java b/app/src/main/java/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java
index 2fc05d0..4f541b6 100644
--- a/app/src/main/java/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java
+++ b/app/src/main/java/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java
@@ -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) {
diff --git a/app/src/main/java/com/android/messaging/datamodel/MediaScratchFileProvider.java b/app/src/main/java/com/android/messaging/datamodel/MediaScratchFileProvider.java
index 9c096d2..4c663cc 100644
--- a/app/src/main/java/com/android/messaging/datamodel/MediaScratchFileProvider.java
+++ b/app/src/main/java/com/android/messaging/datamodel/MediaScratchFileProvider.java
@@ -46,7 +46,7 @@ public class MediaScratchFileProvider extends FileProvider {
private static final SimpleArrayMap 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) {
diff --git a/app/src/main/java/com/android/messaging/datamodel/MessageNotificationState.java b/app/src/main/java/com/android/messaging/datamodel/MessageNotificationState.java
index cece072..23a8baa 100644
--- a/app/src/main/java/com/android/messaging/datamodel/MessageNotificationState.java
+++ b/app/src/main/java/com/android/messaging/datamodel/MessageNotificationState.java
@@ -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 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;
}
}
diff --git a/app/src/main/java/com/android/messaging/datamodel/MessagingContentProvider.java b/app/src/main/java/com/android/messaging/datamodel/MessagingContentProvider.java
index 3db0f67..12b430e 100644
--- a/app/src/main/java/com/android/messaging/datamodel/MessagingContentProvider.java
+++ b/app/src/main/java/com/android/messaging/datamodel/MessagingContentProvider.java
@@ -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
diff --git a/app/src/main/java/com/android/messaging/datamodel/MmsFileProvider.java b/app/src/main/java/com/android/messaging/datamodel/MmsFileProvider.java
index 69f0307..ae77472 100644
--- a/app/src/main/java/com/android/messaging/datamodel/MmsFileProvider.java
+++ b/app/src/main/java/com/android/messaging/datamodel/MmsFileProvider.java
@@ -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";
/**
diff --git a/app/src/main/java/com/android/messaging/datamodel/ScheduledMessageStore.java b/app/src/main/java/com/android/messaging/datamodel/ScheduledMessageStore.java
new file mode 100644
index 0000000..67dc526
--- /dev/null
+++ b/app/src/main/java/com/android/messaging/datamodel/ScheduledMessageStore.java
@@ -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 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 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 entries = loadLocked(context);
+ final long now = System.currentTimeMillis();
+ final List due = new ArrayList<>();
+ synchronized (ScheduledMessageStore.class) {
+ final List 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 loadLocked(@NonNull final Context context) {
+ final SharedPreferences prefs = prefs(context);
+ final String raw = prefs.getString(KEY_ENTRIES, "[]");
+ final List 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 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;
+ }
+}
diff --git a/app/src/main/java/com/android/messaging/datamodel/action/ActionServiceImpl.java b/app/src/main/java/com/android/messaging/datamodel/action/ActionServiceImpl.java
index 58cffe5..1522e54 100644
--- a/app/src/main/java/com/android/messaging/datamodel/action/ActionServiceImpl.java
+++ b/app/src/main/java/com/android/messaging/datamodel/action/ActionServiceImpl.java
@@ -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.
diff --git a/app/src/main/java/com/android/messaging/datamodel/data/ContactPickerData.java b/app/src/main/java/com/android/messaging/datamodel/data/ContactPickerData.java
index 8ed5d9a..bcf3dab 100644
--- a/app/src/main/java/com/android/messaging/datamodel/data/ContactPickerData.java
+++ b/app/src/main/java/com/android/messaging/datamodel/data/ContactPickerData.java
@@ -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 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;
diff --git a/app/src/main/java/com/android/messaging/receiver/BootAndPackageReplacedReceiver.java b/app/src/main/java/com/android/messaging/receiver/BootAndPackageReplacedReceiver.java
index be0d296..3b4acd0 100644
--- a/app/src/main/java/com/android/messaging/receiver/BootAndPackageReplacedReceiver.java
+++ b/app/src/main/java/com/android/messaging/receiver/BootAndPackageReplacedReceiver.java
@@ -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());
diff --git a/app/src/main/java/com/android/messaging/receiver/ScheduledMessageReceiver.java b/app/src/main/java/com/android/messaging/receiver/ScheduledMessageReceiver.java
new file mode 100644
index 0000000..1306ef3
--- /dev/null
+++ b/app/src/main/java/com/android/messaging/receiver/ScheduledMessageReceiver.java
@@ -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();
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/android/messaging/receiver/SendStatusReceiver.java b/app/src/main/java/com/android/messaging/receiver/SendStatusReceiver.java
index a3c4a35..03228d6 100644
--- a/app/src/main/java/com/android/messaging/receiver/SendStatusReceiver.java
+++ b/app/src/main/java/com/android/messaging/receiver/SendStatusReceiver.java
@@ -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";
diff --git a/app/src/main/java/com/android/messaging/ui/RemoteInputEntrypointActivity.java b/app/src/main/java/com/android/messaging/ui/RemoteInputEntrypointActivity.java
index c731164..19649b4 100644
--- a/app/src/main/java/com/android/messaging/ui/RemoteInputEntrypointActivity.java
+++ b/app/src/main/java/com/android/messaging/ui/RemoteInputEntrypointActivity.java
@@ -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);
+ }
}
diff --git a/app/src/main/java/com/android/messaging/ui/UIIntents.java b/app/src/main/java/com/android/messaging/ui/UIIntents.java
index 7ead46a..c270efc 100644
--- a/app/src/main/java/com/android/messaging/ui/UIIntents.java
+++ b/app/src/main/java/com/android/messaging/ui/UIIntents.java
@@ -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 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.
diff --git a/app/src/main/java/com/android/messaging/ui/UIIntentsImpl.java b/app/src/main/java/com/android/messaging/ui/UIIntentsImpl.java
index 1014562..a72ad3a 100644
--- a/app/src/main/java/com/android/messaging/ui/UIIntentsImpl.java
+++ b/app/src/main/java/com/android/messaging/ui/UIIntentsImpl.java
@@ -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 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);
}
diff --git a/app/src/main/java/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java b/app/src/main/java/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java
index 8d75824..27f27ef 100644
--- a/app/src/main/java/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java
+++ b/app/src/main/java/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java
@@ -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 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() {
diff --git a/app/src/main/java/com/android/messaging/ui/contact/ContactListItemView.java b/app/src/main/java/com/android/messaging/ui/contact/ContactListItemView.java
index eef96b7..b1e89c4 100644
--- a/app/src/main/java/com/android/messaging/ui/contact/ContactListItemView.java
+++ b/app/src/main/java/com/android/messaging/ui/contact/ContactListItemView.java
@@ -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);
}
diff --git a/app/src/main/java/com/android/messaging/ui/contact/ContactSectionIndexer.java b/app/src/main/java/com/android/messaging/ui/contact/ContactSectionIndexer.java
index d768b2d..e948568 100644
--- a/app/src/main/java/com/android/messaging/ui/contact/ContactSectionIndexer.java
+++ b/app/src/main/java/com/android/messaging/ui/contact/ContactSectionIndexer.java
@@ -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.
diff --git a/app/src/main/java/com/android/messaging/ui/conversation/ComposeMessageView.java b/app/src/main/java/com/android/messaging/ui/conversation/ComposeMessageView.java
index 3227b07..b7b3064 100644
--- a/app/src/main/java/com/android/messaging/ui/conversation/ComposeMessageView.java
+++ b/app/src/main/java/com/android/messaging/ui/conversation/ComposeMessageView.java
@@ -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 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;
diff --git a/app/src/main/java/com/android/messaging/ui/conversation/ConversationFragment.java b/app/src/main/java/com/android/messaging/ui/conversation/ConversationFragment.java
index 75d446c..92632d8 100644
--- a/app/src/main/java/com/android/messaging/ui/conversation/ConversationFragment.java
+++ b/app/src/main/java/com/android/messaging/ui/conversation/ConversationFragment.java
@@ -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 mLocationPermissionLauncher =
+ registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(),
+ result -> {
+ final Runnable next = mOnLocationPermissionGranted;
+ mOnLocationPermissionGranted = null;
+ if (next != null) {
+ next.run();
+ }
+ });
+ private final ActivityResultLauncher 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());
diff --git a/app/src/main/java/com/android/messaging/ui/conversation/ConversationMessageView.java b/app/src/main/java/com/android/messaging/ui/conversation/ConversationMessageView.java
index 2bab5ff..6242053 100644
--- a/app/src/main/java/com/android/messaging/ui/conversation/ConversationMessageView.java
+++ b/app/src/main/java/com/android/messaging/ui/conversation/ConversationMessageView.java
@@ -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);
}
/**
diff --git a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListActivity.java b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListActivity.java
index cb2c174..51e446b 100644
--- a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListActivity.java
+++ b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListActivity.java
@@ -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);
}
diff --git a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListFragment.java b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListFragment.java
index 632c839..40e2609 100644
--- a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListFragment.java
+++ b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListFragment.java
@@ -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 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();
}
diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeAppLock.java b/app/src/main/java/com/android/messaging/ui/home/HomeAppLock.java
new file mode 100644
index 0000000..1fdd2e3
--- /dev/null
+++ b/app/src/main/java/com/android/messaging/ui/home/HomeAppLock.java
@@ -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 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 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 appendPatternCells(@NonNull final List current, final int next) {
+ if (current.isEmpty()) {
+ final List 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 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 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();
+ }
+}
diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeAppLockActivity.java b/app/src/main/java/com/android/messaging/ui/home/HomeAppLockActivity.java
new file mode 100644
index 0000000..31cf14c
--- /dev/null
+++ b/app/src/main/java/com/android/messaging/ui/home/HomeAppLockActivity.java
@@ -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 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 cells) {
+ hideError();
+ if (mPatternRecorded != null) {
+ mPatternRecorded.setVisibility(
+ cells.size() >= 4 ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ @Override
+ public void onPatternComplete(final List 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();
+ }
+}
diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeComposeActivity.java b/app/src/main/java/com/android/messaging/ui/home/HomeComposeActivity.java
index caba722..2fb6bf0 100644
--- a/app/src/main/java/com/android/messaging/ui/home/HomeComposeActivity.java
+++ b/app/src/main/java/com/android/messaging/ui/home/HomeComposeActivity.java
@@ -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 accounts =
+ ContactUtil.getContactAccounts(this);
+ mMainHandler.post(() -> presentAccountSheet(accounts));
+ });
+ }
+
+ private void presentAccountSheet(final List 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())) {
diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeComposeAttachPanel.java b/app/src/main/java/com/android/messaging/ui/home/HomeComposeAttachPanel.java
index cbbd4ef..c42ca41 100644
--- a/app/src/main/java/com/android/messaging/ui/home/HomeComposeAttachPanel.java
+++ b/app/src/main/java/com/android/messaging/ui/home/HomeComposeAttachPanel.java
@@ -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 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();
}
diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeContactListAdapter.java b/app/src/main/java/com/android/messaging/ui/home/HomeContactListAdapter.java
index fbeb9ea..d468529 100644
--- a/app/src/main/java/com/android/messaging/ui/home/HomeContactListAdapter.java
+++ b/app/src/main/java/com/android/messaging/ui/home/HomeContactListAdapter.java
@@ -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);
+ }
}
diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeContactsFragment.java b/app/src/main/java/com/android/messaging/ui/home/HomeContactsFragment.java
index 206450b..ba531a4 100644
--- a/app/src/main/java/com/android/messaging/ui/home/HomeContactsFragment.java
+++ b/app/src/main/java/com/android/messaging/ui/home/HomeContactsFragment.java
@@ -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.
*
diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeInputFocus.java b/app/src/main/java/com/android/messaging/ui/home/HomeInputFocus.java
index 5021605..b8bab8b 100644
--- a/app/src/main/java/com/android/messaging/ui/home/HomeInputFocus.java
+++ b/app/src/main/java/com/android/messaging/ui/home/HomeInputFocus.java
@@ -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 {
diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeLocationActivity.java b/app/src/main/java/com/android/messaging/ui/home/HomeLocationActivity.java
new file mode 100644
index 0000000..39e2f31
--- /dev/null
+++ b/app/src/main/java/com/android/messaging/ui/home/HomeLocationActivity.java
@@ -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 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);
+ });
+ }
+ }
+}
diff --git a/app/src/main/java/com/android/messaging/ui/home/HomePassThroughView.java b/app/src/main/java/com/android/messaging/ui/home/HomePassThroughView.java
new file mode 100644
index 0000000..d987102
--- /dev/null
+++ b/app/src/main/java/com/android/messaging/ui/home/HomePassThroughView.java
@@ -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;
+ }
+}
diff --git a/app/src/main/java/com/android/messaging/ui/home/HomePatternLockView.java b/app/src/main/java/com/android/messaging/ui/home/HomePatternLockView.java
new file mode 100644
index 0000000..a38960a
--- /dev/null
+++ b/app/src/main/java/com/android/messaging/ui/home/HomePatternLockView.java
@@ -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 cells);
+
+ void onPatternComplete(@NonNull List 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 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 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 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());
+ }
+}
diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeSchedulePicker.java b/app/src/main/java/com/android/messaging/ui/home/HomeSchedulePicker.java
new file mode 100644
index 0000000..42c3672
--- /dev/null
+++ b/app/src/main/java/com/android/messaging/ui/home/HomeSchedulePicker.java
@@ -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);
+ }
+ }
+}
diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeSecuritySettingsActivity.java b/app/src/main/java/com/android/messaging/ui/home/HomeSecuritySettingsActivity.java
new file mode 100644
index 0000000..9b060b6
--- /dev/null
+++ b/app/src/main/java/com/android/messaging/ui/home/HomeSecuritySettingsActivity.java
@@ -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);
+ }
+}
diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeSettingsRow.java b/app/src/main/java/com/android/messaging/ui/home/HomeSettingsRow.java
index 1253cb3..d078e68 100644
--- a/app/src/main/java/com/android/messaging/ui/home/HomeSettingsRow.java
+++ b/app/src/main/java/com/android/messaging/ui/home/HomeSettingsRow.java
@@ -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);
}
diff --git a/app/src/main/java/com/android/messaging/util/ContactUtil.java b/app/src/main/java/com/android/messaging/util/ContactUtil.java
index 648b94a..898e3be 100644
--- a/app/src/main/java/com/android/messaging/util/ContactUtil.java
+++ b/app/src/main/java/com/android/messaging/util/ContactUtil.java
@@ -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 getContactAccounts(final Context context) {
+ final List accounts = new ArrayList<>();
+ if (!hasReadContactsPermission() || context == null) {
+ return accounts;
+ }
+ final Map 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 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);
}
diff --git a/app/src/main/java/com/android/messaging/util/OsUtil.java b/app/src/main/java/com/android/messaging/util/OsUtil.java
index 43cbb58..f7e0efb 100644
--- a/app/src/main/java/com/android/messaging/util/OsUtil.java
+++ b/app/src/main/java/com/android/messaging/util/OsUtil.java
@@ -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() {
diff --git a/app/src/main/res/drawable/home_bg_compose_account_chip.xml b/app/src/main/res/drawable/home_bg_compose_account_chip.xml
new file mode 100644
index 0000000..7dac0d6
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_compose_account_chip.xml
@@ -0,0 +1,10 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/home_bg_compose_group.xml b/app/src/main/res/drawable/home_bg_compose_group.xml
new file mode 100644
index 0000000..d135879
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_compose_group.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/home_bg_compose_group_pill.xml b/app/src/main/res/drawable/home_bg_compose_group_pill.xml
new file mode 100644
index 0000000..1c66aa4
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_compose_group_pill.xml
@@ -0,0 +1,10 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/home_bg_contact_section_bottom.xml b/app/src/main/res/drawable/home_bg_contact_section_bottom.xml
new file mode 100644
index 0000000..7f22228
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_contact_section_bottom.xml
@@ -0,0 +1,12 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/home_bg_contact_section_middle.xml b/app/src/main/res/drawable/home_bg_contact_section_middle.xml
new file mode 100644
index 0000000..47ab529
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_contact_section_middle.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/home_bg_contact_section_single.xml b/app/src/main/res/drawable/home_bg_contact_section_single.xml
new file mode 100644
index 0000000..6973b17
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_contact_section_single.xml
@@ -0,0 +1,10 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/home_bg_contact_section_top.xml b/app/src/main/res/drawable/home_bg_contact_section_top.xml
new file mode 100644
index 0000000..80d5e1c
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_contact_section_top.xml
@@ -0,0 +1,12 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/home_bg_list_bottom_fade.xml b/app/src/main/res/drawable/home_bg_list_bottom_fade.xml
new file mode 100644
index 0000000..05819af
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_list_bottom_fade.xml
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/home_bg_list_top_fade.xml b/app/src/main/res/drawable/home_bg_list_top_fade.xml
new file mode 100644
index 0000000..c9c0c96
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_list_top_fade.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/home_bg_lock_continue.xml b/app/src/main/res/drawable/home_bg_lock_continue.xml
new file mode 100644
index 0000000..3506401
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_lock_continue.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/home_bg_lock_method.xml b/app/src/main/res/drawable/home_bg_lock_method.xml
new file mode 100644
index 0000000..e96a37b
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_lock_method.xml
@@ -0,0 +1,21 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/home_bg_map_locate.xml b/app/src/main/res/drawable/home_bg_map_locate.xml
new file mode 100644
index 0000000..2eb9b1d
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_map_locate.xml
@@ -0,0 +1,12 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/home_bg_pin_dot.xml b/app/src/main/res/drawable/home_bg_pin_dot.xml
new file mode 100644
index 0000000..909bbf4
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_pin_dot.xml
@@ -0,0 +1,18 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/home_bg_schedule_chip.xml b/app/src/main/res/drawable/home_bg_schedule_chip.xml
new file mode 100644
index 0000000..bc0058e
--- /dev/null
+++ b/app/src/main/res/drawable/home_bg_schedule_chip.xml
@@ -0,0 +1,10 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/home_switch.xml b/app/src/main/res/drawable/home_switch.xml
new file mode 100644
index 0000000..ed0da72
--- /dev/null
+++ b/app/src/main/res/drawable/home_switch.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_backspace.xml b/app/src/main/res/drawable/ic_home_backspace.xml
new file mode 100644
index 0000000..9f0c34d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_backspace.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_expand.xml b/app/src/main/res/drawable/ic_home_expand.xml
new file mode 100644
index 0000000..9ecffd9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_expand.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_language.xml b/app/src/main/res/drawable/ic_home_language.xml
new file mode 100644
index 0000000..bd9e490
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_language.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_lock.xml b/app/src/main/res/drawable/ic_home_lock.xml
new file mode 100644
index 0000000..1ed28ae
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_lock.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_password.xml b/app/src/main/res/drawable/ic_home_password.xml
new file mode 100644
index 0000000..b280317
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_password.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_pattern.xml b/app/src/main/res/drawable/ic_home_pattern.xml
new file mode 100644
index 0000000..951a22e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_pattern.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_pin.xml b/app/src/main/res/drawable/ic_home_pin.xml
new file mode 100644
index 0000000..d66760a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_pin.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_resend.xml b/app/src/main/res/drawable/ic_home_resend.xml
new file mode 100644
index 0000000..6b52dd0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_resend.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_status_retry.xml b/app/src/main/res/drawable/ic_home_status_retry.xml
new file mode 100644
index 0000000..c1996b9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_status_retry.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_switch_off.xml b/app/src/main/res/drawable/ic_home_switch_off.xml
new file mode 100644
index 0000000..75029c7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_switch_off.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_switch_on.xml b/app/src/main/res/drawable/ic_home_switch_on.xml
new file mode 100644
index 0000000..90d1ad8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_switch_on.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_visibility.xml b/app/src/main/res/drawable/ic_home_visibility.xml
new file mode 100644
index 0000000..24df8eb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_visibility.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_visibility_off.xml b/app/src/main/res/drawable/ic_home_visibility_off.xml
new file mode 100644
index 0000000..11c2724
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_visibility_off.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/compose_message_view.xml b/app/src/main/res/layout/compose_message_view.xml
index 2f4c8de..ea1aee8 100644
--- a/app/src/main/res/layout/compose_message_view.xml
+++ b/app/src/main/res/layout/compose_message_view.xml
@@ -188,6 +188,49 @@
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+ android:paddingBottom="2dp">
-
-
-
-
-
-
-
-
-
-
-
-
+ android:paddingBottom="4dp"
+ android:visibility="invisible">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/conversation_list_item_view.xml b/app/src/main/res/layout/conversation_list_item_view.xml
index ad2f998..4c4d242 100644
--- a/app/src/main/res/layout/conversation_list_item_view.xml
+++ b/app/src/main/res/layout/conversation_list_item_view.xml
@@ -107,7 +107,7 @@
android:minHeight="72dp"
android:paddingStart="@dimen/looply_space_md"
android:paddingEnd="@dimen/looply_space_lg"
- android:paddingTop="@dimen/looply_space_md"
+ android:paddingTop="@dimen/looply_space_sm"
android:paddingBottom="@dimen/looply_space_md"
android:baselineAligned="false">
diff --git a/app/src/main/res/layout/conversation_message_view.xml b/app/src/main/res/layout/conversation_message_view.xml
index 150a57b..500dd52 100644
--- a/app/src/main/res/layout/conversation_message_view.xml
+++ b/app/src/main/res/layout/conversation_message_view.xml
@@ -127,72 +127,79 @@
android:visibility="gone"
android:importantForAccessibility="no" />
-
-
-
-
-
-
-
-
-
-
-
+ android:importantForAccessibility="noHideDescendants"
+ android:orientation="horizontal">
-
+ android:visibility="gone" />
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_compose_account_row.xml b/app/src/main/res/layout/home_compose_account_row.xml
new file mode 100644
index 0000000..b731f2e
--- /dev/null
+++ b/app/src/main/res/layout/home_compose_account_row.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_compose_account_sheet.xml b/app/src/main/res/layout/home_compose_account_sheet.xml
new file mode 100644
index 0000000..3407886
--- /dev/null
+++ b/app/src/main/res/layout/home_compose_account_sheet.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app/src/main/res/layout/home_compose_activity.xml b/app/src/main/res/layout/home_compose_activity.xml
index 16de628..77e3e37 100644
--- a/app/src/main/res/layout/home_compose_activity.xml
+++ b/app/src/main/res/layout/home_compose_activity.xml
@@ -3,7 +3,9 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/home_sheet_scrim"
- android:fitsSystemWindows="true">
+ android:fitsSystemWindows="true"
+ android:focusable="true"
+ android:focusableInTouchMode="true">
-
-
-
-
-
-
-
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:paddingStart="@dimen/looply_screen_padding"
+ android:paddingTop="@dimen/looply_space_md"
+ android:paddingEnd="@dimen/looply_screen_padding"
+ android:paddingBottom="@dimen/looply_space_xs">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_contacts_list_item.xml b/app/src/main/res/layout/home_contacts_list_item.xml
index 0e3d335..6b6ba9f 100644
--- a/app/src/main/res/layout/home_contacts_list_item.xml
+++ b/app/src/main/res/layout/home_contacts_list_item.xml
@@ -4,79 +4,118 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:background="?android:attr/selectableItemBackground"
- android:gravity="center_vertical"
- android:minHeight="56dp"
- android:orientation="horizontal"
- android:paddingStart="16dp"
- android:paddingTop="8dp"
- android:paddingEnd="16dp"
- android:paddingBottom="8dp">
+ android:orientation="vertical">
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:textSize="14sp"
+ android:textStyle="bold"
+ android:visibility="gone" />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_conversation_search_activity.xml b/app/src/main/res/layout/home_conversation_search_activity.xml
index de29320..8004391 100644
--- a/app/src/main/res/layout/home_conversation_search_activity.xml
+++ b/app/src/main/res/layout/home_conversation_search_activity.xml
@@ -5,6 +5,8 @@
android:layout_height="match_parent"
android:background="@color/home_bg"
android:fitsSystemWindows="true"
+ android:focusable="true"
+ android:focusableInTouchMode="true"
android:orientation="vertical">
diff --git a/app/src/main/res/layout/home_language_row.xml b/app/src/main/res/layout/home_language_row.xml
new file mode 100644
index 0000000..8608e8c
--- /dev/null
+++ b/app/src/main/res/layout/home_language_row.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_language_sheet.xml b/app/src/main/res/layout/home_language_sheet.xml
new file mode 100644
index 0000000..7b2be27
--- /dev/null
+++ b/app/src/main/res/layout/home_language_sheet.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app/src/main/res/layout/home_list_profile_popup.xml b/app/src/main/res/layout/home_list_profile_popup.xml
index 1b3155a..013e8cf 100644
--- a/app/src/main/res/layout/home_list_profile_popup.xml
+++ b/app/src/main/res/layout/home_list_profile_popup.xml
@@ -6,6 +6,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true"
+ android:elevation="24dp"
android:focusable="true"
android:visibility="gone">
@@ -21,12 +22,12 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
- android:scaleType="centerCrop" />
+ android:scaleType="fitXY" />
diff --git a/app/src/main/res/layout/home_location_activity.xml b/app/src/main/res/layout/home_location_activity.xml
new file mode 100644
index 0000000..146375f
--- /dev/null
+++ b/app/src/main/res/layout/home_location_activity.xml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_lock_method_sheet.xml b/app/src/main/res/layout/home_lock_method_sheet.xml
new file mode 100644
index 0000000..96de286
--- /dev/null
+++ b/app/src/main/res/layout/home_lock_method_sheet.xml
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_lock_password_body.xml b/app/src/main/res/layout/home_lock_password_body.xml
new file mode 100644
index 0000000..9497065
--- /dev/null
+++ b/app/src/main/res/layout/home_lock_password_body.xml
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_lock_pattern_body.xml b/app/src/main/res/layout/home_lock_pattern_body.xml
new file mode 100644
index 0000000..02df0b8
--- /dev/null
+++ b/app/src/main/res/layout/home_lock_pattern_body.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_lock_pin_body.xml b/app/src/main/res/layout/home_lock_pin_body.xml
new file mode 100644
index 0000000..e22556b
--- /dev/null
+++ b/app/src/main/res/layout/home_lock_pin_body.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_lock_pin_dots.xml b/app/src/main/res/layout/home_lock_pin_dots.xml
new file mode 100644
index 0000000..af77840
--- /dev/null
+++ b/app/src/main/res/layout/home_lock_pin_dots.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_lock_pin_keypad.xml b/app/src/main/res/layout/home_lock_pin_keypad.xml
new file mode 100644
index 0000000..3a3053b
--- /dev/null
+++ b/app/src/main/res/layout/home_lock_pin_keypad.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_lock_setup.xml b/app/src/main/res/layout/home_lock_setup.xml
new file mode 100644
index 0000000..fa1adf8
--- /dev/null
+++ b/app/src/main/res/layout/home_lock_setup.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_lock_unlock.xml b/app/src/main/res/layout/home_lock_unlock.xml
new file mode 100644
index 0000000..9437bdd
--- /dev/null
+++ b/app/src/main/res/layout/home_lock_unlock.xml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_message_status_sheet.xml b/app/src/main/res/layout/home_message_status_sheet.xml
new file mode 100644
index 0000000..1065aae
--- /dev/null
+++ b/app/src/main/res/layout/home_message_status_sheet.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_notification_reply_sheet.xml b/app/src/main/res/layout/home_notification_reply_sheet.xml
new file mode 100644
index 0000000..ccd34bb
--- /dev/null
+++ b/app/src/main/res/layout/home_notification_reply_sheet.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_popover_filter.xml b/app/src/main/res/layout/home_popover_filter.xml
index 1cd5c36..90049af 100644
--- a/app/src/main/res/layout/home_popover_filter.xml
+++ b/app/src/main/res/layout/home_popover_filter.xml
@@ -221,6 +221,33 @@
android:layout_marginBottom="4dp"
android:background="@color/home_divider" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_schedule_time_sheet.xml b/app/src/main/res/layout/home_schedule_time_sheet.xml
new file mode 100644
index 0000000..582ef1d
--- /dev/null
+++ b/app/src/main/res/layout/home_schedule_time_sheet.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_search_activity.xml b/app/src/main/res/layout/home_search_activity.xml
index 5b75951..35b9068 100644
--- a/app/src/main/res/layout/home_search_activity.xml
+++ b/app/src/main/res/layout/home_search_activity.xml
@@ -5,6 +5,8 @@
android:layout_height="match_parent"
android:background="@color/home_bg"
android:fitsSystemWindows="true"
+ android:focusable="true"
+ android:focusableInTouchMode="true"
android:orientation="vertical">
diff --git a/app/src/main/res/layout/home_security_settings_activity.xml b/app/src/main/res/layout/home_security_settings_activity.xml
new file mode 100644
index 0000000..45a8678
--- /dev/null
+++ b/app/src/main/res/layout/home_security_settings_activity.xml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/home_settings_activity.xml b/app/src/main/res/layout/home_settings_activity.xml
index c9e496b..6cf4793 100644
--- a/app/src/main/res/layout/home_settings_activity.xml
+++ b/app/src/main/res/layout/home_settings_activity.xml
@@ -24,6 +24,70 @@
android:layout_height="wrap_content"
android:orientation="vertical">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/looply_space_xs" />
diff --git a/app/src/main/res/layout/home_settings_row.xml b/app/src/main/res/layout/home_settings_row.xml
index 011aac0..ac66758 100644
--- a/app/src/main/res/layout/home_settings_row.xml
+++ b/app/src/main/res/layout/home_settings_row.xml
@@ -63,13 +63,16 @@
android:textSize="14sp"
android:visibility="gone" />
-
+
+ Looply Components
+ 이름 또는 번호 검색
+ 전체
+ 안 읽음
+ 그룹
+ 메시지 입력
+ 채팅
+ 통화
+ 연락처
+ 설정
+
+ Message Q
+ 프로필
+ 편집
+ 검색
+ 개인
+ 업무
+ 전체
+ 안 읽음
+ 그룹
+ 보관함
+ 검색
+ 필터
+ 새 메시지
+ 음성 입력
+ 자동화 기능은 곧 제공됩니다
+ 채팅 시작
+
+ 모든 메시지
+ 안 읽음
+ 그룹
+ 보관함
+ 메시지
+ 연락처
+ 통화
+ 설정
+ 설정
+ 환경설정
+ 메시징
+ 자동화
+ 채팅
+ 보안
+ 개인정보 및 데이터
+ 도움말
+ Message Q Premium
+ 자동화와 추가 기능을 위한 하나의 요금제
+ 기본 SMS 앱
+ SMS 및 MMS
+ 그룹 메시지, 자동 수신, 전송 확인
+ 알림
+ 소리, 배너, 잠금 화면
+ 화면
+ 테마와 표시
+ 언어
+ 한국어 또는 영어
+ 🌐 시스템 언어
+ 🇺🇸 English
+ 🇰🇷 한국어
+ 앱 잠금
+ 앱을 열 때 인증을 요구합니다
+ 보안 설정
+ 켜짐
+ 꺼짐
+ PIN 설정
+ 패턴 설정
+ 비밀번호 설정
+ 현재 잠금 방식
+ 잠금 방식 선택
+ PIN
+ 6자리 PIN을 사용합니다
+ 패턴
+ 패턴을 그립니다
+ 비밀번호
+ 비밀번호를 사용합니다
+ 계속
+ 잠금을 해제하세요
+ 6자리 PIN을 입력하세요
+ 패턴을 그리세요
+ 비밀번호를 입력하세요
+ 6자리 PIN 설정
+ 기억할 수 있는 6자리 PIN을 입력하세요. 다음 단계에서 같은 PIN을 다시 입력해 확인합니다.
+ 확인을 위해 같은 6자리 PIN을 다시 입력하세요.
+ 기억할 수 있는 순서로 점 4개 이상을 연결하세요. 패턴이 끝나면 손가락을 떼세요.
+ 확인을 위해 같은 패턴을 다시 그리세요.
+ 패턴이 기록되었습니다
+ 패턴 지우기
+ 8자 이상이며 숫자, 기호, 대문자를 각각 하나 이상 포함하세요.
+ 확인을 위해 같은 비밀번호를 다시 입력하세요.
+ 비밀번호 입력
+ 비밀번호 확인
+ 비밀번호 표시 또는 숨기기
+ 8자 이상
+ 숫자 1개
+ 기호 1개
+ 대문자 1개
+ PIN이 올바르지 않습니다
+ 패턴이 올바르지 않습니다
+ 비밀번호가 올바르지 않습니다
+ 다시 시도
+ 입력이 일치하지 않습니다. 잠금을 다시 설정하세요.
+ 지우기
+ 자동화 전용 모드
+ 받은편지함을 숨기고 이벤트 자동화만 실행
+ 자동화
+ 이벤트가 발생하면 문자를 보냅니다
+ 전송 소리
+ 오른쪽으로 밀어 삭제
+ 대화를 오른쪽으로 밀어 삭제합니다
+ 차단 및 스팸
+ 차단된 번호와 스팸 신고
+ 개인정보
+ 권한과 알 수 없는 발신자
+ 백업
+ 이 기기의 메시지를 복사합니다
+ 데이터 및 저장공간
+ 미디어, 캐시, 사용량
+ 도움말
+ FAQ와 Message Q 사용법
+ 친구 초대
+ Message Q 공유
+ 고급
+ 라이선스
+ 일반
+ MMS
+ 그룹 메시지
+ 그룹에 MMS 하나로 보내기
+ 각 사람에게 SMS를 따로 보내기
+ 내 전화번호
+ 알 수 없음
+ 전화번호
+ 자동 수신
+ MMS를 자동으로 다운로드
+ 로밍 중 자동 수신
+ 로밍 중에도 MMS를 다운로드
+ SMS 전송 확인
+ 문자가 도착하면 보고서를 요청
+ 자동화
+ 자동화
+ 자동화가 없습니다
+ 메시지를 자동으로 처리할 규칙을 만드세요.
+ 대화가 없습니다
+ 새 채팅을 시작하면 여기에 표시됩니다.
+ 대화를 불러오는 중
+ 잠시 후 메시지가 여기에 나타납니다.
+ 보관된 대화가 없습니다
+ 보관한 채팅은 복원하거나 삭제할 때까지 여기에 있습니다.
+ 스팸 번호가 없습니다
+ 차단된 발신자가 여기에 표시됩니다. 항목을 눌러 해제하세요.
+ 표시할 연락처가 없습니다
+ 이름이나 번호를 입력해 새 채팅을 시작하세요.
+ 연락처 권한이 필요합니다
+ 메시지를 보낼 사람을 보려면 연락처 접근을 허용하세요.
+ 결과가 없습니다
+ 다른 이름이나 전화번호를 입력해 보세요.
+ 이 대화 검색
+ 별표 메시지, 미디어, 링크가 여기에 나타납니다.
+ 일치하는 메시지가 없습니다
+ 이 채팅에서 “%1$s”와 일치하는 내용이 없습니다.
+ 메시지가 없습니다
+ %1$s에게 문자를 보내 대화를 시작하세요.
+ 사진이나 동영상이 없습니다
+ 이 기기의 사진과 동영상이 여기에 나타납니다.
+ 사진 접근 허용
+ 사진과 동영상을 첨부하려면 접근을 허용하세요.
+ 새 자동화
+ 새 자동화
+ 자동화 이름
+ 언제 실행하고 무엇을 할지 설정하세요.
+ 모두 읽음
+ 안 읽음
+ 완료
+ 선택한 메시지 삭제
+ 1개 선택됨
+ 복사
+ 전달
+ 공유
+ 상세
+ 저장
+ 다시 보내기
+ 다운로드
+ 삭제
+ 보내지 못함.
+ 연결 대기 중,
+ 옵션 확인
+ 옵션
+ 전달
+ 복사
+ 별표
+ 별표 해제
+ 삭제
+ 더 선택
+ 상세
+ 이 대화가 삭제됩니다.\n\n스팸으로 신고하면 발신자도 차단 목록에 추가됩니다.
+ 선택한 대화 %1$d개가 삭제됩니다.\n\n스팸으로 신고하면 1:1 대화 발신자도 차단 목록에 추가됩니다.
+ 삭제
+ 삭제하고 스팸 신고
+ 대화를 삭제했습니다
+ 대화 %1$d개를 삭제했습니다
+ 삭제하고 스팸으로 신고했습니다
+ 대화 %1$d개를 삭제하고 스팸으로 신고했습니다
+ 스팸
+ 목록의 번호에서 온 메시지는 차단됩니다. 항목을 눌러 제거하세요.
+ 스팸 번호가 없습니다.
+ 뒤로
+ 닫기
+ 추가
+ 통화 기능은 곧 제공됩니다
+ 더보기
+
+ 메시지
+ 스팸
+ 보관함
+ 보관함
+ 보관한 대화는 복원하거나 삭제할 때까지 여기에 있습니다.
+ 보관된 대화가 없습니다
+ 필터
+ 안 읽음
+ 업무
+ 개인
+ 앱 공유
+ Message Q를 써보세요:\n%s
+ 설정
+ 메시지 선택
+ 고정 편집
+ 곧 제공됩니다
+
+ 고정된 대화
+ 대화는 최대 9개까지 고정할 수 있습니다. 이 대화를 고정하려면 다른 고정을 먼저 해제하세요.
+ 확인
+ 대화 고정
+ 고정 해제
+ 고정 제거
+
+ 검색
+ 메시지 또는 연락처 검색
+ 지우기
+ 초기화
+ 음성 검색
+ 자주 연락하는 사람
+ 모두 보기
+ 카테고리
+ %1$d개
+ 최근 검색
+ 기록 지우기
+ 안 읽음
+ 저장된 연락처
+ 알 수 없는 연락처
+ 별표 메시지
+ 이미지
+ 동영상
+ 장소
+ 링크
+ %1$s 검색은 곧 제공됩니다
+
+ 새 채팅
+ 받는 사람:
+ 이름, 전화번호 또는 이메일 입력
+ 그룹 만들기
+ 연락처
+ 모든 연락처
+ 전화
+ 연락처
+ 표시할 연락처가 없습니다.
+ 연락처 권한이 필요합니다.
+ 결과가 없습니다.
+ %1$s에게 보내기
+ 사람 추가
+ 다음
+ 새 그룹
+
+ 상세
+ 연락처 정보
+ 대화 이름 변경
+ 대화 이름
+ 저장
+ 이 이름은 나에게만 보입니다.
+ 통화
+ 영상
+ 연락처 추가
+ 사람 추가
+ 메시지
+ 검색
+ 등록되지 않은 번호
+ 사람 추가
+ 알림
+ 기본
+ 보관
+ 보관함으로 이동
+ 보관 해제
+ 스팸 신고 및 차단
+ 이 번호의 메시지는 차단됩니다
+ 이 번호 차단 해제
+ 대화 삭제
+ 참가자 %1$d
+ 나와 1명
+ 나와 %1$d명
+ 참가자: 나와 1명
+ 참가자: 나와 %1$d명
+ 번호 복사
+ 연락처 추가
+ 대화를 보관했습니다
+ 보관을 해제했습니다
+
+ 대화 검색
+ 별표 메시지, 미디어, 링크가 여기에 나타납니다.
+ “%1$s”와 일치하는 메시지가 없습니다.
+ 나
+ %1$s을(를) 저장할까요?
+ 이 번호를 저장하면 새 연락처가 추가됩니다.
+ 스팸 신고
+ 연락처 추가
+ 닫기
+ 문자 메시지
+ 온라인
+ 마지막 접속 %1$s
+ 그룹
+ 갤러리
+ 카메라
+ 사진
+ 동영상
+ GIF
+ 스티커
+ 파일
+ 오디오
+ 연락처
+ 예약
+ 위치
+ 항목
+ 체크리스트
+ 일회성 위치
+ 실시간 위치
+ 대화 열기
+ 상세
+ %1$s와 문자 중
+ 이모지
+ 갤러리
+ 카메라
+ 음성 메시지
+ 더보기
+ 음성 메시지는 곧 제공됩니다.
+ 간략히
+ 상세
+ 개인
+ 그룹
+ 채팅에 초대
+
+ 그룹 이름 추가
+ 그룹 이름 (선택)
+ 이 그룹 이름은 나에게만 보입니다.
+ 완료
+ 사진이나 동영상이 없습니다
+ 사진과 동영상 접근을 허용하세요
+ 허용
+ 갤러리
+ 추가
+ %1$d개 추가
+ 설명 추가...
+ 빠른 스크롤
+ 첨부파일
+ 고정
+ 고정 해제
+ 안 읽음으로 표시
+ 읽음으로 표시
+ 알림 숨기기
+ 알림 표시
+ 삭제
+ 대화를 고정했습니다
+ 고정을 해제했습니다
+ 알림을 숨겼습니다
+ 알림이 켜졌습니다
+ 안 읽음으로 표시했습니다
+ 읽음으로 표시했습니다
+ 알림 숨기기
+ 삭제
+ 보관
+ 기본 SMS 앱을 Messages로 설정하세요
+ 보내려면 기본 SMS 앱을 Messages로 설정하세요
+ 변경
+
+ 전체
+ 개인
+ 프로모션
+ 카테고리 추가
+ + 새로 추가
+ 카테고리 추가
+ 대화를 바탕으로 한 추천 카테고리
+ 직접 추가
+ 카테고리 이름
+ 추가
+ 인증
+ %1$s (%2$d)
+ 대화가 없습니다
+ 채팅을 길게 누른 뒤 카테고리에 추가를 선택하세요.
+ 카테고리를 추가했습니다
+ 이미 있는 카테고리입니다
+ 카테고리는 최대 12개까지 추가할 수 있습니다
+ 카테고리를 삭제할까요?
+ 대화는 받은편지함에 그대로 있습니다. 이 카테고리만 삭제됩니다.
+ 카테고리에 추가
+ 하나 이상의 카테고리를 선택하세요.
+ 카테고리 이름을 입력하세요.
+ 확인
+ 카테고리를 업데이트했습니다
+ %1$s에 추가했습니다
+ 이미 %1$s에 있습니다
+ 먼저 카테고리를 추가하세요
+ %1$s에서 제거
+ %1$s에서 제거했습니다
+ 답장
+ 메시지 보내기
+ 나
+ 날짜 선택
+ 시간 선택
+ 취소
+ 뒤로
+ 다음
+ 완료
+ 예약 해제
+ 오늘 %1$s
+ 내일 %1$s
+ %1$s에 예약됨
+ 예약하려면 메시지를 입력하세요
+ 이후 시간을 선택하세요
+ 위치 공유
+ 이 위치 보내기
+ 위치를 찾는 중…
+ 내 위치
+ 위치 접근이 꺼져 있습니다. 지도에서 장소를 고를 수는 있습니다.
+ 현재 위치를 사용할 수 없습니다
+
diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml
index 04fc431..0139328 100644
--- a/app/src/main/res/values/constants.xml
+++ b/app/src/main/res/values/constants.xml
@@ -25,6 +25,9 @@
sms_enabled
send_sound
true
+ home_language
+ automation_only_mode
+ false
advanced_prefs
+
+
@@ -123,6 +126,8 @@
6dp
3dp
3dp
+ 52dp
+ 32dp
70dp
6dp
280dp
diff --git a/app/src/main/res/values/looply_strings.xml b/app/src/main/res/values/looply_strings.xml
index 0f33e4c..5770074 100644
--- a/app/src/main/res/values/looply_strings.xml
+++ b/app/src/main/res/values/looply_strings.xml
@@ -12,7 +12,7 @@
Settings
- Message +
+ Message Q
Profile
Edit
Search
@@ -38,12 +38,89 @@
Calls
Settings
Settings
+ Preferences
+ Messaging
+ Automation
+ Chats
+ Security
+ Privacy and data
+ Help
+ Message Q Premium
+ One plan for extra automations and extras
Default SMS app
+ SMS and MMS
+ Group messages, auto-retrieve, delivery reports
Notifications
Sounds, banners, and lock screen
+ Appearance
+ Theme and display
+ Language
+ Korean or English
+ 🌐 System language
+ 🇺🇸 English
+ 🇰🇷 한국어
+ App Lock
+ Require authentication when opening the app
+ Security Settings
+ On
+ Off
+ Set PIN
+ Set Pattern
+ Set Password
+ Current lock method
+ Choose Lock Method
+ PIN
+ Use a 6-digit PIN
+ Pattern
+ Draw a pattern
+ Password
+ Use a password
+ Continue
+ Enter your lock
+ Enter your 6-digit PIN
+ Draw your pattern
+ Enter your password
+ Set 6-Digit PIN
+ Enter a 6-digit PIN you can remember. You\'ll type it again on the next step to confirm.
+ Enter the same 6-digit PIN again to confirm.
+ Connect at least 4 dots in an order you can remember. Lift your finger when the pattern is complete.
+ Draw the same pattern again to confirm.
+ Pattern recorded
+ Clear Pattern
+ Use at least 8 characters, including one number, one symbol, and one uppercase letter.
+ Enter the same password again to confirm.
+ Input Password
+ Confirm Password
+ Show or hide password
+ At least 8 characters
+ One number
+ One symbol
+ One uppercase letter
+ Incorrect PIN
+ Incorrect pattern
+ Incorrect password
+ Try again
+ The entries do not match. Please set your lock again.
+ Backspace
+ Automation-only mode
+ Hide the inbox and run event automations only
+ Automations
+ Send texts when events happen
Send sound
Swipe right to delete
Swipe a conversation right to delete it
+ Blocked and spam
+ Blocked numbers and spam reports
+ Privacy
+ Permissions and unknown senders
+ Backup
+ Copy messages off this device
+ Data and storage
+ Media, cache, and usage
+ Help
+ FAQ and how Message Q works
+ Invite friends
+ Share Message Q
Advanced
Licenses
General
@@ -105,6 +182,10 @@
Resend
Download
Delete
+ Not sent.
+ Waiting for connection,
+ Check options
+ Options
Forward
Copy
Star
@@ -140,6 +221,8 @@
Unread
Work
Personal
+ Share this app
+ Check out Message Q:\n%s
Settings
Select Messages
Edit Pinned
@@ -180,6 +263,9 @@
Enter a name, phone number or email
Create group
Contacts
+ All contacts
+ Phone
+ Contacts
No contacts to show.
Contacts permission is required.
No results found.
@@ -330,4 +416,25 @@
Add a category first
Remove from %1$s
Removed from %1$s
+ Reply
+ Send message
+ You
+ Select date
+ Select time
+ Cancel
+ Back
+ Next
+ Done
+ Clear schedule
+ Today %1$s
+ Tomorrow %1$s
+ Scheduled for %1$s
+ Type a message to schedule
+ Choose a future time
+ Share location
+ Send this location
+ Finding location…
+ My location
+ Location access is off. You can still pick a place on the map.
+ Current location unavailable
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 63cee82..54e09c7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -17,10 +17,10 @@
-->
- Message +
+ Message Q
- Message +
+ Message Q
Select conversation
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
new file mode 100644
index 0000000..493af65
--- /dev/null
+++ b/app/src/main/res/xml/locales_config.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/com.bnkode.notie.allowlist.xml b/com.filabcorp.msgq.allowlist.xml
similarity index 92%
rename from com.bnkode.notie.allowlist.xml
rename to com.filabcorp.msgq.allowlist.xml
index d8dcd92..5baa5c9 100644
--- a/com.bnkode.notie.allowlist.xml
+++ b/com.filabcorp.msgq.allowlist.xml
@@ -15,5 +15,5 @@
limitations under the License.
-->
-
+
diff --git a/tools/buglesql b/tools/buglesql
index 4187cb5..82a6103 100755
--- a/tools/buglesql
+++ b/tools/buglesql
@@ -40,4 +40,4 @@ if [ $# -lt 1 ]; then
exit 1
fi
-adb shell su -c sqlite3 $opts data/data/com.bnkode.notie/databases/bugle_db "$1"
+adb shell su -c sqlite3 $opts data/data/com.filabcorp.msgq/databases/bugle_db "$1"
diff --git a/tools/seed_demo_data.ps1 b/tools/seed_demo_data.ps1
index 9ab6b1e..80d032e 100644
--- a/tools/seed_demo_data.ps1
+++ b/tools/seed_demo_data.ps1
@@ -11,14 +11,14 @@ function Invoke-Adb {
}
Write-Host "== Default SMS + permissions =="
-Invoke-Adb shell cmd role add-role-holder android.app.role.SMS com.bnkode.notie | Out-Null
-Invoke-Adb shell settings put secure sms_default_application com.bnkode.notie | Out-Null
+Invoke-Adb shell cmd role add-role-holder android.app.role.SMS com.filabcorp.msgq | Out-Null
+Invoke-Adb shell settings put secure sms_default_application com.filabcorp.msgq | Out-Null
foreach ($p in @(
"READ_SMS","RECEIVE_SMS","SEND_SMS","RECEIVE_MMS",
"READ_CONTACTS","WRITE_CONTACTS","READ_PHONE_STATE",
"READ_PHONE_NUMBERS","POST_NOTIFICATIONS"
)) {
- Invoke-Adb shell pm grant com.bnkode.notie "android.permission.$p" 2>$null | Out-Null
+ Invoke-Adb shell pm grant com.filabcorp.msgq "android.permission.$p" 2>$null | Out-Null
}
$demo = @(
@@ -37,7 +37,7 @@ $demo = @(
@{ Name = "Emily Watson"; Phone = "+15551230013"; Msgs = @("Flight delayed 40 min.","See you at baggage claim.") }
@{ Name = "Support Desk"; Phone = "+15551230014"; Msgs = @("Ticket 9021 resolved. Reply STOP to opt out.","We received your request.") }
@{ Name = "Leo Martinez"; Phone = "+15551230015"; Msgs = @("Game night Friday?","I will bring snacks.") }
- @{ Name = "Hannah Kim"; Phone = "+15551230016"; Msgs = @("Updated the contacts card layout.","Looks much closer to Message+.") }
+ @{ Name = "Hannah Kim"; Phone = "+15551230016"; Msgs = @("Updated the contacts card layout.","Looks much closer to Message Q.") }
@{ Name = "Delivery"; Phone = "+15551230017"; Msgs = @("Driver is 3 stops away.","Pin: leave at door.") }
@{ Name = "Sam Torres"; Phone = "+15551230018"; Msgs = @("Lunch tomorrow?","That new ramen place.") }
@{ Name = "Auto Bot"; Phone = "+15551230019"; Msgs = @("Automation rule ran successfully.","3 messages routed to Archives.") }
@@ -79,7 +79,7 @@ for ($i = 0; $i -lt $demo.Count; $i += 2) {
}
Write-Host "== Relaunch =="
-Invoke-Adb shell am force-stop com.bnkode.notie | Out-Null
+Invoke-Adb shell am force-stop com.filabcorp.msgq | Out-Null
Start-Sleep -Seconds 1
-Invoke-Adb shell am start -n com.bnkode.notie/com.android.messaging.ui.conversationlist.ConversationListActivity | Out-Null
+Invoke-Adb shell am start -n com.filabcorp.msgq/com.android.messaging.ui.conversationlist.ConversationListActivity | Out-Null
Write-Host "Done."