Compare commits

..
10 Commits
Author SHA1 Message Date
privaongandCursor 0b2f7dfdb5 Messaging: Add automation compose and rebrand as com.bnkode.notie.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 04:55:27 +09:00
privaongandCursor a34cccdf76 Messaging: Show the automation home preview and shorten pill shadows.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 03:18:08 +09:00
privaongandCursor 49789fc4a5 Messaging: Vendor chips, colorpicker, photoviewer, and vcard in-tree
Clone no longer depends on LineageOS gitlinks; the libraries used by the app live in this repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 06:19:53 +09:00
privaongandCursor a6b23f0f3c Messaging: Split trash from archive and match home selection chrome
Inbox delete now parks conversations in a 30-day trash, and spam/archive/trash lists share the home select/search chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 06:16:01 +09:00
privaongandCursor 47d735be2c Messaging: Meet Play memory/R8 rules, ship Q icon, and polish home chrome
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 23:44:47 +09:00
privaongandCursor 74fb80f783 Messaging: Add scheduled send, location share, app lock, and settings polish
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 08:13:21 +09:00
privaongandCursor 42148360e0 Messaging: Add home categories and shared modal/input chrome
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 02:41:59 +09:00
privaongandCursor a32250fe34 Messaging: Looply search hub with frequent contacts and nav inset
Redesign Search around frequent contacts, category counts, and recent chips, and keep the Messages/Automation pill on the left guideline without moving compose.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 05:36:08 +09:00
privaongandCursor 88529c3634 Messaging: Looply empty states, popovers, and conversation polish
Keep English-only UI, align home menus to the screen guideline, and
match message-action icon weight across conversation and list popovers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 05:03:33 +09:00
privaongandCursor 8e5d88183c Messaging: Looply conversation profile, search, and in-app gallery
Replace People & Options with the home profile and search screens, and attach media from an in-conversation gallery sheet that avoids the system picker flash.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-25 01:23:10 +09:00
930 changed files with 70714 additions and 39191 deletions
+5
View File
@@ -2,6 +2,10 @@
.cursor/
.gradle/
/local.properties
/keystore.properties
/keystore/
*.jks
*.keystore
/.idea/
.DS_Store
/build/
@@ -11,6 +15,7 @@
app/build/
modules/*/build/
modules/*/.git/
modules/*/sample/
*.apk
*.ap_
*.dex
+5 -3
View File
@@ -87,11 +87,13 @@ android_app {
"1.0.001",
"--version-code",
"10001040",
"--rename-manifest-package",
"com.bnkode.notie",
],
required: [
"libframesequence",
"libgiftranscode",
"com.android.messaging.allowlist",
"com.bnkode.notie.allowlist",
],
optimize: {
obfuscate: true,
@@ -105,9 +107,9 @@ android_app {
}
prebuilt_etc {
name: "com.android.messaging.allowlist",
name: "com.bnkode.notie.allowlist",
product_specific: true,
sub_dir: "sysconfig",
src: "com.android.messaging.allowlist.xml",
src: "com.bnkode.notie.allowlist.xml",
filename_from_src: true,
}
+23 -2
View File
@@ -2,17 +2,24 @@ plugins {
id 'com.android.application'
}
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('keystore.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace 'com.android.messaging'
compileSdk 35
defaultConfig {
applicationId 'com.android.messaging'
applicationId 'com.bnkode.notie'
minSdk 33
targetSdk 34
versionCode 10001040
versionName '1.0.001'
vectorDrawables.useSupportLibrary true
resourceConfigurations += ['en', 'ko']
}
compileOptions {
@@ -20,9 +27,22 @@ android {
targetCompatibility JavaVersion.VERSION_17
}
signingConfigs {
release {
if (keystorePropertiesFile.exists()) {
storeFile rootProject.file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
rootProject.file('proguard-release.flags'),
rootProject.file('proguard.flags')
@@ -59,6 +79,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'
@@ -85,5 +106,5 @@ tasks.register('launchMessaging', Exec) {
description = "Launch Messaging on ${messagingAvdSerial}"
dependsOn 'installMessaging'
commandLine adbExecutable.absolutePath, '-s', messagingAvdSerial, 'shell', 'am', 'start',
'-n', 'com.android.messaging/.ui.conversationlist.ConversationListActivity'
'-n', 'com.bnkode.notie/com.android.messaging.ui.conversationlist.ConversationListActivity'
}
+148 -24
View File
@@ -44,6 +44,12 @@
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- Optional features -->
<uses-feature
@@ -61,9 +67,15 @@
<uses-feature
android:name="android.hardware.screen.portrait"
android:required="false" />
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<uses-feature
android:name="android.hardware.location"
android:required="false" />
<uses-feature
android:name="android.hardware.location.gps"
android:required="false" />
<queries>
<intent>
@@ -79,7 +91,9 @@
android:allowBackup="false"
android:appCategory="social"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true"
android:theme="@style/BugleTheme">
@@ -132,10 +146,18 @@
<activity
android:name=".ui.conversationlist.ArchivedConversationListActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/archived_activity_title"
android:label="@string/home_archived_title"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:screenOrientation="user"
android:theme="@style/BugleTheme.ArchivedConversationListActivity" />
android:theme="@style/Home.SubScreen" />
<activity
android:name=".ui.conversationlist.RecentlyDeletedConversationListActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_recently_deleted_title"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:screenOrientation="user"
android:theme="@style/Home.SubScreen" />
<!-- Displays the contents of a single conversation -->
<activity
@@ -155,6 +177,81 @@
android:screenOrientation="user"
android:theme="@style/BugleTheme" />
<activity
android:name=".ui.home.HomeSpamActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_spam_title"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:screenOrientation="user"
android:theme="@style/Home.SubScreen" />
<activity
android:name=".ui.home.HomeAutomationActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_automation_title"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:screenOrientation="user"
android:theme="@style/Home.SubScreen" />
<activity
android:name=".ui.home.HomeAutomationCreateActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_automation_create_title"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:theme="@style/Home.BottomSheet"
android:windowSoftInputMode="adjustResize|stateHidden" />
<activity
android:name=".ui.home.HomeSearchActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_search_field_hint"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:screenOrientation="user"
android:theme="@style/Home.SubScreen"
android:windowSoftInputMode="adjustResize|stateVisible" />
<activity
android:name=".ui.home.HomeComposeActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_compose_title"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:theme="@style/Home.BottomSheet"
android:windowSoftInputMode="adjustResize|stateHidden" />
<activity
android:name=".ui.home.HomeGroupNameActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_group_name_title"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:theme="@style/Home.BottomSheet"
android:windowSoftInputMode="adjustResize|stateHidden" />
<activity
android:name=".ui.home.HomeLocationActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_location_title"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:screenOrientation="user"
android:theme="@style/Home.SubScreen" />
<activity
android:name=".ui.home.HomeSecuritySettingsActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_security_title"
android:parentActivityName="com.android.messaging.ui.appsettings.ApplicationSettingsActivity"
android:screenOrientation="user"
android:theme="@style/Home.SubScreen" />
<activity
android:name=".ui.home.HomeAppLockActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:excludeFromRecents="true"
android:label="@string/home_settings_app_lock"
android:launchMode="singleTop"
android:screenOrientation="user"
android:theme="@style/Home.SubScreen"
android:windowSoftInputMode="adjustResize" />
<!-- Full-screen photo viewer -->
<activity
android:name=".ui.photoviewer.BuglePhotoViewActivity"
@@ -171,7 +268,7 @@
android:label="@string/settings_activity_title"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:screenOrientation="user"
android:theme="@style/BugleTheme.SettingsActivity" />
android:theme="@style/Home.SubScreen" />
<activity
android:name=".ui.appsettings.PerSubscriptionSettingsActivity"
@@ -179,7 +276,7 @@
android:label="@string/advanced_settings_activity_title"
android:parentActivityName="com.android.messaging.ui.appsettings.SettingsActivity"
android:screenOrientation="user"
android:theme="@style/BugleTheme.SettingsActivity" />
android:theme="@style/Home.SubScreen" />
<activity
android:name=".ui.appsettings.ApplicationSettingsActivity"
@@ -188,7 +285,7 @@
android:label="@string/general_settings_activity_title"
android:parentActivityName="com.android.messaging.ui.appsettings.SettingsActivity"
android:screenOrientation="user"
android:theme="@style/BugleTheme.SettingsActivity">
android:theme="@style/Home.SubScreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -223,22 +320,40 @@
</intent-filter>
</activity>
<!-- People & Options -->
<!-- Conversation profile -->
<activity
android:name=".ui.conversationsettings.PeopleAndOptionsActivity"
android:name=".ui.home.HomeProfileActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/people_and_options_activity_title"
android:label="@string/home_profile_title"
android:parentActivityName="com.android.messaging.ui.conversation.ConversationActivity"
android:screenOrientation="user"
android:theme="@style/BugleTheme" />
android:theme="@style/Home.SubScreen"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<!-- Conversation search -->
<activity
android:name=".ui.home.HomeConversationSearchActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_conversation_search_hint"
android:parentActivityName="com.android.messaging.ui.conversation.ConversationActivity"
android:screenOrientation="user"
android:theme="@style/Home.SubScreen"
android:windowSoftInputMode="adjustResize|stateVisible" />
<activity
android:name=".ui.home.HomeMessageDetailsActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_message_details_title"
android:parentActivityName="com.android.messaging.ui.conversation.ConversationActivity"
android:screenOrientation="user"
android:theme="@style/Home.SubScreen" />
<!-- License -->
<activity
android:name=".ui.LicenseActivity"
android:exported="true"
android:label="@string/menu_license"
android:theme="@android:style/Theme.Holo.Light.Dialog">
</activity>
android:label="@string/home_settings_licenses"
android:theme="@style/Home.SubScreen" />
<!-- Message Forwarding -->
<activity
@@ -259,7 +374,8 @@
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="false"
android:screenOrientation="user"
android:theme="@style/Invisible">
android:theme="@style/Home.TransparentSheetHost"
android:windowSoftInputMode="adjustResize">
</activity>
<!-- VCard details -->
@@ -282,20 +398,20 @@
<provider
android:name=".datamodel.MessagingContentProvider"
android:authorities="com.android.messaging.datamodel.MessagingContentProvider"
android:authorities="com.bnkode.notie.datamodel.MessagingContentProvider"
android:exported="false"
android:label="@string/app_name">
</provider>
<provider
android:name=".datamodel.MmsFileProvider"
android:authorities="com.android.messaging.datamodel.MmsFileProvider"
android:authorities="com.bnkode.notie.datamodel.MmsFileProvider"
android:exported="false"
android:grantUriPermissions="true" />
<provider
android:name=".datamodel.MediaScratchFileProvider"
android:authorities="com.android.messaging.datamodel.MediaScratchFileProvider"
android:authorities="com.bnkode.notie.datamodel.MediaScratchFileProvider"
android:exported="false"
android:grantUriPermissions="true" />
@@ -351,18 +467,18 @@
android:name=".receiver.SendStatusReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.android.messaging.receiver.SendStatusReceiver.MESSAGE_SENT" />
<action android:name="com.bnkode.notie.receiver.SendStatusReceiver.MESSAGE_SENT" />
<data android:scheme="content" />
</intent-filter>
<intent-filter>
<action android:name="com.android.messaging.receiver.SendStatusReceiver.MESSAGE_DELIVERED" />
<action android:name="com.bnkode.notie.receiver.SendStatusReceiver.MESSAGE_DELIVERED" />
</intent-filter>
<intent-filter>
<action android:name="com.android.messaging.receiver.SendStatusReceiver.MMS_SENT" />
<action android:name="com.bnkode.notie.receiver.SendStatusReceiver.MMS_SENT" />
<data android:scheme="content" />
</intent-filter>
<intent-filter>
<action android:name="com.android.messaging.receiver.SendStatusReceiver.MMS_DOWNLOADED" />
<action android:name="com.bnkode.notie.receiver.SendStatusReceiver.MMS_DOWNLOADED" />
<data android:scheme="content" />
</intent-filter>
</receiver>
@@ -401,6 +517,14 @@
</intent-filter>
</receiver>
<receiver
android:name=".receiver.ScheduledMessageReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.bnkode.notie.SEND_SCHEDULED_MESSAGE" />
</intent-filter>
</receiver>
<!-- Broadcast receiver that will be notified to reset notifications -->
<receiver
android:name=".receiver.NotificationReceiver"
@@ -412,7 +536,7 @@
android:name=".datamodel.action.ActionServiceImpl$PendingActionReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.android.messaging.datamodel.PENDING_ACTION" />
<action android:name="com.bnkode.notie.datamodel.PENDING_ACTION" />
</intent-filter>
</receiver>
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<style>
html, body, #map { height: 100%; margin: 0; padding: 0; background: #f4f1fb; }
.leaflet-control-attribution { font-size: 10px; }
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
var map = L.map('map', { zoomControl: false }).setView([37.5665, 126.9780], 16);
L.control.zoom({ position: 'bottomright' }).addTo(map);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap'
}).addTo(map);
function notify() {
var c = map.getCenter();
if (window.LocationBridge) {
LocationBridge.onMapMoved(c.lat, c.lng);
}
}
map.on('moveend', notify);
function setCenter(lat, lng) {
map.setView([lat, lng], Math.max(map.getZoom(), 16));
}
</script>
</body>
</html>
@@ -36,6 +36,8 @@ 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;
import com.android.messaging.util.PhoneUtils;
@@ -57,6 +59,8 @@ public class BugleApplication extends Application implements UncaughtExceptionHa
super.onCreate();
FactoryImpl.register(getApplicationContext(), this);
HomeAppLock.install(this);
HomeToast.install(this);
sSystemUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler(this);
@@ -124,6 +128,18 @@ public class BugleApplication extends Application implements UncaughtExceptionHa
Trace.endSection();
}
@Override
public void onTrimMemory(final int level) {
super.onTrimMemory(level);
if (level < TRIM_MEMORY_UI_HIDDEN) {
return;
}
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "BugleApplication.onTrimMemory " + level);
}
reclaimCachedBitmaps();
}
@Override
public void onLowMemory() {
super.onLowMemory();
@@ -131,7 +147,14 @@ public class BugleApplication extends Application implements UncaughtExceptionHa
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "BugleApplication.onLowMemory");
}
Factory.get().reclaimMemory();
reclaimCachedBitmaps();
}
private static void reclaimCachedBitmaps() {
final Factory factory = Factory.get();
if (factory != null) {
factory.reclaimMemory();
}
}
@Override
@@ -533,6 +533,7 @@ public class BugleDatabaseOperations {
// the sender is blocked, or we have been told to keep it archived.
if (!keepArchived) {
values.put(ConversationColumns.ARCHIVE_STATUS, 0);
values.put(ConversationColumns.TRASH_TIMESTAMP, 0);
}
final MessageData message = readMessage(dbWrapper, messageId);
@@ -575,6 +576,20 @@ public class BugleDatabaseOperations {
updateConversationRowIfExists(dbWrapper, conversationId, values);
}
@DoesNotRunOnMainThread
public static void updateConversationTrashTimestampInTransaction(
final DatabaseWrapper dbWrapper, final String conversationId,
final long trashTimestamp) {
Assert.isNotMainThread();
Assert.isTrue(dbWrapper.getDatabase().inTransaction());
final ContentValues values = new ContentValues();
values.put(ConversationColumns.TRASH_TIMESTAMP, trashTimestamp);
if (trashTimestamp > 0) {
values.put(ConversationColumns.ARCHIVE_STATUS, 0);
}
updateConversationRowIfExists(dbWrapper, conversationId, values);
}
static void addSnippetTextAndPreviewToContentValues(final MessageData message,
final boolean showDraft, final ContentValues values) {
values.put(ConversationColumns.SHOW_DRAFT, showDraft ? 1 : 0);
@@ -685,7 +700,16 @@ public class BugleDatabaseOperations {
sortTimestamp = cursor.getLong(1);
}
}
if (sortTimestamp == 0) {
try (Cursor existing = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE,
new String[]{ ConversationColumns.SORT_TIMESTAMP },
ConversationColumns._ID + "=?",
new String[]{ conversationId }, null, null, null)) {
if (existing.moveToFirst()) {
sortTimestamp = existing.getLong(0);
}
}
}
final ContentValues values = new ContentValues();
if (draftMessage == null || !draftMessage.hasContent()) {
@@ -66,6 +66,7 @@ import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
import com.android.messaging.sms.MmsSmsUtils;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.home.MutedConversationStore;
import com.android.messaging.util.Assert;
import com.android.messaging.util.AvatarUriUtil;
import com.android.messaging.util.BugleGservicesKeys;
@@ -354,6 +355,9 @@ public class BugleNotifications {
final Context context = Factory.get().getApplicationContext();
// TODO: Need to fix this for multi conversation notifications to rate limit dings.
final String conversationId = state.mConversationIds.first();
if (MutedConversationStore.isMuted(context, conversationId)) {
return;
}
String id = NotificationsUtil.DEFAULT_CHANNEL_ID;
if (NotificationsUtil.getNotificationChannel(context, conversationId) != null) {
id = conversationId;
@@ -535,7 +539,10 @@ public class BugleNotifications {
private static void createMessageNotification(final boolean silent,
final String conversationId) {
final NotificationState state = MessageNotificationState.getNotificationState();
final boolean softSound = DataModel.get().isNewMessageObservable(conversationId);
final boolean muted = MutedConversationStore.isMuted(
Factory.get().getApplicationContext(), conversationId);
final boolean softSound = !muted
&& DataModel.get().isNewMessageObservable(conversationId);
if (state == null) {
cancel(PendingIntentConstants.SMS_NOTIFICATION_ID);
if (softSound && !TextUtils.isEmpty(conversationId)) {
@@ -718,7 +725,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
@@ -783,28 +792,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,
@@ -28,6 +28,8 @@ import com.android.messaging.datamodel.action.ActionService;
import com.android.messaging.datamodel.action.BackgroundWorker;
import com.android.messaging.datamodel.data.BlockedParticipantsData;
import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener;
import com.android.messaging.datamodel.data.ConversationSearchData;
import com.android.messaging.datamodel.data.ConversationSearchData.ConversationSearchDataListener;
import com.android.messaging.datamodel.data.ContactListItemData;
import com.android.messaging.datamodel.data.ContactPickerData;
import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener;
@@ -42,9 +44,8 @@ import com.android.messaging.datamodel.data.MediaPickerData;
import com.android.messaging.datamodel.data.MessagePartData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.datamodel.data.ParticipantListItemData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener;
import com.android.messaging.datamodel.data.PeopleOptionsItemData;
import com.android.messaging.datamodel.data.ConversationProfileData;
import com.android.messaging.datamodel.data.ConversationProfileData.ConversationProfileDataListener;
import com.android.messaging.datamodel.data.SettingsData;
import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener;
import com.android.messaging.datamodel.data.SubscriptionListData;
@@ -67,8 +68,14 @@ public abstract class DataModel {
get().getActionService().scheduleAction(action, code, delayMs);
}
public ConversationListData createConversationListData(final Context context,
final ConversationListDataListener listener, final boolean archivedMode) {
return createConversationListData(context, listener, archivedMode, false);
}
public abstract ConversationListData createConversationListData(final Context context,
final ConversationListDataListener listener, final boolean archivedMode);
final ConversationListDataListener listener, final boolean archivedMode,
final boolean trashMode);
public abstract ConversationData createConversationData(final Context context,
final ConversationDataListener listener, final String conversationId);
@@ -83,10 +90,9 @@ public abstract class DataModel {
public abstract LaunchConversationData createLaunchConversationData(
LaunchConversationDataListener listener);
public abstract PeopleOptionsItemData createPeopleOptionsItemData(final Context context);
public abstract PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId,
final Context context, final PeopleAndOptionsDataListener listener);
public abstract ConversationProfileData createConversationProfileData(
final String conversationId, final Context context,
final ConversationProfileDataListener listener);
public abstract VCardContactItemData createVCardContactItemData(final Context context,
final MessagePartData data);
@@ -100,6 +106,9 @@ public abstract class DataModel {
public abstract BlockedParticipantsData createBlockedParticipantsData(Context context,
BlockedParticipantsDataListener listener);
public abstract ConversationSearchData createConversationSearchData(Context context,
String conversationId, ConversationSearchDataListener listener);
public abstract SubscriptionListData createSubscriptonListData(Context context);
public abstract SettingsData createSettingsData(Context context, SettingsDataListener listener);
@@ -26,8 +26,11 @@ import com.android.messaging.datamodel.action.ActionService;
import com.android.messaging.datamodel.action.BackgroundWorker;
import com.android.messaging.datamodel.action.FixupMessageStatusOnStartupAction;
import com.android.messaging.datamodel.action.ProcessPendingMessagesAction;
import com.android.messaging.datamodel.action.PurgeExpiredTrashAction;
import com.android.messaging.datamodel.data.BlockedParticipantsData;
import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener;
import com.android.messaging.datamodel.data.ConversationSearchData;
import com.android.messaging.datamodel.data.ConversationSearchData.ConversationSearchDataListener;
import com.android.messaging.datamodel.data.ContactListItemData;
import com.android.messaging.datamodel.data.ContactPickerData;
import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener;
@@ -42,9 +45,8 @@ import com.android.messaging.datamodel.data.MediaPickerData;
import com.android.messaging.datamodel.data.MessagePartData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.datamodel.data.ParticipantListItemData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener;
import com.android.messaging.datamodel.data.PeopleOptionsItemData;
import com.android.messaging.datamodel.data.ConversationProfileData;
import com.android.messaging.datamodel.data.ConversationProfileData.ConversationProfileDataListener;
import com.android.messaging.datamodel.data.SettingsData;
import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener;
import com.android.messaging.datamodel.data.SubscriptionListData;
@@ -80,8 +82,9 @@ public class DataModelImpl extends DataModel {
@Override
public ConversationListData createConversationListData(final Context context,
final ConversationListDataListener listener, final boolean archivedMode) {
return new ConversationListData(context, listener, archivedMode);
final ConversationListDataListener listener, final boolean archivedMode,
final boolean trashMode) {
return new ConversationListData(context, listener, archivedMode, trashMode);
}
@Override
@@ -107,6 +110,12 @@ public class DataModelImpl extends DataModel {
return new BlockedParticipantsData(context, listener);
}
@Override
public ConversationSearchData createConversationSearchData(final Context context,
final String conversationId, final ConversationSearchDataListener listener) {
return new ConversationSearchData(context, conversationId, listener);
}
@Override
public MediaPickerData createMediaPickerData(final Context context) {
return new MediaPickerData(context);
@@ -119,14 +128,9 @@ public class DataModelImpl extends DataModel {
}
@Override
public PeopleOptionsItemData createPeopleOptionsItemData(final Context context) {
return new PeopleOptionsItemData(context);
}
@Override
public PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId,
final Context context, final PeopleAndOptionsDataListener listener) {
return new PeopleAndOptionsData(conversationId, context, listener);
public ConversationProfileData createConversationProfileData(final String conversationId,
final Context context, final ConversationProfileDataListener listener) {
return new ConversationProfileData(conversationId, context, listener);
}
@Override
@@ -209,6 +213,7 @@ public class DataModelImpl extends DataModel {
createConnectivityUtilForEachActiveSubscription();
FixupMessageStatusOnStartupAction.fixupMessageStatus();
PurgeExpiredTrashAction.purgeExpired();
ProcessPendingMessagesAction.processFirstPendingMessage();
SyncManager.immediateSync();
@@ -101,6 +101,9 @@ public class DatabaseHelper extends SQLiteOpenHelper {
/* If this conversation is archived */
public static final String ARCHIVE_STATUS = "archive_status";
/* When the conversation was moved to Trash; 0 if not in Trash */
public static final String TRASH_TIMESTAMP = "trash_timestamp";
/* Timestamp for sorting purposes */
public static final String SORT_TIMESTAMP = "sort_timestamp";
@@ -159,6 +162,7 @@ public class DatabaseHelper extends SQLiteOpenHelper {
+ ConversationColumns.DRAFT_PREVIEW_URI + " TEXT, "
+ ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE + " TEXT, "
+ ConversationColumns.ARCHIVE_STATUS + " INT DEFAULT(0), "
+ ConversationColumns.TRASH_TIMESTAMP + " INT DEFAULT(0), "
+ ConversationColumns.SORT_TIMESTAMP + " INT DEFAULT(0), "
+ ConversationColumns.LAST_READ_TIMESTAMP + " INT DEFAULT(0), "
+ ConversationColumns.ICON + " TEXT, "
@@ -183,6 +187,11 @@ public class DatabaseHelper extends SQLiteOpenHelper {
+ " ON " + CONVERSATIONS_TABLE
+ "(" + ConversationColumns.ARCHIVE_STATUS + ")";
private static final String CONVERSATIONS_TABLE_TRASH_TIMESTAMP_INDEX_SQL =
"CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.TRASH_TIMESTAMP
+ " ON " + CONVERSATIONS_TABLE
+ "(" + ConversationColumns.TRASH_TIMESTAMP + ")";
private static final String CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL =
"CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.SORT_TIMESTAMP
+ " ON " + CONVERSATIONS_TABLE
@@ -525,6 +534,7 @@ public class DatabaseHelper extends SQLiteOpenHelper {
private static final String[] CREATE_INDEX_SQLS = new String[] {
CONVERSATIONS_TABLE_SMS_THREAD_ID_INDEX_SQL,
CONVERSATIONS_TABLE_ARCHIVE_STATUS_INDEX_SQL,
CONVERSATIONS_TABLE_TRASH_TIMESTAMP_INDEX_SQL,
CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL,
MESSAGES_TABLE_SORT_INDEX_SQL,
MESSAGES_TABLE_STATUS_SEEN_INDEX_SQL,
@@ -48,6 +48,9 @@ public class DatabaseUpgradeHelper {
if (currentVersion < 2) {
currentVersion = upgradeToVersion2(db);
}
if (currentVersion < 3) {
currentVersion = upgradeToVersion3(db);
}
// Rebuild all the views
final Context context = Factory.get().getApplicationContext();
DatabaseHelper.dropAllViews(db);
@@ -63,6 +66,17 @@ public class DatabaseUpgradeHelper {
return 2;
}
private int upgradeToVersion3(final SQLiteDatabase db) {
db.execSQL("ALTER TABLE " + DatabaseHelper.CONVERSATIONS_TABLE + " ADD COLUMN " +
DatabaseHelper.ConversationColumns.TRASH_TIMESTAMP + " INT DEFAULT(0)");
db.execSQL("CREATE INDEX IF NOT EXISTS index_" + DatabaseHelper.CONVERSATIONS_TABLE
+ "_" + DatabaseHelper.ConversationColumns.TRASH_TIMESTAMP
+ " ON " + DatabaseHelper.CONVERSATIONS_TABLE
+ "(" + DatabaseHelper.ConversationColumns.TRASH_TIMESTAMP + ")");
LogUtil.i(TAG, "Upgraded database to version 3");
return 3;
}
/**
* Checks db version correctness at the end of each milestone release. If target database
* version lies beyond the version range that the current release may handle, we snap the
@@ -103,7 +103,7 @@ public abstract class FileProvider extends ContentProvider {
final String fileId = uri.getPath();
if (isValidFileId(fileId)) {
final File file = getFile(fileId, getExtensionFromUri(uri));
return file.delete() ? 1 : 0;
return file != null && file.delete() ? 1 : 0;
}
return 0;
}
@@ -114,6 +114,9 @@ public abstract class FileProvider extends ContentProvider {
final String fileId = uri.getPath();
if (isValidFileId(fileId)) {
final File file = getFile(fileId, getExtensionFromUri(uri));
if (file == null) {
throw new FileNotFoundException(String.valueOf(uri));
}
final int mode =
(TextUtils.equals(fileMode, "r") ? ParcelFileDescriptor.MODE_READ_ONLY :
ParcelFileDescriptor.MODE_WRITE_ONLY | ParcelFileDescriptor.MODE_TRUNCATE);
@@ -120,6 +120,8 @@ public class FrequentContactsCursorBuilder {
mAllContactsCursor.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE);
row[ContactUtil.INDEX_PHONE_EMAIL_LABEL] =
mAllContactsCursor.getString(ContactUtil.INDEX_PHONE_EMAIL_LABEL);
row[ContactUtil.INDEX_SORT_KEY] =
mAllContactsCursor.getString(ContactUtil.INDEX_SORT_KEY);
boolean numberAlreadyAdded = false;
for (Object[] oldRow : rows) {
@@ -46,7 +46,7 @@ public class MediaScratchFileProvider extends FileProvider {
private static final SimpleArrayMap<Uri, String> sUriToDisplayNameMap = new SimpleArrayMap<>();
public static final String AUTHORITY =
"com.android.messaging.datamodel.MediaScratchFileProvider";
"com.bnkode.notie.datamodel.MediaScratchFileProvider";
private static final String MEDIA_SCRATCH_SPACE_DIR = "mediascratchspace";
public static boolean isMediaScratchSpaceUri(final Uri uri) {
@@ -68,8 +68,8 @@ public class MediaScratchFileProvider extends FileProvider {
public static Uri buildMediaScratchSpaceUri(final String extension) {
final Uri uri = FileProvider.buildFileUri(AUTHORITY, extension);
final File file = getFileWithExtension(uri.getPath(), extension);
if (!ensureFileExists(file)) {
LogUtil.e(TAG, "Failed to create temp file " + file.getAbsolutePath());
if (file == null || !ensureFileExists(file)) {
LogUtil.e(TAG, "Failed to create temp file for " + uri);
}
return uri;
}
@@ -90,8 +90,12 @@ public class MediaScratchFileProvider extends FileProvider {
private static File getFileWithExtension(final String path, final String extension) {
final Context context = Factory.get().getApplicationContext();
String name = path;
if (name != null && name.startsWith("/")) {
name = name.substring(1);
}
final File filePath = new File(getDirectory(context),
TextUtils.isEmpty(extension) ? path : path + "." + extension);
TextUtils.isEmpty(extension) ? name : name + "." + extension);
try {
if (!filePath.getCanonicalPath()
@@ -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;
@@ -50,6 +51,7 @@ import com.android.messaging.datamodel.data.MessagePartData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.home.MutedConversationStore;
import com.android.messaging.util.Assert;
import com.android.messaging.util.AvatarUriUtil;
import com.android.messaging.util.BugleGservicesKeys;
@@ -494,85 +496,62 @@ public abstract class MessageNotificationState extends NotificationState {
@Override
protected NotificationCompat.Style build(final Builder builder) {
builder.setContentTitle(mTitle)
.setTicker(getTicker());
.setTicker(getTicker())
.setContentText(mContent);
NotificationCompat.Style notifStyle;
final Context context = Factory.get().getApplicationContext();
final ConversationLineInfo convInfo = mConvList.mConvInfos.get(0);
final List<NotificationLineInfo> lineInfos = convInfo.mLineInfos;
final int messageCount = lineInfos.size();
// At this point, all the messages come from the same conversation. We need to load
// the sender's avatar and then finish building the notification on a callback.
builder.setContentText(mContent); // for collapsed state
if (messageCount == 1) {
final boolean shouldShowImage = ContentType.isImageType(mAttachmentType)
|| (ContentType.isVideoType(mAttachmentType));
if (mAttachmentUri != null && shouldShowImage) {
// Show "Picture" as the content
final MessageLineInfo messageLineInfo = (MessageLineInfo) lineInfos.get(0);
String authorFirstName = messageLineInfo.mAuthorFirstName;
// For the collapsed state, just show "picture" unless this is a
// group conversation. If it's a group, show the sender name and
// "picture".
final CharSequence tickerTag =
BugleNotifications.formatAttachmentTag(authorFirstName,
mAttachmentType);
// For 1:1 notifications don't show first name in the notification, but
// do show it in the ticker text
CharSequence pictureTag = tickerTag;
if (!convInfo.mIsGroup) {
authorFirstName = null;
pictureTag = BugleNotifications.formatAttachmentTag(authorFirstName,
mAttachmentType);
}
builder.setContentText(pictureTag);
builder.setTicker(tickerTag);
notifStyle = new NotificationCompat.BigPictureStyle(builder)
.setSummaryText(BugleNotifications.formatInboxMessage(
authorFirstName,
null, null,
null)); // expanded state, just show sender
} else {
notifStyle = new NotificationCompat.BigTextStyle(builder)
.bigText(mContent);
final Person user = new Person.Builder()
.setName(context.getString(R.string.home_notification_you))
.build();
final NotificationCompat.MessagingStyle style =
new NotificationCompat.MessagingStyle(user);
if (convInfo.mIsGroup) {
style.setGroupConversation(true);
if (!TextUtils.isEmpty(convInfo.mGroupConversationName)) {
style.setConversationTitle(convInfo.mGroupConversationName);
}
} else {
// We've got multiple messages for the same sender.
// Starting with the oldest new message, display the full text of each message.
// Begin a line for each subsequent message.
final SpannableStringBuilder buf = new SpannableStringBuilder();
for (int i = lineInfos.size() - 1; i >= 0; --i) {
final NotificationLineInfo info = lineInfos.get(i);
final MessageLineInfo messageLineInfo = (MessageLineInfo) info;
mAttachmentUri = messageLineInfo.mAttachmentUri;
mAttachmentType = messageLineInfo.mAttachmentType;
CharSequence text = messageLineInfo.mText;
if (!TextUtils.isEmpty(text) || mAttachmentUri != null) {
if (convInfo.mIsGroup) {
// append the bold name to the front of the message
text = BugleNotifications.buildSpaceSeparatedMessage(
messageLineInfo.mAuthorFullName, text, mAttachmentUri,
mAttachmentType);
} else {
text = BugleNotifications.buildSpaceSeparatedMessage(
null, text, mAttachmentUri, mAttachmentType);
}
buf.append(text);
if (i > 0) {
buf.append('\n');
}
}
}
// Show a single notification -- big style with the text of all the messages
notifStyle = new NotificationCompat.BigTextStyle(builder).bigText(buf);
}
// MessagingStyle wants chronological order (oldest first). Latest is at index 0.
for (int i = lineInfos.size() - 1; i >= 0; --i) {
final NotificationLineInfo info = lineInfos.get(i);
if (!(info instanceof MessageLineInfo)) {
continue;
}
final MessageLineInfo messageLineInfo = (MessageLineInfo) info;
CharSequence text = messageLineInfo.mText;
if (TextUtils.isEmpty(text) && messageLineInfo.mAttachmentUri != null) {
text = BugleNotifications.formatAttachmentTag(
convInfo.mIsGroup ? messageLineInfo.mAuthorFirstName : null,
messageLineInfo.mAttachmentType);
}
if (TextUtils.isEmpty(text)) {
continue;
}
final Person.Builder personBuilder = new Person.Builder()
.setName(!TextUtils.isEmpty(messageLineInfo.mAuthorFullName)
? messageLineInfo.mAuthorFullName : mTitle);
if (convInfo.mContactUri != null) {
personBuilder.setUri(convInfo.mContactUri.toString());
}
final long timestamp = Math.max(0, convInfo.mReceivedTimestamp - i);
final NotificationCompat.MessagingStyle.Message message =
new NotificationCompat.MessagingStyle.Message(
text, timestamp, personBuilder.build());
if (messageLineInfo.mAttachmentUri != null
&& !TextUtils.isEmpty(messageLineInfo.mAttachmentType)
&& (ContentType.isImageType(messageLineInfo.mAttachmentType)
|| ContentType.isVideoType(messageLineInfo.mAttachmentType))) {
message.setData(messageLineInfo.mAttachmentType,
messageLineInfo.mAttachmentUri);
}
style.addMessage(message);
}
builder.setWhen(convInfo.mReceivedTimestamp);
return notifStyle;
return style;
}
}
@@ -824,6 +803,9 @@ public abstract class MessageNotificationState extends NotificationState {
final String messageText = convMessageData.getText();
final String convId = convMessageData.getConversationId();
if (MutedConversationStore.isMuted(context, convId)) {
continue;
}
final String messageId = convMessageData.getMessageId();
CharSequence text = messageText;
@@ -840,6 +822,9 @@ public abstract class MessageNotificationState extends NotificationState {
if (currConvInfo == null) {
final ConversationListItemData convData =
ConversationListItemData.getExistingConversation(db, convId);
if (convData == null || convData.getIsTrashed()) {
continue;
}
final int subId = BugleDatabaseOperations.getSelfSubscriptionId(db,
convData.getSelfId());
groupConversationName = convData.getName();
@@ -53,7 +53,7 @@ public class MessagingContentProvider extends ContentProvider {
private static final String TAG = LogUtil.BUGLE_TAG;
public static final String AUTHORITY =
"com.android.messaging.datamodel.MessagingContentProvider";
"com.bnkode.notie.datamodel.MessagingContentProvider";
private static final String CONTENT_AUTHORITY = "content://" + AUTHORITY + '/';
// Conversations query
@@ -70,6 +70,11 @@ public class MessagingContentProvider extends ContentProvider {
public static final Uri CONVERSATION_MESSAGES_URI = Uri.parse(CONTENT_AUTHORITY +
MESSAGES_QUERY + "/conversation");
public static final Uri CONVERSATION_MESSAGES_SEARCH_URI = Uri.parse(CONTENT_AUTHORITY +
MESSAGES_QUERY + "/search");
private static final String SEARCH_QUERY_PARAMETER = "q";
// Conversation participants query
private static final String PARTICIPANTS_QUERY = "participants";
@@ -146,6 +151,7 @@ public class MessagingContentProvider extends ContentProvider {
private static final int CONVERSATION_QUERY_CODE = 20;
private static final int CONVERSATION_MESSAGES_QUERY_CODE = 30;
private static final int CONVERSATION_MESSAGES_SEARCH_QUERY_CODE = 35;
private static final int CONVERSATION_PARTICIPANTS_QUERY_CODE = 40;
private static final int CONVERSATION_IMAGES_QUERY_CODE = 50;
private static final int DRAFT_IMAGES_QUERY_CODE = 60;
@@ -158,6 +164,8 @@ public class MessagingContentProvider extends ContentProvider {
sURIMatcher.addURI(AUTHORITY, CONVERSATIONS_QUERY + "/*", CONVERSATION_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, MESSAGES_QUERY + "/conversation/*",
CONVERSATION_MESSAGES_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, MESSAGES_QUERY + "/search/*",
CONVERSATION_MESSAGES_SEARCH_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY + "/conversation/*",
CONVERSATION_PARTICIPANTS_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY, PARTICIPANTS_QUERY_CODE);
@@ -176,6 +184,17 @@ public class MessagingContentProvider extends ContentProvider {
return builder.build();
}
/**
* Build a uri for the messages of a conversation whose text matches {@code searchText}.
*/
public static Uri buildConversationMessagesSearchUri(final String conversationId,
final String searchText) {
return CONVERSATION_MESSAGES_SEARCH_URI.buildUpon()
.appendPath(conversationId)
.appendQueryParameter(SEARCH_QUERY_PARAMETER, searchText)
.build();
}
public static void notifyMessagesChanged(final String conversationId) {
final Uri uri = buildConversationMessagesUri(conversationId);
final Context context = Factory.get().getApplicationContext();
@@ -319,6 +338,19 @@ public class MessagingContentProvider extends ContentProvider {
} else {
throw new IllegalArgumentException("Malformed URI " + uri);
}
case CONVERSATION_MESSAGES_SEARCH_QUERY_CODE:
if (uri.getPathSegments().size() == 3) {
// Like the query above this is a raw query, so selection/sorting is invalid.
if (selection == null && selectionArgs == null && sortOrder == null) {
return queryConversationMessagesSearch(uri.getPathSegments().get(2),
uri.getQueryParameter(SEARCH_QUERY_PARAMETER));
} else {
throw new IllegalArgumentException(
"Cannot set selection or sort order with this query");
}
} else {
throw new IllegalArgumentException("Malformed URI " + uri);
}
case CONVERSATION_IMAGES_QUERY_CODE:
queryBuilder.setTables(ConversationImagePartsView.getViewName());
if (uri.getPathSegments().size() == 2) {
@@ -366,6 +398,24 @@ public class MessagingContentProvider extends ContentProvider {
return cursor;
}
private Cursor queryConversationMessagesSearch(final String conversationId,
final String searchText) {
final String[] queryArgs = { conversationId, "%" + escapeLikePattern(searchText) + "%" };
final Cursor cursor = getDatabaseWrapper().rawQuery(
ConversationMessageData.getConversationMessagesSearchQuerySql(), queryArgs);
// Follow the plain message uri so that incoming messages refresh the results as well.
cursor.setNotificationUri(getContext().getContentResolver(),
buildConversationMessagesUri(conversationId));
return cursor;
}
private static String escapeLikePattern(final String text) {
if (TextUtils.isEmpty(text)) {
return "";
}
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
@Override
public String getType(@NonNull final Uri uri) {
final StringBuilder sb = new
@@ -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.android.messaging.datamodel.MmsFileProvider";
static final String AUTHORITY = "com.bnkode.notie.datamodel.MmsFileProvider";
private static final String RAW_MMS_DIR = "rawmms";
/**
@@ -0,0 +1,236 @@
package com.android.messaging.datamodel;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Parcel;
import android.text.TextUtils;
import android.util.Base64;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.action.InsertNewMessageAction;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.receiver.ScheduledMessageReceiver;
import com.android.messaging.util.LogUtil;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/** Persists compose-time scheduled sends and arms AlarmManager to deliver them. */
public final class ScheduledMessageStore {
private static final String PREFS = "home_scheduled_messages";
private static final String KEY_ENTRIES = "entries";
private static final String JSON_ID = "id";
private static final String JSON_SEND_AT = "sendAt";
private static final String JSON_PAYLOAD = "payload";
private ScheduledMessageStore() {
}
public static void schedule(@NonNull final Context context, @NonNull final MessageData message,
final long sendAt) {
final Entry entry = new Entry();
entry.id = UUID.randomUUID().toString();
entry.sendAt = sendAt;
entry.payload = encode(message);
if (TextUtils.isEmpty(entry.payload)) {
LogUtil.e(LogUtil.BUGLE_TAG, "Scheduled message encode failed");
return;
}
final List<Entry> entries = loadLocked(context);
entries.add(entry);
saveLocked(context, entries);
setAlarm(context, entry);
}
@WorkerThread
public static void sendNow(@NonNull final Context context, @Nullable final String id) {
if (TextUtils.isEmpty(id)) {
return;
}
Entry match = null;
synchronized (ScheduledMessageStore.class) {
final List<Entry> entries = loadLocked(context);
for (int i = 0; i < entries.size(); i++) {
if (id.equals(entries.get(i).id)) {
match = entries.remove(i);
break;
}
}
if (match != null) {
saveLocked(context, entries);
}
}
if (match == null) {
return;
}
cancelAlarm(context, match.id);
final MessageData message = decode(match.payload);
if (message == null || !message.hasContent()) {
LogUtil.e(LogUtil.BUGLE_TAG, "Scheduled message payload missing for " + id);
return;
}
InsertNewMessageAction.insertNewMessage(message);
}
public static void rescheduleAll(@NonNull final Context context) {
final List<Entry> entries = loadLocked(context);
final long now = System.currentTimeMillis();
final List<Entry> due = new ArrayList<>();
synchronized (ScheduledMessageStore.class) {
final List<Entry> remaining = new ArrayList<>();
for (final Entry entry : entries) {
if (entry.sendAt <= now) {
due.add(entry);
} else {
remaining.add(entry);
setAlarm(context, entry);
}
}
if (due.size() != entries.size()) {
saveLocked(context, remaining);
} else if (!due.isEmpty()) {
saveLocked(context, new ArrayList<>());
}
}
for (final Entry entry : due) {
final MessageData message = decode(entry.payload);
if (message != null && message.hasContent()) {
InsertNewMessageAction.insertNewMessage(message);
}
}
}
private static void setAlarm(@NonNull final Context context, @NonNull final Entry entry) {
final AlarmManager alarms = context.getSystemService(AlarmManager.class);
if (alarms == null) {
return;
}
final PendingIntent pending = pendingIntent(context, entry.id, true);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarms.canScheduleExactAlarms()) {
alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, entry.sendAt, pending);
return;
}
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, entry.sendAt, pending);
}
private static void cancelAlarm(@NonNull final Context context, @NonNull final String id) {
final AlarmManager alarms = context.getSystemService(AlarmManager.class);
if (alarms == null) {
return;
}
final PendingIntent pending = pendingIntent(context, id, false);
if (pending != null) {
alarms.cancel(pending);
}
}
private static PendingIntent pendingIntent(@NonNull final Context context,
@NonNull final String id, final boolean update) {
final Intent intent = new Intent(context, ScheduledMessageReceiver.class);
intent.setAction(ScheduledMessageReceiver.ACTION_SEND);
intent.setData(Uri.parse("msgq-schedule:" + id));
intent.putExtra(ScheduledMessageReceiver.EXTRA_ID, id);
final int flags = PendingIntent.FLAG_IMMUTABLE
| (update ? PendingIntent.FLAG_UPDATE_CURRENT : PendingIntent.FLAG_NO_CREATE);
return PendingIntent.getBroadcast(context.getApplicationContext(), id.hashCode(),
intent, flags);
}
private static synchronized List<Entry> loadLocked(@NonNull final Context context) {
final SharedPreferences prefs = prefs(context);
final String raw = prefs.getString(KEY_ENTRIES, "[]");
final List<Entry> entries = new ArrayList<>();
try {
final JSONArray array = new JSONArray(raw);
for (int i = 0; i < array.length(); i++) {
final JSONObject object = array.getJSONObject(i);
final Entry entry = new Entry();
entry.id = object.optString(JSON_ID);
entry.sendAt = object.optLong(JSON_SEND_AT);
entry.payload = object.optString(JSON_PAYLOAD);
if (!TextUtils.isEmpty(entry.id) && !TextUtils.isEmpty(entry.payload)) {
entries.add(entry);
}
}
} catch (final Exception exception) {
LogUtil.e(LogUtil.BUGLE_TAG, "Failed to load scheduled messages", exception);
}
return entries;
}
private static synchronized void saveLocked(@NonNull final Context context,
@NonNull final List<Entry> entries) {
final JSONArray array = new JSONArray();
try {
for (final Entry entry : entries) {
final JSONObject object = new JSONObject();
object.put(JSON_ID, entry.id);
object.put(JSON_SEND_AT, entry.sendAt);
object.put(JSON_PAYLOAD, entry.payload);
array.put(object);
}
prefs(context).edit().putString(KEY_ENTRIES, array.toString()).apply();
} catch (final Exception exception) {
LogUtil.e(LogUtil.BUGLE_TAG, "Failed to save scheduled messages", exception);
}
}
private static SharedPreferences prefs(@NonNull final Context context) {
final Context app = context.getApplicationContext() != null
? context.getApplicationContext()
: Factory.get().getApplicationContext();
return app.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
@Nullable
private static String encode(@NonNull final MessageData message) {
final Parcel parcel = Parcel.obtain();
try {
message.writeToParcel(parcel, 0);
return Base64.encodeToString(parcel.marshall(), Base64.NO_WRAP);
} catch (final Exception exception) {
LogUtil.e(LogUtil.BUGLE_TAG, "Failed to encode scheduled message", exception);
return null;
} finally {
parcel.recycle();
}
}
@Nullable
private static MessageData decode(@Nullable final String payload) {
if (TextUtils.isEmpty(payload)) {
return null;
}
final Parcel parcel = Parcel.obtain();
try {
final byte[] bytes = Base64.decode(payload, Base64.NO_WRAP);
parcel.unmarshall(bytes, 0, bytes.length);
parcel.setDataPosition(0);
return MessageData.CREATOR.createFromParcel(parcel);
} catch (final Exception exception) {
LogUtil.e(LogUtil.BUGLE_TAG, "Failed to decode scheduled message", exception);
return null;
} finally {
parcel.recycle();
}
}
private static final class Entry {
String id;
long sendAt;
String payload;
}
}
@@ -137,7 +137,7 @@ public class ActionServiceImpl extends JobIntentService {
* Broadcast receiver for alarms scheduled through ActionService.
*/
public static class PendingActionReceiver extends BroadcastReceiver {
static final String ACTION = "com.android.messaging.datamodel.PENDING_ACTION";
static final String ACTION = "com.bnkode.notie.datamodel.PENDING_ACTION";
/**
* Allocate an intent with a specific opcode and alarm action.
@@ -22,6 +22,7 @@ import android.widget.Toast;
import com.android.messaging.Factory;
import com.android.messaging.R;
import com.android.messaging.ui.home.HomeToast;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
@@ -115,13 +116,13 @@ public class BugleActionToasts {
}
private static void showToast(final int messageResId) {
ThreadUtil.getMainThreadHandler().post(() -> Toast.makeText(getApplicationContext(),
getApplicationContext().getString(messageResId), Toast.LENGTH_LONG).show());
ThreadUtil.getMainThreadHandler().post(() -> HomeToast.show(getApplicationContext(),
messageResId, Toast.LENGTH_LONG));
}
private static void showToast(final String message) {
ThreadUtil.getMainThreadHandler().post(() ->
Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show());
HomeToast.show(getApplicationContext(), message, Toast.LENGTH_LONG));
}
private static Context getApplicationContext() {
@@ -143,12 +144,11 @@ public class BugleActionToasts {
final boolean block,
final String destination) {
if (success) {
Toast.makeText(mContext,
HomeToast.show(mContext,
block
? R.string.update_destination_blocked
: R.string.update_destination_unblocked,
Toast.LENGTH_LONG
).show();
Toast.LENGTH_LONG);
}
}
}
@@ -132,6 +132,13 @@ public class InsertNewMessageAction extends Action implements Parcelable {
final DatabaseWrapper db = DataModel.get().getDatabase();
final String conversationId = message.getConversationId();
final ConversationListItemData conversation =
ConversationListItemData.getExistingConversation(db, conversationId);
if (conversation != null && conversation.getIsTrashed()) {
LogUtil.w(TAG, "InsertNewMessageAction: conversation is in trash");
return null;
}
final ParticipantData self = getSelf(db, conversationId, message);
if (self == null) {
return null;
@@ -190,6 +197,7 @@ public class InsertNewMessageAction extends Action implements Parcelable {
messageToSend, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT);
}
MessagingContentProvider.notifyConversationListChanged();
MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
return message;
@@ -0,0 +1,146 @@
/*
* Copyright (C) 2015 The Android Open Source Project
* Copyright (C) 2024-2025 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.datamodel.action;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDoneException;
import android.database.sqlite.SQLiteStatement;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.Factory;
import com.android.messaging.util.Assert;
/**
* Marks the latest message in a conversation as unread (iOS-style thread unread).
*/
public class MarkAsUnreadAction extends Action implements Parcelable {
private static final String KEY_CONVERSATION_ID = "conversation_id";
public static void markAsUnread(final String conversationId) {
final MarkAsUnreadAction action = new MarkAsUnreadAction(conversationId);
action.start();
}
private MarkAsUnreadAction(final String conversationId) {
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
}
@Override
protected Object executeAction() {
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
if (TextUtils.isEmpty(conversationId)) {
return null;
}
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
String latestMessageId;
try {
final SQLiteStatement latestMessageIdSql =
BugleDatabaseOperations.getQueryMessagesLatestMessageStatement(
db, conversationId);
latestMessageId = latestMessageIdSql.simpleQueryForString();
} catch (final SQLiteDoneException e) {
return null;
}
if (TextUtils.isEmpty(latestMessageId)) {
return null;
}
final ContentValues values = new ContentValues();
values.put(MessageColumns.READ, 0);
values.put(MessageColumns.SEEN, 0);
final int count = db.update(DatabaseHelper.MESSAGES_TABLE, values,
MessageColumns._ID + "=? AND (" + MessageColumns.READ + " != 0 OR "
+ MessageColumns.SEEN + " != 0)",
new String[] { latestMessageId });
if (count > 0) {
updateTelephonyReadStatus(db, latestMessageId);
MessagingContentProvider.notifyMessagesChanged(conversationId);
MessagingContentProvider.notifyConversationListChanged();
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
return null;
}
private static void updateTelephonyReadStatus(final DatabaseWrapper db,
final String messageId) {
Assert.isNotMainThread();
try (Cursor cursor = db.query(DatabaseHelper.MESSAGES_TABLE,
new String[] { MessageColumns.SMS_MESSAGE_URI },
MessageColumns._ID + "=?",
new String[] { messageId },
null, null, null)) {
if (!cursor.moveToFirst()) {
return;
}
final String smsMessageUri = cursor.getString(0);
if (TextUtils.isEmpty(smsMessageUri)) {
return;
}
final ContentValues telephonyValues = new ContentValues();
telephonyValues.put("read", 0);
telephonyValues.put("seen", 0);
Factory.get().getApplicationContext().getContentResolver().update(
Uri.parse(smsMessageUri),
telephonyValues,
null,
null);
}
}
private MarkAsUnreadAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<MarkAsUnreadAction> CREATOR =
new Parcelable.Creator<>() {
@Override
public MarkAsUnreadAction createFromParcel(final Parcel in) {
return new MarkAsUnreadAction(in);
}
@Override
public MarkAsUnreadAction[] newArray(final int size) {
return new MarkAsUnreadAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,77 @@
package com.android.messaging.datamodel.action;
import android.database.Cursor;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.util.LogUtil;
import java.util.ArrayList;
/** Permanently deletes conversations that have been in Trash longer than 30 days. */
public class PurgeExpiredTrashAction extends Action {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
public static void purgeExpired() {
new PurgeExpiredTrashAction().start();
}
private PurgeExpiredTrashAction() {
}
@Override
protected Object executeAction() {
final long cutoff = System.currentTimeMillis()
- UpdateConversationTrashStatusAction.RETENTION_MS;
final DatabaseWrapper db = DataModel.get().getDatabase();
final ArrayList<String> ids = new ArrayList<>();
final ArrayList<Long> timestamps = new ArrayList<>();
try (Cursor cursor = db.query(DatabaseHelper.CONVERSATIONS_TABLE,
new String[] { ConversationColumns._ID, ConversationColumns.SORT_TIMESTAMP },
ConversationColumns.TRASH_TIMESTAMP + " > 0 AND "
+ ConversationColumns.TRASH_TIMESTAMP + " < ?",
new String[] { Long.toString(cutoff) },
null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
ids.add(cursor.getString(0));
timestamps.add(cursor.getLong(1));
}
}
for (int i = 0; i < ids.size(); i++) {
DeleteConversationAction.deleteConversation(ids.get(i), timestamps.get(i));
}
if (!ids.isEmpty()) {
LogUtil.i(TAG, "Purged " + ids.size() + " expired trash conversations");
}
return null;
}
private PurgeExpiredTrashAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<PurgeExpiredTrashAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public PurgeExpiredTrashAction createFromParcel(final Parcel in) {
return new PurgeExpiredTrashAction(in);
}
@Override
public PurgeExpiredTrashAction[] newArray(final int size) {
return new PurgeExpiredTrashAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,95 @@
/*
* Copyright (C) 2025 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.datamodel.action;
import android.content.ContentValues;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
/**
* Gives a conversation a custom name, used when creating a group chat. The name lives only in
* the local database, so it is visible to this device only.
*/
public class RenameConversationAction extends Action implements Parcelable {
private static final String KEY_CONVERSATION_ID = "conversation_id";
private static final String KEY_NAME = "name";
public static void renameConversation(final String conversationId, final String name) {
final RenameConversationAction action = new RenameConversationAction(conversationId, name);
action.start();
}
private RenameConversationAction(final String conversationId, final String name) {
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
actionParameters.putString(KEY_NAME, name);
}
@Override
protected Object executeAction() {
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final String name = actionParameters.getString(KEY_NAME);
if (TextUtils.isEmpty(conversationId) || TextUtils.isEmpty(name)) {
return null;
}
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
final ContentValues values = new ContentValues();
values.put(ConversationColumns.NAME, name);
BugleDatabaseOperations.updateConversationRowIfExists(db, conversationId, values);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
MessagingContentProvider.notifyConversationListChanged();
return null;
}
private RenameConversationAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<RenameConversationAction> CREATOR =
new Parcelable.Creator<>() {
@Override
public RenameConversationAction createFromParcel(final Parcel in) {
return new RenameConversationAction(in);
}
@Override
public RenameConversationAction[] newArray(final int size) {
return new RenameConversationAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -28,6 +28,7 @@ import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.LogUtil;
@@ -70,6 +71,12 @@ public class ResendMessageAction extends Action implements Parcelable {
final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
// Check message can be resent
if (message != null && message.canResendMessage()) {
final ConversationListItemData conversation =
ConversationListItemData.getExistingConversation(db, message.getConversationId());
if (conversation != null && conversation.getIsTrashed()) {
LogUtil.w(TAG, "ResendMessageAction: conversation is in trash");
return null;
}
final boolean isMms = message.getIsMms();
long timestamp = System.currentTimeMillis();
if (isMms) {
@@ -0,0 +1,86 @@
package com.android.messaging.datamodel.action;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.ui.home.PinnedConversationStore;
import com.android.messaging.util.Assert;
public class UpdateConversationTrashStatusAction extends Action {
public static final long RETENTION_MS = 30L * 24 * 60 * 60 * 1000;
public static void moveToTrash(final String conversationId) {
final UpdateConversationTrashStatusAction action =
new UpdateConversationTrashStatusAction(conversationId, true);
action.start();
}
public static void restoreFromTrash(final String conversationId) {
final UpdateConversationTrashStatusAction action =
new UpdateConversationTrashStatusAction(conversationId, false);
action.start();
}
private static final String KEY_CONVERSATION_ID = "conversation_id";
private static final String KEY_TO_TRASH = "to_trash";
private UpdateConversationTrashStatusAction(final String conversationId,
final boolean toTrash) {
Assert.isTrue(!TextUtils.isEmpty(conversationId));
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
actionParameters.putBoolean(KEY_TO_TRASH, toTrash);
}
@Override
protected Object executeAction() {
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final boolean toTrash = actionParameters.getBoolean(KEY_TO_TRASH);
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
BugleDatabaseOperations.updateConversationTrashTimestampInTransaction(
db, conversationId, toTrash ? System.currentTimeMillis() : 0L);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
if (toTrash) {
PinnedConversationStore.unpin(Factory.get().getApplicationContext(), conversationId);
UpdateMessageNotificationAction.updateMessageNotification();
}
MessagingContentProvider.notifyConversationListChanged();
MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
return null;
}
private UpdateConversationTrashStatusAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<UpdateConversationTrashStatusAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public UpdateConversationTrashStatusAction createFromParcel(final Parcel in) {
return new UpdateConversationTrashStatusAction(in);
}
@Override
public UpdateConversationTrashStatusAction[] newArray(final int size) {
return new UpdateConversationTrashStatusAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -20,6 +20,7 @@ package com.android.messaging.datamodel.data;
import android.content.Context;
import android.database.Cursor;
import android.os.Bundle;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.loader.app.LoaderManager;
@@ -46,6 +47,13 @@ public class ContactPickerData extends BindableData implements
void onAllContactsCursorUpdated(Cursor data);
void onFrequentContactsCursorUpdated(Cursor data);
void onContactCustomColorLoaded(ContactPickerData data);
/**
* Results for the latest {@link ContactPickerData#setSearchText}, or null once the
* search is cleared.
*/
default void onFilteredContactsCursorUpdated(Cursor data) {
}
}
private static final String BINDING_ID = "bindingId";
@@ -53,6 +61,8 @@ public class ContactPickerData extends BindableData implements
private LoaderManager mLoaderManager;
private ContactPickerDataListener mListener;
private final FrequentContactsCursorBuilder mFrequentContactsCursorBuilder;
private String mSearchText;
private ContactUtil.AccountFilter mAccountFilter;
public ContactPickerData(final Context context, final ContactPickerDataListener listener) {
mListener = listener;
@@ -63,6 +73,7 @@ public class ContactPickerData extends BindableData implements
private static final int ALL_CONTACTS_LOADER = 1;
private static final int FREQUENT_CONTACTS_LOADER = 2;
private static final int PARTICIPANT_LOADER = 3;
private static final int FILTERED_CONTACTS_LOADER = 4;
@NonNull
@Override
@@ -72,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)
@@ -81,6 +92,9 @@ public class ContactPickerData extends BindableData implements
return new BoundCursorLoader(bindingId, mContext,
MessagingContentProvider.PARTICIPANTS_URI,
ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
case FILTERED_CONTACTS_LOADER:
return ContactUtil.filterDestination(mContext, mSearchText, mAccountFilter)
.createBoundCursorLoader(bindingId);
default:
Assert.fail("Unknown loader id for contact picker!");
break;
@@ -108,12 +122,16 @@ public class ContactPickerData extends BindableData implements
case PARTICIPANT_LOADER:
mListener.onContactCustomColorLoaded(this);
break;
case FILTERED_CONTACTS_LOADER:
mListener.onFilteredContactsCursorUpdated(data);
break;
default:
Assert.fail("Unknown loader id for contact picker!");
break;
}
if (loader.getId() != PARTICIPANT_LOADER) {
if (loader.getId() == ALL_CONTACTS_LOADER
|| loader.getId() == FREQUENT_CONTACTS_LOADER) {
// The frequent contacts cursor to be used in the UI depends on results from both
// all contacts and frequent contacts loader, and we don't know which will finish
// first. Therefore, try to build the cursor and notify the listener if it's
@@ -151,6 +169,9 @@ public class ContactPickerData extends BindableData implements
case PARTICIPANT_LOADER:
mListener.onContactCustomColorLoaded(this);
break;
case FILTERED_CONTACTS_LOADER:
mListener.onFilteredContactsCursorUpdated(null);
break;
default:
Assert.fail("Unknown loader id for contact picker!");
break;
@@ -170,6 +191,56 @@ public class ContactPickerData extends BindableData implements
mLoaderManager.initLoader(PARTICIPANT_LOADER, args, this);
}
/**
* Narrows the contact list down to destinations matching {@code searchText}. Passing empty
* text clears the search and reports a null cursor.
*/
public void setSearchText(final BindingBase<ContactPickerData> binding,
final String searchText) {
if (mLoaderManager == null) {
return;
}
final String trimmed = searchText == null ? null : searchText.trim();
final String normalized = TextUtils.isEmpty(trimmed) ? null : trimmed;
if (TextUtils.equals(mSearchText, normalized)) {
return;
}
mSearchText = normalized;
if (mSearchText == null) {
mLoaderManager.destroyLoader(FILTERED_CONTACTS_LOADER);
if (mListener != null) {
mListener.onFilteredContactsCursorUpdated(null);
}
return;
}
final Bundle args = new Bundle();
args.putString(BINDING_ID, binding.getBindingId());
mLoaderManager.restartLoader(FILTERED_CONTACTS_LOADER, args, this);
}
/**
* Reloads the list for one contacts account, or every account when {@code filter} is null.
*/
public void setAccountFilter(final BindingBase<ContactPickerData> binding,
final ContactUtil.AccountFilter filter) {
if (mLoaderManager == null) {
mAccountFilter = filter;
return;
}
final boolean same = (filter == null && mAccountFilter == null)
|| (filter != null && filter.matches(mAccountFilter));
if (same) {
return;
}
mAccountFilter = filter;
final Bundle args = new Bundle();
args.putString(BINDING_ID, binding.getBindingId());
mLoaderManager.restartLoader(ALL_CONTACTS_LOADER, args, this);
if (mSearchText != null) {
mLoaderManager.restartLoader(FILTERED_CONTACTS_LOADER, args, this);
}
}
@Override
protected void unregisterListeners() {
mListener = null;
@@ -180,6 +251,7 @@ public class ContactPickerData extends BindableData implements
mLoaderManager.destroyLoader(ALL_CONTACTS_LOADER);
mLoaderManager.destroyLoader(FREQUENT_CONTACTS_LOADER);
mLoaderManager.destroyLoader(PARTICIPANT_LOADER);
mLoaderManager.destroyLoader(FILTERED_CONTACTS_LOADER);
mLoaderManager = null;
}
mFrequentContactsCursorBuilder.resetBuilder();
@@ -42,6 +42,7 @@ import com.android.messaging.datamodel.action.InsertNewMessageAction;
import com.android.messaging.datamodel.action.RedownloadMmsAction;
import com.android.messaging.datamodel.action.ResendMessageAction;
import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction;
import com.android.messaging.datamodel.action.UpdateConversationTrashStatusAction;
import com.android.messaging.datamodel.binding.BindableData;
import com.android.messaging.datamodel.binding.Binding;
import com.android.messaging.datamodel.binding.BindingBase;
@@ -460,7 +461,11 @@ public class ConversationData extends BindableData {
}
public boolean getIsArchived() {
return mConversationMetadata.getIsArchived();
return mConversationMetadata != null && mConversationMetadata.getIsArchived();
}
public boolean getIsTrashed() {
return mConversationMetadata != null && mConversationMetadata.getIsTrashed();
}
public String getIcon() {
@@ -569,6 +574,12 @@ public class ConversationData extends BindableData {
final MessageData message) {
Assert.isTrue(TextUtils.equals(mConversationId, message.getConversationId()));
Assert.isTrue(binding.getData() == this);
if (getIsTrashed()) {
return;
}
if (getIsArchived()) {
unarchiveConversation(binding);
}
if (message.getSelfId() == null) {
InsertNewMessageAction.insertNewMessage(message);
@@ -612,6 +623,12 @@ public class ConversationData extends BindableData {
public void resendMessage(final BindingBase<ConversationData> binding, final String messageId) {
Assert.isTrue(binding.getData() == this);
Assert.notNull(messageId);
if (getIsTrashed()) {
return;
}
if (getIsArchived()) {
unarchiveConversation(binding);
}
ResendMessageAction.resendMessage(messageId);
}
@@ -642,6 +659,16 @@ public class ConversationData extends BindableData {
UpdateConversationArchiveStatusAction.unarchiveConversation(mConversationId);
}
public void moveConversationToTrash(final BindingBase<ConversationData> binding) {
Assert.isTrue(binding.getData() == this);
UpdateConversationTrashStatusAction.moveToTrash(mConversationId);
}
public void restoreConversationFromTrash(final BindingBase<ConversationData> binding) {
Assert.isTrue(binding.getData() == this);
UpdateConversationTrashStatusAction.restoreFromTrash(mConversationId);
}
public ConversationParticipantsData getParticipants() {
return mParticipantData;
}
@@ -52,6 +52,12 @@ public class ConversationListData extends BindableData
"(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 1)";
public static final String WHERE_NOT_ARCHIVED =
"(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 0)";
public static final String WHERE_NOT_TRASHED =
"(" + ConversationListViewColumns.TRASH_TIMESTAMP + " = 0)";
public static final String WHERE_TRASHED =
"(" + ConversationListViewColumns.TRASH_TIMESTAMP + " > 0)";
public static final String WHERE_INBOX = WHERE_NOT_ARCHIVED + " AND " + WHERE_NOT_TRASHED;
public static final String WHERE_ARCHIVED_VISIBLE = WHERE_ARCHIVED + " AND " + WHERE_NOT_TRASHED;
public interface ConversationListDataListener {
void onConversationListCursorUpdated(ConversationListData data, Cursor cursor);
@@ -61,13 +67,20 @@ public class ConversationListData extends BindableData
private ConversationListDataListener mListener;
private final Context mContext;
private final boolean mArchivedMode;
private final boolean mTrashMode;
private LoaderManager mLoaderManager;
public ConversationListData(final Context context, final ConversationListDataListener listener,
final boolean archivedMode) {
this(context, listener, archivedMode, false);
}
public ConversationListData(final Context context, final ConversationListDataListener listener,
final boolean archivedMode, final boolean trashMode) {
mListener = listener;
mContext = context;
mArchivedMode = archivedMode;
mTrashMode = trashMode;
mArchivedMode = archivedMode && !trashMode;
}
private static final int CONVERSATION_LIST_LOADER = 1;
@@ -101,7 +114,7 @@ public class ConversationListData extends BindableData
loader = new BoundCursorLoader(bindingId, mContext,
MessagingContentProvider.CONVERSATIONS_URI,
ConversationListItemData.PROJECTION,
mArchivedMode ? WHERE_ARCHIVED : WHERE_NOT_ARCHIVED,
listSelection(),
null, // selection args
SORT_ORDER);
break;
@@ -212,4 +225,18 @@ public class ConversationListData extends BindableData
public HashSet<String> getBlockedParticipants() {
return mBlockedParticipants;
}
public boolean isTrashMode() {
return mTrashMode;
}
private String listSelection() {
if (mTrashMode) {
return WHERE_TRASHED;
}
if (mArchivedMode) {
return WHERE_ARCHIVED_VISIBLE;
}
return WHERE_INBOX;
}
}
@@ -28,6 +28,7 @@ import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.action.DeleteConversationAction;
import com.android.messaging.datamodel.action.UpdateConversationTrashStatusAction;
import com.android.messaging.util.Assert;
import com.android.messaging.util.ContactUtil;
import com.android.messaging.util.Dates;
@@ -61,6 +62,7 @@ public class ConversationListItemData {
private String mDraftPreviewContentType;
private String mDraftSnippetText;
private boolean mIsArchived;
private long mTrashTimestamp;
private String mSubject;
private String mDraftSubject;
private String mSnippetSenderFirstName;
@@ -110,6 +112,8 @@ public class ConversationListItemData {
}
mIsArchived = cursor.getInt(INDEX_ARCHIVE_STATUS) == 1;
final int trashIndex = cursor.getColumnIndex(ConversationListViewColumns.TRASH_TIMESTAMP);
mTrashTimestamp = trashIndex >= 0 ? cursor.getLong(trashIndex) : 0;
mSubject = cursor.getString(INDEX_SUBJECT_TEXT);
mSnippetSenderFirstName = cursor.getString(INDEX_SNIPPET_SENDER_FIRST_NAME);
mSnippetSenderDisplayDestination =
@@ -240,6 +244,14 @@ public class ConversationListItemData {
return mIsArchived;
}
public boolean getIsTrashed() {
return mTrashTimestamp > 0;
}
public long getTrashTimestamp() {
return mTrashTimestamp;
}
public String getSubject() {
return mSubject;
}
@@ -256,7 +268,11 @@ public class ConversationListItemData {
}
public void deleteConversation() {
DeleteConversationAction.deleteConversation(mConversationId, mTimestamp);
if (getIsTrashed()) {
DeleteConversationAction.deleteConversation(mConversationId, mTimestamp);
} else {
UpdateConversationTrashStatusAction.moveToTrash(mConversationId);
}
}
/**
@@ -281,6 +297,8 @@ public class ConversationListItemData {
+ " as " + ConversationListViewColumns.CURRENT_SELF_ID + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ARCHIVE_STATUS
+ " as " + ConversationListViewColumns.ARCHIVE_STATUS + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.TRASH_TIMESTAMP
+ " as " + ConversationListViewColumns.TRASH_TIMESTAMP + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.READ
+ " as " + ConversationListViewColumns.READ + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ICON
@@ -354,6 +372,7 @@ public class ConversationListItemData {
public static final String _ID = ConversationColumns._ID;
static final String NAME = ConversationColumns.NAME;
static final String ARCHIVE_STATUS = ConversationColumns.ARCHIVE_STATUS;
static final String TRASH_TIMESTAMP = ConversationColumns.TRASH_TIMESTAMP;
static final String READ = MessageColumns.READ;
static final String SORT_TIMESTAMP = ConversationColumns.SORT_TIMESTAMP;
static final String PREVIEW_URI = ConversationColumns.PREVIEW_URI;
@@ -412,6 +431,7 @@ public class ConversationListItemData {
ConversationListViewColumns.SNIPPET_SENDER_FIRST_NAME,
ConversationListViewColumns.SNIPPET_SENDER_DISPLAY_DESTINATION,
ConversationListViewColumns.IS_ENTERPRISE,
ConversationListViewColumns.TRASH_TIMESTAMP,
};
private static final int INDEX_ID = 0;
@@ -441,6 +461,7 @@ public class ConversationListItemData {
private static final int INDEX_SNIPPET_SENDER_FIRST_NAME = 24;
private static final int INDEX_SNIPPET_SENDER_DISPLAY_DESTINATION = 25;
private static final int INDEX_IS_ENTERPRISE = 26;
private static final int INDEX_TRASH_TIMESTAMP = 27;
private static final String DIVIDER_TEXT = ", ";
@@ -367,27 +367,25 @@ public class ConversationMessageData {
* @return the text that is a concatenation of all text parts
*/
public String getText() {
// This is optimized for single text part case, which is the majority
return stripDisplayText(getRawText());
}
// For single text part, we just return the part without creating the StringBuilder
public String getRawText() {
// Optimized for the common single text part case.
String firstTextPart = null;
boolean foundText = false;
// For multiple text parts, we need the StringBuilder and the separator for concatenation
StringBuilder sb = null;
String separator = null;
for (final MessagePartData part : mParts) {
if (part.isText()) {
if (!foundText) {
// First text part
firstTextPart = part.getText();
foundText = true;
} else {
// Second and beyond
if (sb == null) {
// Need the StringBuilder and the separator starting from 2nd text part
sb = new StringBuilder();
if (!TextUtils.isEmpty(firstTextPart)) {
sb.append(firstTextPart);
sb.append(firstTextPart);
}
separator = BugleGservicesKeys.MMS_TEXT_CONCAT_SEPARATOR_DEFAULT;
}
@@ -401,13 +399,112 @@ public class ConversationMessageData {
}
}
}
if (sb == null) {
// Only one text part
return firstTextPart;
} else {
// More than one
return sb.toString();
return sb == null ? firstTextPart : sb.toString();
}
private static String stripDisplayText(final String text) {
return sanitizeDisplayText(text);
}
public static String sanitizeDisplayText(final String text) {
if (text == null) {
return null;
}
final StringBuilder out = new StringBuilder(text.length());
boolean lineStart = true;
for (int i = 0; i < text.length(); ) {
final int cp = text.codePointAt(i);
if (lineStart && isDisplayPadding(cp)) {
i += Character.charCount(cp);
continue;
}
lineStart = cp == '\n' || cp == '\r';
out.appendCodePoint(cp);
i += Character.charCount(cp);
}
int end = out.length();
while (end > 0 && isDisplayPadding(out.charAt(end - 1))) {
end--;
}
return end <= 0 ? null : out.substring(0, end);
}
/**
* Keep original spaces but flatten body line breaks so list / peek preview
* wraps by width only.
*/
public static String flattenPreviewText(final String text) {
if (text == null) {
return null;
}
final StringBuilder out = new StringBuilder(text.length());
boolean pendingBreak = false;
for (int i = 0; i < text.length(); ) {
final int cp = text.codePointAt(i);
if (cp == '\n' || cp == '\r') {
pendingBreak = true;
} else {
if (pendingBreak && out.length() > 0) {
out.append(' ');
}
pendingBreak = false;
out.appendCodePoint(cp);
}
i += Character.charCount(cp);
}
return out.length() == 0 ? null : out.toString();
}
/**
* Drop spaces next to CJK so wrapped lines do not start with a leftover space.
*/
public static String collapseCjkWrapSpaces(final String text) {
if (text == null || text.isEmpty()) {
return text;
}
final StringBuilder out = new StringBuilder(text.length());
for (int i = 0; i < text.length(); ) {
final int cp = text.codePointAt(i);
final int n = Character.charCount(cp);
if (cp == ' ' || cp == 0x00A0 || cp == 0x3000) {
final int prev = out.length() > 0 ? out.codePointBefore(out.length()) : 0;
final int next = i + n < text.length() ? text.codePointAt(i + n) : 0;
if (isCjk(prev) || isCjk(next)) {
i += n;
continue;
}
}
out.appendCodePoint(cp);
i += n;
}
return out.toString();
}
private static boolean isCjk(final int cp) {
return Character.UnicodeBlock.of(cp) == Character.UnicodeBlock.HANGUL_SYLLABLES
|| Character.UnicodeBlock.of(cp) == Character.UnicodeBlock.HANGUL_JAMO
|| Character.UnicodeBlock.of(cp) == Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO
|| Character.UnicodeBlock.of(cp) == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| Character.UnicodeBlock.of(cp) == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION;
}
private static boolean isDisplayPadding(final int cp) {
final int type = Character.getType(cp);
return Character.isWhitespace(cp)
|| Character.isSpaceChar(cp)
|| Character.isISOControl(cp)
|| type == Character.FORMAT
|| type == Character.CONTROL
|| cp == 0x00A0
|| cp == 0x1680
|| cp == 0x180E
|| cp == 0x200B
|| cp == 0x200C
|| cp == 0x200D
|| cp == 0x2060
|| cp == 0x3000
|| cp == 0x3164
|| cp == 0xFEFF;
}
public boolean hasAttachments() {
@@ -619,6 +716,21 @@ public class ConversationMessageData {
+ CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY;
}
/**
* Same rows as {@link #getConversationMessagesQuerySql}, narrowed to the messages that have
* a part matching the search term. Takes the conversation id then the LIKE pattern.
*/
public static String getConversationMessagesSearchQuerySql() {
return CONVERSATION_MESSAGES_QUERY_SQL
+ " AND "
+ DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?"
+ " AND "
+ DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns._ID + " IN (SELECT "
+ PartColumns.MESSAGE_ID + " FROM " + DatabaseHelper.PARTS_TABLE
+ " WHERE " + PartColumns.TEXT + " LIKE ? ESCAPE '\\'))"
+ CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY;
}
static String getConversationMessageIdsQuerySql() {
return CONVERSATION_MESSAGES_IDS_QUERY_SQL
+ " AND "
@@ -1,6 +1,6 @@
/*
* Copyright (C) 2015 The Android Open Source Project
* Copyright (C) 2024 The LineageOS Project
* Copyright (C) 2025 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -38,125 +38,122 @@ import com.android.messaging.util.LogUtil;
import java.util.List;
/**
* Services data needs for PeopleAndOptionsFragment.
* Serves the conversation metadata and the participant list needed by the conversation profile
* screen.
*/
public class PeopleAndOptionsData extends BindableData implements
public class ConversationProfileData extends BindableData implements
LoaderManager.LoaderCallbacks<Cursor> {
public interface PeopleAndOptionsDataListener {
void onOptionsCursorUpdated(PeopleAndOptionsData data, Cursor cursor);
void onParticipantsListLoaded(PeopleAndOptionsData data,
public interface ConversationProfileDataListener {
void onConversationMetadataUpdated(ConversationProfileData data,
ConversationListItemData conversation);
void onParticipantsListLoaded(ConversationProfileData data,
List<ParticipantData> participants);
}
private static final String BINDING_ID = "bindingId";
private static final int CONVERSATION_METADATA_LOADER = 1;
private static final int PARTICIPANT_LOADER = 2;
private final Context mContext;
private final String mConversationId;
private final ConversationParticipantsData mParticipantData;
private LoaderManager mLoaderManager;
private PeopleAndOptionsDataListener mListener;
private ConversationProfileDataListener mListener;
public PeopleAndOptionsData(final String conversationId, final Context context,
final PeopleAndOptionsDataListener listener) {
public ConversationProfileData(final String conversationId, final Context context,
final ConversationProfileDataListener listener) {
mListener = listener;
mContext = context;
mConversationId = conversationId;
mParticipantData = new ConversationParticipantsData();
}
private static final int CONVERSATION_OPTIONS_LOADER = 1;
private static final int PARTICIPANT_LOADER = 2;
@NonNull
@Override
public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
final String bindingId = args.getString(BINDING_ID);
// Check if data still bound to the requesting ui element
if (isBound(bindingId)) {
switch (id) {
case CONVERSATION_OPTIONS_LOADER: {
final Uri uri =
MessagingContentProvider.buildConversationMetadataUri(mConversationId);
return new BoundCursorLoader(bindingId, mContext, uri,
new String[]{}, null, null, null);
}
case PARTICIPANT_LOADER: {
final Uri uri =
MessagingContentProvider
.buildConversationParticipantsUri(mConversationId);
return new BoundCursorLoader(bindingId, mContext, uri,
ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
}
default:
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding PeopleAndOptionsFragment");
if (!isBound(bindingId)) {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding conversation profile");
return null;
}
switch (id) {
case CONVERSATION_METADATA_LOADER: {
final Uri uri =
MessagingContentProvider.buildConversationMetadataUri(mConversationId);
return new BoundCursorLoader(bindingId, mContext, uri,
ConversationListItemData.PROJECTION, null, null, null);
}
case PARTICIPANT_LOADER: {
final Uri uri =
MessagingContentProvider.buildConversationParticipantsUri(mConversationId);
return new BoundCursorLoader(bindingId, mContext, uri,
ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
}
default:
Assert.fail("Unknown loader id for the conversation profile!");
return null;
}
return null;
}
/**
* {@inheritDoc}
*/
@Override
public void onLoadFinished(@NonNull final Loader<Cursor> loader, final Cursor data) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId())) {
switch (loader.getId()) {
case CONVERSATION_OPTIONS_LOADER:
mListener.onOptionsCursorUpdated(this, data);
break;
case PARTICIPANT_LOADER:
mParticipantData.bind(data);
mListener.onParticipantsListLoaded(this,
mParticipantData.getParticipantListExcludingSelf());
break;
default:
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
break;
if (!isBound(cursorLoader.getBindingId()) || mListener == null) {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader finished after unbinding conversation profile");
return;
}
switch (loader.getId()) {
case CONVERSATION_METADATA_LOADER: {
ConversationListItemData conversation = null;
if (data != null && data.moveToFirst()) {
conversation = new ConversationListItemData();
conversation.bind(data);
}
mListener.onConversationMetadataUpdated(this, conversation);
break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG,
"Loader finished after unbinding PeopleAndOptionsFragment");
case PARTICIPANT_LOADER:
mParticipantData.bind(data);
mListener.onParticipantsListLoaded(this,
mParticipantData.getParticipantListExcludingSelf());
break;
default:
Assert.fail("Unknown loader id for the conversation profile!");
break;
}
}
/**
* {@inheritDoc}
*/
@Override
public void onLoaderReset(@NonNull final Loader<Cursor> loader) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId())) {
switch (loader.getId()) {
case CONVERSATION_OPTIONS_LOADER:
mListener.onOptionsCursorUpdated(this, null);
break;
if (!isBound(cursorLoader.getBindingId()) || mListener == null) {
return;
}
switch (loader.getId()) {
case CONVERSATION_METADATA_LOADER:
mListener.onConversationMetadataUpdated(this, null);
break;
case PARTICIPANT_LOADER:
mParticipantData.bind(null);
break;
case PARTICIPANT_LOADER:
mParticipantData.bind(null);
break;
default:
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding PeopleAndOptionsFragment");
default:
Assert.fail("Unknown loader id for the conversation profile!");
break;
}
}
public void init(final LoaderManager loaderManager,
final BindingBase<PeopleAndOptionsData> binding) {
final BindingBase<ConversationProfileData> binding) {
final Bundle args = new Bundle();
args.putString(BINDING_ID, binding.getBindingId());
mLoaderManager = loaderManager;
mLoaderManager.initLoader(CONVERSATION_OPTIONS_LOADER, args, this);
mLoaderManager.initLoader(CONVERSATION_METADATA_LOADER, args, this);
mLoaderManager.initLoader(PARTICIPANT_LOADER, args, this);
}
@@ -166,17 +163,16 @@ public class PeopleAndOptionsData extends BindableData implements
// This could be null if we bind but the caller doesn't init the BindableData
if (mLoaderManager != null) {
mLoaderManager.destroyLoader(CONVERSATION_OPTIONS_LOADER);
mLoaderManager.destroyLoader(CONVERSATION_METADATA_LOADER);
mLoaderManager.destroyLoader(PARTICIPANT_LOADER);
mLoaderManager = null;
}
}
public void setDestinationBlocked(final BindingBase<PeopleAndOptionsData> binding,
public void setDestinationBlocked(final BindingBase<ConversationProfileData> binding,
final boolean blocked) {
final String bindingId = binding.getBindingId();
final ParticipantData participantData = mParticipantData.getOtherParticipant();
if (isBound(bindingId) && participantData != null) {
if (isBound(binding.getBindingId()) && participantData != null) {
UpdateDestinationBlockedAction.updateDestinationBlocked(
participantData.getNormalizedDestination(),
blocked, mConversationId,
@@ -184,6 +180,11 @@ public class PeopleAndOptionsData extends BindableData implements
}
}
/** The single other participant of a one to one conversation, or null for a group. */
public ParticipantData getOtherParticipant() {
return mParticipantData.getOtherParticipant();
}
public String getConversationId() {
return mConversationId;
}
@@ -0,0 +1,132 @@
/*
* Copyright (C) 2025 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.datamodel.data;
import android.content.Context;
import android.database.Cursor;
import android.os.Bundle;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import com.android.messaging.datamodel.BoundCursorLoader;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.binding.BindableData;
import com.android.messaging.datamodel.binding.BindingBase;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
/**
* Searches the messages of a single conversation. No loader runs until
* {@link #setSearchText} gets a non empty term.
*/
public class ConversationSearchData extends BindableData implements
LoaderManager.LoaderCallbacks<Cursor> {
public interface ConversationSearchDataListener {
/** Results for the latest search term, or null once the search is cleared. */
void onConversationSearchCursorUpdated(Cursor cursor);
}
private static final String BINDING_ID = "bindingId";
private static final int SEARCH_LOADER = 1;
private final Context mContext;
private final String mConversationId;
private LoaderManager mLoaderManager;
private ConversationSearchDataListener mListener;
private String mSearchText;
public ConversationSearchData(final Context context, final String conversationId,
final ConversationSearchDataListener listener) {
mContext = context;
mConversationId = conversationId;
mListener = listener;
}
public void init(final LoaderManager loaderManager) {
mLoaderManager = loaderManager;
}
public void setSearchText(final BindingBase<ConversationSearchData> binding,
final String searchText) {
if (mLoaderManager == null) {
return;
}
final String trimmed = searchText == null ? null : searchText.trim();
final String normalized = TextUtils.isEmpty(trimmed) ? null : trimmed;
if (TextUtils.equals(mSearchText, normalized)) {
return;
}
mSearchText = normalized;
if (mSearchText == null) {
mLoaderManager.destroyLoader(SEARCH_LOADER);
if (mListener != null) {
mListener.onConversationSearchCursorUpdated(null);
}
return;
}
final Bundle args = new Bundle();
args.putString(BINDING_ID, binding.getBindingId());
mLoaderManager.restartLoader(SEARCH_LOADER, args, this);
}
public String getSearchText() {
return mSearchText;
}
@NonNull
@Override
public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
Assert.isTrue(id == SEARCH_LOADER);
final String bindingId = args.getString(BINDING_ID);
if (!isBound(bindingId)) {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding conversation search");
return null;
}
return new BoundCursorLoader(bindingId, mContext,
MessagingContentProvider.buildConversationMessagesSearchUri(
mConversationId, mSearchText),
null, null, null, null);
}
@Override
public void onLoadFinished(@NonNull final Loader<Cursor> loader, final Cursor cursor) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId()) && mListener != null) {
mListener.onConversationSearchCursorUpdated(cursor);
}
}
@Override
public void onLoaderReset(@NonNull final Loader<Cursor> loader) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId()) && mListener != null) {
mListener.onConversationSearchCursorUpdated(null);
}
}
@Override
protected void unregisterListeners() {
mListener = null;
if (mLoaderManager != null) {
mLoaderManager.destroyLoader(SEARCH_LOADER);
mLoaderManager = null;
}
}
}
@@ -282,6 +282,12 @@ public class ParticipantData implements Parcelable {
return pd;
}
public static String getCachedParticipantId(final int subId) {
synchronized (sSubIdtoParticipantIdCache) {
return sSubIdtoParticipantIdCache.get(subId);
}
}
public static String getParticipantId(final DatabaseWrapper db, final int subId) {
String id;
synchronized (sSubIdtoParticipantIdCache) {
@@ -87,6 +87,11 @@ public class ParticipantListItemData extends PersonItemData {
return mNormalizedDestination;
}
public void unblockQuietly(final Context context) {
UpdateDestinationBlockedAction.updateDestinationBlocked(
mNormalizedDestination, false, null, (action, success, block, destination) -> { });
}
public void unblock(final Context context) {
UpdateDestinationBlockedAction.updateDestinationBlocked(
mNormalizedDestination, false, null,
@@ -1,79 +0,0 @@
/*
* Copyright (C) 2015 The Android Open Source Project
* Copyright (C) 2024 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.datamodel.data;
import android.content.Context;
import android.database.Cursor;
import com.android.messaging.R;
import com.android.messaging.util.Assert;
public class PeopleOptionsItemData {
// Identification for each setting that's surfaced to the UI layer.
public static final int SETTING_NOTIFICATION = 0;
public static final int SETTING_BLOCKED = 1;
public static final int SETTINGS_COUNT = 2;
private String mTitle;
private int mItemId;
private ParticipantData mOtherParticipant;
private final Context mContext;
public PeopleOptionsItemData(final Context context) {
mContext = context;
}
/**
* Bind to a specific setting column on conversation metadata cursor. (Note
* that it binds to columns because it treats individual columns of the cursor as
* separate options to display for the conversation, e.g. notification settings).
*/
public void bind(
final Cursor cursor, final ParticipantData otherParticipant, final int settingType) {
mItemId = settingType;
mOtherParticipant = otherParticipant;
switch (settingType) {
case SETTING_NOTIFICATION:
mTitle = mContext.getString(R.string.notifications_enabled_conversation_pref_title);
break;
case SETTING_BLOCKED:
Assert.notNull(otherParticipant);
final int resourceId = otherParticipant.isBlocked() ?
R.string.unblock_contact_title : R.string.block_contact_title;
mTitle = mContext.getString(resourceId, otherParticipant.getDisplayDestination());
break;
default:
Assert.fail("Unsupported conversation option type!");
}
}
public String getTitle() {
return mTitle;
}
public int getItemId() {
return mItemId;
}
public ParticipantData getOtherParticipant() {
return mOtherParticipant;
}
}
@@ -127,6 +127,14 @@ public class AvatarRequest extends UriImageRequest<AvatarRequestDescriptor> {
mContext.getResources(), R.drawable.ic_person_light, mContext.getTheme());
float dstWidth = Math.min(defaultPerson.getIntrinsicWidth() * SCALING_FACTOR, width);
float dstHeight = Math.min(defaultPerson.getIntrinsicHeight() * SCALING_FACTOR, height);
final float dest = Math.min(width, height);
// List-size tiles keep the 24dp * 1.33 silhouette. Profile-popup tiles
// (240dp) would otherwise leave a tiny icon in the center of the card.
if (dest > dstWidth * 2f) {
final float large = dest * 0.56f;
dstWidth = large;
dstHeight = large;
}
canvas.translate((width - dstWidth) / 2, (height - dstHeight) / 2);
defaultPerson.setBounds(0, 0, (int)dstWidth, (int)dstHeight);
@@ -211,7 +211,16 @@ public class DecodedImageResource extends ImageResource {
}
}
byte[] encodedBytes = ImageUtils.bitmapToBytes(bitmap, COMPRESS_QUALITY);
return new EncodedImageResource(getKey(), encodedBytes, getOrientation());
int desiredWidth = ImageRequest.UNSPECIFIED_SIZE;
int desiredHeight = ImageRequest.UNSPECIFIED_SIZE;
if (mOriginalImageRequest instanceof ImageRequest<?>) {
final ImageRequestDescriptor descriptor =
((ImageRequest<?>) mOriginalImageRequest).getDescriptor();
desiredWidth = descriptor.desiredWidth;
desiredHeight = descriptor.desiredHeight;
}
return new EncodedImageResource(getKey(), encodedBytes, getOrientation(),
desiredWidth, desiredHeight);
} catch (Exception ex) {
// Something went wrong during bitmap compression, fall back to just using the
// original bitmap.
@@ -17,11 +17,11 @@ package com.android.messaging.datamodel.media;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.Drawable;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
import com.android.messaging.util.ImageUtils;
import java.util.Arrays;
import java.util.List;
@@ -41,10 +41,20 @@ import java.util.List;
*/
public class EncodedImageResource extends ImageResource {
private final byte[] mImageBytes;
private final int mDesiredWidth;
private final int mDesiredHeight;
public EncodedImageResource(String key, byte[] imageBytes, int orientation) {
this(key, imageBytes, orientation, ImageRequest.UNSPECIFIED_SIZE,
ImageRequest.UNSPECIFIED_SIZE);
}
public EncodedImageResource(String key, byte[] imageBytes, int orientation,
final int desiredWidth, final int desiredHeight) {
super(key, orientation);
mImageBytes = imageBytes;
mDesiredWidth = desiredWidth;
mDesiredHeight = desiredHeight;
}
@Override
@@ -54,7 +64,7 @@ public class EncodedImageResource extends ImageResource {
try {
// This should only be called during the decode request.
Assert.isNotMainThread();
return BitmapFactory.decodeByteArray(mImageBytes, 0, mImageBytes.length);
return decodeSampledBitmap();
} finally {
releaseLock();
}
@@ -99,11 +109,15 @@ public class EncodedImageResource extends ImageResource {
return true;
}
private Bitmap decodeSampledBitmap() {
return ImageUtils.decodeByteArraySampled(mImageBytes, mDesiredWidth, mDesiredHeight);
}
@Override
MediaRequest<? extends RefCountedMediaResource> getMediaDecodingRequest(
final MediaRequest<? extends RefCountedMediaResource> originalRequest) {
Assert.isTrue(isEncoded());
return new DecodeImageRequest();
return new DecodeImageRequest(originalRequest);
}
/**
@@ -112,7 +126,22 @@ public class EncodedImageResource extends ImageResource {
* properties such as binding.
*/
private class DecodeImageRequest implements MediaRequest<ImageResource> {
public DecodeImageRequest() {
private final int mRequestDesiredWidth;
private final int mRequestDesiredHeight;
public DecodeImageRequest(
final MediaRequest<? extends RefCountedMediaResource> originalRequest) {
int desiredWidth = mDesiredWidth;
int desiredHeight = mDesiredHeight;
if (originalRequest != null) {
final MediaRequestDescriptor<?> descriptor = originalRequest.getDescriptor();
if (descriptor instanceof ImageRequestDescriptor) {
desiredWidth = ((ImageRequestDescriptor) descriptor).desiredWidth;
desiredHeight = ((ImageRequestDescriptor) descriptor).desiredHeight;
}
}
mRequestDesiredWidth = desiredWidth;
mRequestDesiredHeight = desiredHeight;
// Hold a ref onto the encoded resource before the request finishes.
addRef();
}
@@ -129,8 +158,11 @@ public class EncodedImageResource extends ImageResource {
Assert.isNotMainThread();
acquireLock();
try {
final Bitmap decodedBitmap = BitmapFactory.decodeByteArray(mImageBytes, 0,
mImageBytes.length);
final Bitmap decodedBitmap = ImageUtils.decodeByteArraySampled(mImageBytes,
mRequestDesiredWidth, mRequestDesiredHeight);
if (decodedBitmap == null) {
throw new RuntimeException("failed decoding encoded bitmap");
}
return new DecodedImageResource(getKey(), decodedBitmap, getOrientation());
} finally {
releaseLock();
@@ -18,11 +18,11 @@ package com.android.messaging.datamodel.media;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.support.v7.mms.pdu.ContentType;
import com.android.messaging.Factory;
import com.android.messaging.util.Assert;
import com.android.messaging.util.ImageUtils;
import com.android.messaging.util.LogUtil;
import java.io.FileNotFoundException;
@@ -91,7 +91,11 @@ public class NetworkUriImageRequest<D extends UriImageRequestDescriptor> extends
connection.setDoInput(true);
connection.connect();
if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) {
bitmap = BitmapFactory.decodeStream(connection.getInputStream());
try (InputStream inputStream = connection.getInputStream()) {
final byte[] bytes = ImageUtils.readStreamToByteArray(inputStream);
bitmap = ImageUtils.decodeByteArraySampled(bytes,
mDescriptor.desiredWidth, mDescriptor.desiredHeight);
}
}
} catch (MalformedURLException e) {
LogUtil.e(LogUtil.BUGLE_TAG,
@@ -22,6 +22,7 @@ import android.content.Intent;
import com.android.messaging.BugleApplication;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.ScheduledMessageStore;
import com.android.messaging.datamodel.action.UpdateMessageNotificationAction;
import com.android.messaging.util.BuglePrefsKeys;
import com.android.messaging.util.LogUtil;
@@ -40,6 +41,7 @@ public class BootAndPackageReplacedReceiver extends BroadcastReceiver {
UpdateMessageNotificationAction.updateMessageNotification();
BugleApplication.updateAppConfig(context);
ScheduledMessageStore.rescheduleAll(context);
} else {
LogUtil.i(LogUtil.BUGLE_TAG, "BootAndPackageReplacedReceiver got unexpected action: "
+ intent.getAction());
@@ -0,0 +1,35 @@
package com.android.messaging.receiver;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.text.TextUtils;
import com.android.messaging.datamodel.ScheduledMessageStore;
import java.util.concurrent.Executors;
/** Fires a previously scheduled compose message when its AlarmManager time is reached. */
public class ScheduledMessageReceiver extends BroadcastReceiver {
public static final String ACTION_SEND = "com.bnkode.notie.SEND_SCHEDULED_MESSAGE";
public static final String EXTRA_ID = "scheduled_id";
@Override
public void onReceive(final Context context, final Intent intent) {
if (intent == null || !ACTION_SEND.equals(intent.getAction())) {
return;
}
final String id = intent.getStringExtra(EXTRA_ID);
if (TextUtils.isEmpty(id)) {
return;
}
final PendingResult pending = goAsync();
Executors.newSingleThreadExecutor().execute(() -> {
try {
ScheduledMessageStore.sendNow(context.getApplicationContext(), id);
} finally {
pending.finish();
}
});
}
}
@@ -38,13 +38,13 @@ import com.android.messaging.util.LogUtil;
*/
public class SendStatusReceiver extends BroadcastReceiver {
public static final String MESSAGE_SENT_ACTION =
"com.android.messaging.receiver.SendStatusReceiver.MESSAGE_SENT";
"com.bnkode.notie.receiver.SendStatusReceiver.MESSAGE_SENT";
public static final String MESSAGE_DELIVERED_ACTION =
"com.android.messaging.receiver.SendStatusReceiver.MESSAGE_DELIVERED";
"com.bnkode.notie.receiver.SendStatusReceiver.MESSAGE_DELIVERED";
public static final String MMS_SENT_ACTION =
"com.android.messaging.receiver.SendStatusReceiver.MMS_SENT";
"com.bnkode.notie.receiver.SendStatusReceiver.MMS_SENT";
public static final String MMS_DOWNLOADED_ACTION =
"com.android.messaging.receiver.SendStatusReceiver.MMS_DOWNLOADED";
"com.bnkode.notie.receiver.SendStatusReceiver.MMS_DOWNLOADED";
// Defined by platform, but no constant provided. See docs for SmsManager.sendTextMessage.
public static final String EXTRA_ERROR_CODE = "errorCode";
@@ -23,13 +23,16 @@ import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import com.android.messaging.R;
import com.android.messaging.ui.home.HomeInputFocus;
import com.android.messaging.util.BugleActivityUtil;
import com.android.messaging.util.ImeUtil;
import com.android.messaging.util.LogUtil;
@@ -70,9 +73,6 @@ public class BugleActionBarActivity extends AppCompatActivity implements ImeUtil
mLastScreenHeight = getResources().getDisplayMetrics().heightPixels;
LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onCreate");
getWindow().setStatusBarColor(getResources().getColor(R.color.action_bar_background_color,
getTheme()));
}
@Override
@@ -94,6 +94,13 @@ public class BugleActionBarActivity extends AppCompatActivity implements ImeUtil
BugleActivityUtil.onActivityResume(this, BugleActionBarActivity.this);
}
@Override
public boolean dispatchTouchEvent(final MotionEvent ev) {
final Window window = getWindow();
HomeInputFocus.clearIfOutside(window == null ? null : window.getDecorView(), ev);
return super.dispatchTouchEvent(ev);
}
@Override
protected void onPause() {
super.onPause();
@@ -339,6 +346,9 @@ public class BugleActionBarActivity extends AppCompatActivity implements ImeUtil
}
public void updateActionBar(final ActionBar actionBar) {
if (actionBar == null) {
return;
}
if (!BugleActionBarActivity.this.shouldShowContextualActionBar(mCallback)) {
actionBar.hide();
return;
@@ -41,8 +41,11 @@ public class ContactIconView extends AsyncImageView {
private static final int NORMAL_ICON_SIZE_ID = 0;
private static final int LARGE_ICON_SIZE_ID = 1;
private static final int SMALL_ICON_SIZE_ID = 2;
private static final int PROFILE_POPUP_ICON_SIZE_ID = 3;
private static final int HERO_ICON_SIZE_ID = 4;
protected final int mIconSize;
private final boolean mCropToCircle;
private final int mColorPressedId;
private long mContactId;
@@ -58,6 +61,7 @@ public class ContactIconView extends AsyncImageView {
final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ContactIconView);
final int iconSizeId = a.getInt(R.styleable.ContactIconView_contactIconSize, 0);
boolean cropToCircle = true;
switch (iconSizeId) {
case NORMAL_ICON_SIZE_ID:
mIconSize = (int) resources.getDimension(
@@ -71,11 +75,21 @@ public class ContactIconView extends AsyncImageView {
mIconSize = (int) resources.getDimension(
R.dimen.contact_icon_view_small_size);
break;
case PROFILE_POPUP_ICON_SIZE_ID:
mIconSize = (int) resources.getDimension(
R.dimen.home_list_profile_avatar_size);
cropToCircle = false;
break;
case HERO_ICON_SIZE_ID:
mIconSize = (int) resources.getDimension(
R.dimen.home_profile_avatar_size);
break;
default:
// For the compiler, something has to be set even with the assert.
mIconSize = 0;
Assert.fail("Unsupported ContactIconView icon size attribute");
}
mCropToCircle = cropToCircle;
mColorPressedId = resources.getColor(R.color.contact_avatar_pressed_color,
context.getTheme());
@@ -118,7 +132,8 @@ public class ContactIconView extends AsyncImageView {
if (AvatarUriUtil.TYPE_GROUP_URI.equals(avatarType)) {
setImageResourceId(new AvatarGroupRequestDescriptor(uri, mIconSize, mIconSize));
} else {
setImageResourceId(new AvatarRequestDescriptor(uri, mIconSize, mIconSize));
setImageResourceId(new AvatarRequestDescriptor(uri, mIconSize, mIconSize,
mCropToCircle));
}
}
@@ -17,20 +17,31 @@
package com.android.messaging.ui;
import android.app.Activity;
import android.os.Bundle;
import android.webkit.WebView;
import com.android.messaging.R;
import com.android.messaging.ui.home.HomeSubHeader;
public class LicenseActivity extends Activity {
private final String LICENSE_URL = "file:///android_asset/licenses.html";
public class LicenseActivity extends BugleActionBarActivity {
private static final String LICENSE_URL = "file:///android_asset/licenses.html";
@Override
public void onCreate(final Bundle bundle) {
super.onCreate(bundle);
setContentView(R.layout.license_activity);
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.home_license_activity);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
HomeSubHeader.bind(this, R.string.home_settings_licenses, v -> finish());
final WebView webView = findViewById(R.id.content);
webView.setBackgroundColor(getColor(R.color.home_bg));
webView.loadUrl(LICENSE_URL);
}
@Override
public void finish() {
super.finish();
overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
}
}
@@ -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);
}
}
@@ -22,7 +22,6 @@ import androidx.annotation.Nullable;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup.MarginLayoutParams;
import android.widget.FrameLayout;
import android.widget.TextView;
@@ -272,18 +271,12 @@ public class SnackBar {
private void setUpButton() {
if (mAction == null || mAction.getActionRunnable() == null) {
mActionTextView.setVisibility(View.GONE);
// In the XML layout we add left/right padding to the button to add space between
// the message text and the button and on the right side we add padding to put space
// between the button and the edge of the snack bar. This is so the button can use the
// padding area as part of it's click target. Since we have no button, we need to put
// some margin on the right side. While the left margin is already set on the wrapper,
// we're setting it again to not have to make a special case for RTL.
final MarginLayoutParams lp = (MarginLayoutParams) mMessageWrapper.getLayoutParams();
final int leftRightMargin = mContext.getResources().getDimensionPixelSize(
R.dimen.snack_bar_left_right_margin);
lp.leftMargin = leftRightMargin;
lp.rightMargin = leftRightMargin;
mMessageWrapper.setLayoutParams(lp);
final int endPad = mSnackBarView.getPaddingStart();
mSnackBarView.setPaddingRelative(
mSnackBarView.getPaddingStart(),
mSnackBarView.getPaddingTop(),
endPad,
mSnackBarView.getPaddingBottom());
} else {
mActionTextView.setVisibility(View.VISIBLE);
mActionTextView.setText(mAction.getActionLabel());
@@ -26,9 +26,11 @@ 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;
import com.android.messaging.datamodel.data.ConversationMessageData;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.ConversationIdSet;
@@ -69,7 +71,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.android.messaging.reset_notifications";
"com.bnkode.notie.reset_notifications";
// Sending VCard uri to VCard detail activity
public static final String UI_INTENT_EXTRA_VCARD_URI = "vcard_uri";
@@ -102,13 +104,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.android.messaging.widget_conversation:";
"com.bnkode.notie.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";
@@ -166,11 +173,23 @@ public abstract class UIIntents {
public abstract void launchAddContactActivity(final Context context, final String destination);
/**
* Launch an activity to show people & options for a given conversation.
* Launch home UI to show the profile of a given conversation.
*/
public abstract void launchPeopleAndOptionsActivity(final Activity context,
public abstract void launchHomeProfileActivity(final Activity activity,
final String conversationId);
/**
* Launch home UI to search the messages of a given conversation.
*/
public abstract void launchHomeConversationSearchActivity(final Context context,
final String conversationId);
/**
* Launch home UI for a single message's details.
*/
public abstract void launchHomeMessageDetailsActivity(final Context context,
final ConversationMessageData data);
/**
* Launch an external activity to handle a phone call
* @param phoneNumber the phone number to call
@@ -184,11 +203,47 @@ public abstract class UIIntents {
*/
public abstract void launchArchivedConversationsActivity(final Context context);
/**
* Launch an activity to show conversations in Trash.
*/
public abstract void launchRecentlyDeletedConversationsActivity(final Context context);
/**
* Launch an activity to show blocked participants.
*/
public abstract void launchBlockedParticipantsActivity(final Context context);
/**
* Launch home UI to manage spam-registered (blocked) senders.
*/
public abstract void launchHomeSpamActivity(final Context context);
/**
* Launch home UI to search messages.
*/
public abstract void launchHomeSearchActivity(final Context context);
/**
* Launch home UI to start a new chat.
*/
public abstract void launchHomeComposeActivity(final Context context);
/**
* Launch home UI for message automations.
*/
public abstract void launchHomeAutomationActivity(final Context context);
/**
* Launch home UI to create an automation task.
*/
public abstract void launchHomeAutomationCreateActivity(final Context context);
/**
* Launch home UI to pick a location to share in compose.
*/
public abstract void launchHomeLocationActivity(final Activity activity,
final ActivityResultLauncher<Intent> launcher);
/**
* Launch an activity to show a class zero message
*/
@@ -284,7 +339,8 @@ public abstract class UIIntents {
*/
public abstract PendingIntent getPendingIntentForSendingMessageToConversation(
final Context context, final String conversationId, final String selfId,
final boolean requiresMms, final int requestCode);
final boolean requiresMms, final CharSequence conversationName,
final int requestCode);
/**
* Get a PendingIntent for clearing notifications.
@@ -35,6 +35,7 @@ import android.provider.MediaStore;
import android.support.v7.mms.pdu.ContentType;
import android.text.TextUtils;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.Nullable;
import androidx.core.app.TaskStackBuilder;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
@@ -44,6 +45,7 @@ import com.android.messaging.R;
import com.android.messaging.datamodel.ConversationImagePartsView;
import com.android.messaging.datamodel.MediaScratchFileProvider;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.ConversationMessageData;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.MessagePartData;
import com.android.messaging.datamodel.data.ParticipantData;
@@ -55,9 +57,10 @@ import com.android.messaging.ui.appsettings.SettingsActivity;
import com.android.messaging.ui.conversation.ConversationActivity;
import com.android.messaging.ui.conversation.LaunchConversationActivity;
import com.android.messaging.ui.conversationlist.ArchivedConversationListActivity;
import com.android.messaging.ui.conversationlist.RecentlyDeletedConversationListActivity;
import com.android.messaging.ui.conversationlist.ConversationListActivity;
import com.android.messaging.ui.conversationlist.ForwardMessageActivity;
import com.android.messaging.ui.conversationsettings.PeopleAndOptionsActivity;
import com.android.messaging.ui.home.HomeMessageDetailsActivity;
import com.android.messaging.ui.photoviewer.BuglePhotoViewActivity;
import com.android.messaging.util.Assert;
import com.android.messaging.util.ConversationIdSet;
@@ -208,6 +211,12 @@ public class UIIntentsImpl extends UIIntents {
context.startActivity(intent);
}
@Override
public void launchRecentlyDeletedConversationsActivity(final Context context) {
final Intent intent = new Intent(context, RecentlyDeletedConversationListActivity.class);
context.startActivity(intent);
}
@Override
public void launchBlockedParticipantsActivity(final Context context) {
final Intent intent = new Intent(context, BlockedParticipantsActivity.class);
@@ -215,11 +224,79 @@ public class UIIntentsImpl extends UIIntents {
}
@Override
public void launchPeopleAndOptionsActivity(final Activity activity,
final String conversationId) {
final Intent intent = new Intent(activity, PeopleAndOptionsActivity.class);
public void launchHomeSpamActivity(final Context context) {
final Intent intent = new Intent(context,
com.android.messaging.ui.home.HomeSpamActivity.class);
context.startActivity(intent);
}
@Override
public void launchHomeSearchActivity(final Context context) {
final Intent intent = new Intent(context,
com.android.messaging.ui.home.HomeSearchActivity.class);
context.startActivity(intent);
}
@Override
public void launchHomeComposeActivity(final Context context) {
final Intent intent = new Intent(context,
com.android.messaging.ui.home.HomeComposeActivity.class);
context.startActivity(intent);
}
@Override
public void launchHomeAutomationActivity(final Context context) {
final Intent intent = new Intent(context,
com.android.messaging.ui.home.HomeAutomationActivity.class);
context.startActivity(intent);
}
@Override
public void launchHomeAutomationCreateActivity(final Context context) {
final Intent intent = new Intent(context,
com.android.messaging.ui.home.HomeAutomationCreateActivity.class);
context.startActivity(intent);
}
@Override
public void launchHomeLocationActivity(final Activity activity,
final ActivityResultLauncher<Intent> launcher) {
final Intent intent = new Intent(activity,
com.android.messaging.ui.home.HomeLocationActivity.class);
if (launcher != null) {
launcher.launch(intent);
} else {
activity.startActivity(intent);
}
activity.overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold);
}
@Override
public void launchHomeProfileActivity(final Activity activity, final String conversationId) {
final Intent intent = new Intent(activity,
com.android.messaging.ui.home.HomeProfileActivity.class);
intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId);
activity.startActivityForResult(intent, 0);
activity.overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold);
}
@Override
public void launchHomeConversationSearchActivity(final Context context,
final String conversationId) {
final Intent intent = new Intent(context,
com.android.messaging.ui.home.HomeConversationSearchActivity.class);
intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId);
context.startActivity(intent);
}
@Override
public void launchHomeMessageDetailsActivity(final Context context,
final ConversationMessageData data) {
HomeMessageDetailsActivity.start(context, data);
if (context instanceof Activity) {
((Activity) context).overridePendingTransition(
R.anim.home_activity_open, R.anim.home_activity_hold);
}
}
@Override
@@ -352,7 +429,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
@@ -360,6 +437,10 @@ public class UIIntentsImpl extends UIIntents {
intent.putExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID, conversationId);
intent.putExtra(UIIntents.UI_INTENT_EXTRA_SELF_ID, selfId);
intent.putExtra(UIIntents.UI_INTENT_EXTRA_REQUIRES_MMS, requiresMms);
if (!TextUtils.isEmpty(conversationName)) {
intent.putExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_NAME,
conversationName.toString());
}
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
return getPendingIntentWithParentStack(context, intent, requestCode);
}
@@ -24,148 +24,268 @@ import android.content.Intent;
import android.os.Bundle;
import android.provider.Settings;
import android.text.TextUtils;
import android.view.Menu;
import android.view.MenuItem;
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 androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.NavUtils;
import androidx.fragment.app.FragmentTransaction;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceScreen;
import com.android.messaging.R;
import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.LicenseActivity;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.home.HomeAppLock;
import com.android.messaging.ui.home.HomeBottomSheet;
import com.android.messaging.ui.home.HomeSecuritySettingsActivity;
import com.android.messaging.ui.home.HomeSettingsRow;
import com.android.messaging.ui.home.HomeSubHeader;
import com.android.messaging.ui.home.HomeToast;
import com.android.messaging.util.BuglePrefs;
import com.android.messaging.util.ChangeDefaultSmsAppHelper;
import com.android.messaging.util.PhoneUtils;
import com.google.android.material.bottomsheet.BottomSheetDialog;
public class ApplicationSettingsActivity extends BugleActionBarActivity {
private static final String LANGUAGE_SYSTEM = "system";
private static final String LANGUAGE_EN = "en";
private static final String LANGUAGE_KO = "ko";
private View mSmsRow;
private View mLanguageRow;
private String mLanguageTag = LANGUAGE_SYSTEM;
private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper;
private final ActivityResultLauncher<Intent> mLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(), result -> {
if (result.getResultCode() == Activity.RESULT_OK) {
if (mChangeDefaultSmsAppHelper == null) {
mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper();
}
mChangeDefaultSmsAppHelper.handleChangeDefaultSmsResult(
REQUEST_SET_DEFAULT_SMS_APP, result.getResultCode(), null);
}
});
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
final boolean topLevel = getIntent().getBooleanExtra(
UIIntents.UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS, false);
if (topLevel) {
getSupportActionBar().setTitle(getString(R.string.settings_activity_title));
setContentView(R.layout.home_settings_activity);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
HomeSubHeader.bind(this, R.string.home_settings_title, v -> finish());
FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
ft.replace(android.R.id.content, new ApplicationSettingsFragment());
ft.commit();
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();
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
if (super.onCreateOptionsMenu(menu)) {
return true;
}
getMenuInflater().inflate(R.menu.settings_menu, menu);
return true;
protected void onResume() {
super.onResume();
updateSmsRow();
bindAppLockRow();
}
@Override
public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
int itemId = item.getItemId();
if (itemId == android.R.id.home) {
NavUtils.navigateUpFromSameTask(this);
return true;
} else if (itemId == R.id.action_license) {
final Intent intent = new Intent(this, LicenseActivity.class);
startActivity(intent);
return true;
}
return super.onOptionsItemSelected(item);
public void finish() {
super.finish();
overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
}
public static class ApplicationSettingsFragment extends PreferenceFragmentCompat {
private void bindStaticRows() {
final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
private String mNotificationsPreferenceKey;
private String mSmsEnabledPrefKey;
private Preference mSmsEnabledPreference;
private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper;
private final ActivityResultLauncher<Intent> mLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(), result -> {
if (result.getResultCode() == Activity.RESULT_OK) {
if (mChangeDefaultSmsAppHelper == null) {
mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper();
}
mChangeDefaultSmsAppHelper.handleChangeDefaultSmsResult(
REQUEST_SET_DEFAULT_SMS_APP, result.getResultCode(), null);
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,
getString(R.string.home_settings_notifications),
getString(R.string.home_settings_notifications_subtitle),
true, false, false,
v -> {
final Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS);
intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName());
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));
HomeSettingsRow.bind(findViewById(R.id.home_settings_send_sound),
R.drawable.ic_home_sound,
getString(R.string.home_settings_send_sound),
null, false, true, sendSound,
null, (button, checked) -> prefs.putBoolean(sendKey, 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);
});
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);
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);
});
}
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() {
if (mSmsRow == null) {
return;
}
String summary = getString(R.string.default_sms_app,
PhoneUtils.getDefault().getDefaultSmsAppLabel());
if (TextUtils.isEmpty(PhoneUtils.getDefault().getDefaultSmsAppLabel())) {
summary = getString(R.string.app_name);
}
HomeSettingsRow.bind(mSmsRow,
R.drawable.ic_home_nav_messages,
getString(R.string.home_settings_default_sms),
summary, true, false, false,
v -> {
if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
final Intent intent = UIIntents.get().getChangeDefaultSmsAppIntent(this);
mLauncher.launch(intent);
}
});
public ApplicationSettingsFragment() {
// Required empty constructor
}
@Override
public void onCreatePreferences(@Nullable Bundle savedInstanceState,
@Nullable String rootKey) {
getPreferenceManager().setSharedPreferencesName(BuglePrefs.SHARED_PREFERENCES_NAME);
addPreferencesFromResource(R.xml.preferences_application);
mNotificationsPreferenceKey =
getString(R.string.notifications_pref_key);
mSmsEnabledPrefKey = getString(R.string.sms_enabled_pref_key);
mSmsEnabledPreference = findPreference(mSmsEnabledPrefKey);
final PreferenceScreen advancedScreen = findPreference(
getString(R.string.advanced_pref_key));
final boolean topLevel = getActivity().getIntent().getBooleanExtra(
UIIntents.UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS, false);
if (topLevel) {
advancedScreen.setIntent(UIIntents.get()
.getAdvancedSettingsIntent(getPreferenceScreen().getContext()));
} else {
// Hide the Advanced settings screen if this is not top-level; these are shown at
// the parent SettingsActivity.
getPreferenceScreen().removePreference(advancedScreen);
}
}
@Override
public boolean onPreferenceTreeClick(@NonNull Preference preference) {
if (preference.getKey().equals(mNotificationsPreferenceKey)) {
Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS);
intent.putExtra(Settings.EXTRA_APP_PACKAGE, getContext().getPackageName());
startActivity(intent);
}
return super.onPreferenceTreeClick(preference);
}
private void updateSmsEnabledPreferences() {
String defaultSmsAppLabel = getString(R.string.default_sms_app,
PhoneUtils.getDefault().getDefaultSmsAppLabel());
if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
mSmsEnabledPreference.setOnPreferenceClickListener(preference -> {
final Intent intent =
UIIntents.get().getChangeDefaultSmsAppIntent(getActivity());
mLauncher.launch(intent);
return true;
});
} else {
mSmsEnabledPreference.setOnPreferenceClickListener(null);
// Fallback since we don't always get our own name
if (TextUtils.isEmpty(defaultSmsAppLabel)) {
defaultSmsAppLabel = getString(R.string.app_name);
}
}
mSmsEnabledPreference.setSummary(defaultSmsAppLabel);
}
@Override
public void onResume() {
super.onResume();
updateSmsEnabledPreferences();
}
}, null);
}
}
@@ -16,12 +16,13 @@
*/
package com.android.messaging.ui.appsettings;
import android.app.AlertDialog;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.RadioButton;
import androidx.appcompat.app.AlertDialog;
import com.android.messaging.R;
import com.android.messaging.util.Assert;
import com.android.messaging.util.BuglePrefs;
@@ -48,9 +49,9 @@ public class GroupMmsSettingDialog {
private void show() {
Assert.isNull(mDialog);
mDialog = new AlertDialog.Builder(mContext, R.style.AlertDialogTheme)
mDialog = new AlertDialog.Builder(mContext, R.style.Home_Dialog)
.setView(createView())
.setTitle(R.string.group_mms_pref_title)
.setTitle(R.string.home_settings_group_mms)
.setNegativeButton(android.R.string.cancel, null)
.show();
}
@@ -17,174 +17,218 @@
package com.android.messaging.ui.appsettings;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.MenuItem;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.EditText;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.NavUtils;
import androidx.fragment.app.FragmentTransaction;
import androidx.preference.Preference;
import androidx.preference.PreferenceCategory;
import androidx.preference.PreferenceFragmentCompat;
import androidx.appcompat.app.AlertDialog;
import com.android.messaging.Factory;
import com.android.messaging.R;
import com.android.messaging.datamodel.ParticipantRefresh;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsConfig;
import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.home.HomeSettingsRow;
import com.android.messaging.ui.home.HomeSubHeader;
import com.android.messaging.util.BuglePrefs;
import com.android.messaging.util.PhoneUtils;
public class PerSubscriptionSettingsActivity extends BugleActionBarActivity {
private int mSubId;
private BuglePrefs mPrefs;
private String mDefaultPhoneNumber;
private View mGroupRow;
private View mPhoneRow;
private View mRoamingRow;
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
final String title = getIntent().getStringExtra(
UIIntents.UI_INTENT_EXTRA_PER_SUBSCRIPTION_SETTING_TITLE);
if (!TextUtils.isEmpty(title)) {
getSupportActionBar().setTitle(title);
} else {
// This will fall back to the default title, i.e. "Messaging settings," so No-op.
setContentView(R.layout.home_settings_advanced_activity);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
final FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
final PerSubscriptionSettingsFragment fragment = new PerSubscriptionSettingsFragment();
ft.replace(android.R.id.content, fragment);
ft.commit();
mSubId = getIntent().getIntExtra(UIIntents.UI_INTENT_EXTRA_SUB_ID,
ParticipantData.DEFAULT_SELF_SUB_ID);
mPrefs = BuglePrefs.getSubscriptionPrefs(mSubId);
mDefaultPhoneNumber = PhoneUtils.get(mSubId).getCanonicalForSelf(false /* allowOverride */);
final String title = getIntent().getStringExtra(
UIIntents.UI_INTENT_EXTRA_PER_SUBSCRIPTION_SETTING_TITLE);
HomeSubHeader.bind(this,
TextUtils.isEmpty(title) ? getString(R.string.home_settings_advanced) : title,
v -> finish());
mGroupRow = findViewById(R.id.home_settings_group_mms);
mPhoneRow = findViewById(R.id.home_settings_phone_number);
mRoamingRow = findViewById(R.id.home_settings_roaming_retrieve);
bindRows();
}
@Override
public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
if (item.getItemId() == android.R.id.home) {
NavUtils.navigateUpFromSameTask(this);
return true;
}
return super.onOptionsItemSelected(item);
protected void onResume() {
super.onResume();
updateGroupRow();
updatePhoneRow();
}
public static class PerSubscriptionSettingsFragment extends PreferenceFragmentCompat
implements OnSharedPreferenceChangeListener {
private PhoneNumberPreference mPhoneNumberPreference;
private Preference mGroupMmsPreference;
private String mGroupMmsPrefKey;
private String mPhoneNumberKey;
private int mSubId;
@Override
public void finish() {
super.finish();
overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
}
public PerSubscriptionSettingsFragment() {
// Required empty constructor
private void bindRows() {
final boolean defaultSms = PhoneUtils.getDefault().isDefaultSmsApp();
final boolean groupEnabled = MmsConfig.get(mSubId).getGroupMmsEnabled();
final boolean deliveryEnabled = MmsConfig.get(mSubId).getSMSDeliveryReportsEnabled();
if (groupEnabled) {
mGroupRow.setVisibility(View.VISIBLE);
updateGroupRow();
HomeSettingsRow.setEnabled(mGroupRow, defaultSms);
} else {
mGroupRow.setVisibility(View.GONE);
}
@Override
public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) {
// Get sub id from launch intent
final Intent intent = requireActivity().getIntent();
mSubId = (intent != null) ? intent.getIntExtra(UIIntents.UI_INTENT_EXTRA_SUB_ID,
ParticipantData.DEFAULT_SELF_SUB_ID) : ParticipantData.DEFAULT_SELF_SUB_ID;
updatePhoneRow();
final BuglePrefs subPrefs = Factory.get().getSubscriptionPrefs(mSubId);
getPreferenceManager().setSharedPreferencesName(subPrefs.getSharedPreferencesName());
addPreferencesFromResource(R.xml.preferences_per_subscription);
mPhoneNumberKey = getString(R.string.mms_phone_number_pref_key);
mPhoneNumberPreference = findPreference(mPhoneNumberKey);
final PreferenceCategory advancedCategory =
findPreference(getString(R.string.advanced_category_pref_key));
final PreferenceCategory mmsCategory =
findPreference(getString(R.string.mms_messaging_category_pref_key));
mPhoneNumberPreference.setDefaultPhoneNumber(
PhoneUtils.get(mSubId).getCanonicalForSelf(false/*allowOverride*/), mSubId);
mGroupMmsPrefKey = getString(R.string.group_mms_pref_key);
mGroupMmsPreference = findPreference(mGroupMmsPrefKey);
if (!MmsConfig.get(mSubId).getGroupMmsEnabled()) {
// Always show group messaging setting even if the SIM has no number
// If broadcast sms is selected, the SIM number is not needed
// If group mms is selected, the phone number dialog will popup when message
// is being sent, making sure we will have a self number for group mms.
mmsCategory.removePreference(mGroupMmsPreference);
} else {
mGroupMmsPreference.setOnPreferenceClickListener(pref -> {
GroupMmsSettingDialog.showDialog(getActivity(), mSubId);
return true;
final String autoKey = getString(R.string.auto_retrieve_mms_pref_key);
final boolean autoOn = mPrefs.getBoolean(autoKey,
getResources().getBoolean(R.bool.auto_retrieve_mms_pref_default));
HomeSettingsRow.bind(findViewById(R.id.home_settings_auto_retrieve),
R.drawable.ic_home_file_line,
getString(R.string.home_settings_auto_retrieve),
getString(R.string.home_settings_auto_retrieve_subtitle),
false, true, autoOn, null,
(button, checked) -> {
mPrefs.putBoolean(autoKey, checked);
updateRoamingEnabled(checked && defaultSms);
});
updateGroupMmsPrefSummary();
}
HomeSettingsRow.setEnabled(findViewById(R.id.home_settings_auto_retrieve), defaultSms);
if (!MmsConfig.get(mSubId).getSMSDeliveryReportsEnabled()) {
final Preference deliveryReportsPref = findPreference(
getString(R.string.delivery_reports_pref_key));
advancedCategory.removePreference(deliveryReportsPref);
}
final String roamingKey = getString(R.string.auto_retrieve_mms_when_roaming_pref_key);
final boolean roamingOn = mPrefs.getBoolean(roamingKey,
getResources().getBoolean(R.bool.auto_retrieve_mms_when_roaming_pref_default));
HomeSettingsRow.bind(mRoamingRow,
R.drawable.ic_home_place_line,
getString(R.string.home_settings_roaming_retrieve),
getString(R.string.home_settings_roaming_retrieve_subtitle),
false, true, roamingOn, null,
(button, checked) -> mPrefs.putBoolean(roamingKey, checked));
updateRoamingEnabled(autoOn && defaultSms);
// We want to disable preferences if we are not the default app, but we do all of the
// above first so that the user sees the correct information on the screen
if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
mGroupMmsPreference.setEnabled(false);
final Preference autoRetrieveMmsPreference =
findPreference(getString(R.string.auto_retrieve_mms_pref_key));
autoRetrieveMmsPreference.setEnabled(false);
final Preference deliveryReportsPreference =
findPreference(getString(R.string.delivery_reports_pref_key));
if (deliveryReportsPreference != null) {
deliveryReportsPreference.setEnabled(false);
}
}
if (deliveryEnabled) {
final String deliveryKey = getString(R.string.delivery_reports_pref_key);
final boolean deliveryOn = mPrefs.getBoolean(deliveryKey,
getResources().getBoolean(R.bool.delivery_reports_pref_default));
HomeSettingsRow.bind(findViewById(R.id.home_settings_delivery_reports),
R.drawable.ic_home_checklist,
getString(R.string.home_settings_delivery_reports),
getString(R.string.home_settings_delivery_reports_subtitle),
false, true, deliveryOn, null,
(button, checked) -> mPrefs.putBoolean(deliveryKey, checked));
HomeSettingsRow.setEnabled(findViewById(R.id.home_settings_delivery_reports),
defaultSms);
} else {
findViewById(R.id.home_settings_delivery_label).setVisibility(View.GONE);
findViewById(R.id.home_settings_delivery_card).setVisibility(View.GONE);
}
}
if (advancedCategory.getPreferenceCount() == 0) {
getPreferenceScreen().removePreference(advancedCategory);
private void updateGroupRow() {
if (mGroupRow == null || mGroupRow.getVisibility() != View.VISIBLE) {
return;
}
final boolean groupOn = mPrefs.getBoolean(getString(R.string.group_mms_pref_key),
getResources().getBoolean(R.bool.group_mms_pref_default));
HomeSettingsRow.bind(mGroupRow,
R.drawable.ic_home_nav_messages,
getString(R.string.home_settings_group_mms),
getString(groupOn
? R.string.home_settings_group_mms_on
: R.string.home_settings_group_mms_off),
true, false, false,
v -> GroupMmsSettingDialog.showDialog(this, mSubId),
null);
HomeSettingsRow.setEnabled(mGroupRow, PhoneUtils.getDefault().isDefaultSmsApp());
}
private void updatePhoneRow() {
if (mPhoneRow == null) {
return;
}
HomeSettingsRow.bind(mPhoneRow,
R.drawable.ic_home_menu_person,
getString(R.string.home_settings_phone_number),
formatPhoneSummary(),
true, false, false,
v -> showPhoneNumberDialog(),
null);
}
private void updateRoamingEnabled(final boolean enabled) {
if (mRoamingRow != null) {
HomeSettingsRow.setEnabled(mRoamingRow, enabled);
}
}
private String formatPhoneSummary() {
String value = mPrefs.getString(getString(R.string.mms_phone_number_pref_key), "");
if (TextUtils.isEmpty(value)) {
value = mDefaultPhoneNumber;
}
if (TextUtils.isEmpty(value)) {
return getString(R.string.home_settings_phone_unknown);
}
return PhoneUtils.get(mSubId).formatForDisplay(value);
}
private void showPhoneNumberDialog() {
final View content = LayoutInflater.from(this)
.inflate(R.layout.home_settings_phone_dialog, null);
final EditText field = content.findViewById(R.id.home_settings_phone_field);
String value = mPrefs.getString(getString(R.string.mms_phone_number_pref_key), "");
if (TextUtils.isEmpty(value)) {
value = mDefaultPhoneNumber;
}
if (value == null) {
value = "";
}
field.setText(value);
if (!TextUtils.isEmpty(value)) {
field.setSelection(value.length());
}
new AlertDialog.Builder(this, R.style.Home_Dialog)
.setTitle(R.string.home_settings_phone_number)
.setView(content)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
savePhoneNumber(field.getText().toString().trim());
updatePhoneRow();
})
.show();
}
private void savePhoneNumber(final String text) {
final String key = getString(R.string.mms_phone_number_pref_key);
if (TextUtils.isEmpty(text)) {
mPrefs.remove(key);
} else {
final PhoneUtils phoneUtils = PhoneUtils.get(mSubId);
final String canonical = phoneUtils.getCanonicalBySystemLocale(text);
if (!TextUtils.isEmpty(mDefaultPhoneNumber)
&& TextUtils.equals(canonical,
phoneUtils.getCanonicalBySystemLocale(mDefaultPhoneNumber))) {
mPrefs.remove(key);
} else {
mPrefs.putString(key, text);
}
}
private void updateGroupMmsPrefSummary() {
final boolean groupMmsEnabled = getPreferenceScreen().getSharedPreferences().getBoolean(
mGroupMmsPrefKey, getResources().getBoolean(R.bool.group_mms_pref_default));
mGroupMmsPreference.setSummary(groupMmsEnabled ?
R.string.enable_group_mms : R.string.disable_group_mms);
}
@Override
public void onResume() {
super.onResume();
getPreferenceScreen().getSharedPreferences()
.registerOnSharedPreferenceChangeListener(this);
}
@Override
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
final String key) {
if (key.equals(mGroupMmsPrefKey)) {
updateGroupMmsPrefSummary();
} else if (key.equals(mPhoneNumberKey)) {
// Save the changed phone number in preferences specific to the sub id
final String newPhoneNumber = mPhoneNumberPreference.getText();
final BuglePrefs subPrefs = BuglePrefs.getSubscriptionPrefs(mSubId);
if (TextUtils.isEmpty(newPhoneNumber)) {
subPrefs.remove(mPhoneNumberKey);
} else {
subPrefs.putString(getString(R.string.mms_phone_number_pref_key),
newPhoneNumber);
}
// Update the self participants so the new phone number will be reflected
// everywhere in the UI.
ParticipantRefresh.refreshSelfParticipants();
}
}
@Override
public void onPause() {
super.onPause();
getPreferenceScreen().getSharedPreferences()
.unregisterOnSharedPreferenceChangeListener(this);
}
ParticipantRefresh.refreshSelfParticipants();
}
}
@@ -17,20 +17,11 @@
package com.android.messaging.ui.appsettings;
import android.content.Context;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.app.NavUtils;
import androidx.fragment.app.Fragment;
import androidx.loader.app.LoaderManager;
import com.android.messaging.R;
@@ -42,137 +33,98 @@ import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener;
import com.android.messaging.datamodel.data.SettingsData.SettingsItem;
import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.home.HomeSettingsRow;
import com.android.messaging.ui.home.HomeSubHeader;
import com.android.messaging.util.Assert;
import com.android.messaging.util.PhoneUtils;
import java.util.ArrayList;
import java.util.List;
/**
* Shows the "primary" settings activity that contains two parts, one for application-wide settings
* (dubbed "General settings"), and one or more for per-subscription settings (dubbed "Messaging
* settings" for single-SIM, and the actual SIM name for multi-SIM). Clicking on either item
* (e.g. "General settings") will open the detail settings activity (ApplicationSettingsActivity
* in this case).
* Multi-SIM settings hub. Single-SIM devices skip this and open ApplicationSettingsActivity.
*/
public class SettingsActivity extends BugleActionBarActivity {
public class SettingsActivity extends BugleActionBarActivity implements SettingsDataListener {
private final Binding<SettingsData> mBinding = BindingBase.createBinding(this);
private ViewGroup mList;
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
// Directly open the detailed settings page as the top-level settings activity if this is
// not a multi-SIM device.
if (PhoneUtils.getDefault().getActiveSubscriptionCount() <= 1) {
UIIntents.get().launchApplicationSettingsActivity(this, true /* topLevel */);
finish();
} else {
getSupportFragmentManager().beginTransaction()
.replace(android.R.id.content, new SettingsFragment())
.commit();
overridePendingTransition(0, 0);
return;
}
setContentView(R.layout.home_settings_hub_activity);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
HomeSubHeader.bind(this, R.string.home_settings_title, v -> finish());
mList = findViewById(R.id.home_settings_hub_list);
mBinding.bind(DataModel.get().createSettingsData(this, this));
mBinding.getData().init(LoaderManager.getInstance(this), mBinding);
}
@Override
protected void onDestroy() {
super.onDestroy();
if (mBinding.isBound()) {
mBinding.unbind();
}
}
@Override
public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
if (item.getItemId() == android.R.id.home) {
NavUtils.navigateUpFromSameTask(this);
return true;
}
return super.onOptionsItemSelected(item);
public void finish() {
super.finish();
overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
}
public static class SettingsFragment extends Fragment implements SettingsDataListener {
private ListView mListView;
private SettingsListAdapter mAdapter;
private final Binding<SettingsData> mBinding = BindingBase.createBinding(this);
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding.bind(DataModel.get().createSettingsData(getActivity(), this));
mBinding.getData().init(LoaderManager.getInstance(this), mBinding);
@Override
public void onSelfParticipantDataLoaded(final SettingsData data) {
mBinding.ensureBound(data);
if (mList == null) {
return;
}
@Override
public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
final Bundle savedInstanceState) {
final View view = inflater.inflate(R.layout.settings_fragment, container, false);
mListView = view.findViewById(android.R.id.list);
mAdapter = new SettingsListAdapter(getActivity());
mListView.setAdapter(mAdapter);
return view;
}
@Override
public void onDestroy() {
super.onDestroy();
mBinding.unbind();
}
@Override
public void onSelfParticipantDataLoaded(SettingsData data) {
mBinding.ensureBound(data);
mAdapter.setSettingsItems(data.getSettingsItems());
}
/**
* An adapter that displays a list of SettingsItem.
*/
private class SettingsListAdapter extends ArrayAdapter<SettingsItem> {
public SettingsListAdapter(final Context context) {
super(context, R.layout.settings_item_view, new ArrayList<>());
mList.removeAllViews();
final LayoutInflater inflater = LayoutInflater.from(this);
boolean first = true;
for (final SettingsItem item : data.getSettingsItems()) {
final View row = inflater.inflate(R.layout.home_settings_row, mList, false);
if (!first) {
final ViewGroup.MarginLayoutParams lp =
(ViewGroup.MarginLayoutParams) row.getLayoutParams();
lp.topMargin = getResources().getDimensionPixelSize(R.dimen.looply_space_xs);
row.setLayoutParams(lp);
}
first = false;
final int icon = item.getType() == SettingsItem.TYPE_GENERAL_SETTINGS
? R.drawable.ic_home_nav_settings
: R.drawable.ic_home_nav_messages;
final CharSequence title = item.getType() == SettingsItem.TYPE_GENERAL_SETTINGS
? getString(R.string.home_settings_general)
: item.getDisplayName();
HomeSettingsRow.bind(row, icon, title, item.getDisplayDetail(),
true, false, false,
v -> openItem(item), null);
mList.addView(row);
}
}
public void setSettingsItems(final List<SettingsItem> newList) {
clear();
addAll(newList);
notifyDataSetChanged();
}
@NonNull
@Override
public View getView(final int position, final View convertView,
@NonNull final ViewGroup parent) {
View itemView;
if (convertView != null) {
itemView = convertView;
} else {
final LayoutInflater inflater = (LayoutInflater) getContext()
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
itemView = inflater.inflate(
R.layout.settings_item_view, parent, false);
}
final SettingsItem item = getItem(position);
final TextView titleTextView = itemView.findViewById(R.id.title);
final TextView subtitleTextView = itemView.findViewById(R.id.subtitle);
final String summaryText = item.getDisplayDetail();
titleTextView.setText(item.getDisplayName());
if (!TextUtils.isEmpty(summaryText)) {
subtitleTextView.setText(summaryText);
subtitleTextView.setVisibility(View.VISIBLE);
} else {
subtitleTextView.setVisibility(View.GONE);
}
itemView.setOnClickListener(view -> {
switch (item.getType()) {
case SettingsItem.TYPE_GENERAL_SETTINGS:
UIIntents.get().launchApplicationSettingsActivity(getActivity(),
false /* topLevel */);
break;
case SettingsItem.TYPE_PER_SUBSCRIPTION_SETTINGS:
UIIntents.get().launchPerSubscriptionSettingsActivity(getActivity(),
item.getSubId(), item.getActivityTitle());
break;
default:
Assert.fail("unrecognized setting type!");
break;
}
});
return itemView;
}
private void openItem(final SettingsItem item) {
switch (item.getType()) {
case SettingsItem.TYPE_GENERAL_SETTINGS:
UIIntents.get().launchApplicationSettingsActivity(this, false /* topLevel */);
overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold);
break;
case SettingsItem.TYPE_PER_SUBSCRIPTION_SETTINGS:
UIIntents.get().launchPerSubscriptionSettingsActivity(this,
item.getSubId(), item.getActivityTitle());
overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold);
break;
default:
Assert.fail("unrecognized setting type!");
break;
}
}
}
@@ -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;
@@ -33,6 +34,7 @@ import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.data.ContactListItemData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.ui.ContactIconView;
import com.android.messaging.ui.home.HomeAvatarOverlay;
import com.android.messaging.util.Assert;
import com.android.messaging.util.AvatarUriUtil;
@@ -43,6 +45,10 @@ public class ContactListItemView extends LinearLayout implements OnClickListener
public interface HostInterface {
void onContactListItemClicked(ContactListItemData item, ContactListItemView view);
boolean isContactSelected(ContactListItemData item);
default boolean isContactSelectionMode() {
return false;
}
}
final ContactListItemData mData;
@@ -53,9 +59,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 +87,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);
}
}
/**
@@ -129,7 +194,8 @@ public class ContactListItemView extends LinearLayout implements OnClickListener
mContactIconView.setImageResourceUri(avatarUri, mData.getContactId(),
mData.getLookupKey(), destinationString);
mContactIconView.setVisibility(VISIBLE);
mContactCheckmarkView.setVisibility(GONE);
applySelectionCheck(isContactChecked());
bindAvatarClickTargets();
mContactDetailTypeTextView.setVisibility(GONE);
mContactDetailsTextView.setVisibility(GONE);
mContactNameTextView.setVisibility(VISIBLE);
@@ -142,7 +208,8 @@ public class ContactListItemView extends LinearLayout implements OnClickListener
mContactNameTextView.setVisibility(VISIBLE);
final boolean isSelected = mHostInterface.isContactSelected(mData);
setSelected(isSelected);
mContactCheckmarkView.setVisibility(isSelected ? VISIBLE : GONE);
applySelectionCheck(isSelected);
bindAvatarClickTargets();
mContactDetailsTextView.setVisibility(VISIBLE);
mContactDetailTypeTextView.setVisibility(VISIBLE);
} else {
@@ -151,7 +218,8 @@ public class ContactListItemView extends LinearLayout implements OnClickListener
mContactNameTextView.setVisibility(GONE);
final boolean isSelected = mHostInterface.isContactSelected(mData);
setSelected(isSelected);
mContactCheckmarkView.setVisibility(isSelected ? VISIBLE : GONE);
applySelectionCheck(isSelected);
bindAvatarClickTargets();
mContactDetailsTextView.setVisibility(VISIBLE);
mContactDetailTypeTextView.setVisibility(VISIBLE);
}
@@ -160,14 +228,44 @@ 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);
}
}
private boolean isContactChecked() {
return mHostInterface != null && mHostInterface.isContactSelected(mData);
}
private void applySelectionCheck(final boolean selected) {
HomeAvatarOverlay.apply(mContactCheckmarkView, isSelectionMode(), selected);
}
private boolean isSelectionMode() {
return mHostInterface != null && mHostInterface.isContactSelectionMode();
}
private void bindAvatarClickTargets() {
if (mContactIconView == null) {
return;
}
mContactIconView.setImageClickHandlerDisabled(true);
final boolean selectionMode = isSelectionMode();
final OnClickListener selectRow = v -> performClick();
if (mContactCheckmarkView != null) {
mContactCheckmarkView.setOnClickListener(selectionMode ? selectRow : null);
mContactCheckmarkView.setClickable(selectionMode);
mContactCheckmarkView.setFocusable(false);
}
mContactIconView.setOnClickListener(selectionMode ? selectRow : null);
mContactIconView.setClickable(selectionMode);
}
/**
* {@inheritDoc} from OnClickListener
*/
@@ -291,6 +291,13 @@ public class ContactPickerFragment extends Fragment implements ContactPickerData
item.getRecipientEntry().getDestination()));
}
@Override
public boolean isContactSelectionMode() {
return mContactPickingMode == MODE_PICK_INITIAL_CONTACT
|| mContactPickingMode == MODE_PICK_MORE_CONTACTS
|| mContactPickingMode == MODE_PICK_MAX_PARTICIPANTS;
}
/**
* Call this immediately after attaching the fragment, or when there's a ui state change that
* changes our host (i.e. restore from saved instance state).
@@ -557,7 +564,7 @@ public class ContactPickerFragment extends Fragment implements ContactPickerData
// etc. will take the spot of the action bar.
actionBar.hide();
UiUtils.setStatusBarColor(getActivity(),
getResources().getColor(R.color.looply_bg, getActivity().getTheme()));
getResources().getColor(R.color.home_bg, getActivity().getTheme()));
}
private GetOrCreateConversationActionMonitor mMonitor;
@@ -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.
@@ -16,7 +16,9 @@
*/
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;
@@ -34,18 +36,25 @@ import android.text.format.Formatter;
import android.util.AttributeSet;
import android.view.ContextThemeWrapper;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityEvent;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.core.view.WindowInsetsCompat;
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;
@@ -65,7 +74,14 @@ 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;
import com.android.messaging.util.ImageUtils;
import com.android.messaging.util.ImeUtil;
import com.android.messaging.util.Assert;
import com.android.messaging.util.AvatarUriUtil;
import com.android.messaging.util.BuglePrefs;
@@ -102,6 +118,10 @@ public class ComposeMessageView extends LinearLayout
boolean tooManyVideos);
void notifyOfAttachmentLoadFailed();
void showAttachmentChooser();
void launchSystemGalleryPicker();
void requestMediaPermission(Runnable onGranted);
void requestLocationPermission(Runnable onGranted);
void launchLocationShare();
boolean shouldShowSubjectEditor();
boolean shouldHideAttachmentsWhenSimSelectorShown();
Uri getSelfSendButtonIconUri();
@@ -110,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;
@@ -128,6 +149,17 @@ public class ComposeMessageView extends LinearLayout
private ImageButton mDeleteSubjectButton;
private AttachmentPreview mAttachmentPreview;
private ImageButton mAttachMediaButton;
private View mAttachmentSheetContainer;
private View mComposeBottomStack;
private View mComposeBar;
private boolean mAttachmentSheetOpen;
private HomeComposeAttachPanel.Host mAttachHost;
private int mComposeBarPaddingBottom;
private ImageButton mVoiceButton;
private View mSendColumn;
private View mScheduleChipRow;
private TextView mScheduleChipText;
private long mScheduledSendAt;
private final Binding<DraftMessageData> mBinding;
private IComposeMessageViewHost mHost;
@@ -199,14 +231,24 @@ public class ComposeMessageView extends LinearLayout
mComposeEditText.setOnEditorActionListener(this);
mComposeEditText.addTextChangedListener(this);
mComposeEditText.setOnFocusChangeListener((v, hasFocus) -> {
if (mAttachMediaButton != null) {
mAttachMediaButton.setSelected(hasFocus);
}
if (v == mComposeEditText && hasFocus) {
mHost.onComposeEditTextFocused();
if (mHost != null) {
mHost.onComposeEditTextFocused();
}
showComposeKeyboard();
}
});
mComposeEditText.setOnClickListener(arg0 -> {
if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) {
hideSimSelector();
mComposeEditText.setOnTouchListener((v, event) -> {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
if (mHost != null && mHost.shouldHideAttachmentsWhenSimSelectorShown()) {
hideSimSelector();
}
showComposeKeyboard();
}
return false;
});
// onFinishInflate() is called before self is loaded from db. We set the default text
@@ -283,10 +325,53 @@ public class ComposeMessageView extends LinearLayout
});
mAttachMediaButton = findViewById(R.id.attach_media_button);
mAttachMediaButton.setOnClickListener(clickView -> {
// Showing the media picker is treated as starting to compose the message.
mInputManager.showHideMediaPicker(true /* show */, true /* animate */);
});
mAttachmentSheetContainer = findViewById(R.id.home_compose_attachment_sheet_container);
mComposeBottomStack = findViewById(R.id.home_compose_bottom_stack);
mComposeBar = findViewById(R.id.home_conversation_compose_bar);
if (mComposeBar != null) {
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());
final ImageButton emojiButton = findViewById(R.id.home_compose_emoji_button);
if (emojiButton != null) {
emojiButton.setOnClickListener(v -> showComposeKeyboard());
}
final ImageButton galleryButton = findViewById(R.id.home_compose_gallery_button);
if (galleryButton != null) {
galleryButton.setOnClickListener(v -> {
if (!mAttachmentSheetOpen) {
toggleComposeAttachmentSheet();
}
});
}
mVoiceButton = findViewById(R.id.home_conversation_voice_button);
mSendColumn = findViewById(R.id.home_conversation_send_column);
if (mVoiceButton != null) {
mVoiceButton.setOnClickListener(v -> {
final String messageText = mComposeEditText.getText().toString();
final boolean hasDraft = TextUtils.getTrimmedLength(messageText) > 0
|| (mBinding.isBound() && mBinding.getData().hasAttachments());
if (hasDraft) {
sendMessageInternal(true /* checkMessageSize */);
} else {
HomeToast.show(mOriginalContext, R.string.home_conversation_voice_coming_soon);
}
});
}
mAttachmentPreview = findViewById(R.id.attachment_draft_view);
mAttachmentPreview.setComposeMessageView(this);
@@ -368,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);
@@ -537,11 +625,39 @@ public class ComposeMessageView extends LinearLayout
@Override
public void resumeComposeMessage() {
mComposeEditText.requestFocus();
mInputManager.showHideImeKeyboard(true, true);
showComposeKeyboard();
announceAttachmentState();
}
private void showComposeKeyboard() {
if (mComposeEditText == null) {
return;
}
mComposeEditText.setEnabled(true);
mComposeEditText.setFocusable(true);
mComposeEditText.setFocusableInTouchMode(true);
mComposeEditText.setShowSoftInputOnFocus(true);
mComposeEditText.requestFocus();
final Activity activity = resolveActivity();
if (activity != null && activity.getWindow() != null) {
activity.getWindow().setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
| WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
new WindowInsetsControllerCompat(activity.getWindow(), mComposeEditText)
.show(WindowInsetsCompat.Type.ime());
final InputMethodManager imm = activity.getSystemService(InputMethodManager.class);
if (imm != null) {
imm.restartInput(mComposeEditText);
imm.showSoftInput(mComposeEditText, InputMethodManager.SHOW_FORCED);
}
}
final Context imeContext = activity != null ? activity : mOriginalContext;
ImeUtil.get().showImeKeyboard(imeContext, mComposeEditText);
if (mInputManager != null) {
mInputManager.showHideImeKeyboard(true, false);
}
}
public void clearAttachments() {
mBinding.getData().clearAttachments(mHost.getAttachmentsClearedFlags());
mHost.onAttachmentsCleared();
@@ -666,7 +782,7 @@ public class ComposeMessageView extends LinearLayout
mMessageBodySize.setVisibility(View.VISIBLE);
}
} else {
mMessageBodySize.setVisibility(View.INVISIBLE);
mMessageBodySize.setVisibility(View.GONE);
}
} else { // SMS case
// Update the SMS text counter.
@@ -685,7 +801,7 @@ public class ComposeMessageView extends LinearLayout
mMessageBodySize.setText(counterText);
mMessageBodySize.setVisibility(View.VISIBLE);
} else {
mMessageBodySize.setVisibility(View.INVISIBLE);
mMessageBodySize.setVisibility(View.GONE);
}
}
@@ -695,35 +811,31 @@ public class ComposeMessageView extends LinearLayout
int sendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR;
if (selfSendButtonUri != null) {
if (hasWorkingDraft && isDataLoadedForMessageSend()) {
UiUtils.revealOrHideViewWithAnimation(mSendButton, VISIBLE, null);
if (isOverriddenAvatarAGroup()) {
// If the host has overriden the avatar to show a group avatar where the
// send button sits, we have to hide the group avatar because it can be larger
// than the send button and pieces of the avatar will stick out from behind
// the send button.
UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, GONE, null);
}
hideSelfSendIcon();
mMmsIndicator.setVisibility(draftMessageData.getIsMms() ? VISIBLE : INVISIBLE);
sendWidgetMode = SEND_WIDGET_MODE_SEND_BUTTON;
} else {
mSelfSendIcon.setImageResourceUri(selfSendButtonUri);
if (isOverriddenAvatarAGroup()) {
UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, VISIBLE, null);
}
UiUtils.revealOrHideViewWithAnimation(mSendButton, GONE, null);
hideSelfSendIcon();
mMmsIndicator.setVisibility(INVISIBLE);
if (shouldShowSimSelector(mConversationDataModel.getData())) {
sendWidgetMode = SEND_WIDGET_MODE_SIM_SELECTOR;
}
}
} else {
mSelfSendIcon.setImageResourceUri(null);
hideSelfSendIcon();
if (hasWorkingDraft && isDataLoadedForMessageSend()) {
mMmsIndicator.setVisibility(draftMessageData.getIsMms() ? VISIBLE : INVISIBLE);
sendWidgetMode = SEND_WIDGET_MODE_SEND_BUTTON;
} else {
mMmsIndicator.setVisibility(INVISIBLE);
}
}
if (mSendWidgetMode != sendWidgetMode || sendWidgetMode == SEND_WIDGET_MODE_SIM_SELECTOR) {
setSendButtonAccessibility(sendWidgetMode);
mSendWidgetMode = sendWidgetMode;
}
updateVoiceAndSendColumnVisibility(sendWidgetMode);
// Update the text hint on the message box depending on the attachment type.
final int attachmentCount = attachments.size();
@@ -795,6 +907,30 @@ public class ComposeMessageView extends LinearLayout
}
}
private void updateVoiceAndSendColumnVisibility(final int sendWidgetMode) {
if (mVoiceButton == null || mSendColumn == null) {
return;
}
final boolean showSend = sendWidgetMode == SEND_WIDGET_MODE_SEND_BUTTON;
if (showSend) {
mSendColumn.setVisibility(VISIBLE);
UiUtils.revealOrHideViewWithAnimation(mSendButton, VISIBLE, null);
UiUtils.revealOrHideViewWithAnimation(mVoiceButton, GONE, null);
return;
}
UiUtils.revealOrHideViewWithAnimation(mVoiceButton, VISIBLE, null);
if (mSendColumn.getVisibility() == VISIBLE) {
UiUtils.revealOrHideViewWithAnimation(mSendButton, GONE, () -> {
if (mSendWidgetMode != SEND_WIDGET_MODE_SEND_BUTTON && mSendColumn != null) {
mSendColumn.setVisibility(GONE);
}
});
} else {
mSendButton.setVisibility(GONE);
mSendColumn.setVisibility(GONE);
}
}
private void setSendButtonAccessibility(final int sendWidgetMode) {
switch (sendWidgetMode) {
case SEND_WIDGET_MODE_SELF_AVATAR:
@@ -802,7 +938,6 @@ public class ComposeMessageView extends LinearLayout
// important for accessibility.
mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
mSelfSendIcon.setContentDescription(null);
mSendButton.setVisibility(View.GONE);
setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SELF_AVATAR);
break;
@@ -920,18 +1055,56 @@ public class ComposeMessageView extends LinearLayout
}
public void hideAllComposeInputs(final boolean animate) {
if (mAttachmentSheetOpen) {
closeComposeAttachmentSheet(animate, null);
}
mInputManager.hideAllInputs(animate);
}
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() {
if (mAttachmentSheetOpen) {
closeComposeAttachmentSheet(false /* animate */, null);
}
mInputManager.resetMediaPickerState();
}
public boolean onBackPressed() {
if (HomeComposeAttachPanel.isShowing(resolveActivity())) {
return HomeComposeAttachPanel.onBackPressed(resolveActivity());
}
return mInputManager.onBackPressed();
}
@@ -960,12 +1133,13 @@ public class ComposeMessageView extends LinearLayout
mHost.notifyOfAttachmentLoadFailed();
}
private boolean isOverriddenAvatarAGroup() {
final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri();
if (overridenSelfUri == null) {
return false;
private void hideSelfSendIcon() {
if (mSelfSendIcon == null) {
return;
}
return AvatarUriUtil.TYPE_GROUP_URI.equals(AvatarUriUtil.getAvatarType(overridenSelfUri));
mSelfSendIcon.animate().cancel();
mSelfSendIcon.setVisibility(GONE);
mSelfSendIcon.setImageResourceUri(null);
}
@Override
@@ -982,4 +1156,264 @@ public class ComposeMessageView extends LinearLayout
mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
}
}
private HomeComposeAttachPanel.Host createComposeAttachHost() {
return new HomeComposeAttachPanel.Host() {
@Override
public void onMediaPicked(@NonNull final List<Uri> uris,
@Nullable final String caption) {
if (!TextUtils.isEmpty(caption)) {
mComposeEditText.setText(caption);
mComposeEditText.setSelection(caption.length());
if (mBinding.isBound()) {
mBinding.getData().setMessageText(caption);
}
}
attachPickedUris(uris);
}
@Override
public void onTakePhoto() {
openMediaFromAttachmentSheet(ConversationInputManager.MEDIA_PICKER_TARGET_PHOTO);
}
@Override
public void onRecordVideo() {
openMediaFromAttachmentSheet(ConversationInputManager.MEDIA_PICKER_TARGET_VIDEO);
}
@Override
public void onOpenContact() {
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) {
mHost.requestMediaPermission(() ->
HomeComposeAttachPanel.reloadIfShowing(resolveActivity()));
}
}
@Override
public void onComingSoon() {
HomeToast.show(mOriginalContext, R.string.home_popover_coming_soon);
}
@Override
public void onDismiss() {
mAttachmentSheetOpen = false;
updateAttachButtonContentDescription();
updateComposeBottomStackBackground();
}
@Override
public void onAttachSheetExtentChanged(final int overlapPx) {
applyAttachSheetChatPush(overlapPx);
}
@Override
public void onRequestComposeKeyboard() {
showComposeKeyboard();
}
};
}
private void attachPickedUris(final List<Uri> uris) {
if (uris == null || uris.isEmpty()) {
return;
}
final ExecutorService executor = Executors.newSingleThreadExecutor();
final Handler handler = new Handler(Looper.getMainLooper());
executor.execute(() -> {
final List<PendingAttachmentData> pending = new ArrayList<>();
for (final Uri uri : uris) {
final String contentType = ImageUtils.getContentType(
Factory.get().getApplicationContext().getContentResolver(), uri);
if (contentType != null) {
pending.add(PendingAttachmentData.createPendingAttachmentData(contentType, uri));
}
}
handler.post(() -> {
for (final PendingAttachmentData item : pending) {
onPendingAttachmentAdded(item);
}
executor.shutdown();
});
});
}
private void applyAttachSheetChatPush(final int overlapPx) {
final Activity activity = resolveActivity();
if (activity == null) {
return;
}
final View chatArea = activity.findViewById(R.id.conversation_chat_area);
if (chatArea == null) {
return;
}
final int inset = Math.max(0, overlapPx);
if (chatArea.getPaddingBottom() != inset) {
chatArea.setPaddingRelative(
chatArea.getPaddingStart(),
chatArea.getPaddingTop(),
chatArea.getPaddingEnd(),
inset);
}
}
private void toggleComposeAttachmentSheet() {
if (mAttachmentSheetOpen) {
closeComposeAttachmentSheet(true /* animate */, null);
return;
}
final Activity activity = resolveActivity();
if (activity == null || mAttachHost == null) {
return;
}
if (mInputManager != null) {
mInputManager.showHideImeKeyboard(false, true);
mInputManager.showHideMediaPicker(false, false);
}
mAttachmentSheetOpen = true;
updateAttachButtonContentDescription();
updateComposeBottomStackBackground();
HomeComposeAttachPanel.show(activity, mAttachHost);
}
private void closeComposeAttachmentSheet(final boolean animate, final Runnable endAction) {
final boolean dismissed = HomeComposeAttachPanel.dismissIfShowing(resolveActivity());
if (!dismissed) {
applyAttachSheetChatPush(0);
}
mAttachmentSheetOpen = false;
updateAttachButtonContentDescription();
updateComposeBottomStackBackground();
if (endAction != null) {
endAction.run();
}
if (!dismissed && endAction == null) {
return;
}
}
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;
}
final Context context = getContext();
if (context instanceof Activity) {
return (Activity) context;
}
return null;
}
private void updateComposeBottomStackBackground() {
if (mComposeBottomStack == null) {
return;
}
mComposeBottomStack.setClipToOutline(false);
mComposeBottomStack.setBackgroundColor(android.graphics.Color.TRANSPARENT);
if (mComposeBar != null) {
mComposeBar.setVisibility(View.VISIBLE);
mComposeBar.setBackgroundColor(android.graphics.Color.TRANSPARENT);
}
}
private void openMediaFromAttachmentSheet(final int mediaPickerTarget) {
if (mediaPickerTarget == ConversationInputManager.MEDIA_PICKER_TARGET_GALLERY) {
closeComposeAttachmentSheet(false /* animate */, null);
mHost.launchSystemGalleryPicker();
return;
}
if (mediaPickerTarget == ConversationInputManager.MEDIA_PICKER_TARGET_CONTACT
|| mediaPickerTarget == ConversationInputManager.MEDIA_PICKER_TARGET_PHOTO
|| mediaPickerTarget == ConversationInputManager.MEDIA_PICKER_TARGET_VIDEO) {
if (mInputManager != null) {
mInputManager.showHideMediaPicker(true /* show */, false /* animate */,
mediaPickerTarget);
}
closeComposeAttachmentSheet(false /* animate */, null);
return;
}
closeComposeAttachmentSheet(true /* animate */, () -> {
if (mInputManager != null) {
mInputManager.showHideMediaPicker(true /* show */, true /* animate */,
mediaPickerTarget);
}
});
}
private void updateAttachButtonContentDescription() {
if (mAttachMediaButton == null) {
return;
}
mAttachMediaButton.setContentDescription(getResources().getText(mAttachmentSheetOpen
? R.string.home_sub_header_close
: R.string.attachMediaButtonContentDescription));
}
}
@@ -61,6 +61,10 @@ public class ConversationActivity extends BugleActionBarActivity
// Tracks whether onPause is called.
private boolean mIsPaused;
// Photo Picker is a system Activity; skip its close animation so a black window
// does not slide down over the conversation when the handle is dismissed.
private boolean mSuppressNextResumeTransition;
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -146,9 +150,28 @@ public class ConversationActivity extends BugleActionBarActivity
}
}
@Override
@Deprecated
protected void onActivityResult(final int requestCode, final int resultCode,
final Intent data) {
super.onActivityResult(requestCode, resultCode, data);
final ConversationFragment conversationFragment = getConversationFragment();
if (conversationFragment != null) {
conversationFragment.onDefaultSmsAppResult(requestCode, resultCode);
}
}
public void suppressNextResumeTransition() {
mSuppressNextResumeTransition = true;
}
@Override
protected void onResume() {
super.onResume();
if (mSuppressNextResumeTransition) {
overridePendingTransition(0, 0);
mSuppressNextResumeTransition = false;
}
// we need to reset the mInstanceStateSaved flag since we may have just been restored from
// a previous onStop() instead of an onDestroy().
@@ -0,0 +1,38 @@
/*
* Copyright (C) 2026 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.conversation;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Conversation chrome container. Compose keeps a higher elevation than the
* decorative fades so taps reach the EditText through the normal dispatch path.
*/
public class ConversationChatAreaLayout extends FrameLayout {
public ConversationChatAreaLayout(@NonNull final Context context) {
super(context);
}
public ConversationChatAreaLayout(@NonNull final Context context,
@Nullable final AttributeSet attrs) {
super(context, attrs);
}
}
File diff suppressed because it is too large Load Diff
@@ -50,6 +50,15 @@ import java.util.Collection;
* as the other components).
*/
public class ConversationInputManager implements ConversationInput.ConversationInputBase {
public static final int MEDIA_PICKER_TARGET_DEFAULT = 0;
public static final int MEDIA_PICKER_TARGET_CAMERA = 1;
public static final int MEDIA_PICKER_TARGET_GALLERY = 2;
public static final int MEDIA_PICKER_TARGET_AUDIO = 3;
public static final int MEDIA_PICKER_TARGET_CONTACT = 4;
public static final int MEDIA_PICKER_TARGET_PHOTO = 5;
public static final int MEDIA_PICKER_TARGET_VIDEO = 6;
/**
* The host component where all input components are contained. This is typically the
* conversation fragment but may be mocked in test code.
@@ -189,6 +198,18 @@ public class ConversationInputManager implements ConversationInput.ConversationI
}
public void showHideMediaPicker(final boolean show, final boolean animate) {
showHideMediaPicker(show, animate, MEDIA_PICKER_TARGET_DEFAULT);
}
public boolean isMediaPickerOpen() {
return mMediaInput.mShowing;
}
public void showHideMediaPicker(final boolean show, final boolean animate,
final int mediaPickerTarget) {
if (show) {
mMediaInput.setOpenTarget(mediaPickerTarget);
}
showHideInternal(mMediaInput, show, animate);
}
@@ -203,6 +224,16 @@ public class ConversationInputManager implements ConversationInput.ConversationI
}
public void showHideImeKeyboard(final boolean show, final boolean animate) {
if (show) {
final EditText editText = mSink.getComposeEditText();
if (editText != null) {
ImeUtil.get().showImeKeyboard(mContext, editText);
if (mConversationDataModel.isBound() && !mImeInput.mShowing) {
showHideInternal(mImeInput, true, animate);
}
return;
}
}
showHideInternal(mImeInput, show, animate);
}
@@ -243,6 +274,9 @@ public class ConversationInputManager implements ConversationInput.ConversationI
}
if (target.mShowing == show) {
if (show && target instanceof ConversationImeKeyboard) {
ImeUtil.get().showImeKeyboard(mContext, mSink.getComposeEditText());
}
return false;
}
beginUpdate();
@@ -322,6 +356,11 @@ public class ConversationInputManager implements ConversationInput.ConversationI
}
private MediaPicker mMediaPicker;
private int mOpenTarget = MEDIA_PICKER_TARGET_DEFAULT;
void setOpenTarget(final int openTarget) {
mOpenTarget = openTarget;
}
@Override
public boolean show(boolean animate) {
@@ -393,7 +432,32 @@ public class ConversationInputManager implements ConversationInput.ConversationI
});
}
mMediaPicker.open(MediaPicker.MEDIA_TYPE_DEFAULT, animate);
switch (mOpenTarget) {
case MEDIA_PICKER_TARGET_CAMERA:
mMediaPicker.openCamera(animate);
break;
case MEDIA_PICKER_TARGET_GALLERY:
mMediaPicker.openGallery(animate);
// System picker only — do not keep the in-app panel marked open.
return false;
case MEDIA_PICKER_TARGET_AUDIO:
mMediaPicker.openAudio(animate);
break;
case MEDIA_PICKER_TARGET_CONTACT:
mMediaPicker.openContact(animate);
// System picker only — do not keep the in-app panel marked open.
return false;
case MEDIA_PICKER_TARGET_PHOTO:
mMediaPicker.openTakePicture();
return false;
case MEDIA_PICKER_TARGET_VIDEO:
mMediaPicker.openTakeVideo();
return false;
default:
mMediaPicker.open(MediaPicker.MEDIA_TYPE_DEFAULT, animate);
break;
}
mOpenTarget = MEDIA_PICKER_TARGET_DEFAULT;
return isOpen();
}
@@ -436,7 +500,7 @@ public class ConversationInputManager implements ConversationInput.ConversationI
mFragmentManager.beginTransaction().replace(
R.id.mediapicker_container,
mediaPicker,
MediaPicker.FRAGMENT_TAG).commit();
MediaPicker.FRAGMENT_TAG).commitNowAllowingStateLoss();
}
return mediaPicker;
}
@@ -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;
@@ -114,6 +119,7 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
private boolean mOneOnOne;
private ConversationMessageViewHost mHost;
private String mOptimisticSendingMessageId;
public ConversationMessageView(final Context context, final AttributeSet attrs) {
super(context, attrs);
@@ -143,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);
@@ -244,6 +260,11 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
// Update our UI model
mData.bind(cursor);
if (mOptimisticSendingMessageId != null
&& (!TextUtils.equals(mOptimisticSendingMessageId, mData.getMessageId())
|| !isOutgoingFailedStatus(mData.getStatus()))) {
mOptimisticSendingMessageId = null;
}
setSelected(TextUtils.equals(mData.getMessageId(), selectedMessageId));
// Update text and image content for the view.
@@ -272,6 +293,51 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
return mData;
}
public void showSendingStatus() {
if (mData == null || !isOutgoingFailedStatus(mData.getStatus())) {
return;
}
mOptimisticSendingMessageId = mData.getMessageId();
updateViewContent();
updateViewAppearance();
}
private boolean isOptimisticSending() {
return mOptimisticSendingMessageId != null
&& mData != null
&& TextUtils.equals(mOptimisticSendingMessageId, mData.getMessageId());
}
private static boolean isOutgoingFailedStatus(final int status) {
return status == MessageData.BUGLE_STATUS_OUTGOING_FAILED
|| 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).
@@ -340,12 +406,12 @@ 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:
statusResId = R.string.message_status_sending;
break;
case MessageData.BUGLE_STATUS_OUTGOING_RESENDING:
case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
statusResId = R.string.message_status_send_retrying;
statusText = getResources().getString(R.string.home_message_status_waiting);
break;
case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER:
@@ -354,13 +420,12 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
case MessageData.BUGLE_STATUS_OUTGOING_FAILED:
// don't show the error state unless we're the default sms app
if (isOptimisticSending()) {
statusResId = R.string.message_status_sending;
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
@@ -416,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 =
@@ -450,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);
@@ -697,7 +767,7 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
textBottomPadding = textBottomPaddingDefault;
textLeftPadding = messageTextLeftRightPadding;
textRightPadding = messageTextLeftRightPadding;
mMessageTextView.setTextIsSelectable(isSelected());
mMessageTextView.setTextIsSelectable(false);
} else {
// Attachment(s) only
contentLeftPadding = incoming ? arrowWidth : 0;
@@ -723,7 +793,7 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
textTopMargin = 0;
textTopPadding = textTopPaddingDefault;
textBottomPadding = textBottomPaddingDefault;
mMessageTextView.setTextIsSelectable(isSelected());
mMessageTextView.setTextIsSelectable(false);
textLeftPadding = messageTextLeftRightPadding;
textRightPadding = messageTextLeftRightPadding;
}
@@ -760,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();
@@ -817,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);
@@ -908,7 +985,11 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
case MessageData.BUGLE_STATUS_OUTGOING_FAILED:
case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER:
timestampColorResId = R.color.message_failed_timestamp_text;
if (isOptimisticSending()) {
timestampColorResId = R.color.timestamp_text_outgoing;
} else {
timestampColorResId = R.color.message_failed_timestamp_text;
}
subjectLabelColorResId = R.color.timestamp_text_outgoing;
break;
@@ -964,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);
}
/**
@@ -25,8 +25,8 @@ import android.content.Intent;
import android.content.res.Resources;
import android.net.Uri;
import android.os.Bundle;
import android.view.ActionMode;
import android.view.View;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
@@ -36,14 +36,20 @@ import androidx.appcompat.app.ActionBar;
import com.android.messaging.R;
import com.android.messaging.datamodel.action.DeleteConversationAction;
import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction;
import com.android.messaging.datamodel.action.UpdateConversationTrashStatusAction;
import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction;
import com.android.messaging.datamodel.data.ConversationListData;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.SnackBar;
import com.android.messaging.ui.SnackBarInteraction;
import com.android.messaging.datamodel.action.MarkAsUnreadAction;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.contact.AddContactsConfirmationDialog;
import com.android.messaging.ui.home.HomeDeleteAlertDialog;
import com.android.messaging.ui.home.HomeInboxMenu;
import com.android.messaging.ui.home.HomeModal;
import com.android.messaging.ui.home.HomeToast;
import com.android.messaging.ui.conversationlist.ConversationListFragment.ConversationListFragmentHost;
import com.android.messaging.ui.conversationlist.MultiSelectActionModeCallback.SelectedConversation;
import com.android.messaging.util.ChangeDefaultSmsAppHelper;
@@ -61,6 +67,10 @@ import java.util.List;
public abstract class AbstractConversationListActivity extends BugleActionBarActivity
implements ConversationListFragmentHost, MultiSelectActionModeCallback.Listener {
private static final UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener
NOOP_BLOCK_LISTENER =
(action, success, block, destination) -> { };
protected ConversationListFragment mConversationListFragment;
private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper;
@@ -90,22 +100,147 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
return false;
}
protected boolean isTrashList() {
return false;
}
@Override
protected boolean shouldShowContextualActionBar(final ActionMode.Callback callback) {
if (useHomeSelectionChrome() && callback instanceof MultiSelectActionModeCallback) {
return !((MultiSelectActionModeCallback) callback).useHomeSelectionChrome();
}
return super.shouldShowContextualActionBar(callback);
}
public void enterHomeEditMode() {
if (!hasVisibleConversations()) {
HomeToast.show(this, R.string.home_toast_nothing_to_select);
return;
}
if (!isInConversationListSelectMode()) {
startMultiSelectActionMode();
}
}
private boolean hasVisibleConversations() {
return mConversationListFragment != null
&& mConversationListFragment.getVisibleConversationCount() > 0;
}
public void onSubListSelectionMode(final boolean enabled) {
onSubListSelectionCount(0);
HomeInboxMenu.setSelecting(this, enabled);
}
public void onSubListSelectionCount(final int count) {
final int visible = mConversationListFragment == null
? 0 : mConversationListFragment.getVisibleConversationCount();
final boolean hasWork = count > 0 || visible > 0;
if (isTrashList()) {
HomeInboxMenu.setSelectAction(this,
count > 0 ? R.drawable.ic_delete_small_dark : R.drawable.ic_home_menu_trash,
count > 0 ? R.string.home_delete_confirm : R.string.home_empty_trash_action,
hasWork, true);
} else {
HomeInboxMenu.setSelectAction(this, R.drawable.ic_home_menu_archive,
count > 0 ? R.string.home_sub_unarchive : R.string.home_sub_unarchive_all,
hasWork, false);
}
}
public void onSubListPrimaryAction() {
if (isTrashList()) {
emptyTrashOrDeleteSelected();
} else {
unarchiveAllOrSelected();
}
}
private void unarchiveAllOrSelected() {
final Collection<SelectedConversation> selected = getSelectedConversationsIfAny();
if (selected != null && !selected.isEmpty()) {
restoreSelectedConversations();
return;
}
if (mConversationListFragment == null) {
return;
}
final List<ConversationListItemData> items =
mConversationListFragment.snapshotVisibleConversations();
if (items.isEmpty()) {
HomeToast.show(this, R.string.home_toast_nothing_to_unarchive);
return;
}
for (final ConversationListItemData item : items) {
UpdateConversationArchiveStatusAction.unarchiveConversation(item.getConversationId());
}
if (items.size() == 1) {
HomeToast.show(this, R.string.home_profile_unarchived_toast);
} else {
HomeToast.show(this, getString(R.string.home_sub_unarchived_toast_many, items.size()));
}
exitMultiSelectState();
}
private void emptyTrashOrDeleteSelected() {
final Collection<SelectedConversation> selected = getSelectedConversationsIfAny();
if (selected != null && !selected.isEmpty()) {
confirmPermanentDelete(selected);
return;
}
if (mConversationListFragment == null) {
return;
}
final List<ConversationListItemData> items =
mConversationListFragment.snapshotVisibleConversations();
if (items.isEmpty()) {
HomeToast.show(this, R.string.home_toast_nothing_to_empty);
return;
}
final ArrayList<SelectedConversation> all = new ArrayList<>(items.size());
for (final ConversationListItemData item : items) {
all.add(new SelectedConversation(item));
}
confirmEmptyAll(all);
}
private Collection<SelectedConversation> getSelectedConversationsIfAny() {
if (!isInConversationListSelectMode()) {
return null;
}
final MultiSelectActionModeCallback callback =
(MultiSelectActionModeCallback) getActionModeCallback();
return callback.getSelectedConversations();
}
protected void startMultiSelectActionMode() {
startActionMode(new MultiSelectActionModeCallback(this, useHomeSelectionChrome()));
mConversationListFragment.dismissFab();
if (useHomeSelectionChrome()) {
mConversationListFragment.setHomeSelectionMode(true);
if (mConversationListFragment == null) {
return;
}
final boolean homeChrome = useHomeSelectionChrome();
if (homeChrome) {
hideHomeContextualActionBar();
mConversationListFragment.dismissFab(true);
}
startActionMode(new MultiSelectActionModeCallback(this, homeChrome));
if (homeChrome) {
mConversationListFragment.setHomeSelectionMode(true);
} else {
mConversationListFragment.dismissFab();
}
}
public void exitMultiSelectState() {
dismissActionMode();
if (mConversationListFragment == null) {
return;
}
if (useHomeSelectionChrome()) {
mConversationListFragment.setHomeSelectionMode(false);
} else {
mConversationListFragment.updateUi();
}
mConversationListFragment.showFab();
dismissActionMode();
mConversationListFragment.updateUi();
}
protected void hideHomeContextualActionBar() {
@@ -121,7 +256,149 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
}
final MultiSelectActionModeCallback callback =
(MultiSelectActionModeCallback) getActionModeCallback();
onActionBarDelete(callback.getSelectedConversations());
final Collection<SelectedConversation> conversations =
callback.getSelectedConversations();
if (conversations.isEmpty()) {
return;
}
if (isTrashList()) {
confirmPermanentDelete(conversations);
return;
}
if (useHomeSelectionChrome()) {
showHomeDeleteAlert(conversations);
} else {
onActionBarDelete(conversations);
}
}
public void confirmDeleteConversation(final ConversationListItemData data) {
if (data == null) {
return;
}
final java.util.List<SelectedConversation> one = java.util.Collections.singletonList(
new MultiSelectActionModeCallback.SelectedConversation(data));
if (isTrashList()) {
confirmPermanentDelete(one);
return;
}
showHomeDeleteAlert(one);
}
public void markSelectedConversationsUnread() {
if (!isInConversationListSelectMode()) {
return;
}
final MultiSelectActionModeCallback callback =
(MultiSelectActionModeCallback) getActionModeCallback();
for (final MultiSelectActionModeCallback.SelectedConversation conversation
: callback.getSelectedConversations()) {
MarkAsUnreadAction.markAsUnread(conversation.conversationId);
}
exitMultiSelectState();
}
public void restoreSelectedConversations() {
if (!isInConversationListSelectMode()) {
return;
}
final MultiSelectActionModeCallback callback =
(MultiSelectActionModeCallback) getActionModeCallback();
final Collection<SelectedConversation> conversations =
callback.getSelectedConversations();
if (conversations.isEmpty()) {
return;
}
for (final SelectedConversation conversation : conversations) {
if (isTrashList()) {
UpdateConversationTrashStatusAction.restoreFromTrash(conversation.conversationId);
} else {
UpdateConversationArchiveStatusAction.unarchiveConversation(
conversation.conversationId);
}
}
HomeToast.show(this, isTrashList()
? R.string.home_trash_restored_toast
: R.string.home_profile_unarchived_toast);
exitMultiSelectState();
}
public void archiveSelectedConversations() {
if (!isInConversationListSelectMode()) {
return;
}
final MultiSelectActionModeCallback callback =
(MultiSelectActionModeCallback) getActionModeCallback();
final Collection<SelectedConversation> conversations =
callback.getSelectedConversations();
if (conversations.isEmpty()) {
return;
}
int count = 0;
for (final SelectedConversation conversation : conversations) {
UpdateConversationArchiveStatusAction.archiveConversation(
conversation.conversationId);
count++;
}
if (count == 1) {
HomeToast.show(this, R.string.home_profile_archived_toast);
} else {
HomeToast.show(this, getString(R.string.home_selection_archived_toast_many, count));
}
exitMultiSelectState();
}
public void reportSelectedConversationsSpam() {
if (!isInConversationListSelectMode()) {
return;
}
final MultiSelectActionModeCallback callback =
(MultiSelectActionModeCallback) getActionModeCallback();
final Collection<SelectedConversation> conversations = new ArrayList<>(
callback.getSelectedConversations());
int reportableCount = 0;
for (final SelectedConversation conversation : conversations) {
if (!conversation.isGroup
&& conversation.otherParticipantNormalizedDestination != null) {
reportableCount++;
}
}
if (reportableCount == 0) {
HomeToast.show(this, R.string.home_report_spam_groups_only);
return;
}
final int reportable = reportableCount;
final String message = reportable == 1
? getString(R.string.home_report_spam_message_one)
: getString(R.string.home_report_spam_message_many, reportable);
HomeModal.builder(this)
.icon(R.drawable.ic_home_menu_spam)
.title(R.string.home_report_spam_title)
.message(message)
.negative(android.R.string.cancel, null)
.positive(R.string.home_unknown_sender_report_spam, () ->
performReportSelectedSpam(conversations, reportable))
.show();
}
private void performReportSelectedSpam(
final Collection<SelectedConversation> conversations, final int reportable) {
for (final SelectedConversation conversation : conversations) {
if (conversation.isGroup
|| conversation.otherParticipantNormalizedDestination == null) {
continue;
}
UpdateDestinationBlockedAction.updateDestinationBlocked(
conversation.otherParticipantNormalizedDestination, true,
conversation.conversationId,
NOOP_BLOCK_LISTENER);
}
if (reportable == 1) {
HomeToast.show(this, R.string.home_report_spam_toast_one);
} else {
HomeToast.show(this, getString(R.string.home_report_spam_toast_many, reportable));
}
exitMultiSelectState();
}
protected boolean isInConversationListSelectMode() {
@@ -133,23 +410,104 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
return isInConversationListSelectMode();
}
private void showHomeDeleteAlert(
final Collection<SelectedConversation> conversations) {
if (!ensureDefaultSmsAppForDelete()) {
return;
}
HomeDeleteAlertDialog.show(this, conversations.size(),
() -> performDeleteConversations(conversations));
}
private boolean ensureDefaultSmsAppForDelete() {
if (PhoneUtils.getDefault().isDefaultSmsApp()) {
return true;
}
// TODO: figure out a good way to combine this with the implementation in
// ConversationFragment doing similar things
final Activity activity = this;
UiUtils.showSnackBarWithCustomAction(this,
getWindow().getDecorView().getRootView(),
getString(R.string.home_requires_default_sms_app),
SnackBar.Action.createCustomAction(() -> {
final Intent intent =
UIIntents.get().getChangeDefaultSmsAppIntent(activity);
mLauncher.launch(intent);
},
getString(R.string.home_requires_default_sms_change)),
null /* interactions */,
null /* placement */);
return false;
}
private void performDeleteConversations(
final Collection<SelectedConversation> conversations) {
final int count = conversations.size();
if (isTrashList()) {
for (final SelectedConversation conversation : conversations) {
DeleteConversationAction.deleteConversation(
conversation.conversationId,
conversation.timestamp);
}
showDeleteCompletedToast(count);
} else {
for (final SelectedConversation conversation : conversations) {
UpdateConversationTrashStatusAction.moveToTrash(conversation.conversationId);
}
if (useHomeSelectionChrome() && count > 0) {
HomeToast.show(this, count == 1
? getString(R.string.home_trash_moved_toast_one)
: getString(R.string.home_trash_moved_toast_many, count));
}
}
if (isInConversationListSelectMode()) {
exitMultiSelectState();
}
}
private void confirmPermanentDelete(final Collection<SelectedConversation> conversations) {
if (!ensureDefaultSmsAppForDelete()) {
return;
}
final int count = conversations.size();
HomeModal.builder(this)
.icon(R.drawable.ic_home_menu_trash)
.title(R.string.home_trash_permanent_title)
.message(count == 1
? getString(R.string.home_trash_permanent_message_one)
: getString(R.string.home_trash_permanent_message_many, count))
.negative(android.R.string.cancel, null)
.positive(R.string.home_delete_confirm, () ->
performDeleteConversations(conversations))
.show();
}
private void confirmEmptyAll(final Collection<SelectedConversation> conversations) {
if (!ensureDefaultSmsAppForDelete()) {
return;
}
HomeModal.builder(this)
.icon(R.drawable.ic_home_menu_trash)
.title(R.string.home_empty_all_title)
.message(R.string.home_empty_all_message)
.negative(android.R.string.cancel, null)
.positive(R.string.home_empty_all_confirm, () ->
performDeleteConversations(conversations))
.show();
}
private void showDeleteCompletedToast(final int count) {
if (!useHomeSelectionChrome() || count <= 0) {
return;
}
HomeToast.show(this, count == 1
? getString(R.string.home_delete_toast_one)
: getString(R.string.home_delete_toast_many, count));
}
@Override
public void onActionBarDelete(final Collection<SelectedConversation> conversations) {
if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
// TODO: figure out a good way to combine this with the implementation in
// ConversationFragment doing similar things
final Activity activity = this;
UiUtils.showSnackBarWithCustomAction(this,
getWindow().getDecorView().getRootView(),
getString(R.string.requires_default_sms_app),
SnackBar.Action.createCustomAction(() -> {
final Intent intent =
UIIntents.get().getChangeDefaultSmsAppIntent(activity);
mLauncher.launch(intent);
},
getString(R.string.requires_default_sms_change_button)),
null /* interactions */,
null /* placement */);
if (!ensureDefaultSmsAppForDelete()) {
return;
}
@@ -158,14 +516,7 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
R.plurals.delete_conversations_confirmation_dialog_title,
conversations.size()))
.setPositiveButton(R.string.delete_conversation_confirmation_button,
(dialog, button) -> {
for (final SelectedConversation conversation : conversations) {
DeleteConversationAction.deleteConversation(
conversation.conversationId,
conversation.timestamp);
}
exitMultiSelectState();
})
(dialog, button) -> performDeleteConversations(conversations))
.setNegativeButton(R.string.delete_conversation_decline_button, null)
.show();
}
@@ -256,7 +607,7 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
@Override
public void onSelectionCountChanged(final int count) {
if (useHomeSelectionChrome()) {
mConversationListFragment.updateHomeSelectionDeleteEnabled(count);
mConversationListFragment.updateHomeSelectionBar(count);
hideHomeContextualActionBar();
}
}
@@ -16,37 +16,143 @@
*/
package com.android.messaging.ui.conversationlist;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle;
import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;
import androidx.appcompat.app.ActionBar;
import androidx.core.content.ContextCompat;
import com.android.messaging.R;
import com.android.messaging.ui.home.HomeAutomationHome;
import com.android.messaging.ui.home.HomeEmptyState;
import com.android.messaging.ui.home.HomeInboxMenu;
import com.android.messaging.ui.home.HomeSubHeader;
public class ArchivedConversationListActivity extends AbstractConversationListActivity {
private static final String STATE_SHOW_RULES = "home_archived_show_rules";
private TextView mChatsTab;
private TextView mRulesTab;
private View mListHost;
private View mRulesHost;
private View mSearchCollapse;
private boolean mShowRules;
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.home_archived_activity);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
HomeSubHeader.bind(this, R.string.home_archived_title, v -> onActionBarHome());
HomeInboxMenu.bind(this, HomeInboxMenu.Screen.ARCHIVE);
bindTabs();
if (savedInstanceState != null) {
mShowRules = savedInstanceState.getBoolean(STATE_SHOW_RULES, false);
}
showRules(mShowRules);
mConversationListFragment =
ConversationListFragment.createArchivedConversationListFragment();
getSupportFragmentManager()
.beginTransaction()
.add(android.R.id.content, mConversationListFragment)
.commit();
invalidateActionBar();
if (savedInstanceState == null) {
mConversationListFragment =
ConversationListFragment.createArchivedConversationListFragment();
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.home_archived_list_host, mConversationListFragment)
.commit();
} else {
mConversationListFragment = (ConversationListFragment) getSupportFragmentManager()
.findFragmentById(R.id.home_archived_list_host);
}
}
protected void updateActionBar(ActionBar actionBar) {
actionBar.setTitle(getString(R.string.archived_activity_title));
actionBar.setDisplayShowTitleEnabled(true);
actionBar.setDisplayHomeAsUpEnabled(true);
actionBar.setBackgroundDrawable(new ColorDrawable(
getResources().getColor(
R.color.archived_conversation_action_bar_background_color_dark)));
actionBar.show();
@Override
protected void onSaveInstanceState(final Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBoolean(STATE_SHOW_RULES, mShowRules);
}
private void bindTabs() {
final View tabs = findViewById(R.id.home_archived_tabs);
mChatsTab = findViewById(R.id.home_archived_tab_chats);
mRulesTab = findViewById(R.id.home_archived_tab_rules);
mListHost = findViewById(R.id.home_archived_list_host);
mRulesHost = findViewById(R.id.home_archived_rules_host);
mSearchCollapse = findViewById(R.id.home_sub_search_collapse);
if (tabs != null) {
tabs.setVisibility(View.VISIBLE);
}
if (mRulesHost != null) {
HomeAutomationHome.bindArchive(mRulesHost);
final HomeEmptyState empty = findViewById(R.id.home_archived_rules_empty);
if (empty != null) {
empty.setContent(R.drawable.ic_home_menu_archive,
R.string.home_archived_rules_empty_title,
R.string.home_archived_rules_empty_body);
}
}
if (mChatsTab != null) {
mChatsTab.setOnClickListener(v -> showRules(false));
}
if (mRulesTab != null) {
mRulesTab.setOnClickListener(v -> showRules(true));
}
}
private void showRules(final boolean rules) {
if (rules && isInConversationListSelectMode()) {
exitMultiSelectState();
}
mShowRules = rules;
applyPill(mChatsTab, !rules);
applyPill(mRulesTab, rules);
if (mListHost != null) {
mListHost.setVisibility(rules ? View.GONE : View.VISIBLE);
}
if (mRulesHost != null) {
mRulesHost.setVisibility(rules ? View.VISIBLE : View.GONE);
}
if (mSearchCollapse != null) {
mSearchCollapse.setVisibility(rules ? View.GONE : View.VISIBLE);
}
final View edit = findViewById(R.id.home_sub_header_edit);
if (edit != null) {
edit.setVisibility(rules ? View.GONE : View.VISIBLE);
}
final View selectBar = findViewById(R.id.home_sub_select_bar);
if (selectBar != null && rules) {
selectBar.setVisibility(View.GONE);
}
if (rules && mRulesHost != null) {
HomeAutomationHome.bindArchive(mRulesHost);
}
}
private void applyPill(final TextView pill, final boolean selected) {
if (pill == null) {
return;
}
pill.setBackgroundResource(selected
? R.drawable.home_bg_automation_pill_selected
: R.drawable.home_bg_automation_pill);
pill.setTextColor(ContextCompat.getColor(this,
selected ? android.R.color.white : R.color.home_text_primary));
}
@Override
protected boolean useHomeSelectionChrome() {
return true;
}
@Override
protected void updateActionBar(final ActionBar actionBar) {
if (actionBar == null) {
return;
}
actionBar.hide();
super.updateActionBar(actionBar);
}
@@ -60,7 +166,7 @@ public class ArchivedConversationListActivity extends AbstractConversationListAc
}
@Override
public boolean onOptionsItemSelected(MenuItem menuItem) {
public boolean onOptionsItemSelected(final MenuItem menuItem) {
if (menuItem.getItemId() == android.R.id.home) {
onActionBarHome();
return true;
@@ -75,6 +181,12 @@ public class ArchivedConversationListActivity extends AbstractConversationListAc
@Override
public boolean isSwipeAnimatable() {
return false;
return !isInConversationListSelectMode();
}
@Override
public void finish() {
super.finish();
overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
}
}
@@ -18,11 +18,9 @@
package com.android.messaging.ui.conversationlist;
import android.os.Bundle;
import android.view.ActionMode;
import android.window.SplashScreenView;
import android.view.Menu;
import android.view.MenuItem;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.widget.PopupMenu;
@@ -36,6 +34,7 @@ public class ConversationListActivity extends AbstractConversationListActivity {
@Override
protected void onCreate(final Bundle savedInstanceState) {
Trace.beginSection("ConversationListActivity.onCreate");
getSplashScreen().setOnExitAnimationListener(SplashScreenView::remove);
setTheme(R.style.BugleTheme_ConversationListActivity);
super.onCreate(savedInstanceState);
mConversationListFragment = ConversationListFragment.createConversationListFragment(null);
@@ -52,20 +51,12 @@ public class ConversationListActivity extends AbstractConversationListActivity {
return true;
}
@Override
protected boolean shouldShowContextualActionBar(final ActionMode.Callback callback) {
if (callback instanceof MultiSelectActionModeCallback) {
return !((MultiSelectActionModeCallback) callback).useHomeSelectionChrome();
}
return true;
}
@Override
protected void updateActionBar(final ActionBar actionBar) {
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);
}
@@ -80,6 +71,9 @@ public class ConversationListActivity extends AbstractConversationListActivity {
public void onBackPressed() {
if (isInConversationListSelectMode()) {
exitMultiSelectState();
} else if (mConversationListFragment != null
&& mConversationListFragment.onHomeBackPressed()) {
return;
} else {
super.onBackPressed();
}
@@ -146,13 +140,7 @@ public class ConversationListActivity extends AbstractConversationListActivity {
}
public void showAutomateComingSoon() {
Toast.makeText(this, R.string.home_automate_coming_soon, Toast.LENGTH_SHORT).show();
}
public void enterHomeEditMode() {
if (!isInConversationListSelectMode()) {
startMultiSelectActionMode();
}
UIIntents.get().launchHomeAutomationActivity(this);
}
@Override
@@ -32,6 +32,10 @@ import androidx.recyclerview.widget.RecyclerView;
import com.android.messaging.R;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.ui.CursorRecyclerAdapter;
import com.android.messaging.ui.home.HomeCategoryClassifier;
import com.android.messaging.ui.home.HomeCategoryStore;
import com.android.messaging.ui.home.HomeInboxFilter;
import com.android.messaging.ui.home.HomeSubListSearch;
import com.android.messaging.ui.home.PinnedConversationStore;
import java.util.ArrayList;
@@ -49,14 +53,19 @@ public class ConversationListAdapter
public static final int FILTER_ALL = 0;
public static final int FILTER_UNREAD = 1;
public static final int FILTER_GROUPS = 2;
public static final int FILTER_WORK = 3;
public static final int FILTER_PERSONAL = 4;
private static final int VIEW_TYPE_PINNED_HEADER = 1;
private static final int VIEW_TYPE_CONVERSATION = 2;
private static final long PINNED_HEADER_ITEM_ID = -9001L;
static final Object PAYLOAD_SELECTION_CHROME = new Object();
private final ConversationListItemView.HostInterface mClivHostInterface;
private final ConversationListItemData mFilterProbe = new ConversationListItemData();
private int mFilterMode = FILTER_ALL;
private String mCategoryId = HomeCategoryStore.ID_ALL;
private String mSearchQuery = "";
private int[] mDisplayPositions = new int[0];
private View mPinnedStripRoot;
private boolean mPinnedHeaderVisible;
@@ -112,6 +121,93 @@ public class ConversationListAdapter
return mFilterMode;
}
public void setSearchQuery(final String query) {
final String next = query == null ? "" : query.trim();
if (next.equals(mSearchQuery)) {
return;
}
mSearchQuery = next;
rebuildFilterIndex();
notifyDataSetChanged();
}
public String getSearchQuery() {
return mSearchQuery;
}
public boolean hasSearchQuery() {
return !mSearchQuery.isEmpty();
}
public void setCategoryId(final String categoryId) {
final String next = categoryId == null ? HomeCategoryStore.ID_ALL : categoryId;
if (next.equals(mCategoryId)) {
return;
}
mCategoryId = next;
rebuildFilterIndex();
notifyDataSetChanged();
}
public String getCategoryId() {
return mCategoryId;
}
public void refreshCategoryFilter() {
rebuildFilterIndex();
notifyDataSetChanged();
}
public List<ConversationListItemData> snapshotConversations() {
final Cursor cursor = getCursor();
if (cursor == null || cursor.getCount() == 0) {
return Collections.emptyList();
}
final List<ConversationListItemData> items = new ArrayList<>(cursor.getCount());
final int saved = cursor.getPosition();
for (int i = 0; i < cursor.getCount(); i++) {
if (!cursor.moveToPosition(i)) {
continue;
}
final ConversationListItemData item = new ConversationListItemData();
item.bind(cursor);
items.add(item);
}
if (saved >= 0 && saved < cursor.getCount()) {
cursor.moveToPosition(saved);
}
return items;
}
/** Visible (filtered) conversation rows, not the raw cursor. */
public List<ConversationListItemData> snapshotVisibleConversations() {
final Cursor cursor = getCursor();
if (cursor == null || mDisplayPositions.length == 0) {
return Collections.emptyList();
}
final List<ConversationListItemData> items = new ArrayList<>(mDisplayPositions.length);
final int saved = cursor.getPosition();
for (final int position : mDisplayPositions) {
if (!cursor.moveToPosition(position)) {
continue;
}
final ConversationListItemData item = new ConversationListItemData();
item.bind(cursor);
items.add(item);
}
if (saved >= 0 && saved < cursor.getCount()) {
cursor.moveToPosition(saved);
}
return items;
}
public void notifySelectionChromeChanged() {
final int count = getItemCount();
if (count > 0) {
notifyItemRangeChanged(0, count, PAYLOAD_SELECTION_CHROME);
}
}
public void refreshPinOrdering() {
refreshPinOrderingAnimated();
}
@@ -237,16 +333,25 @@ public class ConversationListAdapter
}
private boolean passesFilter() {
if (mFilterMode == FILTER_ALL) {
return true;
if (mFilterMode == FILTER_UNREAD && mFilterProbe.getIsRead()) {
return false;
}
if (mFilterMode == FILTER_UNREAD) {
return !mFilterProbe.getIsRead();
if (mFilterMode == FILTER_GROUPS && mFilterProbe.getParticipantCount() <= 1) {
return false;
}
if (mFilterMode == FILTER_GROUPS) {
return mFilterProbe.getParticipantCount() > 1;
if (mFilterMode == FILTER_WORK && !HomeInboxFilter.matchesWork(mContext, mFilterProbe)) {
return false;
}
return true;
if (mFilterMode == FILTER_PERSONAL
&& !HomeInboxFilter.matchesPersonal(mContext, mFilterProbe)) {
return false;
}
if (!HomeSubListSearch.matches(mSearchQuery,
mFilterProbe.getName(), mFilterProbe.getSnippetText(),
mFilterProbe.getSnippetSenderName())) {
return false;
}
return HomeCategoryClassifier.matches(mContext, mCategoryId, mFilterProbe);
}
private void sortPinnedFirst(final ArrayList<Integer> positions, final Cursor cursor) {
@@ -323,6 +428,22 @@ public class ConversationListAdapter
return createConversationViewHolder(parent);
}
@Override
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder,
final int position, @NonNull final List<Object> payloads) {
if (!payloads.isEmpty() && payloads.contains(PAYLOAD_SELECTION_CHROME)) {
if (holder instanceof PinnedHeaderViewHolder) {
attachPinnedStripToHeader((PinnedHeaderViewHolder) holder);
return;
}
if (holder instanceof ConversationListViewHolder) {
((ConversationListViewHolder) holder).mView.rebindSelectionChrome();
return;
}
}
onBindViewHolder(holder, position);
}
@Override
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder,
final int position) {
@@ -348,8 +469,8 @@ public class ConversationListAdapter
container.setLayoutParams(new RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT));
container.setClipChildren(true);
container.setClipToPadding(true);
container.setClipChildren(false);
container.setClipToPadding(false);
return new PinnedHeaderViewHolder(container);
}
@@ -20,14 +20,21 @@ import android.content.Context;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.support.v7.mms.pdu.ContentType;
import android.text.Layout;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.style.LeadingMarginSpan;
import android.telephony.PhoneNumberUtils;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.View.OnLayoutChangeListener;
@@ -39,7 +46,6 @@ import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.core.content.res.ResourcesCompat;
import androidx.core.text.BidiFormatter;
import androidx.core.text.TextDirectionHeuristicsCompat;
@@ -47,7 +53,9 @@ import com.android.messaging.Factory;
import com.android.messaging.R;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction;
import com.android.messaging.datamodel.action.UpdateConversationTrashStatusAction;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.datamodel.data.ConversationMessageData;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
import com.android.messaging.sms.MmsUtils;
@@ -56,9 +64,13 @@ import com.android.messaging.ui.AudioAttachmentView;
import com.android.messaging.ui.ContactIconView;
import com.android.messaging.ui.SnackBar;
import com.android.messaging.ui.SnackBarInteraction;
import com.android.messaging.ui.home.HomeAvatarOverlay;
import com.android.messaging.ui.home.HomeToast;
import com.android.messaging.ui.home.MutedConversationStore;
import com.android.messaging.util.Assert;
import com.android.messaging.util.ImageUtils;
import com.android.messaging.util.PhoneUtils;
import com.android.messaging.util.SimNameUtil;
import com.android.messaging.util.Typefaces;
import com.android.messaging.util.UiUtils;
import com.android.messaging.util.UriUtil;
@@ -72,8 +84,9 @@ import java.util.List;
*/
public class ConversationListItemView extends FrameLayout implements OnClickListener,
OnLongClickListener, OnLayoutChangeListener {
static final int UNREAD_SNIPPET_LINE_COUNT = 2;
static final int NO_UNREAD_SNIPPET_LINE_COUNT = 2;
static final int UNREAD_SNIPPET_LINE_COUNT = 3;
static final int NO_UNREAD_SNIPPET_LINE_COUNT = 3;
private static final int SNIPPET_ELLIPSIS_RESERVE_CHARS = 5;
private static final int LIST_DIVIDER_INSET_DEFAULT_DP = 74;
private static final int LIST_DIVIDER_INSET_SELECTION_DP = 104;
private int mListItemReadColor;
@@ -87,6 +100,8 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
boolean isConversationSelected(final String conversationId);
void onConversationClicked(final ConversationListItemData conversationListItemData,
boolean isLongClick, final ConversationListItemView conversationView);
void onConversationAvatarClicked(final ConversationListItemData conversationListItemData,
final View avatarView);
boolean isSwipeAnimatable();
List<SnackBarInteraction> getSnackBarInteractions();
void startFullScreenPhotoViewer(final Uri initialPhoto, final Rect initialPhotoBounds,
@@ -96,6 +111,8 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
boolean shouldShowConversationPinButton();
boolean isConversationPinned(final String conversationId);
void onConversationPinButtonClicked(final String conversationId);
void onConversationSwipeMuteClicked(ConversationListItemData data);
void onConversationSwipeDeleteClicked(ConversationListItemData data);
}
private final OnClickListener fullScreenPreviewClickListener = new OnClickListener() {
@@ -142,8 +159,11 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
private ContactIconView mContactIconView;
private ImageView mContactCheckmarkView;
private ImageView mFailedStatusIconView;
private ImageView mMuteBadgeView;
private ImageView mCrossSwipeArchiveLeftImageView;
private ImageView mCrossSwipeArchiveRightImageView;
private View mCrossSwipeRightActions;
private ImageView mCrossSwipeMuteView;
private ImageView mCrossSwipeDeleteView;
private AsyncImageView mImagePreviewView;
private AudioAttachmentView mAudioAttachmentView;
private ImageView mChevronView;
@@ -153,6 +173,7 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
private static final int SELECTION_TOGGLE_ANIM_MS = 180;
private String mSelectionStateConversationId;
private Boolean mSelectionStateMode;
private Boolean mSelectionStateChecked;
public ConversationListItemView(final Context context, final AttributeSet attrs) {
@@ -181,8 +202,27 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
mContactIconView = findViewById(R.id.conversation_icon);
mContactCheckmarkView = findViewById(R.id.conversation_checkmark);
mFailedStatusIconView = findViewById(R.id.conversation_failed_status_icon);
mMuteBadgeView = findViewById(R.id.conversation_mute_badge);
mCrossSwipeArchiveLeftImageView = findViewById(R.id.crossSwipeArchiveIconLeft);
mCrossSwipeArchiveRightImageView = findViewById(R.id.crossSwipeArchiveIconRight);
mCrossSwipeRightActions = findViewById(R.id.cross_swipe_right_actions);
mCrossSwipeMuteView = findViewById(R.id.cross_swipe_mute);
mCrossSwipeDeleteView = findViewById(R.id.cross_swipe_delete);
if (mCrossSwipeMuteView != null) {
mCrossSwipeMuteView.setOnClickListener(v -> {
if (mHostInterface != null) {
mHostInterface.onConversationSwipeMuteClicked(mData);
}
setSwipeTranslationX(0);
});
}
if (mCrossSwipeDeleteView != null) {
mCrossSwipeDeleteView.setOnClickListener(v -> {
if (mHostInterface != null) {
mHostInterface.onConversationSwipeDeleteClicked(mData);
}
setSwipeTranslationX(0);
});
}
mImagePreviewView = findViewById(R.id.conversation_image_preview);
mAudioAttachmentView = findViewById(R.id.audio_attachment_view);
mChevronView = findViewById(R.id.conversation_chevron);
@@ -190,6 +230,9 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
mListDividerView = findViewById(R.id.conversation_list_divider);
mConversationNameView.addOnLayoutChangeListener(this);
mSnippetTextView.addOnLayoutChangeListener(this);
if (mCategoryChipView != null) {
mCategoryChipView.addOnLayoutChangeListener(this);
}
final Resources resources = getContext().getResources();
final Resources.Theme theme = getContext().getTheme();
@@ -212,28 +255,32 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
setSnippet();
} else if (v == mSubjectTextView) {
setSubject();
} else if (v == mCategoryChipView) {
alignSimBadgeToFirstSnippetLine();
}
}
private void setWorkProfileIcon() {
mWorkProfileIconView.setVisibility(mData.isEnterprise() ? View.VISIBLE : View.GONE);
if (mCategoryChipView != null) {
final boolean selectionMode =
mHostInterface != null && mHostInterface.isSelectionMode();
if (selectionMode) {
mCategoryChipView.setVisibility(View.GONE);
} else {
mCategoryChipView.setVisibility(View.VISIBLE);
mCategoryChipView.setText(mData.isEnterprise()
? R.string.home_chip_work
: R.string.home_chip_personal);
}
setSimBadge();
}
private void setSimBadge() {
if (mCategoryChipView == null) {
return;
}
if (!SimNameUtil.shouldShowSimName(getContext())) {
mCategoryChipView.setText(null);
mCategoryChipView.setVisibility(GONE);
mCategoryChipView.setTranslationY(0f);
return;
}
mCategoryChipView.setText(SimNameUtil.getSimDisplayName(getContext(), mData.getSelfId()));
mCategoryChipView.setVisibility(VISIBLE);
alignSimBadgeToFirstSnippetLine();
}
private void setConversationName() {
final boolean selectionMode =
mHostInterface != null && mHostInterface.isSelectionMode();
final boolean showUnreadDot = !mData.getIsRead() && !mData.getShowDraft();
if (mUnreadDotSlotView != null) {
mUnreadDotSlotView.setVisibility(View.VISIBLE);
@@ -241,10 +288,7 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
if (mUnreadDotView != null) {
mUnreadDotView.setVisibility(showUnreadDot ? View.VISIBLE : View.INVISIBLE);
}
if (selectionMode) {
mConversationNameView.setTextColor(mListItemReadColor);
mConversationNameView.setTypeface(mListItemUnreadTypeface);
} else if (mData.getIsRead() || mData.getShowDraft()) {
if (mData.getIsRead() || mData.getShowDraft()) {
mConversationNameView.setTextColor(mListItemReadColor);
mConversationNameView.setTypeface(mListItemReadTypeface);
} else {
@@ -253,6 +297,8 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
}
final String conversationName = mData.getName();
mConversationNameView.setTextSize(TypedValue.COMPLEX_UNIT_SP,
looksLikePhoneNumberName(conversationName) ? 18 : 17);
// For group conversations, ellipsize the group members that do not fit
final CharSequence ellipsizedName = UiUtils.commaEllipsize(
@@ -270,6 +316,23 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
mConversationNameView.setText(bidiFormattedName);
}
private static boolean looksLikePhoneNumberName(final String name) {
if (TextUtils.isEmpty(name)) {
return false;
}
final String digits = PhoneNumberUtils.normalizeNumber(name);
if (digits.length() < 6) {
return false;
}
for (int i = 0; i < digits.length(); i++) {
final char c = digits.charAt(i);
if (c != '+' && !Character.isDigit(c)) {
return false;
}
}
return true;
}
private static String getPlusOneString() {
if (sPlusOneString == null) {
sPlusOneString = Factory.get().getApplicationContext().getResources()
@@ -287,100 +350,174 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
}
private void setSubject() {
final String subjectText = mData.getShowDraft() ?
mData.getDraftSubject() :
MmsUtils.cleanseMmsSubject(getContext().getResources(), mData.getSubject());
if (!TextUtils.isEmpty(subjectText)) {
final String subjectPrepend = getResources().getString(R.string.subject_label);
mSubjectTextView.setText(TextUtils.concat(subjectPrepend, subjectText));
mSubjectTextView.setVisibility(VISIBLE);
} else {
if (mSubjectTextView != null) {
mSubjectTextView.setVisibility(GONE);
}
}
private void setSnippet() {
mSnippetTextView.setText(getSnippetText());
final String snippetText = ellipsizeSnippetForMaxLines(getSnippetText());
final int indent = getSnippetChipIndent();
if (indent <= 0) {
mSnippetTextView.setText(snippetText);
} else {
final SpannableString spanned = new SpannableString(snippetText == null ? "" : snippetText);
spanned.setSpan(new LeadingMarginSpan.Standard(indent, 0),
0, spanned.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
mSnippetTextView.setText(spanned);
}
alignSimBadgeToFirstSnippetLine();
}
private void alignSimBadgeToFirstSnippetLine() {
if (mCategoryChipView == null) {
return;
}
if (mCategoryChipView.getVisibility() != VISIBLE || mSnippetTextView == null) {
mCategoryChipView.setTranslationY(0f);
return;
}
int chipHeight = mCategoryChipView.getHeight();
if (chipHeight <= 0) {
mCategoryChipView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
chipHeight = mCategoryChipView.getMeasuredHeight();
}
if (chipHeight <= 0) {
return;
}
final Paint.FontMetricsInt fm = mSnippetTextView.getPaint().getFontMetricsInt();
final int textHeight = fm.descent - fm.ascent;
int textTop = mSnippetTextView.getPaddingTop();
final Layout layout = mSnippetTextView.getLayout();
if (layout != null && layout.getLineCount() > 0) {
textTop = mSnippetTextView.getPaddingTop() + layout.getLineBaseline(0) + fm.ascent;
}
mCategoryChipView.setTranslationY(textTop + (textHeight - chipHeight) / 2f);
}
private int getSnippetChipIndent() {
if (mCategoryChipView == null || mCategoryChipView.getVisibility() != VISIBLE) {
return 0;
}
int width = mCategoryChipView.getWidth();
if (width <= 0) {
mCategoryChipView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
width = mCategoryChipView.getMeasuredWidth();
}
if (width <= 0) {
return 0;
}
return width + getResources().getDimensionPixelSize(R.dimen.looply_space_sm);
}
private String ellipsizeSnippetForMaxLines(final String source) {
if (TextUtils.isEmpty(source)) {
return source;
}
final int maxLines = mSnippetTextView.getMaxLines();
final int width = mSnippetTextView.getWidth()
- mSnippetTextView.getPaddingLeft() - mSnippetTextView.getPaddingRight();
if (maxLines <= 0 || width <= 0) {
return source;
}
final int indent = getSnippetChipIndent();
final CharSequence measured;
if (indent > 0) {
final SpannableString spanned = new SpannableString(source);
spanned.setSpan(new LeadingMarginSpan.Standard(indent, 0),
0, spanned.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
measured = spanned;
} else {
measured = source;
}
final StaticLayout layout = StaticLayout.Builder
.obtain(measured, 0, measured.length(), mSnippetTextView.getPaint(), width)
.setMaxLines(maxLines + 1)
.setEllipsize(null)
.setIncludePad(false)
.setLineSpacing(mSnippetTextView.getLineSpacingExtra(),
mSnippetTextView.getLineSpacingMultiplier())
.build();
if (layout.getLineCount() <= maxLines) {
return source;
}
final int last = maxLines - 1;
final int lineStart = layout.getLineStart(last);
int cut = layout.getLineEnd(last);
while (cut > lineStart && Character.isWhitespace(source.charAt(cut - 1))) {
cut--;
}
int reserved = 0;
while (cut > lineStart && reserved < SNIPPET_ELLIPSIS_RESERVE_CHARS) {
cut -= Character.charCount(Character.codePointBefore(source, cut));
reserved++;
}
while (cut > lineStart && Character.isWhitespace(source.charAt(cut - 1))) {
cut--;
}
if (cut <= 0) {
return "...";
}
return source.substring(0, cut) + "...";
}
private void updateSelectionIndicator(final boolean selectionMode, final boolean isSelected,
final Resources resources) {
if (mSelectionIndicatorView == null || mSelectionRingView == null
|| mSelectionCheckView == null) {
return;
}
if (!selectionMode) {
if (mSelectionIndicatorView != null) {
mSelectionIndicatorView.animate().cancel();
mSelectionCheckView.animate().cancel();
mSelectionIndicatorView.setScaleX(1f);
mSelectionIndicatorView.setScaleY(1f);
mSelectionCheckView.setAlpha(1f);
mSelectionCheckView.setScaleX(1f);
mSelectionCheckView.setScaleY(1f);
mSelectionIndicatorView.setVisibility(GONE);
mSelectionStateConversationId = null;
mSelectionStateChecked = null;
}
if (mContactCheckmarkView == null) {
return;
}
mSelectionIndicatorView.setVisibility(VISIBLE);
final String conversationId = mData.getConversationId();
final boolean sameConversation = conversationId != null
&& conversationId.equals(mSelectionStateConversationId);
final boolean animateToggle = sameConversation && mSelectionStateChecked != null
&& mSelectionStateChecked.booleanValue() != isSelected;
final boolean wasMode = Boolean.TRUE.equals(mSelectionStateMode);
final boolean wasSelected = Boolean.TRUE.equals(mSelectionStateChecked);
final boolean animateToggle = sameConversation
&& (wasMode != selectionMode || wasSelected != isSelected);
mSelectionStateConversationId = conversationId;
mSelectionStateMode = selectionMode;
mSelectionStateChecked = isSelected;
mSelectionIndicatorView.animate().cancel();
mSelectionCheckView.animate().cancel();
if (isSelected) {
mSelectionRingView.setBackground(resources.getDrawable(
R.drawable.home_bg_selection_ring_checked, getContext().getTheme()));
mSelectionCheckView.setVisibility(VISIBLE);
if (animateToggle) {
mSelectionIndicatorView.setScaleX(0.88f);
mSelectionIndicatorView.setScaleY(0.88f);
mSelectionIndicatorView.animate()
.scaleX(1f)
.scaleY(1f)
mContactCheckmarkView.animate().cancel();
if (!selectionMode) {
if (animateToggle && mContactCheckmarkView.getVisibility() == VISIBLE) {
mContactCheckmarkView.animate()
.alpha(0f)
.scaleX(0.6f)
.scaleY(0.6f)
.setDuration(SELECTION_TOGGLE_ANIM_MS)
.setInterpolator(new OvershootInterpolator(1.6f));
mSelectionCheckView.setAlpha(0f);
mSelectionCheckView.setScaleX(0.4f);
mSelectionCheckView.setScaleY(0.4f);
mSelectionCheckView.animate()
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
.setDuration(SELECTION_TOGGLE_ANIM_MS)
.setInterpolator(UiUtils.DEFAULT_INTERPOLATOR);
.setInterpolator(UiUtils.DEFAULT_INTERPOLATOR)
.withEndAction(() ->
HomeAvatarOverlay.apply(mContactCheckmarkView, false, false))
.start();
} else {
mSelectionIndicatorView.setScaleX(1f);
mSelectionIndicatorView.setScaleY(1f);
mSelectionCheckView.setAlpha(1f);
mSelectionCheckView.setScaleX(1f);
mSelectionCheckView.setScaleY(1f);
HomeAvatarOverlay.apply(mContactCheckmarkView, false, false);
}
return;
}
HomeAvatarOverlay.apply(mContactCheckmarkView, true, isSelected);
if (animateToggle && isSelected && !wasSelected) {
mContactCheckmarkView.setAlpha(0f);
mContactCheckmarkView.setScaleX(0.6f);
mContactCheckmarkView.setScaleY(0.6f);
mContactCheckmarkView.animate()
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
.setDuration(SELECTION_TOGGLE_ANIM_MS)
.setInterpolator(new OvershootInterpolator(1.6f))
.start();
} else {
mSelectionRingView.setBackground(resources.getDrawable(
R.drawable.home_bg_selection_ring, getContext().getTheme()));
mSelectionCheckView.setVisibility(GONE);
mSelectionCheckView.setAlpha(1f);
mSelectionCheckView.setScaleX(1f);
mSelectionCheckView.setScaleY(1f);
if (animateToggle) {
mSelectionIndicatorView.setScaleX(0.92f);
mSelectionIndicatorView.setScaleY(0.92f);
mSelectionIndicatorView.animate()
.scaleX(1f)
.scaleY(1f)
.setDuration(SELECTION_TOGGLE_ANIM_MS)
.setInterpolator(UiUtils.DEFAULT_INTERPOLATOR);
} else {
mSelectionIndicatorView.setScaleX(1f);
mSelectionIndicatorView.setScaleY(1f);
}
mContactCheckmarkView.setAlpha(1f);
mContactCheckmarkView.setScaleX(1f);
mContactCheckmarkView.setScaleY(1f);
}
}
@@ -421,8 +558,7 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
}
final ViewGroup.MarginLayoutParams layoutParams =
(ViewGroup.MarginLayoutParams) mListDividerView.getLayoutParams();
final int insetDp = selectionMode ? LIST_DIVIDER_INSET_SELECTION_DP
: LIST_DIVIDER_INSET_DEFAULT_DP;
final int insetDp = LIST_DIVIDER_INSET_DEFAULT_DP;
final float density = getResources().getDisplayMetrics().density;
final int insetPx = (int) (insetDp * density + 0.5f);
if (layoutParams.leftMargin != insetPx) {
@@ -561,34 +697,27 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
final String snippetText = getSnippetText();
final boolean selectionMode = mHostInterface.isSelectionMode();
if (selectionMode) {
maxLines = 0;
typeface = mListItemReadTypeface;
} else if (mData.getIsRead() || mData.getShowDraft()) {
if (mData.getIsRead() || mData.getShowDraft()) {
maxLines = TextUtils.isEmpty(snippetText) ? 0 : NO_UNREAD_SNIPPET_LINE_COUNT;
typeface = mListItemReadTypeface;
} else {
maxLines = TextUtils.isEmpty(snippetText) ? 0 : UNREAD_SNIPPET_LINE_COUNT;
typeface = mListItemUnreadTypeface;
}
color = getResources().getColor(R.color.home_text_secondary, getContext().getTheme());
color = getResources().getColor(R.color.home_text_snippet, getContext().getTheme());
mSnippetTextView.setMaxLines(maxLines);
mSnippetTextView.setEllipsize(TextUtils.TruncateAt.END);
mSnippetTextView.setTextColor(color);
mSnippetTextView.setTypeface(typeface, typefaceStyle);
mSubjectTextView.setTextColor(color);
mSubjectTextView.setTypeface(typeface, typefaceStyle);
if (selectionMode) {
mSnippetTextView.setVisibility(GONE);
mSubjectTextView.setVisibility(GONE);
} else {
mSnippetTextView.setVisibility(VISIBLE);
setSnippet();
setSubject();
}
setConversationName();
mSnippetTextView.setVisibility(VISIBLE);
setWorkProfileIcon();
setSnippet();
setSubject();
setConversationName();
setContentDescription(buildContentDescription(resources, mData,
mConversationNameView.getPaint()));
@@ -616,9 +745,13 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
mTimestampTextView.setText(resources.getString(
R.string.conversation_list_item_view_draft_message));
} else {
mTimestampTextView.setTextColor(resources.getColor(R.color.home_text_secondary,
getContext().getTheme()));
mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle);
final boolean unread = !mData.getIsRead();
mTimestampTextView.setTextColor(unread
? color
: resources.getColor(R.color.home_text_secondary, getContext().getTheme()));
mTimestampTextView.setTypeface(
unread ? mListItemUnreadTypeface : mListItemReadTypeface,
unread ? Typeface.BOLD : typefaceStyle);
final String formattedTimestamp = mData.getFormattedTimestamp();
if (mData.getIsSendRequested()) {
mTimestampTextView.setText(R.string.message_status_sending);
@@ -631,7 +764,6 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
setSelected(isSelected);
Uri iconUri = null;
int contactIconVisibility = VISIBLE;
int checkmarkVisiblity = GONE;
int failStatusVisiblity = GONE;
if (!isSelected) {
// Only show the fail icon if it is not a group conversation.
@@ -647,12 +779,19 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
mContactIconView.setImageResourceUri(iconUri, mData.getParticipantContactId(),
mData.getParticipantLookupKey(), mData.getOtherParticipantNormalizedDestination());
mContactIconView.setVisibility(contactIconVisibility);
mContactIconView.setOnLongClickListener(this);
mContactIconView.setClickable(!mHostInterface.isSelectionMode());
mContactIconView.setLongClickable(!mHostInterface.isSelectionMode());
mContactIconView.setImageClickHandlerDisabled(true);
bindAvatarClickTargets(selectionMode);
mContactCheckmarkView.setVisibility(checkmarkVisiblity);
mFailedStatusIconView.setVisibility(failStatusVisiblity);
final boolean muted = MutedConversationStore.isMuted(getContext(),
mData.getConversationId());
final boolean trashed = mData.getIsTrashed();
if (mMuteBadgeView != null) {
mMuteBadgeView.setVisibility(
muted && !trashed && failStatusVisiblity != VISIBLE && !isSelected
? VISIBLE : GONE);
}
updateSwipeMuteButton(muted, trashed);
updatePinButton(selectionMode, resources);
updateListDividerInset(selectionMode);
@@ -691,22 +830,36 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */));
mImagePreviewView.setOnLongClickListener(this);
mImagePreviewView.setVisibility(previewImageVisibility);
mImagePreviewView.setOnClickListener(previewClickListener);
mImagePreviewView.setOnClickListener(selectionMode
? v -> processClick(mSwipeableContainer, false)
: previewClickListener);
mAudioAttachmentView.setOnLongClickListener(this);
mAudioAttachmentView.setVisibility(audioPreviewVisiblity);
Drawable archiveDrawable = ResourcesCompat.getDrawable(
getResources(), R.drawable.ic_archive_small_dark, getContext().getTheme());
if (PrefsUtils.isSwipeRightToDeleteEnabled()) {
mCrossSwipeArchiveLeftImageView.setImageDrawable(ResourcesCompat.getDrawable(
getResources(), R.drawable.ic_delete_small_dark, getContext().getTheme()));
mCrossSwipeArchiveRightImageView.setImageDrawable(archiveDrawable);
} else {
mCrossSwipeArchiveLeftImageView.setImageDrawable(archiveDrawable);
mCrossSwipeArchiveRightImageView.setImageDrawable(archiveDrawable);
if (mCrossSwipeArchiveLeftImageView != null) {
mCrossSwipeArchiveLeftImageView.setImageResource(
PrefsUtils.isSwipeRightToDeleteEnabled()
? R.drawable.ic_delete_small_dark
: R.drawable.ic_archive_small_dark);
}
}
/** Lightweight refresh when toggling home selection chrome. */
public void rebindSelectionChrome() {
if (mHostInterface == null || mData == null) {
return;
}
final Resources resources = getContext().getResources();
final boolean selectionMode = mHostInterface.isSelectionMode();
final boolean isSelected = mHostInterface.isConversationSelected(
mData.getConversationId());
setSelected(isSelected);
updateSelectionIndicator(selectionMode, isSelected, resources);
bindAvatarClickTargets(selectionMode);
updatePinButton(selectionMode, resources);
updateListDividerInset(selectionMode);
}
public boolean isSwipeAnimatable() {
return mHostInterface.isSwipeAnimatable();
}
@@ -715,24 +868,97 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
return mSwipeableContainer.getTranslationX();
}
public float getSwipeActionsRevealWidth() {
if (mCrossSwipeRightActions == null) {
return 0f;
}
if (mCrossSwipeRightActions.getWidth() <= 0) {
mCrossSwipeRightActions.measure(
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
return mCrossSwipeRightActions.getMeasuredWidth()
+ getResources().getDimensionPixelSize(R.dimen.looply_space_lg);
}
return mCrossSwipeRightActions.getWidth()
+ getResources().getDimensionPixelSize(R.dimen.looply_space_lg);
}
public boolean isSwipeActionsOpen() {
final float reveal = getSwipeActionsRevealWidth();
return reveal > 0 && getSwipeTranslationX() <= -reveal * 0.85f;
}
public boolean isTouchOnSwipeActions(final float parentX, final float parentY) {
if (!isSwipeActionsOpen() || mCrossSwipeRightActions == null
|| mCrossSwipeRightActions.getVisibility() != VISIBLE) {
return false;
}
final int[] parentLoc = new int[2];
getLocationOnScreen(parentLoc);
final int[] actionLoc = new int[2];
mCrossSwipeRightActions.getLocationOnScreen(actionLoc);
final float screenX = parentLoc[0] + parentX;
final float screenY = parentLoc[1] + parentY;
return screenX >= actionLoc[0]
&& screenX <= actionLoc[0] + mCrossSwipeRightActions.getWidth()
&& screenY >= actionLoc[1]
&& screenY <= actionLoc[1] + mCrossSwipeRightActions.getHeight();
}
private void updateSwipeMuteButton(final boolean muted, final boolean trashed) {
if (mCrossSwipeMuteView == null) {
return;
}
mCrossSwipeMuteView.setVisibility(trashed ? GONE : VISIBLE);
if (mCrossSwipeDeleteView != null) {
final ViewGroup.MarginLayoutParams lp =
(ViewGroup.MarginLayoutParams) mCrossSwipeDeleteView.getLayoutParams();
if (lp != null) {
lp.setMarginStart(trashed ? 0
: getResources().getDimensionPixelSize(R.dimen.looply_space_sm));
mCrossSwipeDeleteView.setLayoutParams(lp);
}
}
if (trashed) {
return;
}
mCrossSwipeMuteView.setImageResource(muted
? R.drawable.ic_home_bell
: R.drawable.ic_home_bell_off);
mCrossSwipeMuteView.setContentDescription(getResources().getString(muted
? R.string.home_swipe_unmute
: R.string.home_swipe_mute));
}
public void setSwipeTranslationX(final float translationX) {
mSwipeableContainer.setTranslationX(translationX);
if (translationX == 0) {
mCrossSwipeBackground.setVisibility(View.GONE);
mCrossSwipeArchiveLeftImageView.setVisibility(GONE);
mCrossSwipeArchiveRightImageView.setVisibility(GONE);
if (mCrossSwipeArchiveLeftImageView != null) {
mCrossSwipeArchiveLeftImageView.setVisibility(GONE);
}
if (mCrossSwipeRightActions != null) {
mCrossSwipeRightActions.setVisibility(GONE);
}
mSwipeableContainer.setBackgroundColor(Color.TRANSPARENT);
} else {
mCrossSwipeBackground.setVisibility(View.VISIBLE);
if (translationX > 0) {
mCrossSwipeArchiveLeftImageView.setVisibility(VISIBLE);
mCrossSwipeArchiveRightImageView.setVisibility(GONE);
if (mCrossSwipeArchiveLeftImageView != null) {
mCrossSwipeArchiveLeftImageView.setVisibility(VISIBLE);
}
if (mCrossSwipeRightActions != null) {
mCrossSwipeRightActions.setVisibility(GONE);
}
} else {
mCrossSwipeArchiveLeftImageView.setVisibility(GONE);
mCrossSwipeArchiveRightImageView.setVisibility(VISIBLE);
if (mCrossSwipeArchiveLeftImageView != null) {
mCrossSwipeArchiveLeftImageView.setVisibility(GONE);
}
if (mCrossSwipeRightActions != null) {
mCrossSwipeRightActions.setVisibility(VISIBLE);
}
}
mSwipeableContainer.setBackgroundResource(R.color.window_background);
mSwipeableContainer.setBackgroundResource(R.color.home_bg);
}
}
@@ -740,9 +966,24 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
final String conversationId = mData.getConversationId();
if (PrefsUtils.isSwipeRightToDeleteEnabled()
&& swipeDirection == ConversationListSwipeHelper.SWIPE_DIRECTION_RIGHT) {
final boolean alreadyTrashed = mData.getIsTrashed();
mData.deleteConversation();
UiUtils.showSnackBar(getContext(), getRootView(),
getResources().getString(R.string.conversation_deleted));
if (alreadyTrashed) {
UiUtils.showSnackBar(getContext(), getRootView(),
getResources().getString(R.string.conversation_deleted));
} else {
HomeToast.show(getContext(), R.string.home_trash_moved_toast_one);
}
return;
}
if (mData.getIsTrashed()) {
UpdateConversationTrashStatusAction.restoreFromTrash(conversationId);
HomeToast.show(getContext(), R.string.home_trash_restored_toast);
return;
}
if (mData.getIsArchived()) {
UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId);
HomeToast.show(getContext(), R.string.home_profile_unarchived_toast);
return;
}
UpdateConversationArchiveStatusAction.archiveConversation(conversationId);
@@ -812,10 +1053,38 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
return processClick(v, true);
}
private void bindAvatarClickTargets(final boolean selectionMode) {
final OnClickListener selectRow = v -> processClick(mSwipeableContainer, false);
if (mContactCheckmarkView != null) {
mContactCheckmarkView.setOnClickListener(selectionMode ? selectRow : null);
mContactCheckmarkView.setClickable(selectionMode);
mContactCheckmarkView.setFocusable(false);
}
mContactIconView.setOnLongClickListener(this);
if (selectionMode) {
mContactIconView.setOnClickListener(selectRow);
mContactIconView.setClickable(true);
mContactIconView.setLongClickable(false);
} else {
mContactIconView.setOnClickListener(v -> mHostInterface.onConversationAvatarClicked(
mData, mContactIconView));
mContactIconView.setClickable(true);
mContactIconView.setLongClickable(true);
}
}
private boolean processClick(final View v, final boolean isLongClick) {
Assert.isTrue(v == mSwipeableContainer || v == mContactIconView || v == mImagePreviewView);
if (!mHostInterface.isSelectionMode() && v == mContactIconView) {
return false;
}
Assert.isTrue(v == mSwipeableContainer || v == mImagePreviewView);
Assert.notNull(mData.getName());
if (!isLongClick && isSwipeActionsOpen()) {
setSwipeTranslationX(0);
return true;
}
if (mHostInterface != null) {
mHostInterface.onConversationClicked(mData, isLongClick, this);
return true;
@@ -836,6 +1105,7 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
mData.getDraftSnippetText() : mData.getSnippetText();
final String previewContentType = mData.getShowDraft() ?
mData.getDraftPreviewContentType() : mData.getPreviewContentType();
snippetText = ConversationMessageData.flattenPreviewText(snippetText);
if (TextUtils.isEmpty(snippetText)) {
Resources resources = getResources();
// Use the attachment type as a snippet so the preview doesn't look odd
@@ -63,8 +63,10 @@ public class ConversationListSwipeHelper implements OnItemTouchListener {
private VelocityTracker mVelocityTracker;
private float mInitialX;
private float mInitialY;
private float mStartTranslationX;
private boolean mIsSwiping;
private ConversationListItemView mListItemView;
private ConversationListItemView mOpenActionsItem;
public ConversationListSwipeHelper(final RecyclerView recyclerView) {
mRecyclerView = recyclerView;
@@ -104,12 +106,19 @@ public class ConversationListSwipeHelper implements OnItemTouchListener {
mInitialY = event.getY();
final View viewAtPoint = mRecyclerView.findChildViewUnder(mInitialX, mInitialY);
final ConversationListItemView child = (ConversationListItemView) viewAtPoint;
if (viewAtPoint instanceof ConversationListItemView &&
child != null && child.isSwipeAnimatable()) {
// Begin detecting swipe on the target for the rest of the gesture.
mListItemView = child;
if (mListItemView.isAnimating()) {
if (viewAtPoint instanceof ConversationListItemView) {
final ConversationListItemView child = (ConversationListItemView) viewAtPoint;
if (child.isTouchOnSwipeActions(mInitialX - child.getLeft(),
mInitialY - child.getTop())) {
mListItemView = null;
} else if (child.isSwipeAnimatable()) {
// Begin detecting swipe on the target for the rest of the gesture.
mListItemView = child;
mStartTranslationX = child.getSwipeTranslationX();
if (mListItemView.isAnimating()) {
mListItemView = null;
}
} else {
mListItemView = null;
}
} else {
@@ -186,7 +195,8 @@ public class ConversationListSwipeHelper implements OnItemTouchListener {
case MotionEvent.ACTION_OUTSIDE:
case MotionEvent.ACTION_MOVE:
if (hasValidGestureSwipeTarget()) {
mListItemView.setSwipeTranslationX(event.getX() - mInitialX);
mListItemView.setSwipeTranslationX(clampTranslation(
mListItemView, mStartTranslationX + event.getX() - mInitialX));
}
break;
case MotionEvent.ACTION_UP:
@@ -208,17 +218,27 @@ public class ConversationListSwipeHelper implements OnItemTouchListener {
final boolean fastEnough = isTargetSwipedFastEnough();
final boolean farEnough = isTargetSwipedFarEnough();
final boolean shouldDismiss = (fastEnough || farEnough);
final boolean shouldRevealActions = swipeDirection == SWIPE_DIRECTION_LEFT
&& isTargetSwipedFarEnoughForActions();
final boolean shouldDismiss = swipeDirection == SWIPE_DIRECTION_RIGHT
&& (fastEnough || farEnough);
if (shouldDismiss) {
closeOpenActions(mListItemView);
if (fastEnough) {
animateDismiss(mListItemView, velocityX);
} else {
animateDismiss(mListItemView, swipeDirection);
}
} else if (shouldRevealActions) {
closeOpenActions(mListItemView);
animateRevealActions(mListItemView, velocityX);
mOpenActionsItem = mListItemView;
} else {
animateRestore(mListItemView, velocityX);
if (mOpenActionsItem == mListItemView) {
mOpenActionsItem = null;
}
}
onSwipeGestureEnd(mListItemView,
@@ -266,6 +286,51 @@ public class ConversationListSwipeHelper implements OnItemTouchListener {
mRecyclerView.getParent().requestDisallowInterceptTouchEvent(true);
setHardwareAnimatingLayerType(itemView, ANIMATING);
itemView.setAnimating(true);
closeOpenActions(itemView);
}
private void closeOpenActions(final ConversationListItemView keepOpen) {
if (mOpenActionsItem != null && mOpenActionsItem != keepOpen) {
animateRestore(mOpenActionsItem, 0f);
mOpenActionsItem = null;
}
}
private float clampTranslation(final ConversationListItemView itemView,
final float translationX) {
final float reveal = itemView.getSwipeActionsRevealWidth();
if (translationX < 0) {
return Math.max(translationX, -reveal * 1.2f);
}
return translationX;
}
private boolean isTargetSwipedFarEnoughForActions() {
final float translationX = mListItemView.getSwipeTranslationX();
final float reveal = mListItemView.getSwipeActionsRevealWidth();
return translationX < 0 && reveal > 0 && Math.abs(translationX) > reveal * 0.45f;
}
private void animateRevealActions(final ConversationListItemView itemView,
final float velocityX) {
onSwipeAnimationStart(itemView);
final float animateTo = -itemView.getSwipeActionsRevealWidth();
final long duration;
if (velocityX != 0) {
duration = calculateTranslationDuration(
animateTo - itemView.getSwipeTranslationX(), velocityX);
} else {
duration = mDefaultRestoreAnimationDuration;
}
final ObjectAnimator animator = getSwipeTranslationXAnimator(
itemView, animateTo, duration, UiUtils.DEFAULT_INTERPOLATOR);
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(final Animator animation) {
onSwipeAnimationEnd(itemView);
}
});
animator.start();
}
/**
@@ -0,0 +1,105 @@
/*
* Copyright (C) 2015 The Android Open Source Project
* Copyright (C) 2024-2025 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.conversationlist;
import android.os.Bundle;
import android.view.MenuItem;
import androidx.appcompat.app.ActionBar;
import com.android.messaging.R;
import com.android.messaging.ui.home.HomeInboxMenu;
import com.android.messaging.ui.home.HomeSubHeader;
public class RecentlyDeletedConversationListActivity extends AbstractConversationListActivity {
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.home_archived_activity);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
HomeSubHeader.bind(this, R.string.home_recently_deleted_title, v -> onActionBarHome());
HomeInboxMenu.bind(this, HomeInboxMenu.Screen.TRASH);
if (savedInstanceState == null) {
mConversationListFragment =
ConversationListFragment.createTrashConversationListFragment();
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.home_archived_list_host, mConversationListFragment)
.commit();
} else {
mConversationListFragment = (ConversationListFragment) getSupportFragmentManager()
.findFragmentById(R.id.home_archived_list_host);
}
}
@Override
protected boolean useHomeSelectionChrome() {
return true;
}
@Override
protected boolean isTrashList() {
return true;
}
@Override
protected void updateActionBar(final ActionBar actionBar) {
if (actionBar == null) {
return;
}
actionBar.hide();
super.updateActionBar(actionBar);
}
@Override
public void onBackPressed() {
if (isInConversationListSelectMode()) {
exitMultiSelectState();
} else {
super.onBackPressed();
}
}
@Override
public boolean onOptionsItemSelected(final MenuItem menuItem) {
if (menuItem.getItemId() == android.R.id.home) {
onActionBarHome();
return true;
}
return super.onOptionsItemSelected(menuItem);
}
@Override
public void onActionBarHome() {
onBackPressed();
}
@Override
public boolean isSwipeAnimatable() {
return !isInConversationListSelectMode();
}
@Override
public void finish() {
super.finish();
overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
}
}
@@ -1,66 +0,0 @@
/*
* Copyright (C) 2015 The Android Open Source Project
* Copyright (C) 2024 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.conversationsettings;
import android.os.Bundle;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import com.android.messaging.R;
import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.util.Assert;
/**
* Shows a list of participants in a conversation.
*/
public class PeopleAndOptionsActivity extends BugleActionBarActivity {
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.people_and_options_activity);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
@Override
public void onAttachFragment(@NonNull final Fragment fragment) {
if (fragment instanceof PeopleAndOptionsFragment) {
final String conversationId =
getIntent().getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID);
Assert.notNull(conversationId);
final PeopleAndOptionsFragment peopleAndOptionsFragment =
(PeopleAndOptionsFragment) fragment;
peopleAndOptionsFragment.setConversationId(conversationId);
}
}
@Override
public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
if (item.getItemId() == android.R.id.home) {
// Treat the home press as back press so that when we go back to
// ConversationActivity, it doesn't lose its original intent (conversation id etc.)
onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
}
@@ -1,314 +0,0 @@
/*
* Copyright (C) 2015 The Android Open Source Project
* Copyright (C) 2024 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.conversationsettings;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.Settings;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.BaseAdapter;
import android.widget.ListView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.loader.app.LoaderManager;
import com.android.messaging.R;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.binding.Binding;
import com.android.messaging.datamodel.binding.BindingBase;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.datamodel.data.ParticipantListItemData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener;
import com.android.messaging.datamodel.data.PeopleOptionsItemData;
import com.android.messaging.datamodel.data.PersonItemData;
import com.android.messaging.ui.CompositeAdapter;
import com.android.messaging.ui.PersonItemView;
import com.android.messaging.ui.conversation.ConversationActivity;
import com.android.messaging.util.Assert;
import com.android.messaging.util.NotificationsUtil;
import java.util.ArrayList;
import java.util.List;
/**
* Shows a list of participants of a conversation and displays options.
*/
public class PeopleAndOptionsFragment extends Fragment
implements PeopleAndOptionsDataListener, PeopleOptionsItemView.HostInterface {
private ListView mListView;
private OptionsListAdapter mOptionsListAdapter;
private PeopleListAdapter mPeopleListAdapter;
private List<ParticipantData> mOtherParticipants;
private final Binding<PeopleAndOptionsData> mBinding =
BindingBase.createBinding(this);
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding.getData().init(LoaderManager.getInstance(this), mBinding);
}
@Override
public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
final Bundle savedInstanceState) {
final View view = inflater.inflate(R.layout.people_and_options_fragment, container, false);
mListView = view.findViewById(android.R.id.list);
mPeopleListAdapter = new PeopleListAdapter(getActivity());
mOptionsListAdapter = new OptionsListAdapter();
final CompositeAdapter compositeAdapter = new CompositeAdapter(getActivity());
compositeAdapter.addPartition(new PeopleAndOptionsPartition(mOptionsListAdapter,
R.string.general_settings_title, false));
compositeAdapter.addPartition(new PeopleAndOptionsPartition(mPeopleListAdapter,
R.string.participant_list_title, true));
mListView.setAdapter(compositeAdapter);
return view;
}
@Override
public void onDestroy() {
super.onDestroy();
mBinding.unbind();
}
public void setConversationId(final String conversationId) {
Assert.isTrue(getView() == null);
Assert.notNull(conversationId);
mBinding.bind(DataModel.get().createPeopleAndOptionsData(conversationId, getActivity(),
this));
}
@Override
public void onOptionsCursorUpdated(final PeopleAndOptionsData data, final Cursor cursor) {
Assert.isTrue(cursor == null || cursor.getCount() == 1);
mBinding.ensureBound(data);
mOptionsListAdapter.swapCursor(cursor);
}
@Override
public void onParticipantsListLoaded(final PeopleAndOptionsData data,
final List<ParticipantData> participants) {
mBinding.ensureBound(data);
mPeopleListAdapter.updateParticipants(participants);
mOtherParticipants = participants;
final ParticipantData otherParticipant = participants.size() == 1 ?
participants.get(0) : null;
mOptionsListAdapter.setOtherParticipant(otherParticipant);
}
@Override
public void onOptionsItemViewClicked(final PeopleOptionsItemData item) {
if (item.getItemId() == PeopleOptionsItemData.SETTING_NOTIFICATION) {
ArrayList<String> participantsNames = new ArrayList<>();
for (ParticipantData participant : mOtherParticipants) {
participantsNames.add(participant.getDisplayName(true));
}
NotificationsUtil.createNotificationChannelGroup(getActivity(),
NotificationsUtil.CONVERSATION_GROUP_NAME,
R.string.notification_channel_messages_title);
NotificationsUtil.createNotificationChannel(getActivity(),
mBinding.getData().getConversationId(),
String.join(", ", participantsNames),
NotificationManager.IMPORTANCE_HIGH,
NotificationsUtil.CONVERSATION_GROUP_NAME);
Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS);
intent.putExtra(Settings.EXTRA_APP_PACKAGE, getContext().getPackageName());
startActivity(intent);
} else if (item.getItemId() == PeopleOptionsItemData.SETTING_BLOCKED) {
if (item.getOtherParticipant().isBlocked()) {
mBinding.getData().setDestinationBlocked(mBinding, false);
return;
}
final Resources res = getResources();
final Activity activity = getActivity();
new AlertDialog.Builder(activity, R.style.AlertDialogTheme)
.setTitle(res.getString(R.string.block_confirmation_title,
item.getOtherParticipant().getDisplayDestination()))
.setMessage(res.getString(R.string.block_confirmation_message))
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok, (arg0, arg1) -> {
mBinding.getData().setDestinationBlocked(mBinding, true);
activity.setResult(ConversationActivity.FINISH_RESULT_CODE);
activity.finish();
})
.create()
.show();
}
}
/**
* A simple adapter that takes a conversation metadata cursor and binds
* PeopleAndOptionsItemViews to individual COLUMNS of the first cursor record. (Note
* that this is not a CursorAdapter because it treats individual columns of the cursor as
* separate options to display for the conversation, e.g. notification settings).
*/
private class OptionsListAdapter extends BaseAdapter {
private Cursor mOptionsCursor;
private ParticipantData mOtherParticipantData;
public Cursor swapCursor(final Cursor newCursor) {
final Cursor oldCursor = mOptionsCursor;
if (newCursor != oldCursor) {
mOptionsCursor = newCursor;
notifyDataSetChanged();
}
return oldCursor;
}
public void setOtherParticipant(final ParticipantData participantData) {
if (mOtherParticipantData != participantData) {
mOtherParticipantData = participantData;
notifyDataSetChanged();
}
}
@Override
public int getCount() {
int count = PeopleOptionsItemData.SETTINGS_COUNT;
if (mOtherParticipantData == null) {
count--;
}
return mOptionsCursor == null ? 0 : count;
}
@Override
public Object getItem(final int position) {
return null;
}
@Override
public long getItemId(final int position) {
return 0;
}
@Override
public View getView(final int position, final View convertView, final ViewGroup parent) {
final PeopleOptionsItemView itemView;
if (convertView != null && convertView instanceof PeopleOptionsItemView) {
itemView = (PeopleOptionsItemView) convertView;
} else {
final LayoutInflater inflater = (LayoutInflater) getActivity()
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
itemView = (PeopleOptionsItemView)
inflater.inflate(R.layout.people_options_item_view, parent, false);
}
mOptionsCursor.moveToFirst();
itemView.bind(mOptionsCursor, position, mOtherParticipantData,
PeopleAndOptionsFragment.this);
return itemView;
}
}
/**
* An adapter that takes a list of ParticipantData and displays them as a list of
* ParticipantListItemViews.
*/
private class PeopleListAdapter extends ArrayAdapter<ParticipantData> {
public PeopleListAdapter(final Context context) {
super(context, R.layout.people_list_item_view, new ArrayList<>());
}
public void updateParticipants(final List<ParticipantData> newList) {
clear();
addAll(newList);
notifyDataSetChanged();
}
@NonNull
@Override
public View getView(final int position, final View convertView,
@NonNull final ViewGroup parent) {
PersonItemView itemView;
final ParticipantData item = getItem(position);
if (convertView != null && convertView instanceof PersonItemView) {
itemView = (PersonItemView) convertView;
} else {
final LayoutInflater inflater = (LayoutInflater) getContext()
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
itemView = (PersonItemView) inflater.inflate(R.layout.people_list_item_view, parent,
false);
}
final ParticipantListItemData itemData =
DataModel.get().createParticipantListItemData(item);
itemView.bind(itemData);
// Any click on the row should have the same effect as clicking the avatar icon
final PersonItemView itemViewClosure = itemView;
itemView.setListener(new PersonItemView.PersonItemViewListener() {
@Override
public void onPersonClicked(final PersonItemData data) {
itemViewClosure.performClickOnAvatar();
}
@Override
public boolean onPersonLongClicked(final PersonItemData data) {
if (mBinding.isBound()) {
final CopyContactDetailDialog dialog = new CopyContactDetailDialog(
getContext(), data.getDetails());
dialog.show();
return true;
}
return false;
}
});
return itemView;
}
}
/**
* Represents a partition/section in the People & Options list (e.g. "general options" and
* "people in this conversation" sections).
*/
private class PeopleAndOptionsPartition extends CompositeAdapter.Partition {
private final int mHeaderResId;
private final boolean mNeedDivider;
public PeopleAndOptionsPartition(final BaseAdapter adapter, final int headerResId,
final boolean needDivider) {
super(true /* showIfEmpty */, true /* hasHeader */, adapter);
mHeaderResId = headerResId;
mNeedDivider = needDivider;
}
@Override
public View getHeaderView(final View convertView, final ViewGroup parentView) {
View view;
if (convertView != null && convertView.getId() == R.id.people_and_options_header) {
view = convertView;
} else {
view = LayoutInflater.from(getActivity()).inflate(
R.layout.people_and_options_section_header, parentView, false);
}
final TextView text = view.findViewById(R.id.header_text);
final View divider = view.findViewById(R.id.divider);
text.setText(mHeaderResId);
divider.setVisibility(mNeedDivider ? View.VISIBLE : View.GONE);
return view;
}
}
}
@@ -1,70 +0,0 @@
/*
* Copyright (C) 2015 The Android Open Source Project
* Copyright (C) 2024 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.conversationsettings;
import android.content.Context;
import android.database.Cursor;
import androidx.appcompat.widget.SwitchCompat;
import android.util.AttributeSet;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.android.messaging.R;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.datamodel.data.PeopleOptionsItemData;
import com.android.messaging.util.Assert;
/**
* The view for a single entry in the options section of people & options activity.
*/
public class PeopleOptionsItemView extends LinearLayout {
/**
* Implemented by the host of this view that handles options click event.
*/
public interface HostInterface {
void onOptionsItemViewClicked(PeopleOptionsItemData item);
}
private TextView mTitle;
private TextView mSubtitle;
private SwitchCompat mSwitch;
private final PeopleOptionsItemData mData;
private HostInterface mHostInterface;
public PeopleOptionsItemView(final Context context, final AttributeSet attrs) {
super(context, attrs);
mData = DataModel.get().createPeopleOptionsItemData(context);
}
@Override
protected void onFinishInflate () {
super.onFinishInflate();
mTitle = findViewById(R.id.title);
setOnClickListener(v -> mHostInterface.onOptionsItemViewClicked(mData));
}
public void bind(final Cursor cursor, final int columnIndex, ParticipantData otherParticipant,
final HostInterface hostInterface) {
Assert.isTrue(columnIndex < PeopleOptionsItemData.SETTINGS_COUNT && columnIndex >= 0);
mData.bind(cursor, otherParticipant, columnIndex);
mHostInterface = hostInterface;
mTitle.setText(mData.getTitle());
}
}
@@ -0,0 +1,294 @@
package com.android.messaging.ui.home;
import android.app.Activity;
import android.app.Application;
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleEventObserver;
import androidx.lifecycle.ProcessLifecycleOwner;
import com.android.messaging.util.BuglePrefs;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/** App lock credentials, session, and process-level unlock gate (File Plus behavior). */
public final class HomeAppLock {
public enum Method {
NONE, PIN, PATTERN, PASSWORD;
static Method fromStored(@Nullable final String value) {
if (TextUtils.isEmpty(value)) {
return NONE;
}
try {
return Method.valueOf(value);
} catch (final IllegalArgumentException ignored) {
return NONE;
}
}
}
public static final String EXTRA_MODE = "home_app_lock_mode";
public static final int MODE_UNLOCK = 0;
public static final int MODE_SET_PIN = 1;
public static final int MODE_SET_PATTERN = 2;
public static final int MODE_SET_PASSWORD = 3;
private static final String KEY_ENABLED = "home_app_lock_enabled";
private static final String KEY_METHOD = "home_app_lock_method";
private static final String KEY_HASH = "home_app_lock_hash";
private static final String HASH_SALT = "Messaging.Security.v1";
private static boolean sUnlocked;
private static boolean sShowingUnlock;
private HomeAppLock() {
}
public static void install(@NonNull final Application application) {
ProcessLifecycleOwner.get().getLifecycle().addObserver(
(LifecycleEventObserver) (source, event) -> {
if (event == Lifecycle.Event.ON_STOP && requiresUnlock()) {
sUnlocked = false;
}
});
application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(@NonNull final Activity activity,
@Nullable final Bundle savedInstanceState) {
}
@Override
public void onActivityStarted(@NonNull final Activity activity) {
}
@Override
public void onActivityResumed(@NonNull final Activity activity) {
maybeLock(activity);
}
@Override
public void onActivityPaused(@NonNull final Activity activity) {
}
@Override
public void onActivityStopped(@NonNull final Activity activity) {
}
@Override
public void onActivitySaveInstanceState(@NonNull final Activity activity,
@NonNull final Bundle outState) {
}
@Override
public void onActivityDestroyed(@NonNull final Activity activity) {
}
});
}
public static boolean isEnabled() {
return prefs().getBoolean(KEY_ENABLED, false);
}
public static void setEnabled(final boolean enabled) {
prefs().putBoolean(KEY_ENABLED, enabled);
if (enabled) {
markUnlocked();
}
}
public static Method getMethod() {
return Method.fromStored(prefs().getString(KEY_METHOD, Method.NONE.name()));
}
public static boolean hasLockConfigured() {
return getMethod() != Method.NONE && !TextUtils.isEmpty(prefs().getString(KEY_HASH, null));
}
public static boolean requiresUnlock() {
return isEnabled() && hasLockConfigured();
}
public static void setPin(@NonNull final String pin) {
if (pin.length() != 6 || !pin.matches("\\d{6}")) {
throw new IllegalArgumentException("PIN must be 6 digits");
}
store(Method.PIN, pin);
}
public static void setPattern(@NonNull final List<Integer> cells) {
if (cells.size() < 4) {
throw new IllegalArgumentException("Pattern too short");
}
store(Method.PATTERN, joinCells(cells));
}
public static void setPassword(@NonNull final String password) {
if (password.length() < 8) {
throw new IllegalArgumentException("Password too short");
}
store(Method.PASSWORD, password);
}
public static boolean verifyPin(@NonNull final String pin) {
return verify(Method.PIN, pin);
}
public static boolean verifyPattern(@NonNull final List<Integer> cells) {
return verify(Method.PATTERN, joinCells(cells));
}
public static boolean verifyPassword(@NonNull final String password) {
return verify(Method.PASSWORD, password);
}
public static boolean isUnlockedThisSession() {
return sUnlocked;
}
public static void markUnlocked() {
sUnlocked = true;
}
public static void clearUnlocked() {
sUnlocked = false;
}
static void markUnlockShowing(final boolean showing) {
sShowingUnlock = showing;
}
public static Intent unlockIntent(@NonNull final Activity activity) {
return createIntent(activity, MODE_UNLOCK);
}
public static Intent createIntent(@NonNull final Activity activity, final int mode) {
final Intent intent = new Intent(activity, HomeAppLockActivity.class);
intent.putExtra(EXTRA_MODE, mode);
return intent;
}
static List<Integer> appendPatternCells(@NonNull final List<Integer> current, final int next) {
if (current.isEmpty()) {
final List<Integer> start = new ArrayList<>();
start.add(next);
return start;
}
final int last = current.get(current.size() - 1);
if (next == last) {
return current;
}
final Integer bridge = bridgedCellIndex(last, next);
final List<Integer> out = new ArrayList<>(current);
if (bridge != null && !current.contains(bridge)) {
out.add(bridge);
}
out.add(next);
return out;
}
@Nullable
static Integer bridgedCellIndex(final int from, final int to) {
final int fromRow = from / 3;
final int fromCol = from % 3;
final int toRow = to / 3;
final int toCol = to % 3;
final int dRow = toRow - fromRow;
final int dCol = toCol - fromCol;
if (dRow == 0 && dCol == 0) {
return null;
}
if (dRow != 0 && dCol != 0 && Math.abs(dRow) != Math.abs(dCol)) {
return null;
}
if (dRow != 0 && dRow % 2 != 0 && Math.abs(dRow) > 1) {
return null;
}
if (dCol != 0 && dCol % 2 != 0 && Math.abs(dCol) > 1) {
return null;
}
final int midRow = fromRow + dRow / 2;
final int midCol = fromCol + dCol / 2;
if (midRow == fromRow && midCol == fromCol) {
return null;
}
if (midRow == toRow && midCol == toCol) {
return null;
}
return midRow * 3 + midCol;
}
private static void maybeLock(@NonNull final Activity activity) {
if (!requiresUnlock() || sUnlocked || sShowingUnlock) {
return;
}
if (activity instanceof HomeAppLockActivity) {
return;
}
activity.getWindow().getDecorView().post(() -> {
if (activity.isFinishing() || activity.isDestroyed()
|| !requiresUnlock() || sUnlocked || sShowingUnlock) {
return;
}
sShowingUnlock = true;
activity.startActivity(unlockIntent(activity));
activity.overridePendingTransition(0, 0);
});
}
private static void store(@NonNull final Method method, @NonNull final String secret) {
final BuglePrefs prefs = prefs();
prefs.putString(KEY_HASH, hash(secret));
prefs.putString(KEY_METHOD, method.name());
prefs.putBoolean(KEY_ENABLED, true);
markUnlocked();
}
private static boolean verify(@NonNull final Method method, @NonNull final String secret) {
if (getMethod() != method) {
return false;
}
final String stored = prefs().getString(KEY_HASH, null);
return !TextUtils.isEmpty(stored) && stored.equals(hash(secret));
}
private static String joinCells(@NonNull final List<Integer> cells) {
final StringBuilder builder = new StringBuilder();
for (int i = 0; i < cells.size(); i++) {
if (i > 0) {
builder.append(',');
}
builder.append(cells.get(i));
}
return builder.toString();
}
private static String hash(@NonNull final String value) {
try {
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
final byte[] bytes = digest.digest(
(HASH_SALT + "|" + value).getBytes(StandardCharsets.UTF_8));
final StringBuilder hex = new StringBuilder(bytes.length * 2);
for (final byte b : bytes) {
hex.append(String.format(Locale.US, "%02x", b));
}
return hex.toString();
} catch (final Exception e) {
throw new IllegalStateException("Unable to hash lock secret", e);
}
}
private static BuglePrefs prefs() {
return BuglePrefs.getApplicationPrefs();
}
}
@@ -0,0 +1,520 @@
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.Gravity;
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.LinearLayout;
import android.widget.TextView;
import androidx.activity.OnBackPressedCallback;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import com.android.messaging.R;
import com.android.messaging.ui.BugleActionBarActivity;
import java.util.ArrayList;
import java.util.List;
/** Unlock overlay and PIN / pattern / password setup screens. */
public class HomeAppLockActivity extends BugleActionBarActivity {
private int mMode;
private int mStep;
private String mFirstSecret = "";
private final List<Integer> mFirstPattern = new ArrayList<>();
private String mPin = "";
private TextView mSubtitle;
private TextView mError;
private ViewGroup mPinDots;
private HomePatternLockView mPatternView;
private View mPatternRecorded;
private EditText mPassword;
private ImageView mPasswordToggle;
private View mPasswordRules;
private TextView mPasswordLabel;
private boolean mPasswordVisible;
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
mMode = getIntent().getIntExtra(HomeAppLock.EXTRA_MODE, HomeAppLock.MODE_UNLOCK);
if (mMode == HomeAppLock.MODE_UNLOCK) {
HomeAppLock.markUnlockShowing(true);
if (!HomeAppLock.requiresUnlock()) {
HomeAppLock.markUnlocked();
finish();
return;
}
setContentView(R.layout.home_lock_unlock);
} else {
setContentView(R.layout.home_lock_setup);
HomeSubHeader.bind(this, headerTitle(), v -> finish());
}
mSubtitle = findViewById(R.id.home_lock_subtitle);
mError = findViewById(R.id.home_lock_error);
final FrameLayout body = findViewById(R.id.home_lock_body);
final LayoutInflater inflater = LayoutInflater.from(this);
if (isPinMode()) {
inflater.inflate(R.layout.home_lock_pin_body, body, true);
bindPinPad(body);
if (mMode != HomeAppLock.MODE_UNLOCK) {
relocateLockPrompt(body);
}
} else if (isPatternMode()) {
inflater.inflate(R.layout.home_lock_pattern_body, body, true);
bindPattern(body);
if (mMode != HomeAppLock.MODE_UNLOCK) {
relocateLockPrompt(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 relocateLockPrompt(final View host) {
final ViewGroup prompt = host.findViewById(R.id.home_lock_prompt);
if (prompt == null) {
return;
}
if (mSubtitle != null) {
mSubtitle.setGravity(Gravity.CENTER);
mSubtitle.setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
final LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
lp.bottomMargin = getResources().getDimensionPixelSize(R.dimen.looply_space_sm);
mSubtitle.setLayoutParams(lp);
moveChild(prompt, mSubtitle, 0);
}
if (mError != null) {
moveChild(prompt, mError, mSubtitle == null ? 0 : 1);
}
}
private static void moveChild(final ViewGroup parent, final View child, final int index) {
if (parent == null || child == null) {
return;
}
if (child.getParent() instanceof ViewGroup) {
((ViewGroup) child.getParent()).removeView(child);
}
parent.addView(child, Math.min(index, parent.getChildCount()));
}
private void bindPinPad(final View host) {
mPinDots = host.findViewById(R.id.home_lock_pin_dots);
final ViewGroup keypad = host.findViewById(R.id.home_lock_pin_keypad);
bindDigitKeys(keypad);
final View backspace = host.findViewById(R.id.home_lock_pin_backspace);
if (backspace != null) {
backspace.setOnClickListener(v -> {
if (!mPin.isEmpty()) {
mPin = mPin.substring(0, mPin.length() - 1);
updatePinDots();
hideError();
}
});
}
updatePinDots();
}
private void bindDigitKeys(final ViewGroup parent) {
for (int i = 0; i < parent.getChildCount(); i++) {
final View child = parent.getChildAt(i);
if (child instanceof ViewGroup) {
bindDigitKeys((ViewGroup) child);
} else if (child instanceof TextView) {
final String text = ((TextView) child).getText().toString();
if (text.length() == 1 && Character.isDigit(text.charAt(0))) {
child.setOnClickListener(v -> appendPin(text));
}
}
}
}
private void appendPin(final String digit) {
if (mPin.length() >= 6) {
return;
}
mPin += digit;
updatePinDots();
hideError();
if (mPin.length() < 6) {
return;
}
if (mMode == HomeAppLock.MODE_UNLOCK) {
if (HomeAppLock.verifyPin(mPin)) {
unlockAndFinish();
} else {
showError(R.string.home_lock_incorrect_pin);
mPin = "";
updatePinDots();
}
return;
}
if (mStep == 0) {
mFirstSecret = mPin;
mPin = "";
mStep = 1;
updatePinDots();
refreshCopy();
} else if (mFirstSecret.equals(mPin)) {
HomeAppLock.setPin(mPin);
finish();
} else {
showMismatch();
}
}
private void updatePinDots() {
if (mPinDots == null) {
return;
}
for (int i = 0; i < mPinDots.getChildCount(); i++) {
mPinDots.getChildAt(i).setSelected(i < mPin.length());
}
}
private void bindPattern(final View host) {
mPatternView = host.findViewById(R.id.home_lock_pattern);
mPatternRecorded = host.findViewById(R.id.home_lock_pattern_recorded);
host.findViewById(R.id.home_lock_pattern_clear).setOnClickListener(v -> {
if (mPatternView != null) {
mPatternView.clear();
}
if (mPatternRecorded != null) {
mPatternRecorded.setVisibility(View.GONE);
}
hideError();
});
mPatternView.setListener(new HomePatternLockView.Listener() {
@Override
public void onPatternChanged(final List<Integer> cells) {
hideError();
if (mPatternRecorded != null) {
mPatternRecorded.setVisibility(
cells.size() >= 4 ? View.VISIBLE : View.GONE);
}
}
@Override
public void onPatternComplete(final List<Integer> cells) {
if (mMode == HomeAppLock.MODE_UNLOCK) {
if (HomeAppLock.verifyPattern(cells)) {
unlockAndFinish();
} else {
showError(R.string.home_lock_incorrect_pattern);
mPatternView.clear();
}
return;
}
if (cells.size() < 4) {
mPatternView.clear();
return;
}
if (mStep == 0) {
mFirstPattern.clear();
mFirstPattern.addAll(cells);
mPatternView.clear();
mStep = 1;
if (mPatternRecorded != null) {
mPatternRecorded.setVisibility(View.GONE);
}
refreshCopy();
} else if (mFirstPattern.equals(cells)) {
HomeAppLock.setPattern(cells);
finish();
} else {
showMismatch();
}
}
});
}
private void bindPassword(final View host) {
mPassword = host.findViewById(R.id.home_lock_password);
mPasswordToggle = host.findViewById(R.id.home_lock_password_toggle);
mPasswordRules = host.findViewById(R.id.home_lock_password_rules);
mPasswordLabel = host.findViewById(R.id.home_lock_password_label);
if (mMode != HomeAppLock.MODE_UNLOCK) {
mPasswordRules.setVisibility(View.VISIBLE);
}
mPasswordToggle.setOnClickListener(v -> {
mPasswordVisible = !mPasswordVisible;
final int start = mPassword.getSelectionStart();
mPassword.setTransformationMethod(mPasswordVisible
? null : PasswordTransformationMethod.getInstance());
mPasswordToggle.setImageResource(mPasswordVisible
? R.drawable.ic_home_visibility_off : R.drawable.ic_home_visibility);
if (start >= 0) {
mPassword.setSelection(start);
}
});
mPassword.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE
|| (event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER)) {
attemptPassword();
return true;
}
return false;
});
if (mMode != HomeAppLock.MODE_UNLOCK) {
mPassword.requestFocus();
}
mPassword.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(final CharSequence s, final int start, final int count,
final int after) {
}
@Override
public void onTextChanged(final CharSequence s, final int start, final int before,
final int count) {
}
@Override
public void afterTextChanged(final Editable s) {
hideError();
final String value = s.toString();
if (mMode == HomeAppLock.MODE_UNLOCK) {
if (value.length() >= 8 && HomeAppLock.verifyPassword(value)) {
unlockAndFinish();
}
return;
}
if (mStep == 0) {
updatePasswordRules(value);
if (isPasswordValid(value)) {
mFirstSecret = value;
mStep = 1;
mPassword.setText("");
refreshCopy();
}
} else if (value.length() >= mFirstSecret.length()) {
if (mFirstSecret.equals(value)) {
HomeAppLock.setPassword(value);
finish();
} else {
showMismatch();
}
}
}
});
}
private void attemptPassword() {
final String value = mPassword.getText() == null ? "" : mPassword.getText().toString();
if (HomeAppLock.verifyPassword(value)) {
unlockAndFinish();
} else if (!TextUtils.isEmpty(value)) {
showError(R.string.home_lock_incorrect_password);
mPassword.setText("");
}
}
private void updatePasswordRules(final String password) {
setRule(R.id.home_lock_rule_length, password.length() >= 8);
setRule(R.id.home_lock_rule_number, containsDigit(password));
setRule(R.id.home_lock_rule_symbol, containsSymbol(password));
setRule(R.id.home_lock_rule_upper, containsUpper(password));
}
private void setRule(final int id, final boolean ok) {
final TextView view = findViewById(id);
if (view == null) {
return;
}
view.setTextColor(getColor(ok ? R.color.home_text_primary : R.color.home_text_secondary));
view.setCompoundDrawableTintList(ContextCompat.getColorStateList(this,
ok ? R.color.home_accent : R.color.home_text_tertiary));
}
private static boolean isPasswordValid(final String password) {
return password.length() >= 8 && containsDigit(password)
&& containsSymbol(password) && containsUpper(password);
}
private static boolean containsDigit(final String value) {
for (int i = 0; i < value.length(); i++) {
if (Character.isDigit(value.charAt(i))) {
return true;
}
}
return false;
}
private static boolean containsSymbol(final String value) {
for (int i = 0; i < value.length(); i++) {
if (!Character.isLetterOrDigit(value.charAt(i))) {
return true;
}
}
return false;
}
private static boolean containsUpper(final String value) {
for (int i = 0; i < value.length(); i++) {
if (Character.isUpperCase(value.charAt(i))) {
return true;
}
}
return false;
}
private void refreshCopy() {
if (mSubtitle != null) {
mSubtitle.setText(subtitle());
}
if (mPasswordLabel != null) {
mPasswordLabel.setText(mStep == 0
? R.string.home_lock_password_hint
: R.string.home_lock_password_confirm_hint);
}
if (mPasswordRules != null) {
mPasswordRules.setVisibility(mMode != HomeAppLock.MODE_UNLOCK && mStep == 0
? View.VISIBLE : View.GONE);
}
}
private int headerTitle() {
switch (mMode) {
case HomeAppLock.MODE_SET_PIN:
return R.string.home_lock_set_pin;
case HomeAppLock.MODE_SET_PATTERN:
return R.string.home_lock_set_pattern;
case HomeAppLock.MODE_SET_PASSWORD:
return R.string.home_lock_set_password;
default:
return R.string.home_title;
}
}
private int subtitle() {
if (mMode == HomeAppLock.MODE_UNLOCK) {
switch (HomeAppLock.getMethod()) {
case PIN:
return R.string.home_lock_enter_pin;
case PATTERN:
return R.string.home_lock_enter_pattern;
case PASSWORD:
return R.string.home_lock_enter_password;
default:
return R.string.home_lock_enter_title;
}
}
if (mMode == HomeAppLock.MODE_SET_PIN) {
return mStep == 0 ? R.string.home_lock_create_pin_subtitle
: R.string.home_lock_confirm_pin;
}
if (mMode == HomeAppLock.MODE_SET_PATTERN) {
return mStep == 0 ? R.string.home_lock_create_pattern_subtitle
: R.string.home_lock_confirm_pattern;
}
return mStep == 0 ? R.string.home_lock_create_password_subtitle
: R.string.home_lock_confirm_password;
}
private boolean isPinMode() {
return mMode == HomeAppLock.MODE_SET_PIN
|| (mMode == HomeAppLock.MODE_UNLOCK
&& HomeAppLock.getMethod() == HomeAppLock.Method.PIN);
}
private boolean isPatternMode() {
return mMode == HomeAppLock.MODE_SET_PATTERN
|| (mMode == HomeAppLock.MODE_UNLOCK
&& HomeAppLock.getMethod() == HomeAppLock.Method.PATTERN);
}
private void showError(final int messageRes) {
if (mError == null) {
return;
}
mError.setText(messageRes);
mError.setVisibility(View.VISIBLE);
}
private void hideError() {
if (mError != null) {
mError.setVisibility(View.GONE);
}
}
private void showMismatch() {
new AlertDialog.Builder(this, R.style.Home_AlertDialog)
.setTitle(R.string.home_lock_mismatch_title)
.setMessage(R.string.home_lock_mismatch_message)
.setPositiveButton(android.R.string.ok, (dialog, which) -> resetSetup())
.setOnDismissListener(dialog -> resetSetup())
.show();
}
private void resetSetup() {
mStep = 0;
mPin = "";
mFirstSecret = "";
mFirstPattern.clear();
updatePinDots();
if (mPatternView != null) {
mPatternView.clear();
}
if (mPatternRecorded != null) {
mPatternRecorded.setVisibility(View.GONE);
}
if (mPassword != null) {
mPassword.setText("");
}
refreshCopy();
hideError();
}
private void unlockAndFinish() {
HomeAppLock.markUnlocked();
HomeAppLock.markUnlockShowing(false);
finish();
}
}
@@ -0,0 +1,105 @@
/*
* Copyright (C) 2026 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Full-screen attach overlay. Sheet/dock keep their own touches. A tap above the
* sheet dismisses it; a tap on the conversation compose field also asks for the IME.
*/
public class HomeAttachOverlayLayout extends FrameLayout {
public interface OutsideTapListener {
void onOutsideTap(boolean onComposeField);
}
@Nullable private View mSheet;
@Nullable private View mDock;
@Nullable private View mComposeField;
@Nullable private OutsideTapListener mOutsideTapListener;
private boolean mDownOutside;
public HomeAttachOverlayLayout(@NonNull final Context context) {
super(context);
}
public HomeAttachOverlayLayout(@NonNull final Context context,
@Nullable final AttributeSet attrs) {
super(context, attrs);
}
public void bind(@Nullable final View sheet, @Nullable final View dock,
@Nullable final View composeField, @Nullable final OutsideTapListener listener) {
mSheet = sheet;
mDock = dock;
mComposeField = composeField;
mOutsideTapListener = listener;
}
@Override
public boolean dispatchTouchEvent(final MotionEvent ev) {
final int action = ev.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
mDownOutside = !isOnSheetOrDock(ev);
}
if (mDownOutside) {
if (action == MotionEvent.ACTION_UP && mOutsideTapListener != null) {
mOutsideTapListener.onOutsideTap(isOnComposeField(ev));
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
mDownOutside = false;
}
return true;
}
return super.dispatchTouchEvent(ev);
}
private boolean isOnSheetOrDock(final MotionEvent ev) {
final float x = ev.getX();
final float y = ev.getY();
if (containsLocal(mDock, x, y)) {
return true;
}
return mSheet != null && mSheet.getVisibility() == VISIBLE && y >= mSheet.getY();
}
private boolean isOnComposeField(final MotionEvent ev) {
if (mComposeField == null || mComposeField.getVisibility() != VISIBLE) {
return false;
}
final int[] loc = new int[2];
mComposeField.getLocationInWindow(loc);
final float x = ev.getRawX();
final float y = ev.getRawY();
return x >= loc[0] && x < loc[0] + mComposeField.getWidth()
&& y >= loc[1] && y < loc[1] + mComposeField.getHeight();
}
private static boolean containsLocal(@Nullable final View view, final float x, final float y) {
if (view == null || view.getVisibility() != VISIBLE) {
return false;
}
return x >= view.getX() && x < view.getX() + view.getWidth()
&& y >= view.getY() && y < view.getY() + view.getHeight();
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (C) 2026 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.os.Bundle;
import com.android.messaging.R;
import com.android.messaging.ui.BugleActionBarActivity;
/** Home Automation screen launched from the bottom nav. */
public class HomeAutomationActivity extends BugleActionBarActivity {
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.home_automation_activity);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
HomeSubHeader.bind(this, R.string.home_automation_title, v -> finish());
HomeAutomationHome.bind(findViewById(R.id.home_automation_pane));
}
@Override
public void finish() {
super.finish();
overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
}
}
@@ -0,0 +1,407 @@
/*
* Copyright (C) 2026 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.DrawableRes;
import androidx.annotation.StringRes;
import androidx.core.content.ContextCompat;
import com.android.messaging.R;
import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.ContactIconView;
import java.util.ArrayList;
/** Sheet to create a new automation rule. */
public class HomeAutomationCreateActivity extends BugleActionBarActivity {
private View mTriggerMissed;
private View mTriggerThanks;
private View mTriggerSchedule;
private View mTriggerKeyword;
private TextView mRecipientAll;
private TextView mRecipientContacts;
private TextView mRecipientUnknown;
private TextView mSelectContacts;
private HomeFadeScrollView mRecipientScroll;
private View mTargetChipHost;
private View mTargetChipScroll;
private LinearLayout mTargetChips;
private static final String STATE_TRIGGER = "home_automation_trigger";
private static final String STATE_RECIPIENT = "home_automation_recipient";
private static final String STATE_TARGETS = "home_automation_targets";
private int mTrigger;
private int mRecipient;
private final ArrayList<HomeAutomationTarget> mTargets = new ArrayList<>();
private boolean mRestorePromptOpen;
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.home_automation_create_activity);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
final View close = findViewById(R.id.home_sub_header_close);
if (close != null) {
close.setOnClickListener(v -> finish());
}
final View scrim = findViewById(R.id.home_automation_create_scrim);
if (scrim != null) {
scrim.setOnClickListener(v -> finish());
}
mTriggerMissed = bindTriggerRow(R.id.home_automation_trigger_missed,
R.drawable.ic_home_automation_phone,
R.string.home_automation_trigger_missed_title,
R.string.home_automation_trigger_missed_body, true, 0);
mTriggerThanks = bindTriggerRow(R.id.home_automation_trigger_thanks,
R.drawable.ic_home_automation_phone,
R.string.home_automation_trigger_thanks_title,
R.string.home_automation_trigger_thanks_body, false, 1);
mTriggerSchedule = bindTriggerTile(R.id.home_automation_trigger_schedule,
R.drawable.ic_home_schedule_line,
R.string.home_automation_trigger_schedule_title,
R.string.home_automation_trigger_schedule_body, 2);
mTriggerKeyword = bindTriggerTile(R.id.home_automation_trigger_keyword,
R.drawable.ic_home_automation_chat,
R.string.home_automation_trigger_keyword_title,
R.string.home_automation_trigger_keyword_body, 3);
mRecipientScroll = findViewById(R.id.home_automation_recipient_scroll);
mRecipientAll = findViewById(R.id.home_automation_recipient_all);
mRecipientContacts = findViewById(R.id.home_automation_recipient_contacts);
mRecipientUnknown = findViewById(R.id.home_automation_recipient_unknown);
bindRecipient(mRecipientAll, 0);
bindRecipient(mRecipientContacts, 1);
bindRecipient(mRecipientUnknown, 2);
mSelectContacts = findViewById(R.id.home_automation_select_contacts);
mTargetChipHost = findViewById(R.id.home_automation_target_chip_host);
mTargetChipScroll = findViewById(R.id.home_automation_target_chip_scroll);
mTargetChips = findViewById(R.id.home_automation_target_chips);
getSupportFragmentManager().setFragmentResultListener(
HomeAutomationTargetPickerSheet.REQUEST_KEY, this, (key, bundle) -> {
final ArrayList<HomeAutomationTarget> selected = bundle
.getParcelableArrayList(HomeAutomationTargetPickerSheet.EXTRA_TARGETS);
mTargets.clear();
if (selected != null) {
mTargets.addAll(selected);
}
bindTargets();
});
if (mSelectContacts != null) {
mSelectContacts.setOnClickListener(v -> openTargetPicker());
}
comingSoon(R.id.home_automation_preview);
comingSoon(R.id.home_automation_activate);
if (savedInstanceState != null) {
restoreState(savedInstanceState);
} else if (HomeAutomationDraftStore.hasDraft(this)) {
promptRestoreDraft();
}
applyTrigger();
applyRecipient();
bindTargets();
}
private View bindTriggerRow(final int id, @DrawableRes final int icon,
@StringRes final int title, @StringRes final int body, final boolean recommended,
final int triggerIndex) {
final View row = findViewById(id);
if (row == null) {
return null;
}
final ImageView iconView = row.findViewById(R.id.home_automation_trigger_icon);
if (iconView != null) {
iconView.setImageResource(icon);
}
final TextView titleView = row.findViewById(R.id.home_automation_trigger_title);
if (titleView != null) {
titleView.setText(title);
}
final TextView bodyView = row.findViewById(R.id.home_automation_trigger_body);
if (bodyView != null) {
bodyView.setText(body);
}
final View badge = row.findViewById(R.id.home_automation_trigger_badge);
if (badge != null) {
badge.setVisibility(recommended ? View.VISIBLE : View.GONE);
}
if (triggerIndex >= 0) {
row.setOnClickListener(v -> selectTrigger(triggerIndex));
}
return row;
}
private View bindTriggerTile(final int id, @DrawableRes final int icon,
@StringRes final int title, @StringRes final int body, final int triggerIndex) {
final View tile = findViewById(id);
if (tile == null) {
return null;
}
final ImageView iconView = tile.findViewById(R.id.home_automation_trigger_icon);
if (iconView != null) {
iconView.setImageResource(icon);
}
final TextView titleView = tile.findViewById(R.id.home_automation_trigger_title);
if (titleView != null) {
titleView.setText(title);
}
final TextView bodyView = tile.findViewById(R.id.home_automation_trigger_body);
if (bodyView != null) {
bodyView.setText(body);
}
tile.setOnClickListener(v -> selectTrigger(triggerIndex));
return tile;
}
private void bindRecipient(final TextView pill, final int index) {
if (pill != null) {
pill.setOnClickListener(v -> selectRecipient(index));
}
}
private void comingSoon(final int id) {
final View view = findViewById(id);
if (view != null) {
view.setOnClickListener(v -> HomeToast.show(this, R.string.home_automate_coming_soon));
}
}
private void selectTrigger(final int index) {
if (mTrigger == index) {
return;
}
mTrigger = index;
applyTrigger();
}
private void selectRecipient(final int index) {
if (mRecipient == index) {
return;
}
mRecipient = index;
applyRecipient();
}
private void applyTrigger() {
applyTriggerRow(mTriggerMissed, mTrigger == 0, true);
applyTriggerRow(mTriggerThanks, mTrigger == 1, true);
applyTriggerTile(mTriggerSchedule, mTrigger == 2);
applyTriggerTile(mTriggerKeyword, mTrigger == 3);
}
private void applyRecipient() {
applyPill(mRecipientAll, mRecipient == 0);
applyPill(mRecipientContacts, mRecipient == 1);
applyPill(mRecipientUnknown, mRecipient == 2);
if (mRecipientScroll != null) {
mRecipientScroll.centerChild(mRecipient == 0 ? mRecipientAll
: mRecipient == 1 ? mRecipientContacts : mRecipientUnknown);
}
}
private void applySelectContacts() {
final boolean selected = !mTargets.isEmpty();
applyPill(mSelectContacts, selected);
if (selected && mRecipientScroll != null) {
mRecipientScroll.centerChild(mSelectContacts);
}
}
private void openTargetPicker() {
HomeAutomationTargetPickerSheet.show(getSupportFragmentManager(),
new ArrayList<>(mTargets));
}
private void bindTargets() {
if (mTargetChips == null) {
return;
}
mTargetChips.removeAllViews();
final boolean empty = mTargets.isEmpty();
if (mTargetChipHost != null) {
mTargetChipHost.setVisibility(empty ? View.GONE : View.VISIBLE);
} else if (mTargetChipScroll != null) {
mTargetChipScroll.setVisibility(empty ? View.GONE : View.VISIBLE);
}
applySelectContacts();
if (empty) {
return;
}
final LayoutInflater inflater = LayoutInflater.from(this);
for (final HomeAutomationTarget target : mTargets) {
final View chip = inflater.inflate(R.layout.home_automation_target_chip, mTargetChips,
false);
final ContactIconView avatar = chip.findViewById(R.id.home_compose_chip_avatar);
if (!TextUtils.isEmpty(target.iconUri)) {
avatar.setImageResourceUri(Uri.parse(target.iconUri), target.contactId,
target.lookupKey, target.destination);
}
avatar.setImageClickHandlerDisabled(true);
final TextView name = chip.findViewById(R.id.home_compose_chip_name);
name.setText(target.displayName());
final View remove = chip.findViewById(R.id.home_automation_target_chip_remove);
if (remove != null) {
remove.setOnClickListener(v -> {
mTargets.remove(target);
bindTargets();
});
}
final LinearLayout.LayoutParams params =
(LinearLayout.LayoutParams) chip.getLayoutParams();
params.setMarginEnd(getResources().getDimensionPixelSize(R.dimen.looply_space_xs));
mTargetChips.addView(chip, params);
}
}
private void applyTriggerRow(final View row, final boolean selected, final boolean tintIcon) {
if (row == null) {
return;
}
row.setBackgroundResource(selected
? R.drawable.home_bg_automation_option_selected
: R.drawable.home_bg_automation_option);
final ImageView icon = row.findViewById(R.id.home_automation_trigger_icon);
if (icon != null && tintIcon) {
if (selected) {
icon.setBackgroundResource(R.drawable.home_bg_automation_icon_on);
icon.setColorFilter(ContextCompat.getColor(this, android.R.color.white));
} else {
icon.setBackground(null);
icon.setColorFilter(ContextCompat.getColor(this, R.color.home_text_primary));
}
}
applyCheck(row.findViewById(R.id.home_automation_trigger_check), selected);
}
private void applyTriggerTile(final View tile, final boolean selected) {
if (tile == null) {
return;
}
tile.setBackgroundResource(selected
? R.drawable.home_bg_automation_option_selected
: R.drawable.home_bg_automation_option);
}
private void applyPill(final TextView pill, final boolean selected) {
if (pill == null) {
return;
}
pill.setBackgroundResource(selected
? R.drawable.home_bg_automation_pill_selected
: R.drawable.home_bg_automation_pill);
pill.setTextColor(ContextCompat.getColor(this,
selected ? android.R.color.white : R.color.home_text_primary));
}
private void applyCheck(final ImageView check, final boolean selected) {
if (check == null) {
return;
}
if (selected) {
check.setBackgroundResource(R.drawable.home_bg_selection_ring_checked);
check.setImageResource(R.drawable.ic_home_check);
check.setColorFilter(ContextCompat.getColor(this, android.R.color.white));
check.setPadding(4, 4, 4, 4);
} else {
check.setBackgroundResource(R.drawable.home_bg_selection_ring);
check.setImageDrawable(null);
check.setColorFilter(null);
check.setPadding(0, 0, 0, 0);
}
}
private void promptRestoreDraft() {
mRestorePromptOpen = true;
final HomeModal.Handle handle = HomeModal.builder(this)
.icon(R.drawable.ic_home_nav_automate)
.title(R.string.home_automation_draft_title)
.message(R.string.home_automation_draft_body)
.positive(R.string.home_automation_draft_resume, this::applyStoredDraft)
.negative(R.string.home_automation_draft_start_new, () ->
HomeAutomationDraftStore.clear(this))
.show();
handle.dialog.setOnDismissListener(dialog -> mRestorePromptOpen = false);
}
private void applyStoredDraft() {
final HomeAutomationDraftStore.Draft draft = HomeAutomationDraftStore.load(this);
if (draft == null) {
return;
}
applyDraft(draft.trigger, draft.recipient, draft.targets);
}
private void restoreState(final Bundle savedInstanceState) {
final ArrayList<HomeAutomationTarget> targets =
savedInstanceState.getParcelableArrayList(STATE_TARGETS);
applyDraft(savedInstanceState.getInt(STATE_TRIGGER, 0),
savedInstanceState.getInt(STATE_RECIPIENT, 0), targets);
}
private void applyDraft(final int trigger, final int recipient,
final ArrayList<HomeAutomationTarget> targets) {
mTrigger = trigger;
mRecipient = recipient;
mTargets.clear();
if (targets != null) {
mTargets.addAll(targets);
}
applyTrigger();
applyRecipient();
bindTargets();
}
@Override
protected void onSaveInstanceState(final Bundle outState) {
super.onSaveInstanceState(outState);
outState.putInt(STATE_TRIGGER, mTrigger);
outState.putInt(STATE_RECIPIENT, mRecipient);
outState.putParcelableArrayList(STATE_TARGETS, new ArrayList<>(mTargets));
}
@Override
protected void onPause() {
super.onPause();
if (mRestorePromptOpen) {
return;
}
HomeAutomationDraftStore.save(this, mTrigger, mRecipient, mTargets);
}
@Override
public void finish() {
super.finish();
overridePendingTransition(R.anim.home_activity_hold, R.anim.home_sheet_close);
}
}
@@ -0,0 +1,130 @@
/*
* Copyright (C) 2026 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
/** Local draft for an automation rule that was left unfinished. */
public final class HomeAutomationDraftStore {
private static final String PREFS_NAME = "home_automation_draft";
private static final String KEY_JSON = "draft_json";
public static final class Draft {
public final int trigger;
public final int recipient;
public final ArrayList<HomeAutomationTarget> targets;
public Draft(final int trigger, final int recipient,
final ArrayList<HomeAutomationTarget> targets) {
this.trigger = trigger;
this.recipient = recipient;
this.targets = targets == null ? new ArrayList<>() : targets;
}
public boolean isDirty() {
return trigger != 0 || recipient != 0 || !targets.isEmpty();
}
}
private HomeAutomationDraftStore() {
}
public static boolean hasDraft(final Context context) {
final Draft draft = load(context);
return draft != null && draft.isDirty();
}
public static Draft load(final Context context) {
final String json = prefs(context).getString(KEY_JSON, null);
if (TextUtils.isEmpty(json)) {
return null;
}
try {
final JSONObject object = new JSONObject(json);
final ArrayList<HomeAutomationTarget> targets = new ArrayList<>();
final JSONArray array = object.optJSONArray("targets");
if (array != null) {
for (int i = 0; i < array.length(); i++) {
final JSONObject item = array.optJSONObject(i);
if (item == null) {
continue;
}
targets.add(new HomeAutomationTarget(
item.optString("name", null),
item.optString("destination", null),
item.optBoolean("group", false),
item.optLong("contactId", -1),
emptyToNull(item.optString("lookupKey", null)),
emptyToNull(item.optString("iconUri", null))));
}
}
return new Draft(object.optInt("trigger", 0), object.optInt("recipient", 0), targets);
} catch (final Exception ignored) {
return null;
}
}
public static void save(final Context context, final int trigger, final int recipient,
final ArrayList<HomeAutomationTarget> targets) {
final Draft draft = new Draft(trigger, recipient, targets);
if (!draft.isDirty()) {
clear(context);
return;
}
try {
final JSONObject object = new JSONObject();
object.put("trigger", trigger);
object.put("recipient", recipient);
final JSONArray array = new JSONArray();
for (final HomeAutomationTarget target : draft.targets) {
if (target == null) {
continue;
}
final JSONObject item = new JSONObject();
item.put("name", target.name);
item.put("destination", target.destination);
item.put("group", target.group);
item.put("contactId", target.contactId);
item.put("lookupKey", target.lookupKey);
item.put("iconUri", target.iconUri);
array.put(item);
}
object.put("targets", array);
prefs(context).edit().putString(KEY_JSON, object.toString()).apply();
} catch (final Exception ignored) {
}
}
public static void clear(final Context context) {
prefs(context).edit().remove(KEY_JSON).apply();
}
private static String emptyToNull(final String value) {
return TextUtils.isEmpty(value) ? null : value;
}
private static SharedPreferences prefs(final Context context) {
return context.getApplicationContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
}
}
@@ -0,0 +1,182 @@
/*
* Copyright (C) 2026 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import com.android.messaging.R;
/** Binds the Automation home preview and archived-rule list. */
public final class HomeAutomationHome {
private HomeAutomationHome() {
}
public static void bind(final View root) {
bind(root, false);
}
public static void bindArchive(final View root) {
bind(root, true);
}
public static void applyVisibility(final View root) {
applyVisibility(root, false);
}
private static void bind(final View root, final boolean archiveList) {
if (root == null) {
return;
}
bindChip(root, R.id.home_automation_rule_missed_chip1,
R.string.home_automation_rule_missed_chip1);
bindChip(root, R.id.home_automation_rule_missed_chip2,
R.string.home_automation_rule_missed_chip2);
bindChip(root, R.id.home_automation_rule_thanks_chip1,
R.string.home_automation_rule_thanks_chip1);
bindChip(root, R.id.home_automation_rule_thanks_chip2,
R.string.home_automation_rule_thanks_chip2);
bindChip(root, R.id.home_automation_rule_weekly_chip1,
R.string.home_automation_rule_weekly_chip1);
bindChip(root, R.id.home_automation_rule_weekly_chip2,
R.string.home_automation_rule_weekly_chip2);
if (!archiveList) {
comingSoon(root, R.id.home_automation_modes_more);
comingSoon(root, R.id.home_automation_mode_call);
comingSoon(root, R.id.home_automation_mode_cards);
comingSoon(root, R.id.home_automation_rules_sort);
}
comingSoon(root, R.id.home_automation_rule_missed);
comingSoon(root, R.id.home_automation_rule_thanks);
comingSoon(root, R.id.home_automation_rule_weekly);
bindSwitch(root, R.id.home_automation_rule_missed_switch, true);
bindSwitch(root, R.id.home_automation_rule_thanks_switch, true);
bindSwitch(root, R.id.home_automation_rule_weekly_switch, true);
bindSwipe(root, R.id.home_automation_rule_missed, archiveList);
bindSwipe(root, R.id.home_automation_rule_thanks, archiveList);
bindSwipe(root, R.id.home_automation_rule_weekly, archiveList);
applyVisibility(root, archiveList);
}
private static void bindSwipe(final View root, final int id, final boolean archiveList) {
final View card = root.findViewById(id);
if (card == null || card.getParent() instanceof HomeSwipeCard) {
return;
}
final String key = HomeAutomationRuleStore.keyForView(id);
HomeSwipeCard.install(card, new HomeSwipeCard.Listener() {
@Override
public void onArchive(final HomeSwipeCard swipe) {
if (archiveList) {
HomeAutomationRuleStore.unarchive(root.getContext(), key);
HomeToast.show(root.getContext(), R.string.home_automation_rule_restored);
} else {
HomeAutomationRuleStore.archive(root.getContext(), key);
HomeToast.show(root.getContext(), R.string.home_automation_rule_archived);
}
applyVisibility(root, archiveList);
}
@Override
public void onDelete(final HomeSwipeCard swipe) {
HomeAutomationRuleStore.delete(root.getContext(), key);
HomeToast.show(root.getContext(), R.string.home_automation_rule_deleted);
applyVisibility(root, archiveList);
}
});
}
private static void applyVisibility(final View root, final boolean archiveList) {
if (root == null) {
return;
}
int visible = 0;
for (final int id : HomeAutomationRuleStore.RULE_VIEW_IDS) {
visible += applyRuleVisibility(root, id, archiveList) ? 1 : 0;
}
final TextView count = root.findViewById(R.id.home_automation_rules_count);
if (count != null) {
count.setText(String.valueOf(visible));
count.setVisibility(visible > 0 ? View.VISIBLE : View.GONE);
}
final View empty = root.findViewById(R.id.home_archived_rules_empty);
if (empty != null) {
empty.setVisibility(archiveList && visible == 0 ? View.VISIBLE : View.GONE);
}
}
private static boolean applyRuleVisibility(final View root, final int id,
final boolean archiveList) {
View card = root.findViewById(id);
if (card == null) {
return false;
}
if (card.getParent() instanceof HomeSwipeCard) {
card = (View) card.getParent();
}
final String key = HomeAutomationRuleStore.keyForView(id);
final boolean deleted = HomeAutomationRuleStore.isDeleted(root.getContext(), key);
final boolean archived = HomeAutomationRuleStore.isArchived(root.getContext(), key);
final boolean show = archiveList ? (!deleted && archived) : (!deleted && !archived);
if (card instanceof HomeSwipeCard) {
if (show) {
((HomeSwipeCard) card).setSwipeTranslationX(0f);
}
}
card.setVisibility(show ? View.VISIBLE : View.GONE);
return show;
}
private static void bindChip(final View root, final int chipId, final int labelId) {
final View chip = root.findViewById(chipId);
if (chip == null) {
return;
}
final TextView label = chip.findViewById(R.id.home_automation_chip_label);
if (label != null) {
label.setText(labelId);
}
}
private static void comingSoon(final View root, final int id) {
final View view = root.findViewById(id);
if (view != null) {
view.setOnClickListener(v -> HomeToast.show(v.getContext(),
R.string.home_automate_coming_soon));
}
}
private static void bindSwitch(final View root, final int id, final boolean on) {
final View view = root.findViewById(id);
if (!(view instanceof ImageView)) {
return;
}
final ImageView toggle = (ImageView) view;
applySwitch(toggle, on);
toggle.setOnClickListener(v -> applySwitch(toggle, !toggle.isSelected()));
}
private static void applySwitch(final ImageView toggle, final boolean on) {
toggle.setSelected(on);
toggle.setContentDescription(toggle.getResources().getString(
on ? R.string.home_automation_on : R.string.home_automation_off));
}
}
@@ -0,0 +1,120 @@
/*
* Copyright (C) 2026 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.content.Context;
import android.content.SharedPreferences;
import com.android.messaging.R;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/** Local archive/delete flags for the demo automation rules. */
public final class HomeAutomationRuleStore {
public static final String MISSED = "missed";
public static final String THANKS = "thanks";
public static final String WEEKLY = "weekly";
static final int[] RULE_VIEW_IDS = {
R.id.home_automation_rule_missed,
R.id.home_automation_rule_thanks,
R.id.home_automation_rule_weekly
};
private static final String PREFS = "home_automation_rules";
private static final String KEY_ARCHIVED = "archived";
private static final String KEY_DELETED = "deleted";
private HomeAutomationRuleStore() {
}
static String keyForView(final int viewId) {
if (viewId == R.id.home_automation_rule_missed) {
return MISSED;
}
if (viewId == R.id.home_automation_rule_thanks) {
return THANKS;
}
if (viewId == R.id.home_automation_rule_weekly) {
return WEEKLY;
}
return null;
}
public static boolean isArchived(final Context context, final String key) {
return set(context, KEY_ARCHIVED).contains(key);
}
public static boolean isDeleted(final Context context, final String key) {
return set(context, KEY_DELETED).contains(key);
}
public static void archive(final Context context, final String key) {
add(context, KEY_ARCHIVED, key);
remove(context, KEY_DELETED, key);
}
public static void unarchive(final Context context, final String key) {
remove(context, KEY_ARCHIVED, key);
}
public static void delete(final Context context, final String key) {
add(context, KEY_DELETED, key);
remove(context, KEY_ARCHIVED, key);
}
public static boolean hasArchived(final Context context) {
if (context == null) {
return false;
}
for (final String key : set(context, KEY_ARCHIVED)) {
if (!isDeleted(context, key)) {
return true;
}
}
return false;
}
private static Set<String> set(final Context context, final String key) {
return new HashSet<>(prefs(context).getStringSet(key, Collections.emptySet()));
}
private static void add(final Context context, final String key, final String value) {
if (value == null) {
return;
}
final Set<String> next = set(context, key);
if (next.add(value)) {
prefs(context).edit().putStringSet(key, next).apply();
}
}
private static void remove(final Context context, final String key, final String value) {
if (value == null) {
return;
}
final Set<String> next = set(context, key);
if (next.remove(value)) {
prefs(context).edit().putStringSet(key, next).apply();
}
}
private static SharedPreferences prefs(final Context context) {
return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (C) 2026 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
/** A contact or group chosen as an automation include/exclude target. */
public final class HomeAutomationTarget implements Parcelable {
public final String name;
public final String destination;
public final boolean group;
public final long contactId;
public final String lookupKey;
public final String iconUri;
public HomeAutomationTarget(final String name, final String destination, final boolean group,
final long contactId, final String lookupKey, final String iconUri) {
this.name = name;
this.destination = destination;
this.group = group;
this.contactId = contactId;
this.lookupKey = lookupKey;
this.iconUri = iconUri;
}
protected HomeAutomationTarget(final Parcel in) {
name = in.readString();
destination = in.readString();
group = in.readInt() == 1;
contactId = in.readLong();
lookupKey = in.readString();
iconUri = in.readString();
}
public String key() {
return (group ? "g:" : "c:") + (destination == null ? "" : destination);
}
public String displayName() {
return TextUtils.isEmpty(name) ? destination : name;
}
@Override
public void writeToParcel(final Parcel dest, final int flags) {
dest.writeString(name);
dest.writeString(destination);
dest.writeInt(group ? 1 : 0);
dest.writeLong(contactId);
dest.writeString(lookupKey);
dest.writeString(iconUri);
}
@Override
public int describeContents() {
return 0;
}
public static final Creator<HomeAutomationTarget> CREATOR =
new Creator<HomeAutomationTarget>() {
@Override
public HomeAutomationTarget createFromParcel(final Parcel in) {
return new HomeAutomationTarget(in);
}
@Override
public HomeAutomationTarget[] newArray(final int size) {
return new HomeAutomationTarget[size];
}
};
}
@@ -0,0 +1,372 @@
/*
* Copyright (C) 2026 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.TextView;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import com.android.ex.chips.RecipientEntry;
import com.android.messaging.R;
import com.android.messaging.datamodel.data.ParticipantData;
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.PhoneUtils;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
/** In-window sheet to pick contacts and contact groups as automation targets. */
public class HomeAutomationTargetPickerSheet extends Fragment
implements HomeContactsFragment.GroupSelectionHost, LoaderManager.LoaderCallbacks<Cursor> {
public static final String REQUEST_KEY = "home_automation_target_picker";
public static final String EXTRA_TARGETS = "home_automation_targets";
private static final String TAG = "home_automation_target_picker";
private static final int GROUPS_LOADER = 1;
private View mDim;
private View mPanel;
private boolean mClosing;
private TextView mContactsTab;
private TextView mGroupsTab;
private View mContactsHost;
private ListView mGroupsList;
private HomeEmptyState mGroupsEmpty;
private final LinkedHashMap<String, HomeAutomationTarget> mSelectedGroups =
new LinkedHashMap<>();
private final ArrayList<HomeContactGroups.Item> mGroups = new ArrayList<>();
private final GroupAdapter mGroupAdapter = new GroupAdapter();
private boolean mShowGroups;
private boolean mContactsSeeded;
public static void show(@NonNull final FragmentManager fragmentManager,
@Nullable final ArrayList<HomeAutomationTarget> incoming) {
final HomeAutomationTargetPickerSheet sheet = new HomeAutomationTargetPickerSheet();
final Bundle args = new Bundle();
args.putParcelableArrayList(EXTRA_TARGETS, incoming);
if (fragmentManager.findFragmentByTag(TAG) != null) {
return;
}
sheet.setArguments(args);
fragmentManager.beginTransaction()
.replace(R.id.home_automation_picker_host, sheet, TAG)
.commit();
}
@Nullable
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.home_automation_target_picker_sheet, container, false);
}
@Override
public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
mDim = view.findViewById(R.id.home_automation_picker_dim);
mPanel = view.findViewById(R.id.home_automation_picker_panel);
if (mDim != null) {
mDim.setOnClickListener(v -> dismissSheet());
}
view.findViewById(R.id.home_automation_picker_close).setOnClickListener(v -> dismissSheet());
view.findViewById(R.id.home_automation_picker_done).setOnClickListener(v -> finishWithResult());
final View host = requireActivity().findViewById(R.id.home_automation_picker_host);
if (host != null) {
host.setVisibility(View.VISIBLE);
}
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(),
new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
dismissSheet();
}
});
if (savedInstanceState == null) {
view.post(() -> HomeUiMotion.gallerySheetOpen(mDim, mPanel));
}
mContactsTab = view.findViewById(R.id.home_automation_picker_contacts);
mGroupsTab = view.findViewById(R.id.home_automation_picker_groups);
mContactsHost = view.findViewById(R.id.home_automation_picker_contacts_host);
mGroupsList = view.findViewById(R.id.home_automation_picker_groups_list);
mGroupsEmpty = view.findViewById(R.id.home_automation_picker_groups_empty);
mGroupsList.setAdapter(mGroupAdapter);
if (mGroupsEmpty != null) {
mGroupsEmpty.setContent(R.drawable.ic_home_automation_person,
R.string.home_automation_no_groups_title,
R.string.home_automation_no_groups_body);
}
HomeContactGroups.ensureDemo(requireContext());
seedIncoming(getArguments() == null ? null
: getArguments().getParcelableArrayList(EXTRA_TARGETS));
mContactsTab.setOnClickListener(v -> showGroups(false));
mGroupsTab.setOnClickListener(v -> showGroups(true));
showGroups(false);
prepareContactsFragment();
if (ContactUtil.hasReadContactsPermission()) {
LoaderManager.getInstance(this).initLoader(GROUPS_LOADER, null, this);
}
}
private void dismissSheet() {
if (mClosing || !isAdded()) {
return;
}
mClosing = true;
HomeUiMotion.gallerySheetClose(mDim, mPanel, () -> {
if (!isAdded()) {
return;
}
final View host = requireActivity().findViewById(R.id.home_automation_picker_host);
getParentFragmentManager().beginTransaction().remove(this).commitAllowingStateLoss();
if (host != null) {
host.setVisibility(View.GONE);
}
});
}
private void prepareContactsFragment() {
HomeContactsFragment fragment = getContactsFragment();
if (fragment == null) {
fragment = new HomeContactsFragment();
getChildFragmentManager().beginTransaction()
.replace(R.id.home_automation_picker_contacts_host, fragment)
.commitNow();
}
fragment.setGroupMode(true);
if (!mContactsSeeded) {
fragment.seedSelection(contactSeeds());
mContactsSeeded = true;
}
}
private ArrayList<String> contactSeeds() {
final ArrayList<HomeAutomationTarget> incoming = getArguments() == null
? null : getArguments().getParcelableArrayList(EXTRA_TARGETS);
final ArrayList<String> seeds = new ArrayList<>();
if (incoming == null) {
return seeds;
}
for (final HomeAutomationTarget target : incoming) {
if (target != null && !target.group && target.destination != null) {
seeds.add(target.destination);
}
}
return seeds;
}
private void seedIncoming(final ArrayList<HomeAutomationTarget> incoming) {
mSelectedGroups.clear();
if (incoming == null) {
return;
}
for (final HomeAutomationTarget target : incoming) {
if (target != null && target.group && target.destination != null) {
mSelectedGroups.put(target.destination, target);
}
}
mGroupAdapter.notifyDataSetChanged();
}
private HomeContactsFragment getContactsFragment() {
return (HomeContactsFragment) getChildFragmentManager()
.findFragmentById(R.id.home_automation_picker_contacts_host);
}
private void showGroups(final boolean groups) {
mShowGroups = groups;
applyPill(mContactsTab, !groups);
applyPill(mGroupsTab, groups);
if (mContactsHost != null) {
mContactsHost.setVisibility(groups ? View.GONE : View.VISIBLE);
}
updateGroupsVisibility();
}
private void updateGroupsVisibility() {
final boolean empty = mGroups.isEmpty();
if (mGroupsList != null) {
mGroupsList.setVisibility(mShowGroups && !empty ? View.VISIBLE : View.GONE);
}
if (mGroupsEmpty != null) {
mGroupsEmpty.setVisibility(mShowGroups && empty ? View.VISIBLE : View.GONE);
}
}
private void applyPill(final TextView pill, final boolean selected) {
if (pill == null) {
return;
}
pill.setBackgroundResource(selected
? R.drawable.home_bg_automation_pill_selected
: R.drawable.home_bg_automation_pill);
pill.setTextColor(ContextCompat.getColor(requireContext(),
selected ? android.R.color.white : R.color.home_text_primary));
}
@Override
public void onGroupSelectionChanged(final List<RecipientEntry> selected) {
// Selection is read from the fragment when Done is pressed.
}
@NonNull
@Override
public Loader<Cursor> onCreateLoader(final int id, @Nullable final Bundle args) {
return new CursorLoader(requireContext(), HomeContactGroups.SUMMARY_URI,
HomeContactGroups.PROJECTION, HomeContactGroups.SELECTION, null,
HomeContactGroups.SORT);
}
@Override
public void onLoadFinished(@NonNull final Loader<Cursor> loader, final Cursor data) {
mGroups.clear();
if (data != null && data.moveToFirst()) {
do {
final HomeContactGroups.Item item = HomeContactGroups.fromCursor(data);
if (!item.title.isEmpty()) {
mGroups.add(item);
}
} while (data.moveToNext());
}
mGroupAdapter.notifyDataSetChanged();
updateGroupsVisibility();
}
@Override
public void onLoaderReset(@NonNull final Loader<Cursor> loader) {
mGroups.clear();
mGroupAdapter.notifyDataSetChanged();
updateGroupsVisibility();
}
private void finishWithResult() {
final LinkedHashMap<String, HomeAutomationTarget> targets = new LinkedHashMap<>();
final HomeContactsFragment fragment = getContactsFragment();
if (fragment != null) {
for (final RecipientEntry entry : fragment.getSelectedEntries()) {
final HomeAutomationTarget target = fromEntry(entry);
targets.put(target.key(), target);
}
}
for (final HomeAutomationTarget group : mSelectedGroups.values()) {
targets.put(group.key(), group);
}
final Bundle result = new Bundle();
result.putParcelableArrayList(EXTRA_TARGETS, new ArrayList<>(targets.values()));
getParentFragmentManager().setFragmentResult(REQUEST_KEY, result);
dismissSheet();
}
private static HomeAutomationTarget fromEntry(final RecipientEntry entry) {
final ParticipantData participant = ParticipantData.getFromRecipientEntry(entry);
final Uri icon = AvatarUriUtil.createAvatarUri(participant);
return new HomeAutomationTarget(
ContactRecipientEntryUtils.getDisplayNameForContactList(entry),
PhoneUtils.getDefault().getCanonicalBySystemLocale(entry.getDestination()),
false,
participant.getContactId(),
participant.getLookupKey(),
icon == null ? null : icon.toString());
}
private static HomeAutomationTarget fromGroup(final HomeContactGroups.Item item) {
final Uri icon = item.avatarUri();
return new HomeAutomationTarget(item.title, item.destination(), true, 0, null,
icon == null ? null : icon.toString());
}
private void applyCheck(final View check, final boolean selected) {
if (check instanceof ImageView) {
HomeAvatarOverlay.apply((ImageView) check, true, selected);
}
}
private final class GroupAdapter extends BaseAdapter {
@Override
public int getCount() {
return mGroups.size();
}
@Override
public HomeContactGroups.Item getItem(final int position) {
return mGroups.get(position);
}
@Override
public long getItemId(final int position) {
return getItem(position).id;
}
@Override
public View getView(final int position, final View convertView, final ViewGroup parent) {
final View row = convertView != null ? convertView
: LayoutInflater.from(parent.getContext())
.inflate(R.layout.home_automation_target_row, parent, false);
final HomeContactGroups.Item item = getItem(position);
final ContactIconView avatar = row.findViewById(R.id.home_automation_target_avatar);
final TextView name = row.findViewById(R.id.home_automation_target_name);
final TextView meta = row.findViewById(R.id.home_automation_target_meta);
final View check = row.findViewById(R.id.home_automation_target_check);
avatar.setImageResourceUri(item.avatarUri());
avatar.setImageClickHandlerDisabled(true);
name.setText(item.title);
if (meta != null) {
if (item.memberCount > 0) {
meta.setVisibility(View.VISIBLE);
meta.setText(getResources().getQuantityString(
R.plurals.home_automation_group_member_count, item.memberCount,
item.memberCount));
} else {
meta.setVisibility(View.GONE);
}
}
final boolean selected = mSelectedGroups.containsKey(item.destination());
applyCheck(check, selected);
row.setOnClickListener(v -> {
final String id = item.destination();
if (mSelectedGroups.remove(id) == null) {
mSelectedGroups.put(id, fromGroup(item));
}
notifyDataSetChanged();
});
return row;
}
}
}
@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License and the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.view.View;
import android.widget.ImageView;
import com.android.messaging.R;
/** Avatar wash + check used while a people/message list is in edit/selection mode. */
public final class HomeAvatarOverlay {
private HomeAvatarOverlay() {
}
public static void apply(final ImageView overlay, final boolean selectionMode,
final boolean selected) {
if (overlay == null) {
return;
}
if (!selectionMode) {
overlay.animate().cancel();
overlay.setBackground(null);
overlay.setImageDrawable(null);
overlay.setPadding(0, 0, 0, 0);
overlay.setVisibility(View.GONE);
overlay.setAlpha(1f);
overlay.setScaleX(1f);
overlay.setScaleY(1f);
return;
}
overlay.setBackgroundResource(selected
? R.drawable.home_bg_avatar_selected_wash
: R.drawable.home_bg_avatar_edit);
if (selected) {
overlay.setScaleType(ImageView.ScaleType.FIT_CENTER);
applyHeaderCheckRatio(overlay);
overlay.setImageResource(R.drawable.ic_home_check_stroke);
} else {
overlay.setPadding(0, 0, 0, 0);
overlay.setImageDrawable(null);
}
overlay.setVisibility(View.VISIBLE);
}
/**
* Matches {@code ic_checkmark_circle_primary}: the tick is 16.3/42 of the circle. The
* stroke asset has extra viewport padding, so inset is reduced to keep that same visible size.
*/
private static void applyHeaderCheckRatio(final ImageView overlay) {
int side = overlay.getWidth();
if (side <= 0 && overlay.getLayoutParams() != null) {
side = overlay.getLayoutParams().width;
}
if (side <= 0) {
side = overlay.getResources().getDimensionPixelSize(R.dimen.home_list_avatar_size);
}
final int inset = Math.round(side * ((42f - 16.3f) / 42f / 2f) * (16f / 24f));
overlay.setPadding(inset, inset, inset, inset);
}
}
@@ -0,0 +1,100 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import com.android.messaging.R;
import com.google.android.material.bottomsheet.BottomSheetDialog;
/** Default home bottom sheet: handle, close X, optional title, custom body, dismiss on outside tap. */
public final class HomeBottomSheet {
public static final class Builder {
private final Context mContext;
private CharSequence mTitle;
private View mCustom;
private boolean mCancelable = true;
public Builder(@NonNull final Context context) {
mContext = context;
}
public Builder title(@StringRes final int titleRes) {
mTitle = mContext.getString(titleRes);
return this;
}
public Builder title(final CharSequence title) {
mTitle = title;
return this;
}
public Builder custom(final View custom) {
mCustom = custom;
return this;
}
public Builder cancelable(final boolean cancelable) {
mCancelable = cancelable;
return this;
}
public BottomSheetDialog show() {
return HomeBottomSheet.show(this);
}
}
private HomeBottomSheet() {
}
public static Builder builder(@NonNull final Context context) {
return new Builder(context);
}
private static BottomSheetDialog show(final Builder options) {
final Context context = options.mContext;
final View root = LayoutInflater.from(context).inflate(R.layout.home_bottom_sheet, null, false);
final TextView title = root.findViewById(R.id.home_sheet_title);
if (title != null) {
title.setText(options.mTitle);
title.setVisibility(TextUtils.isEmpty(options.mTitle) ? View.GONE : View.VISIBLE);
}
final FrameLayout custom = root.findViewById(R.id.home_sheet_custom);
if (custom != null && options.mCustom != null) {
if (options.mCustom.getParent() instanceof ViewGroup) {
((ViewGroup) options.mCustom.getParent()).removeView(options.mCustom);
}
custom.addView(options.mCustom, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
}
final BottomSheetDialog dialog = new BottomSheetDialog(context,
R.style.Home_BottomSheetDialog);
dialog.setContentView(root);
dialog.setCancelable(options.mCancelable);
dialog.setCanceledOnTouchOutside(options.mCancelable);
final View close = root.findViewById(R.id.home_sheet_close);
if (close != null) {
close.setOnClickListener(v -> dialog.dismiss());
close.setVisibility(options.mCancelable ? View.VISIBLE : View.GONE);
}
final Window window = dialog.getWindow();
if (window != null) {
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
}
dialog.show();
HomeInputFocus.install(dialog.getWindow());
return dialog;
}
}
@@ -0,0 +1,91 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.EditText;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.messaging.R;
import com.android.messaging.util.ImeUtil;
import java.util.Collections;
import java.util.List;
/** Shared Add category name modal. */
public final class HomeCategoryAddModal {
public interface Listener {
void onCategoryCreated(HomeCategoryStore.Category created);
}
private static boolean sShowing;
private HomeCategoryAddModal() {
}
public static void show(@NonNull final Context context, @Nullable final Listener listener) {
if (sShowing) {
return;
}
sShowing = true;
final View content = LayoutInflater.from(context)
.inflate(R.layout.home_category_name_dialog, null, false);
final EditText field = content.findViewById(R.id.home_category_name_field);
final HomeModal.Handle handle = HomeModal.builder(context)
.icon(R.drawable.ic_home_category)
.title(R.string.home_category_add)
.message(R.string.home_category_name_message)
.custom(content)
.negative(android.R.string.cancel, null)
.positive(R.string.home_category_add_new, () -> {
final String name = field == null ? "" : field.getText().toString();
final HomeCategoryStore.Category created = tryCreate(context, name, "",
Collections.emptyList());
if (created != null && listener != null) {
listener.onCategoryCreated(created);
}
})
.show();
if (handle == null || handle.dialog == null) {
sShowing = false;
return;
}
handle.dialog.setOnDismissListener(dialog -> sShowing = false);
if (field != null) {
field.requestFocus();
ImeUtil.get().showImeKeyboard(context, field);
}
}
@Nullable
public static HomeCategoryStore.Category tryCreate(@NonNull final Context context,
final String name, final String kind, @Nullable final List<String> conversationIds) {
final String trimmed = name == null ? "" : name.trim();
if (TextUtils.isEmpty(trimmed)) {
return null;
}
if (HomeCategoryStore.hasName(context, trimmed)) {
HomeToast.show(context, R.string.home_category_exists_toast);
return null;
}
if (HomeCategoryStore.listCustom(context).size() >= HomeCategoryStore.MAX_CUSTOM) {
HomeToast.show(context, R.string.home_category_limit_toast);
return null;
}
final HomeCategoryStore.Category created = HomeCategoryStore.addCustom(context, trimmed,
kind);
if (created == null) {
HomeToast.show(context, R.string.home_category_exists_toast);
return null;
}
if (conversationIds != null && !conversationIds.isEmpty()) {
HomeCategoryStore.assignAll(context, conversationIds, created.id);
}
HomeToast.show(context, R.string.home_category_added_toast);
return created;
}
}
@@ -0,0 +1,134 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import com.android.messaging.R;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/** Assigns a conversation to categories using the default home modal. */
public final class HomeCategoryAssignSheet {
public interface Listener {
void onAssignmentsChanged();
}
private HomeCategoryAssignSheet() {
}
public static void show(@NonNull final Context context,
@NonNull final ConversationListItemData data, final Listener listener) {
final String conversationId = data.getConversationId();
if (TextUtils.isEmpty(conversationId)) {
return;
}
final View custom = LayoutInflater.from(context)
.inflate(R.layout.home_category_assign_sheet, null, false);
final ChipGroup chips = custom.findViewById(R.id.home_category_assign_chips);
final Set<String> selected = new LinkedHashSet<>(
HomeCategoryStore.assignedIds(context, conversationId));
if (!selected.contains(HomeCategoryStore.ID_PERSONAL)
&& !selected.contains(HomeCategoryStore.ID_PROMOTION)) {
selected.add(HomeCategoryClassifier.isPromotion(data)
? HomeCategoryStore.ID_PROMOTION : HomeCategoryStore.ID_PERSONAL);
}
final Runnable[] bindChips = new Runnable[1];
bindChips[0] = () -> {
if (chips == null) {
return;
}
chips.removeAllViews();
final List<HomeCategoryStore.Category> categories =
HomeCategoryStore.listTabs(context);
for (final HomeCategoryStore.Category category : categories) {
if (HomeCategoryStore.ID_ALL.equals(category.id)) {
continue;
}
final Chip chip = new Chip(context);
chip.setText(category.name);
chip.setCheckable(true);
chip.setCheckedIconVisible(false);
chip.setChecked(selected.contains(category.id));
styleChip(chip);
chip.setOnCheckedChangeListener((button, isChecked) -> {
if (isChecked) {
selected.add(category.id);
} else {
selected.remove(category.id);
}
styleChip(chip);
});
chips.addView(chip);
}
final Chip addChip = new Chip(context);
addChip.setText(R.string.home_category_add_new);
addChip.setCheckable(false);
addChip.setCheckedIconVisible(false);
styleChip(addChip);
addChip.setOnClickListener(v -> HomeCategoryAddModal.show(context, created -> {
selected.add(created.id);
bindChips[0].run();
}));
chips.addView(addChip);
};
bindChips[0].run();
HomeModal.builder(context)
.icon(R.drawable.ic_home_category)
.title(R.string.home_category_assign)
.message(R.string.home_category_assign_message)
.custom(custom)
.negative(android.R.string.cancel, null)
.positive(R.string.home_category_done, () -> {
applyAssignments(context, conversationId, selected);
if (listener != null) {
listener.onAssignmentsChanged();
}
HomeToast.show(context, R.string.home_category_updated_toast);
})
.show();
}
private static void applyAssignments(final Context context, final String conversationId,
final Set<String> selected) {
final Set<String> current = HomeCategoryStore.assignedIds(context, conversationId);
for (final String id : selected) {
if (!current.contains(id)) {
HomeCategoryStore.assign(context, conversationId, id);
}
}
for (final String id : current) {
if (!selected.contains(id)) {
HomeCategoryStore.unassign(context, conversationId, id);
}
}
}
private static void styleChip(final Chip chip) {
final float density = chip.getResources().getDisplayMetrics().density;
chip.setChipBackgroundColorResource(R.color.home_category_chip_bg);
chip.setChipStrokeColorResource(R.color.home_category_chip_stroke);
chip.setChipStrokeWidth(density);
chip.setChipMinHeight(36f * density);
chip.setChipStartPadding(12f * density);
chip.setChipEndPadding(12f * density);
chip.setTextSize(14f);
chip.setTypeface(chip.getTypeface(), android.graphics.Typeface.BOLD);
chip.setTextColor(ContextCompat.getColorStateList(chip.getContext(),
R.color.home_category_chip_text));
chip.setCheckedIconVisible(false);
chip.setEnsureMinTouchTargetSize(false);
}
}
@@ -0,0 +1,149 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.telephony.PhoneNumberUtils;
import android.text.TextUtils;
import com.android.messaging.R;
import com.android.messaging.datamodel.data.ConversationListItemData;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/** Personal / promotion split and suggested categories from conversation text. */
public final class HomeCategoryClassifier {
public static final class Suggestion {
public final String kind;
public final String name;
public final List<String> conversationIds = new ArrayList<>();
Suggestion(final String kind, final String name) {
this.kind = kind;
this.name = name;
}
}
private HomeCategoryClassifier() {
}
public static boolean matches(final Context context, final String categoryId,
final ConversationListItemData item) {
if (item == null || TextUtils.isEmpty(categoryId)
|| HomeCategoryStore.ID_ALL.equals(categoryId)) {
return true;
}
if (HomeCategoryStore.ID_PERSONAL.equals(categoryId)) {
if (HomeCategoryStore.isAssigned(context, item.getConversationId(),
HomeCategoryStore.ID_PERSONAL)) {
return true;
}
if (HomeCategoryStore.isAssigned(context, item.getConversationId(),
HomeCategoryStore.ID_PROMOTION)) {
return false;
}
return isPersonal(item);
}
if (HomeCategoryStore.ID_PROMOTION.equals(categoryId)) {
if (HomeCategoryStore.isAssigned(context, item.getConversationId(),
HomeCategoryStore.ID_PROMOTION)) {
return true;
}
if (HomeCategoryStore.isAssigned(context, item.getConversationId(),
HomeCategoryStore.ID_PERSONAL)) {
return false;
}
return isPromotion(item);
}
return HomeCategoryStore.isAssigned(context, item.getConversationId(), categoryId);
}
public static boolean isPersonal(final ConversationListItemData item) {
return !isPromotion(item);
}
public static boolean isPromotion(final ConversationListItemData item) {
if (item == null) {
return false;
}
if (item.getIsGroup() || item.getParticipantContactId() > 0) {
return false;
}
if (looksLikeMobile(item)) {
return false;
}
return true;
}
public static boolean isVerification(final ConversationListItemData item) {
return containsAny(haystack(item),
"verification", "verify", "otp", "passcode", "auth code",
"인증", "인증번호", "인증코드", "인증 번호");
}
public static List<Suggestion> suggestions(final Context context,
final List<ConversationListItemData> items) {
final List<Suggestion> out = new ArrayList<>();
if (HomeCategoryStore.hasKind(context, HomeCategoryStore.KIND_VERIFICATION)) {
return out;
}
final Suggestion verification = new Suggestion(HomeCategoryStore.KIND_VERIFICATION,
context.getString(R.string.home_category_suggest_verification));
if (items != null) {
for (final ConversationListItemData item : items) {
if (item != null && isVerification(item)
&& !TextUtils.isEmpty(item.getConversationId())) {
verification.conversationIds.add(item.getConversationId());
}
}
}
if (!verification.conversationIds.isEmpty()) {
out.add(verification);
}
return out;
}
private static boolean looksLikeMobile(final ConversationListItemData item) {
final String dest = item.getOtherParticipantNormalizedDestination();
if (looksLikeMobileNumber(dest)) {
return true;
}
return looksLikeMobileNumber(item.getName());
}
private static boolean looksLikeMobileNumber(final String raw) {
if (TextUtils.isEmpty(raw)) {
return false;
}
final String digits = PhoneNumberUtils.normalizeNumber(raw);
if (TextUtils.isEmpty(digits)) {
return false;
}
for (int i = 0; i < digits.length(); i++) {
final char c = digits.charAt(i);
if (c != '+' && !Character.isDigit(c)) {
return false;
}
}
return digits.length() >= 8;
}
private static String haystack(final ConversationListItemData item) {
final String name = item.getName() == null ? "" : item.getName();
final String snippet = item.getSnippetText() == null ? "" : item.getSnippetText();
return (name + " " + snippet).toLowerCase(Locale.ROOT);
}
private static boolean containsAny(final String haystack, final String... needles) {
if (TextUtils.isEmpty(haystack)) {
return false;
}
for (final String needle : needles) {
if (haystack.contains(needle)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,143 @@
/*
* Copyright (C) 2026 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewParent;
import android.widget.HorizontalScrollView;
import com.android.messaging.R;
/** Category tab strip that keeps horizontal swipes instead of letting parents steal them. */
public final class HomeCategoryScroller extends HorizontalScrollView {
private final int mTouchSlop;
private float mDownX;
private float mDownY;
private boolean mDragging;
public HomeCategoryScroller(final Context context) {
this(context, null);
}
public HomeCategoryScroller(final Context context, final AttributeSet attrs) {
this(context, attrs, 0);
}
public HomeCategoryScroller(final Context context, final AttributeSet attrs,
final int defStyleAttr) {
super(context, attrs, defStyleAttr);
mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
setHorizontalScrollBarEnabled(false);
setHorizontalFadingEdgeEnabled(false);
setOverScrollMode(OVER_SCROLL_IF_CONTENT_SCROLLS);
}
@Override
protected void onLayout(final boolean changed, final int l, final int t, final int r,
final int b) {
super.onLayout(changed, l, t, r, b);
updateEdgeFades();
}
@Override
protected void onScrollChanged(final int l, final int t, final int oldl, final int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
updateEdgeFades();
}
private void updateEdgeFades() {
final ViewParent parent = getParent();
if (!(parent instanceof View)) {
return;
}
final View host = (View) parent;
HomeScrollEdgeFades.apply(this, host.findViewById(R.id.home_category_fade_start),
host.findViewById(R.id.home_category_fade_end));
}
@Override
public boolean onInterceptTouchEvent(final MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mDownX = ev.getX();
mDownY = ev.getY();
mDragging = false;
disallowParent(canScrollContent());
break;
case MotionEvent.ACTION_MOVE:
if (isHorizontalDrag(ev)) {
mDragging = true;
disallowParent(true);
return true;
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mDragging = false;
disallowParent(false);
break;
default:
break;
}
return super.onInterceptTouchEvent(ev) || mDragging;
}
@Override
public boolean onTouchEvent(final MotionEvent ev) {
final int action = ev.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
mDownX = ev.getX();
mDownY = ev.getY();
mDragging = false;
} else if (action == MotionEvent.ACTION_MOVE && isHorizontalDrag(ev)) {
mDragging = true;
}
if (mDragging || canScrollContent()) {
disallowParent(action != MotionEvent.ACTION_UP && action != MotionEvent.ACTION_CANCEL);
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
mDragging = false;
disallowParent(false);
}
return super.onTouchEvent(ev);
}
private boolean isHorizontalDrag(final MotionEvent ev) {
final float dx = Math.abs(ev.getX() - mDownX);
final float dy = Math.abs(ev.getY() - mDownY);
return dx > mTouchSlop && dx > dy;
}
private boolean canScrollContent() {
if (getChildCount() == 0) {
return false;
}
final View child = getChildAt(0);
return child.getWidth() > getWidth() - getPaddingLeft() - getPaddingRight();
}
private void disallowParent(final boolean disallow) {
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(disallow);
}
}
}
@@ -0,0 +1,256 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
import com.android.messaging.R;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/** Built-in and user-created conversation categories for the home list. */
public final class HomeCategoryStore {
public static final String ID_ALL = "all";
public static final String ID_PERSONAL = "personal";
public static final String ID_PROMOTION = "promotion";
public static final String KIND_VERIFICATION = "verification";
private static final String PREFS_NAME = "home_categories";
private static final String KEY_CUSTOM = "custom";
private static final String KEY_ASSIGN = "assign";
private static final String KEY_SELECTED = "selected";
private static final String ROW = "\n";
private static final String COL = "\t";
public static final int MAX_CUSTOM = 12;
public static final int MAX_NAME = 24;
public static final class Category {
public final String id;
public final String name;
public final String kind;
public final boolean builtIn;
Category(final String id, final String name, final String kind, final boolean builtIn) {
this.id = id;
this.name = name;
this.kind = kind == null ? "" : kind;
this.builtIn = builtIn;
}
}
private HomeCategoryStore() {
}
public static boolean isBuiltIn(final String id) {
return ID_ALL.equals(id) || ID_PERSONAL.equals(id) || ID_PROMOTION.equals(id);
}
public static List<Category> listTabs(final Context context) {
final List<Category> tabs = new ArrayList<>();
tabs.add(new Category(ID_ALL, context.getString(R.string.home_category_all), "", true));
tabs.add(new Category(ID_PERSONAL, context.getString(R.string.home_category_personal),
"", true));
tabs.add(new Category(ID_PROMOTION, context.getString(R.string.home_category_promotion),
"", true));
tabs.addAll(listCustom(context));
return tabs;
}
public static List<Category> listCustom(final Context context) {
final String raw = prefs(context).getString(KEY_CUSTOM, "");
if (TextUtils.isEmpty(raw)) {
return Collections.emptyList();
}
final List<Category> items = new ArrayList<>();
for (final String line : raw.split(ROW, -1)) {
if (TextUtils.isEmpty(line)) {
continue;
}
final String[] parts = line.split(COL, -1);
if (parts.length < 2 || TextUtils.isEmpty(parts[0]) || TextUtils.isEmpty(parts[1])) {
continue;
}
final String kind = parts.length > 2 ? parts[2] : "";
items.add(new Category(parts[0], parts[1], kind, false));
}
return items;
}
public static boolean hasKind(final Context context, final String kind) {
if (TextUtils.isEmpty(kind)) {
return false;
}
for (final Category category : listCustom(context)) {
if (kind.equals(category.kind)) {
return true;
}
}
return false;
}
public static boolean hasName(final Context context, final String name) {
final String trimmed = name == null ? "" : name.trim();
if (TextUtils.isEmpty(trimmed)) {
return false;
}
for (final Category category : listTabs(context)) {
if (trimmed.equalsIgnoreCase(category.name)) {
return true;
}
}
return false;
}
public static Category addCustom(final Context context, final String name, final String kind) {
String trimmed = name == null ? "" : name.trim();
if (TextUtils.isEmpty(trimmed)) {
return null;
}
if (trimmed.length() > MAX_NAME) {
trimmed = trimmed.substring(0, MAX_NAME);
}
if (hasName(context, trimmed)) {
return null;
}
final List<Category> items = new ArrayList<>(listCustom(context));
if (items.size() >= MAX_CUSTOM) {
return null;
}
final Category created = new Category("c_" + UUID.randomUUID().toString(), trimmed,
kind == null ? "" : kind, false);
items.add(created);
saveCustom(context, items);
return created;
}
public static void removeCustom(final Context context, final String categoryId) {
if (isBuiltIn(categoryId)) {
return;
}
final List<Category> items = new ArrayList<>(listCustom(context));
boolean removed = false;
for (int i = items.size() - 1; i >= 0; i--) {
if (items.get(i).id.equals(categoryId)) {
items.remove(i);
removed = true;
}
}
if (!removed) {
return;
}
saveCustom(context, items);
final List<String> kept = new ArrayList<>();
for (final String row : assignmentRows(context)) {
final String[] parts = row.split(COL, -1);
if (parts.length >= 2 && !categoryId.equals(parts[1])) {
kept.add(row);
}
}
prefs(context).edit().putString(KEY_ASSIGN, TextUtils.join(ROW, kept)).apply();
if (categoryId.equals(getSelectedId(context))) {
setSelectedId(context, ID_ALL);
}
}
public static void assign(final Context context, final String conversationId,
final String categoryId) {
if (TextUtils.isEmpty(conversationId) || TextUtils.isEmpty(categoryId)
|| ID_ALL.equals(categoryId)) {
return;
}
final LinkedHashSet<String> rows = new LinkedHashSet<>(assignmentRows(context));
rows.add(conversationId + COL + categoryId);
prefs(context).edit().putString(KEY_ASSIGN, TextUtils.join(ROW, rows)).apply();
}
public static void assignAll(final Context context, final List<String> conversationIds,
final String categoryId) {
if (conversationIds == null) {
return;
}
for (final String id : conversationIds) {
assign(context, id, categoryId);
}
}
public static void unassign(final Context context, final String conversationId,
final String categoryId) {
if (TextUtils.isEmpty(conversationId) || TextUtils.isEmpty(categoryId)) {
return;
}
final String needle = conversationId + COL + categoryId;
final List<String> rows = assignmentRows(context);
if (!rows.remove(needle)) {
return;
}
prefs(context).edit().putString(KEY_ASSIGN, TextUtils.join(ROW, rows)).apply();
}
public static Set<String> assignedIds(final Context context,
final String conversationId) {
final LinkedHashSet<String> ids = new LinkedHashSet<>();
if (TextUtils.isEmpty(conversationId)) {
return ids;
}
for (final String row : assignmentRows(context)) {
final String[] parts = row.split(COL, -1);
if (parts.length >= 2 && conversationId.equals(parts[0])) {
ids.add(parts[1]);
}
}
return ids;
}
public static boolean isAssigned(final Context context, final String conversationId,
final String categoryId) {
if (TextUtils.isEmpty(conversationId) || TextUtils.isEmpty(categoryId)) {
return false;
}
final String needle = conversationId + COL + categoryId;
return assignmentRows(context).contains(needle);
}
public static String getSelectedId(final Context context) {
final String selected = prefs(context).getString(KEY_SELECTED, ID_ALL);
return TextUtils.isEmpty(selected) ? ID_ALL : selected;
}
public static void setSelectedId(final Context context, final String categoryId) {
prefs(context).edit().putString(KEY_SELECTED,
TextUtils.isEmpty(categoryId) ? ID_ALL : categoryId).apply();
}
private static List<String> assignmentRows(final Context context) {
final String raw = prefs(context).getString(KEY_ASSIGN, "");
if (TextUtils.isEmpty(raw)) {
return new ArrayList<>();
}
final List<String> rows = new ArrayList<>();
for (final String line : raw.split(ROW, -1)) {
if (!TextUtils.isEmpty(line)) {
rows.add(line);
}
}
return rows;
}
private static void saveCustom(final Context context, final List<Category> items) {
final List<String> rows = new ArrayList<>(items.size());
for (final Category item : items) {
rows.add(item.id + COL + item.name + COL + item.kind);
}
prefs(context).edit().putString(KEY_CUSTOM, TextUtils.join(ROW, rows)).apply();
}
private static SharedPreferences prefs(final Context context) {
return context.getApplicationContext().getSharedPreferences(PREFS_NAME,
Context.MODE_PRIVATE);
}
}
@@ -0,0 +1,112 @@
/*
* Copyright (C) 2026 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;
import com.android.messaging.R;
/**
* Category tabs + add button. Tabs stay left of the add control; the add button
* follows the last tab and never crosses the strip's end padding.
*/
public final class HomeCategoryStrip extends LinearLayout {
public HomeCategoryStrip(final Context context) {
this(context, null);
}
public HomeCategoryStrip(final Context context, final AttributeSet attrs) {
this(context, attrs, 0);
}
public HomeCategoryStrip(final Context context, final AttributeSet attrs,
final int defStyleAttr) {
super(context, attrs, defStyleAttr);
setOrientation(HORIZONTAL);
setClipToPadding(true);
setClipChildren(true);
}
@Override
protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
final View all = findViewById(R.id.home_category_all);
final View add = findViewById(R.id.home_category_add);
final View scroll = findViewById(R.id.home_category_scroll_host);
int pinned = 0;
int contentHeight = 0;
if (all != null && all.getVisibility() != GONE) {
measureChildWithMargins(all, widthMeasureSpec, 0, heightMeasureSpec, 0);
pinned += measuredWidthWithMargins(all);
contentHeight = Math.max(contentHeight, measuredHeightWithMargins(all));
}
if (add != null && add.getVisibility() != GONE) {
measureChildWithMargins(add, widthMeasureSpec, pinned, heightMeasureSpec, 0);
pinned += measuredWidthWithMargins(add);
contentHeight = Math.max(contentHeight, measuredHeightWithMargins(add));
}
int contentWidth = pinned;
if (scroll != null && scroll.getVisibility() != GONE) {
measureChildWithMargins(scroll, widthMeasureSpec, pinned, heightMeasureSpec, 0);
contentWidth += measuredWidthWithMargins(scroll);
contentHeight = Math.max(contentHeight, measuredHeightWithMargins(scroll));
}
setMeasuredDimension(
resolveSize(contentWidth + getPaddingLeft() + getPaddingRight(),
widthMeasureSpec),
resolveSize(contentHeight + getPaddingTop() + getPaddingBottom(),
heightMeasureSpec));
}
@Override
protected void onLayout(final boolean changed, final int left, final int top, final int right,
final int bottom) {
final int innerTop = getPaddingTop();
final int innerBottom = bottom - top - getPaddingBottom();
int x = getPaddingLeft();
x = layoutChild(findViewById(R.id.home_category_all), x, innerTop, innerBottom);
x = layoutChild(findViewById(R.id.home_category_scroll_host), x, innerTop, innerBottom);
layoutChild(findViewById(R.id.home_category_add), x, innerTop, innerBottom);
}
private int layoutChild(final View child, final int x, final int innerTop,
final int innerBottom) {
if (child == null || child.getVisibility() == GONE) {
return x;
}
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
final int height = child.getMeasuredHeight();
final int childTop = innerTop + lp.topMargin
+ Math.max(0, (innerBottom - innerTop - height - lp.topMargin - lp.bottomMargin) / 2);
final int childLeft = x + lp.leftMargin;
child.layout(childLeft, childTop, childLeft + child.getMeasuredWidth(),
childTop + height);
return childLeft + child.getMeasuredWidth() + lp.rightMargin;
}
private static int measuredWidthWithMargins(final View child) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
return child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
}
private static int measuredHeightWithMargins(final View child) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
return child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
}
}
@@ -0,0 +1,344 @@
/*
* Copyright (C) 2025 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.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;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.EditText;
import android.widget.HorizontalScrollView;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.android.ex.chips.RecipientEntry;
import com.android.messaging.R;
import com.android.messaging.datamodel.data.ParticipantData;
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
implements HomeContactsFragment.GroupSelectionHost {
/** Matches the duration of {@code R.anim.home_sheet_open}. */
private static final long SHEET_ENTER_MS = 300L;
private static final int REQUEST_GROUP_NAME = 1;
private static final int MIN_GROUP_SIZE = 2;
private EditText mRecipientField;
private TextView mHeaderTitle;
private View mCreateGroupButton;
private HorizontalScrollView mChipScroll;
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) {
super.onCreate(savedInstanceState);
setContentView(R.layout.home_compose_activity);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
// Presented as a sheet, so the header closes instead of navigating back.
final ImageButton back = findViewById(R.id.home_sub_header_back);
back.setVisibility(View.GONE);
final ImageButton close = findViewById(R.id.home_sub_header_close);
close.setVisibility(View.VISIBLE);
close.setOnClickListener(v -> finish());
mHeaderTitle = findViewById(R.id.home_sub_header_title);
mHeaderTitle.setText(R.string.home_compose_title);
final View scrim = findViewById(R.id.home_compose_scrim);
// The tap that opened the sheet can land here while it is still sliding up, so the
// dismiss handler only arms once the entry animation is done.
scrim.postDelayed(() -> scrim.setOnClickListener(v -> finish()), SHEET_ENTER_MS);
mChipScroll = findViewById(R.id.home_compose_chip_scroll);
mChipContainer = findViewById(R.id.home_compose_chips);
mChipHint = findViewById(R.id.home_compose_chip_hint);
mNextButton = findViewById(R.id.home_compose_next);
mNextButton.setOnClickListener(v -> openGroupNameStep());
mRecipientField = findViewById(R.id.home_compose_recipient);
final View recipientBox = findViewById(R.id.home_compose_recipient_box);
if (recipientBox != null) {
recipientBox.setOnClickListener(v -> {
if (mRecipientField.getVisibility() != View.VISIBLE) {
return;
}
mRecipientField.requestFocus();
ImeUtil.get().showImeKeyboard(this, mRecipientField);
});
}
mRecipientField.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) {
final HomeContactsFragment fragment = getContactsFragment();
if (fragment != null) {
fragment.setFilterText(s.toString());
}
}
});
mRecipientField.setOnEditorActionListener((v, actionId, event) -> {
final HomeContactsFragment fragment = getContactsFragment();
return fragment != null && fragment.startConversationWithFilterText();
});
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() {
return (HomeContactsFragment) getSupportFragmentManager()
.findFragmentById(R.id.home_compose_fragment_host);
}
private void showAccountSheet() {
mAccountExecutor.execute(() -> {
final List<ContactUtil.ContactAccount> accounts =
ContactUtil.getContactAccounts(this);
mMainHandler.post(() -> presentAccountSheet(accounts));
});
}
private void presentAccountSheet(final List<ContactUtil.ContactAccount> accounts) {
if (isFinishing() || isDestroyed()) {
return;
}
final LayoutInflater inflater = LayoutInflater.from(this);
final LinearLayout list = (LinearLayout) inflater.inflate(
R.layout.home_compose_account_sheet, null, false);
final BottomSheetDialog[] dialog = new BottomSheetDialog[1];
int allCount = 0;
for (final ContactUtil.ContactAccount account : accounts) {
allCount += account.contactCount;
}
list.addView(createAccountRow(inflater, list,
getString(R.string.home_compose_all_contacts), null, allCount,
mAccountFilter == null, () -> {
applyAccountFilter(null, getString(R.string.home_compose_all_contacts));
if (dialog[0] != null) {
dialog[0].dismiss();
}
}));
for (final ContactUtil.ContactAccount account : accounts) {
final String title = account.deviceOnly
? getString(R.string.home_compose_device_contacts)
: (!TextUtils.isEmpty(account.title) ? account.title
: getString(R.string.home_compose_device_contacts));
final String subtitle = account.deviceOnly ? null : account.subtitle;
final boolean selected = mAccountFilter != null
&& account.toFilter().matches(mAccountFilter);
list.addView(createAccountRow(inflater, list, title, subtitle, account.contactCount,
selected, () -> {
applyAccountFilter(account.toFilter(), title);
if (dialog[0] != null) {
dialog[0].dismiss();
}
}));
}
dialog[0] = HomeBottomSheet.builder(this)
.title(R.string.home_compose_contacts_source_title)
.custom(list)
.show();
}
private View createAccountRow(final LayoutInflater inflater, final LinearLayout parent,
final String title, final String subtitle, final int count, final boolean selected,
final Runnable onClick) {
final View row = inflater.inflate(R.layout.home_compose_account_row, parent, false);
((TextView) row.findViewById(R.id.home_compose_account_title)).setText(title);
final TextView subtitleView = row.findViewById(R.id.home_compose_account_subtitle);
if (TextUtils.isEmpty(subtitle)) {
subtitleView.setVisibility(View.GONE);
} else {
subtitleView.setVisibility(View.VISIBLE);
subtitleView.setText(subtitle);
}
((TextView) row.findViewById(R.id.home_compose_account_count))
.setText(String.valueOf(count));
row.findViewById(R.id.home_compose_account_check)
.setVisibility(selected ? View.VISIBLE : View.GONE);
row.setOnClickListener(v -> onClick.run());
final LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
params.topMargin = parent.getChildCount() == 0
? 0 : getResources().getDimensionPixelSize(R.dimen.looply_space_sm);
row.setLayoutParams(params);
return row;
}
private void applyAccountFilter(final ContactUtil.AccountFilter filter, final String label) {
mAccountFilter = filter;
mAccountLabel.setText(label);
final HomeContactsFragment fragment = getContactsFragment();
if (fragment != null) {
fragment.setAccountFilter(filter);
}
}
private void enterGroupMode() {
mGroupMode = true;
ImeUtil.get().hideImeKeyboard(this, mRecipientField);
mHeaderTitle.setText(R.string.home_compose_group_title);
mRecipientField.setText("");
mRecipientField.setVisibility(View.GONE);
mCreateGroupButton.setVisibility(View.GONE);
mChipScroll.setVisibility(View.VISIBLE);
mChipHint.setVisibility(View.VISIBLE);
mNextButton.setVisibility(View.VISIBLE);
final HomeContactsFragment fragment = getContactsFragment();
if (fragment != null) {
fragment.setGroupMode(true);
}
updateNextButton(0);
}
private void exitGroupMode() {
mGroupMode = false;
mHeaderTitle.setText(R.string.home_compose_title);
mRecipientField.setVisibility(View.VISIBLE);
mCreateGroupButton.setVisibility(View.VISIBLE);
mChipScroll.setVisibility(View.GONE);
mChipHint.setVisibility(View.GONE);
mNextButton.setVisibility(View.GONE);
mChipContainer.removeAllViews();
final HomeContactsFragment fragment = getContactsFragment();
if (fragment != null) {
fragment.setGroupMode(false);
}
}
@Override
public void onGroupSelectionChanged(final List<RecipientEntry> selected) {
mChipContainer.removeAllViews();
final LayoutInflater inflater = LayoutInflater.from(this);
for (final RecipientEntry entry : selected) {
final View chip = inflater.inflate(R.layout.home_compose_chip, mChipContainer, false);
final ContactIconView avatar = chip.findViewById(R.id.home_compose_chip_avatar);
final ParticipantData participant = ParticipantData.getFromRecipientEntry(entry);
avatar.setImageResourceUri(AvatarUriUtil.createAvatarUri(participant),
participant.getContactId(), participant.getLookupKey(),
participant.getNormalizedDestination());
avatar.setImageClickHandlerDisabled(true);
final TextView name = chip.findViewById(R.id.home_compose_chip_name);
name.setText(ContactRecipientEntryUtils.getDisplayNameForContactList(entry));
final LinearLayout.LayoutParams params =
(LinearLayout.LayoutParams) chip.getLayoutParams();
params.setMarginEnd(getResources().getDimensionPixelSize(R.dimen.looply_space_xs));
mChipContainer.addView(chip, params);
}
mChipHint.setVisibility(selected.isEmpty() ? View.VISIBLE : View.GONE);
updateNextButton(selected.size());
}
private void updateNextButton(final int selectedCount) {
final boolean enabled = selectedCount >= MIN_GROUP_SIZE;
mNextButton.setEnabled(enabled);
mNextButton.setAlpha(enabled ? 1f : 0.4f);
}
private void openGroupNameStep() {
final HomeContactsFragment fragment = getContactsFragment();
if (fragment == null) {
return;
}
final ArrayList<ParticipantData> participants = fragment.getSelectedParticipants();
if (participants.size() < MIN_GROUP_SIZE) {
return;
}
startActivityForResult(HomeGroupNameActivity.buildIntent(this, participants),
REQUEST_GROUP_NAME);
overridePendingTransition(R.anim.home_sheet_open, R.anim.home_activity_hold);
}
@Override
protected void onActivityResult(final int requestCode, final int resultCode,
final Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_GROUP_NAME && resultCode == RESULT_OK) {
// The conversation is open, so this sheet should not be behind it.
finish();
}
}
@Override
public void onBackPressed() {
if (mGroupMode) {
exitGroupMode();
return;
}
super.onBackPressed();
}
@Override
protected void onDestroy() {
mAccountExecutor.shutdownNow();
super.onDestroy();
}
@Override
public void finish() {
if (mRecipientField != null && !TextUtils.isEmpty(mRecipientField.getText())) {
ImeUtil.get().hideImeKeyboard(this, mRecipientField);
}
super.finish();
overridePendingTransition(R.anim.home_activity_hold, R.anim.home_sheet_close);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,209 @@
/*
* Copyright (C) 2026 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.Manifest;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.net.Uri;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.provider.ContactsContract.Groups;
import android.provider.ContactsContract.RawContacts;
import android.text.TextUtils;
import com.android.messaging.util.AvatarUriUtil;
import com.android.messaging.util.ContactUtil;
import com.android.messaging.util.OsUtil;
/** Android Contacts groups (not chat threads) for automation include/exclude. */
final class HomeContactGroups {
static final String[] PROJECTION = {
Groups._ID,
Groups.TITLE,
Groups.SUMMARY_COUNT,
Groups.SUMMARY_WITH_PHONES
};
static final String SELECTION = Groups.DELETED + "=0 AND (" + Groups.AUTO_ADD
+ "=0 OR " + Groups.AUTO_ADD + " IS NULL)";
static final String SORT = Groups.TITLE + " COLLATE LOCALIZED ASC";
static final Uri SUMMARY_URI = Groups.CONTENT_SUMMARY_URI;
private static final String PREFS = "home_demo";
private static final String KEY_SEEDED = "contact_groups_seeded_v1";
private static final String DEST_PREFIX = "cg:";
private HomeContactGroups() {
}
static void ensureDemo(final Context context) {
if (context == null || !OsUtil.hasPermission(Manifest.permission.WRITE_CONTACTS)
|| !ContactUtil.hasReadContactsPermission()) {
return;
}
final SharedPreferences prefs =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
if (prefs.getBoolean(KEY_SEEDED, false)) {
return;
}
final ContentResolver resolver = context.getContentResolver();
seedGroup(resolver, "Family",
"+15551230008", "+15551230003", "+15551230023", "+15551230005");
seedGroup(resolver, "Friends",
"+15551230006", "+15551230012", "+15551230015", "+15551230018",
"+15551230020", "+15551230007");
seedGroup(resolver, "Work",
"+15551230001", "+15551230002", "+15551230004", "+15551230011",
"+15551230016", "+15551230013");
seedGroup(resolver, "Clients",
"+15551230009", "+15551230010", "+15551230014", "+15551230017",
"+15551230021", "+15551230022");
prefs.edit().putBoolean(KEY_SEEDED, true).apply();
}
static Item fromCursor(final Cursor cursor) {
final long id = cursor.getLong(0);
final String title = cursor.getString(1);
int count = 0;
if (!cursor.isNull(3)) {
count = cursor.getInt(3);
} else if (!cursor.isNull(2)) {
count = cursor.getInt(2);
}
return new Item(id, title, count);
}
static final class Item {
final long id;
final String title;
final int memberCount;
Item(final long id, final String title, final int memberCount) {
this.id = id;
this.title = title == null ? "" : title;
this.memberCount = memberCount;
}
String destination() {
return DEST_PREFIX + id;
}
Uri avatarUri() {
if (TextUtils.isEmpty(title)) {
return AvatarUriUtil.createAvatarUri(null, null, destination(), null);
}
return AvatarUriUtil.createAvatarUri(null, title, destination(), null);
}
}
private static void seedGroup(final ContentResolver resolver, final String title,
final String... numbers) {
if (findGroupId(resolver, title) >= 0) {
return;
}
final ContentValues values = new ContentValues();
values.put(Groups.TITLE, title);
values.put(Groups.GROUP_VISIBLE, 1);
bindAccount(resolver, values);
final Uri inserted = resolver.insert(Groups.CONTENT_URI, values);
if (inserted == null) {
return;
}
final long groupId = android.content.ContentUris.parseId(inserted);
if (groupId <= 0 || numbers == null) {
return;
}
for (final String number : numbers) {
final long rawId = findRawContactId(resolver, number);
if (rawId > 0) {
addMembership(resolver, rawId, groupId);
}
}
}
private static long findGroupId(final ContentResolver resolver, final String title) {
try (Cursor cursor = resolver.query(Groups.CONTENT_URI,
new String[] { Groups._ID },
Groups.DELETED + "=0 AND " + Groups.TITLE + "=?",
new String[] { title }, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getLong(0);
}
}
return -1;
}
private static void bindAccount(final ContentResolver resolver, final ContentValues values) {
try (Cursor cursor = resolver.query(RawContacts.CONTENT_URI,
new String[] { RawContacts.ACCOUNT_NAME, RawContacts.ACCOUNT_TYPE },
RawContacts.DELETED + "=0", null, RawContacts._ID + " ASC")) {
if (cursor != null && cursor.moveToFirst()) {
final String name = cursor.getString(0);
final String type = cursor.getString(1);
if (!TextUtils.isEmpty(type)) {
values.put(Groups.ACCOUNT_NAME, name);
values.put(Groups.ACCOUNT_TYPE, type);
}
}
}
}
private static long findRawContactId(final ContentResolver resolver, final String number) {
if (TextUtils.isEmpty(number)) {
return -1;
}
final String digits = digitsOnly(number);
try (Cursor cursor = resolver.query(Phone.CONTENT_URI,
new String[] { Phone.RAW_CONTACT_ID, Phone.NORMALIZED_NUMBER, Phone.NUMBER },
null, null, null)) {
if (cursor == null) {
return -1;
}
while (cursor.moveToNext()) {
final String normalized = cursor.getString(1);
final String raw = cursor.getString(2);
if (sameNumber(digits, normalized) || sameNumber(digits, raw)) {
return cursor.getLong(0);
}
}
}
return -1;
}
private static void addMembership(final ContentResolver resolver, final long rawContactId,
final long groupId) {
final ContentValues values = new ContentValues();
values.put(ContactsContract.Data.RAW_CONTACT_ID, rawContactId);
values.put(ContactsContract.Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
values.put(GroupMembership.GROUP_ROW_ID, groupId);
resolver.insert(ContactsContract.Data.CONTENT_URI, values);
}
private static boolean sameNumber(final String expectedDigits, final String candidate) {
if (TextUtils.isEmpty(candidate)) {
return false;
}
return TextUtils.equals(expectedDigits, digitsOnly(candidate));
}
private static String digitsOnly(final String value) {
final StringBuilder out = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
final char c = value.charAt(i);
if (c >= '0' && c <= '9') {
out.append(c);
}
}
return out.toString();
}
}
@@ -5,30 +5,116 @@ import android.database.Cursor;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AbsListView;
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()));
wrapListRow(itemView);
}
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
public View newView(final Context context, final Cursor cursor, final ViewGroup parent) {
return LayoutInflater.from(context).inflate(R.layout.home_contacts_list_item, parent,
false);
final View view = LayoutInflater.from(context).inflate(R.layout.home_contacts_list_item,
parent, false);
wrapListRow(view);
return view;
}
private static void wrapListRow(final View view) {
ViewGroup.LayoutParams params = view.getLayoutParams();
if (params instanceof AbsListView.LayoutParams) {
params.height = ViewGroup.LayoutParams.WRAP_CONTENT;
view.setLayoutParams(params);
return;
}
view.setLayoutParams(new AbsListView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
}
@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);
}
}
@@ -2,25 +2,24 @@ package com.android.messaging.ui.home;
import android.database.Cursor;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ListView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.loader.app.LoaderManager;
import com.android.ex.chips.RecipientEntry;
import com.android.messaging.R;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.action.GetOrCreateConversationAction;
import com.android.messaging.datamodel.action.ActionMonitor;
import com.android.messaging.datamodel.action.GetOrCreateConversationAction;
import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener;
import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionMonitor;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.RunsOnMainThread;
import com.android.messaging.datamodel.binding.Binding;
import com.android.messaging.datamodel.binding.BindingBase;
import com.android.messaging.datamodel.data.ContactListItemData;
@@ -29,17 +28,39 @@ import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataL
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.contact.ContactListItemView;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.RunsOnMainThread;
import com.android.messaging.util.ContactRecipientEntryUtils;
import com.android.messaging.util.ContactUtil;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.PhoneUtils;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
/**
* Contact list for the home "new chat" screen. Tapping a row opens that conversation, or picks
* the contact when the host screen is building a group.
*/
public class HomeContactsFragment extends Fragment implements ContactPickerDataListener,
ContactListItemView.HostInterface, GetOrCreateConversationActionListener {
/** Implemented by a host that lets the user pick several contacts at once. */
public interface GroupSelectionHost {
void onGroupSelectionChanged(List<RecipientEntry> selected);
}
private final Binding<ContactPickerData> mBinding = BindingBase.createBinding(this);
private final LinkedHashMap<String, RecipientEntry> mSelected = new LinkedHashMap<>();
private HomeContactListAdapter mAdapter;
private GetOrCreateConversationActionMonitor mMonitor;
private HomeEmptyState mEmptyView;
private ContactListItemView mSendToView;
private Cursor mAllContactsCursor;
private String mFilterText;
private boolean mGroupMode;
private java.util.Set<String> mPendingSeed;
@Override
public void onCreate(@Nullable final Bundle savedInstanceState) {
@@ -61,23 +82,82 @@ public class HomeContactsFragment extends Fragment implements ContactPickerDataL
public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
final ListView listView = view.findViewById(R.id.home_contacts_list);
mEmptyView = view.findViewById(R.id.home_contacts_empty);
mSendToView = view.findViewById(R.id.home_contacts_send_to);
mAdapter = new HomeContactListAdapter(requireContext(), null, this);
listView.setAdapter(mAdapter);
if (getParentFragment() instanceof GroupSelectionHost
|| getActivity() instanceof GroupSelectionHost) {
mGroupMode = true;
}
updateResultViews();
}
view.findViewById(R.id.home_contacts_search).setOnClickListener(v ->
Toast.makeText(requireContext(), R.string.home_popover_coming_soon,
Toast.LENGTH_SHORT).show());
view.findViewById(R.id.home_contacts_invite).setOnClickListener(v ->
Toast.makeText(requireContext(), R.string.home_popover_coming_soon,
Toast.LENGTH_SHORT).show());
view.findViewById(R.id.home_contacts_recent_calls).setOnClickListener(v ->
Toast.makeText(requireContext(), R.string.home_popover_coming_soon,
Toast.LENGTH_SHORT).show());
view.findViewById(R.id.home_contacts_sort).setOnClickListener(v ->
Toast.makeText(requireContext(), R.string.home_popover_coming_soon,
Toast.LENGTH_SHORT).show());
view.findViewById(R.id.home_contacts_add).setOnClickListener(v ->
UIIntents.get().launchCreateNewConversationActivity(requireActivity(), null));
/** Switches between opening a conversation on tap and collecting group members. */
public void setGroupMode(final boolean groupMode) {
if (mGroupMode == groupMode) {
return;
}
mGroupMode = groupMode;
if (!groupMode) {
mSelected.clear();
}
notifyGroupSelectionChanged();
if (mAdapter != null) {
mAdapter.notifyDataSetChanged();
}
}
public List<RecipientEntry> getSelectedEntries() {
return new ArrayList<>(mSelected.values());
}
public void seedSelection(final List<String> destinations) {
mPendingSeed = destinations == null
? null : new java.util.LinkedHashSet<>(destinations);
applyPendingSeed();
}
public ArrayList<ParticipantData> getSelectedParticipants() {
final ArrayList<ParticipantData> participants = new ArrayList<>(mSelected.size());
for (final RecipientEntry entry : mSelected.values()) {
participants.add(ParticipantData.getFromRecipientEntry(entry));
}
return participants;
}
/** 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.
*
* @return whether the text was a usable destination
*/
public boolean startConversationWithFilterText() {
final String destination = mFilterText == null ? null : mFilterText.trim();
if (TextUtils.isEmpty(destination)
|| !PhoneUtils.isValidSmsMmsDestination(destination)) {
return false;
}
startConversation(ContactRecipientEntryUtils.constructSendToDestinationEntry(destination));
return true;
}
@Override
@@ -95,7 +175,21 @@ public class HomeContactsFragment extends Fragment implements ContactPickerDataL
@Override
public void onAllContactsCursorUpdated(final Cursor data) {
mBinding.ensureBound();
mAdapter.changeCursor(data);
mAllContactsCursor = data;
if (TextUtils.isEmpty(mFilterText)) {
mAdapter.changeCursor(data);
applyPendingSeed();
updateResultViews();
}
}
@Override
public void onFilteredContactsCursorUpdated(final Cursor data) {
mBinding.ensureBound();
// A null cursor means the search was cleared, so fall back to the full list.
mAdapter.changeCursor(data != null ? data : mAllContactsCursor);
applyPendingSeed();
updateResultViews();
}
@Override
@@ -112,29 +206,90 @@ public class HomeContactsFragment extends Fragment implements ContactPickerDataL
@Override
public void onContactListItemClicked(final ContactListItemData item,
final ContactListItemView view) {
if (!mGroupMode) {
startConversation(item.getRecipientEntry());
return;
}
final RecipientEntry entry = item.getRecipientEntry();
final String key = selectionKey(entry);
if (mSelected.remove(key) == null) {
mSelected.put(key, entry);
}
notifyGroupSelectionChanged();
if (mAdapter != null) {
mAdapter.notifyDataSetChanged();
}
}
private void applyPendingSeed() {
if (mPendingSeed == null || mPendingSeed.isEmpty() || mAdapter == null) {
return;
}
final Cursor cursor = mAdapter.getCursor();
if (cursor == null || cursor.getCount() == 0) {
return;
}
final ContactListItemData item = DataModel.get().createContactListItemData();
final int position = cursor.getPosition();
if (cursor.moveToFirst()) {
do {
item.bind(cursor, null);
final RecipientEntry entry = item.getRecipientEntry();
final String key = selectionKey(entry);
if (mPendingSeed.remove(key)) {
mSelected.put(key, entry);
}
} while (cursor.moveToNext() && !mPendingSeed.isEmpty());
}
cursor.moveToPosition(position);
notifyGroupSelectionChanged();
mAdapter.notifyDataSetChanged();
}
private static String selectionKey(final RecipientEntry entry) {
return PhoneUtils.getDefault().getCanonicalBySystemLocale(entry.getDestination());
}
private void notifyGroupSelectionChanged() {
if (getParentFragment() instanceof GroupSelectionHost) {
((GroupSelectionHost) getParentFragment()).onGroupSelectionChanged(
new ArrayList<>(mSelected.values()));
} else if (getActivity() instanceof GroupSelectionHost) {
((GroupSelectionHost) getActivity()).onGroupSelectionChanged(
new ArrayList<>(mSelected.values()));
}
}
private void startConversation(final RecipientEntry entry) {
if (mMonitor != null) {
return;
}
final ArrayList<ParticipantData> participants = new ArrayList<>(1);
participants.add(ParticipantData.getFromRecipientEntry(item.getRecipientEntry()));
mMonitor = GetOrCreateConversationAction.getOrCreateConversation(participants, null,
this);
participants.add(ParticipantData.getFromRecipientEntry(entry));
mMonitor = GetOrCreateConversationAction.getOrCreateConversation(participants, null, this);
}
@Override
public boolean isContactSelected(final ContactListItemData item) {
return false;
return mGroupMode && mSelected.containsKey(selectionKey(item.getRecipientEntry()));
}
@Override
public boolean isContactSelectionMode() {
return mGroupMode || getParentFragment() instanceof GroupSelectionHost
|| getActivity() instanceof HomeComposeActivity;
}
@Override
@RunsOnMainThread
public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor,
final Object data, final String conversationId) {
public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor, final Object data,
final String conversationId) {
Assert.isTrue(monitor == mMonitor);
mMonitor = null;
if (conversationId != null) {
if (conversationId != null && getActivity() != null) {
UIIntents.get().launchConversationActivity(requireActivity(), conversationId, null,
null, false);
getActivity().finish();
}
}
@@ -145,4 +300,63 @@ public class HomeContactsFragment extends Fragment implements ContactPickerDataL
LogUtil.e(LogUtil.BUGLE_TAG, "HomeContactsFragment onGetOrCreateConversationFailed");
mMonitor = null;
}
/**
* Shows the typed destination as a tappable row whenever the filter matched no contact, so a
* number that is not in the address book can still start a conversation.
*/
private void updateResultViews() {
if (mEmptyView == null || mSendToView == null) {
return;
}
final String destination = mFilterText == null ? null : mFilterText.trim();
final boolean filtering = !TextUtils.isEmpty(destination);
final boolean noMatches = mAdapter == null || mAdapter.getCount() == 0;
final boolean showSendTo = filtering && noMatches
&& PhoneUtils.isValidSmsMmsDestination(destination);
if (showSendTo) {
final RecipientEntry entry =
ContactRecipientEntryUtils.constructSendToDestinationEntry(destination);
mSendToView.bind(entry, getString(R.string.home_compose_send_to,
ContactRecipientEntryUtils.formatDestination(entry)), null, this, true, false);
// The RecipientEntry bind path is meant for dropdowns, which handle clicks themselves.
mSendToView.setOnClickListener(v -> {
if (mGroupMode) {
final String key = selectionKey(entry);
if (mSelected.remove(key) == null) {
mSelected.put(key, entry);
}
notifyGroupSelectionChanged();
if (mAdapter != null) {
mAdapter.notifyDataSetChanged();
}
} else {
startConversation(entry);
}
});
mSendToView.setVisibility(View.VISIBLE);
} else {
mSendToView.setVisibility(View.GONE);
}
if (!noMatches || showSendTo) {
mEmptyView.setVisibility(View.GONE);
return;
}
if (filtering) {
mEmptyView.setContent(R.drawable.ic_home_nav_contacts,
R.string.home_empty_contacts_no_results_title,
R.string.home_empty_contacts_no_results_body);
} else if (ContactUtil.hasReadContactsPermission()) {
mEmptyView.setContent(R.drawable.ic_home_nav_contacts,
R.string.home_empty_contacts_title,
R.string.home_empty_contacts_body);
} else {
mEmptyView.setContent(R.drawable.ic_home_nav_contacts,
R.string.home_empty_contacts_permission_title,
R.string.home_empty_contacts_permission_body);
}
mEmptyView.setVisibility(View.VISIBLE);
}
}
@@ -0,0 +1,231 @@
/*
* Copyright (C) 2025 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.content.Context;
import android.database.Cursor;
import android.os.Bundle;
import android.text.Editable;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.style.BackgroundColorSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.ListView;
import android.widget.TextView;
import androidx.cursoradapter.widget.CursorAdapter;
import androidx.loader.app.LoaderManager;
import com.android.messaging.R;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.binding.Binding;
import com.android.messaging.datamodel.binding.BindingBase;
import com.android.messaging.datamodel.data.ConversationMessageData;
import com.android.messaging.datamodel.data.ConversationSearchData;
import com.android.messaging.datamodel.data.ConversationSearchData.ConversationSearchDataListener;
import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.ContactIconView;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.util.AvatarUriUtil;
import com.android.messaging.util.Dates;
import com.android.messaging.util.ImeUtil;
/** Searches the messages of a single conversation as the user types. */
public class HomeConversationSearchActivity extends BugleActionBarActivity
implements ConversationSearchDataListener {
private final Binding<ConversationSearchData> mBinding = BindingBase.createBinding(this);
private String mConversationId;
private EditText mSearchField;
private ImageButton mClearButton;
private ListView mListView;
private HomeEmptyState mEmptyView;
private SearchResultAdapter mAdapter;
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.home_conversation_search_activity);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
mConversationId = getIntent().getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID);
if (TextUtils.isEmpty(mConversationId)) {
finish();
return;
}
mListView = findViewById(R.id.home_conversation_search_list);
mEmptyView = findViewById(R.id.home_conversation_search_empty);
mAdapter = new SearchResultAdapter(this);
mListView.setAdapter(mAdapter);
mListView.setOnItemClickListener((parent, view, position, id) -> openConversation());
HomeSubHeader.bind(this, R.string.home_search_title, v -> finish());
mClearButton = findViewById(R.id.home_conversation_search_clear);
mClearButton.setOnClickListener(v -> mSearchField.setText(""));
mSearchField = findViewById(R.id.home_conversation_search_field);
mSearchField.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) {
mClearButton.setVisibility(TextUtils.isEmpty(s) ? View.GONE : View.VISIBLE);
mBinding.getData().setSearchText(mBinding, s.toString());
updateEmptyView();
}
});
mSearchField.setOnEditorActionListener((v, actionId, event) -> {
ImeUtil.get().hideImeKeyboard(this, mSearchField);
return true;
});
mBinding.bind(DataModel.get().createConversationSearchData(this, mConversationId, this));
mBinding.getData().init(LoaderManager.getInstance(this));
mSearchField.requestFocus();
mSearchField.post(() -> ImeUtil.get().showImeKeyboard(this, mSearchField));
updateEmptyView();
}
@Override
protected void onDestroy() {
super.onDestroy();
mBinding.unbind();
}
@Override
public void onConversationSearchCursorUpdated(final Cursor cursor) {
mAdapter.swapCursor(cursor);
updateEmptyView();
}
private void updateEmptyView() {
if (mEmptyView == null || mListView == null) {
return;
}
final String searchText = mBinding.isBound() ? mBinding.getData().getSearchText() : null;
if (TextUtils.isEmpty(searchText)) {
mEmptyView.setContent(R.drawable.ic_home_search_line,
R.string.home_empty_conversation_search_idle_title,
R.string.home_empty_conversation_search_idle_body);
mEmptyView.setVisibility(View.VISIBLE);
mListView.setVisibility(View.GONE);
return;
}
final boolean empty = mAdapter.getCount() == 0;
if (empty) {
mEmptyView.setContent(R.drawable.ic_home_search_line,
getString(R.string.home_empty_conversation_search_title),
getString(R.string.home_empty_conversation_search_body, searchText));
}
mEmptyView.setVisibility(empty ? View.VISIBLE : View.GONE);
mListView.setVisibility(empty ? View.GONE : View.VISIBLE);
}
private void openConversation() {
// Bugle cannot open a conversation anchored on a single message, so land on the latest.
UIIntents.get().launchConversationActivity(this, mConversationId, null, null, false);
}
@Override
protected void onPause() {
if (mSearchField != null) {
ImeUtil.get().hideImeKeyboard(this, mSearchField);
}
super.onPause();
}
@Override
public void finish() {
if (mSearchField != null) {
ImeUtil.get().hideImeKeyboard(this, mSearchField);
}
super.finish();
overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
}
private class SearchResultAdapter extends CursorAdapter {
SearchResultAdapter(final Context context) {
super(context, null, 0);
}
@Override
public View newView(final Context context, final Cursor cursor, final ViewGroup parent) {
return LayoutInflater.from(context)
.inflate(R.layout.home_conversation_search_item, parent, false);
}
@Override
public void bindView(final View view, final Context context, final Cursor cursor) {
final ConversationMessageData message = new ConversationMessageData();
message.bind(cursor);
final ContactIconView avatar =
view.findViewById(R.id.home_conversation_search_avatar);
avatar.setImageResourceUri(AvatarUriUtil.createAvatarUri(
message.getSenderProfilePhotoUri(), message.getSenderFullName(),
message.getSenderNormalizedDestination(),
message.getSenderContactLookupKey()),
message.getSenderContactId(), message.getSenderContactLookupKey(),
message.getSenderNormalizedDestination());
avatar.setImageClickHandlerDisabled(true);
((TextView) view.findViewById(R.id.home_conversation_search_sender)).setText(
message.getIsIncoming() ? message.getSenderDisplayName()
: getString(R.string.home_conversation_search_you));
((TextView) view.findViewById(R.id.home_conversation_search_time)).setText(
Dates.getConversationTimeString(message.getReceivedTimeStamp()));
((TextView) view.findViewById(R.id.home_conversation_search_text))
.setText(highlight(message.getText()));
}
private CharSequence highlight(final String text) {
final String searchText = mBinding.getData().getSearchText();
if (TextUtils.isEmpty(text) || TextUtils.isEmpty(searchText)) {
return text;
}
final int start = text.toLowerCase().indexOf(searchText.toLowerCase());
if (start < 0) {
return text;
}
final SpannableString spannable = new SpannableString(text);
spannable.setSpan(new BackgroundColorSpan(getColor(R.color.home_accent_soft)),
start, start + searchText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannable;
}
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (C) 2025 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.ui.home;
import android.content.Context;
import androidx.annotation.NonNull;
import com.android.messaging.R;
/** Home modal for moving conversations to Trash from the conversation list. */
public final class HomeDeleteAlertDialog {
public interface Listener {
void onDelete();
}
private HomeDeleteAlertDialog() {
}
public static void show(@NonNull final Context context, final int conversationCount,
@NonNull final Listener listener) {
final String title = context.getResources().getQuantityString(
R.plurals.delete_conversations_confirmation_dialog_title, conversationCount);
final String message = conversationCount == 1
? context.getString(R.string.home_delete_alert_message_one)
: context.getString(R.string.home_delete_alert_message_many, conversationCount);
HomeModal.builder(context)
.icon(R.drawable.ic_home_menu_trash)
.title(title)
.message(message)
.positive(R.string.home_delete_confirm, listener::onDelete)
.negative(R.string.delete_conversation_decline_button, null)
.show();
}
}

Some files were not shown because too many files have changed in this diff Show More