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."