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/ .cursor/
.gradle/ .gradle/
/local.properties /local.properties
/keystore.properties
/keystore/
*.jks
*.keystore
/.idea/ /.idea/
.DS_Store .DS_Store
/build/ /build/
@@ -11,6 +15,7 @@
app/build/ app/build/
modules/*/build/ modules/*/build/
modules/*/.git/ modules/*/.git/
modules/*/sample/
*.apk *.apk
*.ap_ *.ap_
*.dex *.dex
+5 -3
View File
@@ -87,11 +87,13 @@ android_app {
"1.0.001", "1.0.001",
"--version-code", "--version-code",
"10001040", "10001040",
"--rename-manifest-package",
"com.bnkode.notie",
], ],
required: [ required: [
"libframesequence", "libframesequence",
"libgiftranscode", "libgiftranscode",
"com.android.messaging.allowlist", "com.bnkode.notie.allowlist",
], ],
optimize: { optimize: {
obfuscate: true, obfuscate: true,
@@ -105,9 +107,9 @@ android_app {
} }
prebuilt_etc { prebuilt_etc {
name: "com.android.messaging.allowlist", name: "com.bnkode.notie.allowlist",
product_specific: true, product_specific: true,
sub_dir: "sysconfig", sub_dir: "sysconfig",
src: "com.android.messaging.allowlist.xml", src: "com.bnkode.notie.allowlist.xml",
filename_from_src: true, filename_from_src: true,
} }
+23 -2
View File
@@ -2,17 +2,24 @@ plugins {
id 'com.android.application' id 'com.android.application'
} }
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('keystore.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android { android {
namespace 'com.android.messaging' namespace 'com.android.messaging'
compileSdk 35 compileSdk 35
defaultConfig { defaultConfig {
applicationId 'com.android.messaging' applicationId 'com.bnkode.notie'
minSdk 33 minSdk 33
targetSdk 34 targetSdk 34
versionCode 10001040 versionCode 10001040
versionName '1.0.001' versionName '1.0.001'
vectorDrawables.useSupportLibrary true vectorDrawables.useSupportLibrary true
resourceConfigurations += ['en', 'ko']
} }
compileOptions { compileOptions {
@@ -20,9 +27,22 @@ android {
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
signingConfigs {
release {
if (keystorePropertiesFile.exists()) {
storeFile rootProject.file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
}
buildTypes { buildTypes {
release { release {
minifyEnabled true minifyEnabled true
shrinkResources true
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
rootProject.file('proguard-release.flags'), rootProject.file('proguard-release.flags'),
rootProject.file('proguard.flags') rootProject.file('proguard.flags')
@@ -59,6 +79,7 @@ dependencies {
implementation 'androidx.core:core:1.15.0' implementation 'androidx.core:core:1.15.0'
implementation 'androidx.exifinterface:exifinterface:1.3.7' implementation 'androidx.exifinterface:exifinterface:1.3.7'
implementation 'androidx.fragment:fragment:1.8.5' implementation 'androidx.fragment:fragment:1.8.5'
implementation 'androidx.lifecycle:lifecycle-process:2.8.7'
implementation 'androidx.media:media:1.7.0' implementation 'androidx.media:media:1.7.0'
implementation 'androidx.palette:palette:1.0.0' implementation 'androidx.palette:palette:1.0.0'
implementation 'androidx.preference:preference:1.2.1' implementation 'androidx.preference:preference:1.2.1'
@@ -85,5 +106,5 @@ tasks.register('launchMessaging', Exec) {
description = "Launch Messaging on ${messagingAvdSerial}" description = "Launch Messaging on ${messagingAvdSerial}"
dependsOn 'installMessaging' dependsOn 'installMessaging'
commandLine adbExecutable.absolutePath, '-s', messagingAvdSerial, 'shell', 'am', 'start', 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.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <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 --> <!-- Optional features -->
<uses-feature <uses-feature
@@ -61,9 +67,15 @@
<uses-feature <uses-feature
android:name="android.hardware.screen.portrait" android:name="android.hardware.screen.portrait"
android:required="false" /> android:required="false" />
<uses-feature <uses-feature
android:name="android.hardware.telephony" android:name="android.hardware.telephony"
android:required="false" /> 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> <queries>
<intent> <intent>
@@ -79,7 +91,9 @@
android:allowBackup="false" android:allowBackup="false"
android:appCategory="social" android:appCategory="social"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/BugleTheme"> android:theme="@style/BugleTheme">
@@ -132,10 +146,18 @@
<activity <activity
android:name=".ui.conversationlist.ArchivedConversationListActivity" android:name=".ui.conversationlist.ArchivedConversationListActivity"
android:configChanges="orientation|screenSize|keyboardHidden" 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:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:screenOrientation="user" 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 --> <!-- Displays the contents of a single conversation -->
<activity <activity
@@ -155,6 +177,81 @@
android:screenOrientation="user" android:screenOrientation="user"
android:theme="@style/BugleTheme" /> 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 --> <!-- Full-screen photo viewer -->
<activity <activity
android:name=".ui.photoviewer.BuglePhotoViewActivity" android:name=".ui.photoviewer.BuglePhotoViewActivity"
@@ -171,7 +268,7 @@
android:label="@string/settings_activity_title" android:label="@string/settings_activity_title"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity" android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:screenOrientation="user" android:screenOrientation="user"
android:theme="@style/BugleTheme.SettingsActivity" /> android:theme="@style/Home.SubScreen" />
<activity <activity
android:name=".ui.appsettings.PerSubscriptionSettingsActivity" android:name=".ui.appsettings.PerSubscriptionSettingsActivity"
@@ -179,7 +276,7 @@
android:label="@string/advanced_settings_activity_title" android:label="@string/advanced_settings_activity_title"
android:parentActivityName="com.android.messaging.ui.appsettings.SettingsActivity" android:parentActivityName="com.android.messaging.ui.appsettings.SettingsActivity"
android:screenOrientation="user" android:screenOrientation="user"
android:theme="@style/BugleTheme.SettingsActivity" /> android:theme="@style/Home.SubScreen" />
<activity <activity
android:name=".ui.appsettings.ApplicationSettingsActivity" android:name=".ui.appsettings.ApplicationSettingsActivity"
@@ -188,7 +285,7 @@
android:label="@string/general_settings_activity_title" android:label="@string/general_settings_activity_title"
android:parentActivityName="com.android.messaging.ui.appsettings.SettingsActivity" android:parentActivityName="com.android.messaging.ui.appsettings.SettingsActivity"
android:screenOrientation="user" android:screenOrientation="user"
android:theme="@style/BugleTheme.SettingsActivity"> android:theme="@style/Home.SubScreen">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -223,22 +320,40 @@
</intent-filter> </intent-filter>
</activity> </activity>
<!-- People & Options --> <!-- Conversation profile -->
<activity <activity
android:name=".ui.conversationsettings.PeopleAndOptionsActivity" android:name=".ui.home.HomeProfileActivity"
android:configChanges="orientation|screenSize|keyboardHidden" 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:parentActivityName="com.android.messaging.ui.conversation.ConversationActivity"
android:screenOrientation="user" 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 --> <!-- License -->
<activity <activity
android:name=".ui.LicenseActivity" android:name=".ui.LicenseActivity"
android:exported="true" android:exported="true"
android:label="@string/menu_license" android:label="@string/home_settings_licenses"
android:theme="@android:style/Theme.Holo.Light.Dialog"> android:theme="@style/Home.SubScreen" />
</activity>
<!-- Message Forwarding --> <!-- Message Forwarding -->
<activity <activity
@@ -259,7 +374,8 @@
android:configChanges="orientation|screenSize|keyboardHidden" android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="false" android:exported="false"
android:screenOrientation="user" android:screenOrientation="user"
android:theme="@style/Invisible"> android:theme="@style/Home.TransparentSheetHost"
android:windowSoftInputMode="adjustResize">
</activity> </activity>
<!-- VCard details --> <!-- VCard details -->
@@ -282,20 +398,20 @@
<provider <provider
android:name=".datamodel.MessagingContentProvider" android:name=".datamodel.MessagingContentProvider"
android:authorities="com.android.messaging.datamodel.MessagingContentProvider" android:authorities="com.bnkode.notie.datamodel.MessagingContentProvider"
android:exported="false" android:exported="false"
android:label="@string/app_name"> android:label="@string/app_name">
</provider> </provider>
<provider <provider
android:name=".datamodel.MmsFileProvider" android:name=".datamodel.MmsFileProvider"
android:authorities="com.android.messaging.datamodel.MmsFileProvider" android:authorities="com.bnkode.notie.datamodel.MmsFileProvider"
android:exported="false" android:exported="false"
android:grantUriPermissions="true" /> android:grantUriPermissions="true" />
<provider <provider
android:name=".datamodel.MediaScratchFileProvider" android:name=".datamodel.MediaScratchFileProvider"
android:authorities="com.android.messaging.datamodel.MediaScratchFileProvider" android:authorities="com.bnkode.notie.datamodel.MediaScratchFileProvider"
android:exported="false" android:exported="false"
android:grantUriPermissions="true" /> android:grantUriPermissions="true" />
@@ -351,18 +467,18 @@
android:name=".receiver.SendStatusReceiver" android:name=".receiver.SendStatusReceiver"
android:exported="false"> android:exported="false">
<intent-filter> <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" /> <data android:scheme="content" />
</intent-filter> </intent-filter>
<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>
<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" /> <data android:scheme="content" />
</intent-filter> </intent-filter>
<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" /> <data android:scheme="content" />
</intent-filter> </intent-filter>
</receiver> </receiver>
@@ -401,6 +517,14 @@
</intent-filter> </intent-filter>
</receiver> </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 --> <!-- Broadcast receiver that will be notified to reset notifications -->
<receiver <receiver
android:name=".receiver.NotificationReceiver" android:name=".receiver.NotificationReceiver"
@@ -412,7 +536,7 @@
android:name=".datamodel.action.ActionServiceImpl$PendingActionReceiver" android:name=".datamodel.action.ActionServiceImpl$PendingActionReceiver"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="com.android.messaging.datamodel.PENDING_ACTION" /> <action android:name="com.bnkode.notie.datamodel.PENDING_ACTION" />
</intent-filter> </intent-filter>
</receiver> </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.BugleUserAgentInfoLoader;
import com.android.messaging.sms.MmsConfig; import com.android.messaging.sms.MmsConfig;
import com.android.messaging.ui.ConversationDrawables; 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.BuglePrefsKeys;
import com.android.messaging.util.LogUtil; import com.android.messaging.util.LogUtil;
import com.android.messaging.util.PhoneUtils; import com.android.messaging.util.PhoneUtils;
@@ -57,6 +59,8 @@ public class BugleApplication extends Application implements UncaughtExceptionHa
super.onCreate(); super.onCreate();
FactoryImpl.register(getApplicationContext(), this); FactoryImpl.register(getApplicationContext(), this);
HomeAppLock.install(this);
HomeToast.install(this);
sSystemUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler(); sSystemUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler(this); Thread.setDefaultUncaughtExceptionHandler(this);
@@ -124,6 +128,18 @@ public class BugleApplication extends Application implements UncaughtExceptionHa
Trace.endSection(); 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 @Override
public void onLowMemory() { public void onLowMemory() {
super.onLowMemory(); super.onLowMemory();
@@ -131,7 +147,14 @@ public class BugleApplication extends Application implements UncaughtExceptionHa
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "BugleApplication.onLowMemory"); LogUtil.d(TAG, "BugleApplication.onLowMemory");
} }
Factory.get().reclaimMemory(); reclaimCachedBitmaps();
}
private static void reclaimCachedBitmaps() {
final Factory factory = Factory.get();
if (factory != null) {
factory.reclaimMemory();
}
} }
@Override @Override
@@ -533,6 +533,7 @@ public class BugleDatabaseOperations {
// the sender is blocked, or we have been told to keep it archived. // the sender is blocked, or we have been told to keep it archived.
if (!keepArchived) { if (!keepArchived) {
values.put(ConversationColumns.ARCHIVE_STATUS, 0); values.put(ConversationColumns.ARCHIVE_STATUS, 0);
values.put(ConversationColumns.TRASH_TIMESTAMP, 0);
} }
final MessageData message = readMessage(dbWrapper, messageId); final MessageData message = readMessage(dbWrapper, messageId);
@@ -575,6 +576,20 @@ public class BugleDatabaseOperations {
updateConversationRowIfExists(dbWrapper, conversationId, values); 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, static void addSnippetTextAndPreviewToContentValues(final MessageData message,
final boolean showDraft, final ContentValues values) { final boolean showDraft, final ContentValues values) {
values.put(ConversationColumns.SHOW_DRAFT, showDraft ? 1 : 0); values.put(ConversationColumns.SHOW_DRAFT, showDraft ? 1 : 0);
@@ -685,7 +700,16 @@ public class BugleDatabaseOperations {
sortTimestamp = cursor.getLong(1); 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(); final ContentValues values = new ContentValues();
if (draftMessage == null || !draftMessage.hasContent()) { 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.MmsSmsUtils;
import com.android.messaging.sms.MmsUtils; import com.android.messaging.sms.MmsUtils;
import com.android.messaging.ui.UIIntents; import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.home.MutedConversationStore;
import com.android.messaging.util.Assert; import com.android.messaging.util.Assert;
import com.android.messaging.util.AvatarUriUtil; import com.android.messaging.util.AvatarUriUtil;
import com.android.messaging.util.BugleGservicesKeys; import com.android.messaging.util.BugleGservicesKeys;
@@ -354,6 +355,9 @@ public class BugleNotifications {
final Context context = Factory.get().getApplicationContext(); final Context context = Factory.get().getApplicationContext();
// TODO: Need to fix this for multi conversation notifications to rate limit dings. // TODO: Need to fix this for multi conversation notifications to rate limit dings.
final String conversationId = state.mConversationIds.first(); final String conversationId = state.mConversationIds.first();
if (MutedConversationStore.isMuted(context, conversationId)) {
return;
}
String id = NotificationsUtil.DEFAULT_CHANNEL_ID; String id = NotificationsUtil.DEFAULT_CHANNEL_ID;
if (NotificationsUtil.getNotificationChannel(context, conversationId) != null) { if (NotificationsUtil.getNotificationChannel(context, conversationId) != null) {
id = conversationId; id = conversationId;
@@ -535,7 +539,10 @@ public class BugleNotifications {
private static void createMessageNotification(final boolean silent, private static void createMessageNotification(final boolean silent,
final String conversationId) { final String conversationId) {
final NotificationState state = MessageNotificationState.getNotificationState(); 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) { if (state == null) {
cancel(PendingIntentConstants.SMS_NOTIFICATION_ID); cancel(PendingIntentConstants.SMS_NOTIFICATION_ID);
if (softSound && !TextUtils.isEmpty(conversationId)) { if (softSound && !TextUtils.isEmpty(conversationId)) {
@@ -718,7 +725,9 @@ public class BugleNotifications {
setWearableGroupOptions(notifBuilder, notificationState); setWearableGroupOptions(notifBuilder, notificationState);
if (notificationState instanceof MultiMessageNotificationState) { 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 // 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 // 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 // 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 int requestCode = multiMessageNotificationState.getReplyIntentRequestCode();
final PendingIntent replyPendingIntent = UIIntents.get() final PendingIntent replyPendingIntent = UIIntents.get()
.getPendingIntentForSendingMessageToConversation(context, .getPendingIntentForSendingMessageToConversation(context,
conversationId, selfId, requiresMms, requestCode); conversationId, selfId, requiresMms,
multiMessageNotificationState.mTitle, requestCode);
final int replyLabelRes = requiresMms ? R.string.notification_reply_via_mms : // Phone: open the Home bottom-sheet composer. Wear keeps RemoteInput.
R.string.notification_reply_via_sms; final NotificationCompat.Action replyAction =
final NotificationCompat.Action.Builder actionBuilder =
new NotificationCompat.Action.Builder(R.drawable.ic_wear_reply, new NotificationCompat.Action.Builder(R.drawable.ic_wear_reply,
context.getString(replyLabelRes), replyPendingIntent); context.getString(R.string.home_notification_reply), replyPendingIntent)
final RemoteInput.Builder remoteInputBuilder = new RemoteInput.Builder(Intent.EXTRA_TEXT); .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
remoteInputBuilder.setLabel(context.getString(R.string.notification_reply_prompt)); .setShowsUserInterface(true)
actionBuilder.addRemoteInput(remoteInputBuilder.build()); .build();
notifBuilder.addAction(actionBuilder.build()); notifBuilder.addAction(replyAction);
// Support the action on a wearable device // 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( final String[] choices = context.getResources().getStringArray(
R.array.notification_reply_choices); R.array.notification_reply_choices);
remoteInputBuilder.setChoices(choices); final RemoteInput wearRemoteInput = new RemoteInput.Builder(Intent.EXTRA_TEXT)
wearActionBuilder.addRemoteInput(remoteInputBuilder.build()); .setLabel(context.getString(R.string.home_notification_reply_hint))
wearableExtender.addAction(wearActionBuilder.build()); .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, 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.action.BackgroundWorker;
import com.android.messaging.datamodel.data.BlockedParticipantsData; import com.android.messaging.datamodel.data.BlockedParticipantsData;
import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener; 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.ContactListItemData;
import com.android.messaging.datamodel.data.ContactPickerData; import com.android.messaging.datamodel.data.ContactPickerData;
import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener; 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.MessagePartData;
import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.datamodel.data.ParticipantListItemData; import com.android.messaging.datamodel.data.ParticipantListItemData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData; import com.android.messaging.datamodel.data.ConversationProfileData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener; import com.android.messaging.datamodel.data.ConversationProfileData.ConversationProfileDataListener;
import com.android.messaging.datamodel.data.PeopleOptionsItemData;
import com.android.messaging.datamodel.data.SettingsData; import com.android.messaging.datamodel.data.SettingsData;
import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener; import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener;
import com.android.messaging.datamodel.data.SubscriptionListData; import com.android.messaging.datamodel.data.SubscriptionListData;
@@ -67,8 +68,14 @@ public abstract class DataModel {
get().getActionService().scheduleAction(action, code, delayMs); 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, 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, public abstract ConversationData createConversationData(final Context context,
final ConversationDataListener listener, final String conversationId); final ConversationDataListener listener, final String conversationId);
@@ -83,10 +90,9 @@ public abstract class DataModel {
public abstract LaunchConversationData createLaunchConversationData( public abstract LaunchConversationData createLaunchConversationData(
LaunchConversationDataListener listener); LaunchConversationDataListener listener);
public abstract PeopleOptionsItemData createPeopleOptionsItemData(final Context context); public abstract ConversationProfileData createConversationProfileData(
final String conversationId, final Context context,
public abstract PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId, final ConversationProfileDataListener listener);
final Context context, final PeopleAndOptionsDataListener listener);
public abstract VCardContactItemData createVCardContactItemData(final Context context, public abstract VCardContactItemData createVCardContactItemData(final Context context,
final MessagePartData data); final MessagePartData data);
@@ -100,6 +106,9 @@ public abstract class DataModel {
public abstract BlockedParticipantsData createBlockedParticipantsData(Context context, public abstract BlockedParticipantsData createBlockedParticipantsData(Context context,
BlockedParticipantsDataListener listener); BlockedParticipantsDataListener listener);
public abstract ConversationSearchData createConversationSearchData(Context context,
String conversationId, ConversationSearchDataListener listener);
public abstract SubscriptionListData createSubscriptonListData(Context context); public abstract SubscriptionListData createSubscriptonListData(Context context);
public abstract SettingsData createSettingsData(Context context, SettingsDataListener listener); 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.BackgroundWorker;
import com.android.messaging.datamodel.action.FixupMessageStatusOnStartupAction; import com.android.messaging.datamodel.action.FixupMessageStatusOnStartupAction;
import com.android.messaging.datamodel.action.ProcessPendingMessagesAction; 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;
import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener; 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.ContactListItemData;
import com.android.messaging.datamodel.data.ContactPickerData; import com.android.messaging.datamodel.data.ContactPickerData;
import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener; 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.MessagePartData;
import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.datamodel.data.ParticipantListItemData; import com.android.messaging.datamodel.data.ParticipantListItemData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData; import com.android.messaging.datamodel.data.ConversationProfileData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener; import com.android.messaging.datamodel.data.ConversationProfileData.ConversationProfileDataListener;
import com.android.messaging.datamodel.data.PeopleOptionsItemData;
import com.android.messaging.datamodel.data.SettingsData; import com.android.messaging.datamodel.data.SettingsData;
import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener; import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener;
import com.android.messaging.datamodel.data.SubscriptionListData; import com.android.messaging.datamodel.data.SubscriptionListData;
@@ -80,8 +82,9 @@ public class DataModelImpl extends DataModel {
@Override @Override
public ConversationListData createConversationListData(final Context context, public ConversationListData createConversationListData(final Context context,
final ConversationListDataListener listener, final boolean archivedMode) { final ConversationListDataListener listener, final boolean archivedMode,
return new ConversationListData(context, listener, archivedMode); final boolean trashMode) {
return new ConversationListData(context, listener, archivedMode, trashMode);
} }
@Override @Override
@@ -107,6 +110,12 @@ public class DataModelImpl extends DataModel {
return new BlockedParticipantsData(context, listener); 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 @Override
public MediaPickerData createMediaPickerData(final Context context) { public MediaPickerData createMediaPickerData(final Context context) {
return new MediaPickerData(context); return new MediaPickerData(context);
@@ -119,14 +128,9 @@ public class DataModelImpl extends DataModel {
} }
@Override @Override
public PeopleOptionsItemData createPeopleOptionsItemData(final Context context) { public ConversationProfileData createConversationProfileData(final String conversationId,
return new PeopleOptionsItemData(context); final Context context, final ConversationProfileDataListener listener) {
} return new ConversationProfileData(conversationId, context, listener);
@Override
public PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId,
final Context context, final PeopleAndOptionsDataListener listener) {
return new PeopleAndOptionsData(conversationId, context, listener);
} }
@Override @Override
@@ -209,6 +213,7 @@ public class DataModelImpl extends DataModel {
createConnectivityUtilForEachActiveSubscription(); createConnectivityUtilForEachActiveSubscription();
FixupMessageStatusOnStartupAction.fixupMessageStatus(); FixupMessageStatusOnStartupAction.fixupMessageStatus();
PurgeExpiredTrashAction.purgeExpired();
ProcessPendingMessagesAction.processFirstPendingMessage(); ProcessPendingMessagesAction.processFirstPendingMessage();
SyncManager.immediateSync(); SyncManager.immediateSync();
@@ -101,6 +101,9 @@ public class DatabaseHelper extends SQLiteOpenHelper {
/* If this conversation is archived */ /* If this conversation is archived */
public static final String ARCHIVE_STATUS = "archive_status"; 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 */ /* Timestamp for sorting purposes */
public static final String SORT_TIMESTAMP = "sort_timestamp"; 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_URI + " TEXT, "
+ ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE + " TEXT, " + ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE + " TEXT, "
+ ConversationColumns.ARCHIVE_STATUS + " INT DEFAULT(0), " + ConversationColumns.ARCHIVE_STATUS + " INT DEFAULT(0), "
+ ConversationColumns.TRASH_TIMESTAMP + " INT DEFAULT(0), "
+ ConversationColumns.SORT_TIMESTAMP + " INT DEFAULT(0), " + ConversationColumns.SORT_TIMESTAMP + " INT DEFAULT(0), "
+ ConversationColumns.LAST_READ_TIMESTAMP + " INT DEFAULT(0), " + ConversationColumns.LAST_READ_TIMESTAMP + " INT DEFAULT(0), "
+ ConversationColumns.ICON + " TEXT, " + ConversationColumns.ICON + " TEXT, "
@@ -183,6 +187,11 @@ public class DatabaseHelper extends SQLiteOpenHelper {
+ " ON " + CONVERSATIONS_TABLE + " ON " + CONVERSATIONS_TABLE
+ "(" + ConversationColumns.ARCHIVE_STATUS + ")"; + "(" + 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 = private static final String CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL =
"CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.SORT_TIMESTAMP "CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.SORT_TIMESTAMP
+ " ON " + CONVERSATIONS_TABLE + " ON " + CONVERSATIONS_TABLE
@@ -525,6 +534,7 @@ public class DatabaseHelper extends SQLiteOpenHelper {
private static final String[] CREATE_INDEX_SQLS = new String[] { private static final String[] CREATE_INDEX_SQLS = new String[] {
CONVERSATIONS_TABLE_SMS_THREAD_ID_INDEX_SQL, CONVERSATIONS_TABLE_SMS_THREAD_ID_INDEX_SQL,
CONVERSATIONS_TABLE_ARCHIVE_STATUS_INDEX_SQL, CONVERSATIONS_TABLE_ARCHIVE_STATUS_INDEX_SQL,
CONVERSATIONS_TABLE_TRASH_TIMESTAMP_INDEX_SQL,
CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL, CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL,
MESSAGES_TABLE_SORT_INDEX_SQL, MESSAGES_TABLE_SORT_INDEX_SQL,
MESSAGES_TABLE_STATUS_SEEN_INDEX_SQL, MESSAGES_TABLE_STATUS_SEEN_INDEX_SQL,
@@ -48,6 +48,9 @@ public class DatabaseUpgradeHelper {
if (currentVersion < 2) { if (currentVersion < 2) {
currentVersion = upgradeToVersion2(db); currentVersion = upgradeToVersion2(db);
} }
if (currentVersion < 3) {
currentVersion = upgradeToVersion3(db);
}
// Rebuild all the views // Rebuild all the views
final Context context = Factory.get().getApplicationContext(); final Context context = Factory.get().getApplicationContext();
DatabaseHelper.dropAllViews(db); DatabaseHelper.dropAllViews(db);
@@ -63,6 +66,17 @@ public class DatabaseUpgradeHelper {
return 2; 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 * 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 * 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(); final String fileId = uri.getPath();
if (isValidFileId(fileId)) { if (isValidFileId(fileId)) {
final File file = getFile(fileId, getExtensionFromUri(uri)); final File file = getFile(fileId, getExtensionFromUri(uri));
return file.delete() ? 1 : 0; return file != null && file.delete() ? 1 : 0;
} }
return 0; return 0;
} }
@@ -114,6 +114,9 @@ public abstract class FileProvider extends ContentProvider {
final String fileId = uri.getPath(); final String fileId = uri.getPath();
if (isValidFileId(fileId)) { if (isValidFileId(fileId)) {
final File file = getFile(fileId, getExtensionFromUri(uri)); final File file = getFile(fileId, getExtensionFromUri(uri));
if (file == null) {
throw new FileNotFoundException(String.valueOf(uri));
}
final int mode = final int mode =
(TextUtils.equals(fileMode, "r") ? ParcelFileDescriptor.MODE_READ_ONLY : (TextUtils.equals(fileMode, "r") ? ParcelFileDescriptor.MODE_READ_ONLY :
ParcelFileDescriptor.MODE_WRITE_ONLY | ParcelFileDescriptor.MODE_TRUNCATE); ParcelFileDescriptor.MODE_WRITE_ONLY | ParcelFileDescriptor.MODE_TRUNCATE);
@@ -120,6 +120,8 @@ public class FrequentContactsCursorBuilder {
mAllContactsCursor.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE); mAllContactsCursor.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE);
row[ContactUtil.INDEX_PHONE_EMAIL_LABEL] = row[ContactUtil.INDEX_PHONE_EMAIL_LABEL] =
mAllContactsCursor.getString(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; boolean numberAlreadyAdded = false;
for (Object[] oldRow : rows) { for (Object[] oldRow : rows) {
@@ -46,7 +46,7 @@ public class MediaScratchFileProvider extends FileProvider {
private static final SimpleArrayMap<Uri, String> sUriToDisplayNameMap = new SimpleArrayMap<>(); private static final SimpleArrayMap<Uri, String> sUriToDisplayNameMap = new SimpleArrayMap<>();
public static final String AUTHORITY = public static final String AUTHORITY =
"com.android.messaging.datamodel.MediaScratchFileProvider"; "com.bnkode.notie.datamodel.MediaScratchFileProvider";
private static final String MEDIA_SCRATCH_SPACE_DIR = "mediascratchspace"; private static final String MEDIA_SCRATCH_SPACE_DIR = "mediascratchspace";
public static boolean isMediaScratchSpaceUri(final Uri uri) { public static boolean isMediaScratchSpaceUri(final Uri uri) {
@@ -68,8 +68,8 @@ public class MediaScratchFileProvider extends FileProvider {
public static Uri buildMediaScratchSpaceUri(final String extension) { public static Uri buildMediaScratchSpaceUri(final String extension) {
final Uri uri = FileProvider.buildFileUri(AUTHORITY, extension); final Uri uri = FileProvider.buildFileUri(AUTHORITY, extension);
final File file = getFileWithExtension(uri.getPath(), extension); final File file = getFileWithExtension(uri.getPath(), extension);
if (!ensureFileExists(file)) { if (file == null || !ensureFileExists(file)) {
LogUtil.e(TAG, "Failed to create temp file " + file.getAbsolutePath()); LogUtil.e(TAG, "Failed to create temp file for " + uri);
} }
return uri; return uri;
} }
@@ -90,8 +90,12 @@ public class MediaScratchFileProvider extends FileProvider {
private static File getFileWithExtension(final String path, final String extension) { private static File getFileWithExtension(final String path, final String extension) {
final Context context = Factory.get().getApplicationContext(); 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), final File filePath = new File(getDirectory(context),
TextUtils.isEmpty(extension) ? path : path + "." + extension); TextUtils.isEmpty(extension) ? name : name + "." + extension);
try { try {
if (!filePath.getCanonicalPath() if (!filePath.getCanonicalPath()
@@ -39,6 +39,7 @@ import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationCompat.Builder; import androidx.core.app.NotificationCompat.Builder;
import androidx.core.app.NotificationCompat.WearableExtender; import androidx.core.app.NotificationCompat.WearableExtender;
import androidx.core.app.NotificationManagerCompat; import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.Person;
import com.android.messaging.Factory; import com.android.messaging.Factory;
import com.android.messaging.R; 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.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils; import com.android.messaging.sms.MmsUtils;
import com.android.messaging.ui.UIIntents; import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.home.MutedConversationStore;
import com.android.messaging.util.Assert; import com.android.messaging.util.Assert;
import com.android.messaging.util.AvatarUriUtil; import com.android.messaging.util.AvatarUriUtil;
import com.android.messaging.util.BugleGservicesKeys; import com.android.messaging.util.BugleGservicesKeys;
@@ -494,85 +496,62 @@ public abstract class MessageNotificationState extends NotificationState {
@Override @Override
protected NotificationCompat.Style build(final Builder builder) { protected NotificationCompat.Style build(final Builder builder) {
builder.setContentTitle(mTitle) 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 ConversationLineInfo convInfo = mConvList.mConvInfos.get(0);
final List<NotificationLineInfo> lineInfos = convInfo.mLineInfos; final List<NotificationLineInfo> lineInfos = convInfo.mLineInfos;
final int messageCount = lineInfos.size(); final Person user = new Person.Builder()
// At this point, all the messages come from the same conversation. We need to load .setName(context.getString(R.string.home_notification_you))
// the sender's avatar and then finish building the notification on a callback. .build();
final NotificationCompat.MessagingStyle style =
builder.setContentText(mContent); // for collapsed state new NotificationCompat.MessagingStyle(user);
if (convInfo.mIsGroup) {
if (messageCount == 1) { style.setGroupConversation(true);
final boolean shouldShowImage = ContentType.isImageType(mAttachmentType) if (!TextUtils.isEmpty(convInfo.mGroupConversationName)) {
|| (ContentType.isVideoType(mAttachmentType)); style.setConversationTitle(convInfo.mGroupConversationName);
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);
} }
} 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); 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 messageText = convMessageData.getText();
final String convId = convMessageData.getConversationId(); final String convId = convMessageData.getConversationId();
if (MutedConversationStore.isMuted(context, convId)) {
continue;
}
final String messageId = convMessageData.getMessageId(); final String messageId = convMessageData.getMessageId();
CharSequence text = messageText; CharSequence text = messageText;
@@ -840,6 +822,9 @@ public abstract class MessageNotificationState extends NotificationState {
if (currConvInfo == null) { if (currConvInfo == null) {
final ConversationListItemData convData = final ConversationListItemData convData =
ConversationListItemData.getExistingConversation(db, convId); ConversationListItemData.getExistingConversation(db, convId);
if (convData == null || convData.getIsTrashed()) {
continue;
}
final int subId = BugleDatabaseOperations.getSelfSubscriptionId(db, final int subId = BugleDatabaseOperations.getSelfSubscriptionId(db,
convData.getSelfId()); convData.getSelfId());
groupConversationName = convData.getName(); groupConversationName = convData.getName();
@@ -53,7 +53,7 @@ public class MessagingContentProvider extends ContentProvider {
private static final String TAG = LogUtil.BUGLE_TAG; private static final String TAG = LogUtil.BUGLE_TAG;
public static final String AUTHORITY = public static final String AUTHORITY =
"com.android.messaging.datamodel.MessagingContentProvider"; "com.bnkode.notie.datamodel.MessagingContentProvider";
private static final String CONTENT_AUTHORITY = "content://" + AUTHORITY + '/'; private static final String CONTENT_AUTHORITY = "content://" + AUTHORITY + '/';
// Conversations query // Conversations query
@@ -70,6 +70,11 @@ public class MessagingContentProvider extends ContentProvider {
public static final Uri CONVERSATION_MESSAGES_URI = Uri.parse(CONTENT_AUTHORITY + public static final Uri CONVERSATION_MESSAGES_URI = Uri.parse(CONTENT_AUTHORITY +
MESSAGES_QUERY + "/conversation"); 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 // Conversation participants query
private static final String PARTICIPANTS_QUERY = "participants"; 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_QUERY_CODE = 20;
private static final int CONVERSATION_MESSAGES_QUERY_CODE = 30; 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_PARTICIPANTS_QUERY_CODE = 40;
private static final int CONVERSATION_IMAGES_QUERY_CODE = 50; private static final int CONVERSATION_IMAGES_QUERY_CODE = 50;
private static final int DRAFT_IMAGES_QUERY_CODE = 60; 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, CONVERSATIONS_QUERY + "/*", CONVERSATION_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, MESSAGES_QUERY + "/conversation/*", sURIMatcher.addURI(AUTHORITY, MESSAGES_QUERY + "/conversation/*",
CONVERSATION_MESSAGES_QUERY_CODE); CONVERSATION_MESSAGES_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, MESSAGES_QUERY + "/search/*",
CONVERSATION_MESSAGES_SEARCH_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY + "/conversation/*", sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY + "/conversation/*",
CONVERSATION_PARTICIPANTS_QUERY_CODE); CONVERSATION_PARTICIPANTS_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY, PARTICIPANTS_QUERY_CODE); sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY, PARTICIPANTS_QUERY_CODE);
@@ -176,6 +184,17 @@ public class MessagingContentProvider extends ContentProvider {
return builder.build(); 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) { public static void notifyMessagesChanged(final String conversationId) {
final Uri uri = buildConversationMessagesUri(conversationId); final Uri uri = buildConversationMessagesUri(conversationId);
final Context context = Factory.get().getApplicationContext(); final Context context = Factory.get().getApplicationContext();
@@ -319,6 +338,19 @@ public class MessagingContentProvider extends ContentProvider {
} else { } else {
throw new IllegalArgumentException("Malformed URI " + uri); 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: case CONVERSATION_IMAGES_QUERY_CODE:
queryBuilder.setTables(ConversationImagePartsView.getViewName()); queryBuilder.setTables(ConversationImagePartsView.getViewName());
if (uri.getPathSegments().size() == 2) { if (uri.getPathSegments().size() == 2) {
@@ -366,6 +398,24 @@ public class MessagingContentProvider extends ContentProvider {
return cursor; 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 @Override
public String getType(@NonNull final Uri uri) { public String getType(@NonNull final Uri uri) {
final StringBuilder sb = new final StringBuilder sb = new
@@ -32,7 +32,7 @@ import java.io.IOException;
public class MmsFileProvider extends FileProvider { public class MmsFileProvider extends FileProvider {
private static final String TAG = LogUtil.BUGLE_TAG; 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"; 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. * Broadcast receiver for alarms scheduled through ActionService.
*/ */
public static class PendingActionReceiver extends BroadcastReceiver { 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. * 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.Factory;
import com.android.messaging.R; import com.android.messaging.R;
import com.android.messaging.ui.home.HomeToast;
import com.android.messaging.datamodel.DataModel; import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.data.MessageData; import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.datamodel.data.ParticipantData;
@@ -115,13 +116,13 @@ public class BugleActionToasts {
} }
private static void showToast(final int messageResId) { private static void showToast(final int messageResId) {
ThreadUtil.getMainThreadHandler().post(() -> Toast.makeText(getApplicationContext(), ThreadUtil.getMainThreadHandler().post(() -> HomeToast.show(getApplicationContext(),
getApplicationContext().getString(messageResId), Toast.LENGTH_LONG).show()); messageResId, Toast.LENGTH_LONG));
} }
private static void showToast(final String message) { private static void showToast(final String message) {
ThreadUtil.getMainThreadHandler().post(() -> ThreadUtil.getMainThreadHandler().post(() ->
Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show()); HomeToast.show(getApplicationContext(), message, Toast.LENGTH_LONG));
} }
private static Context getApplicationContext() { private static Context getApplicationContext() {
@@ -143,12 +144,11 @@ public class BugleActionToasts {
final boolean block, final boolean block,
final String destination) { final String destination) {
if (success) { if (success) {
Toast.makeText(mContext, HomeToast.show(mContext,
block block
? R.string.update_destination_blocked ? R.string.update_destination_blocked
: R.string.update_destination_unblocked, : R.string.update_destination_unblocked,
Toast.LENGTH_LONG Toast.LENGTH_LONG);
).show();
} }
} }
} }
@@ -132,6 +132,13 @@ public class InsertNewMessageAction extends Action implements Parcelable {
final DatabaseWrapper db = DataModel.get().getDatabase(); final DatabaseWrapper db = DataModel.get().getDatabase();
final String conversationId = message.getConversationId(); 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); final ParticipantData self = getSelf(db, conversationId, message);
if (self == null) { if (self == null) {
return null; return null;
@@ -190,6 +197,7 @@ public class InsertNewMessageAction extends Action implements Parcelable {
messageToSend, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT); messageToSend, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT);
} }
MessagingContentProvider.notifyConversationListChanged(); MessagingContentProvider.notifyConversationListChanged();
MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this); ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
return message; 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.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper; import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider; import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.datamodel.data.MessageData; import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.LogUtil; import com.android.messaging.util.LogUtil;
@@ -70,6 +71,12 @@ public class ResendMessageAction extends Action implements Parcelable {
final MessageData message = BugleDatabaseOperations.readMessage(db, messageId); final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
// Check message can be resent // Check message can be resent
if (message != null && message.canResendMessage()) { 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(); final boolean isMms = message.getIsMms();
long timestamp = System.currentTimeMillis(); long timestamp = System.currentTimeMillis();
if (isMms) { 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.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.loader.app.LoaderManager; import androidx.loader.app.LoaderManager;
@@ -46,6 +47,13 @@ public class ContactPickerData extends BindableData implements
void onAllContactsCursorUpdated(Cursor data); void onAllContactsCursorUpdated(Cursor data);
void onFrequentContactsCursorUpdated(Cursor data); void onFrequentContactsCursorUpdated(Cursor data);
void onContactCustomColorLoaded(ContactPickerData 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"; private static final String BINDING_ID = "bindingId";
@@ -53,6 +61,8 @@ public class ContactPickerData extends BindableData implements
private LoaderManager mLoaderManager; private LoaderManager mLoaderManager;
private ContactPickerDataListener mListener; private ContactPickerDataListener mListener;
private final FrequentContactsCursorBuilder mFrequentContactsCursorBuilder; private final FrequentContactsCursorBuilder mFrequentContactsCursorBuilder;
private String mSearchText;
private ContactUtil.AccountFilter mAccountFilter;
public ContactPickerData(final Context context, final ContactPickerDataListener listener) { public ContactPickerData(final Context context, final ContactPickerDataListener listener) {
mListener = 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 ALL_CONTACTS_LOADER = 1;
private static final int FREQUENT_CONTACTS_LOADER = 2; private static final int FREQUENT_CONTACTS_LOADER = 2;
private static final int PARTICIPANT_LOADER = 3; private static final int PARTICIPANT_LOADER = 3;
private static final int FILTERED_CONTACTS_LOADER = 4;
@NonNull @NonNull
@Override @Override
@@ -72,7 +83,7 @@ public class ContactPickerData extends BindableData implements
if (isBound(bindingId)) { if (isBound(bindingId)) {
switch (id) { switch (id) {
case ALL_CONTACTS_LOADER: case ALL_CONTACTS_LOADER:
return ContactUtil.getPhones(mContext) return ContactUtil.getPhones(mContext, mAccountFilter)
.createBoundCursorLoader(bindingId); .createBoundCursorLoader(bindingId);
case FREQUENT_CONTACTS_LOADER: case FREQUENT_CONTACTS_LOADER:
return ContactUtil.getFrequentContacts(mContext) return ContactUtil.getFrequentContacts(mContext)
@@ -81,6 +92,9 @@ public class ContactPickerData extends BindableData implements
return new BoundCursorLoader(bindingId, mContext, return new BoundCursorLoader(bindingId, mContext,
MessagingContentProvider.PARTICIPANTS_URI, MessagingContentProvider.PARTICIPANTS_URI,
ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
case FILTERED_CONTACTS_LOADER:
return ContactUtil.filterDestination(mContext, mSearchText, mAccountFilter)
.createBoundCursorLoader(bindingId);
default: default:
Assert.fail("Unknown loader id for contact picker!"); Assert.fail("Unknown loader id for contact picker!");
break; break;
@@ -108,12 +122,16 @@ public class ContactPickerData extends BindableData implements
case PARTICIPANT_LOADER: case PARTICIPANT_LOADER:
mListener.onContactCustomColorLoaded(this); mListener.onContactCustomColorLoaded(this);
break; break;
case FILTERED_CONTACTS_LOADER:
mListener.onFilteredContactsCursorUpdated(data);
break;
default: default:
Assert.fail("Unknown loader id for contact picker!"); Assert.fail("Unknown loader id for contact picker!");
break; 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 // 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 // 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 // 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: case PARTICIPANT_LOADER:
mListener.onContactCustomColorLoaded(this); mListener.onContactCustomColorLoaded(this);
break; break;
case FILTERED_CONTACTS_LOADER:
mListener.onFilteredContactsCursorUpdated(null);
break;
default: default:
Assert.fail("Unknown loader id for contact picker!"); Assert.fail("Unknown loader id for contact picker!");
break; break;
@@ -170,6 +191,56 @@ public class ContactPickerData extends BindableData implements
mLoaderManager.initLoader(PARTICIPANT_LOADER, args, this); 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 @Override
protected void unregisterListeners() { protected void unregisterListeners() {
mListener = null; mListener = null;
@@ -180,6 +251,7 @@ public class ContactPickerData extends BindableData implements
mLoaderManager.destroyLoader(ALL_CONTACTS_LOADER); mLoaderManager.destroyLoader(ALL_CONTACTS_LOADER);
mLoaderManager.destroyLoader(FREQUENT_CONTACTS_LOADER); mLoaderManager.destroyLoader(FREQUENT_CONTACTS_LOADER);
mLoaderManager.destroyLoader(PARTICIPANT_LOADER); mLoaderManager.destroyLoader(PARTICIPANT_LOADER);
mLoaderManager.destroyLoader(FILTERED_CONTACTS_LOADER);
mLoaderManager = null; mLoaderManager = null;
} }
mFrequentContactsCursorBuilder.resetBuilder(); 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.RedownloadMmsAction;
import com.android.messaging.datamodel.action.ResendMessageAction; import com.android.messaging.datamodel.action.ResendMessageAction;
import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; 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.BindableData;
import com.android.messaging.datamodel.binding.Binding; import com.android.messaging.datamodel.binding.Binding;
import com.android.messaging.datamodel.binding.BindingBase; import com.android.messaging.datamodel.binding.BindingBase;
@@ -460,7 +461,11 @@ public class ConversationData extends BindableData {
} }
public boolean getIsArchived() { public boolean getIsArchived() {
return mConversationMetadata.getIsArchived(); return mConversationMetadata != null && mConversationMetadata.getIsArchived();
}
public boolean getIsTrashed() {
return mConversationMetadata != null && mConversationMetadata.getIsTrashed();
} }
public String getIcon() { public String getIcon() {
@@ -569,6 +574,12 @@ public class ConversationData extends BindableData {
final MessageData message) { final MessageData message) {
Assert.isTrue(TextUtils.equals(mConversationId, message.getConversationId())); Assert.isTrue(TextUtils.equals(mConversationId, message.getConversationId()));
Assert.isTrue(binding.getData() == this); Assert.isTrue(binding.getData() == this);
if (getIsTrashed()) {
return;
}
if (getIsArchived()) {
unarchiveConversation(binding);
}
if (message.getSelfId() == null) { if (message.getSelfId() == null) {
InsertNewMessageAction.insertNewMessage(message); InsertNewMessageAction.insertNewMessage(message);
@@ -612,6 +623,12 @@ public class ConversationData extends BindableData {
public void resendMessage(final BindingBase<ConversationData> binding, final String messageId) { public void resendMessage(final BindingBase<ConversationData> binding, final String messageId) {
Assert.isTrue(binding.getData() == this); Assert.isTrue(binding.getData() == this);
Assert.notNull(messageId); Assert.notNull(messageId);
if (getIsTrashed()) {
return;
}
if (getIsArchived()) {
unarchiveConversation(binding);
}
ResendMessageAction.resendMessage(messageId); ResendMessageAction.resendMessage(messageId);
} }
@@ -642,6 +659,16 @@ public class ConversationData extends BindableData {
UpdateConversationArchiveStatusAction.unarchiveConversation(mConversationId); 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() { public ConversationParticipantsData getParticipants() {
return mParticipantData; return mParticipantData;
} }
@@ -52,6 +52,12 @@ public class ConversationListData extends BindableData
"(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 1)"; "(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 1)";
public static final String WHERE_NOT_ARCHIVED = public static final String WHERE_NOT_ARCHIVED =
"(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 0)"; "(" + 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 { public interface ConversationListDataListener {
void onConversationListCursorUpdated(ConversationListData data, Cursor cursor); void onConversationListCursorUpdated(ConversationListData data, Cursor cursor);
@@ -61,13 +67,20 @@ public class ConversationListData extends BindableData
private ConversationListDataListener mListener; private ConversationListDataListener mListener;
private final Context mContext; private final Context mContext;
private final boolean mArchivedMode; private final boolean mArchivedMode;
private final boolean mTrashMode;
private LoaderManager mLoaderManager; private LoaderManager mLoaderManager;
public ConversationListData(final Context context, final ConversationListDataListener listener, public ConversationListData(final Context context, final ConversationListDataListener listener,
final boolean archivedMode) { final boolean archivedMode) {
this(context, listener, archivedMode, false);
}
public ConversationListData(final Context context, final ConversationListDataListener listener,
final boolean archivedMode, final boolean trashMode) {
mListener = listener; mListener = listener;
mContext = context; mContext = context;
mArchivedMode = archivedMode; mTrashMode = trashMode;
mArchivedMode = archivedMode && !trashMode;
} }
private static final int CONVERSATION_LIST_LOADER = 1; private static final int CONVERSATION_LIST_LOADER = 1;
@@ -101,7 +114,7 @@ public class ConversationListData extends BindableData
loader = new BoundCursorLoader(bindingId, mContext, loader = new BoundCursorLoader(bindingId, mContext,
MessagingContentProvider.CONVERSATIONS_URI, MessagingContentProvider.CONVERSATIONS_URI,
ConversationListItemData.PROJECTION, ConversationListItemData.PROJECTION,
mArchivedMode ? WHERE_ARCHIVED : WHERE_NOT_ARCHIVED, listSelection(),
null, // selection args null, // selection args
SORT_ORDER); SORT_ORDER);
break; break;
@@ -212,4 +225,18 @@ public class ConversationListData extends BindableData
public HashSet<String> getBlockedParticipants() { public HashSet<String> getBlockedParticipants() {
return mBlockedParticipants; 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.DatabaseHelper.ParticipantColumns;
import com.android.messaging.datamodel.DatabaseWrapper; import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.action.DeleteConversationAction; 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.Assert;
import com.android.messaging.util.ContactUtil; import com.android.messaging.util.ContactUtil;
import com.android.messaging.util.Dates; import com.android.messaging.util.Dates;
@@ -61,6 +62,7 @@ public class ConversationListItemData {
private String mDraftPreviewContentType; private String mDraftPreviewContentType;
private String mDraftSnippetText; private String mDraftSnippetText;
private boolean mIsArchived; private boolean mIsArchived;
private long mTrashTimestamp;
private String mSubject; private String mSubject;
private String mDraftSubject; private String mDraftSubject;
private String mSnippetSenderFirstName; private String mSnippetSenderFirstName;
@@ -110,6 +112,8 @@ public class ConversationListItemData {
} }
mIsArchived = cursor.getInt(INDEX_ARCHIVE_STATUS) == 1; 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); mSubject = cursor.getString(INDEX_SUBJECT_TEXT);
mSnippetSenderFirstName = cursor.getString(INDEX_SNIPPET_SENDER_FIRST_NAME); mSnippetSenderFirstName = cursor.getString(INDEX_SNIPPET_SENDER_FIRST_NAME);
mSnippetSenderDisplayDestination = mSnippetSenderDisplayDestination =
@@ -240,6 +244,14 @@ public class ConversationListItemData {
return mIsArchived; return mIsArchived;
} }
public boolean getIsTrashed() {
return mTrashTimestamp > 0;
}
public long getTrashTimestamp() {
return mTrashTimestamp;
}
public String getSubject() { public String getSubject() {
return mSubject; return mSubject;
} }
@@ -256,7 +268,11 @@ public class ConversationListItemData {
} }
public void deleteConversation() { 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 + ", " + " as " + ConversationListViewColumns.CURRENT_SELF_ID + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ARCHIVE_STATUS + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ARCHIVE_STATUS
+ " as " + ConversationListViewColumns.ARCHIVE_STATUS + ", " + " as " + ConversationListViewColumns.ARCHIVE_STATUS + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.TRASH_TIMESTAMP
+ " as " + ConversationListViewColumns.TRASH_TIMESTAMP + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.READ + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.READ
+ " as " + ConversationListViewColumns.READ + ", " + " as " + ConversationListViewColumns.READ + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ICON + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ICON
@@ -354,6 +372,7 @@ public class ConversationListItemData {
public static final String _ID = ConversationColumns._ID; public static final String _ID = ConversationColumns._ID;
static final String NAME = ConversationColumns.NAME; static final String NAME = ConversationColumns.NAME;
static final String ARCHIVE_STATUS = ConversationColumns.ARCHIVE_STATUS; 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 READ = MessageColumns.READ;
static final String SORT_TIMESTAMP = ConversationColumns.SORT_TIMESTAMP; static final String SORT_TIMESTAMP = ConversationColumns.SORT_TIMESTAMP;
static final String PREVIEW_URI = ConversationColumns.PREVIEW_URI; static final String PREVIEW_URI = ConversationColumns.PREVIEW_URI;
@@ -412,6 +431,7 @@ public class ConversationListItemData {
ConversationListViewColumns.SNIPPET_SENDER_FIRST_NAME, ConversationListViewColumns.SNIPPET_SENDER_FIRST_NAME,
ConversationListViewColumns.SNIPPET_SENDER_DISPLAY_DESTINATION, ConversationListViewColumns.SNIPPET_SENDER_DISPLAY_DESTINATION,
ConversationListViewColumns.IS_ENTERPRISE, ConversationListViewColumns.IS_ENTERPRISE,
ConversationListViewColumns.TRASH_TIMESTAMP,
}; };
private static final int INDEX_ID = 0; 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_FIRST_NAME = 24;
private static final int INDEX_SNIPPET_SENDER_DISPLAY_DESTINATION = 25; private static final int INDEX_SNIPPET_SENDER_DISPLAY_DESTINATION = 25;
private static final int INDEX_IS_ENTERPRISE = 26; private static final int INDEX_IS_ENTERPRISE = 26;
private static final int INDEX_TRASH_TIMESTAMP = 27;
private static final String DIVIDER_TEXT = ", "; private static final String DIVIDER_TEXT = ", ";
@@ -367,27 +367,25 @@ public class ConversationMessageData {
* @return the text that is a concatenation of all text parts * @return the text that is a concatenation of all text parts
*/ */
public String getText() { 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; String firstTextPart = null;
boolean foundText = false; boolean foundText = false;
// For multiple text parts, we need the StringBuilder and the separator for concatenation
StringBuilder sb = null; StringBuilder sb = null;
String separator = null; String separator = null;
for (final MessagePartData part : mParts) { for (final MessagePartData part : mParts) {
if (part.isText()) { if (part.isText()) {
if (!foundText) { if (!foundText) {
// First text part
firstTextPart = part.getText(); firstTextPart = part.getText();
foundText = true; foundText = true;
} else { } else {
// Second and beyond
if (sb == null) { if (sb == null) {
// Need the StringBuilder and the separator starting from 2nd text part
sb = new StringBuilder(); sb = new StringBuilder();
if (!TextUtils.isEmpty(firstTextPart)) { if (!TextUtils.isEmpty(firstTextPart)) {
sb.append(firstTextPart); sb.append(firstTextPart);
} }
separator = BugleGservicesKeys.MMS_TEXT_CONCAT_SEPARATOR_DEFAULT; separator = BugleGservicesKeys.MMS_TEXT_CONCAT_SEPARATOR_DEFAULT;
} }
@@ -401,13 +399,112 @@ public class ConversationMessageData {
} }
} }
} }
if (sb == null) { return sb == null ? firstTextPart : sb.toString();
// Only one text part }
return firstTextPart;
} else { private static String stripDisplayText(final String text) {
// More than one return sanitizeDisplayText(text);
return sb.toString(); }
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() { public boolean hasAttachments() {
@@ -619,6 +716,21 @@ public class ConversationMessageData {
+ CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY; + 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() { static String getConversationMessageIdsQuerySql() {
return CONVERSATION_MESSAGES_IDS_QUERY_SQL return CONVERSATION_MESSAGES_IDS_QUERY_SQL
+ " AND " + " AND "
@@ -1,6 +1,6 @@
/* /*
* Copyright (C) 2015 The Android Open Source Project * 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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; 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> { LoaderManager.LoaderCallbacks<Cursor> {
public interface PeopleAndOptionsDataListener { public interface ConversationProfileDataListener {
void onOptionsCursorUpdated(PeopleAndOptionsData data, Cursor cursor); void onConversationMetadataUpdated(ConversationProfileData data,
void onParticipantsListLoaded(PeopleAndOptionsData data, ConversationListItemData conversation);
void onParticipantsListLoaded(ConversationProfileData data,
List<ParticipantData> participants); List<ParticipantData> participants);
} }
private static final String BINDING_ID = "bindingId"; 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 Context mContext;
private final String mConversationId; private final String mConversationId;
private final ConversationParticipantsData mParticipantData; private final ConversationParticipantsData mParticipantData;
private LoaderManager mLoaderManager; private LoaderManager mLoaderManager;
private PeopleAndOptionsDataListener mListener; private ConversationProfileDataListener mListener;
public PeopleAndOptionsData(final String conversationId, final Context context, public ConversationProfileData(final String conversationId, final Context context,
final PeopleAndOptionsDataListener listener) { final ConversationProfileDataListener listener) {
mListener = listener; mListener = listener;
mContext = context; mContext = context;
mConversationId = conversationId; mConversationId = conversationId;
mParticipantData = new ConversationParticipantsData(); mParticipantData = new ConversationParticipantsData();
} }
private static final int CONVERSATION_OPTIONS_LOADER = 1;
private static final int PARTICIPANT_LOADER = 2;
@NonNull @NonNull
@Override @Override
public Loader<Cursor> onCreateLoader(final int id, final Bundle args) { public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
final String bindingId = args.getString(BINDING_ID); final String bindingId = args.getString(BINDING_ID);
// Check if data still bound to the requesting ui element if (!isBound(bindingId)) {
if (isBound(bindingId)) { LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding conversation profile");
switch (id) { return null;
case CONVERSATION_OPTIONS_LOADER: { }
final Uri uri = switch (id) {
MessagingContentProvider.buildConversationMetadataUri(mConversationId); case CONVERSATION_METADATA_LOADER: {
return new BoundCursorLoader(bindingId, mContext, uri, final Uri uri =
new String[]{}, null, null, null); MessagingContentProvider.buildConversationMetadataUri(mConversationId);
} return new BoundCursorLoader(bindingId, mContext, uri,
ConversationListItemData.PROJECTION, null, null, null);
case PARTICIPANT_LOADER: { }
final Uri uri =
MessagingContentProvider case PARTICIPANT_LOADER: {
.buildConversationParticipantsUri(mConversationId); final Uri uri =
return new BoundCursorLoader(bindingId, mContext, uri, MessagingContentProvider.buildConversationParticipantsUri(mConversationId);
ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); return new BoundCursorLoader(bindingId, mContext, uri,
} ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
}
default:
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!"); default:
break; Assert.fail("Unknown loader id for the conversation profile!");
} return null;
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding PeopleAndOptionsFragment");
} }
return null;
} }
/**
* {@inheritDoc}
*/
@Override @Override
public void onLoadFinished(@NonNull final Loader<Cursor> loader, final Cursor data) { public void onLoadFinished(@NonNull final Loader<Cursor> loader, final Cursor data) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId())) { if (!isBound(cursorLoader.getBindingId()) || mListener == null) {
switch (loader.getId()) { LogUtil.w(LogUtil.BUGLE_TAG, "Loader finished after unbinding conversation profile");
case CONVERSATION_OPTIONS_LOADER: return;
mListener.onOptionsCursorUpdated(this, data); }
break; switch (loader.getId()) {
case CONVERSATION_METADATA_LOADER: {
case PARTICIPANT_LOADER: ConversationListItemData conversation = null;
mParticipantData.bind(data); if (data != null && data.moveToFirst()) {
mListener.onParticipantsListLoaded(this, conversation = new ConversationListItemData();
mParticipantData.getParticipantListExcludingSelf()); conversation.bind(data);
break; }
mListener.onConversationMetadataUpdated(this, conversation);
default: break;
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
break;
} }
} else {
LogUtil.w(LogUtil.BUGLE_TAG, case PARTICIPANT_LOADER:
"Loader finished after unbinding PeopleAndOptionsFragment"); mParticipantData.bind(data);
mListener.onParticipantsListLoaded(this,
mParticipantData.getParticipantListExcludingSelf());
break;
default:
Assert.fail("Unknown loader id for the conversation profile!");
break;
} }
} }
/**
* {@inheritDoc}
*/
@Override @Override
public void onLoaderReset(@NonNull final Loader<Cursor> loader) { public void onLoaderReset(@NonNull final Loader<Cursor> loader) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId())) { if (!isBound(cursorLoader.getBindingId()) || mListener == null) {
switch (loader.getId()) { return;
case CONVERSATION_OPTIONS_LOADER: }
mListener.onOptionsCursorUpdated(this, null); switch (loader.getId()) {
break; case CONVERSATION_METADATA_LOADER:
mListener.onConversationMetadataUpdated(this, null);
break;
case PARTICIPANT_LOADER: case PARTICIPANT_LOADER:
mParticipantData.bind(null); mParticipantData.bind(null);
break; break;
default: default:
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!"); Assert.fail("Unknown loader id for the conversation profile!");
break; break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding PeopleAndOptionsFragment");
} }
} }
public void init(final LoaderManager loaderManager, public void init(final LoaderManager loaderManager,
final BindingBase<PeopleAndOptionsData> binding) { final BindingBase<ConversationProfileData> binding) {
final Bundle args = new Bundle(); final Bundle args = new Bundle();
args.putString(BINDING_ID, binding.getBindingId()); args.putString(BINDING_ID, binding.getBindingId());
mLoaderManager = loaderManager; mLoaderManager = loaderManager;
mLoaderManager.initLoader(CONVERSATION_OPTIONS_LOADER, args, this); mLoaderManager.initLoader(CONVERSATION_METADATA_LOADER, args, this);
mLoaderManager.initLoader(PARTICIPANT_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 // This could be null if we bind but the caller doesn't init the BindableData
if (mLoaderManager != null) { if (mLoaderManager != null) {
mLoaderManager.destroyLoader(CONVERSATION_OPTIONS_LOADER); mLoaderManager.destroyLoader(CONVERSATION_METADATA_LOADER);
mLoaderManager.destroyLoader(PARTICIPANT_LOADER); mLoaderManager.destroyLoader(PARTICIPANT_LOADER);
mLoaderManager = null; mLoaderManager = null;
} }
} }
public void setDestinationBlocked(final BindingBase<PeopleAndOptionsData> binding, public void setDestinationBlocked(final BindingBase<ConversationProfileData> binding,
final boolean blocked) { final boolean blocked) {
final String bindingId = binding.getBindingId();
final ParticipantData participantData = mParticipantData.getOtherParticipant(); final ParticipantData participantData = mParticipantData.getOtherParticipant();
if (isBound(bindingId) && participantData != null) { if (isBound(binding.getBindingId()) && participantData != null) {
UpdateDestinationBlockedAction.updateDestinationBlocked( UpdateDestinationBlockedAction.updateDestinationBlocked(
participantData.getNormalizedDestination(), participantData.getNormalizedDestination(),
blocked, mConversationId, 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() { public String getConversationId() {
return mConversationId; 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; 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) { public static String getParticipantId(final DatabaseWrapper db, final int subId) {
String id; String id;
synchronized (sSubIdtoParticipantIdCache) { synchronized (sSubIdtoParticipantIdCache) {
@@ -87,6 +87,11 @@ public class ParticipantListItemData extends PersonItemData {
return mNormalizedDestination; return mNormalizedDestination;
} }
public void unblockQuietly(final Context context) {
UpdateDestinationBlockedAction.updateDestinationBlocked(
mNormalizedDestination, false, null, (action, success, block, destination) -> { });
}
public void unblock(final Context context) { public void unblock(final Context context) {
UpdateDestinationBlockedAction.updateDestinationBlocked( UpdateDestinationBlockedAction.updateDestinationBlocked(
mNormalizedDestination, false, null, 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()); mContext.getResources(), R.drawable.ic_person_light, mContext.getTheme());
float dstWidth = Math.min(defaultPerson.getIntrinsicWidth() * SCALING_FACTOR, width); float dstWidth = Math.min(defaultPerson.getIntrinsicWidth() * SCALING_FACTOR, width);
float dstHeight = Math.min(defaultPerson.getIntrinsicHeight() * SCALING_FACTOR, height); 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); canvas.translate((width - dstWidth) / 2, (height - dstHeight) / 2);
defaultPerson.setBounds(0, 0, (int)dstWidth, (int)dstHeight); defaultPerson.setBounds(0, 0, (int)dstWidth, (int)dstHeight);
@@ -211,7 +211,16 @@ public class DecodedImageResource extends ImageResource {
} }
} }
byte[] encodedBytes = ImageUtils.bitmapToBytes(bitmap, COMPRESS_QUALITY); 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) { } catch (Exception ex) {
// Something went wrong during bitmap compression, fall back to just using the // Something went wrong during bitmap compression, fall back to just using the
// original bitmap. // original bitmap.
@@ -17,11 +17,11 @@ package com.android.messaging.datamodel.media;
import android.content.res.Resources; import android.content.res.Resources;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import com.android.messaging.util.Assert; import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.DoesNotRunOnMainThread; import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
import com.android.messaging.util.ImageUtils;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
@@ -41,10 +41,20 @@ import java.util.List;
*/ */
public class EncodedImageResource extends ImageResource { public class EncodedImageResource extends ImageResource {
private final byte[] mImageBytes; private final byte[] mImageBytes;
private final int mDesiredWidth;
private final int mDesiredHeight;
public EncodedImageResource(String key, byte[] imageBytes, int orientation) { 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); super(key, orientation);
mImageBytes = imageBytes; mImageBytes = imageBytes;
mDesiredWidth = desiredWidth;
mDesiredHeight = desiredHeight;
} }
@Override @Override
@@ -54,7 +64,7 @@ public class EncodedImageResource extends ImageResource {
try { try {
// This should only be called during the decode request. // This should only be called during the decode request.
Assert.isNotMainThread(); Assert.isNotMainThread();
return BitmapFactory.decodeByteArray(mImageBytes, 0, mImageBytes.length); return decodeSampledBitmap();
} finally { } finally {
releaseLock(); releaseLock();
} }
@@ -99,11 +109,15 @@ public class EncodedImageResource extends ImageResource {
return true; return true;
} }
private Bitmap decodeSampledBitmap() {
return ImageUtils.decodeByteArraySampled(mImageBytes, mDesiredWidth, mDesiredHeight);
}
@Override @Override
MediaRequest<? extends RefCountedMediaResource> getMediaDecodingRequest( MediaRequest<? extends RefCountedMediaResource> getMediaDecodingRequest(
final MediaRequest<? extends RefCountedMediaResource> originalRequest) { final MediaRequest<? extends RefCountedMediaResource> originalRequest) {
Assert.isTrue(isEncoded()); Assert.isTrue(isEncoded());
return new DecodeImageRequest(); return new DecodeImageRequest(originalRequest);
} }
/** /**
@@ -112,7 +126,22 @@ public class EncodedImageResource extends ImageResource {
* properties such as binding. * properties such as binding.
*/ */
private class DecodeImageRequest implements MediaRequest<ImageResource> { 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. // Hold a ref onto the encoded resource before the request finishes.
addRef(); addRef();
} }
@@ -129,8 +158,11 @@ public class EncodedImageResource extends ImageResource {
Assert.isNotMainThread(); Assert.isNotMainThread();
acquireLock(); acquireLock();
try { try {
final Bitmap decodedBitmap = BitmapFactory.decodeByteArray(mImageBytes, 0, final Bitmap decodedBitmap = ImageUtils.decodeByteArraySampled(mImageBytes,
mImageBytes.length); mRequestDesiredWidth, mRequestDesiredHeight);
if (decodedBitmap == null) {
throw new RuntimeException("failed decoding encoded bitmap");
}
return new DecodedImageResource(getKey(), decodedBitmap, getOrientation()); return new DecodedImageResource(getKey(), decodedBitmap, getOrientation());
} finally { } finally {
releaseLock(); releaseLock();
@@ -18,11 +18,11 @@ package com.android.messaging.datamodel.media;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.support.v7.mms.pdu.ContentType; import android.support.v7.mms.pdu.ContentType;
import com.android.messaging.Factory; import com.android.messaging.Factory;
import com.android.messaging.util.Assert; import com.android.messaging.util.Assert;
import com.android.messaging.util.ImageUtils;
import com.android.messaging.util.LogUtil; import com.android.messaging.util.LogUtil;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
@@ -91,7 +91,11 @@ public class NetworkUriImageRequest<D extends UriImageRequestDescriptor> extends
connection.setDoInput(true); connection.setDoInput(true);
connection.connect(); connection.connect();
if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) { 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) { } catch (MalformedURLException e) {
LogUtil.e(LogUtil.BUGLE_TAG, LogUtil.e(LogUtil.BUGLE_TAG,
@@ -22,6 +22,7 @@ import android.content.Intent;
import com.android.messaging.BugleApplication; import com.android.messaging.BugleApplication;
import com.android.messaging.Factory; import com.android.messaging.Factory;
import com.android.messaging.datamodel.ScheduledMessageStore;
import com.android.messaging.datamodel.action.UpdateMessageNotificationAction; import com.android.messaging.datamodel.action.UpdateMessageNotificationAction;
import com.android.messaging.util.BuglePrefsKeys; import com.android.messaging.util.BuglePrefsKeys;
import com.android.messaging.util.LogUtil; import com.android.messaging.util.LogUtil;
@@ -40,6 +41,7 @@ public class BootAndPackageReplacedReceiver extends BroadcastReceiver {
UpdateMessageNotificationAction.updateMessageNotification(); UpdateMessageNotificationAction.updateMessageNotification();
BugleApplication.updateAppConfig(context); BugleApplication.updateAppConfig(context);
ScheduledMessageStore.rescheduleAll(context);
} else { } else {
LogUtil.i(LogUtil.BUGLE_TAG, "BootAndPackageReplacedReceiver got unexpected action: " LogUtil.i(LogUtil.BUGLE_TAG, "BootAndPackageReplacedReceiver got unexpected action: "
+ intent.getAction()); + 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 class SendStatusReceiver extends BroadcastReceiver {
public static final String MESSAGE_SENT_ACTION = 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 = 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 = 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 = 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. // Defined by platform, but no constant provided. See docs for SmsManager.sendTextMessage.
public static final String EXTRA_ERROR_CODE = "errorCode"; public static final String EXTRA_ERROR_CODE = "errorCode";
@@ -23,13 +23,16 @@ import android.view.ActionMode;
import android.view.Menu; import android.view.Menu;
import android.view.MenuInflater; import android.view.MenuInflater;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View; import android.view.View;
import android.view.Window;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import com.android.messaging.R; import com.android.messaging.R;
import com.android.messaging.ui.home.HomeInputFocus;
import com.android.messaging.util.BugleActivityUtil; import com.android.messaging.util.BugleActivityUtil;
import com.android.messaging.util.ImeUtil; import com.android.messaging.util.ImeUtil;
import com.android.messaging.util.LogUtil; import com.android.messaging.util.LogUtil;
@@ -70,9 +73,6 @@ public class BugleActionBarActivity extends AppCompatActivity implements ImeUtil
mLastScreenHeight = getResources().getDisplayMetrics().heightPixels; mLastScreenHeight = getResources().getDisplayMetrics().heightPixels;
LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onCreate"); LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onCreate");
getWindow().setStatusBarColor(getResources().getColor(R.color.action_bar_background_color,
getTheme()));
} }
@Override @Override
@@ -94,6 +94,13 @@ public class BugleActionBarActivity extends AppCompatActivity implements ImeUtil
BugleActivityUtil.onActivityResume(this, BugleActionBarActivity.this); 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 @Override
protected void onPause() { protected void onPause() {
super.onPause(); super.onPause();
@@ -339,6 +346,9 @@ public class BugleActionBarActivity extends AppCompatActivity implements ImeUtil
} }
public void updateActionBar(final ActionBar actionBar) { public void updateActionBar(final ActionBar actionBar) {
if (actionBar == null) {
return;
}
if (!BugleActionBarActivity.this.shouldShowContextualActionBar(mCallback)) { if (!BugleActionBarActivity.this.shouldShowContextualActionBar(mCallback)) {
actionBar.hide(); actionBar.hide();
return; return;
@@ -41,8 +41,11 @@ public class ContactIconView extends AsyncImageView {
private static final int NORMAL_ICON_SIZE_ID = 0; private static final int NORMAL_ICON_SIZE_ID = 0;
private static final int LARGE_ICON_SIZE_ID = 1; private static final int LARGE_ICON_SIZE_ID = 1;
private static final int SMALL_ICON_SIZE_ID = 2; 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; protected final int mIconSize;
private final boolean mCropToCircle;
private final int mColorPressedId; private final int mColorPressedId;
private long mContactId; private long mContactId;
@@ -58,6 +61,7 @@ public class ContactIconView extends AsyncImageView {
final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ContactIconView); final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ContactIconView);
final int iconSizeId = a.getInt(R.styleable.ContactIconView_contactIconSize, 0); final int iconSizeId = a.getInt(R.styleable.ContactIconView_contactIconSize, 0);
boolean cropToCircle = true;
switch (iconSizeId) { switch (iconSizeId) {
case NORMAL_ICON_SIZE_ID: case NORMAL_ICON_SIZE_ID:
mIconSize = (int) resources.getDimension( mIconSize = (int) resources.getDimension(
@@ -71,11 +75,21 @@ public class ContactIconView extends AsyncImageView {
mIconSize = (int) resources.getDimension( mIconSize = (int) resources.getDimension(
R.dimen.contact_icon_view_small_size); R.dimen.contact_icon_view_small_size);
break; 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: default:
// For the compiler, something has to be set even with the assert. // For the compiler, something has to be set even with the assert.
mIconSize = 0; mIconSize = 0;
Assert.fail("Unsupported ContactIconView icon size attribute"); Assert.fail("Unsupported ContactIconView icon size attribute");
} }
mCropToCircle = cropToCircle;
mColorPressedId = resources.getColor(R.color.contact_avatar_pressed_color, mColorPressedId = resources.getColor(R.color.contact_avatar_pressed_color,
context.getTheme()); context.getTheme());
@@ -118,7 +132,8 @@ public class ContactIconView extends AsyncImageView {
if (AvatarUriUtil.TYPE_GROUP_URI.equals(avatarType)) { if (AvatarUriUtil.TYPE_GROUP_URI.equals(avatarType)) {
setImageResourceId(new AvatarGroupRequestDescriptor(uri, mIconSize, mIconSize)); setImageResourceId(new AvatarGroupRequestDescriptor(uri, mIconSize, mIconSize));
} else { } else {
setImageResourceId(new AvatarRequestDescriptor(uri, mIconSize, mIconSize)); setImageResourceId(new AvatarRequestDescriptor(uri, mIconSize, mIconSize,
mCropToCircle));
} }
} }
@@ -17,20 +17,31 @@
package com.android.messaging.ui; package com.android.messaging.ui;
import android.app.Activity;
import android.os.Bundle; import android.os.Bundle;
import android.webkit.WebView; import android.webkit.WebView;
import com.android.messaging.R; import com.android.messaging.R;
import com.android.messaging.ui.home.HomeSubHeader;
public class LicenseActivity extends Activity { public class LicenseActivity extends BugleActionBarActivity {
private final String LICENSE_URL = "file:///android_asset/licenses.html"; private static final String LICENSE_URL = "file:///android_asset/licenses.html";
@Override @Override
public void onCreate(final Bundle bundle) { protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(bundle); super.onCreate(savedInstanceState);
setContentView(R.layout.license_activity); 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); final WebView webView = findViewById(R.id.content);
webView.setBackgroundColor(getColor(R.color.home_bg));
webView.loadUrl(LICENSE_URL); 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.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.telephony.TelephonyManager; 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.datamodel.NoConfirmationSmsSendService;
import com.android.messaging.ui.home.HomeBottomSheet;
import com.android.messaging.util.LogUtil; import com.android.messaging.util.LogUtil;
import com.google.android.material.bottomsheet.BottomSheetDialog;
public class RemoteInputEntrypointActivity extends BaseBugleActivity { public class RemoteInputEntrypointActivity extends BaseBugleActivity {
private static final String TAG = LogUtil.BUGLE_TAG; private static final String TAG = LogUtil.BUGLE_TAG;
@@ -37,22 +51,111 @@ public class RemoteInputEntrypointActivity extends BaseBugleActivity {
return; return;
} }
// Perform some action depending on the intent
String action = intent.getAction(); String action = intent.getAction();
if (Intent.ACTION_SENDTO.equals(action)) { 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 {
LogUtil.w(TAG, "Unrecognized intent action: " + action); LogUtil.w(TAG, "Unrecognized intent action: " + action);
setResult(RESULT_CANCELED); 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(); 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.text.TextUtils;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup.MarginLayoutParams;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import android.widget.TextView; import android.widget.TextView;
@@ -272,18 +271,12 @@ public class SnackBar {
private void setUpButton() { private void setUpButton() {
if (mAction == null || mAction.getActionRunnable() == null) { if (mAction == null || mAction.getActionRunnable() == null) {
mActionTextView.setVisibility(View.GONE); mActionTextView.setVisibility(View.GONE);
// In the XML layout we add left/right padding to the button to add space between final int endPad = mSnackBarView.getPaddingStart();
// the message text and the button and on the right side we add padding to put space mSnackBarView.setPaddingRelative(
// between the button and the edge of the snack bar. This is so the button can use the mSnackBarView.getPaddingStart(),
// padding area as part of it's click target. Since we have no button, we need to put mSnackBarView.getPaddingTop(),
// some margin on the right side. While the left margin is already set on the wrapper, endPad,
// we're setting it again to not have to make a special case for RTL. mSnackBarView.getPaddingBottom());
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);
} else { } else {
mActionTextView.setVisibility(View.VISIBLE); mActionTextView.setVisibility(View.VISIBLE);
mActionTextView.setText(mAction.getActionLabel()); mActionTextView.setText(mAction.getActionLabel());
@@ -26,9 +26,11 @@ import android.graphics.Rect;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import androidx.activity.result.ActivityResultLauncher;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import com.android.messaging.Factory; import com.android.messaging.Factory;
import com.android.messaging.datamodel.data.ConversationMessageData;
import com.android.messaging.datamodel.data.MessageData; import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.ConversationIdSet; 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 UI_INTENT_EXTRA_WITH_CUSTOM_TRANSITION = "with_custom_transition";
public static final String ACTION_RESET_NOTIFICATIONS = public static final String ACTION_RESET_NOTIFICATIONS =
"com.android.messaging.reset_notifications"; "com.bnkode.notie.reset_notifications";
// Sending VCard uri to VCard detail activity // Sending VCard uri to VCard detail activity
public static final String UI_INTENT_EXTRA_VCARD_URI = "vcard_uri"; 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 // Sending attachment content type from widget
public static final String UI_INTENT_EXTRA_ATTACHMENT_TYPE = "attachment_type"; 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 = 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_REQUIRES_MMS = "requires_mms";
public static final String UI_INTENT_EXTRA_SELF_ID = "self_id"; 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. // Message position to scroll to.
public static final String UI_INTENT_EXTRA_MESSAGE_POSITION = "message_position"; 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); 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); 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 * Launch an external activity to handle a phone call
* @param phoneNumber the phone number to call * @param phoneNumber the phone number to call
@@ -184,11 +203,47 @@ public abstract class UIIntents {
*/ */
public abstract void launchArchivedConversationsActivity(final Context context); 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. * Launch an activity to show blocked participants.
*/ */
public abstract void launchBlockedParticipantsActivity(final Context context); 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 * Launch an activity to show a class zero message
*/ */
@@ -284,7 +339,8 @@ public abstract class UIIntents {
*/ */
public abstract PendingIntent getPendingIntentForSendingMessageToConversation( public abstract PendingIntent getPendingIntentForSendingMessageToConversation(
final Context context, final String conversationId, final String selfId, 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. * Get a PendingIntent for clearing notifications.
@@ -35,6 +35,7 @@ import android.provider.MediaStore;
import android.support.v7.mms.pdu.ContentType; import android.support.v7.mms.pdu.ContentType;
import android.text.TextUtils; import android.text.TextUtils;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.app.TaskStackBuilder; import androidx.core.app.TaskStackBuilder;
import androidx.localbroadcastmanager.content.LocalBroadcastManager; 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.ConversationImagePartsView;
import com.android.messaging.datamodel.MediaScratchFileProvider; import com.android.messaging.datamodel.MediaScratchFileProvider;
import com.android.messaging.datamodel.MessagingContentProvider; 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.MessageData;
import com.android.messaging.datamodel.data.MessagePartData; import com.android.messaging.datamodel.data.MessagePartData;
import com.android.messaging.datamodel.data.ParticipantData; 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.ConversationActivity;
import com.android.messaging.ui.conversation.LaunchConversationActivity; import com.android.messaging.ui.conversation.LaunchConversationActivity;
import com.android.messaging.ui.conversationlist.ArchivedConversationListActivity; 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.ConversationListActivity;
import com.android.messaging.ui.conversationlist.ForwardMessageActivity; 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.ui.photoviewer.BuglePhotoViewActivity;
import com.android.messaging.util.Assert; import com.android.messaging.util.Assert;
import com.android.messaging.util.ConversationIdSet; import com.android.messaging.util.ConversationIdSet;
@@ -208,6 +211,12 @@ public class UIIntentsImpl extends UIIntents {
context.startActivity(intent); context.startActivity(intent);
} }
@Override
public void launchRecentlyDeletedConversationsActivity(final Context context) {
final Intent intent = new Intent(context, RecentlyDeletedConversationListActivity.class);
context.startActivity(intent);
}
@Override @Override
public void launchBlockedParticipantsActivity(final Context context) { public void launchBlockedParticipantsActivity(final Context context) {
final Intent intent = new Intent(context, BlockedParticipantsActivity.class); final Intent intent = new Intent(context, BlockedParticipantsActivity.class);
@@ -215,11 +224,79 @@ public class UIIntentsImpl extends UIIntents {
} }
@Override @Override
public void launchPeopleAndOptionsActivity(final Activity activity, public void launchHomeSpamActivity(final Context context) {
final String conversationId) { final Intent intent = new Intent(context,
final Intent intent = new Intent(activity, PeopleAndOptionsActivity.class); 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); intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId);
activity.startActivityForResult(intent, 0); 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 @Override
@@ -352,7 +429,7 @@ public class UIIntentsImpl extends UIIntents {
@Override @Override
public PendingIntent getPendingIntentForSendingMessageToConversation(final Context context, public PendingIntent getPendingIntentForSendingMessageToConversation(final Context context,
final String conversationId, final String selfId, final boolean requiresMms, 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); final Intent intent = new Intent(context, RemoteInputEntrypointActivity.class);
intent.setAction(Intent.ACTION_SENDTO); intent.setAction(Intent.ACTION_SENDTO);
// Ensure that the platform doesn't reuse PendingIntents across conversations // 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_CONVERSATION_ID, conversationId);
intent.putExtra(UIIntents.UI_INTENT_EXTRA_SELF_ID, selfId); intent.putExtra(UIIntents.UI_INTENT_EXTRA_SELF_ID, selfId);
intent.putExtra(UIIntents.UI_INTENT_EXTRA_REQUIRES_MMS, requiresMms); 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); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
return getPendingIntentWithParentStack(context, intent, requestCode); return getPendingIntentWithParentStack(context, intent, requestCode);
} }
@@ -24,148 +24,268 @@ import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.provider.Settings; import android.provider.Settings;
import android.text.TextUtils; import android.text.TextUtils;
import android.view.Menu; import android.view.LayoutInflater;
import android.view.MenuItem; import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts; 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.R;
import com.android.messaging.ui.BugleActionBarActivity; import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.LicenseActivity; import com.android.messaging.ui.LicenseActivity;
import com.android.messaging.ui.UIIntents; 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.BuglePrefs;
import com.android.messaging.util.ChangeDefaultSmsAppHelper; import com.android.messaging.util.ChangeDefaultSmsAppHelper;
import com.android.messaging.util.PhoneUtils; import com.android.messaging.util.PhoneUtils;
import com.google.android.material.bottomsheet.BottomSheetDialog;
public class ApplicationSettingsActivity extends BugleActionBarActivity { 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 @Override
protected void onCreate(final Bundle savedInstanceState) { protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.home_settings_activity);
getSupportActionBar().setDisplayHomeAsUpEnabled(true); if (getSupportActionBar() != null) {
final boolean topLevel = getIntent().getBooleanExtra( getSupportActionBar().hide();
UIIntents.UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS, false);
if (topLevel) {
getSupportActionBar().setTitle(getString(R.string.settings_activity_title));
} }
HomeSubHeader.bind(this, R.string.home_settings_title, v -> finish());
FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); mSmsRow = findViewById(R.id.home_settings_sms);
ft.replace(android.R.id.content, new ApplicationSettingsFragment()); mLanguageRow = findViewById(R.id.home_settings_language);
ft.commit(); mLanguageTag = BuglePrefs.getApplicationPrefs().getString(
getString(R.string.language_pref_key), LANGUAGE_SYSTEM);
bindStaticRows();
updateSmsRow();
} }
@Override @Override
public boolean onCreateOptionsMenu(Menu menu) { protected void onResume() {
if (super.onCreateOptionsMenu(menu)) { super.onResume();
return true; updateSmsRow();
} bindAppLockRow();
getMenuInflater().inflate(R.menu.settings_menu, menu);
return true;
} }
@Override @Override
public boolean onOptionsItemSelected(@NonNull final MenuItem item) { public void finish() {
int itemId = item.getItemId(); super.finish();
if (itemId == android.R.id.home) { overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
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 static class ApplicationSettingsFragment extends PreferenceFragmentCompat { private void bindStaticRows() {
final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
private String mNotificationsPreferenceKey; bindComingSoon(R.id.home_settings_premium, R.drawable.ic_home_star_line,
private String mSmsEnabledPrefKey; R.string.home_settings_premium, R.string.home_settings_premium_subtitle);
private Preference mSmsEnabledPreference;
private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper; HomeSettingsRow.bind(findViewById(R.id.home_settings_notifications),
private final ActivityResultLauncher<Intent> mLauncher = registerForActivityResult( R.drawable.ic_home_bell,
new ActivityResultContracts.StartActivityForResult(), result -> { getString(R.string.home_settings_notifications),
if (result.getResultCode() == Activity.RESULT_OK) { getString(R.string.home_settings_notifications_subtitle),
if (mChangeDefaultSmsAppHelper == null) { true, false, false,
mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper(); v -> {
} final Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS);
mChangeDefaultSmsAppHelper.handleChangeDefaultSmsResult( intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName());
REQUEST_SET_DEFAULT_SMS_APP, result.getResultCode(), null); 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);
} }
}); }, null);
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();
}
} }
} }
@@ -16,12 +16,13 @@
*/ */
package com.android.messaging.ui.appsettings; package com.android.messaging.ui.appsettings;
import android.app.AlertDialog;
import android.content.Context; import android.content.Context;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.widget.RadioButton; import android.widget.RadioButton;
import androidx.appcompat.app.AlertDialog;
import com.android.messaging.R; import com.android.messaging.R;
import com.android.messaging.util.Assert; import com.android.messaging.util.Assert;
import com.android.messaging.util.BuglePrefs; import com.android.messaging.util.BuglePrefs;
@@ -48,9 +49,9 @@ public class GroupMmsSettingDialog {
private void show() { private void show() {
Assert.isNull(mDialog); Assert.isNull(mDialog);
mDialog = new AlertDialog.Builder(mContext, R.style.AlertDialogTheme) mDialog = new AlertDialog.Builder(mContext, R.style.Home_Dialog)
.setView(createView()) .setView(createView())
.setTitle(R.string.group_mms_pref_title) .setTitle(R.string.home_settings_group_mms)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show(); .show();
} }
@@ -17,174 +17,218 @@
package com.android.messaging.ui.appsettings; 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.os.Bundle;
import android.text.TextUtils; 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.appcompat.app.AlertDialog;
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 com.android.messaging.Factory;
import com.android.messaging.R; import com.android.messaging.R;
import com.android.messaging.datamodel.ParticipantRefresh; import com.android.messaging.datamodel.ParticipantRefresh;
import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsConfig; import com.android.messaging.sms.MmsConfig;
import com.android.messaging.ui.BugleActionBarActivity; import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.UIIntents; 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.BuglePrefs;
import com.android.messaging.util.PhoneUtils; import com.android.messaging.util.PhoneUtils;
public class PerSubscriptionSettingsActivity extends BugleActionBarActivity { public class PerSubscriptionSettingsActivity extends BugleActionBarActivity {
private int mSubId;
private BuglePrefs mPrefs;
private String mDefaultPhoneNumber;
private View mGroupRow;
private View mPhoneRow;
private View mRoamingRow;
@Override @Override
protected void onCreate(final Bundle savedInstanceState) { protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.home_settings_advanced_activity);
getSupportActionBar().setDisplayHomeAsUpEnabled(true); if (getSupportActionBar() != null) {
final String title = getIntent().getStringExtra( getSupportActionBar().hide();
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.
} }
final FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); mSubId = getIntent().getIntExtra(UIIntents.UI_INTENT_EXTRA_SUB_ID,
final PerSubscriptionSettingsFragment fragment = new PerSubscriptionSettingsFragment(); ParticipantData.DEFAULT_SELF_SUB_ID);
ft.replace(android.R.id.content, fragment); mPrefs = BuglePrefs.getSubscriptionPrefs(mSubId);
ft.commit(); 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 @Override
public boolean onOptionsItemSelected(@NonNull final MenuItem item) { protected void onResume() {
if (item.getItemId() == android.R.id.home) { super.onResume();
NavUtils.navigateUpFromSameTask(this); updateGroupRow();
return true; updatePhoneRow();
}
return super.onOptionsItemSelected(item);
} }
public static class PerSubscriptionSettingsFragment extends PreferenceFragmentCompat @Override
implements OnSharedPreferenceChangeListener { public void finish() {
private PhoneNumberPreference mPhoneNumberPreference; super.finish();
private Preference mGroupMmsPreference; overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
private String mGroupMmsPrefKey; }
private String mPhoneNumberKey;
private int mSubId;
public PerSubscriptionSettingsFragment() { private void bindRows() {
// Required empty constructor 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 updatePhoneRow();
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;
final BuglePrefs subPrefs = Factory.get().getSubscriptionPrefs(mSubId); final String autoKey = getString(R.string.auto_retrieve_mms_pref_key);
getPreferenceManager().setSharedPreferencesName(subPrefs.getSharedPreferencesName()); final boolean autoOn = mPrefs.getBoolean(autoKey,
addPreferencesFromResource(R.xml.preferences_per_subscription); getResources().getBoolean(R.bool.auto_retrieve_mms_pref_default));
HomeSettingsRow.bind(findViewById(R.id.home_settings_auto_retrieve),
mPhoneNumberKey = getString(R.string.mms_phone_number_pref_key); R.drawable.ic_home_file_line,
mPhoneNumberPreference = findPreference(mPhoneNumberKey); getString(R.string.home_settings_auto_retrieve),
final PreferenceCategory advancedCategory = getString(R.string.home_settings_auto_retrieve_subtitle),
findPreference(getString(R.string.advanced_category_pref_key)); false, true, autoOn, null,
final PreferenceCategory mmsCategory = (button, checked) -> {
findPreference(getString(R.string.mms_messaging_category_pref_key)); mPrefs.putBoolean(autoKey, checked);
updateRoamingEnabled(checked && defaultSms);
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;
}); });
updateGroupMmsPrefSummary(); HomeSettingsRow.setEnabled(findViewById(R.id.home_settings_auto_retrieve), defaultSms);
}
if (!MmsConfig.get(mSubId).getSMSDeliveryReportsEnabled()) { final String roamingKey = getString(R.string.auto_retrieve_mms_when_roaming_pref_key);
final Preference deliveryReportsPref = findPreference( final boolean roamingOn = mPrefs.getBoolean(roamingKey,
getString(R.string.delivery_reports_pref_key)); getResources().getBoolean(R.bool.auto_retrieve_mms_when_roaming_pref_default));
advancedCategory.removePreference(deliveryReportsPref); 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 if (deliveryEnabled) {
// above first so that the user sees the correct information on the screen final String deliveryKey = getString(R.string.delivery_reports_pref_key);
if (!PhoneUtils.getDefault().isDefaultSmsApp()) { final boolean deliveryOn = mPrefs.getBoolean(deliveryKey,
mGroupMmsPreference.setEnabled(false); getResources().getBoolean(R.bool.delivery_reports_pref_default));
final Preference autoRetrieveMmsPreference = HomeSettingsRow.bind(findViewById(R.id.home_settings_delivery_reports),
findPreference(getString(R.string.auto_retrieve_mms_pref_key)); R.drawable.ic_home_checklist,
autoRetrieveMmsPreference.setEnabled(false); getString(R.string.home_settings_delivery_reports),
final Preference deliveryReportsPreference = getString(R.string.home_settings_delivery_reports_subtitle),
findPreference(getString(R.string.delivery_reports_pref_key)); false, true, deliveryOn, null,
if (deliveryReportsPreference != null) { (button, checked) -> mPrefs.putBoolean(deliveryKey, checked));
deliveryReportsPreference.setEnabled(false); 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) { private void updateGroupRow() {
getPreferenceScreen().removePreference(advancedCategory); 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);
} }
} }
ParticipantRefresh.refreshSelfParticipants();
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);
}
} }
} }
@@ -17,20 +17,11 @@
package com.android.messaging.ui.appsettings; package com.android.messaging.ui.appsettings;
import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.view.ViewGroup; 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 androidx.loader.app.LoaderManager;
import com.android.messaging.R; 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.datamodel.data.SettingsData.SettingsItem;
import com.android.messaging.ui.BugleActionBarActivity; import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.UIIntents; 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.Assert;
import com.android.messaging.util.PhoneUtils; 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 * Multi-SIM settings hub. Single-SIM devices skip this and open ApplicationSettingsActivity.
* (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).
*/ */
public class SettingsActivity extends BugleActionBarActivity { public class SettingsActivity extends BugleActionBarActivity implements SettingsDataListener {
private final Binding<SettingsData> mBinding = BindingBase.createBinding(this);
private ViewGroup mList;
@Override @Override
protected void onCreate(final Bundle savedInstanceState) { protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(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) { if (PhoneUtils.getDefault().getActiveSubscriptionCount() <= 1) {
UIIntents.get().launchApplicationSettingsActivity(this, true /* topLevel */); UIIntents.get().launchApplicationSettingsActivity(this, true /* topLevel */);
finish(); finish();
} else { overridePendingTransition(0, 0);
getSupportFragmentManager().beginTransaction() return;
.replace(android.R.id.content, new SettingsFragment()) }
.commit();
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 @Override
public boolean onOptionsItemSelected(@NonNull final MenuItem item) { public void finish() {
if (item.getItemId() == android.R.id.home) { super.finish();
NavUtils.navigateUpFromSameTask(this); overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close);
return true;
}
return super.onOptionsItemSelected(item);
} }
public static class SettingsFragment extends Fragment implements SettingsDataListener { @Override
private ListView mListView; public void onSelfParticipantDataLoaded(final SettingsData data) {
private SettingsListAdapter mAdapter; mBinding.ensureBound(data);
private final Binding<SettingsData> mBinding = BindingBase.createBinding(this); if (mList == null) {
return;
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding.bind(DataModel.get().createSettingsData(getActivity(), this));
mBinding.getData().init(LoaderManager.getInstance(this), mBinding);
} }
mList.removeAllViews();
@Override final LayoutInflater inflater = LayoutInflater.from(this);
public View onCreateView(final LayoutInflater inflater, final ViewGroup container, boolean first = true;
final Bundle savedInstanceState) { for (final SettingsItem item : data.getSettingsItems()) {
final View view = inflater.inflate(R.layout.settings_fragment, container, false); final View row = inflater.inflate(R.layout.home_settings_row, mList, false);
mListView = view.findViewById(android.R.id.list); if (!first) {
mAdapter = new SettingsListAdapter(getActivity()); final ViewGroup.MarginLayoutParams lp =
mListView.setAdapter(mAdapter); (ViewGroup.MarginLayoutParams) row.getLayoutParams();
return view; lp.topMargin = getResources().getDimensionPixelSize(R.dimen.looply_space_xs);
} row.setLayoutParams(lp);
@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<>());
} }
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) { private void openItem(final SettingsItem item) {
clear(); switch (item.getType()) {
addAll(newList); case SettingsItem.TYPE_GENERAL_SETTINGS:
notifyDataSetChanged(); UIIntents.get().launchApplicationSettingsActivity(this, false /* topLevel */);
} overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold);
break;
@NonNull case SettingsItem.TYPE_PER_SUBSCRIPTION_SETTINGS:
@Override UIIntents.get().launchPerSubscriptionSettingsActivity(this,
public View getView(final int position, final View convertView, item.getSubId(), item.getActivityTitle());
@NonNull final ViewGroup parent) { overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold);
View itemView; break;
if (convertView != null) { default:
itemView = convertView; Assert.fail("unrecognized setting type!");
} else { break;
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;
}
} }
} }
} }
@@ -20,6 +20,7 @@ import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import android.provider.ContactsContract.CommonDataKinds.Phone; import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.text.TextUtils;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.view.View; import android.view.View;
import android.view.View.OnClickListener; 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.ContactListItemData;
import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.ui.ContactIconView; import com.android.messaging.ui.ContactIconView;
import com.android.messaging.ui.home.HomeAvatarOverlay;
import com.android.messaging.util.Assert; import com.android.messaging.util.Assert;
import com.android.messaging.util.AvatarUriUtil; import com.android.messaging.util.AvatarUriUtil;
@@ -43,6 +45,10 @@ public class ContactListItemView extends LinearLayout implements OnClickListener
public interface HostInterface { public interface HostInterface {
void onContactListItemClicked(ContactListItemData item, ContactListItemView view); void onContactListItemClicked(ContactListItemData item, ContactListItemView view);
boolean isContactSelected(ContactListItemData item); boolean isContactSelected(ContactListItemData item);
default boolean isContactSelectionMode() {
return false;
}
} }
final ContactListItemData mData; final ContactListItemData mData;
@@ -53,9 +59,19 @@ public class ContactListItemView extends LinearLayout implements OnClickListener
private ContactIconView mContactIconView; private ContactIconView mContactIconView;
private ImageView mContactCheckmarkView; private ImageView mContactCheckmarkView;
private ImageView mWorkProfileIcon; private ImageView mWorkProfileIcon;
private View mSectionCard;
private View mSectionDivider;
private View mSectionGapTop;
private View mSectionGapBottom;
private HostInterface mHostInterface; private HostInterface mHostInterface;
private boolean mShouldShowAlphabetHeader; 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) { public ContactListItemView(final Context context, final AttributeSet attrs) {
super(context, attrs); super(context, attrs);
mData = DataModel.get().createContactListItemData(); mData = DataModel.get().createContactListItemData();
@@ -71,6 +87,55 @@ public class ContactListItemView extends LinearLayout implements OnClickListener
mContactIconView = findViewById(R.id.contact_icon); mContactIconView = findViewById(R.id.contact_icon);
mContactCheckmarkView = findViewById(R.id.contact_checkmark); mContactCheckmarkView = findViewById(R.id.contact_checkmark);
mWorkProfileIcon = findViewById(R.id.work_profile_icon); 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(), mContactIconView.setImageResourceUri(avatarUri, mData.getContactId(),
mData.getLookupKey(), destinationString); mData.getLookupKey(), destinationString);
mContactIconView.setVisibility(VISIBLE); mContactIconView.setVisibility(VISIBLE);
mContactCheckmarkView.setVisibility(GONE); applySelectionCheck(isContactChecked());
bindAvatarClickTargets();
mContactDetailTypeTextView.setVisibility(GONE); mContactDetailTypeTextView.setVisibility(GONE);
mContactDetailsTextView.setVisibility(GONE); mContactDetailsTextView.setVisibility(GONE);
mContactNameTextView.setVisibility(VISIBLE); mContactNameTextView.setVisibility(VISIBLE);
@@ -142,7 +208,8 @@ public class ContactListItemView extends LinearLayout implements OnClickListener
mContactNameTextView.setVisibility(VISIBLE); mContactNameTextView.setVisibility(VISIBLE);
final boolean isSelected = mHostInterface.isContactSelected(mData); final boolean isSelected = mHostInterface.isContactSelected(mData);
setSelected(isSelected); setSelected(isSelected);
mContactCheckmarkView.setVisibility(isSelected ? VISIBLE : GONE); applySelectionCheck(isSelected);
bindAvatarClickTargets();
mContactDetailsTextView.setVisibility(VISIBLE); mContactDetailsTextView.setVisibility(VISIBLE);
mContactDetailTypeTextView.setVisibility(VISIBLE); mContactDetailTypeTextView.setVisibility(VISIBLE);
} else { } else {
@@ -151,7 +218,8 @@ public class ContactListItemView extends LinearLayout implements OnClickListener
mContactNameTextView.setVisibility(GONE); mContactNameTextView.setVisibility(GONE);
final boolean isSelected = mHostInterface.isContactSelected(mData); final boolean isSelected = mHostInterface.isContactSelected(mData);
setSelected(isSelected); setSelected(isSelected);
mContactCheckmarkView.setVisibility(isSelected ? VISIBLE : GONE); applySelectionCheck(isSelected);
bindAvatarClickTargets();
mContactDetailsTextView.setVisibility(VISIBLE); mContactDetailsTextView.setVisibility(VISIBLE);
mContactDetailTypeTextView.setVisibility(VISIBLE); mContactDetailTypeTextView.setVisibility(VISIBLE);
} }
@@ -160,14 +228,44 @@ public class ContactListItemView extends LinearLayout implements OnClickListener
} else { } else {
mWorkProfileIcon.setVisibility(GONE); mWorkProfileIcon.setVisibility(GONE);
} }
if (mShouldShowAlphabetHeader) { final String headerLabel = mData.getAlphabetHeader();
if (mShouldShowAlphabetHeader && !TextUtils.isEmpty(headerLabel)
&& !TextUtils.equals(headerLabel, " ")) {
mAlphabetHeaderTextView.setVisibility(VISIBLE); mAlphabetHeaderTextView.setVisibility(VISIBLE);
mAlphabetHeaderTextView.setText(mData.getAlphabetHeader()); mAlphabetHeaderTextView.setText(headerLabel);
} else { } else {
mAlphabetHeaderTextView.setVisibility(GONE); 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 * {@inheritDoc} from OnClickListener
*/ */
@@ -291,6 +291,13 @@ public class ContactPickerFragment extends Fragment implements ContactPickerData
item.getRecipientEntry().getDestination())); 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 * 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). * 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. // etc. will take the spot of the action bar.
actionBar.hide(); actionBar.hide();
UiUtils.setStatusBarColor(getActivity(), UiUtils.setStatusBarColor(getActivity(),
getResources().getColor(R.color.looply_bg, getActivity().getTheme())); getResources().getColor(R.color.home_bg, getActivity().getTheme()));
} }
private GetOrCreateConversationActionMonitor mMonitor; private GetOrCreateConversationActionMonitor mMonitor;
@@ -118,7 +118,7 @@ public class ContactSectionIndexer implements SectionIndexer {
if (TextUtils.isEmpty(mSections[i])) { if (TextUtils.isEmpty(mSections[i])) {
mSections[i] = BLANK_HEADER_STRING; mSections[i] = BLANK_HEADER_STRING;
} else if (!mSections[i].equals(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); 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 // 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 // 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. // a slightly more sophisticated heuristic, but as a fallback this is good enough.
final String sortKey = cursor.getString(ContactUtil.INDEX_SORT_KEY); String sortKey = cursor.getString(ContactUtil.INDEX_SORT_KEY);
final String section = TextUtils.isEmpty(sortKey) ? BLANK_HEADER_STRING : if (TextUtils.isEmpty(sortKey)) {
sortKey.substring(0, 1).toUpperCase(); sortKey = cursor.getString(ContactUtil.INDEX_DISPLAY_NAME);
}
final String section = sectionLabelFor(sortKey);
final int lastIndex = sections.size() - 1; final int lastIndex = sections.size() - 1;
final String currentSection = lastIndex >= 0 ? sections.get(lastIndex) : null; final String currentSection = lastIndex >= 0 ? sections.get(lastIndex) : null;
@@ -158,6 +160,94 @@ public class ContactSectionIndexer implements SectionIndexer {
sections.toArray(mSections); 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) { private void buildIndexer(final Cursor cursor) {
// First check if we get indexer label extras from the contact provider; if not, fall back // First check if we get indexer label extras from the contact provider; if not, fall back
// to building from display names. // to building from display names.
@@ -16,7 +16,9 @@
*/ */
package com.android.messaging.ui.conversation; package com.android.messaging.ui.conversation;
import android.app.Activity;
import android.content.Context; import android.content.Context;
import android.content.Intent;
import android.content.res.Resources; import android.content.res.Resources;
import android.graphics.Rect; import android.graphics.Rect;
import android.net.Uri; import android.net.Uri;
@@ -34,18 +36,25 @@ import android.text.format.Formatter;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.view.ContextThemeWrapper; import android.view.ContextThemeWrapper;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View; import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityEvent;
import android.view.inputmethod.EditorInfo; import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.android.messaging.Factory; import com.android.messaging.Factory;
import com.android.messaging.R; import com.android.messaging.R;
import com.android.messaging.datamodel.ScheduledMessageStore;
import com.android.messaging.datamodel.binding.Binding; import com.android.messaging.datamodel.binding.Binding;
import com.android.messaging.datamodel.binding.BindingBase; import com.android.messaging.datamodel.binding.BindingBase;
import com.android.messaging.datamodel.binding.ImmutableBindingRef; 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.BugleActionBarActivity;
import com.android.messaging.ui.PlainTextEditText; import com.android.messaging.ui.PlainTextEditText;
import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputSink; 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.AccessibilityUtil;
import com.android.messaging.util.ImageUtils;
import com.android.messaging.util.ImeUtil;
import com.android.messaging.util.Assert; import com.android.messaging.util.Assert;
import com.android.messaging.util.AvatarUriUtil; import com.android.messaging.util.AvatarUriUtil;
import com.android.messaging.util.BuglePrefs; import com.android.messaging.util.BuglePrefs;
@@ -102,6 +118,10 @@ public class ComposeMessageView extends LinearLayout
boolean tooManyVideos); boolean tooManyVideos);
void notifyOfAttachmentLoadFailed(); void notifyOfAttachmentLoadFailed();
void showAttachmentChooser(); void showAttachmentChooser();
void launchSystemGalleryPicker();
void requestMediaPermission(Runnable onGranted);
void requestLocationPermission(Runnable onGranted);
void launchLocationShare();
boolean shouldShowSubjectEditor(); boolean shouldShowSubjectEditor();
boolean shouldHideAttachmentsWhenSimSelectorShown(); boolean shouldHideAttachmentsWhenSimSelectorShown();
Uri getSelfSendButtonIconUri(); Uri getSelfSendButtonIconUri();
@@ -110,6 +130,7 @@ public class ComposeMessageView extends LinearLayout
} }
public static final int CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN = 10; 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 // There is no draft and there is no need for the SIM selector
private static final int SEND_WIDGET_MODE_SELF_AVATAR = 1; private static final int SEND_WIDGET_MODE_SELF_AVATAR = 1;
@@ -128,6 +149,17 @@ public class ComposeMessageView extends LinearLayout
private ImageButton mDeleteSubjectButton; private ImageButton mDeleteSubjectButton;
private AttachmentPreview mAttachmentPreview; private AttachmentPreview mAttachmentPreview;
private ImageButton mAttachMediaButton; 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 final Binding<DraftMessageData> mBinding;
private IComposeMessageViewHost mHost; private IComposeMessageViewHost mHost;
@@ -199,14 +231,24 @@ public class ComposeMessageView extends LinearLayout
mComposeEditText.setOnEditorActionListener(this); mComposeEditText.setOnEditorActionListener(this);
mComposeEditText.addTextChangedListener(this); mComposeEditText.addTextChangedListener(this);
mComposeEditText.setOnFocusChangeListener((v, hasFocus) -> { mComposeEditText.setOnFocusChangeListener((v, hasFocus) -> {
if (mAttachMediaButton != null) {
mAttachMediaButton.setSelected(hasFocus);
}
if (v == mComposeEditText && hasFocus) { if (v == mComposeEditText && hasFocus) {
mHost.onComposeEditTextFocused(); if (mHost != null) {
mHost.onComposeEditTextFocused();
}
showComposeKeyboard();
} }
}); });
mComposeEditText.setOnClickListener(arg0 -> { mComposeEditText.setOnTouchListener((v, event) -> {
if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) { if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
hideSimSelector(); if (mHost != null && mHost.shouldHideAttachmentsWhenSimSelectorShown()) {
hideSimSelector();
}
showComposeKeyboard();
} }
return false;
}); });
// onFinishInflate() is called before self is loaded from db. We set the default text // 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 = findViewById(R.id.attach_media_button);
mAttachMediaButton.setOnClickListener(clickView -> { mAttachmentSheetContainer = findViewById(R.id.home_compose_attachment_sheet_container);
// Showing the media picker is treated as starting to compose the message. mComposeBottomStack = findViewById(R.id.home_compose_bottom_stack);
mInputManager.showHideMediaPicker(true /* show */, true /* animate */); 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 = findViewById(R.id.attachment_draft_view);
mAttachmentPreview.setComposeMessageView(this); mAttachmentPreview.setComposeMessageView(this);
@@ -368,6 +453,9 @@ public class ComposeMessageView extends LinearLayout
mBinding.ensureBound(data); mBinding.ensureBound(data);
switch (result) { switch (result) {
case CheckDraftForSendTask.RESULT_PASSED: case CheckDraftForSendTask.RESULT_PASSED:
if (queueScheduledMessageIfNeeded()) {
break;
}
// Continue sending after check succeeded. // Continue sending after check succeeded.
final MessageData message = mBinding.getData() final MessageData message = mBinding.getData()
.prepareMessageForSending(mBinding); .prepareMessageForSending(mBinding);
@@ -537,11 +625,39 @@ public class ComposeMessageView extends LinearLayout
@Override @Override
public void resumeComposeMessage() { public void resumeComposeMessage() {
mComposeEditText.requestFocus(); showComposeKeyboard();
mInputManager.showHideImeKeyboard(true, true);
announceAttachmentState(); 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() { public void clearAttachments() {
mBinding.getData().clearAttachments(mHost.getAttachmentsClearedFlags()); mBinding.getData().clearAttachments(mHost.getAttachmentsClearedFlags());
mHost.onAttachmentsCleared(); mHost.onAttachmentsCleared();
@@ -666,7 +782,7 @@ public class ComposeMessageView extends LinearLayout
mMessageBodySize.setVisibility(View.VISIBLE); mMessageBodySize.setVisibility(View.VISIBLE);
} }
} else { } else {
mMessageBodySize.setVisibility(View.INVISIBLE); mMessageBodySize.setVisibility(View.GONE);
} }
} else { // SMS case } else { // SMS case
// Update the SMS text counter. // Update the SMS text counter.
@@ -685,7 +801,7 @@ public class ComposeMessageView extends LinearLayout
mMessageBodySize.setText(counterText); mMessageBodySize.setText(counterText);
mMessageBodySize.setVisibility(View.VISIBLE); mMessageBodySize.setVisibility(View.VISIBLE);
} else { } 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; int sendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR;
if (selfSendButtonUri != null) { if (selfSendButtonUri != null) {
if (hasWorkingDraft && isDataLoadedForMessageSend()) { if (hasWorkingDraft && isDataLoadedForMessageSend()) {
UiUtils.revealOrHideViewWithAnimation(mSendButton, VISIBLE, null); hideSelfSendIcon();
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);
}
mMmsIndicator.setVisibility(draftMessageData.getIsMms() ? VISIBLE : INVISIBLE); mMmsIndicator.setVisibility(draftMessageData.getIsMms() ? VISIBLE : INVISIBLE);
sendWidgetMode = SEND_WIDGET_MODE_SEND_BUTTON; sendWidgetMode = SEND_WIDGET_MODE_SEND_BUTTON;
} else { } else {
mSelfSendIcon.setImageResourceUri(selfSendButtonUri); hideSelfSendIcon();
if (isOverriddenAvatarAGroup()) {
UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, VISIBLE, null);
}
UiUtils.revealOrHideViewWithAnimation(mSendButton, GONE, null);
mMmsIndicator.setVisibility(INVISIBLE); mMmsIndicator.setVisibility(INVISIBLE);
if (shouldShowSimSelector(mConversationDataModel.getData())) { if (shouldShowSimSelector(mConversationDataModel.getData())) {
sendWidgetMode = SEND_WIDGET_MODE_SIM_SELECTOR; sendWidgetMode = SEND_WIDGET_MODE_SIM_SELECTOR;
} }
} }
} else { } 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) { if (mSendWidgetMode != sendWidgetMode || sendWidgetMode == SEND_WIDGET_MODE_SIM_SELECTOR) {
setSendButtonAccessibility(sendWidgetMode); setSendButtonAccessibility(sendWidgetMode);
mSendWidgetMode = sendWidgetMode; mSendWidgetMode = sendWidgetMode;
} }
updateVoiceAndSendColumnVisibility(sendWidgetMode);
// Update the text hint on the message box depending on the attachment type. // Update the text hint on the message box depending on the attachment type.
final int attachmentCount = attachments.size(); 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) { private void setSendButtonAccessibility(final int sendWidgetMode) {
switch (sendWidgetMode) { switch (sendWidgetMode) {
case SEND_WIDGET_MODE_SELF_AVATAR: case SEND_WIDGET_MODE_SELF_AVATAR:
@@ -802,7 +938,6 @@ public class ComposeMessageView extends LinearLayout
// important for accessibility. // important for accessibility.
mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
mSelfSendIcon.setContentDescription(null); mSelfSendIcon.setContentDescription(null);
mSendButton.setVisibility(View.GONE);
setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SELF_AVATAR); setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SELF_AVATAR);
break; break;
@@ -920,18 +1055,56 @@ public class ComposeMessageView extends LinearLayout
} }
public void hideAllComposeInputs(final boolean animate) { public void hideAllComposeInputs(final boolean animate) {
if (mAttachmentSheetOpen) {
closeComposeAttachmentSheet(animate, null);
}
mInputManager.hideAllInputs(animate); mInputManager.hideAllInputs(animate);
} }
public void saveInputState(final Bundle outState) { public void saveInputState(final Bundle outState) {
mInputManager.onSaveInputState(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() { public void resetMediaPickerState() {
if (mAttachmentSheetOpen) {
closeComposeAttachmentSheet(false /* animate */, null);
}
mInputManager.resetMediaPickerState(); mInputManager.resetMediaPickerState();
} }
public boolean onBackPressed() { public boolean onBackPressed() {
if (HomeComposeAttachPanel.isShowing(resolveActivity())) {
return HomeComposeAttachPanel.onBackPressed(resolveActivity());
}
return mInputManager.onBackPressed(); return mInputManager.onBackPressed();
} }
@@ -960,12 +1133,13 @@ public class ComposeMessageView extends LinearLayout
mHost.notifyOfAttachmentLoadFailed(); mHost.notifyOfAttachmentLoadFailed();
} }
private boolean isOverriddenAvatarAGroup() { private void hideSelfSendIcon() {
final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri(); if (mSelfSendIcon == null) {
if (overridenSelfUri == null) { return;
return false;
} }
return AvatarUriUtil.TYPE_GROUP_URI.equals(AvatarUriUtil.getAvatarType(overridenSelfUri)); mSelfSendIcon.animate().cancel();
mSelfSendIcon.setVisibility(GONE);
mSelfSendIcon.setImageResourceUri(null);
} }
@Override @Override
@@ -982,4 +1156,264 @@ public class ComposeMessageView extends LinearLayout
mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); 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. // Tracks whether onPause is called.
private boolean mIsPaused; 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 @Override
protected void onCreate(final Bundle savedInstanceState) { protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(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 @Override
protected void onResume() { protected void onResume() {
super.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 // we need to reset the mInstanceStateSaved flag since we may have just been restored from
// a previous onStop() instead of an onDestroy(). // 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). * as the other components).
*/ */
public class ConversationInputManager implements ConversationInput.ConversationInputBase { 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 * The host component where all input components are contained. This is typically the
* conversation fragment but may be mocked in test code. * 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) { 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); showHideInternal(mMediaInput, show, animate);
} }
@@ -203,6 +224,16 @@ public class ConversationInputManager implements ConversationInput.ConversationI
} }
public void showHideImeKeyboard(final boolean show, final boolean animate) { 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); showHideInternal(mImeInput, show, animate);
} }
@@ -243,6 +274,9 @@ public class ConversationInputManager implements ConversationInput.ConversationI
} }
if (target.mShowing == show) { if (target.mShowing == show) {
if (show && target instanceof ConversationImeKeyboard) {
ImeUtil.get().showImeKeyboard(mContext, mSink.getComposeEditText());
}
return false; return false;
} }
beginUpdate(); beginUpdate();
@@ -322,6 +356,11 @@ public class ConversationInputManager implements ConversationInput.ConversationI
} }
private MediaPicker mMediaPicker; private MediaPicker mMediaPicker;
private int mOpenTarget = MEDIA_PICKER_TARGET_DEFAULT;
void setOpenTarget(final int openTarget) {
mOpenTarget = openTarget;
}
@Override @Override
public boolean show(boolean animate) { 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(); return isOpen();
} }
@@ -436,7 +500,7 @@ public class ConversationInputManager implements ConversationInput.ConversationI
mFragmentManager.beginTransaction().replace( mFragmentManager.beginTransaction().replace(
R.id.mediapicker_container, R.id.mediapicker_container,
mediaPicker, mediaPicker,
MediaPicker.FRAGMENT_TAG).commit(); MediaPicker.FRAGMENT_TAG).commitNowAllowingStateLoss();
} }
return mediaPicker; return mediaPicker;
} }
@@ -19,6 +19,7 @@ package com.android.messaging.ui.conversation;
import android.content.Context; import android.content.Context;
import android.content.res.Resources; import android.content.res.Resources;
import android.database.Cursor; import android.database.Cursor;
import android.graphics.Paint;
import android.graphics.Rect; import android.graphics.Rect;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.net.Uri; import android.net.Uri;
@@ -37,6 +38,7 @@ import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.WindowManager; import android.view.WindowManager;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.ImageView.ScaleType; import android.widget.ImageView.ScaleType;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.TextView; import android.widget.TextView;
@@ -87,6 +89,7 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
Rect imageBounds, boolean longPress); Rect imageBounds, boolean longPress);
SubscriptionListEntry getSubscriptionEntryForSelfParticipant(String selfParticipantId, SubscriptionListEntry getSubscriptionEntryForSelfParticipant(String selfParticipantId,
boolean excludeDefault); boolean excludeDefault);
void onMessageSendOptionsClick(ConversationMessageView view);
} }
private final ConversationMessageData mData; private final ConversationMessageData mData;
@@ -98,6 +101,8 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
private boolean mMessageTextHasLinks; private boolean mMessageTextHasLinks;
private boolean mMessageHasYouTubeLink; private boolean mMessageHasYouTubeLink;
private TextView mStatusTextView; private TextView mStatusTextView;
private TextView mStatusActionView;
private ImageView mStatusIconView;
private TextView mTitleTextView; private TextView mTitleTextView;
private TextView mMmsInfoTextView; private TextView mMmsInfoTextView;
private LinearLayout mMessageTitleLayout; private LinearLayout mMessageTitleLayout;
@@ -114,6 +119,7 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
private boolean mOneOnOne; private boolean mOneOnOne;
private ConversationMessageViewHost mHost; private ConversationMessageViewHost mHost;
private String mOptimisticSendingMessageId;
public ConversationMessageView(final Context context, final AttributeSet attrs) { public ConversationMessageView(final Context context, final AttributeSet attrs) {
super(context, attrs); super(context, attrs);
@@ -143,6 +149,16 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
IgnoreLinkLongClickHelper.ignoreLinkLongClick(mMessageTextView, this); IgnoreLinkLongClickHelper.ignoreLinkLongClick(mMessageTextView, this);
mStatusTextView = findViewById(R.id.message_status); 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); mTitleTextView = findViewById(R.id.message_title);
mMmsInfoTextView = findViewById(R.id.mms_info); mMmsInfoTextView = findViewById(R.id.mms_info);
mMessageTitleLayout = findViewById(R.id.message_title_layout); mMessageTitleLayout = findViewById(R.id.message_title_layout);
@@ -244,6 +260,11 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
// Update our UI model // Update our UI model
mData.bind(cursor); mData.bind(cursor);
if (mOptimisticSendingMessageId != null
&& (!TextUtils.equals(mOptimisticSendingMessageId, mData.getMessageId())
|| !isOutgoingFailedStatus(mData.getStatus()))) {
mOptimisticSendingMessageId = null;
}
setSelected(TextUtils.equals(mData.getMessageId(), selectedMessageId)); setSelected(TextUtils.equals(mData.getMessageId(), selectedMessageId));
// Update text and image content for the view. // Update text and image content for the view.
@@ -272,6 +293,51 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
return mData; 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 * Returns whether we should show simplified visual style for the message view (i.e. hide the
* avatar and bubble arrow, reduce padding). * 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_YET_TO_SEND:
case MessageData.BUGLE_STATUS_OUTGOING_SENDING: case MessageData.BUGLE_STATUS_OUTGOING_SENDING:
case MessageData.BUGLE_STATUS_OUTGOING_RESENDING:
statusResId = R.string.message_status_sending; statusResId = R.string.message_status_sending;
break; break;
case MessageData.BUGLE_STATUS_OUTGOING_RESENDING:
case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY: case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
statusResId = R.string.message_status_send_retrying; statusText = getResources().getString(R.string.home_message_status_waiting);
break; break;
case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: 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: case MessageData.BUGLE_STATUS_OUTGOING_FAILED:
// don't show the error state unless we're the default sms app // 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 (PhoneUtils.getDefault().isDefaultSmsApp()) {
if (isSelected()) { statusText = getResources().getString(R.string.home_message_status_not_sent);
statusResId = R.string.message_status_resend;
} else {
statusResId = MmsUtils.mapRawStatusToErrorResourceId(
mData.getStatus(), mData.getRawTelephonyStatus());
}
break; break;
} }
// FALL THROUGH HERE // 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 // We set the text even if the view will be GONE for accessibility
mStatusTextView.setText(statusText); mStatusTextView.setText(statusText);
final boolean statusVisible = !TextUtils.isEmpty(statusText); final boolean statusVisible = !TextUtils.isEmpty(statusText);
if (statusVisible) { mStatusTextView.setVisibility(statusVisible ? View.VISIBLE : View.GONE);
mStatusTextView.setVisibility(View.VISIBLE);
} else { final boolean sendOptionsVisible = shouldShowSendOptions();
mStatusTextView.setVisibility(View.GONE); if (mStatusActionView != null) {
mStatusActionView.setVisibility(sendOptionsVisible ? View.VISIBLE : View.GONE);
}
if (mStatusIconView != null) {
mStatusIconView.setVisibility(
isOutgoingWaitingStatus() ? View.VISIBLE : View.GONE);
} }
final boolean deliveredBadgeVisible = final boolean deliveredBadgeVisible =
@@ -450,11 +520,11 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
} }
final boolean metadataVisible = senderNameVisible || statusVisible final boolean metadataVisible = senderNameVisible || statusVisible
|| deliveredBadgeVisible || simNameVisible; || sendOptionsVisible || deliveredBadgeVisible || simNameVisible;
mMessageMetadataView.setVisibility(metadataVisible ? View.VISIBLE : View.GONE); mMessageMetadataView.setVisibility(metadataVisible ? View.VISIBLE : View.GONE);
final boolean messageTextAndOrInfoVisible = titleVisible || subjectVisible final boolean messageTextAndOrInfoVisible = titleVisible || subjectVisible
|| mData.hasText() || metadataVisible; || mData.hasText();
mMessageTextAndInfoView.setVisibility( mMessageTextAndInfoView.setVisibility(
messageTextAndOrInfoVisible ? View.VISIBLE : View.GONE); messageTextAndOrInfoVisible ? View.VISIBLE : View.GONE);
@@ -697,7 +767,7 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
textBottomPadding = textBottomPaddingDefault; textBottomPadding = textBottomPaddingDefault;
textLeftPadding = messageTextLeftRightPadding; textLeftPadding = messageTextLeftRightPadding;
textRightPadding = messageTextLeftRightPadding; textRightPadding = messageTextLeftRightPadding;
mMessageTextView.setTextIsSelectable(isSelected()); mMessageTextView.setTextIsSelectable(false);
} else { } else {
// Attachment(s) only // Attachment(s) only
contentLeftPadding = incoming ? arrowWidth : 0; contentLeftPadding = incoming ? arrowWidth : 0;
@@ -723,7 +793,7 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
textTopMargin = 0; textTopMargin = 0;
textTopPadding = textTopPaddingDefault; textTopPadding = textTopPaddingDefault;
textBottomPadding = textBottomPaddingDefault; textBottomPadding = textBottomPaddingDefault;
mMessageTextView.setTextIsSelectable(isSelected()); mMessageTextView.setTextIsSelectable(false);
textLeftPadding = messageTextLeftRightPadding; textLeftPadding = messageTextLeftRightPadding;
textRightPadding = messageTextLeftRightPadding; textRightPadding = messageTextLeftRightPadding;
} }
@@ -760,6 +830,9 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
updateMessageAttachmentsAppearance(gravity); updateMessageAttachmentsAppearance(gravity);
mMessageMetadataView.setPadding(0, metadataTopPadding, 0, 0); mMessageMetadataView.setPadding(0, metadataTopPadding, 0, 0);
if (mMessageMetadataView.getVisibility() == View.VISIBLE) {
setPadding(getPaddingStart(), getPaddingTop(), getPaddingEnd(), metadataTopPadding);
}
updateTextAppearance(); updateTextAppearance();
@@ -817,6 +890,10 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
description.append(separator); description.append(separator);
description.append(mStatusTextView.getText()); description.append(mStatusTextView.getText());
} }
if (mStatusActionView != null && mStatusActionView.getVisibility() == View.VISIBLE) {
description.append(separator);
description.append(mStatusActionView.getText());
}
if (mSimNameView.getVisibility() == View.VISIBLE) { if (mSimNameView.getVisibility() == View.VISIBLE) {
description.append(separator); 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:
case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: 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; subjectLabelColorResId = R.color.timestamp_text_outgoing;
break; break;
@@ -964,10 +1045,30 @@ public class ConversationMessageView extends FrameLayout implements View.OnClick
mData.hasAttachments() && !shouldShowMessageTextBubble()) { mData.hasAttachments() && !shouldShowMessageTextBubble()) {
timestampColorResId = R.color.timestamp_text_outgoing; 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)); 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.content.res.Resources;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.view.ActionMode;
import android.view.View; import android.view.View;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts; import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@@ -36,14 +36,20 @@ import androidx.appcompat.app.ActionBar;
import com.android.messaging.R; import com.android.messaging.R;
import com.android.messaging.datamodel.action.DeleteConversationAction; import com.android.messaging.datamodel.action.DeleteConversationAction;
import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; 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.action.UpdateDestinationBlockedAction;
import com.android.messaging.datamodel.data.ConversationListData; import com.android.messaging.datamodel.data.ConversationListData;
import com.android.messaging.datamodel.data.ConversationListItemData; import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.ui.BugleActionBarActivity; import com.android.messaging.ui.BugleActionBarActivity;
import com.android.messaging.ui.SnackBar; import com.android.messaging.ui.SnackBar;
import com.android.messaging.ui.SnackBarInteraction; import com.android.messaging.ui.SnackBarInteraction;
import com.android.messaging.datamodel.action.MarkAsUnreadAction;
import com.android.messaging.ui.UIIntents; import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.contact.AddContactsConfirmationDialog; 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.ConversationListFragment.ConversationListFragmentHost;
import com.android.messaging.ui.conversationlist.MultiSelectActionModeCallback.SelectedConversation; import com.android.messaging.ui.conversationlist.MultiSelectActionModeCallback.SelectedConversation;
import com.android.messaging.util.ChangeDefaultSmsAppHelper; import com.android.messaging.util.ChangeDefaultSmsAppHelper;
@@ -61,6 +67,10 @@ import java.util.List;
public abstract class AbstractConversationListActivity extends BugleActionBarActivity public abstract class AbstractConversationListActivity extends BugleActionBarActivity
implements ConversationListFragmentHost, MultiSelectActionModeCallback.Listener { implements ConversationListFragmentHost, MultiSelectActionModeCallback.Listener {
private static final UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener
NOOP_BLOCK_LISTENER =
(action, success, block, destination) -> { };
protected ConversationListFragment mConversationListFragment; protected ConversationListFragment mConversationListFragment;
private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper; private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper;
@@ -90,22 +100,147 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
return false; 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() { protected void startMultiSelectActionMode() {
startActionMode(new MultiSelectActionModeCallback(this, useHomeSelectionChrome())); if (mConversationListFragment == null) {
mConversationListFragment.dismissFab(); return;
if (useHomeSelectionChrome()) { }
mConversationListFragment.setHomeSelectionMode(true); final boolean homeChrome = useHomeSelectionChrome();
if (homeChrome) {
hideHomeContextualActionBar(); hideHomeContextualActionBar();
mConversationListFragment.dismissFab(true);
}
startActionMode(new MultiSelectActionModeCallback(this, homeChrome));
if (homeChrome) {
mConversationListFragment.setHomeSelectionMode(true);
} else {
mConversationListFragment.dismissFab();
} }
} }
public void exitMultiSelectState() { public void exitMultiSelectState() {
dismissActionMode();
if (mConversationListFragment == null) {
return;
}
if (useHomeSelectionChrome()) { if (useHomeSelectionChrome()) {
mConversationListFragment.setHomeSelectionMode(false); mConversationListFragment.setHomeSelectionMode(false);
} else {
mConversationListFragment.updateUi();
} }
mConversationListFragment.showFab(); mConversationListFragment.showFab();
dismissActionMode();
mConversationListFragment.updateUi();
} }
protected void hideHomeContextualActionBar() { protected void hideHomeContextualActionBar() {
@@ -121,7 +256,149 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
} }
final MultiSelectActionModeCallback callback = final MultiSelectActionModeCallback callback =
(MultiSelectActionModeCallback) getActionModeCallback(); (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() { protected boolean isInConversationListSelectMode() {
@@ -133,23 +410,104 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
return isInConversationListSelectMode(); 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 @Override
public void onActionBarDelete(final Collection<SelectedConversation> conversations) { public void onActionBarDelete(final Collection<SelectedConversation> conversations) {
if (!PhoneUtils.getDefault().isDefaultSmsApp()) { if (!ensureDefaultSmsAppForDelete()) {
// 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 */);
return; return;
} }
@@ -158,14 +516,7 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
R.plurals.delete_conversations_confirmation_dialog_title, R.plurals.delete_conversations_confirmation_dialog_title,
conversations.size())) conversations.size()))
.setPositiveButton(R.string.delete_conversation_confirmation_button, .setPositiveButton(R.string.delete_conversation_confirmation_button,
(dialog, button) -> { (dialog, button) -> performDeleteConversations(conversations))
for (final SelectedConversation conversation : conversations) {
DeleteConversationAction.deleteConversation(
conversation.conversationId,
conversation.timestamp);
}
exitMultiSelectState();
})
.setNegativeButton(R.string.delete_conversation_decline_button, null) .setNegativeButton(R.string.delete_conversation_decline_button, null)
.show(); .show();
} }
@@ -256,7 +607,7 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
@Override @Override
public void onSelectionCountChanged(final int count) { public void onSelectionCountChanged(final int count) {
if (useHomeSelectionChrome()) { if (useHomeSelectionChrome()) {
mConversationListFragment.updateHomeSelectionDeleteEnabled(count); mConversationListFragment.updateHomeSelectionBar(count);
hideHomeContextualActionBar(); hideHomeContextualActionBar();
} }
} }
@@ -16,37 +16,143 @@
*/ */
package com.android.messaging.ui.conversationlist; package com.android.messaging.ui.conversationlist;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle; import android.os.Bundle;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.core.content.ContextCompat;
import com.android.messaging.R; 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 { 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 @Override
protected void onCreate(final Bundle savedInstanceState) { protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(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 = if (savedInstanceState == null) {
ConversationListFragment.createArchivedConversationListFragment(); mConversationListFragment =
getSupportFragmentManager() ConversationListFragment.createArchivedConversationListFragment();
.beginTransaction() getSupportFragmentManager()
.add(android.R.id.content, mConversationListFragment) .beginTransaction()
.commit(); .replace(R.id.home_archived_list_host, mConversationListFragment)
invalidateActionBar(); .commit();
} else {
mConversationListFragment = (ConversationListFragment) getSupportFragmentManager()
.findFragmentById(R.id.home_archived_list_host);
}
} }
protected void updateActionBar(ActionBar actionBar) { @Override
actionBar.setTitle(getString(R.string.archived_activity_title)); protected void onSaveInstanceState(final Bundle outState) {
actionBar.setDisplayShowTitleEnabled(true); super.onSaveInstanceState(outState);
actionBar.setDisplayHomeAsUpEnabled(true); outState.putBoolean(STATE_SHOW_RULES, mShowRules);
actionBar.setBackgroundDrawable(new ColorDrawable( }
getResources().getColor(
R.color.archived_conversation_action_bar_background_color_dark))); private void bindTabs() {
actionBar.show(); 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); super.updateActionBar(actionBar);
} }
@@ -60,7 +166,7 @@ public class ArchivedConversationListActivity extends AbstractConversationListAc
} }
@Override @Override
public boolean onOptionsItemSelected(MenuItem menuItem) { public boolean onOptionsItemSelected(final MenuItem menuItem) {
if (menuItem.getItemId() == android.R.id.home) { if (menuItem.getItemId() == android.R.id.home) {
onActionBarHome(); onActionBarHome();
return true; return true;
@@ -75,6 +181,12 @@ public class ArchivedConversationListActivity extends AbstractConversationListAc
@Override @Override
public boolean isSwipeAnimatable() { 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; package com.android.messaging.ui.conversationlist;
import android.os.Bundle; import android.os.Bundle;
import android.view.ActionMode; import android.window.SplashScreenView;
import android.view.Menu; import android.view.Menu;
import android.view.MenuItem; import android.view.MenuItem;
import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.appcompat.widget.PopupMenu; import androidx.appcompat.widget.PopupMenu;
@@ -36,6 +34,7 @@ public class ConversationListActivity extends AbstractConversationListActivity {
@Override @Override
protected void onCreate(final Bundle savedInstanceState) { protected void onCreate(final Bundle savedInstanceState) {
Trace.beginSection("ConversationListActivity.onCreate"); Trace.beginSection("ConversationListActivity.onCreate");
getSplashScreen().setOnExitAnimationListener(SplashScreenView::remove);
setTheme(R.style.BugleTheme_ConversationListActivity); setTheme(R.style.BugleTheme_ConversationListActivity);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
mConversationListFragment = ConversationListFragment.createConversationListFragment(null); mConversationListFragment = ConversationListFragment.createConversationListFragment(null);
@@ -52,20 +51,12 @@ public class ConversationListActivity extends AbstractConversationListActivity {
return true; return true;
} }
@Override
protected boolean shouldShowContextualActionBar(final ActionMode.Callback callback) {
if (callback instanceof MultiSelectActionModeCallback) {
return !((MultiSelectActionModeCallback) callback).useHomeSelectionChrome();
}
return true;
}
@Override @Override
protected void updateActionBar(final ActionBar actionBar) { protected void updateActionBar(final ActionBar actionBar) {
if (actionBar == null) { if (actionBar == null) {
return; return;
} }
// Custom Message+ chrome replaces the ActionBar on the home screen. // Custom Message Q chrome replaces the ActionBar on the home screen.
actionBar.hide(); actionBar.hide();
super.updateActionBar(actionBar); super.updateActionBar(actionBar);
} }
@@ -80,6 +71,9 @@ public class ConversationListActivity extends AbstractConversationListActivity {
public void onBackPressed() { public void onBackPressed() {
if (isInConversationListSelectMode()) { if (isInConversationListSelectMode()) {
exitMultiSelectState(); exitMultiSelectState();
} else if (mConversationListFragment != null
&& mConversationListFragment.onHomeBackPressed()) {
return;
} else { } else {
super.onBackPressed(); super.onBackPressed();
} }
@@ -146,13 +140,7 @@ public class ConversationListActivity extends AbstractConversationListActivity {
} }
public void showAutomateComingSoon() { public void showAutomateComingSoon() {
Toast.makeText(this, R.string.home_automate_coming_soon, Toast.LENGTH_SHORT).show(); UIIntents.get().launchHomeAutomationActivity(this);
}
public void enterHomeEditMode() {
if (!isInConversationListSelectMode()) {
startMultiSelectActionMode();
}
} }
@Override @Override
@@ -32,6 +32,10 @@ import androidx.recyclerview.widget.RecyclerView;
import com.android.messaging.R; import com.android.messaging.R;
import com.android.messaging.datamodel.data.ConversationListItemData; import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.ui.CursorRecyclerAdapter; 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 com.android.messaging.ui.home.PinnedConversationStore;
import java.util.ArrayList; import java.util.ArrayList;
@@ -49,14 +53,19 @@ public class ConversationListAdapter
public static final int FILTER_ALL = 0; public static final int FILTER_ALL = 0;
public static final int FILTER_UNREAD = 1; public static final int FILTER_UNREAD = 1;
public static final int FILTER_GROUPS = 2; 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_PINNED_HEADER = 1;
private static final int VIEW_TYPE_CONVERSATION = 2; private static final int VIEW_TYPE_CONVERSATION = 2;
private static final long PINNED_HEADER_ITEM_ID = -9001L; private static final long PINNED_HEADER_ITEM_ID = -9001L;
static final Object PAYLOAD_SELECTION_CHROME = new Object();
private final ConversationListItemView.HostInterface mClivHostInterface; private final ConversationListItemView.HostInterface mClivHostInterface;
private final ConversationListItemData mFilterProbe = new ConversationListItemData(); private final ConversationListItemData mFilterProbe = new ConversationListItemData();
private int mFilterMode = FILTER_ALL; private int mFilterMode = FILTER_ALL;
private String mCategoryId = HomeCategoryStore.ID_ALL;
private String mSearchQuery = "";
private int[] mDisplayPositions = new int[0]; private int[] mDisplayPositions = new int[0];
private View mPinnedStripRoot; private View mPinnedStripRoot;
private boolean mPinnedHeaderVisible; private boolean mPinnedHeaderVisible;
@@ -112,6 +121,93 @@ public class ConversationListAdapter
return mFilterMode; 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() { public void refreshPinOrdering() {
refreshPinOrderingAnimated(); refreshPinOrderingAnimated();
} }
@@ -237,16 +333,25 @@ public class ConversationListAdapter
} }
private boolean passesFilter() { private boolean passesFilter() {
if (mFilterMode == FILTER_ALL) { if (mFilterMode == FILTER_UNREAD && mFilterProbe.getIsRead()) {
return true; return false;
} }
if (mFilterMode == FILTER_UNREAD) { if (mFilterMode == FILTER_GROUPS && mFilterProbe.getParticipantCount() <= 1) {
return !mFilterProbe.getIsRead(); return false;
} }
if (mFilterMode == FILTER_GROUPS) { if (mFilterMode == FILTER_WORK && !HomeInboxFilter.matchesWork(mContext, mFilterProbe)) {
return mFilterProbe.getParticipantCount() > 1; 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) { private void sortPinnedFirst(final ArrayList<Integer> positions, final Cursor cursor) {
@@ -323,6 +428,22 @@ public class ConversationListAdapter
return createConversationViewHolder(parent); 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 @Override
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder,
final int position) { final int position) {
@@ -348,8 +469,8 @@ public class ConversationListAdapter
container.setLayoutParams(new RecyclerView.LayoutParams( container.setLayoutParams(new RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT)); ViewGroup.LayoutParams.WRAP_CONTENT));
container.setClipChildren(true); container.setClipChildren(false);
container.setClipToPadding(true); container.setClipToPadding(false);
return new PinnedHeaderViewHolder(container); return new PinnedHeaderViewHolder(container);
} }
@@ -20,14 +20,21 @@ import android.content.Context;
import android.content.res.Resources; import android.content.res.Resources;
import android.database.Cursor; import android.database.Cursor;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect; import android.graphics.Rect;
import android.graphics.Typeface; import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.net.Uri; import android.net.Uri;
import android.support.v7.mms.pdu.ContentType; 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.TextPaint;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.style.LeadingMarginSpan;
import android.telephony.PhoneNumberUtils;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.View; import android.view.View;
import android.view.View.OnClickListener; import android.view.View.OnClickListener;
import android.view.View.OnLayoutChangeListener; import android.view.View.OnLayoutChangeListener;
@@ -39,7 +46,6 @@ import android.widget.ImageButton;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import androidx.core.content.res.ResourcesCompat;
import androidx.core.text.BidiFormatter; import androidx.core.text.BidiFormatter;
import androidx.core.text.TextDirectionHeuristicsCompat; import androidx.core.text.TextDirectionHeuristicsCompat;
@@ -47,7 +53,9 @@ import com.android.messaging.Factory;
import com.android.messaging.R; import com.android.messaging.R;
import com.android.messaging.datamodel.MessagingContentProvider; import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; 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.ConversationListItemData;
import com.android.messaging.datamodel.data.ConversationMessageData;
import com.android.messaging.datamodel.data.MessageData; import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.media.UriImageRequestDescriptor; import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
import com.android.messaging.sms.MmsUtils; 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.ContactIconView;
import com.android.messaging.ui.SnackBar; import com.android.messaging.ui.SnackBar;
import com.android.messaging.ui.SnackBarInteraction; 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.Assert;
import com.android.messaging.util.ImageUtils; import com.android.messaging.util.ImageUtils;
import com.android.messaging.util.PhoneUtils; import com.android.messaging.util.PhoneUtils;
import com.android.messaging.util.SimNameUtil;
import com.android.messaging.util.Typefaces; import com.android.messaging.util.Typefaces;
import com.android.messaging.util.UiUtils; import com.android.messaging.util.UiUtils;
import com.android.messaging.util.UriUtil; import com.android.messaging.util.UriUtil;
@@ -72,8 +84,9 @@ import java.util.List;
*/ */
public class ConversationListItemView extends FrameLayout implements OnClickListener, public class ConversationListItemView extends FrameLayout implements OnClickListener,
OnLongClickListener, OnLayoutChangeListener { OnLongClickListener, OnLayoutChangeListener {
static final int UNREAD_SNIPPET_LINE_COUNT = 2; static final int UNREAD_SNIPPET_LINE_COUNT = 3;
static final int NO_UNREAD_SNIPPET_LINE_COUNT = 2; 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_DEFAULT_DP = 74;
private static final int LIST_DIVIDER_INSET_SELECTION_DP = 104; private static final int LIST_DIVIDER_INSET_SELECTION_DP = 104;
private int mListItemReadColor; private int mListItemReadColor;
@@ -87,6 +100,8 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
boolean isConversationSelected(final String conversationId); boolean isConversationSelected(final String conversationId);
void onConversationClicked(final ConversationListItemData conversationListItemData, void onConversationClicked(final ConversationListItemData conversationListItemData,
boolean isLongClick, final ConversationListItemView conversationView); boolean isLongClick, final ConversationListItemView conversationView);
void onConversationAvatarClicked(final ConversationListItemData conversationListItemData,
final View avatarView);
boolean isSwipeAnimatable(); boolean isSwipeAnimatable();
List<SnackBarInteraction> getSnackBarInteractions(); List<SnackBarInteraction> getSnackBarInteractions();
void startFullScreenPhotoViewer(final Uri initialPhoto, final Rect initialPhotoBounds, void startFullScreenPhotoViewer(final Uri initialPhoto, final Rect initialPhotoBounds,
@@ -96,6 +111,8 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
boolean shouldShowConversationPinButton(); boolean shouldShowConversationPinButton();
boolean isConversationPinned(final String conversationId); boolean isConversationPinned(final String conversationId);
void onConversationPinButtonClicked(final String conversationId); void onConversationPinButtonClicked(final String conversationId);
void onConversationSwipeMuteClicked(ConversationListItemData data);
void onConversationSwipeDeleteClicked(ConversationListItemData data);
} }
private final OnClickListener fullScreenPreviewClickListener = new OnClickListener() { private final OnClickListener fullScreenPreviewClickListener = new OnClickListener() {
@@ -142,8 +159,11 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
private ContactIconView mContactIconView; private ContactIconView mContactIconView;
private ImageView mContactCheckmarkView; private ImageView mContactCheckmarkView;
private ImageView mFailedStatusIconView; private ImageView mFailedStatusIconView;
private ImageView mMuteBadgeView;
private ImageView mCrossSwipeArchiveLeftImageView; private ImageView mCrossSwipeArchiveLeftImageView;
private ImageView mCrossSwipeArchiveRightImageView; private View mCrossSwipeRightActions;
private ImageView mCrossSwipeMuteView;
private ImageView mCrossSwipeDeleteView;
private AsyncImageView mImagePreviewView; private AsyncImageView mImagePreviewView;
private AudioAttachmentView mAudioAttachmentView; private AudioAttachmentView mAudioAttachmentView;
private ImageView mChevronView; private ImageView mChevronView;
@@ -153,6 +173,7 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
private static final int SELECTION_TOGGLE_ANIM_MS = 180; private static final int SELECTION_TOGGLE_ANIM_MS = 180;
private String mSelectionStateConversationId; private String mSelectionStateConversationId;
private Boolean mSelectionStateMode;
private Boolean mSelectionStateChecked; private Boolean mSelectionStateChecked;
public ConversationListItemView(final Context context, final AttributeSet attrs) { 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); mContactIconView = findViewById(R.id.conversation_icon);
mContactCheckmarkView = findViewById(R.id.conversation_checkmark); mContactCheckmarkView = findViewById(R.id.conversation_checkmark);
mFailedStatusIconView = findViewById(R.id.conversation_failed_status_icon); mFailedStatusIconView = findViewById(R.id.conversation_failed_status_icon);
mMuteBadgeView = findViewById(R.id.conversation_mute_badge);
mCrossSwipeArchiveLeftImageView = findViewById(R.id.crossSwipeArchiveIconLeft); 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); mImagePreviewView = findViewById(R.id.conversation_image_preview);
mAudioAttachmentView = findViewById(R.id.audio_attachment_view); mAudioAttachmentView = findViewById(R.id.audio_attachment_view);
mChevronView = findViewById(R.id.conversation_chevron); mChevronView = findViewById(R.id.conversation_chevron);
@@ -190,6 +230,9 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
mListDividerView = findViewById(R.id.conversation_list_divider); mListDividerView = findViewById(R.id.conversation_list_divider);
mConversationNameView.addOnLayoutChangeListener(this); mConversationNameView.addOnLayoutChangeListener(this);
mSnippetTextView.addOnLayoutChangeListener(this); mSnippetTextView.addOnLayoutChangeListener(this);
if (mCategoryChipView != null) {
mCategoryChipView.addOnLayoutChangeListener(this);
}
final Resources resources = getContext().getResources(); final Resources resources = getContext().getResources();
final Resources.Theme theme = getContext().getTheme(); final Resources.Theme theme = getContext().getTheme();
@@ -212,28 +255,32 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
setSnippet(); setSnippet();
} else if (v == mSubjectTextView) { } else if (v == mSubjectTextView) {
setSubject(); setSubject();
} else if (v == mCategoryChipView) {
alignSimBadgeToFirstSnippetLine();
} }
} }
private void setWorkProfileIcon() { private void setWorkProfileIcon() {
mWorkProfileIconView.setVisibility(mData.isEnterprise() ? View.VISIBLE : View.GONE); mWorkProfileIconView.setVisibility(mData.isEnterprise() ? View.VISIBLE : View.GONE);
if (mCategoryChipView != null) { setSimBadge();
final boolean selectionMode = }
mHostInterface != null && mHostInterface.isSelectionMode();
if (selectionMode) { private void setSimBadge() {
mCategoryChipView.setVisibility(View.GONE); if (mCategoryChipView == null) {
} else { return;
mCategoryChipView.setVisibility(View.VISIBLE);
mCategoryChipView.setText(mData.isEnterprise()
? R.string.home_chip_work
: R.string.home_chip_personal);
}
} }
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() { private void setConversationName() {
final boolean selectionMode =
mHostInterface != null && mHostInterface.isSelectionMode();
final boolean showUnreadDot = !mData.getIsRead() && !mData.getShowDraft(); final boolean showUnreadDot = !mData.getIsRead() && !mData.getShowDraft();
if (mUnreadDotSlotView != null) { if (mUnreadDotSlotView != null) {
mUnreadDotSlotView.setVisibility(View.VISIBLE); mUnreadDotSlotView.setVisibility(View.VISIBLE);
@@ -241,10 +288,7 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
if (mUnreadDotView != null) { if (mUnreadDotView != null) {
mUnreadDotView.setVisibility(showUnreadDot ? View.VISIBLE : View.INVISIBLE); mUnreadDotView.setVisibility(showUnreadDot ? View.VISIBLE : View.INVISIBLE);
} }
if (selectionMode) { if (mData.getIsRead() || mData.getShowDraft()) {
mConversationNameView.setTextColor(mListItemReadColor);
mConversationNameView.setTypeface(mListItemUnreadTypeface);
} else if (mData.getIsRead() || mData.getShowDraft()) {
mConversationNameView.setTextColor(mListItemReadColor); mConversationNameView.setTextColor(mListItemReadColor);
mConversationNameView.setTypeface(mListItemReadTypeface); mConversationNameView.setTypeface(mListItemReadTypeface);
} else { } else {
@@ -253,6 +297,8 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
} }
final String conversationName = mData.getName(); 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 // For group conversations, ellipsize the group members that do not fit
final CharSequence ellipsizedName = UiUtils.commaEllipsize( final CharSequence ellipsizedName = UiUtils.commaEllipsize(
@@ -270,6 +316,23 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
mConversationNameView.setText(bidiFormattedName); 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() { private static String getPlusOneString() {
if (sPlusOneString == null) { if (sPlusOneString == null) {
sPlusOneString = Factory.get().getApplicationContext().getResources() sPlusOneString = Factory.get().getApplicationContext().getResources()
@@ -287,100 +350,174 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
} }
private void setSubject() { private void setSubject() {
final String subjectText = mData.getShowDraft() ? if (mSubjectTextView != null) {
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 {
mSubjectTextView.setVisibility(GONE); mSubjectTextView.setVisibility(GONE);
} }
} }
private void setSnippet() { 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, private void updateSelectionIndicator(final boolean selectionMode, final boolean isSelected,
final Resources resources) { final Resources resources) {
if (mSelectionIndicatorView == null || mSelectionRingView == null if (mSelectionIndicatorView != null) {
|| mSelectionCheckView == null) {
return;
}
if (!selectionMode) {
mSelectionIndicatorView.animate().cancel(); mSelectionIndicatorView.animate().cancel();
mSelectionCheckView.animate().cancel();
mSelectionIndicatorView.setScaleX(1f);
mSelectionIndicatorView.setScaleY(1f);
mSelectionCheckView.setAlpha(1f);
mSelectionCheckView.setScaleX(1f);
mSelectionCheckView.setScaleY(1f);
mSelectionIndicatorView.setVisibility(GONE); mSelectionIndicatorView.setVisibility(GONE);
mSelectionStateConversationId = null; }
mSelectionStateChecked = null; if (mContactCheckmarkView == null) {
return; return;
} }
mSelectionIndicatorView.setVisibility(VISIBLE);
final String conversationId = mData.getConversationId(); final String conversationId = mData.getConversationId();
final boolean sameConversation = conversationId != null final boolean sameConversation = conversationId != null
&& conversationId.equals(mSelectionStateConversationId); && conversationId.equals(mSelectionStateConversationId);
final boolean animateToggle = sameConversation && mSelectionStateChecked != null final boolean wasMode = Boolean.TRUE.equals(mSelectionStateMode);
&& mSelectionStateChecked.booleanValue() != isSelected; final boolean wasSelected = Boolean.TRUE.equals(mSelectionStateChecked);
final boolean animateToggle = sameConversation
&& (wasMode != selectionMode || wasSelected != isSelected);
mSelectionStateConversationId = conversationId; mSelectionStateConversationId = conversationId;
mSelectionStateMode = selectionMode;
mSelectionStateChecked = isSelected; mSelectionStateChecked = isSelected;
mSelectionIndicatorView.animate().cancel(); mContactCheckmarkView.animate().cancel();
mSelectionCheckView.animate().cancel(); if (!selectionMode) {
if (animateToggle && mContactCheckmarkView.getVisibility() == VISIBLE) {
if (isSelected) { mContactCheckmarkView.animate()
mSelectionRingView.setBackground(resources.getDrawable( .alpha(0f)
R.drawable.home_bg_selection_ring_checked, getContext().getTheme())); .scaleX(0.6f)
mSelectionCheckView.setVisibility(VISIBLE); .scaleY(0.6f)
if (animateToggle) {
mSelectionIndicatorView.setScaleX(0.88f);
mSelectionIndicatorView.setScaleY(0.88f);
mSelectionIndicatorView.animate()
.scaleX(1f)
.scaleY(1f)
.setDuration(SELECTION_TOGGLE_ANIM_MS) .setDuration(SELECTION_TOGGLE_ANIM_MS)
.setInterpolator(new OvershootInterpolator(1.6f)); .setInterpolator(UiUtils.DEFAULT_INTERPOLATOR)
mSelectionCheckView.setAlpha(0f); .withEndAction(() ->
mSelectionCheckView.setScaleX(0.4f); HomeAvatarOverlay.apply(mContactCheckmarkView, false, false))
mSelectionCheckView.setScaleY(0.4f); .start();
mSelectionCheckView.animate()
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
.setDuration(SELECTION_TOGGLE_ANIM_MS)
.setInterpolator(UiUtils.DEFAULT_INTERPOLATOR);
} else { } else {
mSelectionIndicatorView.setScaleX(1f); HomeAvatarOverlay.apply(mContactCheckmarkView, false, false);
mSelectionIndicatorView.setScaleY(1f);
mSelectionCheckView.setAlpha(1f);
mSelectionCheckView.setScaleX(1f);
mSelectionCheckView.setScaleY(1f);
} }
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 { } else {
mSelectionRingView.setBackground(resources.getDrawable( mContactCheckmarkView.setAlpha(1f);
R.drawable.home_bg_selection_ring, getContext().getTheme())); mContactCheckmarkView.setScaleX(1f);
mSelectionCheckView.setVisibility(GONE); mContactCheckmarkView.setScaleY(1f);
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);
}
} }
} }
@@ -421,8 +558,7 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
} }
final ViewGroup.MarginLayoutParams layoutParams = final ViewGroup.MarginLayoutParams layoutParams =
(ViewGroup.MarginLayoutParams) mListDividerView.getLayoutParams(); (ViewGroup.MarginLayoutParams) mListDividerView.getLayoutParams();
final int insetDp = selectionMode ? LIST_DIVIDER_INSET_SELECTION_DP final int insetDp = LIST_DIVIDER_INSET_DEFAULT_DP;
: LIST_DIVIDER_INSET_DEFAULT_DP;
final float density = getResources().getDisplayMetrics().density; final float density = getResources().getDisplayMetrics().density;
final int insetPx = (int) (insetDp * density + 0.5f); final int insetPx = (int) (insetDp * density + 0.5f);
if (layoutParams.leftMargin != insetPx) { if (layoutParams.leftMargin != insetPx) {
@@ -561,34 +697,27 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
final String snippetText = getSnippetText(); final String snippetText = getSnippetText();
final boolean selectionMode = mHostInterface.isSelectionMode(); final boolean selectionMode = mHostInterface.isSelectionMode();
if (selectionMode) { if (mData.getIsRead() || mData.getShowDraft()) {
maxLines = 0;
typeface = mListItemReadTypeface;
} else if (mData.getIsRead() || mData.getShowDraft()) {
maxLines = TextUtils.isEmpty(snippetText) ? 0 : NO_UNREAD_SNIPPET_LINE_COUNT; maxLines = TextUtils.isEmpty(snippetText) ? 0 : NO_UNREAD_SNIPPET_LINE_COUNT;
typeface = mListItemReadTypeface; typeface = mListItemReadTypeface;
} else { } else {
maxLines = TextUtils.isEmpty(snippetText) ? 0 : UNREAD_SNIPPET_LINE_COUNT; maxLines = TextUtils.isEmpty(snippetText) ? 0 : UNREAD_SNIPPET_LINE_COUNT;
typeface = mListItemUnreadTypeface; 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.setMaxLines(maxLines);
mSnippetTextView.setEllipsize(TextUtils.TruncateAt.END);
mSnippetTextView.setTextColor(color); mSnippetTextView.setTextColor(color);
mSnippetTextView.setTypeface(typeface, typefaceStyle); mSnippetTextView.setTypeface(typeface, typefaceStyle);
mSubjectTextView.setTextColor(color); mSubjectTextView.setTextColor(color);
mSubjectTextView.setTypeface(typeface, typefaceStyle); mSubjectTextView.setTypeface(typeface, typefaceStyle);
if (selectionMode) { mSnippetTextView.setVisibility(VISIBLE);
mSnippetTextView.setVisibility(GONE);
mSubjectTextView.setVisibility(GONE);
} else {
mSnippetTextView.setVisibility(VISIBLE);
setSnippet();
setSubject();
}
setConversationName();
setWorkProfileIcon(); setWorkProfileIcon();
setSnippet();
setSubject();
setConversationName();
setContentDescription(buildContentDescription(resources, mData, setContentDescription(buildContentDescription(resources, mData,
mConversationNameView.getPaint())); mConversationNameView.getPaint()));
@@ -616,9 +745,13 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
mTimestampTextView.setText(resources.getString( mTimestampTextView.setText(resources.getString(
R.string.conversation_list_item_view_draft_message)); R.string.conversation_list_item_view_draft_message));
} else { } else {
mTimestampTextView.setTextColor(resources.getColor(R.color.home_text_secondary, final boolean unread = !mData.getIsRead();
getContext().getTheme())); mTimestampTextView.setTextColor(unread
mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle); ? color
: resources.getColor(R.color.home_text_secondary, getContext().getTheme()));
mTimestampTextView.setTypeface(
unread ? mListItemUnreadTypeface : mListItemReadTypeface,
unread ? Typeface.BOLD : typefaceStyle);
final String formattedTimestamp = mData.getFormattedTimestamp(); final String formattedTimestamp = mData.getFormattedTimestamp();
if (mData.getIsSendRequested()) { if (mData.getIsSendRequested()) {
mTimestampTextView.setText(R.string.message_status_sending); mTimestampTextView.setText(R.string.message_status_sending);
@@ -631,7 +764,6 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
setSelected(isSelected); setSelected(isSelected);
Uri iconUri = null; Uri iconUri = null;
int contactIconVisibility = VISIBLE; int contactIconVisibility = VISIBLE;
int checkmarkVisiblity = GONE;
int failStatusVisiblity = GONE; int failStatusVisiblity = GONE;
if (!isSelected) { if (!isSelected) {
// Only show the fail icon if it is not a group conversation. // 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(), mContactIconView.setImageResourceUri(iconUri, mData.getParticipantContactId(),
mData.getParticipantLookupKey(), mData.getOtherParticipantNormalizedDestination()); mData.getParticipantLookupKey(), mData.getOtherParticipantNormalizedDestination());
mContactIconView.setVisibility(contactIconVisibility); mContactIconView.setVisibility(contactIconVisibility);
mContactIconView.setOnLongClickListener(this); mContactIconView.setImageClickHandlerDisabled(true);
mContactIconView.setClickable(!mHostInterface.isSelectionMode()); bindAvatarClickTargets(selectionMode);
mContactIconView.setLongClickable(!mHostInterface.isSelectionMode());
mContactCheckmarkView.setVisibility(checkmarkVisiblity);
mFailedStatusIconView.setVisibility(failStatusVisiblity); 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); updatePinButton(selectionMode, resources);
updateListDividerInset(selectionMode); updateListDividerInset(selectionMode);
@@ -691,22 +830,36 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */)); ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */));
mImagePreviewView.setOnLongClickListener(this); mImagePreviewView.setOnLongClickListener(this);
mImagePreviewView.setVisibility(previewImageVisibility); mImagePreviewView.setVisibility(previewImageVisibility);
mImagePreviewView.setOnClickListener(previewClickListener); mImagePreviewView.setOnClickListener(selectionMode
? v -> processClick(mSwipeableContainer, false)
: previewClickListener);
mAudioAttachmentView.setOnLongClickListener(this); mAudioAttachmentView.setOnLongClickListener(this);
mAudioAttachmentView.setVisibility(audioPreviewVisiblity); mAudioAttachmentView.setVisibility(audioPreviewVisiblity);
Drawable archiveDrawable = ResourcesCompat.getDrawable( if (mCrossSwipeArchiveLeftImageView != null) {
getResources(), R.drawable.ic_archive_small_dark, getContext().getTheme()); mCrossSwipeArchiveLeftImageView.setImageResource(
if (PrefsUtils.isSwipeRightToDeleteEnabled()) { PrefsUtils.isSwipeRightToDeleteEnabled()
mCrossSwipeArchiveLeftImageView.setImageDrawable(ResourcesCompat.getDrawable( ? R.drawable.ic_delete_small_dark
getResources(), R.drawable.ic_delete_small_dark, getContext().getTheme())); : R.drawable.ic_archive_small_dark);
mCrossSwipeArchiveRightImageView.setImageDrawable(archiveDrawable);
} else {
mCrossSwipeArchiveLeftImageView.setImageDrawable(archiveDrawable);
mCrossSwipeArchiveRightImageView.setImageDrawable(archiveDrawable);
} }
} }
/** 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() { public boolean isSwipeAnimatable() {
return mHostInterface.isSwipeAnimatable(); return mHostInterface.isSwipeAnimatable();
} }
@@ -715,24 +868,97 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
return mSwipeableContainer.getTranslationX(); 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) { public void setSwipeTranslationX(final float translationX) {
mSwipeableContainer.setTranslationX(translationX); mSwipeableContainer.setTranslationX(translationX);
if (translationX == 0) { if (translationX == 0) {
mCrossSwipeBackground.setVisibility(View.GONE); mCrossSwipeBackground.setVisibility(View.GONE);
mCrossSwipeArchiveLeftImageView.setVisibility(GONE); if (mCrossSwipeArchiveLeftImageView != null) {
mCrossSwipeArchiveRightImageView.setVisibility(GONE); mCrossSwipeArchiveLeftImageView.setVisibility(GONE);
}
if (mCrossSwipeRightActions != null) {
mCrossSwipeRightActions.setVisibility(GONE);
}
mSwipeableContainer.setBackgroundColor(Color.TRANSPARENT); mSwipeableContainer.setBackgroundColor(Color.TRANSPARENT);
} else { } else {
mCrossSwipeBackground.setVisibility(View.VISIBLE); mCrossSwipeBackground.setVisibility(View.VISIBLE);
if (translationX > 0) { if (translationX > 0) {
mCrossSwipeArchiveLeftImageView.setVisibility(VISIBLE); if (mCrossSwipeArchiveLeftImageView != null) {
mCrossSwipeArchiveRightImageView.setVisibility(GONE); mCrossSwipeArchiveLeftImageView.setVisibility(VISIBLE);
}
if (mCrossSwipeRightActions != null) {
mCrossSwipeRightActions.setVisibility(GONE);
}
} else { } else {
mCrossSwipeArchiveLeftImageView.setVisibility(GONE); if (mCrossSwipeArchiveLeftImageView != null) {
mCrossSwipeArchiveRightImageView.setVisibility(VISIBLE); 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(); final String conversationId = mData.getConversationId();
if (PrefsUtils.isSwipeRightToDeleteEnabled() if (PrefsUtils.isSwipeRightToDeleteEnabled()
&& swipeDirection == ConversationListSwipeHelper.SWIPE_DIRECTION_RIGHT) { && swipeDirection == ConversationListSwipeHelper.SWIPE_DIRECTION_RIGHT) {
final boolean alreadyTrashed = mData.getIsTrashed();
mData.deleteConversation(); mData.deleteConversation();
UiUtils.showSnackBar(getContext(), getRootView(), if (alreadyTrashed) {
getResources().getString(R.string.conversation_deleted)); 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; return;
} }
UpdateConversationArchiveStatusAction.archiveConversation(conversationId); UpdateConversationArchiveStatusAction.archiveConversation(conversationId);
@@ -812,10 +1053,38 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
return processClick(v, true); 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) { 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()); Assert.notNull(mData.getName());
if (!isLongClick && isSwipeActionsOpen()) {
setSwipeTranslationX(0);
return true;
}
if (mHostInterface != null) { if (mHostInterface != null) {
mHostInterface.onConversationClicked(mData, isLongClick, this); mHostInterface.onConversationClicked(mData, isLongClick, this);
return true; return true;
@@ -836,6 +1105,7 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
mData.getDraftSnippetText() : mData.getSnippetText(); mData.getDraftSnippetText() : mData.getSnippetText();
final String previewContentType = mData.getShowDraft() ? final String previewContentType = mData.getShowDraft() ?
mData.getDraftPreviewContentType() : mData.getPreviewContentType(); mData.getDraftPreviewContentType() : mData.getPreviewContentType();
snippetText = ConversationMessageData.flattenPreviewText(snippetText);
if (TextUtils.isEmpty(snippetText)) { if (TextUtils.isEmpty(snippetText)) {
Resources resources = getResources(); Resources resources = getResources();
// Use the attachment type as a snippet so the preview doesn't look odd // 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 VelocityTracker mVelocityTracker;
private float mInitialX; private float mInitialX;
private float mInitialY; private float mInitialY;
private float mStartTranslationX;
private boolean mIsSwiping; private boolean mIsSwiping;
private ConversationListItemView mListItemView; private ConversationListItemView mListItemView;
private ConversationListItemView mOpenActionsItem;
public ConversationListSwipeHelper(final RecyclerView recyclerView) { public ConversationListSwipeHelper(final RecyclerView recyclerView) {
mRecyclerView = recyclerView; mRecyclerView = recyclerView;
@@ -104,12 +106,19 @@ public class ConversationListSwipeHelper implements OnItemTouchListener {
mInitialY = event.getY(); mInitialY = event.getY();
final View viewAtPoint = mRecyclerView.findChildViewUnder(mInitialX, mInitialY); final View viewAtPoint = mRecyclerView.findChildViewUnder(mInitialX, mInitialY);
final ConversationListItemView child = (ConversationListItemView) viewAtPoint; if (viewAtPoint instanceof ConversationListItemView) {
if (viewAtPoint instanceof ConversationListItemView && final ConversationListItemView child = (ConversationListItemView) viewAtPoint;
child != null && child.isSwipeAnimatable()) { if (child.isTouchOnSwipeActions(mInitialX - child.getLeft(),
// Begin detecting swipe on the target for the rest of the gesture. mInitialY - child.getTop())) {
mListItemView = child; mListItemView = null;
if (mListItemView.isAnimating()) { } 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; mListItemView = null;
} }
} else { } else {
@@ -186,7 +195,8 @@ public class ConversationListSwipeHelper implements OnItemTouchListener {
case MotionEvent.ACTION_OUTSIDE: case MotionEvent.ACTION_OUTSIDE:
case MotionEvent.ACTION_MOVE: case MotionEvent.ACTION_MOVE:
if (hasValidGestureSwipeTarget()) { if (hasValidGestureSwipeTarget()) {
mListItemView.setSwipeTranslationX(event.getX() - mInitialX); mListItemView.setSwipeTranslationX(clampTranslation(
mListItemView, mStartTranslationX + event.getX() - mInitialX));
} }
break; break;
case MotionEvent.ACTION_UP: case MotionEvent.ACTION_UP:
@@ -208,17 +218,27 @@ public class ConversationListSwipeHelper implements OnItemTouchListener {
final boolean fastEnough = isTargetSwipedFastEnough(); final boolean fastEnough = isTargetSwipedFastEnough();
final boolean farEnough = isTargetSwipedFarEnough(); final boolean farEnough = isTargetSwipedFarEnough();
final boolean shouldRevealActions = swipeDirection == SWIPE_DIRECTION_LEFT
final boolean shouldDismiss = (fastEnough || farEnough); && isTargetSwipedFarEnoughForActions();
final boolean shouldDismiss = swipeDirection == SWIPE_DIRECTION_RIGHT
&& (fastEnough || farEnough);
if (shouldDismiss) { if (shouldDismiss) {
closeOpenActions(mListItemView);
if (fastEnough) { if (fastEnough) {
animateDismiss(mListItemView, velocityX); animateDismiss(mListItemView, velocityX);
} else { } else {
animateDismiss(mListItemView, swipeDirection); animateDismiss(mListItemView, swipeDirection);
} }
} else if (shouldRevealActions) {
closeOpenActions(mListItemView);
animateRevealActions(mListItemView, velocityX);
mOpenActionsItem = mListItemView;
} else { } else {
animateRestore(mListItemView, velocityX); animateRestore(mListItemView, velocityX);
if (mOpenActionsItem == mListItemView) {
mOpenActionsItem = null;
}
} }
onSwipeGestureEnd(mListItemView, onSwipeGestureEnd(mListItemView,
@@ -266,6 +286,51 @@ public class ConversationListSwipeHelper implements OnItemTouchListener {
mRecyclerView.getParent().requestDisallowInterceptTouchEvent(true); mRecyclerView.getParent().requestDisallowInterceptTouchEvent(true);
setHardwareAnimatingLayerType(itemView, ANIMATING); setHardwareAnimatingLayerType(itemView, ANIMATING);
itemView.setAnimating(true); 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.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.AbsListView;
import android.widget.CursorAdapter; import android.widget.CursorAdapter;
import android.widget.SectionIndexer;
import com.android.messaging.R; import com.android.messaging.R;
import com.android.messaging.ui.contact.ContactListItemView; import com.android.messaging.ui.contact.ContactListItemView;
import com.android.messaging.ui.contact.ContactSectionIndexer;
import com.android.messaging.util.Assert; import com.android.messaging.util.Assert;
public class HomeContactListAdapter extends CursorAdapter { public class HomeContactListAdapter extends CursorAdapter implements SectionIndexer {
private final ContactListItemView.HostInterface mHost; private final ContactListItemView.HostInterface mHost;
private ContactSectionIndexer mSectionIndexer;
private boolean mNeedAlphabetHeader = true;
public HomeContactListAdapter(final Context context, final Cursor cursor, public HomeContactListAdapter(final Context context, final Cursor cursor,
final ContactListItemView.HostInterface host) { final ContactListItemView.HostInterface host) {
super(context, cursor, 0); super(context, cursor, 0);
mHost = host; mHost = host;
mSectionIndexer = new ContactSectionIndexer(cursor);
}
public void setNeedAlphabetHeader(final boolean needAlphabetHeader) {
if (mNeedAlphabetHeader == needAlphabetHeader) {
return;
}
mNeedAlphabetHeader = needAlphabetHeader;
notifyDataSetChanged();
} }
@Override @Override
public void bindView(final View view, final Context context, final Cursor cursor) { public void bindView(final View view, final Context context, final Cursor cursor) {
Assert.isTrue(view instanceof ContactListItemView); 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 @Override
public View newView(final Context context, final Cursor cursor, final ViewGroup parent) { public View newView(final Context context, final Cursor cursor, final ViewGroup parent) {
return LayoutInflater.from(context).inflate(R.layout.home_contacts_list_item, parent, final View view = LayoutInflater.from(context).inflate(R.layout.home_contacts_list_item,
false); 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.database.Cursor;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.ListView; import android.widget.ListView;
import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.loader.app.LoaderManager; import androidx.loader.app.LoaderManager;
import com.android.ex.chips.RecipientEntry;
import com.android.messaging.R; import com.android.messaging.R;
import com.android.messaging.datamodel.DataModel; 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.ActionMonitor;
import com.android.messaging.datamodel.action.GetOrCreateConversationAction;
import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener; import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener;
import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionMonitor; 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.Binding;
import com.android.messaging.datamodel.binding.BindingBase; import com.android.messaging.datamodel.binding.BindingBase;
import com.android.messaging.datamodel.data.ContactListItemData; 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.datamodel.data.ParticipantData;
import com.android.messaging.ui.UIIntents; import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.contact.ContactListItemView; 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.ContactUtil;
import com.android.messaging.util.LogUtil; import com.android.messaging.util.LogUtil;
import com.android.messaging.util.PhoneUtils;
import java.util.ArrayList; 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, public class HomeContactsFragment extends Fragment implements ContactPickerDataListener,
ContactListItemView.HostInterface, GetOrCreateConversationActionListener { 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 Binding<ContactPickerData> mBinding = BindingBase.createBinding(this);
private final LinkedHashMap<String, RecipientEntry> mSelected = new LinkedHashMap<>();
private HomeContactListAdapter mAdapter; private HomeContactListAdapter mAdapter;
private GetOrCreateConversationActionMonitor mMonitor; 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 @Override
public void onCreate(@Nullable final Bundle savedInstanceState) { 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) { public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState); super.onViewCreated(view, savedInstanceState);
final ListView listView = view.findViewById(R.id.home_contacts_list); 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); mAdapter = new HomeContactListAdapter(requireContext(), null, this);
listView.setAdapter(mAdapter); listView.setAdapter(mAdapter);
if (getParentFragment() instanceof GroupSelectionHost
|| getActivity() instanceof GroupSelectionHost) {
mGroupMode = true;
}
updateResultViews();
}
view.findViewById(R.id.home_contacts_search).setOnClickListener(v -> /** Switches between opening a conversation on tap and collecting group members. */
Toast.makeText(requireContext(), R.string.home_popover_coming_soon, public void setGroupMode(final boolean groupMode) {
Toast.LENGTH_SHORT).show()); if (mGroupMode == groupMode) {
view.findViewById(R.id.home_contacts_invite).setOnClickListener(v -> return;
Toast.makeText(requireContext(), R.string.home_popover_coming_soon, }
Toast.LENGTH_SHORT).show()); mGroupMode = groupMode;
view.findViewById(R.id.home_contacts_recent_calls).setOnClickListener(v -> if (!groupMode) {
Toast.makeText(requireContext(), R.string.home_popover_coming_soon, mSelected.clear();
Toast.LENGTH_SHORT).show()); }
view.findViewById(R.id.home_contacts_sort).setOnClickListener(v -> notifyGroupSelectionChanged();
Toast.makeText(requireContext(), R.string.home_popover_coming_soon, if (mAdapter != null) {
Toast.LENGTH_SHORT).show()); mAdapter.notifyDataSetChanged();
view.findViewById(R.id.home_contacts_add).setOnClickListener(v -> }
UIIntents.get().launchCreateNewConversationActivity(requireActivity(), null)); }
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 @Override
@@ -95,7 +175,21 @@ public class HomeContactsFragment extends Fragment implements ContactPickerDataL
@Override @Override
public void onAllContactsCursorUpdated(final Cursor data) { public void onAllContactsCursorUpdated(final Cursor data) {
mBinding.ensureBound(); 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 @Override
@@ -112,29 +206,90 @@ public class HomeContactsFragment extends Fragment implements ContactPickerDataL
@Override @Override
public void onContactListItemClicked(final ContactListItemData item, public void onContactListItemClicked(final ContactListItemData item,
final ContactListItemView view) { 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) { if (mMonitor != null) {
return; return;
} }
final ArrayList<ParticipantData> participants = new ArrayList<>(1); final ArrayList<ParticipantData> participants = new ArrayList<>(1);
participants.add(ParticipantData.getFromRecipientEntry(item.getRecipientEntry())); participants.add(ParticipantData.getFromRecipientEntry(entry));
mMonitor = GetOrCreateConversationAction.getOrCreateConversation(participants, null, mMonitor = GetOrCreateConversationAction.getOrCreateConversation(participants, null, this);
this);
} }
@Override @Override
public boolean isContactSelected(final ContactListItemData item) { 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 @Override
@RunsOnMainThread @RunsOnMainThread
public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor, public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor, final Object data,
final Object data, final String conversationId) { final String conversationId) {
Assert.isTrue(monitor == mMonitor); Assert.isTrue(monitor == mMonitor);
mMonitor = null; mMonitor = null;
if (conversationId != null) { if (conversationId != null && getActivity() != null) {
UIIntents.get().launchConversationActivity(requireActivity(), conversationId, null, UIIntents.get().launchConversationActivity(requireActivity(), conversationId, null,
null, false); null, false);
getActivity().finish();
} }
} }
@@ -145,4 +300,63 @@ public class HomeContactsFragment extends Fragment implements ContactPickerDataL
LogUtil.e(LogUtil.BUGLE_TAG, "HomeContactsFragment onGetOrCreateConversationFailed"); LogUtil.e(LogUtil.BUGLE_TAG, "HomeContactsFragment onGetOrCreateConversationFailed");
mMonitor = null; 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