Messaging: Looply home UI and Gradle app module layout

Restructure the tree for Android Studio/Gradle builds and add the iOS-style home experience with pinned conversations, selection chrome, and scroll-aware search padding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-24 18:19:48 +09:00
co-authored by Cursor
parent 6d131ed8e7
commit 349eeb7025
1142 changed files with 7707 additions and 1018 deletions
@@ -0,0 +1,178 @@
/*
* 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;
import android.app.Application;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.res.Configuration;
import android.os.Handler;
import android.support.v7.mms.CarrierConfigValuesLoader;
import android.support.v7.mms.MmsManager;
import android.telephony.CarrierConfigManager;
import androidx.annotation.NonNull;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.receiver.SmsReceiver;
import com.android.messaging.sms.BugleApnSettingsLoader;
import com.android.messaging.sms.BugleUserAgentInfoLoader;
import com.android.messaging.sms.MmsConfig;
import com.android.messaging.ui.ConversationDrawables;
import com.android.messaging.util.BuglePrefsKeys;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.PhoneUtils;
import com.android.messaging.util.Trace;
import java.lang.Thread.UncaughtExceptionHandler;
/**
* The application object
*/
public class BugleApplication extends Application implements UncaughtExceptionHandler {
private static final String TAG = LogUtil.BUGLE_TAG;
private UncaughtExceptionHandler sSystemUncaughtExceptionHandler;
@Override
public void onCreate() {
Trace.beginSection("app.onCreate");
super.onCreate();
FactoryImpl.register(getApplicationContext(), this);
sSystemUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler(this);
Trace.endSection();
}
@Override
public void onConfigurationChanged(@NonNull final Configuration newConfig) {
super.onConfigurationChanged(newConfig);
// Update conversation drawables when changing writing systems
// (Right-To-Left / Left-To-Right)
ConversationDrawables.get().updateDrawables();
}
// Called by the "real" factory from FactoryImpl.register() (i.e. not run in tests)
public void initializeSync(final Factory factory) {
Trace.beginSection("app.initializeSync");
final Context context = factory.getApplicationContext();
final DataModel dataModel = factory.getDataModel();
final CarrierConfigValuesLoader carrierConfigValuesLoader =
factory.getCarrierConfigValuesLoader();
BugleApplication.updateAppConfig(context);
// Initialize MMS lib
initMmsLib(context, carrierConfigValuesLoader);
// Fixup messages in flight if we crashed and send any pending
dataModel.onApplicationCreated();
// Register carrier config change receiver
registerCarrierConfigChangeReceiver(context);
Trace.endSection();
}
private static void registerCarrierConfigChangeReceiver(final Context context) {
context.registerReceiver(new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
LogUtil.i(TAG, "Carrier config changed. Reloading MMS config.");
MmsConfig.loadAsync();
}
}, new IntentFilter(CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED),
Context.RECEIVER_EXPORTED/*UNAUDITED*/);
}
private static void initMmsLib(final Context context,
final CarrierConfigValuesLoader carrierConfigValuesLoader) {
MmsManager.setApnSettingsLoader(new BugleApnSettingsLoader(context));
MmsManager.setCarrierConfigValuesLoader(carrierConfigValuesLoader);
MmsManager.setUserAgentInfoLoader(new BugleUserAgentInfoLoader(context));
}
public static void updateAppConfig(final Context context) {
// Make sure we set the correct state for the SMS/MMS receivers
SmsReceiver.updateSmsReceiveHandler(context);
}
// Called from thread started in FactoryImpl.register() (i.e. not run in tests)
public void initializeAsync(final Factory factory) {
// Handle shared prefs upgrade & Load MMS Configuration
Trace.beginSection("app.initializeAsync");
maybeHandleSharedPrefsUpgrade(factory);
MmsConfig.load();
Trace.endSection();
}
@Override
public void onLowMemory() {
super.onLowMemory();
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "BugleApplication.onLowMemory");
}
Factory.get().reclaimMemory();
}
@Override
public void uncaughtException(@NonNull final Thread thread, @NonNull final Throwable ex) {
final boolean background = getMainLooper().getThread() != thread;
if (background) {
LogUtil.e(TAG, "Uncaught exception in background thread " + thread, ex);
final Handler handler = new Handler(getMainLooper());
handler.post(() -> sSystemUncaughtExceptionHandler.uncaughtException(thread, ex));
} else {
sSystemUncaughtExceptionHandler.uncaughtException(thread, ex);
}
}
private void maybeHandleSharedPrefsUpgrade(final Factory factory) {
final int existingVersion = factory.getApplicationPrefs().getInt(
BuglePrefsKeys.SHARED_PREFERENCES_VERSION,
BuglePrefsKeys.SHARED_PREFERENCES_VERSION_DEFAULT);
final int targetVersion = Integer.parseInt(getString(R.string.pref_version));
if (targetVersion > existingVersion) {
LogUtil.i(LogUtil.BUGLE_TAG, "Upgrading shared prefs from " + existingVersion +
" to " + targetVersion);
try {
// Perform upgrade on application-wide prefs.
factory.getApplicationPrefs().onUpgrade(existingVersion, targetVersion);
// Perform upgrade on each subscription's prefs.
PhoneUtils.forEachActiveSubscription(subId -> factory.getSubscriptionPrefs(subId)
.onUpgrade(existingVersion, targetVersion));
factory.getApplicationPrefs().putInt(BuglePrefsKeys.SHARED_PREFERENCES_VERSION,
targetVersion);
} catch (final Exception ex) {
// Upgrade failed. Don't crash the app because we can always fall back to the
// default settings.
LogUtil.e(LogUtil.BUGLE_TAG, "Failed to upgrade shared prefs", ex);
}
} else if (targetVersion < existingVersion) {
// We don't care about downgrade since real user shouldn't encounter this, so log it
// and ignore any prefs migration.
LogUtil.e(LogUtil.BUGLE_TAG, "Shared prefs downgrade requested and ignored. " +
"oldVersion = " + existingVersion + ", newVersion = " + targetVersion);
}
}
}
@@ -0,0 +1,69 @@
/*
* 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;
import android.content.Context;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.MemoryCacheManager;
import com.android.messaging.datamodel.ParticipantRefresh.ContactContentObserver;
import com.android.messaging.datamodel.media.MediaCacheManager;
import com.android.messaging.datamodel.media.MediaResourceManager;
import com.android.messaging.sms.BugleCarrierConfigValuesLoader;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.util.Assert;
import com.android.messaging.util.BuglePrefs;
import com.android.messaging.util.MediaUtil;
import com.android.messaging.util.PhoneUtils;
public abstract class Factory {
// Making this volatile because on the unit tests, setInstance is called from a unit test
// thread, and then it's read on the UI thread.
private static volatile Factory sInstance;
protected static boolean sRegistered;
protected static boolean sInitialized;
public static Factory get() {
return sInstance;
}
protected static void setInstance(final Factory factory) {
// Not allowed to call this after real application initialization is complete
Assert.isTrue(!sRegistered);
Assert.isTrue(!sInitialized);
sInstance = factory;
}
public abstract void onRequiredPermissionsAcquired();
public abstract Context getApplicationContext();
public abstract DataModel getDataModel();
public abstract BuglePrefs getApplicationPrefs();
public abstract BuglePrefs getSubscriptionPrefs(int subId);
public abstract BuglePrefs getWidgetPrefs();
public abstract UIIntents getUIIntents();
public abstract MemoryCacheManager getMemoryCacheManager();
public abstract MediaResourceManager getMediaResourceManager();
public abstract MediaCacheManager getMediaCacheManager();
public abstract ContactContentObserver getContactContentObserver();
public abstract PhoneUtils getPhoneUtils(int subId);
public abstract MediaUtil getMediaUtil();
public abstract BugleCarrierConfigValuesLoader getCarrierConfigValuesLoader();
// Note this needs to run from any thread
public abstract void reclaimMemory();
}
@@ -0,0 +1,211 @@
/*
* 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;
import android.content.Context;
import android.os.Process;
import android.telephony.SmsManager;
import android.util.SparseArray;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DataModelImpl;
import com.android.messaging.datamodel.MemoryCacheManager;
import com.android.messaging.datamodel.ParticipantRefresh.ContactContentObserver;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.datamodel.media.BugleMediaCacheManager;
import com.android.messaging.datamodel.media.MediaCacheManager;
import com.android.messaging.datamodel.media.MediaResourceManager;
import com.android.messaging.sms.BugleCarrierConfigValuesLoader;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.UIIntentsImpl;
import com.android.messaging.util.Assert;
import com.android.messaging.util.BugleApplicationPrefs;
import com.android.messaging.util.BuglePrefs;
import com.android.messaging.util.BugleSubscriptionPrefs;
import com.android.messaging.util.BugleWidgetPrefs;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.MediaUtil;
import com.android.messaging.util.MediaUtilImpl;
import com.android.messaging.util.OsUtil;
import com.android.messaging.util.PhoneUtils;
import java.util.concurrent.ConcurrentHashMap;
class FactoryImpl extends Factory {
private BugleApplication mApplication;
private DataModel mDataModel;
private BugleApplicationPrefs mBugleApplicationPrefs;
private BugleWidgetPrefs mBugleWidgetPrefs;
private Context mApplicationContext;
private UIIntents mUIIntents;
private MemoryCacheManager mMemoryCacheManager;
private MediaResourceManager mMediaResourceManager;
private MediaCacheManager mMediaCacheManager;
private ContactContentObserver mContactContentObserver;
private MediaUtil mMediaUtil;
private SparseArray<BugleSubscriptionPrefs> mSubscriptionPrefs;
private BugleCarrierConfigValuesLoader mCarrierConfigValuesLoader;
// Cached subId->instance for L_MR1 and beyond
private static final ConcurrentHashMap<Integer, PhoneUtils> sPhoneUtilsInstanceCacheLMR1 =
new ConcurrentHashMap<>();
private FactoryImpl() {
}
public static Factory register(final Context applicationContext,
final BugleApplication application) {
// This only gets called once (from BugleApplication.onCreate), but its not called in tests.
Assert.isTrue(!sRegistered);
Assert.isNull(Factory.get());
final FactoryImpl factory = new FactoryImpl();
Factory.setInstance(factory);
sRegistered = true;
// At this point Factory is published. Services can now get initialized and depend on
// Factory.get().
factory.mApplication = application;
factory.mApplicationContext = applicationContext;
factory.mMemoryCacheManager = new MemoryCacheManager();
factory.mMediaCacheManager = new BugleMediaCacheManager();
factory.mMediaResourceManager = new MediaResourceManager();
factory.mBugleApplicationPrefs = new BugleApplicationPrefs(applicationContext);
factory.mDataModel = new DataModelImpl(applicationContext);
factory.mBugleWidgetPrefs = new BugleWidgetPrefs(applicationContext);
factory.mUIIntents = new UIIntentsImpl();
factory.mContactContentObserver = new ContactContentObserver();
factory.mMediaUtil = new MediaUtilImpl();
factory.mSubscriptionPrefs = new SparseArray<>();
factory.mCarrierConfigValuesLoader = new BugleCarrierConfigValuesLoader(applicationContext);
if (OsUtil.hasRequiredPermissions()) {
factory.onRequiredPermissionsAcquired();
}
return factory;
}
@Override
public void onRequiredPermissionsAcquired() {
if (sInitialized) {
return;
}
sInitialized = true;
mApplication.initializeSync(this);
final Thread asyncInitialization = new Thread(() -> {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
mApplication.initializeAsync(FactoryImpl.this);
});
asyncInitialization.start();
}
@Override
public Context getApplicationContext() {
return mApplicationContext;
}
@Override
public DataModel getDataModel() {
return mDataModel;
}
@Override
public BuglePrefs getApplicationPrefs() {
return mBugleApplicationPrefs;
}
@Override
public BuglePrefs getWidgetPrefs() {
return mBugleWidgetPrefs;
}
@Override
public BuglePrefs getSubscriptionPrefs(int subId) {
subId = PhoneUtils.getDefault().getEffectiveSubId(subId);
BugleSubscriptionPrefs pref = mSubscriptionPrefs.get(subId);
if (pref == null) {
synchronized (this) {
if ((pref = mSubscriptionPrefs.get(subId)) == null) {
pref = new BugleSubscriptionPrefs(getApplicationContext(), subId);
mSubscriptionPrefs.put(subId, pref);
}
}
}
return pref;
}
@Override
public UIIntents getUIIntents() {
return mUIIntents;
}
@Override
public MemoryCacheManager getMemoryCacheManager() {
return mMemoryCacheManager;
}
@Override
public MediaResourceManager getMediaResourceManager() {
return mMediaResourceManager;
}
@Override
public MediaCacheManager getMediaCacheManager() {
return mMediaCacheManager;
}
@Override
public ContactContentObserver getContactContentObserver() {
return mContactContentObserver;
}
@Override
public PhoneUtils getPhoneUtils(int subId) {
if (subId == ParticipantData.DEFAULT_SELF_SUB_ID) {
subId = SmsManager.getDefaultSmsSubscriptionId();
}
if (subId < 0) {
LogUtil.w(LogUtil.BUGLE_TAG, "PhoneUtils.getForLMR1(): invalid subId = " + subId);
subId = ParticipantData.DEFAULT_SELF_SUB_ID;
}
PhoneUtils instance = sPhoneUtilsInstanceCacheLMR1.get(subId);
if (instance == null) {
instance = new PhoneUtils(subId);
sPhoneUtilsInstanceCacheLMR1.putIfAbsent(subId, instance);
}
return instance;
}
@Override
public void reclaimMemory() {
mMemoryCacheManager.reclaimMemory();
}
@Override
public MediaUtil getMediaUtil() {
return mMediaUtil;
}
@Override
public BugleCarrierConfigValuesLoader getCarrierConfigValuesLoader() {
return mCarrierConfigValuesLoader;
}
}
@@ -0,0 +1,47 @@
/*
* 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;
import android.content.Context;
import android.net.Uri;
import androidx.loader.content.CursorLoader;
/**
* Extension to basic cursor loader that has an attached binding id
*/
public class BoundCursorLoader extends CursorLoader {
private final String mBindingId;
/**
* Create cursor loader for associated binding id
*/
public BoundCursorLoader(final String bindingId, final Context context, final Uri uri,
final String[] projection, final String selection, final String[] selectionArgs,
final String sortOrder) {
super(context, uri, projection, selection, selectionArgs, sortOrder);
mBindingId = bindingId;
}
/**
* Binding id associated with this loader - consume can check to verify data still valid
*/
public String getBindingId() {
return mBindingId;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
/*
* Copyright (C) 2015 The Android Open Source 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;
import android.net.Uri;
import android.text.TextUtils;
import com.android.ex.chips.RecipientEntry;
/**
* An extension of RecipientEntry for Bugle's use since Bugle uses phone numbers to identify
* participants / recipients instead of contact ids. This allows the user to send to multiple
* phone numbers of the same contact.
*/
public class BugleRecipientEntry extends RecipientEntry {
protected BugleRecipientEntry(final int entryType, final String displayName,
final String destination, final int destinationType, final String destinationLabel,
final long contactId, final Long directoryId, final long dataId,
final Uri photoThumbnailUri, final boolean isFirstLevel, final boolean isValid,
final String lookupKey) {
super(entryType, displayName, destination, destinationType, destinationLabel, contactId,
directoryId, dataId, photoThumbnailUri, isFirstLevel, isValid, lookupKey);
}
public static BugleRecipientEntry constructTopLevelEntry(final String displayName,
final int displayNameSource, final String destination, final int destinationType,
final String destinationLabel, final long contactId, final Long directoryId,
final long dataId, final String thumbnailUriAsString, final boolean isValid,
final String lookupKey) {
return new BugleRecipientEntry(ENTRY_TYPE_PERSON, displayName, destination, destinationType,
destinationLabel, contactId, directoryId, dataId, (thumbnailUriAsString != null
? Uri.parse(thumbnailUriAsString) : null), true, isValid, lookupKey);
}
public static BugleRecipientEntry constructSecondLevelEntry(final String displayName,
final int displayNameSource, final String destination, final int destinationType,
final String destinationLabel, final long contactId, final Long directoryId,
final long dataId, final String thumbnailUriAsString, final boolean isValid,
final String lookupKey) {
return new BugleRecipientEntry(ENTRY_TYPE_PERSON, displayName, destination, destinationType,
destinationLabel, contactId, directoryId, dataId, (thumbnailUriAsString != null
? Uri.parse(thumbnailUriAsString) : null), false, isValid, lookupKey);
}
@Override
public boolean isSamePerson(final RecipientEntry entry) {
return getDestination() != null && entry.getDestination() != null &&
TextUtils.equals(getDestination(), entry.getDestination());
}
}
@@ -0,0 +1,120 @@
/*
* 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;
import android.provider.BaseColumns;
import android.support.v7.mms.pdu.ContentType;
import com.android.ex.photo.provider.PhotoContract.PhotoViewColumns;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseHelper.PartColumns;
import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
/**
* View for the image parts for the conversation. It is used to provide the photoviewer with a
* a data source for all the photos in a conversation, so that the photoviewer can support paging
* through all the photos of the conversation. The columns of the view are a superset of
* {@link com.android.ex.photo.provider.PhotoContract.PhotoViewColumns}.
*/
public class ConversationImagePartsView {
private static final String VIEW_NAME = "conversation_image_parts_view";
private static final String CREATE_SQL = "CREATE VIEW " +
VIEW_NAME + " AS SELECT "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.CONVERSATION_ID
+ " as " + Columns.CONVERSATION_ID + ", "
+ DatabaseHelper.PARTS_TABLE + '.' + PartColumns.CONTENT_URI
+ " as " + Columns.URI + ", "
+ DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FULL_NAME
+ " as " + Columns.SENDER_FULL_NAME + ", "
+ DatabaseHelper.PARTS_TABLE + '.' + PartColumns.CONTENT_URI
+ " as " + Columns.CONTENT_URI + ", "
// Use NULL as the thumbnail uri
+ " NULL as " + Columns.THUMBNAIL_URI + ", "
+ DatabaseHelper.PARTS_TABLE + '.' + PartColumns.CONTENT_TYPE
+ " as " + Columns.CONTENT_TYPE + ", "
//
// Columns in addition to those specified by PhotoContract
//
+ DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.DISPLAY_DESTINATION
+ " as " + Columns.DISPLAY_DESTINATION + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP
+ " as " + Columns.RECEIVED_TIMESTAMP + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.STATUS
+ " as " + Columns.STATUS + " "
+ " FROM " + DatabaseHelper.MESSAGES_TABLE + " LEFT JOIN " + DatabaseHelper.PARTS_TABLE
+ " ON (" + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns._ID
+ "=" + DatabaseHelper.PARTS_TABLE + "." + PartColumns.MESSAGE_ID + ") "
+ " LEFT JOIN " + DatabaseHelper.PARTICIPANTS_TABLE + " ON ("
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID
+ '=' + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns._ID + ")"
// "content_type like 'image/%'"
+ " WHERE " + DatabaseHelper.PARTS_TABLE + "." + PartColumns.CONTENT_TYPE
+ " like '" + ContentType.IMAGE_PREFIX + "%'"
+ " ORDER BY "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + " ASC, "
+ DatabaseHelper.PARTS_TABLE + '.' + PartColumns._ID + " ASC";
static class Columns implements BaseColumns {
static final String CONVERSATION_ID = MessageColumns.CONVERSATION_ID;
static final String URI = PhotoViewColumns.URI;
static final String SENDER_FULL_NAME = PhotoViewColumns.NAME;
static final String CONTENT_URI = PhotoViewColumns.CONTENT_URI;
static final String THUMBNAIL_URI = PhotoViewColumns.THUMBNAIL_URI;
static final String CONTENT_TYPE = PhotoViewColumns.CONTENT_TYPE;
// Columns in addition to those specified by PhotoContract
static final String DISPLAY_DESTINATION = ParticipantColumns.DISPLAY_DESTINATION;
static final String RECEIVED_TIMESTAMP = MessageColumns.RECEIVED_TIMESTAMP;
static final String STATUS = MessageColumns.STATUS;
}
public interface PhotoViewQuery {
String[] PROJECTION = {
PhotoViewColumns.URI,
PhotoViewColumns.NAME,
PhotoViewColumns.CONTENT_URI,
PhotoViewColumns.THUMBNAIL_URI,
PhotoViewColumns.CONTENT_TYPE,
// Columns in addition to those specified by PhotoContract
Columns.DISPLAY_DESTINATION,
Columns.RECEIVED_TIMESTAMP,
Columns.STATUS,
};
int INDEX_URI = 0;
int INDEX_SENDER_FULL_NAME = 1;
int INDEX_CONTENT_URI = 2;
int INDEX_THUMBNAIL_URI = 3;
int INDEX_CONTENT_TYPE = 4;
// Columns in addition to those specified by PhotoContract
int INDEX_DISPLAY_DESTINATION = 5;
int INDEX_RECEIVED_TIMESTAMP = 6;
int INDEX_STATUS = 7;
}
static String getViewName() {
return VIEW_NAME;
}
static String getCreateSql() {
return CREATE_SQL;
}
}
@@ -0,0 +1,77 @@
/*
* 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;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import com.android.messaging.util.Assert;
/**
* Holds parameters and data (such as content URI) for performing queries on the content provider.
* This class could then be used to perform a query using either a BoundCursorLoader or querying
* on the content resolver directly.
*
* This class is used for cases where the way to load a cursor is not fixed. For example,
* when using ContactUtil to query for phone numbers, the ContactPickerFragment wants to use
* a CursorLoader to asynchronously load the data and tie in nicely with its data binding
* paradigm, whereas ContactRecipientAdapter wants to synchronously perform the query on the
* worker thread.
*/
public class CursorQueryData {
protected final Uri mUri;
protected final String[] mProjection;
protected final String mSelection;
protected final String[] mSelectionArgs;
protected final String mSortOrder;
protected final Context mContext;
public CursorQueryData(final Context context, final Uri uri, final String[] projection,
final String selection, final String[] selectionArgs, final String sortOrder) {
mContext = context;
mUri = uri;
mProjection = projection;
mSelection = selection;
mSelectionArgs = selectionArgs;
mSortOrder = sortOrder;
}
public BoundCursorLoader createBoundCursorLoader(final String bindingId) {
return new BoundCursorLoader(bindingId, mContext, mUri, mProjection, mSelection,
mSelectionArgs, mSortOrder);
}
public Cursor performSynchronousQuery() {
Assert.isNotMainThread();
if (mUri == null) {
// See {@link #getEmptyQueryData}
return null;
} else {
return mContext.getContentResolver().query(mUri, mProjection, mSelection,
mSelectionArgs, mSortOrder);
}
}
/**
* Representation of an invalid query. {@link #performSynchronousQuery} will return
* a null Cursor.
*/
public static CursorQueryData getEmptyQueryData() {
return new CursorQueryData(null, null, null, null, null, null);
}
}
@@ -0,0 +1,152 @@
/*
* 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;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.text.TextUtils;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.action.Action;
import com.android.messaging.datamodel.action.ActionService;
import com.android.messaging.datamodel.action.BackgroundWorker;
import com.android.messaging.datamodel.data.BlockedParticipantsData;
import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener;
import com.android.messaging.datamodel.data.ContactListItemData;
import com.android.messaging.datamodel.data.ContactPickerData;
import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener;
import com.android.messaging.datamodel.data.ConversationData;
import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener;
import com.android.messaging.datamodel.data.ConversationListData;
import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener;
import com.android.messaging.datamodel.data.DraftMessageData;
import com.android.messaging.datamodel.data.LaunchConversationData;
import com.android.messaging.datamodel.data.LaunchConversationData.LaunchConversationDataListener;
import com.android.messaging.datamodel.data.MediaPickerData;
import com.android.messaging.datamodel.data.MessagePartData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.datamodel.data.ParticipantListItemData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener;
import com.android.messaging.datamodel.data.PeopleOptionsItemData;
import com.android.messaging.datamodel.data.SettingsData;
import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener;
import com.android.messaging.datamodel.data.SubscriptionListData;
import com.android.messaging.datamodel.data.VCardContactItemData;
import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
public abstract class DataModel {
private String mFocusedConversation;
private boolean mConversationListScrolledToNewestConversation;
public static DataModel get() {
return Factory.get().getDataModel();
}
public static void startActionService(final Action action) {
get().getActionService().startAction(action);
}
public static void scheduleAction(final Action action, final int code, final long delayMs) {
get().getActionService().scheduleAction(action, code, delayMs);
}
public abstract ConversationListData createConversationListData(final Context context,
final ConversationListDataListener listener, final boolean archivedMode);
public abstract ConversationData createConversationData(final Context context,
final ConversationDataListener listener, final String conversationId);
public abstract ContactListItemData createContactListItemData();
public abstract ContactPickerData createContactPickerData(final Context context,
final ContactPickerDataListener listener);
public abstract MediaPickerData createMediaPickerData(final Context context);
public abstract LaunchConversationData createLaunchConversationData(
LaunchConversationDataListener listener);
public abstract PeopleOptionsItemData createPeopleOptionsItemData(final Context context);
public abstract PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId,
final Context context, final PeopleAndOptionsDataListener listener);
public abstract VCardContactItemData createVCardContactItemData(final Context context,
final MessagePartData data);
public abstract VCardContactItemData createVCardContactItemData(final Context context,
final Uri vCardUri);
public abstract ParticipantListItemData createParticipantListItemData(
final ParticipantData participant);
public abstract BlockedParticipantsData createBlockedParticipantsData(Context context,
BlockedParticipantsDataListener listener);
public abstract SubscriptionListData createSubscriptonListData(Context context);
public abstract SettingsData createSettingsData(Context context, SettingsDataListener listener);
public abstract DraftMessageData createDraftMessageData(String conversationId);
public abstract ActionService getActionService();
public abstract BackgroundWorker getBackgroundWorkerForActionService();
@DoesNotRunOnMainThread
public abstract DatabaseWrapper getDatabase();
// Allow DataModel to coordinate with activity lifetime events.
public abstract void onActivityResume();
abstract void onCreateTables(final SQLiteDatabase db);
public void setFocusedConversation(final String conversationId) {
mFocusedConversation = conversationId;
}
public boolean isFocusedConversation(final String conversationId) {
return !TextUtils.isEmpty(mFocusedConversation)
&& TextUtils.equals(mFocusedConversation, conversationId);
}
public void setConversationListScrolledToNewestConversation(
final boolean scrolledToNewestConversation) {
mConversationListScrolledToNewestConversation = scrolledToNewestConversation;
}
public boolean isConversationListScrolledToNewestConversation() {
return mConversationListScrolledToNewestConversation;
}
/**
* If a new message is received in the specified conversation, will the user be able to
* observe it in some UI within the app?
* @param conversationId conversation with the new incoming message
*/
public boolean isNewMessageObservable(final String conversationId) {
return isConversationListScrolledToNewestConversation()
|| isFocusedConversation(conversationId);
}
public abstract void onApplicationCreated();
public abstract SyncManager getSyncManager();
}
@@ -0,0 +1,248 @@
/*
* 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;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.telephony.SubscriptionManager;
import com.android.messaging.datamodel.action.ActionService;
import com.android.messaging.datamodel.action.BackgroundWorker;
import com.android.messaging.datamodel.action.FixupMessageStatusOnStartupAction;
import com.android.messaging.datamodel.action.ProcessPendingMessagesAction;
import com.android.messaging.datamodel.data.BlockedParticipantsData;
import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener;
import com.android.messaging.datamodel.data.ContactListItemData;
import com.android.messaging.datamodel.data.ContactPickerData;
import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener;
import com.android.messaging.datamodel.data.ConversationData;
import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener;
import com.android.messaging.datamodel.data.ConversationListData;
import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener;
import com.android.messaging.datamodel.data.DraftMessageData;
import com.android.messaging.datamodel.data.LaunchConversationData;
import com.android.messaging.datamodel.data.LaunchConversationData.LaunchConversationDataListener;
import com.android.messaging.datamodel.data.MediaPickerData;
import com.android.messaging.datamodel.data.MessagePartData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.datamodel.data.ParticipantListItemData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData;
import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener;
import com.android.messaging.datamodel.data.PeopleOptionsItemData;
import com.android.messaging.datamodel.data.SettingsData;
import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener;
import com.android.messaging.datamodel.data.SubscriptionListData;
import com.android.messaging.datamodel.data.VCardContactItemData;
import com.android.messaging.sms.MmsConfig;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
import com.android.messaging.util.ConnectivityUtil;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.PhoneUtils;
import java.util.concurrent.ConcurrentHashMap;
public class DataModelImpl extends DataModel {
private final Context mContext;
private final ActionService mActionService;
private final BackgroundWorker mDataModelWorker;
private final DatabaseHelper mDatabaseHelper;
private final SyncManager mSyncManager;
// Cached ConnectivityUtil subId->instance for N and beyond
private static final ConcurrentHashMap<Integer, ConnectivityUtil>
sConnectivityUtilInstanceCacheN = new ConcurrentHashMap<>();
public DataModelImpl(final Context context) {
super();
mContext = context;
mActionService = new ActionService();
mDataModelWorker = new BackgroundWorker();
mDatabaseHelper = DatabaseHelper.getInstance(context);
mSyncManager = new SyncManager();
}
@Override
public ConversationListData createConversationListData(final Context context,
final ConversationListDataListener listener, final boolean archivedMode) {
return new ConversationListData(context, listener, archivedMode);
}
@Override
public ConversationData createConversationData(final Context context,
final ConversationDataListener listener, final String conversationId) {
return new ConversationData(context, listener, conversationId);
}
@Override
public ContactListItemData createContactListItemData() {
return new ContactListItemData();
}
@Override
public ContactPickerData createContactPickerData(final Context context,
final ContactPickerDataListener listener) {
return new ContactPickerData(context, listener);
}
@Override
public BlockedParticipantsData createBlockedParticipantsData(
final Context context, final BlockedParticipantsDataListener listener) {
return new BlockedParticipantsData(context, listener);
}
@Override
public MediaPickerData createMediaPickerData(final Context context) {
return new MediaPickerData(context);
}
@Override
public LaunchConversationData createLaunchConversationData(
final LaunchConversationDataListener listener) {
return new LaunchConversationData(listener);
}
@Override
public PeopleOptionsItemData createPeopleOptionsItemData(final Context context) {
return new PeopleOptionsItemData(context);
}
@Override
public PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId,
final Context context, final PeopleAndOptionsDataListener listener) {
return new PeopleAndOptionsData(conversationId, context, listener);
}
@Override
public VCardContactItemData createVCardContactItemData(final Context context,
final MessagePartData data) {
return new VCardContactItemData(context, data);
}
@Override
public VCardContactItemData createVCardContactItemData(final Context context,
final Uri vCardUri) {
return new VCardContactItemData(context, vCardUri);
}
@Override
public ParticipantListItemData createParticipantListItemData(
final ParticipantData participant) {
return new ParticipantListItemData(participant);
}
@Override
public SubscriptionListData createSubscriptonListData(Context context) {
return new SubscriptionListData(context);
}
@Override
public SettingsData createSettingsData(Context context, SettingsDataListener listener) {
return new SettingsData(context, listener);
}
@Override
public DraftMessageData createDraftMessageData(String conversationId) {
return new DraftMessageData(conversationId);
}
@Override
public ActionService getActionService() {
// We need to allow access to this on the UI thread since it's used to start actions.
return mActionService;
}
@Override
public BackgroundWorker getBackgroundWorkerForActionService() {
return mDataModelWorker;
}
@Override
@DoesNotRunOnMainThread
public DatabaseWrapper getDatabase() {
// We prevent the main UI thread from accessing the database since we have to allow
// public access to this class to enable sub-packages to access data.
Assert.isNotMainThread();
return mDatabaseHelper.getDatabase();
}
@Override
public SyncManager getSyncManager() {
return mSyncManager;
}
@Override
void onCreateTables(final SQLiteDatabase db) {
LogUtil.w(LogUtil.BUGLE_TAG, "Rebuilt databases: reseting related state");
// Clear other things that implicitly reference the DB
SyncManager.resetLastSyncTimestamps();
}
@Override
public void onActivityResume() {
// Perform an incremental sync and register for changes if necessary
mSyncManager.updateSyncObserver(mContext);
// Trigger a participant refresh if needed, we should only need to refresh if there is
// contact change while the activity was paused.
ParticipantRefresh.refreshParticipantsIfNeeded();
}
@Override
public void onApplicationCreated() {
createConnectivityUtilForEachActiveSubscription();
FixupMessageStatusOnStartupAction.fixupMessageStatus();
ProcessPendingMessagesAction.processFirstPendingMessage();
SyncManager.immediateSync();
// Start listening for subscription change events for refreshing any data associated
// with subscriptions.
PhoneUtils.getDefault().registerOnSubscriptionsChangedListener(
new SubscriptionManager.OnSubscriptionsChangedListener() {
@Override
public void onSubscriptionsChanged() {
// TODO: This dynamically changes the mms config that app is
// currently using. It may cause inconsistency in some cases. We need
// to check the usage of mms config and handle the dynamic change
// gracefully
MmsConfig.loadAsync();
ParticipantRefresh.refreshSelfParticipants();
createConnectivityUtilForEachActiveSubscription();
}
});
}
private void createConnectivityUtilForEachActiveSubscription() {
PhoneUtils.forEachActiveSubscription(subId -> {
// Create the ConnectivityUtil instance for given subId if absent.
if (subId <= ParticipantData.DEFAULT_SELF_SUB_ID) {
subId = PhoneUtils.getDefault().getDefaultSmsSubscriptionId();
}
if (!sConnectivityUtilInstanceCacheN.containsKey(subId)) {
sConnectivityUtilInstanceCacheN.put(
subId, new ConnectivityUtil(mContext, subId));
}
});
}
public static ConnectivityUtil getConnectivityUtil(final int subId) {
return sConnectivityUtilInstanceCacheN.get(subId);
}
}
@@ -0,0 +1,794 @@
/*
* 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;
import android.content.Context;
import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.provider.BaseColumns;
import com.android.messaging.R;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
import com.android.messaging.util.LogUtil;
/**
* Allows access to the SQL database. This is package private.
*/
public class DatabaseHelper extends SQLiteOpenHelper {
public static final String DATABASE_NAME = "bugle_db";
private static int getDatabaseVersion(final Context context) {
return Integer.parseInt(context.getResources().getString(R.string.database_version));
}
/**
* Table containing names of all other tables and views.
* TODO(rtenneti): Fix the following special SQLLite table name when SQLLite changes.
*/
private static final String PRIMARY_TABLE = "sqlite_master";
/** Column containing the name of the tables and views */
private static final String[] PRIMARY_COLUMNS = new String[] { "name", };
// Table names
public static final String CONVERSATIONS_TABLE = "conversations";
public static final String MESSAGES_TABLE = "messages";
public static final String PARTS_TABLE = "parts";
public static final String PARTICIPANTS_TABLE = "participants";
public static final String CONVERSATION_PARTICIPANTS_TABLE = "conversation_participants";
// Views
static final String DRAFT_PARTS_VIEW = "draft_parts_view";
// Conversations table schema
public static class ConversationColumns implements BaseColumns {
/* SMS/MMS Thread ID from the system provider */
public static final String SMS_THREAD_ID = "sms_thread_id";
/* Display name for the conversation */
public static final String NAME = "name";
/* Latest Message ID for the read status to display in conversation list */
public static final String LATEST_MESSAGE_ID = "latest_message_id";
/* Latest text snippet for display in conversation list */
public static final String SNIPPET_TEXT = "snippet_text";
/* Latest text subject for display in conversation list, empty string if none exists */
public static final String SUBJECT_TEXT = "subject_text";
/* Preview Uri */
public static final String PREVIEW_URI = "preview_uri";
/* The preview uri's content type */
public static final String PREVIEW_CONTENT_TYPE = "preview_content_type";
/* If we should display the current draft snippet/preview pair or snippet/preview pair */
public static final String SHOW_DRAFT = "show_draft";
/* Latest draft text subject for display in conversation list, empty string if none exists*/
public static final String DRAFT_SUBJECT_TEXT = "draft_subject_text";
/* Latest draft text snippet for display, empty string if none exists */
public static final String DRAFT_SNIPPET_TEXT = "draft_snippet_text";
/* Draft Preview Uri, empty string if none exists */
public static final String DRAFT_PREVIEW_URI = "draft_preview_uri";
/* The preview uri's content type */
public static final String DRAFT_PREVIEW_CONTENT_TYPE = "draft_preview_content_type";
/* If this conversation is archived */
public static final String ARCHIVE_STATUS = "archive_status";
/* Timestamp for sorting purposes */
public static final String SORT_TIMESTAMP = "sort_timestamp";
/* Last read message timestamp */
public static final String LAST_READ_TIMESTAMP = "last_read_timestamp";
/* Avatar for the conversation. Could be for group of individual */
public static final String ICON = "icon";
/* Participant contact ID if this conversation has a single participant. -1 otherwise */
public static final String PARTICIPANT_CONTACT_ID = "participant_contact_id";
/* Participant lookup key if this conversation has a single participant. null otherwise */
public static final String PARTICIPANT_LOOKUP_KEY = "participant_lookup_key";
/*
* Participant's normalized destination if this conversation has a single participant.
* null otherwise.
*/
public static final String OTHER_PARTICIPANT_NORMALIZED_DESTINATION =
"participant_normalized_destination";
/* Default self participant for the conversation */
public static final String CURRENT_SELF_ID = "current_self_id";
/* Participant count not including self (so will be 1 for 1:1 or bigger for group) */
public static final String PARTICIPANT_COUNT = "participant_count";
/* Conversation recipients include email address */
public static final String INCLUDE_EMAIL_ADDRESS = "include_email_addr";
// Record the last received sms's service center info if it indicates that the reply path
// is present (TP-Reply-Path), so that we could use it for the subsequent message to send.
// Refer to TS 23.040 D.6 and SmsMessageSender.java in Android Messaging app.
public static final String SMS_SERVICE_CENTER = "sms_service_center";
// A conversation is enterprise if one of the participant is a enterprise contact.
public static final String IS_ENTERPRISE = "IS_ENTERPRISE";
}
// Conversation table SQL
private static final String CREATE_CONVERSATIONS_TABLE_SQL =
"CREATE TABLE " + CONVERSATIONS_TABLE + "("
+ ConversationColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
// TODO : Int? Required not default?
+ ConversationColumns.SMS_THREAD_ID + " INT DEFAULT(0), "
+ ConversationColumns.NAME + " TEXT, "
+ ConversationColumns.LATEST_MESSAGE_ID + " INT, "
+ ConversationColumns.SNIPPET_TEXT + " TEXT, "
+ ConversationColumns.SUBJECT_TEXT + " TEXT, "
+ ConversationColumns.PREVIEW_URI + " TEXT, "
+ ConversationColumns.PREVIEW_CONTENT_TYPE + " TEXT, "
+ ConversationColumns.SHOW_DRAFT + " INT DEFAULT(0), "
+ ConversationColumns.DRAFT_SNIPPET_TEXT + " TEXT, "
+ ConversationColumns.DRAFT_SUBJECT_TEXT + " TEXT, "
+ ConversationColumns.DRAFT_PREVIEW_URI + " TEXT, "
+ ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE + " TEXT, "
+ ConversationColumns.ARCHIVE_STATUS + " INT DEFAULT(0), "
+ ConversationColumns.SORT_TIMESTAMP + " INT DEFAULT(0), "
+ ConversationColumns.LAST_READ_TIMESTAMP + " INT DEFAULT(0), "
+ ConversationColumns.ICON + " TEXT, "
+ ConversationColumns.PARTICIPANT_CONTACT_ID + " INT DEFAULT ( "
+ ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + "), "
+ ConversationColumns.PARTICIPANT_LOOKUP_KEY + " TEXT, "
+ ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + " TEXT, "
+ ConversationColumns.CURRENT_SELF_ID + " TEXT, "
+ ConversationColumns.PARTICIPANT_COUNT + " INT DEFAULT(0), "
+ ConversationColumns.INCLUDE_EMAIL_ADDRESS + " INT DEFAULT(0), "
+ ConversationColumns.SMS_SERVICE_CENTER + " TEXT ,"
+ ConversationColumns.IS_ENTERPRISE + " INT DEFAULT(0)"
+ ");";
private static final String CONVERSATIONS_TABLE_SMS_THREAD_ID_INDEX_SQL =
"CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.SMS_THREAD_ID
+ " ON " + CONVERSATIONS_TABLE
+ "(" + ConversationColumns.SMS_THREAD_ID + ")";
private static final String CONVERSATIONS_TABLE_ARCHIVE_STATUS_INDEX_SQL =
"CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.ARCHIVE_STATUS
+ " ON " + CONVERSATIONS_TABLE
+ "(" + ConversationColumns.ARCHIVE_STATUS + ")";
private static final String CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL =
"CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.SORT_TIMESTAMP
+ " ON " + CONVERSATIONS_TABLE
+ "(" + ConversationColumns.SORT_TIMESTAMP + ")";
// Messages table schema
public static class MessageColumns implements BaseColumns {
/* conversation id that this message belongs to */
public static final String CONVERSATION_ID = "conversation_id";
/* participant which send this message */
public static final String SENDER_PARTICIPANT_ID = "sender_id";
/* This is bugle's internal status for the message */
public static final String STATUS = "message_status";
/* Type of message: SMS, MMS or MMS notification */
public static final String PROTOCOL = "message_protocol";
/* This is the time that the sender sent the message */
public static final String SENT_TIMESTAMP = "sent_timestamp";
/* Time that we received the message on this device */
public static final String RECEIVED_TIMESTAMP = "received_timestamp";
/* When the message has been seen by a user in a notification */
public static final String SEEN = "seen";
/* When the message has been read by a user */
public static final String READ = "read";
/* participant representing the sim which processed this message */
public static final String SELF_PARTICIPANT_ID = "self_id";
/*
* Time when a retry is initiated. This is used to compute the retry window
* when we retry sending/downloading a message.
*/
public static final String RETRY_START_TIMESTAMP = "retry_start_timestamp";
// Columns which map to the SMS provider
/* Message ID from the platform provider */
public static final String SMS_MESSAGE_URI = "sms_message_uri";
/* The message priority for MMS message */
public static final String SMS_PRIORITY = "sms_priority";
/* The message size for MMS message */
public static final String SMS_MESSAGE_SIZE = "sms_message_size";
/* The subject for MMS message */
public static final String MMS_SUBJECT = "mms_subject";
/* Transaction id for MMS notificaiton */
public static final String MMS_TRANSACTION_ID = "mms_transaction_id";
/* Content location for MMS notificaiton */
public static final String MMS_CONTENT_LOCATION = "mms_content_location";
/* The expiry time (ms) for MMS message */
public static final String MMS_EXPIRY = "mms_expiry";
/* The detailed status (RESPONSE_STATUS or RETRIEVE_STATUS) for MMS message */
public static final String RAW_TELEPHONY_STATUS = "raw_status";
}
// Messages table SQL
private static final String CREATE_MESSAGES_TABLE_SQL =
"CREATE TABLE " + MESSAGES_TABLE + " ("
+ MessageColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
+ MessageColumns.CONVERSATION_ID + " INT, "
+ MessageColumns.SENDER_PARTICIPANT_ID + " INT, "
+ MessageColumns.SENT_TIMESTAMP + " INT DEFAULT(0), "
+ MessageColumns.RECEIVED_TIMESTAMP + " INT DEFAULT(0), "
+ MessageColumns.PROTOCOL + " INT DEFAULT(0), "
+ MessageColumns.STATUS + " INT DEFAULT(0), "
+ MessageColumns.SEEN + " INT DEFAULT(0), "
+ MessageColumns.READ + " INT DEFAULT(0), "
+ MessageColumns.SMS_MESSAGE_URI + " TEXT, "
+ MessageColumns.SMS_PRIORITY + " INT DEFAULT(0), "
+ MessageColumns.SMS_MESSAGE_SIZE + " INT DEFAULT(0), "
+ MessageColumns.MMS_SUBJECT + " TEXT, "
+ MessageColumns.MMS_TRANSACTION_ID + " TEXT, "
+ MessageColumns.MMS_CONTENT_LOCATION + " TEXT, "
+ MessageColumns.MMS_EXPIRY + " INT DEFAULT(0), "
+ MessageColumns.RAW_TELEPHONY_STATUS + " INT DEFAULT(0), "
+ MessageColumns.SELF_PARTICIPANT_ID + " INT, "
+ MessageColumns.RETRY_START_TIMESTAMP + " INT DEFAULT(0), "
+ "FOREIGN KEY (" + MessageColumns.CONVERSATION_ID + ") REFERENCES "
+ CONVERSATIONS_TABLE + "(" + ConversationColumns._ID + ") ON DELETE CASCADE "
+ "FOREIGN KEY (" + MessageColumns.SENDER_PARTICIPANT_ID + ") REFERENCES "
+ PARTICIPANTS_TABLE + "(" + ParticipantColumns._ID + ") ON DELETE SET NULL "
+ "FOREIGN KEY (" + MessageColumns.SELF_PARTICIPANT_ID + ") REFERENCES "
+ PARTICIPANTS_TABLE + "(" + ParticipantColumns._ID + ") ON DELETE SET NULL "
+ ");";
// Primary sort index for messages table : by conversation id, status, received timestamp.
private static final String MESSAGES_TABLE_SORT_INDEX_SQL =
"CREATE INDEX index_" + MESSAGES_TABLE + "_sort ON " + MESSAGES_TABLE + "("
+ MessageColumns.CONVERSATION_ID + ", "
+ MessageColumns.STATUS + ", "
+ MessageColumns.RECEIVED_TIMESTAMP + ")";
private static final String MESSAGES_TABLE_STATUS_SEEN_INDEX_SQL =
"CREATE INDEX index_" + MESSAGES_TABLE + "_status_seen ON " + MESSAGES_TABLE + "("
+ MessageColumns.STATUS + ", "
+ MessageColumns.SEEN + ")";
// Parts table schema
// A part may contain text or a media url, but not both.
public static class PartColumns implements BaseColumns {
/* message id that this part belongs to */
public static final String MESSAGE_ID = "message_id";
/* conversation id that this part belongs to */
public static final String CONVERSATION_ID = "conversation_id";
/* text for this part */
public static final String TEXT = "text";
/* content uri for this part */
public static final String CONTENT_URI = "uri";
/* content type for this part */
public static final String CONTENT_TYPE = "content_type";
/* cached width for this part (for layout while loading) */
public static final String WIDTH = "width";
/* cached height for this part (for layout while loading) */
public static final String HEIGHT = "height";
/* de-normalized copy of timestamp from the messages table. This is populated
* via an insert trigger on the parts table.
*/
public static final String TIMESTAMP = "timestamp";
}
// Message part table SQL
private static final String CREATE_PARTS_TABLE_SQL =
"CREATE TABLE " + PARTS_TABLE + "("
+ PartColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ PartColumns.MESSAGE_ID + " INT,"
+ PartColumns.TEXT + " TEXT,"
+ PartColumns.CONTENT_URI + " TEXT,"
+ PartColumns.CONTENT_TYPE + " TEXT,"
+ PartColumns.WIDTH + " INT DEFAULT("
+ MessagingContentProvider.UNSPECIFIED_SIZE + "),"
+ PartColumns.HEIGHT + " INT DEFAULT("
+ MessagingContentProvider.UNSPECIFIED_SIZE + "),"
+ PartColumns.TIMESTAMP + " INT, "
+ PartColumns.CONVERSATION_ID + " INT NOT NULL,"
+ "FOREIGN KEY (" + PartColumns.MESSAGE_ID + ") REFERENCES "
+ MESSAGES_TABLE + "(" + MessageColumns._ID + ") ON DELETE CASCADE "
+ "FOREIGN KEY (" + PartColumns.CONVERSATION_ID + ") REFERENCES "
+ CONVERSATIONS_TABLE + "(" + ConversationColumns._ID + ") ON DELETE CASCADE "
+ ");";
public static final String CREATE_PARTS_TRIGGER_SQL =
"CREATE TRIGGER " + PARTS_TABLE + "_TRIGGER" + " AFTER INSERT ON " + PARTS_TABLE
+ " FOR EACH ROW "
+ " BEGIN UPDATE " + PARTS_TABLE
+ " SET " + PartColumns.TIMESTAMP + "="
+ " (SELECT received_timestamp FROM " + MESSAGES_TABLE + " WHERE " + MESSAGES_TABLE
+ "." + MessageColumns._ID + "=" + "NEW." + PartColumns.MESSAGE_ID + ")"
+ " WHERE " + PARTS_TABLE + "." + PartColumns._ID + "=" + "NEW." + PartColumns._ID
+ "; END";
public static final String CREATE_MESSAGES_TRIGGER_SQL =
"CREATE TRIGGER " + MESSAGES_TABLE + "_TRIGGER" + " AFTER UPDATE OF "
+ MessageColumns.RECEIVED_TIMESTAMP + " ON " + MESSAGES_TABLE
+ " FOR EACH ROW BEGIN UPDATE " + PARTS_TABLE + " SET " + PartColumns.TIMESTAMP
+ " = NEW." + MessageColumns.RECEIVED_TIMESTAMP + " WHERE " + PARTS_TABLE + "."
+ PartColumns.MESSAGE_ID + " = NEW." + MessageColumns._ID
+ "; END;";
// Primary sort index for parts table : by message_id
private static final String PARTS_TABLE_MESSAGE_INDEX_SQL =
"CREATE INDEX index_" + PARTS_TABLE + "_message_id ON " + PARTS_TABLE + "("
+ PartColumns.MESSAGE_ID + ")";
// Participants table schema
public static class ParticipantColumns implements BaseColumns {
/* The subscription id for the sim associated with this self participant.
* Introduced in L. For earlier versions will always be default_sub_id (-1).
* For multi sim devices (or cases where the sim was changed) single device
* may have several different sub_id values */
public static final String SUB_ID = "sub_id";
/* The slot of the active SIM (inserted in the device) for this self-participant. If the
* self-participant doesn't correspond to any active SIM, this will be
* {@link android.telephony.SubscriptionManager#INVALID_SLOT_ID}.
* The column is ignored for all non-self participants.
*/
public static final String SIM_SLOT_ID = "sim_slot_id";
/* The phone number stored in a standard E164 format if possible. This is unique for a
* given participant. We can't handle multiple participants with the same phone number
* since we don't know which of them a message comes from. This can also be an email
* address, in which case this is the same as the displayed address */
public static final String NORMALIZED_DESTINATION = "normalized_destination";
/* The phone number as originally supplied and used for dialing. Not necessarily in E164
* format or unique */
public static final String SEND_DESTINATION = "send_destination";
/* The user-friendly formatting of the phone number according to the region setting of
* the device when the row was added. */
public static final String DISPLAY_DESTINATION = "display_destination";
/* A string with this participant's full name or a pretty printed phone number */
public static final String FULL_NAME = "full_name";
/* A string with just this participant's first name */
public static final String FIRST_NAME = "first_name";
/* A local URI to an asset for the icon for this participant */
public static final String PROFILE_PHOTO_URI = "profile_photo_uri";
/* Contact id for matching local contact for this participant */
public static final String CONTACT_ID = "contact_id";
/* String that contains hints on how to find contact information in a contact lookup */
public static final String LOOKUP_KEY = "lookup_key";
/* If this participant is blocked */
public static final String BLOCKED = "blocked";
/* The color of the subscription (FOR SELF PARTICIPANTS ONLY) */
public static final String SUBSCRIPTION_COLOR = "subscription_color";
/* The name of the subscription (FOR SELF PARTICIPANTS ONLY) */
public static final String SUBSCRIPTION_NAME = "subscription_name";
/* The exact destination stored in Contacts for this participant */
public static final String CONTACT_DESTINATION = "contact_destination";
}
// Participants table SQL
private static final String CREATE_PARTICIPANTS_TABLE_SQL =
"CREATE TABLE " + PARTICIPANTS_TABLE + "("
+ ParticipantColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ ParticipantColumns.SUB_ID + " INT DEFAULT("
+ ParticipantData.OTHER_THAN_SELF_SUB_ID + "),"
+ ParticipantColumns.SIM_SLOT_ID + " INT DEFAULT("
+ ParticipantData.INVALID_SLOT_ID + "),"
+ ParticipantColumns.NORMALIZED_DESTINATION + " TEXT,"
+ ParticipantColumns.SEND_DESTINATION + " TEXT,"
+ ParticipantColumns.DISPLAY_DESTINATION + " TEXT,"
+ ParticipantColumns.FULL_NAME + " TEXT,"
+ ParticipantColumns.FIRST_NAME + " TEXT,"
+ ParticipantColumns.PROFILE_PHOTO_URI + " TEXT, "
+ ParticipantColumns.CONTACT_ID + " INT DEFAULT( "
+ ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + "), "
+ ParticipantColumns.LOOKUP_KEY + " STRING, "
+ ParticipantColumns.BLOCKED + " INT DEFAULT(0), "
+ ParticipantColumns.SUBSCRIPTION_NAME + " TEXT, "
+ ParticipantColumns.SUBSCRIPTION_COLOR + " INT DEFAULT(0), "
+ ParticipantColumns.CONTACT_DESTINATION + " TEXT, "
+ "UNIQUE (" + ParticipantColumns.NORMALIZED_DESTINATION + ", "
+ ParticipantColumns.SUB_ID + ") ON CONFLICT FAIL" + ");";
private static final String CREATE_SELF_PARTICIPANT_SQL =
"INSERT INTO " + PARTICIPANTS_TABLE
+ " ( " + ParticipantColumns.SUB_ID + " ) VALUES ( %s )";
static String getCreateSelfParticipantSql(int subId) {
return String.format(CREATE_SELF_PARTICIPANT_SQL, subId);
}
// Conversation Participants table schema - contains a list of participants excluding the user
// in a given conversation.
public static class ConversationParticipantsColumns implements BaseColumns {
/* participant id of someone in this conversation */
public static final String PARTICIPANT_ID = "participant_id";
/* conversation id that this participant belongs to */
public static final String CONVERSATION_ID = "conversation_id";
}
// Conversation Participants table SQL
private static final String CREATE_CONVERSATION_PARTICIPANTS_TABLE_SQL =
"CREATE TABLE " + CONVERSATION_PARTICIPANTS_TABLE + "("
+ ConversationParticipantsColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ ConversationParticipantsColumns.CONVERSATION_ID + " INT,"
+ ConversationParticipantsColumns.PARTICIPANT_ID + " INT,"
+ "UNIQUE (" + ConversationParticipantsColumns.CONVERSATION_ID + ","
+ ConversationParticipantsColumns.PARTICIPANT_ID + ") ON CONFLICT FAIL, "
+ "FOREIGN KEY (" + ConversationParticipantsColumns.CONVERSATION_ID + ") "
+ "REFERENCES " + CONVERSATIONS_TABLE + "(" + ConversationColumns._ID + ")"
+ " ON DELETE CASCADE "
+ "FOREIGN KEY (" + ConversationParticipantsColumns.PARTICIPANT_ID + ")"
+ " REFERENCES " + PARTICIPANTS_TABLE + "(" + ParticipantColumns._ID + "));";
// Primary access pattern for conversation participants is to look them up for a specific
// conversation.
private static final String CONVERSATION_PARTICIPANTS_TABLE_CONVERSATION_ID_INDEX_SQL =
"CREATE INDEX index_" + CONVERSATION_PARTICIPANTS_TABLE + "_"
+ ConversationParticipantsColumns.CONVERSATION_ID
+ " ON " + CONVERSATION_PARTICIPANTS_TABLE
+ "(" + ConversationParticipantsColumns.CONVERSATION_ID + ")";
// View for getting parts which are for draft messages.
static final String DRAFT_PARTS_VIEW_SQL = "CREATE VIEW " +
DRAFT_PARTS_VIEW + " AS SELECT "
+ PARTS_TABLE + '.' + PartColumns._ID
+ " as " + PartColumns._ID + ", "
+ PARTS_TABLE + '.' + PartColumns.MESSAGE_ID
+ " as " + PartColumns.MESSAGE_ID + ", "
+ PARTS_TABLE + '.' + PartColumns.TEXT
+ " as " + PartColumns.TEXT + ", "
+ PARTS_TABLE + '.' + PartColumns.CONTENT_URI
+ " as " + PartColumns.CONTENT_URI + ", "
+ PARTS_TABLE + '.' + PartColumns.CONTENT_TYPE
+ " as " + PartColumns.CONTENT_TYPE + ", "
+ PARTS_TABLE + '.' + PartColumns.WIDTH
+ " as " + PartColumns.WIDTH + ", "
+ PARTS_TABLE + '.' + PartColumns.HEIGHT
+ " as " + PartColumns.HEIGHT + ", "
+ MESSAGES_TABLE + '.' + MessageColumns.CONVERSATION_ID
+ " as " + MessageColumns.CONVERSATION_ID + " "
+ " FROM " + MESSAGES_TABLE + " LEFT JOIN " + PARTS_TABLE + " ON ("
+ MESSAGES_TABLE + "." + MessageColumns._ID
+ "=" + PARTS_TABLE + "." + PartColumns.MESSAGE_ID + ")"
// Exclude draft messages from main view
+ " WHERE " + MESSAGES_TABLE + "." + MessageColumns.STATUS
+ " = " + MessageData.BUGLE_STATUS_OUTGOING_DRAFT;
// List of all our SQL tables
private static final String[] CREATE_TABLE_SQLS = new String[] {
CREATE_CONVERSATIONS_TABLE_SQL,
CREATE_MESSAGES_TABLE_SQL,
CREATE_PARTS_TABLE_SQL,
CREATE_PARTICIPANTS_TABLE_SQL,
CREATE_CONVERSATION_PARTICIPANTS_TABLE_SQL,
};
// List of all our indices
private static final String[] CREATE_INDEX_SQLS = new String[] {
CONVERSATIONS_TABLE_SMS_THREAD_ID_INDEX_SQL,
CONVERSATIONS_TABLE_ARCHIVE_STATUS_INDEX_SQL,
CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL,
MESSAGES_TABLE_SORT_INDEX_SQL,
MESSAGES_TABLE_STATUS_SEEN_INDEX_SQL,
PARTS_TABLE_MESSAGE_INDEX_SQL,
CONVERSATION_PARTICIPANTS_TABLE_CONVERSATION_ID_INDEX_SQL,
};
// List of all our SQL triggers
private static final String[] CREATE_TRIGGER_SQLS = new String[] {
CREATE_PARTS_TRIGGER_SQL,
CREATE_MESSAGES_TRIGGER_SQL,
};
// List of all our views
private static final String[] CREATE_VIEW_SQLS = new String[] {
ConversationListItemData.getConversationListViewSql(),
ConversationImagePartsView.getCreateSql(),
DRAFT_PARTS_VIEW_SQL,
};
private static final Object sLock = new Object();
private final Context mApplicationContext;
private static DatabaseHelper sHelperInstance; // Protected by sLock.
private final Object mDatabaseWrapperLock = new Object();
private DatabaseWrapper mDatabaseWrapper; // Protected by mDatabaseWrapperLock.
private final DatabaseUpgradeHelper mUpgradeHelper = new DatabaseUpgradeHelper();
/**
* Get a (singleton) instance of {@link DatabaseHelper}, creating one if there isn't one yet.
* This is the only public method for getting a new instance of the class.
* @param context Should be the application context (or something that will live for the
* lifetime of the application).
* @return The current (or a new) DatabaseHelper instance.
*/
public static DatabaseHelper getInstance(final Context context) {
synchronized (sLock) {
if (sHelperInstance == null) {
sHelperInstance = new DatabaseHelper(context);
}
return sHelperInstance;
}
}
/**
* Private constructor, used from {@link #getInstance()}.
* @param context Should be the application context (or something that will live for the
* lifetime of the application).
*/
private DatabaseHelper(final Context context) {
super(context, DATABASE_NAME, null, getDatabaseVersion(context), null);
mApplicationContext = context;
}
/**
* Get the (singleton) instance of @{link DatabaseWrapper}.
* <p>The database is always opened as a writeable database.
* @return The current (or a new) DatabaseWrapper instance.
*/
@DoesNotRunOnMainThread
DatabaseWrapper getDatabase() {
// We prevent the main UI thread from accessing the database here since we have to allow
// public access to this class to enable sub-packages to access data.
Assert.isNotMainThread();
synchronized (mDatabaseWrapperLock) {
if (mDatabaseWrapper == null) {
mDatabaseWrapper = new DatabaseWrapper(mApplicationContext, getWritableDatabase());
}
return mDatabaseWrapper;
}
}
@Override
public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
mUpgradeHelper.onDowngrade(db, oldVersion, newVersion);
}
/**
* Drops and recreates all tables.
*/
public static void rebuildTables(final SQLiteDatabase db) {
// Drop tables first, then views, and indices.
dropAllTables(db);
dropAllViews(db);
dropAllIndexes(db);
dropAllTriggers(db);
// Recreate the whole database.
createDatabase(db);
}
/**
* Drop and rebuild a given view.
*/
static void rebuildView(final SQLiteDatabase db, final String viewName,
final String createViewSql) {
dropView(db, viewName, true /* throwOnFailure */);
db.execSQL(createViewSql);
}
private static void dropView(final SQLiteDatabase db, final String viewName,
final boolean throwOnFailure) {
final String dropPrefix = "DROP VIEW IF EXISTS ";
try {
db.execSQL(dropPrefix + viewName);
} catch (final SQLException ex) {
if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) {
LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop view " + viewName + " "
+ ex);
}
if (throwOnFailure) {
throw ex;
}
}
}
public static void rebuildAllViews(final DatabaseWrapper db) {
for (final String sql : DatabaseHelper.CREATE_VIEW_SQLS) {
db.execSQL(sql);
}
}
/**
* Drops all user-defined tables from the given database.
*/
private static void dropAllTables(final SQLiteDatabase db) {
final Cursor tableCursor =
db.query(PRIMARY_TABLE, PRIMARY_COLUMNS, "type='table'", null, null, null, null);
if (tableCursor != null) {
try {
final String dropPrefix = "DROP TABLE IF EXISTS ";
while (tableCursor.moveToNext()) {
final String tableName = tableCursor.getString(0);
// Skip special tables
if (tableName.startsWith("android_") || tableName.startsWith("sqlite_")) {
continue;
}
try {
db.execSQL(dropPrefix + tableName);
} catch (final SQLException ex) {
if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) {
LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop table " + tableName + " "
+ ex);
}
}
}
} finally {
tableCursor.close();
}
}
}
/**
* Drops all user-defined triggers from the given database.
*/
private static void dropAllTriggers(final SQLiteDatabase db) {
final Cursor triggerCursor =
db.query(PRIMARY_TABLE, PRIMARY_COLUMNS, "type='trigger'", null, null, null, null);
if (triggerCursor != null) {
try {
final String dropPrefix = "DROP TRIGGER IF EXISTS ";
while (triggerCursor.moveToNext()) {
final String triggerName = triggerCursor.getString(0);
// Skip special tables
if (triggerName.startsWith("android_") || triggerName.startsWith("sqlite_")) {
continue;
}
try {
db.execSQL(dropPrefix + triggerName);
} catch (final SQLException ex) {
if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) {
LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop trigger " + triggerName +
" " + ex);
}
}
}
} finally {
triggerCursor.close();
}
}
}
/**
* Drops all user-defined views from the given database.
*/
public static void dropAllViews(final SQLiteDatabase db) {
final Cursor viewCursor =
db.query(PRIMARY_TABLE, PRIMARY_COLUMNS, "type='view'", null, null, null, null);
if (viewCursor != null) {
try {
while (viewCursor.moveToNext()) {
final String viewName = viewCursor.getString(0);
dropView(db, viewName, false /* throwOnFailure */);
}
} finally {
viewCursor.close();
}
}
}
/**
* Drops all user-defined views from the given database.
*/
private static void dropAllIndexes(final SQLiteDatabase db) {
final Cursor indexCursor =
db.query(PRIMARY_TABLE, PRIMARY_COLUMNS, "type='index'", null, null, null, null);
if (indexCursor != null) {
try {
final String dropPrefix = "DROP INDEX IF EXISTS ";
while (indexCursor.moveToNext()) {
final String indexName = indexCursor.getString(0);
try {
db.execSQL(dropPrefix + indexName);
} catch (final SQLException ex) {
if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) {
LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop index " + indexName + " "
+ ex);
}
}
}
} finally {
indexCursor.close();
}
}
}
private static void createDatabase(final SQLiteDatabase db) {
for (final String sql : CREATE_TABLE_SQLS) {
db.execSQL(sql);
}
for (final String sql : CREATE_INDEX_SQLS) {
db.execSQL(sql);
}
for (final String sql : CREATE_VIEW_SQLS) {
db.execSQL(sql);
}
for (final String sql : CREATE_TRIGGER_SQLS) {
db.execSQL(sql);
}
// Enable foreign key constraints
db.execSQL("PRAGMA foreign_keys=ON;");
// Add the default self participant. The default self will be assigned a proper slot id
// during participant refresh.
db.execSQL(getCreateSelfParticipantSql(ParticipantData.DEFAULT_SELF_SUB_ID));
DataModel.get().onCreateTables(db);
}
@Override
public void onCreate(SQLiteDatabase db) {
createDatabase(db);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
mUpgradeHelper.doOnUpgrade(db, oldVersion, newVersion);
}
}
@@ -0,0 +1,97 @@
/*
* Copyright (C) 2015 The Android Open Source 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;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import com.android.messaging.Factory;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
public class DatabaseUpgradeHelper {
private static final String TAG = LogUtil.BUGLE_DATABASE_TAG;
public void doOnUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
Assert.isTrue(newVersion >= oldVersion);
if (oldVersion == newVersion) {
return;
}
LogUtil.i(TAG, "Database upgrade started from version " + oldVersion + " to " + newVersion);
try {
doUpgradeWithExceptions(db, oldVersion, newVersion);
LogUtil.i(TAG, "Finished database upgrade");
} catch (final Exception ex) {
LogUtil.e(TAG, "Failed to perform db upgrade from version " +
oldVersion + " to version " + newVersion, ex);
DatabaseHelper.rebuildTables(db);
}
}
public void doUpgradeWithExceptions(final SQLiteDatabase db, final int oldVersion,
final int newVersion) throws Exception {
int currentVersion = oldVersion;
if (currentVersion < 2) {
currentVersion = upgradeToVersion2(db);
}
// Rebuild all the views
final Context context = Factory.get().getApplicationContext();
DatabaseHelper.dropAllViews(db);
DatabaseHelper.rebuildAllViews(new DatabaseWrapper(context, db));
// Finally, check if we have arrived at the final version.
checkAndUpdateVersionAtReleaseEnd(currentVersion, Integer.MAX_VALUE, newVersion);
}
private int upgradeToVersion2(final SQLiteDatabase db) {
db.execSQL("ALTER TABLE " + DatabaseHelper.CONVERSATIONS_TABLE + " ADD COLUMN " +
DatabaseHelper.ConversationColumns.IS_ENTERPRISE + " INT DEFAULT(0)");
LogUtil.i(TAG, "Ugraded database to version 2");
return 2;
}
/**
* 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
* current version to the end of the release, so that we may go on to the next release' upgrade
* path. Otherwise, if target version is within reach of the current release, but we are not
* at the target version, then throw an exception to force a table rebuild.
*/
private int checkAndUpdateVersionAtReleaseEnd(final int currentVersion,
final int maxVersionForRelease, final int targetVersion) throws Exception {
if (maxVersionForRelease < targetVersion) {
// Target version is beyond the current release. Snap to max version for the
// current release so we can go on to the upgrade path for the next release.
return maxVersionForRelease;
}
// If we are here, this means the current release' upgrade handler should upgrade to
// target version...
if (currentVersion != targetVersion) {
// No more upgrade handlers. So we can't possibly upgrade to the final version.
throw new Exception("Missing upgrade handler from version " +
currentVersion + " to version " + targetVersion);
}
// Upgrade succeeded.
return targetVersion;
}
public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
DatabaseHelper.rebuildTables(db);
LogUtil.e(TAG, "Database downgrade requested for version " +
oldVersion + " version " + newVersion + ", forcing db rebuild!");
}
}
@@ -0,0 +1,391 @@
/*
* 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;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteFullException;
import android.database.sqlite.SQLiteQueryBuilder;
import android.database.sqlite.SQLiteStatement;
import android.util.SparseArray;
import com.android.messaging.R;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.UiUtils;
import java.util.Locale;
import java.util.Stack;
public class DatabaseWrapper {
private static final String TAG = LogUtil.BUGLE_DATABASE_TAG;
private final SQLiteDatabase mDatabase;
private final Context mContext;
private final boolean mLog;
private static final int sTimingThreshold = 50; // in milliseconds
public static final int INDEX_INSERT_MESSAGE_PART = 0;
public static final int INDEX_INSERT_MESSAGE = 1;
public static final int INDEX_QUERY_CONVERSATIONS_LATEST_MESSAGE = 2;
public static final int INDEX_QUERY_MESSAGES_LATEST_MESSAGE = 3;
private final SparseArray<SQLiteStatement> mCompiledStatements;
static class TransactionData {
long time;
boolean transactionSuccessful;
}
// track transaction on a per thread basis
private static final ThreadLocal<Stack<TransactionData>> sTransactionDepth =
ThreadLocal.withInitial(Stack::new);
private static final String[] sFormatStrings = new String[] {
"took %d ms to %s",
" took %d ms to %s",
" took %d ms to %s",
};
DatabaseWrapper(final Context context, final SQLiteDatabase db) {
mLog = LogUtil.isLoggable(LogUtil.BUGLE_DATABASE_PERF_TAG, LogUtil.VERBOSE);
mDatabase = db;
mContext = context;
mCompiledStatements = new SparseArray<>();
}
public SQLiteStatement getStatementInTransaction(final int index, final String statement) {
// Use transaction to serialize access to statements
Assert.isTrue(mDatabase.inTransaction());
SQLiteStatement compiled = mCompiledStatements.get(index);
if (compiled == null) {
compiled = mDatabase.compileStatement(statement);
Assert.isTrue(compiled.toString().contains(statement.trim()));
mCompiledStatements.put(index, compiled);
}
return compiled;
}
private static void printTiming(final long t1, final String msg) {
final int transactionDepth = sTransactionDepth.get().size();
final long t2 = System.currentTimeMillis();
final long delta = t2 - t1;
if (delta > sTimingThreshold) {
LogUtil.v(LogUtil.BUGLE_DATABASE_PERF_TAG, String.format(Locale.US,
sFormatStrings[Math.min(sFormatStrings.length - 1, transactionDepth)],
delta,
msg));
}
}
public Context getContext() {
return mContext;
}
public void beginTransaction() {
final long t1 = System.currentTimeMillis();
// push the current time onto the transaction stack
final TransactionData f = new TransactionData();
f.time = t1;
sTransactionDepth.get().push(f);
mDatabase.beginTransaction();
}
public void setTransactionSuccessful() {
final TransactionData f = sTransactionDepth.get().peek();
f.transactionSuccessful = true;
mDatabase.setTransactionSuccessful();
}
public void endTransaction() {
long t1 = 0;
long transactionStartTime = 0;
final TransactionData f = sTransactionDepth.get().pop();
if (f.transactionSuccessful == false) {
LogUtil.w(TAG, "endTransaction without setting successful");
for (final StackTraceElement st : (new Exception()).getStackTrace()) {
LogUtil.w(TAG, " " + st.toString());
}
}
if (mLog) {
transactionStartTime = f.time;
t1 = System.currentTimeMillis();
}
try {
mDatabase.endTransaction();
} catch (SQLiteFullException ex) {
LogUtil.e(TAG, "Database full, unable to endTransaction", ex);
UiUtils.showToastAtBottom(R.string.db_full);
}
if (mLog) {
printTiming(t1, String.format(Locale.US,
">>> endTransaction (total for this transaction: %d)",
(System.currentTimeMillis() - transactionStartTime)));
}
}
public void yieldTransaction() {
long yieldStartTime = 0;
if (mLog) {
yieldStartTime = System.currentTimeMillis();
}
final boolean wasYielded = mDatabase.yieldIfContendedSafely();
if (wasYielded && mLog) {
printTiming(yieldStartTime, "yieldTransaction");
}
}
public void insertWithOnConflict(final String searchTable, final String nullColumnHack,
final ContentValues initialValues, final int conflictAlgorithm) {
long t1 = 0;
if (mLog) {
t1 = System.currentTimeMillis();
}
try {
mDatabase.insertWithOnConflict(searchTable, nullColumnHack, initialValues,
conflictAlgorithm);
} catch (SQLiteFullException ex) {
LogUtil.e(TAG, "Database full, unable to insertWithOnConflict", ex);
UiUtils.showToastAtBottom(R.string.db_full);
}
if (mLog) {
printTiming(t1, String.format(Locale.US,
"insertWithOnConflict with %s", searchTable));
}
}
public Cursor query(final String searchTable, final String[] projection,
final String selection, final String[] selectionArgs, final String groupBy,
final String having, final String orderBy, final String limit) {
long t1 = 0;
if (mLog) {
t1 = System.currentTimeMillis();
}
final Cursor cursor = mDatabase.query(searchTable, projection, selection, selectionArgs,
groupBy, having, orderBy, limit);
if (mLog) {
printTiming(
t1,
String.format(Locale.US, "query %s with %s ==> %d",
searchTable, selection, cursor.getCount()));
}
return cursor;
}
public Cursor query(final String searchTable, final String[] columns,
final String selection, final String[] selectionArgs, final String groupBy,
final String having, final String orderBy) {
return query(
searchTable, columns, selection, selectionArgs,
groupBy, having, orderBy, null);
}
public Cursor query(final SQLiteQueryBuilder qb,
final String[] projection, final String selection, final String[] queryArgs,
final String groupBy, final String having, final String sortOrder, final String limit) {
long t1 = 0;
if (mLog) {
t1 = System.currentTimeMillis();
}
final Cursor cursor = qb.query(mDatabase, projection, selection, queryArgs, groupBy,
having, sortOrder, limit);
if (mLog) {
printTiming(
t1,
String.format(Locale.US, "query %s with %s ==> %d",
qb.getTables(), selection, cursor.getCount()));
}
return cursor;
}
public long queryNumEntries(final String table, final String selection,
final String[] selectionArgs) {
long t1 = 0;
if (mLog) {
t1 = System.currentTimeMillis();
}
final long retval =
DatabaseUtils.queryNumEntries(mDatabase, table, selection, selectionArgs);
if (mLog){
printTiming(
t1,
String.format(Locale.US, "queryNumEntries %s with %s ==> %d", table,
selection, retval));
}
return retval;
}
public Cursor rawQuery(final String sql, final String[] args) {
long t1 = 0;
if (mLog) {
t1 = System.currentTimeMillis();
}
final Cursor cursor = mDatabase.rawQuery(sql, args);
if (mLog) {
printTiming(
t1,
String.format(Locale.US, "rawQuery %s ==> %d", sql, cursor.getCount()));
}
return cursor;
}
public int update(final String table, final ContentValues values,
final String selection, final String[] selectionArgs) {
long t1 = 0;
if (mLog) {
t1 = System.currentTimeMillis();
}
int count = 0;
try {
count = mDatabase.update(table, values, selection, selectionArgs);
} catch (SQLiteFullException ex) {
LogUtil.e(TAG, "Database full, unable to update", ex);
UiUtils.showToastAtBottom(R.string.db_full);
}
if (mLog) {
printTiming(t1, String.format(Locale.US, "update %s with %s ==> %d",
table, selection, count));
}
return count;
}
public int delete(final String table, final String whereClause, final String[] whereArgs) {
long t1 = 0;
if (mLog) {
t1 = System.currentTimeMillis();
}
int count = 0;
try {
count = mDatabase.delete(table, whereClause, whereArgs);
} catch (SQLiteFullException ex) {
LogUtil.e(TAG, "Database full, unable to delete", ex);
UiUtils.showToastAtBottom(R.string.db_full);
}
if (mLog) {
printTiming(t1,
String.format(Locale.US, "delete from %s with %s ==> %d", table,
whereClause, count));
}
return count;
}
public long insert(final String table, final String nullColumnHack,
final ContentValues values) {
long t1 = 0;
if (mLog) {
t1 = System.currentTimeMillis();
}
long rowId = -1;
try {
rowId = mDatabase.insert(table, nullColumnHack, values);
} catch (SQLiteFullException ex) {
LogUtil.e(TAG, "Database full, unable to insert", ex);
UiUtils.showToastAtBottom(R.string.db_full);
}
if (mLog) {
printTiming(t1, String.format(Locale.US, "insert to %s", table));
}
return rowId;
}
public long replace(final String table, final String nullColumnHack,
final ContentValues values) {
long t1 = 0;
if (mLog) {
t1 = System.currentTimeMillis();
}
long rowId = -1;
try {
rowId = mDatabase.replace(table, nullColumnHack, values);
} catch (SQLiteFullException ex) {
LogUtil.e(TAG, "Database full, unable to replace", ex);
UiUtils.showToastAtBottom(R.string.db_full);
}
if (mLog) {
printTiming(t1, String.format(Locale.US, "replace to %s", table));
}
return rowId;
}
public void setLocale(final Locale locale) {
mDatabase.setLocale(locale);
}
public void execSQL(final String sql, final String[] bindArgs) {
long t1 = 0;
if (mLog) {
t1 = System.currentTimeMillis();
}
try {
mDatabase.execSQL(sql, bindArgs);
} catch (SQLiteFullException ex) {
LogUtil.e(TAG, "Database full, unable to execSQL", ex);
UiUtils.showToastAtBottom(R.string.db_full);
}
if (mLog) {
printTiming(t1, String.format(Locale.US, "execSQL %s", sql));
}
}
public void execSQL(final String sql) {
long t1 = 0;
if (mLog) {
t1 = System.currentTimeMillis();
}
try {
mDatabase.execSQL(sql);
} catch (SQLiteFullException ex) {
LogUtil.e(TAG, "Database full, unable to execSQL", ex);
UiUtils.showToastAtBottom(R.string.db_full);
}
if (mLog) {
printTiming(t1, String.format(Locale.US, "execSQL %s", sql));
}
}
public int execSQLUpdateDelete(final String sql) {
long t1 = 0;
if (mLog) {
t1 = System.currentTimeMillis();
}
final SQLiteStatement statement = mDatabase.compileStatement(sql);
int rowsUpdated = 0;
try {
rowsUpdated = statement.executeUpdateDelete();
} catch (SQLiteFullException ex) {
LogUtil.e(TAG, "Database full, unable to execSQLUpdateDelete", ex);
UiUtils.showToastAtBottom(R.string.db_full);
}
if (mLog) {
printTiming(t1, String.format(Locale.US, "execSQLUpdateDelete %s", sql));
}
return rowsUpdated;
}
public SQLiteDatabase getDatabase() {
return mDatabase;
}
}
@@ -0,0 +1,154 @@
/*
* 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;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.Random;
/**
* A very simple content provider that can serve files.
*/
public abstract class FileProvider extends ContentProvider {
// Object to generate random id for temp images.
private static final Random RANDOM_ID = new Random();
abstract File getFile(final String path, final String extension);
private static final String FILE_EXTENSION_PARAM_KEY = "ext";
/**
* Check if filename conforms to requirement for our provider
* @param fileId filename (optionally starting with path character
* @return true if filename consists only of digits
*/
protected static boolean isValidFileId(final String fileId) {
// Ignore initial "/"
for (int index = (fileId.startsWith("/") ? 1 : 0); index < fileId.length(); index++) {
final Character c = fileId.charAt(index);
if (!Character.isDigit(c)) {
return false;
}
}
return true;
}
/**
* Create a temp file (to allow writing to that one particular file)
* @param file the file to create
* @return true if file successfully created
*/
protected static boolean ensureFileExists(final File file) {
try {
final File parentDir = file.getParentFile();
if (parentDir.exists() || parentDir.mkdirs()) {
return file.createNewFile();
}
} catch (final IOException e) {
// fail on exceptions creating the file
}
return false;
}
/**
* Build uri for a new temporary file (creating file)
* @param authority authority with which to populate uri
* @param extension optional file extension
* @return unique uri that can be used to write temporary files
*/
protected static Uri buildFileUri(final String authority, final String extension) {
final long fileId = Math.abs(RANDOM_ID.nextLong());
final Uri.Builder builder = (new Uri.Builder()).authority(authority).scheme(
ContentResolver.SCHEME_CONTENT);
builder.appendPath(String.valueOf(fileId));
if (!TextUtils.isEmpty(extension)) {
builder.appendQueryParameter(FILE_EXTENSION_PARAM_KEY, extension);
}
return builder.build();
}
@Override
public boolean onCreate() {
return true;
}
@Override
public int delete(final Uri uri, final String selection, final String[] selectionArgs) {
final String fileId = uri.getPath();
if (isValidFileId(fileId)) {
final File file = getFile(fileId, getExtensionFromUri(uri));
return file.delete() ? 1 : 0;
}
return 0;
}
@Override
public ParcelFileDescriptor openFile(final Uri uri, @NonNull final String fileMode)
throws FileNotFoundException {
final String fileId = uri.getPath();
if (isValidFileId(fileId)) {
final File file = getFile(fileId, getExtensionFromUri(uri));
final int mode =
(TextUtils.equals(fileMode, "r") ? ParcelFileDescriptor.MODE_READ_ONLY :
ParcelFileDescriptor.MODE_WRITE_ONLY | ParcelFileDescriptor.MODE_TRUNCATE);
return ParcelFileDescriptor.open(file, mode);
}
return null;
}
protected static String getExtensionFromUri(final Uri uri) {
return uri.getQueryParameter(FILE_EXTENSION_PARAM_KEY);
}
@Override
public Cursor query(@NonNull final Uri uri, final String[] projection, final String selection,
final String[] selectionArgs, final String sortOrder) {
// Don't support queries.
return null;
}
@Override
public Uri insert(@NonNull final Uri uri, final ContentValues values) {
// Don't support inserts.
return null;
}
@Override
public int update(@NonNull final Uri uri, final ContentValues values, final String selection,
final String[] selectionArgs) {
// Don't support updates.
return 0;
}
@Override
public String getType(@NonNull final Uri uri) {
// No need for mime types.
return null;
}
}
@@ -0,0 +1,190 @@
/*
* 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;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import androidx.collection.SimpleArrayMap;
import com.android.messaging.util.Assert;
import com.android.messaging.util.ContactUtil;
import java.util.ArrayList;
/**
* A cursor builder that takes the frequent contacts cursor and aggregate it with the all contacts
* cursor to fill in contact details such as phone numbers and strip away invalid contacts.
*
* Because the frequent contact list depends on the loading of two cursors, it needs to temporarily
* store the cursor that it receives with setFrequents() and setAllContacts() calls. Because it
* doesn't know which one will be finished first, it always checks whether both cursors are ready
* to pull data from and construct the aggregate cursor when it's ready to do so. Note that
* this cursor builder doesn't assume ownership of the cursors passed in - it merely references
* them and always does a isClosed() check before consuming them. The ownership still belongs to
* the loader framework and the cursor may be closed when the UI is torn down.
*/
public class FrequentContactsCursorBuilder {
private Cursor mAllContactsCursor;
private Cursor mFrequentContactsCursor;
/**
* Sets the frequent contacts cursor as soon as it is loaded, or null if it's reset.
*/
public void setFrequents(final Cursor frequentContactsCursor) {
mFrequentContactsCursor = frequentContactsCursor;
}
/**
* Sets the all contacts cursor as soon as it is loaded, or null if it's reset.
*/
public void setAllContacts(final Cursor allContactsCursor) {
mAllContactsCursor = allContactsCursor;
}
/**
* Reset this builder. Must be called when the consumer resets its data.
*/
public void resetBuilder() {
mAllContactsCursor = null;
mFrequentContactsCursor = null;
}
/**
* Attempt to build the cursor records from the frequent and all contacts cursor if they
* are both ready to be consumed.
* @return the frequent contact cursor if built successfully, or null if it can't be built yet.
*/
public Cursor build(boolean getAllContacts) {
if (mFrequentContactsCursor != null && mAllContactsCursor != null) {
Assert.isTrue(!mFrequentContactsCursor.isClosed());
Assert.isTrue(!mAllContactsCursor.isClosed());
// Frequent contacts cursor has one record per contact, plus it doesn't contain info
// such as phone number and type. In order for the records to be usable by Bugle, we
// would like to populate it with information from the all contacts cursor.
final MatrixCursor retCursor = new MatrixCursor(ContactUtil.PhoneQuery.PROJECTION);
// First, go through the frequents cursor and take note of all lookup keys and their
// corresponding rank in the frequents list.
final SimpleArrayMap<String, Integer> lookupKeyToRankMap = new SimpleArrayMap<>();
int oldPosition = mFrequentContactsCursor.getPosition();
int rank = 0;
mFrequentContactsCursor.moveToPosition(-1);
while (mFrequentContactsCursor.moveToNext()) {
final String lookupKey = mFrequentContactsCursor.getString(
ContactUtil.INDEX_LOOKUP_KEY_FREQUENT);
lookupKeyToRankMap.put(lookupKey, rank++);
}
mFrequentContactsCursor.moveToPosition(oldPosition);
// Second, go through the all contacts cursor once and retrieve all information
// (multiple phone numbers etc.) and store that in an array list. Since the all
// contacts list only contains phone contacts, this step will ensure that we filter
// out any invalid/email contacts in the frequents list.
final ArrayList<Object[]> rows = new ArrayList<>(mFrequentContactsCursor.getCount());
oldPosition = mAllContactsCursor.getPosition();
mAllContactsCursor.moveToPosition(-1);
while (mAllContactsCursor.moveToNext()) {
final String lookupKey = mAllContactsCursor.getString(ContactUtil.INDEX_LOOKUP_KEY);
if (lookupKeyToRankMap.containsKey(lookupKey) || getAllContacts) {
final Object[] row = new Object[ContactUtil.PhoneQuery.PROJECTION.length];
row[ContactUtil.INDEX_DATA_ID] =
mAllContactsCursor.getLong(ContactUtil.INDEX_DATA_ID);
row[ContactUtil.INDEX_CONTACT_ID] =
mAllContactsCursor.getLong(ContactUtil.INDEX_CONTACT_ID);
row[ContactUtil.INDEX_LOOKUP_KEY] =
mAllContactsCursor.getString(ContactUtil.INDEX_LOOKUP_KEY);
row[ContactUtil.INDEX_DISPLAY_NAME] =
mAllContactsCursor.getString(ContactUtil.INDEX_DISPLAY_NAME);
row[ContactUtil.INDEX_PHOTO_URI] =
mAllContactsCursor.getString(ContactUtil.INDEX_PHOTO_URI);
row[ContactUtil.INDEX_PHONE_EMAIL] =
mAllContactsCursor.getString(ContactUtil.INDEX_PHONE_EMAIL)
.replaceAll("[^\\d+]", "");
row[ContactUtil.INDEX_PHONE_EMAIL_TYPE] =
mAllContactsCursor.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE);
row[ContactUtil.INDEX_PHONE_EMAIL_LABEL] =
mAllContactsCursor.getString(ContactUtil.INDEX_PHONE_EMAIL_LABEL);
boolean numberAlreadyAdded = false;
for (Object[] oldRow : rows) {
final int idxType = ContactUtil.INDEX_PHONE_EMAIL_TYPE;
final int idxPhone = ContactUtil.INDEX_PHONE_EMAIL;
if (oldRow[idxType] == row[idxType] &&
oldRow[idxPhone].toString().equals(row[idxPhone].toString())) {
numberAlreadyAdded = true;
break;
}
}
if (!numberAlreadyAdded) {
rows.add(row);
}
}
}
mAllContactsCursor.moveToPosition(oldPosition);
// We can return all rows at this point, no sorting or further filtering needed
if (getAllContacts) {
for (final Object[] row : rows) {
retCursor.addRow(row);
}
return retCursor;
}
// Now we have a list of rows containing frequent contacts in alphabetical order.
// Therefore, sort all the rows according to their actual ranks in the frequents list.
rows.sort((lhs, rhs) -> {
final String lookupKeyLhs = (String) lhs[ContactUtil.INDEX_LOOKUP_KEY];
final String lookupKeyRhs = (String) rhs[ContactUtil.INDEX_LOOKUP_KEY];
Assert.isTrue(lookupKeyToRankMap.containsKey(lookupKeyLhs) &&
lookupKeyToRankMap.containsKey(lookupKeyRhs));
final int rankLhs = lookupKeyToRankMap.get(lookupKeyLhs);
final int rankRhs = lookupKeyToRankMap.get(lookupKeyRhs);
if (rankLhs < rankRhs) {
return -1;
} else if (rankLhs > rankRhs) {
return 1;
} else {
// Same rank, so it's two contact records for the same contact.
// Perform secondary sorting on the phone type. Always place
// mobile before everything else.
final int phoneTypeLhs = (int) lhs[ContactUtil.INDEX_PHONE_EMAIL_TYPE];
final int phoneTypeRhs = (int) rhs[ContactUtil.INDEX_PHONE_EMAIL_TYPE];
if (phoneTypeLhs == Phone.TYPE_MOBILE &&
phoneTypeRhs == Phone.TYPE_MOBILE) {
return 0;
} else if (phoneTypeLhs == Phone.TYPE_MOBILE) {
return -1;
} else if (phoneTypeRhs == Phone.TYPE_MOBILE) {
return 1;
} else {
// Use the default sort order, i.e. sort by phoneType value.
return Integer.compare(phoneTypeLhs, phoneTypeRhs);
}
}
});
// Finally, add all the rows to this cursor.
for (final Object[] row : rows) {
retCursor.addRow(row);
}
return retCursor;
}
return null;
}
}
@@ -0,0 +1,112 @@
/*
* 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;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.ContactsContract;
import android.provider.ContactsContract.Contacts;
import com.android.messaging.util.FallbackStrategies;
import com.android.messaging.util.FallbackStrategies.Strategy;
import com.android.messaging.util.LogUtil;
/**
* Helper for querying frequent (and/or starred) contacts.
*/
public class FrequentContactsCursorQueryData extends CursorQueryData {
private static final String TAG = LogUtil.BUGLE_TAG;
private static class FrequentContactsCursorLoader extends BoundCursorLoader {
private final Uri mOriginalUri;
FrequentContactsCursorLoader(String bindingId, Context context, Uri uri,
String[] projection, String selection, String[] selectionArgs, String sortOrder) {
super(bindingId, context, uri, projection, selection, selectionArgs, sortOrder);
mOriginalUri = uri;
}
@Override
public Cursor loadInBackground() {
return FallbackStrategies
.startWith(new PrimaryStrequentContactsQueryStrategy())
.thenTry(new FrequentOnlyContactsQueryStrategy())
.thenTry(new PhoneOnlyStrequentContactsQueryStrategy())
.execute(null);
}
private abstract class StrequentContactsQueryStrategy implements Strategy<Void, Cursor> {
@Override
public Cursor execute(Void params) {
final Uri uri = getUri();
if (uri != null) {
setUri(uri);
}
return FrequentContactsCursorLoader.super.loadInBackground();
}
protected abstract Uri getUri();
}
private class PrimaryStrequentContactsQueryStrategy extends StrequentContactsQueryStrategy {
@Override
protected Uri getUri() {
// Use the original URI requested.
return mOriginalUri;
}
}
private class FrequentOnlyContactsQueryStrategy extends StrequentContactsQueryStrategy {
@Override
protected Uri getUri() {
// Some phones have a buggy implementation of the Contacts provider which crashes
// when we query for strequent (starred+frequent) contacts (b/17991485).
// If this happens, switch to just querying for frequent contacts.
return Contacts.CONTENT_FREQUENT_URI;
}
}
private class PhoneOnlyStrequentContactsQueryStrategy extends
StrequentContactsQueryStrategy {
@Override
protected Uri getUri() {
// Some 3rd party ROMs have content provider
// implementation where invalid SQL queries are returned for regular strequent
// queries. Using strequent_phone_only query as a fallback to display only phone
// contacts. This is the last-ditch effort; if this fails, we will display an
// empty frequent list (b/18354836).
final String strequentQueryParam = ContactsContract.STREQUENT_PHONE_ONLY;
return Contacts.CONTENT_STREQUENT_URI.buildUpon()
.appendQueryParameter(strequentQueryParam, "true").build();
}
}
}
public FrequentContactsCursorQueryData(Context context, String[] projection,
String selection, String[] selectionArgs, String sortOrder) {
// TODO: Handle enterprise contacts post M once contacts provider supports it
super(context, Contacts.CONTENT_STREQUENT_URI, projection, selection, selectionArgs,
sortOrder);
}
@Override
public BoundCursorLoader createBoundCursorLoader(String bindingId) {
return new FrequentContactsCursorLoader(bindingId, mContext, mUri, mProjection, mSelection,
mSelectionArgs, mSortOrder);
}
}
@@ -0,0 +1,148 @@
/*
* 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;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.database.MatrixCursor.RowBuilder;
import android.net.Uri;
import android.provider.OpenableColumns;
import androidx.annotation.NonNull;
import androidx.collection.SimpleArrayMap;
import android.text.TextUtils;
import com.android.messaging.Factory;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import java.io.File;
import java.io.IOException;
import java.util.List;
/**
* A very simple content provider that can serve media files from our cache directory.
*/
public class MediaScratchFileProvider extends FileProvider {
private static final String TAG = LogUtil.BUGLE_TAG;
private static final SimpleArrayMap<Uri, String> sUriToDisplayNameMap = new SimpleArrayMap<>();
public static final String AUTHORITY =
"com.android.messaging.datamodel.MediaScratchFileProvider";
private static final String MEDIA_SCRATCH_SPACE_DIR = "mediascratchspace";
public static boolean isMediaScratchSpaceUri(final Uri uri) {
if (uri == null) {
return false;
}
final List<String> segments = uri.getPathSegments();
return (TextUtils.equals(uri.getScheme(), ContentResolver.SCHEME_CONTENT) &&
TextUtils.equals(uri.getAuthority(), AUTHORITY) &&
segments.size() == 1 && FileProvider.isValidFileId(segments.get(0)));
}
/**
* Returns a uri that can be used to access a raw mms file.
*
* @return the URI for an raw mms file
*/
public static Uri buildMediaScratchSpaceUri(final String extension) {
final Uri uri = FileProvider.buildFileUri(AUTHORITY, extension);
final File file = getFileWithExtension(uri.getPath(), extension);
if (!ensureFileExists(file)) {
LogUtil.e(TAG, "Failed to create temp file " + file.getAbsolutePath());
}
return uri;
}
public static File getFileFromUri(final Uri uri) {
Assert.equals(AUTHORITY, uri.getAuthority());
return getFileWithExtension(uri.getPath(), getExtensionFromUri(uri));
}
public static Uri.Builder getUriBuilder() {
return (new Uri.Builder()).authority(AUTHORITY).scheme(ContentResolver.SCHEME_CONTENT);
}
@Override
File getFile(final String path, final String extension) {
return getFileWithExtension(path, extension);
}
private static File getFileWithExtension(final String path, final String extension) {
final Context context = Factory.get().getApplicationContext();
final File filePath = new File(getDirectory(context),
TextUtils.isEmpty(extension) ? path : path + "." + extension);
try {
if (!filePath.getCanonicalPath()
.startsWith(getDirectory(context).getCanonicalPath())) {
LogUtil.e(TAG, "getFileWithExtension: path "
+ filePath.getCanonicalPath()
+ " does not start with "
+ getDirectory(context).getCanonicalPath());
return null;
}
} catch (IOException e) {
LogUtil.e(TAG, "getFileWithExtension: getCanonicalPath failed ", e);
return null;
}
return filePath;
}
private static File getDirectory(final Context context) {
return new File(context.getCacheDir(), MEDIA_SCRATCH_SPACE_DIR);
}
@Override
public Cursor query(@NonNull final Uri uri, final String[] projection, final String selection,
final String[] selectionArgs, final String sortOrder) {
if (projection != null && projection.length > 0 &&
TextUtils.equals(projection[0], OpenableColumns.DISPLAY_NAME) &&
isMediaScratchSpaceUri(uri)) {
// Retrieve the display name associated with a temp file. This is used by the Contacts
// ImportVCardActivity to retrieve the name of the contact(s) being imported.
String displayName;
synchronized (sUriToDisplayNameMap) {
displayName = sUriToDisplayNameMap.get(uri);
}
if (!TextUtils.isEmpty(displayName)) {
MatrixCursor cursor =
new MatrixCursor(new String[] { OpenableColumns.DISPLAY_NAME });
RowBuilder row = cursor.newRow();
row.add(displayName);
return cursor;
}
}
return null;
}
public static void addUriToDisplayNameEntry(final Uri scratchFileUri,
final String displayName) {
if (TextUtils.isEmpty(displayName)) {
return;
}
synchronized (sUriToDisplayNameMap) {
sUriToDisplayNameMap.put(scratchFileUri, displayName);
}
}
}
@@ -0,0 +1,76 @@
/*
* 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;
import com.android.messaging.Factory;
import java.util.HashSet;
/**
* Utility abstraction which allows MemoryCaches in an application to register and then when there
* is memory pressure provide a callback to reclaim the memory in the caches.
*/
public class MemoryCacheManager {
private final HashSet<MemoryCache> mMemoryCaches = new HashSet<>();
private final Object mMemoryCacheLock = new Object();
public static MemoryCacheManager get() {
return Factory.get().getMemoryCacheManager();
}
/**
* Extend this interface to provide a reclaim method on a memory cache.
*/
public interface MemoryCache {
void reclaim();
}
/**
* Register the memory cache with the application.
*/
public void registerMemoryCache(final MemoryCache cache) {
synchronized (mMemoryCacheLock) {
mMemoryCaches.add(cache);
}
}
/**
* Unregister the memory cache with the application.
*/
public void unregisterMemoryCache(final MemoryCache cache) {
synchronized (mMemoryCacheLock) {
mMemoryCaches.remove(cache);
}
}
/**
* Reclaim memory in all the memory caches in the application.
*/
@SuppressWarnings("unchecked")
public void reclaimMemory() {
// We're creating a cache copy in the lock to ensure we're not working on a concurrently
// modified set, then reclaim outside of the lock to minimize the time within the lock.
final HashSet<MemoryCache> shallowCopy;
synchronized (mMemoryCacheLock) {
shallowCopy = (HashSet<MemoryCache>) mMemoryCaches.clone();
}
for (final MemoryCache cache : shallowCopy) {
cache.reclaim();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
/*
* Copyright (C) 2015 The Android Open Source 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;
import android.telephony.SmsMessage;
import com.android.messaging.sms.MmsConfig;
public class MessageTextStats {
private boolean mMessageLengthRequiresMms;
private int mMessageCount;
private int mCodePointsRemainingInCurrentMessage;
public MessageTextStats() {
mCodePointsRemainingInCurrentMessage = Integer.MAX_VALUE;
}
public int getNumMessagesToBeSent() {
return mMessageCount;
}
public int getCodePointsRemainingInCurrentMessage() {
return mCodePointsRemainingInCurrentMessage;
}
public boolean getMessageLengthRequiresMms() {
return mMessageLengthRequiresMms;
}
public void updateMessageTextStats(final int selfSubId, final String messageText) {
final int[] params = SmsMessage.calculateLength(messageText, false);
/* SmsMessage.calculateLength returns an int[4] with:
* int[0] being the number of SMS's required,
* int[1] the number of code points used,
* int[2] is the number of code points remaining until the next message.
* int[3] is the encoding type that should be used for the message.
*/
mMessageCount = params[0];
mCodePointsRemainingInCurrentMessage = params[2];
final MmsConfig mmsConfig = MmsConfig.get(selfSubId);
if (!mmsConfig.getMultipartSmsEnabled() &&
!mmsConfig.getSendMultipartSmsAsSeparateMessages()) {
// The provider doesn't support multi-part sms's and we should use MMS to
// send multi-part sms, so as soon as the user types
// an sms longer than one segment, we have to turn the message into an mms.
mMessageLengthRequiresMms = mMessageCount > 1;
} else {
final int threshold = mmsConfig.getSmsToMmsTextThreshold();
mMessageLengthRequiresMms = threshold > 0 && mMessageCount > threshold;
}
// Some carriers require any SMS message longer than 80 to be sent as MMS
// see b/12122333
int smsToMmsLengthThreshold = mmsConfig.getSmsToMmsTextLengthThreshold();
if (smsToMmsLengthThreshold > 0) {
final int usedInCurrentMessage = params[1];
/*
* A little hacky way to find out if we should count characters in double bytes.
* SmsMessage.calculateLength counts message code units based on the characters
* in input. If all of them are ascii, the max length is
* SmsMessage.MAX_USER_DATA_SEPTETS (160). If any of them are double-byte, like
* Korean or Chinese, the max length is SmsMessage.MAX_USER_DATA_BYTES (140) bytes
* (70 code units).
* Here we check if the total code units we can use is smaller than 140. If so,
* we know we should count threshold in double-byte, so divide the threshold by 2.
* In this way, we will count Korean text correctly with regard to the length threshold.
*/
if (usedInCurrentMessage + mCodePointsRemainingInCurrentMessage
< SmsMessage.MAX_USER_DATA_BYTES) {
smsToMmsLengthThreshold /= 2;
}
if (usedInCurrentMessage > smsToMmsLengthThreshold) {
mMessageLengthRequiresMms = true;
}
}
}
}
@@ -0,0 +1,453 @@
/*
* 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;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.sqlite.SQLiteQueryBuilder;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns;
import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
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.ParticipantData;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.PhoneUtils;
import com.android.messaging.widget.BugleWidgetProvider;
import com.android.messaging.widget.WidgetConversationProvider;
import java.io.FileDescriptor;
import java.io.FileNotFoundException;
import java.io.PrintWriter;
/**
* A centralized provider for Uris exposed by Bugle.
* */
public class MessagingContentProvider extends ContentProvider {
private static final String TAG = LogUtil.BUGLE_TAG;
public static final String AUTHORITY =
"com.android.messaging.datamodel.MessagingContentProvider";
private static final String CONTENT_AUTHORITY = "content://" + AUTHORITY + '/';
// Conversations query
private static final String CONVERSATIONS_QUERY = "conversations";
public static final Uri CONVERSATIONS_URI = Uri.parse(CONTENT_AUTHORITY + CONVERSATIONS_QUERY);
static final Uri PARTS_URI = Uri.parse(CONTENT_AUTHORITY + DatabaseHelper.PARTS_TABLE);
// Messages query
private static final String MESSAGES_QUERY = "messages";
static final Uri MESSAGES_URI = Uri.parse(CONTENT_AUTHORITY + MESSAGES_QUERY);
public static final Uri CONVERSATION_MESSAGES_URI = Uri.parse(CONTENT_AUTHORITY +
MESSAGES_QUERY + "/conversation");
// Conversation participants query
private static final String PARTICIPANTS_QUERY = "participants";
static class ConversationParticipantsQueryColumns extends ParticipantColumns {
static final String CONVERSATION_ID = ConversationParticipantsColumns.CONVERSATION_ID;
}
static final Uri CONVERSATION_PARTICIPANTS_URI = Uri.parse(CONTENT_AUTHORITY +
PARTICIPANTS_QUERY + "/conversation");
public static final Uri PARTICIPANTS_URI = Uri.parse(CONTENT_AUTHORITY + PARTICIPANTS_QUERY);
// Conversation images query
private static final String CONVERSATION_IMAGES_QUERY = "conversation_images";
public static final Uri CONVERSATION_IMAGES_URI = Uri.parse(CONTENT_AUTHORITY +
CONVERSATION_IMAGES_QUERY);
private static final String DRAFT_IMAGES_QUERY = "draft_images";
public static final Uri DRAFT_IMAGES_URI = Uri.parse(CONTENT_AUTHORITY +
DRAFT_IMAGES_QUERY);
/**
* Notifies that <i>all</i> data exposed by the provider needs to be refreshed.
* <p>
* <b>IMPORTANT!</b> You probably shouldn't be calling this. Prefer to notify more specific
* uri's instead. Currently only sync uses this, because sync can potentially update many
* different tables at once.
*/
public static void notifyEverythingChanged() {
final Uri uri = Uri.parse(CONTENT_AUTHORITY);
final Context context = Factory.get().getApplicationContext();
final ContentResolver cr = context.getContentResolver();
cr.notifyChange(uri, null);
// Notify any conversations widgets the conversation list has changed.
BugleWidgetProvider.notifyConversationListChanged(context);
// Notify all conversation widgets to update.
WidgetConversationProvider.notifyMessagesChanged(context, null /*conversationId*/);
}
/**
* Build a participant uri from the conversation id.
*/
public static Uri buildConversationParticipantsUri(final String conversationId) {
final Uri.Builder builder = CONVERSATION_PARTICIPANTS_URI.buildUpon();
builder.appendPath(conversationId);
return builder.build();
}
public static void notifyParticipantsChanged(final String conversationId) {
final Uri uri = buildConversationParticipantsUri(conversationId);
final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver();
cr.notifyChange(uri, null);
}
public static void notifyAllMessagesChanged() {
final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver();
cr.notifyChange(CONVERSATION_MESSAGES_URI, null);
}
public static void notifyAllParticipantsChanged() {
final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver();
cr.notifyChange(CONVERSATION_PARTICIPANTS_URI, null);
}
// Default value for unknown dimension of image
public static final int UNSPECIFIED_SIZE = -1;
// Internal
private static final int CONVERSATIONS_QUERY_CODE = 10;
private static final int CONVERSATION_QUERY_CODE = 20;
private static final int CONVERSATION_MESSAGES_QUERY_CODE = 30;
private static final int CONVERSATION_PARTICIPANTS_QUERY_CODE = 40;
private static final int CONVERSATION_IMAGES_QUERY_CODE = 50;
private static final int DRAFT_IMAGES_QUERY_CODE = 60;
private static final int PARTICIPANTS_QUERY_CODE = 70;
// TODO: Move to a better structured URI namespace.
private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH);
static {
sURIMatcher.addURI(AUTHORITY, CONVERSATIONS_QUERY, CONVERSATIONS_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, CONVERSATIONS_QUERY + "/*", CONVERSATION_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, MESSAGES_QUERY + "/conversation/*",
CONVERSATION_MESSAGES_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY + "/conversation/*",
CONVERSATION_PARTICIPANTS_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY, PARTICIPANTS_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, CONVERSATION_IMAGES_QUERY + "/*",
CONVERSATION_IMAGES_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, DRAFT_IMAGES_QUERY + "/*",
DRAFT_IMAGES_QUERY_CODE);
}
/**
* Build a messages uri from the conversation id.
*/
public static Uri buildConversationMessagesUri(final String conversationId) {
final Uri.Builder builder = CONVERSATION_MESSAGES_URI.buildUpon();
builder.appendPath(conversationId);
return builder.build();
}
public static void notifyMessagesChanged(final String conversationId) {
final Uri uri = buildConversationMessagesUri(conversationId);
final Context context = Factory.get().getApplicationContext();
final ContentResolver cr = context.getContentResolver();
cr.notifyChange(uri, null);
notifyConversationListChanged();
// Notify the widget the messages changed
WidgetConversationProvider.notifyMessagesChanged(context, conversationId);
}
/**
* Build a conversation metadata uri from a conversation id.
*/
public static Uri buildConversationMetadataUri(final String conversationId) {
final Uri.Builder builder = CONVERSATIONS_URI.buildUpon();
builder.appendPath(conversationId);
return builder.build();
}
public static void notifyConversationMetadataChanged(final String conversationId) {
final Uri uri = buildConversationMetadataUri(conversationId);
final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver();
cr.notifyChange(uri, null);
notifyConversationListChanged();
}
public static void notifyPartsChanged() {
final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver();
cr.notifyChange(PARTS_URI, null);
}
public static void notifyConversationListChanged() {
final Context context = Factory.get().getApplicationContext();
final ContentResolver cr = context.getContentResolver();
cr.notifyChange(CONVERSATIONS_URI, null);
// Notify the widget the conversation list changed
BugleWidgetProvider.notifyConversationListChanged(context);
}
/**
* Build a conversation images uri from a conversation id.
*/
public static Uri buildConversationImagesUri(final String conversationId) {
final Uri.Builder builder = CONVERSATION_IMAGES_URI.buildUpon();
builder.appendPath(conversationId);
return builder.build();
}
/**
* Build a draft images uri from a conversation id.
*/
public static Uri buildDraftImagesUri(final String conversationId) {
final Uri.Builder builder = DRAFT_IMAGES_URI.buildUpon();
builder.appendPath(conversationId);
return builder.build();
}
private DatabaseHelper mDatabaseHelper;
private DatabaseWrapper mDatabaseWrapper;
public MessagingContentProvider() {
super();
}
private DatabaseWrapper getDatabaseWrapper() {
if (mDatabaseWrapper == null) {
mDatabaseWrapper = mDatabaseHelper.getDatabase();
}
return mDatabaseWrapper;
}
@Override
public Cursor query(@NonNull final Uri uri, final String[] projection, String selection,
final String[] selectionArgs, String sortOrder) {
final SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
String[] queryArgs = selectionArgs;
final int match = sURIMatcher.match(uri);
String groupBy = null;
String limit = null;
switch (match) {
case CONVERSATIONS_QUERY_CODE:
queryBuilder.setTables(ConversationListItemData.getConversationListView());
// Hide empty conversations (ones with 0 sort_timestamp)
queryBuilder.appendWhere(ConversationColumns.SORT_TIMESTAMP + " > 0 ");
break;
case CONVERSATION_QUERY_CODE:
queryBuilder.setTables(ConversationListItemData.getConversationListView());
if (uri.getPathSegments().size() == 2) {
queryBuilder.appendWhere(ConversationColumns._ID + "=?");
// Get the conversation id from the uri
queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(1));
} else {
throw new IllegalArgumentException("Malformed URI " + uri);
}
break;
case CONVERSATION_PARTICIPANTS_QUERY_CODE:
queryBuilder.setTables(DatabaseHelper.PARTICIPANTS_TABLE);
if (uri.getPathSegments().size() == 3 &&
TextUtils.equals(uri.getPathSegments().get(1), "conversation")) {
queryBuilder.appendWhere(ParticipantColumns._ID + " IN ( " + "SELECT "
+ ConversationParticipantsColumns.PARTICIPANT_ID + " AS "
+ ParticipantColumns._ID
+ " FROM " + DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE
+ " WHERE " + ConversationParticipantsColumns.CONVERSATION_ID
+ " =? UNION SELECT " + ParticipantColumns._ID + " FROM "
+ DatabaseHelper.PARTICIPANTS_TABLE + " WHERE "
+ ParticipantColumns.SUB_ID + " != "
+ ParticipantData.OTHER_THAN_SELF_SUB_ID + " )");
// Get the conversation id from the uri
queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(2));
} else {
throw new IllegalArgumentException("Malformed URI " + uri);
}
break;
case PARTICIPANTS_QUERY_CODE:
queryBuilder.setTables(DatabaseHelper.PARTICIPANTS_TABLE);
if (uri.getPathSegments().size() != 1) {
throw new IllegalArgumentException("Malformed URI " + uri);
}
break;
case CONVERSATION_MESSAGES_QUERY_CODE:
if (uri.getPathSegments().size() == 3 &&
TextUtils.equals(uri.getPathSegments().get(1), "conversation")) {
// Get the conversation id from the uri
final String conversationId = uri.getPathSegments().get(2);
// We need to handle this query differently, instead of falling through to the
// generic query call at the bottom. For performance reasons, the conversation
// messages query is executed as a raw query. It is invalid to specify
// selection/sorting for this query.
if (selection == null && selectionArgs == null && sortOrder == null) {
return queryConversationMessages(conversationId, uri);
} else {
throw new IllegalArgumentException(
"Cannot set selection or sort order with this query");
}
} else {
throw new IllegalArgumentException("Malformed URI " + uri);
}
case CONVERSATION_IMAGES_QUERY_CODE:
queryBuilder.setTables(ConversationImagePartsView.getViewName());
if (uri.getPathSegments().size() == 2) {
// Exclude draft.
queryBuilder.appendWhere(
ConversationImagePartsView.Columns.CONVERSATION_ID + " =? AND " +
ConversationImagePartsView.Columns.STATUS + "<>" +
MessageData.BUGLE_STATUS_OUTGOING_DRAFT);
// Get the conversation id from the uri
queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(1));
} else {
throw new IllegalArgumentException("Malformed URI " + uri);
}
break;
case DRAFT_IMAGES_QUERY_CODE:
queryBuilder.setTables(ConversationImagePartsView.getViewName());
if (uri.getPathSegments().size() == 2) {
// Draft only.
queryBuilder.appendWhere(
ConversationImagePartsView.Columns.CONVERSATION_ID + " =? AND " +
ConversationImagePartsView.Columns.STATUS + "=" +
MessageData.BUGLE_STATUS_OUTGOING_DRAFT);
// Get the conversation id from the uri
queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(1));
} else {
throw new IllegalArgumentException("Malformed URI " + uri);
}
break;
default: {
throw new IllegalArgumentException("Unknown URI " + uri);
}
}
final Cursor cursor = getDatabaseWrapper().query(queryBuilder, projection, selection,
queryArgs, groupBy, null, sortOrder, limit);
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;
}
private Cursor queryConversationMessages(final String conversationId, final Uri notifyUri) {
final String[] queryArgs = { conversationId };
final Cursor cursor = getDatabaseWrapper().rawQuery(
ConversationMessageData.getConversationMessagesQuerySql(), queryArgs);
cursor.setNotificationUri(getContext().getContentResolver(), notifyUri);
return cursor;
}
@Override
public String getType(@NonNull final Uri uri) {
final StringBuilder sb = new
StringBuilder("vnd.android.cursor.dir/vnd.android.messaging.");
switch (sURIMatcher.match(uri)) {
case CONVERSATIONS_QUERY_CODE: {
sb.append(CONVERSATIONS_QUERY);
break;
}
default: {
throw new IllegalArgumentException("Unknown URI: " + uri);
}
}
return sb.toString();
}
protected DatabaseHelper getDatabase() {
return DatabaseHelper.getInstance(getContext());
}
@Override
public ParcelFileDescriptor openFile(@NonNull final Uri uri, @NonNull final String fileMode)
throws FileNotFoundException {
throw new IllegalArgumentException("openFile not supported: " + uri);
}
@Override
public Uri insert(@NonNull final Uri uri, final ContentValues values) {
throw new IllegalStateException("Insert not supported " + uri);
}
@Override
public int delete(@NonNull final Uri uri, final String selection, final String[] selectionArgs) {
throw new IllegalArgumentException("Delete not supported: " + uri);
}
@Override
public int update(@NonNull final Uri uri, final ContentValues values, final String selection,
final String[] selectionArgs) {
throw new IllegalArgumentException("Update not supported: " + uri);
}
/**
* Prepends new arguments to the existing argument list.
*
* @param oldArgList The current list of arguments. May be {@code null}
* @param args The new arguments to prepend
* @return A new argument list with the given arguments prepended
*/
private String[] prependArgs(final String[] oldArgList, final String... args) {
if (args == null || args.length == 0) {
return oldArgList;
}
final int oldArgCount = (oldArgList == null ? 0 : oldArgList.length);
final int newArgCount = args.length;
final String[] newArgs = new String[oldArgCount + newArgCount];
System.arraycopy(args, 0, newArgs, 0, newArgCount);
if (oldArgCount > 0) {
System.arraycopy(oldArgList, 0, newArgs, newArgCount, oldArgCount);
}
return newArgs;
}
/**
* {@inheritDoc}
*/
@Override
public void dump(final FileDescriptor fd, final PrintWriter writer, final String[] args) {
// First dump out the default SMS app package name
String defaultSmsApp = PhoneUtils.getDefault().getDefaultSmsApp();
if (TextUtils.isEmpty(defaultSmsApp)) {
defaultSmsApp = "None";
}
writer.println("Default SMS app: " + defaultSmsApp);
}
@Override
public boolean onCreate() {
// This is going to wind up calling into createDatabase() below.
mDatabaseHelper = getDatabase();
// We cannot initialize mDatabaseWrapper yet as the Factory may not be initialized
return true;
}
}
@@ -0,0 +1,84 @@
/*
* 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;
import android.content.Context;
import android.net.Uri;
import com.android.messaging.Factory;
import com.android.messaging.util.LogUtil;
import java.io.File;
import java.io.IOException;
/**
* A very simple content provider that can serve mms files from our cache directory.
*/
public class MmsFileProvider extends FileProvider {
private static final String TAG = LogUtil.BUGLE_TAG;
static final String AUTHORITY = "com.android.messaging.datamodel.MmsFileProvider";
private static final String RAW_MMS_DIR = "rawmms";
/**
* Returns a uri that can be used to access a raw mms file.
*
* @return the URI for an raw mms file
*/
public static Uri buildRawMmsUri() {
final Uri uri = FileProvider.buildFileUri(AUTHORITY, null);
final File file = getFile(uri.getPath());
if (!ensureFileExists(file)) {
LogUtil.e(TAG, "Failed to create temp file " + file.getAbsolutePath());
}
return uri;
}
@Override
File getFile(final String path, final String extension) {
return getFile(path);
}
public static File getFile(final Uri uri) {
return getFile(uri.getPath());
}
private static File getFile(final String path) {
final Context context = Factory.get().getApplicationContext();
final File filePath = new File(getDirectory(context), path + ".dat");
try {
if (!filePath.getCanonicalPath()
.startsWith(getDirectory(context).getCanonicalPath())) {
LogUtil.e(TAG, "getFile: path "
+ filePath.getCanonicalPath()
+ " does not start with "
+ getDirectory(context).getCanonicalPath());
return null;
}
} catch (IOException e) {
LogUtil.e(TAG, "getFile: getCanonicalPath failed ", e);
return null;
}
return filePath;
}
private static File getDirectory(final Context context) {
return new File(context.getCacheDir(), RAW_MMS_DIR);
}
}
@@ -0,0 +1,133 @@
/*
* 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;
import android.app.IntentService;
import android.content.Intent;
import android.os.Bundle;
import androidx.core.app.RemoteInput;
import android.telephony.TelephonyManager;
import android.text.TextUtils;
import com.android.messaging.datamodel.action.InsertNewMessageAction;
import com.android.messaging.datamodel.action.UpdateMessageNotificationAction;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.ui.conversationlist.ConversationListActivity;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.UriUtil;
/**
* Respond to a special intent and send an SMS message without the user's intervention, unless
* the intent extra "showUI" is true.
*/
public class NoConfirmationSmsSendService extends IntentService {
private static final String TAG = LogUtil.BUGLE_TAG;
private static final String EXTRA_SUBSCRIPTION = "subscription";
public static final String EXTRA_SELF_ID = "self_id";
public NoConfirmationSmsSendService() {
// Class name will be the thread name.
super(NoConfirmationSmsSendService.class.getName());
// Intent should be redelivered if the process gets killed before completing the job.
setIntentRedelivery(true);
}
@Override
protected void onHandleIntent(final Intent intent) {
LogUtil.v(TAG, "NoConfirmationSmsSendService onHandleIntent");
final String action = intent.getAction();
if (!TelephonyManager.ACTION_RESPOND_VIA_MESSAGE.equals(action)) {
LogUtil.v(TAG, "NoConfirmationSmsSendService onHandleIntent wrong action: " + action);
return;
}
final Bundle extras = intent.getExtras();
if (extras == null) {
LogUtil.v(TAG, "Called to send SMS but no extras");
return;
}
// Get all possible extras from intent
final String conversationId =
intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID);
final String selfId = intent.getStringExtra(EXTRA_SELF_ID);
final boolean requiresMms = intent.getBooleanExtra(UIIntents.UI_INTENT_EXTRA_REQUIRES_MMS,
false);
final String message = getText(intent, Intent.EXTRA_TEXT);
final String subject = getText(intent, Intent.EXTRA_SUBJECT);
final int subId = extras.getInt(EXTRA_SUBSCRIPTION, ParticipantData.DEFAULT_SELF_SUB_ID);
// Get a comma-separated list of recipients
final String recipients = UriUtil.parseRecipientsFromSmsMmsUri(intent.getData());
if (TextUtils.isEmpty(recipients) && TextUtils.isEmpty(conversationId)) {
LogUtil.v(TAG, "Both conversationId and recipient(s) cannot be empty");
return;
}
if (extras.getBoolean("showUI", false)) {
startActivity(new Intent(this, ConversationListActivity.class));
} else {
if (TextUtils.isEmpty(message)) {
LogUtil.v(TAG, "Message cannot be empty");
return;
}
// TODO: it's possible that a long message would require sending it via mms,
// but we're not testing for that here and we're sending the message as an sms.
if (TextUtils.isEmpty(conversationId)) {
InsertNewMessageAction.insertNewMessage(subId, recipients, message, subject);
} else {
MessageData messageData;
if (requiresMms) {
LogUtil.v(TAG, "Auto-sending MMS message in conversation: " +
conversationId);
messageData = MessageData.createDraftMmsMessage(conversationId, selfId, message,
subject);
} else {
LogUtil.v(TAG, "Auto-sending SMS message in conversation: " +
conversationId);
messageData = MessageData.createDraftSmsMessage(conversationId, selfId,
message);
}
InsertNewMessageAction.insertNewMessage(messageData);
}
UpdateMessageNotificationAction.updateMessageNotification();
}
}
private String getText(final Intent intent, final String textType) {
final String message = intent.getStringExtra(textType);
if (message == null) {
final Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
if (remoteInput != null) {
final CharSequence extra = remoteInput.getCharSequence(textType);
if (extra != null) {
return extra.toString();
}
}
}
return message;
}
}
@@ -0,0 +1,122 @@
/*
* 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;
import android.app.PendingIntent;
import android.net.Uri;
import androidx.core.app.NotificationCompat;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.ConversationIdSet;
import java.util.ArrayList;
import java.util.HashSet;
/**
* Base class for representing notifications. The main reason for this class is that in order to
* show pictures or avatars they might need to be loaded in the background. This class and
* subclasses can do the main work to get the notification ready and then wait until any images
* that are needed are ready before posting.
*
* The creation of a notification is split into two parts. The NotificationState ctor should
* setup the basic information including the mContentIntent. A Notification Builder is created in
* RealTimeChatNotifications and passed to the build() method of each notification where the
* Notification is fully specified.
*
* TODO: There is still some duplication and inconsistency in the utility functions and
* placement of different building blocks across notification types (e.g. summary text for accounts)
*/
public abstract class NotificationState {
private static final int CONTENT_INTENT_REQUEST_CODE_OFFSET = 0;
private static final int CLEAR_INTENT_REQUEST_CODE_OFFSET = 1;
private static final int NUM_REQUEST_CODES_NEEDED = 2;
public interface FailedMessageQuery {
String FAILED_MESSAGES_WHERE_CLAUSE =
"((" + MessageColumns.STATUS + " = " +
MessageData.BUGLE_STATUS_OUTGOING_FAILED + " OR " +
MessageColumns.STATUS + " = " +
MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED + ") AND " +
DatabaseHelper.MessageColumns.SEEN + " = 0)";
String FAILED_ORDER_BY = DatabaseHelper.MessageColumns.CONVERSATION_ID + ", " +
DatabaseHelper.MessageColumns.SENT_TIMESTAMP + " asc";
}
public final ConversationIdSet mConversationIds;
public final HashSet<String> mPeople;
public NotificationCompat.Style mNotificationStyle;
public NotificationCompat.Builder mNotificationBuilder;
public boolean mCanceled;
public int mType;
public int mBaseRequestCode;
public ArrayList<Uri> mParticipantAvatarsUris = null;
public ArrayList<Uri> mParticipantContactUris = null;
NotificationState(final ConversationIdSet conversationIds) {
mConversationIds = conversationIds;
mPeople = new HashSet<>();
}
/**
* The intent to be triggered when the notification is dismissed.
*/
public abstract PendingIntent getClearIntent();
protected Uri getAttachmentUri() {
return null;
}
// Returns the mime type of the attachment (See ContentType class for definitions)
protected String getAttachmentType() {
return null;
}
/**
* Build the notification using the given builder.
* @return The style of the notification.
*/
protected abstract NotificationCompat.Style build(NotificationCompat.Builder builder);
/**
* Reserves request codes for this notification type. By default 2 codes are reserved, one for
* the main intent and another for the cancel intent. Override this function to reserve more.
*/
public int getNumRequestCodesNeeded() {
return NUM_REQUEST_CODES_NEEDED;
}
public int getClearIntentRequestCode() {
return mBaseRequestCode + CLEAR_INTENT_REQUEST_CODE_OFFSET;
}
/**
* Gets the appropriate icon needed for notifications.
*/
public abstract int getIcon();
/**
* @return the notification priority level for this notification.
*/
public abstract int getPriority();
public long getLatestReceivedTimestamp() {
return Long.MIN_VALUE;
}
}
@@ -0,0 +1,695 @@
/*
* 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;
import android.content.ContentValues;
import android.database.ContentObserver;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.graphics.Color;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import androidx.collection.ArrayMap;
import android.telephony.SubscriptionInfo;
import android.text.TextUtils;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns;
import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.datamodel.data.ParticipantData.ParticipantsQuery;
import com.android.messaging.ui.UIIntents;
import com.android.messaging.util.Assert;
import com.android.messaging.util.ContactUtil;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.OsUtil;
import com.android.messaging.util.PhoneUtils;
import com.google.common.base.Joiner;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Utility class for refreshing participant information based on matching contact. This updates
* 1. name, photo_uri, matching contact_id of participants.
* 2. generated_name of conversations.
*
* There are two kinds of participant refreshes,
* 1. Full refresh, this is triggered at application start or activity resumes after contact
* change is detected.
* 2. Partial refresh, this is triggered when a participant is added to a conversation. This
* normally happens during SMS sync.
*/
public class ParticipantRefresh {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
/**
* Refresh all participants including ones that were resolved before.
*/
public static final int REFRESH_MODE_FULL = 0;
/**
* Refresh all unresolved participants.
*/
public static final int REFRESH_MODE_INCREMENTAL = 1;
/**
* Force refresh all self participants.
*/
public static final int REFRESH_MODE_SELF_ONLY = 2;
public static class ConversationParticipantsQuery {
public static final String[] PROJECTION = new String[] {
ConversationParticipantsColumns._ID,
ConversationParticipantsColumns.CONVERSATION_ID,
ConversationParticipantsColumns.PARTICIPANT_ID
};
public static final int INDEX_ID = 0;
public static final int INDEX_CONVERSATION_ID = 1;
public static final int INDEX_PARTICIPANT_ID = 2;
}
// Track whether observer is initialized or not.
private static volatile boolean sObserverInitialized = false;
private static final Object sLock = new Object();
private static final AtomicBoolean sFullRefreshScheduled = new AtomicBoolean(false);
private static final Runnable sFullRefreshRunnable = () -> {
final boolean oldScheduled = sFullRefreshScheduled.getAndSet(false);
Assert.isTrue(oldScheduled);
refreshParticipants(REFRESH_MODE_FULL);
};
private static final Runnable sSelfOnlyRefreshRunnable = () ->
refreshParticipants(REFRESH_MODE_SELF_ONLY);
/**
* A customized content resolver to track contact changes.
*/
public static class ContactContentObserver extends ContentObserver {
private volatile boolean mContactChanged = false;
public ContactContentObserver() {
super(null);
}
@Override
public void onChange(final boolean selfChange) {
super.onChange(selfChange);
LogUtil.v(TAG, "Contacts changed");
mContactChanged = true;
}
public boolean getContactChanged() {
return mContactChanged;
}
public void resetContactChanged() {
mContactChanged = false;
}
public void initialize() {
// TODO: Handle enterprise contacts post M once contacts provider supports it
Factory.get().getApplicationContext().getContentResolver().registerContentObserver(
Phone.CONTENT_URI, true, this);
mContactChanged = true; // Force a full refresh on initialization.
}
}
/**
* Refresh participants only if needed, i.e., application start or contact changed.
*/
public static void refreshParticipantsIfNeeded() {
if (ParticipantRefresh.getNeedFullRefresh() &&
sFullRefreshScheduled.compareAndSet(false, true)) {
LogUtil.v(TAG, "Started full participant refresh");
Executors.newSingleThreadExecutor().execute(sFullRefreshRunnable);
} else {
LogUtil.v(TAG, "Skipped full participant refresh");
}
}
/**
* Refresh self participants on subscription or settings change.
*/
public static void refreshSelfParticipants() {
Executors.newSingleThreadExecutor().execute(sSelfOnlyRefreshRunnable);
}
private static boolean getNeedFullRefresh() {
final ContactContentObserver observer = Factory.get().getContactContentObserver();
if (observer == null) {
// If there is no observer (for unittest cases), we don't need to refresh participants.
return false;
}
if (!sObserverInitialized) {
synchronized (sLock) {
if (!sObserverInitialized) {
observer.initialize();
sObserverInitialized = true;
}
}
}
return observer.getContactChanged();
}
private static void resetNeedFullRefresh() {
final ContactContentObserver observer = Factory.get().getContactContentObserver();
if (observer != null) {
observer.resetContactChanged();
}
}
/**
* This class is totally static. Make constructor to be private so that an instance
* of this class would not be created by by mistake.
*/
private ParticipantRefresh() {
}
/**
* Refresh participants in Bugle.
*
* @param refreshMode the refresh mode desired. See {@link #REFRESH_MODE_FULL},
* {@link #REFRESH_MODE_INCREMENTAL}, and {@link #REFRESH_MODE_SELF_ONLY}
*/
static void refreshParticipants(final int refreshMode) {
Assert.inRange(refreshMode, REFRESH_MODE_FULL, REFRESH_MODE_SELF_ONLY);
switch (refreshMode) {
case REFRESH_MODE_FULL:
LogUtil.v(TAG, "Start full participant refresh");
break;
case REFRESH_MODE_INCREMENTAL:
LogUtil.v(TAG, "Start partial participant refresh");
break;
case REFRESH_MODE_SELF_ONLY:
LogUtil.v(TAG, "Start self participant refresh");
break;
}
if (!ContactUtil.hasReadContactsPermission() || !OsUtil.hasPhonePermission()) {
LogUtil.v(TAG, "Skipping participant referesh because of permissions");
return;
}
if (refreshMode == REFRESH_MODE_FULL) {
// resetNeedFullRefresh right away so that we will skip duplicated full refresh
// requests.
resetNeedFullRefresh();
}
if (refreshMode == REFRESH_MODE_FULL || refreshMode == REFRESH_MODE_SELF_ONLY) {
refreshSelfParticipantList();
}
final ArrayList<String> changedParticipants = new ArrayList<>();
String selection = null;
String[] selectionArgs = null;
if (refreshMode == REFRESH_MODE_INCREMENTAL) {
// In case of incremental refresh, filter out participants that are already resolved.
selection = ParticipantColumns.CONTACT_ID + "=?";
selectionArgs = new String[] {
String.valueOf(ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED) };
} else if (refreshMode == REFRESH_MODE_SELF_ONLY) {
// In case of self-only refresh, filter out non-self participants.
selection = SELF_PARTICIPANTS_CLAUSE;
selectionArgs = null;
}
final DatabaseWrapper db = DataModel.get().getDatabase();
Cursor cursor = null;
boolean selfUpdated = false;
try {
cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE,
ParticipantsQuery.PROJECTION, selection, selectionArgs, null, null, null);
if (cursor != null) {
while (cursor.moveToNext()) {
try {
final ParticipantData participantData =
ParticipantData.getFromCursor(cursor);
if (refreshParticipant(db, participantData)) {
if (participantData.isSelf()) {
selfUpdated = true;
}
updateParticipant(db, participantData);
final String id = participantData.getId();
changedParticipants.add(id);
}
} catch (final Exception exception) {
// Failure to update one participant shouldn't cancel the entire refresh.
// Log the failure so we know what's going on and resume the loop.
LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "ParticipantRefresh: Failed to " +
"update participant", exception);
}
}
}
} finally {
if (cursor != null) {
cursor.close();
}
}
LogUtil.v(TAG, "Number of participants refreshed:" + changedParticipants.size());
// Refresh conversations for participants that are changed.
if (changedParticipants.size() > 0) {
BugleDatabaseOperations.refreshConversationsForParticipants(changedParticipants);
}
if (selfUpdated) {
// Boom
MessagingContentProvider.notifyAllParticipantsChanged();
MessagingContentProvider.notifyAllMessagesChanged();
}
}
private static final String SELF_PARTICIPANTS_CLAUSE = ParticipantColumns.SUB_ID
+ " NOT IN ( "
+ ParticipantData.OTHER_THAN_SELF_SUB_ID
+ " )";
private static Set<Integer> getExistingSubIds() {
final DatabaseWrapper db = DataModel.get().getDatabase();
final HashSet<Integer> existingSubIds = new HashSet<>();
try (Cursor cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE,
ParticipantsQuery.PROJECTION,
SELF_PARTICIPANTS_CLAUSE, null, null, null, null)) {
if (cursor != null) {
while (cursor.moveToNext()) {
final int subId = cursor.getInt(ParticipantsQuery.INDEX_SUB_ID);
existingSubIds.add(subId);
}
}
}
return existingSubIds;
}
private static final String UPDATE_SELF_PARTICIPANT_SUBSCRIPTION_SQL =
"UPDATE " + DatabaseHelper.PARTICIPANTS_TABLE + " SET "
+ ParticipantColumns.SIM_SLOT_ID + " = %d, "
+ ParticipantColumns.SUBSCRIPTION_COLOR + " = %d, "
+ ParticipantColumns.SUBSCRIPTION_NAME + " = %s "
+ " WHERE %s";
static String getUpdateSelfParticipantSubscriptionInfoSql(final int slotId,
final int subscriptionColor, final String subscriptionName, final String where) {
return String.format((Locale) null /* construct SQL string without localization */,
UPDATE_SELF_PARTICIPANT_SUBSCRIPTION_SQL,
slotId, subscriptionColor, subscriptionName, where);
}
/**
* Ensure that there is a self participant corresponding to every active SIM. Also, ensure
* that any other older SIM self participants are marked as inactive.
*/
private static void refreshSelfParticipantList() {
final DatabaseWrapper db = DataModel.get().getDatabase();
final List<SubscriptionInfo> subInfoRecords =
PhoneUtils.getDefault().getActiveSubscriptionInfoList();
final ArrayMap<Integer, SubscriptionInfo> activeSubscriptionIdToRecordMap =
new ArrayMap<>();
db.beginTransaction();
final Set<Integer> existingSubIds = getExistingSubIds();
try {
if (subInfoRecords != null) {
for (final SubscriptionInfo subInfoRecord : subInfoRecords) {
final int subId = subInfoRecord.getSubscriptionId();
// If its a new subscription, add it to the database.
if (!existingSubIds.contains(subId)) {
db.execSQL(DatabaseHelper.getCreateSelfParticipantSql(subId));
// Add it to the local set to guard against duplicated entries returned
// by subscription manager.
existingSubIds.add(subId);
}
activeSubscriptionIdToRecordMap.put(subId, subInfoRecord);
if (subId == PhoneUtils.getDefault().getDefaultSmsSubscriptionId()) {
// This is the system default subscription, so update the default self.
activeSubscriptionIdToRecordMap.put(ParticipantData.DEFAULT_SELF_SUB_ID,
subInfoRecord);
}
}
}
// For subscriptions already in the database, refresh ParticipantColumns.SIM_SLOT_ID.
for (final Integer subId : activeSubscriptionIdToRecordMap.keySet()) {
final SubscriptionInfo record = activeSubscriptionIdToRecordMap.get(subId);
final String displayName =
DatabaseUtils.sqlEscapeString(record.getDisplayName().toString());
db.execSQL(getUpdateSelfParticipantSubscriptionInfoSql(record.getSimSlotIndex(),
record.getIconTint(), displayName,
ParticipantColumns.SUB_ID + " = " + subId));
}
db.execSQL(getUpdateSelfParticipantSubscriptionInfoSql(
ParticipantData.INVALID_SLOT_ID, Color.TRANSPARENT, "''",
ParticipantColumns.SUB_ID + " NOT IN (" +
Joiner.on(", ").join(activeSubscriptionIdToRecordMap.keySet()) + ")"));
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
// Fix up conversation self ids by reverting to default self for conversations whose self
// ids are no longer active.
refreshConversationSelfIds();
}
/**
* Refresh one participant.
* @return true if the ParticipantData was changed
*/
public static boolean refreshParticipant(final DatabaseWrapper db,
final ParticipantData participantData) {
boolean updated = false;
if (participantData.isSelf()) {
final int selfChange = refreshFromSelfProfile(db, participantData);
if (selfChange == SELF_PROFILE_EXISTS) {
// If a self-profile exists, it takes precedence over Contacts data. So we are done.
return true;
}
updated = (selfChange == SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED);
// Fall-through and try to update based on Contacts data
}
updated |= refreshFromContacts(db, participantData);
return updated;
}
private static final int SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED = 1;
private static final int SELF_PROFILE_EXISTS = 2;
private static int refreshFromSelfProfile(final DatabaseWrapper db,
final ParticipantData participantData) {
int changed = 0;
// Refresh the phone number based on information from telephony
if (participantData.updatePhoneNumberForSelfIfChanged()) {
changed = SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED;
}
// Refresh the subscription info based on information from SubscriptionManager.
final SubscriptionInfo subscriptionInfo =
PhoneUtils.get(participantData.getSubId()).getActiveSubscriptionInfo();
if (participantData.updateSubscriptionInfoForSelfIfChanged(subscriptionInfo)) {
changed = SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED;
}
// For self participant, try getting name/avatar from self profile in CP2 first.
// TODO: in case of multi-sim, profile would not be able to be used for
// different numbers. Need to figure out that.
try (Cursor selfCursor = ContactUtil.getSelf(db.getContext()).performSynchronousQuery()) {
if (selfCursor != null && selfCursor.getCount() > 0) {
selfCursor.moveToNext();
final long selfContactId = selfCursor.getLong(ContactUtil.INDEX_CONTACT_ID);
participantData.setContactId(selfContactId);
participantData.setFullName(selfCursor.getString(
ContactUtil.INDEX_DISPLAY_NAME));
participantData.setFirstName(
ContactUtil.lookupFirstName(db.getContext(), selfContactId));
participantData.setProfilePhotoUri(selfCursor.getString(
ContactUtil.INDEX_PHOTO_URI));
participantData.setLookupKey(selfCursor.getString(
ContactUtil.INDEX_SELF_QUERY_LOOKUP_KEY));
return SELF_PROFILE_EXISTS;
}
} catch (final Exception exception) {
// It's possible for contact query to fail and we don't want that to crash our app.
// However, we need to at least log the exception so we know something was wrong.
LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Participant refresh: failed to refresh " +
"participant. exception=" + exception);
}
return changed;
}
private static boolean refreshFromContacts(final DatabaseWrapper db,
final ParticipantData participantData) {
final String normalizedDestination = participantData.getNormalizedDestination();
final long currentContactId = participantData.getContactId();
final String currentDisplayName = participantData.getFullName();
final String currentFirstName = participantData.getFirstName();
final String currentPhotoUri = participantData.getProfilePhotoUri();
final String currentContactDestination = participantData.getContactDestination();
Cursor matchingContactCursor = null;
long matchingContactId = -1;
String matchingDisplayName = null;
String matchingFirstName = null;
String matchingPhotoUri = null;
String matchingLookupKey = null;
String matchingDestination = null;
boolean updated = false;
if (TextUtils.isEmpty(normalizedDestination)) {
// The normalized destination can be "" for the self id if we can't get it from the
// SIM. Some contact providers throw an IllegalArgumentException if you lookup "",
// so we early out.
return false;
}
try {
matchingContactCursor = ContactUtil.lookupDestination(db.getContext(),
normalizedDestination).performSynchronousQuery();
if (matchingContactCursor == null || matchingContactCursor.getCount() == 0) {
// If there is no match, mark the participant as contact not found.
if (currentContactId != ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND) {
participantData.setContactId(ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND);
participantData.setFullName(null);
participantData.setFirstName(null);
participantData.setProfilePhotoUri(null);
participantData.setLookupKey(null);
updated = true;
}
return updated;
}
while (matchingContactCursor.moveToNext()) {
final long contactId = matchingContactCursor.getLong(ContactUtil.INDEX_CONTACT_ID);
// Pick either the first contact or the contact with same id as previous matched
// contact id.
if (matchingContactId == -1 || currentContactId == contactId) {
matchingContactId = contactId;
matchingDisplayName = matchingContactCursor.getString(
ContactUtil.INDEX_DISPLAY_NAME);
matchingFirstName = ContactUtil.lookupFirstName(db.getContext(), contactId);
matchingPhotoUri = matchingContactCursor.getString(
ContactUtil.INDEX_PHOTO_URI);
matchingLookupKey = matchingContactCursor.getString(
ContactUtil.INDEX_LOOKUP_KEY);
matchingDestination = matchingContactCursor.getString(
ContactUtil.INDEX_PHONE_EMAIL);
}
// There is no need to try other contacts if the current contactId was not filled...
if (currentContactId < 0
// or we found the matching contact id
|| currentContactId == contactId) {
break;
}
}
} catch (final Exception exception) {
// It's possible for contact query to fail and we don't want that to crash our app.
// However, we need to at least log the exception so we know something was wrong.
LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Participant refresh: failed to refresh " +
"participant. exception=" + exception);
return false;
} finally {
if (matchingContactCursor != null) {
matchingContactCursor.close();
}
}
// Update participant only if something changed.
final boolean isContactIdChanged = (matchingContactId != currentContactId);
final boolean isDisplayNameChanged =
!TextUtils.equals(matchingDisplayName, currentDisplayName);
final boolean isFirstNameChanged = !TextUtils.equals(matchingFirstName, currentFirstName);
final boolean isPhotoUrlChanged = !TextUtils.equals(matchingPhotoUri, currentPhotoUri);
final boolean isDestinationChanged = !TextUtils.equals(matchingDestination,
currentContactDestination);
if (isContactIdChanged || isDisplayNameChanged || isFirstNameChanged || isPhotoUrlChanged
|| isDestinationChanged) {
participantData.setContactId(matchingContactId);
participantData.setFullName(matchingDisplayName);
participantData.setFirstName(matchingFirstName);
participantData.setProfilePhotoUri(matchingPhotoUri);
participantData.setLookupKey(matchingLookupKey);
participantData.setContactDestination(matchingDestination);
if (isDestinationChanged) {
// Update the send destination to the new one entered by user in Contacts.
participantData.setSendDestination(matchingDestination);
}
updated = true;
}
return updated;
}
/**
* Update participant with matching contact's contactId, displayName and photoUri.
*/
private static void updateParticipant(final DatabaseWrapper db,
final ParticipantData participantData) {
final ContentValues values = new ContentValues();
if (participantData.isSelf()) {
// Self participants can refresh their normalized phone numbers
values.put(ParticipantColumns.NORMALIZED_DESTINATION,
participantData.getNormalizedDestination());
values.put(ParticipantColumns.DISPLAY_DESTINATION,
participantData.getDisplayDestination());
}
values.put(ParticipantColumns.CONTACT_ID, participantData.getContactId());
values.put(ParticipantColumns.LOOKUP_KEY, participantData.getLookupKey());
values.put(ParticipantColumns.FULL_NAME, participantData.getFullName());
values.put(ParticipantColumns.FIRST_NAME, participantData.getFirstName());
values.put(ParticipantColumns.PROFILE_PHOTO_URI, participantData.getProfilePhotoUri());
values.put(ParticipantColumns.CONTACT_DESTINATION, participantData.getContactDestination());
values.put(ParticipantColumns.SEND_DESTINATION, participantData.getSendDestination());
db.beginTransaction();
try {
db.update(DatabaseHelper.PARTICIPANTS_TABLE, values, ParticipantColumns._ID + "=?",
new String[] { participantData.getId() });
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
/**
* Get a list of inactive self ids in the participants table.
*/
private static List<String> getInactiveSelfParticipantIds() {
final DatabaseWrapper db = DataModel.get().getDatabase();
final List<String> inactiveSelf = new ArrayList<>();
final String selection = ParticipantColumns.SIM_SLOT_ID + "=? AND " +
SELF_PARTICIPANTS_CLAUSE;
try (Cursor cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE,
new String[]{ParticipantColumns._ID},
selection, new String[]{String.valueOf(ParticipantData.INVALID_SLOT_ID)},
null, null, null)) {
if (cursor != null) {
while (cursor.moveToNext()) {
final String participantId = cursor.getString(0);
inactiveSelf.add(participantId);
}
}
}
return inactiveSelf;
}
/**
* Gets a list of conversations with the given self ids.
*/
private static List<String> getConversationsWithSelfParticipantIds(final List<String> selfIds) {
final DatabaseWrapper db = DataModel.get().getDatabase();
final List<String> conversationIds = new ArrayList<>();
Cursor cursor = null;
try {
final StringBuilder selectionList = new StringBuilder();
for (int i = 0; i < selfIds.size(); i++) {
selectionList.append('?');
if (i < selfIds.size() - 1) {
selectionList.append(',');
}
}
final String selection =
ConversationColumns.CURRENT_SELF_ID + " IN (" + selectionList + ")";
cursor = db.query(DatabaseHelper.CONVERSATIONS_TABLE,
new String[] { ConversationColumns._ID },
selection, selfIds.toArray(new String[0]),
null, null, null);
if (cursor != null) {
while (cursor.moveToNext()) {
final String conversationId = cursor.getString(0);
conversationIds.add(conversationId);
}
}
} finally {
if (cursor != null) {
cursor.close();
}
}
return conversationIds;
}
/**
* Refresh one conversation's self id.
*/
private static void updateConversationSelfId(final String conversationId,
final String selfId) {
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
BugleDatabaseOperations.updateConversationSelfIdInTransaction(db, conversationId,
selfId);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
MessagingContentProvider.notifyMessagesChanged(conversationId);
MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
UIIntents.get().broadcastConversationSelfIdChange(db.getContext(), conversationId, selfId);
}
/**
* After refreshing the self participant list, find all conversations with inactive self ids,
* and switch them back to system default.
*/
private static void refreshConversationSelfIds() {
final List<String> inactiveSelfs = getInactiveSelfParticipantIds();
if (inactiveSelfs.size() == 0) {
return;
}
final List<String> conversationsToRefresh =
getConversationsWithSelfParticipantIds(inactiveSelfs);
if (conversationsToRefresh.size() == 0) {
return;
}
final DatabaseWrapper db = DataModel.get().getDatabase();
final ParticipantData defaultSelf =
BugleDatabaseOperations.getOrCreateSelf(db, ParticipantData.DEFAULT_SELF_SUB_ID);
if (defaultSelf != null) {
for (final String conversationId : conversationsToRefresh) {
updateConversationSelfId(conversationId, defaultSelf.getId());
}
}
}
}
@@ -0,0 +1,456 @@
/*
* 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;
import android.content.Context;
import android.content.pm.PackageManager;
import android.database.ContentObserver;
import android.net.Uri;
import android.provider.Telephony;
import androidx.collection.LongSparseArray;
import com.android.messaging.datamodel.action.SyncMessagesAction;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.BugleGservicesKeys;
import com.android.messaging.util.BuglePrefs;
import com.android.messaging.util.BuglePrefsKeys;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.OsUtil;
import com.android.messaging.util.PhoneUtils;
import com.google.common.collect.Lists;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
/**
* This class manages message sync with the Telephony SmsProvider/MmsProvider.
*/
public class SyncManager {
private static final String TAG = LogUtil.BUGLE_TAG;
/**
* Record of any user customization to conversation settings
*/
public static class ConversationCustomization {
private final boolean mArchived;
public ConversationCustomization(final boolean archived) {
mArchived = archived;
}
public boolean isArchived() {
return mArchived;
}
}
SyncManager() {
}
/**
* Timestamp of in progress sync - used to keep track of whether sync is running
*/
private long mSyncInProgressTimestamp = -1;
/**
* Timestamp of current sync batch upper bound - used to determine if message makes batch dirty
*/
private long mCurrentUpperBoundTimestamp = -1;
/**
* Timestamp of messages inserted since sync batch started - used to determine if batch dirty
*/
private long mMaxRecentChangeTimestamp = -1L;
private final ThreadInfoCache mThreadInfoCache = new ThreadInfoCache();
/**
* User customization to conversations. If this is set, we need to recover them after
* a full sync.
*/
private LongSparseArray<ConversationCustomization> mCustomization = null;
/**
* Start an incremental sync (backed off a few seconds)
*/
public static void sync() {
SyncMessagesAction.sync();
}
/**
* Start an incremental sync (with no backoff)
*/
public static void immediateSync() {
SyncMessagesAction.immediateSync();
}
/**
* Start a full sync (for debugging)
*/
public static void forceSync() {
SyncMessagesAction.fullSync();
}
/**
* Called from data model thread when starting a sync batch
* @param upperBoundTimestamp upper bound timestamp for sync batch
*/
public synchronized void startSyncBatch(final long upperBoundTimestamp) {
Assert.isTrue(mCurrentUpperBoundTimestamp < 0);
mCurrentUpperBoundTimestamp = upperBoundTimestamp;
mMaxRecentChangeTimestamp = -1L;
}
/**
* Called from data model thread at end of batch to determine if any messages added in window
* @param lowerBoundTimestamp lower bound timestamp for sync batch
* @return true if message added within window from lower to upper bound timestamp of batch
*/
public synchronized boolean isBatchDirty(final long lowerBoundTimestamp) {
Assert.isTrue(mCurrentUpperBoundTimestamp >= 0);
final long max = mMaxRecentChangeTimestamp;
final boolean dirty = (max >= 0 && max >= lowerBoundTimestamp);
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncManager: Sync batch of messages from " + lowerBoundTimestamp
+ " to " + mCurrentUpperBoundTimestamp + " is "
+ (dirty ? "DIRTY" : "clean") + "; max change timestamp = "
+ mMaxRecentChangeTimestamp);
}
mCurrentUpperBoundTimestamp = -1L;
mMaxRecentChangeTimestamp = -1L;
return dirty;
}
/**
* Called from data model or background worker thread to indicate start of message add process
* (add must complete on that thread before action transitions to new thread/stage)
* @param timestamp timestamp of message being added
*/
public synchronized void onNewMessageInserted(final long timestamp) {
if (mCurrentUpperBoundTimestamp >= 0 && timestamp <= mCurrentUpperBoundTimestamp) {
// Message insert in current sync window
mMaxRecentChangeTimestamp = Math.max(mCurrentUpperBoundTimestamp, timestamp);
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " before upper bound of "
+ "current sync batch " + mCurrentUpperBoundTimestamp);
}
} else if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " after upper bound of "
+ "current sync batch " + mCurrentUpperBoundTimestamp);
}
}
/**
* Synchronously checks whether sync is allowed and starts sync if allowed
* @param full - true indicates a full (not incremental) sync operation
* @param startTimestamp - starttimestamp for this sync (if allowed)
* @return - true if sync should start
*/
public synchronized boolean shouldSync(final boolean full, final long startTimestamp) {
LogUtil.v(TAG, "SyncManager: Checking shouldSync " + (full ? "full " : "")
+ "at " + startTimestamp);
if (full) {
final long delayUntilFullSync = delayUntilFullSync(startTimestamp);
if (delayUntilFullSync > 0) {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncManager: Full sync requested for " + startTimestamp
+ " delayed for " + delayUntilFullSync + " ms");
}
return false;
}
}
if (isSyncing()) {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncManager: Not allowed to " + (full ? "full " : "")
+ "sync yet; still running sync started at " + mSyncInProgressTimestamp);
}
return false;
}
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncManager: Starting " + (full ? "full " : "") + "sync at "
+ startTimestamp);
}
mSyncInProgressTimestamp = startTimestamp;
return true;
}
/**
* Return delay (in ms) until allowed to run a full sync (0 meaning can run immediately)
* @param startTimestamp Timestamp used to start the sync
* @return 0 if allowed to run now, else delay in ms
*/
public long delayUntilFullSync(final long startTimestamp) {
final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
final long lastFullSyncTime = prefs.getLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, -1L);
final long smsFullSyncBackoffTimeMillis =
BugleGservicesKeys.SMS_FULL_SYNC_BACKOFF_TIME_MILLIS_DEFAULT;
final long noFullSyncBefore = (lastFullSyncTime < 0 ? startTimestamp :
lastFullSyncTime + smsFullSyncBackoffTimeMillis);
final long delayUntilFullSync = noFullSyncBefore - startTimestamp;
if (delayUntilFullSync > 0) {
return delayUntilFullSync;
}
return 0;
}
/**
* Check if sync currently in progress (public for asserts/logging).
*/
public synchronized boolean isSyncing() {
return (mSyncInProgressTimestamp >= 0);
}
/**
* Check if sync batch should be in progress - compares upperBound with in memory value
* @param upperBoundTimestamp - upperbound timestamp for sync batch
* @return - true if timestamps match (otherwise batch is orphan from older process)
*/
public synchronized boolean isSyncing(final long upperBoundTimestamp) {
Assert.isTrue(upperBoundTimestamp >= 0);
return (upperBoundTimestamp == mCurrentUpperBoundTimestamp);
}
/**
* Check if sync has completed for the first time.
*/
public boolean getHasFirstSyncCompleted() {
final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
return prefs.getLong(BuglePrefsKeys.LAST_SYNC_TIME,
BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT) !=
BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT;
}
/**
* Called once sync is complete
*/
public synchronized void complete() {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncManager: Sync started at " + mSyncInProgressTimestamp
+ " marked as complete");
}
mSyncInProgressTimestamp = -1L;
// Conversation customization only used once
mCustomization = null;
}
private final ContentObserver mMmsSmsObserver = new TelephonyMessagesObserver();
private boolean mSyncOnChanges = false;
private boolean mNotifyOnChanges = false;
/**
* Register content observer when necessary and kick off a catch up sync
*/
public void updateSyncObserver(final Context context) {
registerObserver(context);
// Trigger an sms sync in case we missed and messages before registering this observer or
// becoming the SMS provider.
immediateSync();
}
private void registerObserver(final Context context) {
if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
// Not default SMS app - need to actively monitor telephony but not notify
mNotifyOnChanges = false;
mSyncOnChanges = true;
} else if (OsUtil.isSecondaryUser()){
// Secondary users default SMS app - need to actively monitor telephony and notify
mNotifyOnChanges = true;
mSyncOnChanges = true;
} else {
// Primary users default SMS app - don't monitor telephony (most changes from this app)
mNotifyOnChanges = false;
mSyncOnChanges = false;
if (context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) {
// This is default SMS app but on Auto platform, even default SMS app needs
// to be notified of changes because Bluetooth makes changes to Sms database
// (thru ContentProvider). Content Change notifications help the default SMS app
// refresh display with changes, whenever Bluetooth changes data in SMS db.
mNotifyOnChanges = true;
mSyncOnChanges = false;
}
}
if (mNotifyOnChanges || mSyncOnChanges) {
context.getContentResolver().registerContentObserver(Telephony.MmsSms.CONTENT_URI,
true, mMmsSmsObserver);
} else {
context.getContentResolver().unregisterContentObserver(mMmsSmsObserver);
}
}
public synchronized void setCustomization(
final LongSparseArray<ConversationCustomization> customization) {
this.mCustomization = customization;
}
public synchronized ConversationCustomization getCustomizationForThread(final long threadId) {
if (mCustomization != null) {
return mCustomization.get(threadId);
}
return null;
}
public static void resetLastSyncTimestamps() {
final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME,
BuglePrefsKeys.LAST_FULL_SYNC_TIME_DEFAULT);
prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT);
}
private class TelephonyMessagesObserver extends ContentObserver {
public TelephonyMessagesObserver() {
// Just run on default thread
super(null);
}
// Implement the onChange(boolean) method to delegate the change notification to
// the onChange(boolean, Uri) method to ensure correct operation on older versions
// of the framework that did not have the onChange(boolean, Uri) method.
@Override
public void onChange(final boolean selfChange) {
onChange(selfChange, null);
}
// Implement the onChange(boolean, Uri) method to take advantage of the new Uri argument.
@Override
public void onChange(final boolean selfChange, final Uri uri) {
// Handle change.
LogUtil.v(TAG, "SyncManager: Sms/Mms DB changed @" + System.currentTimeMillis()
+ " for " + (uri == null ? "<unk>" : uri.toString()) + " "
+ mSyncOnChanges + "/" + mNotifyOnChanges);
if (mSyncOnChanges) {
// If sync is already running this will do nothing - but at end of each sync
// action there is a check for recent messages that should catch new changes.
SyncManager.immediateSync();
}
if (mNotifyOnChanges) {
// TODO: Secondary users are not going to get notifications
}
}
}
public ThreadInfoCache getThreadInfoCache() {
return mThreadInfoCache;
}
public static class ThreadInfoCache {
// Cache of thread->conversationId map
private final LongSparseArray<String> mThreadToConversationId = new LongSparseArray<>();
// Cache of thread->recipients map
private final LongSparseArray<List<String>> mThreadToRecipients = new LongSparseArray<>();
// Remember the conversation ids that need to be archived
private final HashSet<String> mArchivedConversations = new HashSet<>();
public synchronized void clear() {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncManager: Cleared ThreadInfoCache");
}
mThreadToConversationId.clear();
mThreadToRecipients.clear();
mArchivedConversations.clear();
}
public synchronized boolean isArchived(final String conversationId) {
return mArchivedConversations.contains(conversationId);
}
/**
* Get or create a conversation based on the message's thread id
*
* @param threadId The message's thread
* @param refSubId The subId used for normalizing phone numbers in the thread
* @param customization The user setting customization to the conversation if any
* @return The existing conversation id or new conversation id
*/
public synchronized String getOrCreateConversation(final DatabaseWrapper db,
final long threadId, int refSubId, final ConversationCustomization customization) {
// This function has several components which need to be atomic.
Assert.isTrue(db.getDatabase().inTransaction());
// If we already have this conversation ID in our local map, just return it
String conversationId = mThreadToConversationId.get(threadId);
if (conversationId != null) {
return conversationId;
}
final List<String> recipients = getThreadRecipients(threadId);
final ArrayList<ParticipantData> participants =
BugleDatabaseOperations.getConversationParticipantsFromRecipients(recipients,
refSubId);
if (customization != null) {
// There is user customization we need to recover
conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
customization.isArchived(), participants);
if (customization.isArchived()) {
mArchivedConversations.add(conversationId);
}
} else {
conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
false/*archived*/, participants);
}
if (conversationId != null) {
mThreadToConversationId.put(threadId, conversationId);
return conversationId;
}
return null;
}
/**
* Load the recipients of a thread from telephony provider. If we fail, use
* a predefined unknown recipient. This should not return null.
*/
public synchronized List<String> getThreadRecipients(final long threadId) {
List<String> recipients = mThreadToRecipients.get(threadId);
if (recipients == null) {
recipients = MmsUtils.getRecipientsByThread(threadId);
if (recipients != null && recipients.size() > 0) {
mThreadToRecipients.put(threadId, recipients);
}
}
if (recipients == null || recipients.isEmpty()) {
LogUtil.w(TAG, "SyncManager : using unknown sender since thread " + threadId +
" couldn't find any recipients.");
// We want to try our best to load the messages,
// so if recipient info is broken, try to fix it with unknown recipient
recipients = Lists.newArrayList();
recipients.add(ParticipantData.getUnknownSenderDestination());
}
return recipients;
}
}
}
@@ -0,0 +1,293 @@
/*
* 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.action;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener;
import com.android.messaging.datamodel.action.ActionMonitor.ActionExecutedListener;
import com.android.messaging.util.LogUtil;
import java.util.LinkedList;
import java.util.List;
/**
* Base class for operations that perform application business logic off the main UI thread while
* holding a wake lock.
* .
* Note all derived classes need to provide real implementation of Parcelable (this is abstract)
*/
public abstract class Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
// Members holding the parameters common to all actions - no action state
public final String actionKey;
// If derived classes keep their data in actionParameters then parcelable is trivial
protected final Bundle actionParameters;
// This does not get written to the parcel
private final List<Action> mBackgroundActions = new LinkedList<>();
/**
* Process the action locally - runs on action service thread.
* TODO: Currently, there is no way for this method to indicate failure
* @return result to be passed in to {@link ActionExecutedListener#onActionExecuted}. It is
* also the result passed in to {@link ActionCompletedListener#onActionSucceeded} if
* there is no background work.
*/
protected Object executeAction() {
return null;
}
/**
* Queues up background work ie. {@link #doBackgroundWork} will be called on the
* background worker thread.
*/
protected void requestBackgroundWork() {
mBackgroundActions.add(this);
}
/**
* Queues up background actions for background processing after the current action has
* completed its processing ({@link #executeAction} or {@link #processBackgroundFailure})
* on the Action thread.
*/
protected void requestBackgroundWork(final Action backgroundAction) {
mBackgroundActions.add(backgroundAction);
}
/**
* Return flag indicating if any actions have been queued
*/
public boolean hasBackgroundActions() {
return !mBackgroundActions.isEmpty();
}
/**
* Send queued actions to the background worker provided
*/
public void sendBackgroundActions(final BackgroundWorker worker) {
worker.queueBackgroundWork(mBackgroundActions);
mBackgroundActions.clear();
}
/**
* Do work in a long running background worker thread.
* {@link #requestBackgroundWork} needs to be called for this method to
* be called.
* @return response that is to be passed to {@link #processBackgroundResponse}
*/
protected Bundle doBackgroundWork() {
return null;
}
/**
* Process the success response from the background worker. Runs on action service thread.
* @param response the response returned by {@link #doBackgroundWork}
* @return result to be passed in to {@link ActionCompletedListener#onActionSucceeded}
*/
protected Object processBackgroundResponse(final Bundle response) {
return null;
}
/**
* Called in case of failures when sending background actions. Runs on action service thread
* @return result to be passed in to {@link ActionCompletedListener#onActionFailed}
*/
protected Object processBackgroundFailure() {
return null;
}
/**
* Constructor
*/
protected Action(final String key) {
this.actionKey = key;
this.actionParameters = new Bundle();
}
/**
* Constructor
*/
protected Action() {
this.actionKey = generateUniqueActionKey(getClass().getSimpleName());
this.actionParameters = new Bundle();
}
/**
* Queue an action and monitor for processing by the ActionService via the factory helper
*/
protected void start(final ActionMonitor monitor) {
ActionMonitor.registerActionMonitor(this.actionKey, monitor);
DataModel.startActionService(this);
}
/**
* Queue an action for processing by the ActionService via the factory helper
*/
public void start() {
DataModel.startActionService(this);
}
/**
* Queue an action for delayed processing by the ActionService via the factory helper
*/
public void schedule(final int requestCode, final long delayMs) {
DataModel.scheduleAction(this, requestCode, delayMs);
}
/**
* Called when action queues ActionService intent
*/
protected final void markStart() {
ActionMonitor.setState(this, ActionMonitor.STATE_CREATED,
ActionMonitor.STATE_QUEUED);
}
/**
* Mark the beginning of local action execution
*/
protected final void markBeginExecute() {
ActionMonitor.setState(this, ActionMonitor.STATE_QUEUED,
ActionMonitor.STATE_EXECUTING);
}
/**
* Mark the end of local action execution - either completes the action or queues
* background actions
*/
protected final void markEndExecute(final Object result) {
final boolean hasBackgroundActions = hasBackgroundActions();
ActionMonitor.setExecutedState(this, ActionMonitor.STATE_EXECUTING,
hasBackgroundActions, result);
if (!hasBackgroundActions) {
ActionMonitor.setCompleteState(this, ActionMonitor.STATE_EXECUTING,
result, true);
}
}
/**
* Update action state to indicate that the background worker is starting
*/
protected final void markBackgroundWorkStarting() {
ActionMonitor.setState(this,
ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED,
ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION);
}
/**
* Update action state to indicate that the background worker has posted its response
* (or failure) to the Action service
*/
protected final void markBackgroundCompletionQueued() {
ActionMonitor.setState(this,
ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION,
ActionMonitor.STATE_BACKGROUND_COMPLETION_QUEUED);
}
/**
* Update action state to indicate the background action failed but is being re-queued for retry
*/
protected final void markBackgroundWorkQueued() {
ActionMonitor.setState(this,
ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION,
ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED);
}
/**
* Called by ActionService to process a response from the background worker
* @param response the response returned by {@link #doBackgroundWork}
*/
protected final void processBackgroundWorkResponse(final Bundle response) {
ActionMonitor.setState(this,
ActionMonitor.STATE_BACKGROUND_COMPLETION_QUEUED,
ActionMonitor.STATE_PROCESSING_BACKGROUND_RESPONSE);
final Object result = processBackgroundResponse(response);
ActionMonitor.setCompleteState(this,
ActionMonitor.STATE_PROCESSING_BACKGROUND_RESPONSE, result, true);
}
/**
* Called by ActionService when a background action fails
*/
protected final void processBackgroundWorkFailure() {
final Object result = processBackgroundFailure();
ActionMonitor.setCompleteState(this, ActionMonitor.STATE_UNDEFINED,
result, false);
}
private static final Object sLock = new Object();
private static long sActionIdx = System.currentTimeMillis() * 1000;
/**
* Helper method to generate a unique operation index
*/
protected static long getActionIdx() {
long idx;
synchronized (sLock) {
idx = ++sActionIdx;
}
return idx;
}
/**
* This helper can be used to generate a unique key used to identify an action.
* @param baseKey - key generated to identify the action parameters
* @return - composite key generated by appending unique index
*/
protected static String generateUniqueActionKey(final String baseKey) {
final StringBuilder key = new StringBuilder();
if (!TextUtils.isEmpty(baseKey)) {
key.append(baseKey);
}
key.append(":");
key.append(getActionIdx());
return key.toString();
}
/**
* Most derived classes use this base implementation (unless they include files handles)
*/
@Override
public int describeContents() {
return 0;
}
/**
* Derived classes need to implement writeToParcel (but typically should call this method
* to parcel Action member variables before they parcel their member variables).
*/
public void writeActionToParcel(final Parcel parcel, final int flags) {
parcel.writeString(this.actionKey);
parcel.writeBundle(this.actionParameters);
}
/**
* Helper for derived classes to implement parcelable
*/
public Action(final Parcel in) {
this.actionKey = in.readString();
// Note: Need to set classloader to ensure we can un-parcel classes from this package
this.actionParameters = in.readBundle(Action.class.getClassLoader());
}
}
@@ -0,0 +1,451 @@
/*
* 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.os.Handler;
import androidx.collection.SimpleArrayMap;
import android.text.TextUtils;
import com.android.messaging.util.Assert.RunsOnAnyThread;
import com.android.messaging.util.Assert.RunsOnMainThread;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.ThreadUtil;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
/**
* Base class for action monitors
* Actions come in various flavors but
* o) Fire and forget - no monitor
* o) Immediate local processing only - will trigger ActionCompletedListener when done
* o) Background worker processing only - will trigger ActionCompletedListener when done
* o) Immediate local processing followed by background work followed by more local processing
* - will trigger ActionExecutedListener once local processing complete and
* ActionCompletedListener when second set of local process (dealing with background
* worker response) is complete
*/
public class ActionMonitor {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
/**
* Interface used to notify on completion of local execution for an action
*/
public interface ActionExecutedListener {
/**
* @param result value returned by {@link Action#executeAction}
*/
@RunsOnMainThread
void onActionExecuted(ActionMonitor monitor, final Action action,
final Object data, final Object result);
}
/**
* Interface used to notify action completion
*/
public interface ActionCompletedListener {
/**
* @param result object returned from processing the action. This is the value returned by
* {@link Action#executeAction} if there is no background work, or
* else the value returned by
* {@link Action#processBackgroundResponse}
*/
@RunsOnMainThread
void onActionSucceeded(ActionMonitor monitor,
final Action action, final Object data, final Object result);
/**
* @param result value returned by {@link Action#processBackgroundFailure}
*/
@RunsOnMainThread
void onActionFailed(ActionMonitor monitor, final Action action,
final Object data, final Object result);
}
/**
* Interface for being notified of action state changes - used for profiling, testing only
*/
protected interface ActionStateChangedListener {
/**
* @param action the action that is changing state
* @param state the new state of the action
*/
@RunsOnAnyThread
void onActionStateChanged(Action action, int state);
}
/**
* Operations always start out as STATE_CREATED and finish as STATE_COMPLETE.
* Some common state transition sequences in between include:
* <ul>
* <li>Local data change only : STATE_QUEUED - STATE_EXECUTING
* <li>Background worker request only : STATE_BACKGROUND_ACTIONS_QUEUED
* - STATE_EXECUTING_BACKGROUND_ACTION
* - STATE_BACKGROUND_COMPLETION_QUEUED
* - STATE_PROCESSING_BACKGROUND_RESPONSE
* <li>Local plus background worker request : STATE_QUEUED - STATE_EXECUTING
* - STATE_BACKGROUND_ACTIONS_QUEUED
* - STATE_EXECUTING_BACKGROUND_ACTION
* - STATE_BACKGROUND_COMPLETION_QUEUED
* - STATE_PROCESSING_BACKGROUND_RESPONSE
* </ul>
*/
protected static final int STATE_UNDEFINED = 0;
protected static final int STATE_CREATED = 1; // Just created
protected static final int STATE_QUEUED = 2; // Action queued for processing
protected static final int STATE_EXECUTING = 3; // Action processing on datamodel thread
protected static final int STATE_BACKGROUND_ACTIONS_QUEUED = 4;
protected static final int STATE_EXECUTING_BACKGROUND_ACTION = 5;
// The background work has completed, either returning a success response or resulting in a
// failure
protected static final int STATE_BACKGROUND_COMPLETION_QUEUED = 6;
protected static final int STATE_PROCESSING_BACKGROUND_RESPONSE = 7;
protected static final int STATE_COMPLETE = 8; // Action complete
/**
* Lock used to protect access to state and listeners
*/
private final Object mLock = new Object();
/**
* Current state of action
*/
protected int mState;
/**
* Listener which is notified on action completion
*/
private ActionCompletedListener mCompletedListener;
/**
* Listener which is notified on action executed
*/
private ActionExecutedListener mExecutedListener;
/**
* Listener which is notified of state changes
*/
private ActionStateChangedListener mStateChangedListener;
/**
* Handler used to post results back to caller
*/
private final Handler mHandler;
/**
* Data passed back to listeners (associated with the action when it is created)
*/
private final Object mData;
/**
* The action key is used to determine equivalence of operations and their requests
*/
private final String mActionKey;
/**
* Get action key identifying associated action
*/
public String getActionKey() {
return mActionKey;
}
/**
* Unregister listeners so that they will not be called back - override this method if needed
*/
public void unregister() {
clearListeners();
}
/**
* Unregister listeners so that they will not be called
*/
protected final void clearListeners() {
synchronized (mLock) {
mCompletedListener = null;
mExecutedListener = null;
}
}
/**
* Create a monitor associated with a particular action instance
*/
protected ActionMonitor(final int initialState, final String actionKey,
final Object data) {
mHandler = ThreadUtil.getMainThreadHandler();
mActionKey = actionKey;
mState = initialState;
mData = data;
}
/**
* Return flag to indicate if action is complete
*/
public boolean isComplete() {
boolean complete;
synchronized (mLock) {
complete = (mState == STATE_COMPLETE);
}
return complete;
}
/**
* Set listener that will be called with action completed result
*/
protected final void setCompletedListener(final ActionCompletedListener listener) {
synchronized (mLock) {
mCompletedListener = listener;
}
}
/**
* Set listener that will be called with local execution result
*/
protected final void setExecutedListener(final ActionExecutedListener listener) {
synchronized (mLock) {
mExecutedListener = listener;
}
}
/**
* Set listener that will be called with local execution result
*/
protected final void setStateChangedListener(final ActionStateChangedListener listener) {
synchronized (mLock) {
mStateChangedListener = listener;
}
}
/**
* Perform a state update transition
* @param action - action whose state is updating
* @param expectedOldState - expected existing state of action (can be UNKNOWN)
* @param newState - new state which will be set
*/
protected void updateState(final Action action, final int expectedOldState,
final int newState) {
ActionStateChangedListener listener = null;
synchronized (mLock) {
if (expectedOldState != STATE_UNDEFINED &&
mState != expectedOldState) {
throw new IllegalStateException("On updateState to " + newState + " was " + mState
+ " expecting " + expectedOldState);
}
if (newState != mState) {
mState = newState;
listener = mStateChangedListener;
}
}
if (listener != null) {
listener.onActionStateChanged(action, newState);
}
}
/**
* Perform a state update transition
* @param action - action whose state is updating
* @param expectedOldState - expected existing state of action (can be UNKNOWN)
* @param newState - new state which will be set
*/
static void setState(final Action action, final int expectedOldState,
final int newState) {
int oldMonitorState = expectedOldState;
int newMonitorState = newState;
final ActionMonitor monitor
= ActionMonitor.lookupActionMonitor(action.actionKey);
if (monitor != null) {
oldMonitorState = monitor.mState;
monitor.updateState(action, expectedOldState, newState);
newMonitorState = monitor.mState;
}
final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS",
Locale.getDefault());
df.setTimeZone(TimeZone.getTimeZone("UTC"));
LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date())
+ "UTC State = " + oldMonitorState + " - " + newMonitorState);
}
/**
* Mark action complete
* @param action - action whose state is updating
* @param expectedOldState - expected existing state of action (can be UNKNOWN)
* @param result - object returned from processing the action. This is the value returned by
* {@link Action#executeAction} if there is no background work, or
* else the value returned by {@link Action#processBackgroundResponse}
* or {@link Action#processBackgroundFailure}
*/
private void complete(final Action action, final int expectedOldState, final Object result,
final boolean succeeded) {
ActionCompletedListener completedListener;
synchronized (mLock) {
setState(action, expectedOldState, STATE_COMPLETE);
completedListener = mCompletedListener;
mExecutedListener = null;
mStateChangedListener = null;
}
if (completedListener != null) {
// Marshal to UI thread
mHandler.post(() -> {
ActionCompletedListener listener = null;
synchronized (mLock) {
if (mCompletedListener != null) {
listener = mCompletedListener;
}
mCompletedListener = null;
}
if (listener != null) {
if (succeeded) {
listener.onActionSucceeded(ActionMonitor.this,
action, mData, result);
} else {
listener.onActionFailed(ActionMonitor.this,
action, mData, result);
}
}
});
}
}
/**
* Mark action complete
* @param action - action whose state is updating
* @param expectedOldState - expected existing state of action (can be UNKNOWN)
* @param result - object returned from processing the action. This is the value returned by
* {@link Action#executeAction} if there is no background work, or
* else the value returned by {@link Action#processBackgroundResponse}
* or {@link Action#processBackgroundFailure}
*/
static void setCompleteState(final Action action, final int expectedOldState,
final Object result, final boolean succeeded) {
int oldMonitorState = expectedOldState;
final ActionMonitor monitor
= ActionMonitor.lookupActionMonitor(action.actionKey);
if (monitor != null) {
oldMonitorState = monitor.mState;
monitor.complete(action, expectedOldState, result, succeeded);
unregisterActionMonitorIfComplete(action.actionKey, monitor);
}
final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS",
Locale.getDefault());
df.setTimeZone(TimeZone.getTimeZone("UTC"));
LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date())
+ "UTC State = " + oldMonitorState + " - " + STATE_COMPLETE);
}
/**
* Mark action complete
* @param action - action whose state is updating
* @param expectedOldState - expected existing state of action (can be UNKNOWN)
* @param hasBackgroundActions - has the completing action requested background work
* @param result - the return value of {@link Action#executeAction}
*/
final void executed(final Action action,
final int expectedOldState, final boolean hasBackgroundActions, final Object result) {
ActionExecutedListener executedListener;
synchronized (mLock) {
if (hasBackgroundActions) {
setState(action, expectedOldState, STATE_BACKGROUND_ACTIONS_QUEUED);
}
executedListener = mExecutedListener;
}
if (executedListener != null) {
// Marshal to UI thread
mHandler.post(() -> {
ActionExecutedListener listener = null;
synchronized (mLock) {
if (mExecutedListener != null) {
listener = mExecutedListener;
mExecutedListener = null;
}
}
if (listener != null) {
listener.onActionExecuted(ActionMonitor.this,
action, mData, result);
}
});
}
}
/**
* Mark action complete
* @param action - action whose state is updating
* @param expectedOldState - expected existing state of action (can be UNKNOWN)
* @param hasBackgroundActions - has the completing action requested background work
* @param result - the return value of {@link Action#executeAction}
*/
static void setExecutedState(final Action action,
final int expectedOldState, final boolean hasBackgroundActions, final Object result) {
int oldMonitorState = expectedOldState;
final ActionMonitor monitor
= ActionMonitor.lookupActionMonitor(action.actionKey);
if (monitor != null) {
oldMonitorState = monitor.mState;
monitor.executed(action, expectedOldState, hasBackgroundActions, result);
}
final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS",
Locale.getDefault());
df.setTimeZone(TimeZone.getTimeZone("UTC"));
LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date())
+ "UTC State = " + oldMonitorState + " - EXECUTED");
}
/**
* Map of action monitors indexed by actionKey
*/
static final SimpleArrayMap<String, ActionMonitor> sActionMonitors = new SimpleArrayMap<>();
/**
* Insert new monitor into map
*/
static void registerActionMonitor(final String actionKey,
final ActionMonitor monitor) {
if (monitor != null
&& (TextUtils.isEmpty(monitor.getActionKey())
|| TextUtils.isEmpty(actionKey)
|| !actionKey.equals(monitor.getActionKey()))) {
throw new IllegalArgumentException("Monitor key " + monitor.getActionKey()
+ " not compatible with action key " + actionKey);
}
synchronized (sActionMonitors) {
sActionMonitors.put(actionKey, monitor);
}
}
/**
* Find monitor associated with particular action
*/
private static ActionMonitor lookupActionMonitor(final String actionKey) {
ActionMonitor monitor;
synchronized (sActionMonitors) {
monitor = sActionMonitors.get(actionKey);
}
return monitor;
}
/**
* Remove monitor from map if the action is complete
*/
static void unregisterActionMonitorIfComplete(final String actionKey,
final ActionMonitor monitor) {
if (monitor != null && monitor.isComplete()) {
synchronized (sActionMonitors) {
sActionMonitors.remove(actionKey);
}
}
}
}
@@ -0,0 +1,63 @@
/*
* Copyright (C) 2015 The Android Open Source 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.app.PendingIntent;
import android.content.Context;
import android.os.Bundle;
/**
* Class providing interface for the ActionService - can be stubbed for testing
*/
public class ActionService {
protected static PendingIntent makeStartActionPendingIntent(final Context context,
final Action action, final int requestCode, final boolean launchesAnActivity) {
return ActionServiceImpl.makeStartActionPendingIntent(context, action, requestCode,
launchesAnActivity);
}
/**
* Start an action by posting it over the the ActionService
*/
public void startAction(final Action action) {
ActionServiceImpl.startAction(action);
}
/**
* Schedule a delayed action by posting it over the the ActionService
*/
public void scheduleAction(final Action action, final int code,
final long delayMs) {
ActionServiceImpl.scheduleAction(action, code, delayMs);
}
/**
* Process a response from the BackgroundWorker in the ActionService
*/
protected void handleResponseFromBackgroundWorker(
final Action action, final Bundle response) {
ActionServiceImpl.handleResponseFromBackgroundWorker(action, response);
}
/**
* Process a failure from the BackgroundWorker in the ActionService
*/
protected void handleFailureFromBackgroundWorker(final Action action,
final Exception exception) {
ActionServiceImpl.handleFailureFromBackgroundWorker(action, exception);
}
}
@@ -0,0 +1,302 @@
/*
* 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.app.AlarmManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import androidx.core.app.JobIntentService;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.LoggingTimer;
/**
* ActionService used to perform background processing for data model
*/
public class ActionServiceImpl extends JobIntentService {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
/**
* Unique job ID for this service.
*/
public static final int JOB_ID = 1000;
public ActionServiceImpl() {
super();
}
/**
* Start action by sending intent to the service
* @param action - action to start
*/
protected static void startAction(final Action action) {
final Intent intent = makeIntent(OP_START_ACTION);
final Bundle actionBundle = new Bundle();
actionBundle.putParcelable(BUNDLE_ACTION, action);
intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
action.markStart();
startServiceWithIntent(intent);
}
/**
* Schedule an action to run after specified delay using alarm manager to send pendingintent
* @param action - action to start
* @param requestCode - request code used to collapse requests
* @param delayMs - delay in ms (from now) before action will start
*/
protected static void scheduleAction(final Action action, final int requestCode,
final long delayMs) {
final Intent intent = PendingActionReceiver.makeIntent(OP_START_ACTION);
final Bundle actionBundle = new Bundle();
actionBundle.putParcelable(BUNDLE_ACTION, action);
intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
PendingActionReceiver.scheduleAlarm(intent, requestCode, delayMs);
}
/**
* Handle response returned by BackgroundWorker
* @param response - response from service
*/
protected static void handleResponseFromBackgroundWorker(final Action action,
final Bundle response) {
final Intent intent = makeIntent(OP_RECEIVE_BACKGROUND_RESPONSE);
final Bundle actionBundle = new Bundle();
actionBundle.putParcelable(BUNDLE_ACTION, action);
intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
intent.putExtra(EXTRA_WORKER_RESPONSE, response);
startServiceWithIntent(intent);
}
/**
* Handle response returned by BackgroundWorker
*/
protected static void handleFailureFromBackgroundWorker(final Action action,
final Exception exception) {
final Intent intent = makeIntent(OP_RECEIVE_BACKGROUND_FAILURE);
final Bundle actionBundle = new Bundle();
actionBundle.putParcelable(BUNDLE_ACTION, action);
intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
intent.putExtra(EXTRA_WORKER_EXCEPTION, exception);
startServiceWithIntent(intent);
}
// ops
protected static final int OP_START_ACTION = 200;
protected static final int OP_RECEIVE_BACKGROUND_RESPONSE = 201;
protected static final int OP_RECEIVE_BACKGROUND_FAILURE = 202;
// extras
protected static final String EXTRA_OP_CODE = "op";
protected static final String EXTRA_ACTION_BUNDLE = "datamodel_action_bundle";
protected static final String EXTRA_WORKER_EXCEPTION = "worker_exception";
protected static final String EXTRA_WORKER_RESPONSE = "worker_response";
protected static final String EXTRA_WORKER_UPDATE = "worker_update";
protected static final String BUNDLE_ACTION = "bundle_action";
private BackgroundWorker mBackgroundWorker;
/**
* Allocate an intent with a specific opcode.
*/
private static Intent makeIntent(final int opcode) {
final Intent intent = new Intent(Factory.get().getApplicationContext(),
ActionServiceImpl.class);
intent.putExtra(EXTRA_OP_CODE, opcode);
return intent;
}
/**
* Broadcast receiver for alarms scheduled through ActionService.
*/
public static class PendingActionReceiver extends BroadcastReceiver {
static final String ACTION = "com.android.messaging.datamodel.PENDING_ACTION";
/**
* Allocate an intent with a specific opcode and alarm action.
*/
public static Intent makeIntent(final int opcode) {
final Intent intent = new Intent(Factory.get().getApplicationContext(),
PendingActionReceiver.class);
intent.setAction(ACTION);
intent.putExtra(EXTRA_OP_CODE, opcode);
return intent;
}
public static void scheduleAlarm(final Intent intent, final int requestCode,
final long delayMs) {
final Context context = Factory.get().getApplicationContext();
final PendingIntent pendingIntent = PendingIntent.getBroadcast(
context, requestCode, intent,
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE);
final AlarmManager mgr =
(AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (delayMs < Long.MAX_VALUE) {
mgr.set(AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + delayMs, pendingIntent);
} else {
mgr.cancel(pendingIntent);
}
}
/**
* {@inheritDoc}
*/
@Override
public void onReceive(final Context context, final Intent intent) {
ActionServiceImpl.startServiceWithIntent(intent);
}
}
/**
* Creates a pending intent that will trigger a data model action when the intent is
* triggered
*/
public static PendingIntent makeStartActionPendingIntent(final Context context,
final Action action, final int requestCode, final boolean launchesAnActivity) {
final Intent intent = PendingActionReceiver.makeIntent(OP_START_ACTION);
final Bundle actionBundle = new Bundle();
actionBundle.putParcelable(BUNDLE_ACTION, action);
intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
if (launchesAnActivity) {
intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
}
return PendingIntent.getBroadcast(context, requestCode, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
/**
* {@inheritDoc}
*/
@Override
public void onCreate() {
super.onCreate();
mBackgroundWorker = DataModel.get().getBackgroundWorkerForActionService();
}
/**
* Queue intent to the ActionService.
*/
private static void startServiceWithIntent(final Intent intent) {
final Context context = Factory.get().getApplicationContext();
intent.setClass(context, ActionServiceImpl.class);
enqueueWork(context, intent);
}
public static void enqueueWork(Context context, Intent work) {
enqueueWork(context, ActionServiceImpl.class, JOB_ID, work);
}
/**
* {@inheritDoc}
*/
@Override
protected void onHandleWork(@NonNull final Intent intent) {
final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0);
Action action;
final Bundle actionBundle = intent.getBundleExtra(EXTRA_ACTION_BUNDLE);
actionBundle.setClassLoader(getClassLoader());
switch(opcode) {
case OP_START_ACTION: {
action = actionBundle.getParcelable(BUNDLE_ACTION, Action.class);
executeAction(action);
break;
}
case OP_RECEIVE_BACKGROUND_RESPONSE: {
action = actionBundle.getParcelable(BUNDLE_ACTION, Action.class);
final Bundle response = intent.getBundleExtra(EXTRA_WORKER_RESPONSE);
processBackgroundResponse(action, response);
break;
}
case OP_RECEIVE_BACKGROUND_FAILURE: {
action = actionBundle.getParcelable(BUNDLE_ACTION, Action.class);
processBackgroundFailure(action);
break;
}
default:
throw new RuntimeException("Unrecognized opcode in ActionServiceImpl");
}
action.sendBackgroundActions(mBackgroundWorker);
}
private static final long EXECUTION_TIME_WARN_LIMIT_MS = 1000; // 1 second
/**
* Local execution of action on ActionService thread
*/
private void executeAction(final Action action) {
action.markBeginExecute();
final LoggingTimer timer = createLoggingTimer(action, "#executeAction");
timer.start();
final Object result = action.executeAction();
timer.stopAndLog();
action.markEndExecute(result);
}
/**
* Process response on ActionService thread
*/
private void processBackgroundResponse(final Action action, final Bundle response) {
final LoggingTimer timer = createLoggingTimer(action, "#processBackgroundResponse");
timer.start();
action.processBackgroundWorkResponse(response);
timer.stopAndLog();
}
/**
* Process failure on ActionService thread
*/
private void processBackgroundFailure(final Action action) {
final LoggingTimer timer = createLoggingTimer(action, "#processBackgroundFailure");
timer.start();
action.processBackgroundWorkFailure();
timer.stopAndLog();
}
private static LoggingTimer createLoggingTimer(
final Action action, final String methodName) {
return new LoggingTimer(TAG, action.getClass().getSimpleName() + methodName,
EXECUTION_TIME_WARN_LIMIT_MS);
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (C) 2015 The Android Open Source 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 java.util.List;
/**
* Interface between action service and its workers
*/
public class BackgroundWorker {
/**
* Send list of requests from action service to a worker
*/
public void queueBackgroundWork(final List<Action> backgroundActions) {
BackgroundWorkerService.queueBackgroundWork(backgroundActions);
}
}
@@ -0,0 +1,137 @@
/*
* 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.Context;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.core.app.JobIntentService;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.LoggingTimer;
import java.util.List;
/**
* Background worker service is an initial example of a background work queue handler
* Used to actually "send" messages which may take some time and should not block ActionService
* or UI
*/
public class BackgroundWorkerService extends JobIntentService {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
/**
* Unique job ID for this service.
*/
public static final int JOB_ID = 1001;
private final ActionService mHost;
public BackgroundWorkerService() {
super();
mHost = DataModel.get().getActionService();
}
/**
* Queue a list of requests from action service to this worker
*/
public static void queueBackgroundWork(final List<Action> actions) {
for (final Action action : actions) {
startServiceWithAction(action, 0);
}
}
// ops
protected static final int OP_PROCESS_REQUEST = 400;
// extras
protected static final String EXTRA_OP_CODE = "op";
protected static final String EXTRA_ACTION = "action";
protected static final String EXTRA_ATTEMPT = "retry_attempt";
/**
* Queue action intent to the BackgroundWorkerService.
*/
private static void startServiceWithAction(final Action action,
final int retryCount) {
final Intent intent = new Intent();
intent.putExtra(EXTRA_ACTION, action);
intent.putExtra(EXTRA_ATTEMPT, retryCount);
startServiceWithIntent(OP_PROCESS_REQUEST, intent);
}
/**
* Queue intent to the BackgroundWorkerService.
*/
private static void startServiceWithIntent(final int opcode, final Intent intent) {
final Context context = Factory.get().getApplicationContext();
intent.setClass(context, BackgroundWorkerService.class);
intent.putExtra(EXTRA_OP_CODE, opcode);
enqueueWork(context, intent);
}
public static void enqueueWork(Context context, Intent work) {
enqueueWork(context, BackgroundWorkerService.class, JOB_ID, work);
}
@Override
protected void onHandleWork(@NonNull final Intent intent) {
final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0);
if (opcode == OP_PROCESS_REQUEST) {
final Action action = intent.getParcelableExtra(EXTRA_ACTION);
final int attempt = intent.getIntExtra(EXTRA_ATTEMPT, -1);
doBackgroundWork(action, attempt);
} else {
LogUtil.w(TAG, "Unrecognized opcode in BackgroundWorkerService " + opcode);
throw new RuntimeException("Unrecognized opcode in BackgroundWorkerService");
}
}
/**
* Local execution of background work for action on ActionService thread
*/
private void doBackgroundWork(final Action action, final int attempt) {
action.markBackgroundWorkStarting();
Bundle response;
try {
final LoggingTimer timer = new LoggingTimer(
TAG, action.getClass().getSimpleName() + "#doBackgroundWork");
timer.start();
response = action.doBackgroundWork();
timer.stopAndLog();
action.markBackgroundCompletionQueued();
mHost.handleResponseFromBackgroundWorker(action, response);
} catch (final Exception exception) {
final boolean retry = false;
LogUtil.e(TAG, "Error in background worker", exception);
Assert.fail("Unexpected error in background worker - abort");
action.markBackgroundCompletionQueued();
mHost.handleFailureFromBackgroundWorker(action, exception);
}
}
}
@@ -0,0 +1,160 @@
/*
* 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.action;
import android.content.Context;
import android.content.res.Resources;
import android.widget.Toast;
import com.android.messaging.Factory;
import com.android.messaging.R;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.AccessibilityUtil;
import com.android.messaging.util.PhoneUtils;
import com.android.messaging.util.ThreadUtil;
import javax.annotation.Nullable;
/**
* Shows one-time, transient notifications in response to action failures (i.e. permanent failures
* when sending a message) by showing toasts.
*/
public class BugleActionToasts {
/**
* Called when SendMessageAction or DownloadMmsAction finishes
* @param conversationId the conversation of the sent or downloaded message
* @param success did the action succeed
* @param status the message sending status
* @param isSms whether the message is sent using SMS
* @param subId the subId of the SIM related to this send
* @param isSend whether it is a send (false for download)
*/
static void onSendMessageOrManualDownloadActionCompleted(
final String conversationId,
final boolean success,
final int status,
final boolean isSms,
final int subId,
final boolean isSend) {
// We only show notifications for two cases, i.e. when mobile data is off or when we are
// in airplane mode, both of which fail fast with permanent failures.
if (!success && status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) {
final PhoneUtils phoneUtils = PhoneUtils.get(subId);
if (phoneUtils.isAirplaneModeOn()) {
if (isSend) {
showToast(R.string.send_message_failure_airplane_mode);
} else {
showToast(R.string.download_message_failure_airplane_mode);
}
return;
} else if (!isSms && !phoneUtils.isMobileDataEnabled()) {
if (isSend) {
showToast(R.string.send_message_failure_no_data);
} else {
showToast(R.string.download_message_failure_no_data);
}
return;
}
}
if (AccessibilityUtil.isTouchExplorationEnabled(Factory.get().getApplicationContext())) {
final boolean isFocusedConversation = DataModel.get().isFocusedConversation(conversationId);
if (isFocusedConversation && success) {
// Using View.announceForAccessibility may be preferable, but we do not have a
// View, and so we use a toast instead.
showToast(isSend ? R.string.send_message_success
: R.string.download_message_success);
return;
}
// {@link MessageNotificationState#checkFailedMessages} does not post a notification for
// failures in observable conversations. For accessibility, we provide an indication
// here.
final boolean isObservableConversation = DataModel.get().isNewMessageObservable(
conversationId);
if (isObservableConversation && !success) {
showToast(isSend ? R.string.send_message_failure
: R.string.download_message_failure);
}
}
}
public static void onMessageReceived(final String conversationId,
@Nullable final ParticipantData sender, @Nullable final MessageData message) {
final Context context = Factory.get().getApplicationContext();
if (AccessibilityUtil.isTouchExplorationEnabled(context)) {
final boolean isFocusedConversation = DataModel.get().isFocusedConversation(
conversationId);
if (isFocusedConversation) {
final Resources res = context.getResources();
final String senderDisplayName = (sender == null)
? res.getString(R.string.unknown_sender) : sender.getDisplayName(false);
final String announcement = res.getString(
R.string.incoming_message_announcement, senderDisplayName,
(message == null) ? "" : message.getMessageText());
showToast(announcement);
}
}
}
private static void showToast(final int messageResId) {
ThreadUtil.getMainThreadHandler().post(() -> Toast.makeText(getApplicationContext(),
getApplicationContext().getString(messageResId), Toast.LENGTH_LONG).show());
}
private static void showToast(final String message) {
ThreadUtil.getMainThreadHandler().post(() ->
Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show());
}
private static Context getApplicationContext() {
return Factory.get().getApplicationContext();
}
private static class UpdateDestinationBlockedActionToast
implements UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener {
private final Context mContext;
UpdateDestinationBlockedActionToast(final Context context) {
mContext = context;
}
@Override
public void onUpdateDestinationBlockedAction(
final UpdateDestinationBlockedAction action,
final boolean success,
final boolean block,
final String destination) {
if (success) {
Toast.makeText(mContext,
block
? R.string.update_destination_blocked
: R.string.update_destination_unblocked,
Toast.LENGTH_LONG
).show();
}
}
}
public static UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener
makeUpdateDestinationBlockedActionListener(final Context context) {
return new UpdateDestinationBlockedActionToast(context);
}
}
@@ -0,0 +1,204 @@
/*
* 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.action;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
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.BugleNotifications;
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.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.NotificationsUtil;
import com.android.messaging.widget.WidgetConversationProvider;
import java.util.ArrayList;
import java.util.List;
/**
* Action used to delete a conversation.
*/
public class DeleteConversationAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
public static void deleteConversation(final String conversationId, final long cutoffTimestamp) {
final DeleteConversationAction action = new DeleteConversationAction(conversationId,
cutoffTimestamp);
action.start();
}
private static final String KEY_CONVERSATION_ID = "conversation_id";
private static final String KEY_CUTOFF_TIMESTAMP = "cutoff_timestamp";
private DeleteConversationAction(final String conversationId, final long cutoffTimestamp) {
super();
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
// TODO: Should we set cuttoff timestamp to prevent us deleting new messages?
actionParameters.putLong(KEY_CUTOFF_TIMESTAMP, cutoffTimestamp);
}
// Delete conversation from both the local DB and telephony in the background so sync cannot
// run concurrently and incorrectly try to recreate the conversation's messages locally. The
// telephony database can sometimes be quite slow to delete conversations, so we delete from
// the local DB first, notify the UI, and then delete from telephony.
@Override
protected Bundle doBackgroundWork() {
final DatabaseWrapper db = DataModel.get().getDatabase();
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final long cutoffTimestamp = actionParameters.getLong(KEY_CUTOFF_TIMESTAMP);
if (!TextUtils.isEmpty(conversationId)) {
// First find the thread id for this conversation.
final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId);
if (BugleDatabaseOperations.deleteConversation(db, conversationId, cutoffTimestamp)) {
LogUtil.i(TAG, "DeleteConversationAction: Deleted local conversation "
+ conversationId);
// Remove notifications if necessary
BugleNotifications.update(true /* silent */, null /* conversationId */,
BugleNotifications.UPDATE_MESSAGES);
// We have changed the conversation list
MessagingContentProvider.notifyConversationListChanged();
// Notify the widget the conversation is deleted so it can go into its configure state.
WidgetConversationProvider.notifyConversationDeleted(
Factory.get().getApplicationContext(),
conversationId);
} else {
LogUtil.w(TAG, "DeleteConversationAction: Could not delete local conversation "
+ conversationId);
return null;
}
// Now delete from telephony DB. MmsSmsProvider throws an exception if the thread id is
// less than 0. If it's greater than zero, it will delete all messages with that thread
// id, even if there's no corresponding row in the threads table.
if (threadId >= 0) {
final int count = MmsUtils.deleteThread(threadId, cutoffTimestamp);
if (count > 0) {
LogUtil.i(TAG, "DeleteConversationAction: Deleted telephony thread "
+ threadId + " (cutoffTimestamp = " + cutoffTimestamp + ")");
} else {
LogUtil.w(TAG, "DeleteConversationAction: Could not delete thread from "
+ "telephony: conversationId = " + conversationId + ", thread id = "
+ threadId);
}
} else {
LogUtil.w(TAG, "DeleteConversationAction: Local conversation " + conversationId
+ " has an invalid telephony thread id; will delete messages individually");
deleteConversationMessagesFromTelephony();
}
// Finally, remove the conversation specific notification channel
NotificationsUtil.deleteNotificationChannel(Factory.get().getApplicationContext(),
conversationId);
} else {
LogUtil.e(TAG, "DeleteConversationAction: conversationId is empty");
}
return null;
}
/**
* Deletes all the telephony messages for the local conversation being deleted.
* <p>
* This is a fallback used when the conversation is not associated with any telephony thread,
* or its thread id is invalid (e.g. negative). This is not common, but can happen sometimes
* (e.g. the Unknown Sender conversation). In the usual case of deleting a conversation, we
* don't need this because the telephony provider automatically deletes messages when a thread
* is deleted.
*/
private void deleteConversationMessagesFromTelephony() {
final DatabaseWrapper db = DataModel.get().getDatabase();
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
Assert.notNull(conversationId);
final List<Uri> messageUris = new ArrayList<>();
try (Cursor cursor = db.query(DatabaseHelper.MESSAGES_TABLE,
new String[]{MessageColumns.SMS_MESSAGE_URI},
MessageColumns.CONVERSATION_ID + "=?",
new String[]{conversationId},
null, null, null)) {
while (cursor.moveToNext()) {
String messageUri = cursor.getString(0);
try {
messageUris.add(Uri.parse(messageUri));
} catch (Exception e) {
LogUtil.e(TAG, "DeleteConversationAction: Could not parse message uri "
+ messageUri);
}
}
}
for (Uri messageUri : messageUris) {
int count = MmsUtils.deleteMessage(messageUri);
if (count > 0) {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "DeleteConversationAction: Deleted telephony message "
+ messageUri);
}
} else {
LogUtil.w(TAG, "DeleteConversationAction: Could not delete telephony message "
+ messageUri);
}
}
}
@Override
protected Object executeAction() {
requestBackgroundWork();
return null;
}
private DeleteConversationAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<DeleteConversationAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public DeleteConversationAction createFromParcel(final Parcel in) {
return new DeleteConversationAction(in);
}
@Override
public DeleteConversationAction[] newArray(final int size) {
return new DeleteConversationAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,138 @@
/*
* 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.action;
import android.net.Uri;
import android.os.Bundle;
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.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.LogUtil;
/**
* Action used to delete a single message.
*/
public class DeleteMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
public static void deleteMessage(final String messageId) {
final DeleteMessageAction action = new DeleteMessageAction(messageId);
action.start();
}
private static final String KEY_MESSAGE_ID = "message_id";
private DeleteMessageAction(final String messageId) {
super();
actionParameters.putString(KEY_MESSAGE_ID, messageId);
}
// Doing this work in the background so that we're not competing with sync
// which could bring the deleted message back to life between the time we deleted
// it locally and deleted it in telephony (sync is also done on doBackgroundWork).
//
// Previously this block of code deleted from telephony first but that can be very
// slow (on the order of seconds) so this was modified to first delete locally, trigger
// the UI update, then delete from telephony.
@Override
protected Bundle doBackgroundWork() {
final DatabaseWrapper db = DataModel.get().getDatabase();
// First find the thread id for this conversation.
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
if (!TextUtils.isEmpty(messageId)) {
// Check message still exists
final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
if (message != null) {
// Delete from local DB
int count = BugleDatabaseOperations.deleteMessage(db, messageId);
if (count > 0) {
LogUtil.i(TAG, "DeleteMessageAction: Deleted local message "
+ messageId);
} else {
LogUtil.w(TAG, "DeleteMessageAction: Could not delete local message "
+ messageId);
}
MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
// We may have changed the conversation list
MessagingContentProvider.notifyConversationListChanged();
final Uri messageUri = message.getSmsMessageUri();
if (messageUri != null) {
// Delete from telephony DB
count = MmsUtils.deleteMessage(messageUri);
if (count > 0) {
LogUtil.i(TAG, "DeleteMessageAction: Deleted telephony message "
+ messageUri);
} else {
LogUtil.w(TAG, "DeleteMessageAction: Could not delete message from "
+ "telephony: messageId = " + messageId + ", telephony uri = "
+ messageUri);
}
} else {
LogUtil.i(TAG, "DeleteMessageAction: Local message " + messageId
+ " has no telephony uri.");
}
} else {
LogUtil.w(TAG, "DeleteMessageAction: Message " + messageId + " no longer exists");
}
}
return null;
}
/**
* Delete the message.
*/
@Override
protected Object executeAction() {
requestBackgroundWork();
return null;
}
private DeleteMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<DeleteMessageAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public DeleteMessageAction createFromParcel(final Parcel in) {
return new DeleteMessageAction(in);
}
@Override
public DeleteMessageAction[] newArray(final int size) {
return new DeleteMessageAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,346 @@
/*
* 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.action;
import android.content.ContentValues;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
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.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.RunsOnMainThread;
import com.android.messaging.util.LogUtil;
/**
* Downloads an MMS message.
* <p>
* This class is public (not package-private) because the SMS/MMS (e.g. MmsUtils) classes need to
* access the EXTRA_* fields for setting up the 'downloaded' pending intent.
*/
public class DownloadMmsAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
/**
* Interface for DownloadMmsAction listeners
*/
public interface DownloadMmsActionListener {
@RunsOnMainThread
void onDownloadMessageStarting(final ActionMonitor monitor,
final Object data, final MessageData message);
@RunsOnMainThread
void onDownloadMessageSucceeded(final ActionMonitor monitor,
final Object data, final MessageData message);
@RunsOnMainThread
void onDownloadMessageFailed(final ActionMonitor monitor,
final Object data, final MessageData message);
}
/**
* Queue download of an mms notification message (can only be called during execute of action)
*/
static boolean queueMmsForDownloadInBackground(final String messageId,
final Action processingAction) {
// When this method is being called, it is always from auto download
final DownloadMmsAction action = new DownloadMmsAction();
// This could queue nothing
return action.queueAction(messageId, processingAction);
}
private static final String KEY_MESSAGE_ID = "message_id";
private static final String KEY_CONVERSATION_ID = "conversation_id";
private static final String KEY_PARTICIPANT_ID = "participant_id";
private static final String KEY_CONTENT_LOCATION = "content_location";
private static final String KEY_TRANSACTION_ID = "transaction_id";
private static final String KEY_NOTIFICATION_URI = "notification_uri";
private static final String KEY_SUB_ID = "sub_id";
private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number";
private static final String KEY_AUTO_DOWNLOAD = "auto_download";
private static final String KEY_FAILURE_STATUS = "failure_status";
private static final String KEY_EXPIRY = "expiry";
// Values we attach to the pending intent that's fired when the message is downloaded.
// Only applicable when downloading via the platform APIs on L+.
public static final String EXTRA_MESSAGE_ID = "message_id";
public static final String EXTRA_CONTENT_URI = "content_uri";
public static final String EXTRA_NOTIFICATION_URI = "notification_uri";
public static final String EXTRA_SUB_ID = "sub_id";
public static final String EXTRA_SUB_PHONE_NUMBER = "sub_phone_number";
public static final String EXTRA_TRANSACTION_ID = "transaction_id";
public static final String EXTRA_CONTENT_LOCATION = "content_location";
public static final String EXTRA_AUTO_DOWNLOAD = "auto_download";
public static final String EXTRA_RECEIVED_TIMESTAMP = "received_timestamp";
public static final String EXTRA_CONVERSATION_ID = "conversation_id";
public static final String EXTRA_PARTICIPANT_ID = "participant_id";
public static final String EXTRA_STATUS_IF_FAILED = "status_if_failed";
public static final String EXTRA_EXPIRY = "expiry";
private DownloadMmsAction() {
super();
}
@Override
protected Object executeAction() {
Assert.fail("DownloadMmsAction must be queued rather than started");
return null;
}
protected boolean queueAction(final String messageId, final Action processingAction) {
actionParameters.putString(KEY_MESSAGE_ID, messageId);
final DatabaseWrapper db = DataModel.get().getDatabase();
// Read the message from local db
final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
if (message != null && message.canDownloadMessage()) {
final Uri notificationUri = message.getSmsMessageUri();
final String conversationId = message.getConversationId();
final int status = message.getStatus();
final String selfId = message.getSelfId();
final ParticipantData self = BugleDatabaseOperations
.getExistingParticipant(db, selfId);
final int subId = self.getSubId();
actionParameters.putInt(KEY_SUB_ID, subId);
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
actionParameters.putString(KEY_PARTICIPANT_ID, message.getParticipantId());
actionParameters.putString(KEY_CONTENT_LOCATION, message.getMmsContentLocation());
actionParameters.putString(KEY_TRANSACTION_ID, message.getMmsTransactionId());
actionParameters.putParcelable(KEY_NOTIFICATION_URI, notificationUri);
actionParameters.putBoolean(KEY_AUTO_DOWNLOAD, isAutoDownload(status));
actionParameters.putLong(KEY_EXPIRY, message.getMmsExpiry());
final long now = System.currentTimeMillis();
if (message.getInDownloadWindow(now)) {
// We can still retry
actionParameters.putString(KEY_SUB_PHONE_NUMBER, self.getNormalizedDestination());
final int downloadingStatus = getDownloadingStatus(status);
// Update message status to indicate downloading.
updateMessageStatus(notificationUri, messageId, conversationId,
downloadingStatus, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED);
// Pre-compute the next status when failed so we don't have to load from db again
actionParameters.putInt(KEY_FAILURE_STATUS, getFailureStatus(downloadingStatus));
// Actual download happens in background
processingAction.requestBackgroundWork(this);
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG,
"DownloadMmsAction: Queued download of MMS message " + messageId);
}
return true;
} else {
LogUtil.w(TAG, "DownloadMmsAction: Download of MMS message " + messageId
+ " failed (outside download window)");
// Retries depleted and we failed. Update the message status so we won't retry again
updateMessageStatus(notificationUri, messageId, conversationId,
MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED,
MessageData.RAW_TELEPHONY_STATUS_UNDEFINED);
if (status == MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD) {
// For auto download failure, we should send a DEFERRED NotifyRespInd
// to carrier to indicate we will manual download later
ProcessDownloadedMmsAction.sendDeferredRespStatus(
messageId, message.getMmsTransactionId(),
message.getMmsContentLocation(), subId);
return true;
}
}
}
return false;
}
/**
* Find out the auto download state of this message based on its starting status
*
* @param status The starting status of the message.
* @return True if this is a message doing auto downloading, false otherwise
*/
private static boolean isAutoDownload(final int status) {
switch (status) {
case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD:
return false;
case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD:
return true;
default:
Assert.fail("isAutoDownload: invalid input status " + status);
return false;
}
}
/**
* Get the corresponding downloading status based on the starting status of the message
*
* @param status The starting status of the message.
* @return The downloading status
*/
private static int getDownloadingStatus(final int status) {
switch (status) {
case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD:
return MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING;
case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD:
return MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING;
default:
Assert.fail("isAutoDownload: invalid input status " + status);
return MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING;
}
}
/**
* Get the corresponding failed status based on the current downloading status
*
* @param status The downloading status
* @return The status the message should have if downloading failed
*/
private static int getFailureStatus(final int status) {
switch (status) {
case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING:
return MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD;
case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING:
return MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD;
default:
Assert.fail("isAutoDownload: invalid input status " + status);
return MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD;
}
}
@Override
protected Bundle doBackgroundWork() {
final Context context = Factory.get().getApplicationContext();
final int subId = actionParameters.getInt(KEY_SUB_ID);
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final Uri notificationUri = actionParameters.getParcelable(KEY_NOTIFICATION_URI, Uri.class);
final String subPhoneNumber = actionParameters.getString(KEY_SUB_PHONE_NUMBER);
final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION);
final boolean autoDownload = actionParameters.getBoolean(KEY_AUTO_DOWNLOAD);
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final String participantId = actionParameters.getString(KEY_PARTICIPANT_ID);
final int statusIfFailed = actionParameters.getInt(KEY_FAILURE_STATUS);
final long expiry = actionParameters.getLong(KEY_EXPIRY);
final long receivedTimestampRoundedToSecond =
1000 * ((System.currentTimeMillis() + 500) / 1000);
LogUtil.i(TAG, "DownloadMmsAction: Downloading MMS message " + messageId
+ " (" + (autoDownload ? "auto" : "manual") + ")");
// Bundle some values we'll need after the message is downloaded (via platform APIs)
final Bundle extras = new Bundle();
extras.putString(EXTRA_MESSAGE_ID, messageId);
extras.putString(EXTRA_CONVERSATION_ID, conversationId);
extras.putString(EXTRA_PARTICIPANT_ID, participantId);
extras.putInt(EXTRA_STATUS_IF_FAILED, statusIfFailed);
// Start the download
final MmsUtils.StatusPlusUri status = MmsUtils.downloadMmsMessage(context,
notificationUri, subId, subPhoneNumber, transactionId, contentLocation,
autoDownload, receivedTimestampRoundedToSecond / 1000L, expiry / 1000L, extras);
if (status == MmsUtils.STATUS_PENDING) {
// Async download; no status yet
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "DownloadMmsAction: Downloading MMS message " + messageId
+ " asynchronously; waiting for pending intent to signal completion");
}
} else {
// Inform sync that message has been added at local received timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(receivedTimestampRoundedToSecond);
// Handle downloaded message
ProcessDownloadedMmsAction.processMessageDownloadFastFailed(messageId,
notificationUri, conversationId, participantId, contentLocation, subId,
subPhoneNumber, statusIfFailed, autoDownload, transactionId,
status.resultCode);
}
return null;
}
@Override
protected Object processBackgroundResponse(final Bundle response) {
// Nothing to do here; post-download actions handled by ProcessDownloadedMmsAction
return null;
}
@Override
protected Object processBackgroundFailure() {
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final String participantId = actionParameters.getString(KEY_PARTICIPANT_ID);
final int statusIfFailed = actionParameters.getInt(KEY_FAILURE_STATUS);
final int subId = actionParameters.getInt(KEY_SUB_ID);
ProcessDownloadedMmsAction.processDownloadActionFailure(messageId,
MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED,
conversationId, participantId, statusIfFailed, subId, transactionId);
return null;
}
static void updateMessageStatus(final Uri messageUri, final String messageId,
final String conversationId, final int status, final int rawStatus) {
final Context context = Factory.get().getApplicationContext();
// Downloading status just kept in local DB but need to fix up telephony DB first
if (status == MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING ||
status == MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING) {
MmsUtils.clearMmsStatus(context, messageUri);
}
// Then mark downloading status in our local DB
final ContentValues values = new ContentValues();
values.put(MessageColumns.STATUS, status);
values.put(MessageColumns.RAW_TELEPHONY_STATUS, rawStatus);
final DatabaseWrapper db = DataModel.get().getDatabase();
BugleDatabaseOperations.updateMessageRowIfExists(db, messageId, values);
MessagingContentProvider.notifyMessagesChanged(conversationId);
}
private DownloadMmsAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<DownloadMmsAction> CREATOR = new Parcelable.Creator<>() {
@Override
public DownloadMmsAction createFromParcel(final Parcel in) {
return new DownloadMmsAction(in);
}
@Override
public DownloadMmsAction[] newArray(final int size) {
return new DownloadMmsAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,116 @@
/*
* 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.action;
import android.content.ContentValues;
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.DatabaseWrapper;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.LogUtil;
/**
* Action used to fixup actively downloading or sending status at startup - just in case we
* crash - never run this when a message might actually be sending or downloading.
*/
public class FixupMessageStatusOnStartupAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
public static void fixupMessageStatus() {
final FixupMessageStatusOnStartupAction action = new FixupMessageStatusOnStartupAction();
action.start();
}
private FixupMessageStatusOnStartupAction() {
}
@Override
protected Object executeAction() {
// Now mark any messages in active sending or downloading state as inactive
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
int downloadFailedCnt;
int sendFailedCnt;
try {
// For both sending and downloading messages, let's assume they failed.
// For MMS sent/downloaded via platform, the sent/downloaded pending intent
// may come back. That will update the message. User may see the message
// in wrong status within a short window if that happens. But this should
// rarely happen. This is a simple solution to situations like app gets killed
// while the pending intent is still in the fly. Alternatively, we could
// keep the status for platform sent/downloaded MMS and timeout these messages.
// But that is much more complex.
final ContentValues values = new ContentValues();
values.put(DatabaseHelper.MessageColumns.STATUS,
MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED);
downloadFailedCnt = db.update(DatabaseHelper.MESSAGES_TABLE, values,
DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)",
new String[]{
Integer.toString(MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING),
Integer.toString(MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING)
});
values.clear();
values.put(DatabaseHelper.MessageColumns.STATUS,
MessageData.BUGLE_STATUS_OUTGOING_FAILED);
sendFailedCnt = db.update(DatabaseHelper.MESSAGES_TABLE, values,
DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)",
new String[]{
Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_SENDING),
Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_RESENDING)
});
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
LogUtil.i(TAG, "Fixup: Send failed - " + sendFailedCnt
+ " Download failed - " + downloadFailedCnt);
// Don't send contentObserver notifications as displayed text should not change
return null;
}
private FixupMessageStatusOnStartupAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<FixupMessageStatusOnStartupAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public FixupMessageStatusOnStartupAction createFromParcel(final Parcel in) {
return new FixupMessageStatusOnStartupAction(in);
}
@Override
public FixupMessageStatusOnStartupAction[] newArray(final int size) {
return new FixupMessageStatusOnStartupAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,177 @@
/*
* 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.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.action.ActionMonitor.ActionCompletedListener;
import com.android.messaging.datamodel.data.LaunchConversationData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.RunsOnMainThread;
import com.android.messaging.util.LogUtil;
import java.util.ArrayList;
/**
* Action used to get or create a conversation for a list of conversation participants.
*/
public class GetOrCreateConversationAction extends Action implements Parcelable {
/**
* Interface for GetOrCreateConversationAction listeners
*/
public interface GetOrCreateConversationActionListener {
@RunsOnMainThread
void onGetOrCreateConversationSucceeded(final ActionMonitor monitor,
final Object data, final String conversationId);
@RunsOnMainThread
void onGetOrCreateConversationFailed(final ActionMonitor monitor,
final Object data);
}
public static GetOrCreateConversationActionMonitor getOrCreateConversation(
final ArrayList<ParticipantData> participants, final Object data,
final GetOrCreateConversationActionListener listener) {
final GetOrCreateConversationActionMonitor monitor = new
GetOrCreateConversationActionMonitor(data, listener);
final GetOrCreateConversationAction action = new GetOrCreateConversationAction(participants,
monitor.getActionKey());
action.start(monitor);
return monitor;
}
public static GetOrCreateConversationActionMonitor getOrCreateConversation(
final String[] recipients, final Object data, final LaunchConversationData listener) {
final ArrayList<ParticipantData> participants = new ArrayList<>();
for (String recipient : recipients) {
recipient = recipient.trim();
if (!TextUtils.isEmpty(recipient)) {
participants.add(ParticipantData.getFromRawPhoneBySystemLocale(recipient));
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "getOrCreateConversation hit empty recipient");
}
}
return getOrCreateConversation(participants, data, listener);
}
private static final String KEY_PARTICIPANTS_LIST = "participants_list";
private GetOrCreateConversationAction(final ArrayList<ParticipantData> participants,
final String actionKey) {
super(actionKey);
actionParameters.putParcelableArrayList(KEY_PARTICIPANTS_LIST, participants);
}
/**
* Lookup the conversation or create a new one.
*/
@Override
protected Object executeAction() {
final DatabaseWrapper db = DataModel.get().getDatabase();
// First find the thread id for this list of participants.
final ArrayList<ParticipantData> participants =
actionParameters.getParcelableArrayList(KEY_PARTICIPANTS_LIST,
ParticipantData.class);
BugleDatabaseOperations.sanitizeConversationParticipants(participants);
final ArrayList<String> recipients =
BugleDatabaseOperations.getRecipientsFromConversationParticipants(participants);
final long threadId = MmsUtils.getOrCreateThreadId(Factory.get().getApplicationContext(),
recipients);
if (threadId < 0) {
LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't create a threadId in SMS db for numbers : " +
LogUtil.sanitizePII(recipients.toString()));
// TODO: Add a better way to indicate an error from executeAction.
return null;
}
final String conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
false, participants);
return conversationId;
}
/**
* A monitor that notifies a listener upon completion
*/
public static class GetOrCreateConversationActionMonitor extends ActionMonitor
implements ActionCompletedListener {
private final GetOrCreateConversationActionListener mListener;
GetOrCreateConversationActionMonitor(final Object data,
final GetOrCreateConversationActionListener listener) {
super(STATE_CREATED, generateUniqueActionKey("GetOrCreateConversationAction"), data);
setCompletedListener(this);
mListener = listener;
}
@Override
public void onActionSucceeded(final ActionMonitor monitor,
final Action action, final Object data, final Object result) {
if (result == null) {
mListener.onGetOrCreateConversationFailed(monitor, data);
} else {
mListener.onGetOrCreateConversationSucceeded(monitor, data, (String) result);
}
}
@Override
public void onActionFailed(final ActionMonitor monitor,
final Action action, final Object data, final Object result) {
// TODO: Currently onActionFailed is only called if there is an error in
// processing requests, not for errors in the local processing.
Assert.fail("Unreachable");
mListener.onGetOrCreateConversationFailed(monitor, data);
}
}
private GetOrCreateConversationAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<GetOrCreateConversationAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public GetOrCreateConversationAction createFromParcel(final Parcel in) {
return new GetOrCreateConversationAction(in);
}
@Override
public GetOrCreateConversationAction[] newArray(final int size) {
return new GetOrCreateConversationAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,480 @@
/*
* 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.Context;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.Telephony;
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.datamodel.SyncManager;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.MessagePartData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.PhoneUtils;
import java.util.ArrayList;
import java.util.List;
/**
* Action used to convert a draft message to an outgoing message. Its writes SMS messages to
* the telephony db, but {@link SendMessageAction} is responsible for inserting MMS message into
* the telephony DB. The latter also does the actual sending of the message in the background.
* The latter is also responsible for re-sending a failed message.
*/
public class InsertNewMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static long sLastSentMessageTimestamp = -1;
/**
* Insert message (no listener)
*/
public static void insertNewMessage(final MessageData message) {
final InsertNewMessageAction action = new InsertNewMessageAction(message);
action.start();
}
/**
* Insert message (no listener) with a given non-default subId.
*/
public static void insertNewMessage(final MessageData message, final int subId) {
Assert.isFalse(subId == ParticipantData.DEFAULT_SELF_SUB_ID);
final InsertNewMessageAction action = new InsertNewMessageAction(message, subId);
action.start();
}
/**
* Insert message (no listener)
*/
public static void insertNewMessage(final int subId, final String recipients,
final String messageText, final String subject) {
final InsertNewMessageAction action = new InsertNewMessageAction(
subId, recipients, messageText, subject);
action.start();
}
public static long getLastSentMessageTimestamp() {
return sLastSentMessageTimestamp;
}
private static final String KEY_SUB_ID = "sub_id";
private static final String KEY_MESSAGE = "message";
private static final String KEY_RECIPIENTS = "recipients";
private static final String KEY_MESSAGE_TEXT = "message_text";
private static final String KEY_SUBJECT_TEXT = "subject_text";
private InsertNewMessageAction(final MessageData message) {
this(message, ParticipantData.DEFAULT_SELF_SUB_ID);
actionParameters.putParcelable(KEY_MESSAGE, message);
}
private InsertNewMessageAction(final MessageData message, final int subId) {
super();
actionParameters.putParcelable(KEY_MESSAGE, message);
actionParameters.putInt(KEY_SUB_ID, subId);
}
private InsertNewMessageAction(final int subId, final String recipients,
final String messageText, final String subject) {
super();
if (TextUtils.isEmpty(recipients) || TextUtils.isEmpty(messageText)) {
Assert.fail("InsertNewMessageAction: Can't have empty recipients or message");
}
actionParameters.putInt(KEY_SUB_ID, subId);
actionParameters.putString(KEY_RECIPIENTS, recipients);
actionParameters.putString(KEY_MESSAGE_TEXT, messageText);
actionParameters.putString(KEY_SUBJECT_TEXT, subject);
}
/**
* Add message to database in pending state and queue actual sending
*/
@Override
protected Object executeAction() {
MessageData message = actionParameters.getParcelable(KEY_MESSAGE, MessageData.class);
if (message == null) {
LogUtil.i(TAG, "InsertNewMessageAction: Creating MessageData with provided data");
message = createMessage();
if (message == null) {
LogUtil.w(TAG, "InsertNewMessageAction: Could not create MessageData");
return null;
}
}
final DatabaseWrapper db = DataModel.get().getDatabase();
final String conversationId = message.getConversationId();
final ParticipantData self = getSelf(db, conversationId, message);
if (self == null) {
return null;
}
message.bindSelfId(self.getId());
// If the user taps the Send button before the conversation draft is created/loaded by
// ReadDraftDataAction (maybe the action service thread was busy), the MessageData may not
// have the participant id set. It should be equal to the self id, so we'll use that.
if (message.getParticipantId() == null) {
message.bindParticipantId(self.getId());
}
final long timestamp = System.currentTimeMillis();
final ArrayList<String> recipients =
BugleDatabaseOperations.getRecipientsForConversation(db, conversationId);
if (recipients.size() < 1) {
LogUtil.w(TAG, "InsertNewMessageAction: message recipients is empty");
return null;
}
final int subId = self.getSubId();
LogUtil.i(TAG, "InsertNewMessageAction: inserting new message for subId " + subId);
actionParameters.putInt(KEY_SUB_ID, subId);
// TODO: Work out whether to send with SMS or MMS (taking into account recipients)?
final boolean isSms = (message.getProtocol() == MessageData.PROTOCOL_SMS);
if (isSms) {
String sendingConversationId = conversationId;
if (recipients.size() > 1) {
// Broadcast SMS - put message in "fake conversation" before farming out to real 1:1
final long laterTimestamp = timestamp + 1;
// Send a single message
insertBroadcastSmsMessage(conversationId, message, subId,
laterTimestamp, recipients);
sendingConversationId = null;
}
for (final String recipient : recipients) {
// Start actual sending
insertSendingSmsMessage(message, subId, recipient,
timestamp, sendingConversationId);
}
// Can now clear draft from conversation (deleting attachments if necessary)
BugleDatabaseOperations.updateDraftMessageData(db, conversationId,
null /* message */, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT);
} else {
final long timestampRoundedToSecond = 1000 * ((timestamp + 500) / 1000);
// Write place holder message directly referencing parts from the draft
final MessageData messageToSend = insertSendingMmsMessage(conversationId,
message, timestampRoundedToSecond);
// Can now clear draft from conversation (preserving attachments which are now
// referenced by messageToSend)
BugleDatabaseOperations.updateDraftMessageData(db, conversationId,
messageToSend, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT);
}
MessagingContentProvider.notifyConversationListChanged();
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
return message;
}
private ParticipantData getSelf(
final DatabaseWrapper db, final String conversationId, final MessageData message) {
ParticipantData self;
// Check if we are asked to bind to a non-default subId. This is directly passed in from
// the UI thread so that the sub id may be locked as soon as the user clicks on the Send
// button.
final int requestedSubId = actionParameters.getInt(
KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
if (requestedSubId != ParticipantData.DEFAULT_SELF_SUB_ID) {
self = BugleDatabaseOperations.getOrCreateSelf(db, requestedSubId);
} else {
String selfId = message.getSelfId();
if (selfId == null) {
// The conversation draft provides no self id hint, meaning that 1) conversation
// self id was not loaded AND 2) the user didn't pick a SIM from the SIM selector.
// In this case, use the conversation's self id.
final ConversationListItemData conversation =
ConversationListItemData.getExistingConversation(db, conversationId);
if (conversation != null) {
selfId = conversation.getSelfId();
} else {
LogUtil.w(LogUtil.BUGLE_DATAMODEL_TAG, "Conversation " + conversationId +
"already deleted before sending draft message " +
message.getMessageId() + ". Aborting InsertNewMessageAction.");
return null;
}
}
// We do not use SubscriptionManager.DEFAULT_SUB_ID for sending a message, so we need
// to bind the message to the system default subscription if it's unbound.
final ParticipantData unboundSelf = BugleDatabaseOperations.getExistingParticipant(
db, selfId);
if (unboundSelf.getSubId() == ParticipantData.DEFAULT_SELF_SUB_ID) {
final int defaultSubId = PhoneUtils.getDefault().getDefaultSmsSubscriptionId();
self = BugleDatabaseOperations.getOrCreateSelf(db, defaultSubId);
} else {
self = unboundSelf;
}
}
return self;
}
/** Create MessageData using KEY_RECIPIENTS, KEY_MESSAGE_TEXT and KEY_SUBJECT */
private MessageData createMessage() {
// First find the thread id for this list of participants.
final String recipientsList = actionParameters.getString(KEY_RECIPIENTS);
final String messageText = actionParameters.getString(KEY_MESSAGE_TEXT);
final String subjectText = actionParameters.getString(KEY_SUBJECT_TEXT);
final int subId = actionParameters.getInt(
KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
final ArrayList<ParticipantData> participants = new ArrayList<>();
for (final String recipient : recipientsList.split(",")) {
participants.add(ParticipantData.getFromRawPhoneBySimLocale(recipient, subId));
}
if (participants.size() == 0) {
Assert.fail("InsertNewMessage: Empty participants");
return null;
}
final DatabaseWrapper db = DataModel.get().getDatabase();
BugleDatabaseOperations.sanitizeConversationParticipants(participants);
final ArrayList<String> recipients =
BugleDatabaseOperations.getRecipientsFromConversationParticipants(participants);
if (recipients.size() == 0) {
Assert.fail("InsertNewMessage: Empty recipients");
return null;
}
final long threadId = MmsUtils.getOrCreateThreadId(Factory.get().getApplicationContext(),
recipients);
if (threadId < 0) {
Assert.fail("InsertNewMessage: Couldn't get threadId in SMS db for these recipients: "
+ recipients);
// TODO: How do we fail the action?
return null;
}
final String conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
false, participants);
final ParticipantData self = BugleDatabaseOperations.getOrCreateSelf(db, subId);
if (TextUtils.isEmpty(subjectText)) {
return MessageData.createDraftSmsMessage(conversationId, self.getId(), messageText);
} else {
return MessageData.createDraftMmsMessage(conversationId, self.getId(), messageText,
subjectText);
}
}
private void insertBroadcastSmsMessage(final String conversationId,
final MessageData message, final int subId, final long laterTimestamp,
final ArrayList<String> recipients) {
LogUtil.v(TAG, "InsertNewMessageAction: Inserting broadcast SMS message "
+ message.getMessageId());
final Context context = Factory.get().getApplicationContext();
final DatabaseWrapper db = DataModel.get().getDatabase();
// Inform sync that message is being added at timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(laterTimestamp);
final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId);
final String address = TextUtils.join(" ", recipients);
final String messageText = message.getMessageText();
// Insert message into telephony database sms message table
final Uri messageUri = MmsUtils.insertSmsMessage(context,
Telephony.Sms.CONTENT_URI,
subId,
address,
messageText,
laterTimestamp,
Telephony.Sms.STATUS_COMPLETE,
Telephony.Sms.MESSAGE_TYPE_SENT, threadId);
if (messageUri != null && !TextUtils.isEmpty(messageUri.toString())) {
db.beginTransaction();
try {
message.updateSendingMessage(conversationId, messageUri, laterTimestamp);
message.markMessageSent(laterTimestamp);
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
BugleDatabaseOperations.updateConversationMetadataInTransaction(db,
conversationId, message.getMessageId(), laterTimestamp,
false /* senderBlocked */, false /* shouldAutoSwitchSelfId */);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "InsertNewMessageAction: Inserted broadcast SMS message "
+ message.getMessageId() + ", uri = " + message.getSmsMessageUri());
}
MessagingContentProvider.notifyMessagesChanged(conversationId);
MessagingContentProvider.notifyPartsChanged();
} else {
// Ignore error as we only really care about the individual messages?
LogUtil.e(TAG,
"InsertNewMessageAction: No uri for broadcast SMS " + message.getMessageId()
+ " inserted into telephony DB");
}
}
/**
* Insert SMS messaging into our database and telephony db.
*/
private MessageData insertSendingSmsMessage(final MessageData content, final int subId,
final String recipient, final long timestamp, final String sendingConversationId) {
sLastSentMessageTimestamp = timestamp;
final Context context = Factory.get().getApplicationContext();
// Inform sync that message is being added at timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(timestamp);
final DatabaseWrapper db = DataModel.get().getDatabase();
// Send a single message
long threadId;
String conversationId;
if (sendingConversationId == null) {
// For 1:1 message generated sending broadcast need to look up threadId+conversationId
threadId = MmsUtils.getOrCreateSmsThreadId(context, recipient);
conversationId = BugleDatabaseOperations.getOrCreateConversationFromRecipient(
db, threadId, false /* sender blocked */,
ParticipantData.getFromRawPhoneBySimLocale(recipient, subId));
} else {
// Otherwise just look up threadId
threadId = BugleDatabaseOperations.getThreadId(db, sendingConversationId);
conversationId = sendingConversationId;
}
final String messageText = content.getMessageText();
// Insert message into telephony database sms message table
final Uri messageUri = MmsUtils.insertSmsMessage(context,
Telephony.Sms.CONTENT_URI,
subId,
recipient,
messageText,
timestamp,
Telephony.Sms.STATUS_NONE,
Telephony.Sms.MESSAGE_TYPE_SENT, threadId);
MessageData message = null;
if (messageUri != null && !TextUtils.isEmpty(messageUri.toString())) {
db.beginTransaction();
try {
message = MessageData.createDraftSmsMessage(conversationId,
content.getSelfId(), messageText);
message.updateSendingMessage(conversationId, messageUri, timestamp);
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
// Do not update the conversation summary to reflect autogenerated 1:1 messages
if (sendingConversationId != null) {
BugleDatabaseOperations.updateConversationMetadataInTransaction(db,
conversationId, message.getMessageId(), timestamp,
false /* senderBlocked */, false /* shouldAutoSwitchSelfId */);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "InsertNewMessageAction: Inserted SMS message "
+ message.getMessageId() + " (uri = " + message.getSmsMessageUri()
+ ", timestamp = " + message.getReceivedTimeStamp() + ")");
}
MessagingContentProvider.notifyMessagesChanged(conversationId);
MessagingContentProvider.notifyPartsChanged();
} else {
LogUtil.e(TAG, "InsertNewMessageAction: No uri for SMS inserted into telephony DB");
}
return message;
}
/**
* Insert MMS messaging into our database.
*/
private MessageData insertSendingMmsMessage(final String conversationId,
final MessageData message, final long timestamp) {
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
final List<MessagePartData> attachmentsUpdated = new ArrayList<>();
try {
sLastSentMessageTimestamp = timestamp;
// Insert "draft" message as placeholder until the final message is written to
// the telephony db
message.updateSendingMessage(conversationId, null/*messageUri*/, timestamp);
// No need to inform SyncManager as message currently has no Uri...
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
BugleDatabaseOperations.updateConversationMetadataInTransaction(db,
conversationId, message.getMessageId(), timestamp,
false /* senderBlocked */, false /* shouldAutoSwitchSelfId */);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "InsertNewMessageAction: Inserted MMS message "
+ message.getMessageId() + " (timestamp = " + timestamp + ")");
}
MessagingContentProvider.notifyMessagesChanged(conversationId);
MessagingContentProvider.notifyPartsChanged();
return message;
}
private InsertNewMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<InsertNewMessageAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public InsertNewMessageAction createFromParcel(final Parcel in) {
return new InsertNewMessageAction(in);
}
@Override
public InsertNewMessageAction[] newArray(final int size) {
return new InsertNewMessageAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,115 @@
/*
* 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.action;
import android.content.ContentValues;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.BugleNotifications;
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.sms.MmsUtils;
import com.android.messaging.util.LogUtil;
/**
* Action used to mark all the messages in a conversation as read
*/
public class MarkAsReadAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String KEY_CONVERSATION_ID = "conversation_id";
/**
* Mark all the messages as read for a particular conversation.
*/
public static void markAsRead(final String conversationId) {
final MarkAsReadAction action = new MarkAsReadAction(conversationId);
action.start();
}
private MarkAsReadAction(final String conversationId) {
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
}
@Override
protected Object executeAction() {
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
// TODO: Consider doing this in background service to avoid delaying other actions
final DatabaseWrapper db = DataModel.get().getDatabase();
// Mark all messages in thread as read in telephony
final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId);
if (threadId != -1) {
MmsUtils.updateSmsReadStatus(threadId, Long.MAX_VALUE);
}
// Update local db
db.beginTransaction();
try {
final ContentValues values = new ContentValues();
values.put(MessageColumns.CONVERSATION_ID, conversationId);
values.put(MessageColumns.READ, 1);
values.put(MessageColumns.SEEN, 1); // if they read it, they saw it
final int count = db.update(DatabaseHelper.MESSAGES_TABLE, values,
"(" + MessageColumns.READ + " !=1 OR " +
MessageColumns.SEEN + " !=1 ) AND " +
MessageColumns.CONVERSATION_ID + "=?",
new String[] { conversationId });
if (count > 0) {
MessagingContentProvider.notifyMessagesChanged(conversationId);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
// After marking messages as read, update the notifications. This will
// clear the now stale notifications.
BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL);
return null;
}
private MarkAsReadAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<MarkAsReadAction> CREATOR = new Parcelable.Creator<>() {
@Override
public MarkAsReadAction createFromParcel(final Parcel in) {
return new MarkAsReadAction(in);
}
@Override
public MarkAsReadAction[] newArray(final int size) {
return new MarkAsReadAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,128 @@
/*
* 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.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.BugleNotifications;
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.util.LogUtil;
/**
* Action used to mark all messages as seen
*/
public class MarkAsSeenAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String KEY_CONVERSATION_ID = "conversation_id";
/**
* Mark all messages as seen.
*/
public static void markAllAsSeen() {
final MarkAsSeenAction action = new MarkAsSeenAction((String) null/*conversationId*/);
action.start();
}
/**
* Mark all messages of a given conversation as seen.
*/
public static void markAsSeen(final String conversationId) {
final MarkAsSeenAction action = new MarkAsSeenAction(conversationId);
action.start();
}
/**
* ctor for MarkAsSeenAction.
* @param conversationId the conversation id for which to mark as seen, or null to mark all
* messages as seen
*/
public MarkAsSeenAction(final String conversationId) {
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
}
@Override
protected Object executeAction() {
final String conversationId =
actionParameters.getString(KEY_CONVERSATION_ID);
final boolean hasSpecificConversation = !TextUtils.isEmpty(conversationId);
// Everything in telephony should already have the seen bit set.
// Possible exception are messages which did not have seen set and
// were sync'ed into bugle.
// Now mark the messages as seen in the bugle db
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
final ContentValues values = new ContentValues();
values.put(MessageColumns.SEEN, 1);
if (hasSpecificConversation) {
final int count = db.update(DatabaseHelper.MESSAGES_TABLE, values,
MessageColumns.SEEN + " != 1 AND " +
MessageColumns.CONVERSATION_ID + "=?",
new String[] { conversationId });
if (count > 0) {
MessagingContentProvider.notifyMessagesChanged(conversationId);
}
} else {
db.update(DatabaseHelper.MESSAGES_TABLE, values,
MessageColumns.SEEN + " != 1", null/*selectionArgs*/);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
// After marking messages as seen, update the notifications. This will
// clear the now stale notifications.
BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL);
return null;
}
private MarkAsSeenAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<MarkAsSeenAction> CREATOR = new Parcelable.Creator<>() {
@Override
public MarkAsSeenAction createFromParcel(final Parcel in) {
return new MarkAsSeenAction(in);
}
@Override
public MarkAsSeenAction[] newArray(final int size) {
return new MarkAsSeenAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,125 @@
/*
* 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.action;
import android.content.ContentUris;
import android.content.ContentValues;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.Telephony;
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.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import java.util.concurrent.TimeUnit;
public class ProcessDeliveryReportAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String KEY_URI = "uri";
private static final String KEY_STATUS = "status";
private ProcessDeliveryReportAction(final Uri uri, final int status) {
actionParameters.putParcelable(KEY_URI, uri);
actionParameters.putInt(KEY_STATUS, status);
}
public static void deliveryReportReceived(final Uri uri, final int status) {
final ProcessDeliveryReportAction action = new ProcessDeliveryReportAction(uri, status);
action.start();
}
@Override
protected Object executeAction() {
final Uri smsMessageUri = actionParameters.getParcelable(KEY_URI, Uri.class);
final int status = actionParameters.getInt(KEY_STATUS);
final DatabaseWrapper db = DataModel.get().getDatabase();
final long messageRowId = ContentUris.parseId(smsMessageUri);
if (messageRowId < 0) {
LogUtil.e(TAG, "ProcessDeliveryReportAction: can't find message");
return null;
}
final long timeSentInMillis = System.currentTimeMillis();
// Update telephony provider
if (smsMessageUri != null) {
MmsUtils.updateSmsStatusAndDateSent(smsMessageUri, status, timeSentInMillis);
}
// Update local message
db.beginTransaction();
try {
final ContentValues values = new ContentValues();
final int bugleStatus = SyncMessageBatch.bugleStatusForSms(true /*outgoing*/,
Telephony.Sms.MESSAGE_TYPE_SENT /* type */, status);
values.put(DatabaseHelper.MessageColumns.STATUS, bugleStatus);
values.put(DatabaseHelper.MessageColumns.SENT_TIMESTAMP,
TimeUnit.MILLISECONDS.toMicros(timeSentInMillis));
final MessageData messageData =
BugleDatabaseOperations.readMessageData(db, smsMessageUri);
// Check the message was not removed before the delivery report comes in
if (messageData != null) {
Assert.isTrue(smsMessageUri.equals(messageData.getSmsMessageUri()));
// Row must exist as was just loaded above (on ActionService thread)
BugleDatabaseOperations.updateMessageRow(db, messageData.getMessageId(), values);
MessagingContentProvider.notifyMessagesChanged(messageData.getConversationId());
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
return null;
}
private ProcessDeliveryReportAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ProcessDeliveryReportAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public ProcessDeliveryReportAction createFromParcel(final Parcel in) {
return new ProcessDeliveryReportAction(in);
}
@Override
public ProcessDeliveryReportAction[] newArray(final int size) {
return new ProcessDeliveryReportAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,583 @@
/*
* 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.app.Activity;
import android.content.ContentValues;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.Telephony.Mms;
import android.support.v7.mms.pdu.PduHeaders;
import android.support.v7.mms.pdu.RetrieveConf;
import android.telephony.SmsManager;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.MmsFileProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.mmslib.SqliteWrapper;
import com.android.messaging.sms.DatabaseMessages;
import com.android.messaging.sms.MmsSender;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import com.google.common.io.Files;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.List;
/**
* Processes an MMS message after it has been downloaded.
* NOTE: This action must queue a ProcessPendingMessagesAction when it is done (success or failure).
*/
public class ProcessDownloadedMmsAction extends Action {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
// Always set when message downloaded
private static final String KEY_DOWNLOADED_BY_PLATFORM = "downloaded_by_platform";
private static final String KEY_MESSAGE_ID = "message_id";
private static final String KEY_NOTIFICATION_URI = "notification_uri";
private static final String KEY_CONVERSATION_ID = "conversation_id";
private static final String KEY_PARTICIPANT_ID = "participant_id";
private static final String KEY_STATUS_IF_FAILED = "status_if_failed";
// Set when message downloaded by platform (L+)
private static final String KEY_RESULT_CODE = "result_code";
private static final String KEY_HTTP_STATUS_CODE = "http_status_code";
private static final String KEY_CONTENT_URI = "content_uri";
private static final String KEY_SUB_ID = "sub_id";
private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number";
private static final String KEY_TRANSACTION_ID = "transaction_id";
private static final String KEY_CONTENT_LOCATION = "content_location";
private static final String KEY_AUTO_DOWNLOAD = "auto_download";
private static final String KEY_RECEIVED_TIMESTAMP = "received_timestamp";
private static final String KEY_EXPIRY = "expiry";
// Set when message downloaded by us (legacy)
private static final String KEY_STATUS = "status";
private static final String KEY_RAW_STATUS = "raw_status";
private static final String KEY_MMS_URI = "mms_uri";
// Used to send a deferred response in response to auto-download failure
private static final String KEY_SEND_DEFERRED_RESP_STATUS = "send_deferred_resp_status";
// Results passed from background worker to processCompletion
private static final String BUNDLE_REQUEST_STATUS = "request_status";
private static final String BUNDLE_RAW_TELEPHONY_STATUS = "raw_status";
private static final String BUNDLE_MMS_URI = "mms_uri";
// This is called when MMS lib API returns via PendingIntent
public static void processMessageDownloaded(final int resultCode, final Bundle extras) {
final String messageId = extras.getString(DownloadMmsAction.EXTRA_MESSAGE_ID);
final Uri contentUri = extras.getParcelable(DownloadMmsAction.EXTRA_CONTENT_URI, Uri.class);
final Uri notificationUri = extras.getParcelable(DownloadMmsAction.EXTRA_NOTIFICATION_URI,
Uri.class);
final String conversationId = extras.getString(DownloadMmsAction.EXTRA_CONVERSATION_ID);
final String participantId = extras.getString(DownloadMmsAction.EXTRA_PARTICIPANT_ID);
Assert.notNull(messageId);
Assert.notNull(contentUri);
Assert.notNull(notificationUri);
Assert.notNull(conversationId);
Assert.notNull(participantId);
final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction();
final Bundle params = action.actionParameters;
params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, true);
params.putString(KEY_MESSAGE_ID, messageId);
params.putInt(KEY_RESULT_CODE, resultCode);
params.putInt(KEY_HTTP_STATUS_CODE,
extras.getInt(SmsManager.EXTRA_MMS_HTTP_STATUS, 0));
params.putParcelable(KEY_CONTENT_URI, contentUri);
params.putParcelable(KEY_NOTIFICATION_URI, notificationUri);
params.putInt(KEY_SUB_ID,
extras.getInt(DownloadMmsAction.EXTRA_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID));
params.putString(KEY_SUB_PHONE_NUMBER,
extras.getString(DownloadMmsAction.EXTRA_SUB_PHONE_NUMBER));
params.putString(KEY_TRANSACTION_ID,
extras.getString(DownloadMmsAction.EXTRA_TRANSACTION_ID));
params.putString(KEY_CONTENT_LOCATION,
extras.getString(DownloadMmsAction.EXTRA_CONTENT_LOCATION));
params.putBoolean(KEY_AUTO_DOWNLOAD,
extras.getBoolean(DownloadMmsAction.EXTRA_AUTO_DOWNLOAD));
params.putLong(KEY_RECEIVED_TIMESTAMP,
extras.getLong(DownloadMmsAction.EXTRA_RECEIVED_TIMESTAMP));
params.putString(KEY_CONVERSATION_ID, conversationId);
params.putString(KEY_PARTICIPANT_ID, participantId);
params.putInt(KEY_STATUS_IF_FAILED,
extras.getInt(DownloadMmsAction.EXTRA_STATUS_IF_FAILED));
params.putLong(KEY_EXPIRY, extras.getLong(DownloadMmsAction.EXTRA_EXPIRY));
action.start();
}
// This is called for fast failing downloading (due to airplane mode or mobile data )
public static void processMessageDownloadFastFailed(final String messageId,
final Uri notificationUri, final String conversationId, final String participantId,
final String contentLocation, final int subId, final String subPhoneNumber,
final int statusIfFailed, final boolean autoDownload, final String transactionId,
final int resultCode) {
Assert.notNull(messageId);
Assert.notNull(notificationUri);
Assert.notNull(conversationId);
Assert.notNull(participantId);
final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction();
final Bundle params = action.actionParameters;
params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, true);
params.putString(KEY_MESSAGE_ID, messageId);
params.putInt(KEY_RESULT_CODE, resultCode);
params.putParcelable(KEY_NOTIFICATION_URI, notificationUri);
params.putInt(KEY_SUB_ID, subId);
params.putString(KEY_SUB_PHONE_NUMBER, subPhoneNumber);
params.putString(KEY_CONTENT_LOCATION, contentLocation);
params.putBoolean(KEY_AUTO_DOWNLOAD, autoDownload);
params.putString(KEY_CONVERSATION_ID, conversationId);
params.putString(KEY_PARTICIPANT_ID, participantId);
params.putInt(KEY_STATUS_IF_FAILED, statusIfFailed);
params.putString(KEY_TRANSACTION_ID, transactionId);
action.start();
}
public static void processDownloadActionFailure(final String messageId, final int status,
final int rawStatus, final String conversationId, final String participantId,
final int statusIfFailed, final int subId, final String transactionId) {
Assert.notNull(messageId);
Assert.notNull(conversationId);
Assert.notNull(participantId);
final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction();
final Bundle params = action.actionParameters;
params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, false);
params.putString(KEY_MESSAGE_ID, messageId);
params.putInt(KEY_STATUS, status);
params.putInt(KEY_RAW_STATUS, rawStatus);
params.putString(KEY_CONVERSATION_ID, conversationId);
params.putString(KEY_PARTICIPANT_ID, participantId);
params.putInt(KEY_STATUS_IF_FAILED, statusIfFailed);
params.putInt(KEY_SUB_ID, subId);
params.putString(KEY_TRANSACTION_ID, transactionId);
action.start();
}
public static void sendDeferredRespStatus(final String messageId, final String transactionId,
final String contentLocation, final int subId) {
final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction();
final Bundle params = action.actionParameters;
params.putString(KEY_MESSAGE_ID, messageId);
params.putString(KEY_TRANSACTION_ID, transactionId);
params.putString(KEY_CONTENT_LOCATION, contentLocation);
params.putBoolean(KEY_SEND_DEFERRED_RESP_STATUS, true);
params.putInt(KEY_SUB_ID, subId);
action.start();
}
private ProcessDownloadedMmsAction() {
// Callers must use one of the static methods above
}
@Override
protected Object executeAction() {
// Fire up the background worker
requestBackgroundWork();
return null;
}
@Override
protected Bundle doBackgroundWork() {
final Context context = Factory.get().getApplicationContext();
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION);
final boolean sendDeferredRespStatus =
actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS, false);
// Send a response indicating that auto-download failed
if (sendDeferredRespStatus) {
LogUtil.v(TAG, "DownloadMmsAction: Auto-download of message " + messageId
+ " failed; sending DEFERRED NotifyRespInd");
MmsUtils.sendNotifyResponseForMmsDownload(
context,
subId,
MmsUtils.stringToBytes(transactionId, "UTF-8"),
contentLocation,
PduHeaders.STATUS_DEFERRED);
return null;
}
// Processing a real MMS download
final boolean downloadedByPlatform = actionParameters.getBoolean(
KEY_DOWNLOADED_BY_PLATFORM);
final int status;
int rawStatus = MmsUtils.PDU_HEADER_VALUE_UNDEFINED;
Uri mmsUri = null;
if (downloadedByPlatform) {
final int resultCode = actionParameters.getInt(KEY_RESULT_CODE);
if (resultCode == Activity.RESULT_OK) {
final Uri contentUri = actionParameters.getParcelable(KEY_CONTENT_URI, Uri.class);
final File downloadedFile = MmsFileProvider.getFile(contentUri);
byte[] downloadedData = null;
try {
downloadedData = Files.toByteArray(downloadedFile);
} catch (final FileNotFoundException e) {
LogUtil.e(TAG, "ProcessDownloadedMmsAction: MMS download file not found: "
+ downloadedFile.getAbsolutePath());
} catch (final IOException e) {
LogUtil.e(TAG, "ProcessDownloadedMmsAction: Error reading MMS download file: "
+ downloadedFile.getAbsolutePath(), e);
}
// Can delete the temp file now
if (downloadedFile.exists()) {
downloadedFile.delete();
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "ProcessDownloadedMmsAction: Deleted temp file with "
+ "downloaded MMS pdu: " + downloadedFile.getAbsolutePath());
}
}
if (downloadedData != null) {
final RetrieveConf retrieveConf =
MmsSender.parseRetrieveConf(downloadedData, subId);
if (retrieveConf != null) {
// Insert the downloaded MMS into telephony
final Uri notificationUri = actionParameters.getParcelable(
KEY_NOTIFICATION_URI, Uri.class);
final String subPhoneNumber = actionParameters.getString(
KEY_SUB_PHONE_NUMBER);
final boolean autoDownload = actionParameters.getBoolean(
KEY_AUTO_DOWNLOAD);
final long receivedTimestampInSeconds =
actionParameters.getLong(KEY_RECEIVED_TIMESTAMP);
final long expiry = actionParameters.getLong(KEY_EXPIRY);
// Inform sync we're adding a message to telephony
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(receivedTimestampInSeconds * 1000L);
final MmsUtils.StatusPlusUri result =
MmsUtils.insertDownloadedMessageAndSendResponse(context,
notificationUri, subId, subPhoneNumber, transactionId,
contentLocation, autoDownload, receivedTimestampInSeconds,
expiry, retrieveConf);
status = result.status;
rawStatus = result.rawStatus;
mmsUri = result.uri;
} else {
// Invalid response PDU
status = MmsUtils.MMS_REQUEST_MANUAL_RETRY;
}
} else {
// Failed to read download file
status = MmsUtils.MMS_REQUEST_MANUAL_RETRY;
}
} else {
LogUtil.w(TAG, "ProcessDownloadedMmsAction: Platform returned error resultCode: "
+ resultCode);
final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE);
status = MmsSender.getErrorResultStatus(resultCode, httpStatusCode);
}
} else {
// Message was already processed by the internal API, or the download action failed.
// In either case, we just need to copy the status to the response bundle.
status = actionParameters.getInt(KEY_STATUS);
rawStatus = actionParameters.getInt(KEY_RAW_STATUS);
mmsUri = actionParameters.getParcelable(KEY_MMS_URI, Uri.class);
}
final Bundle response = new Bundle();
response.putInt(BUNDLE_REQUEST_STATUS, status);
response.putInt(BUNDLE_RAW_TELEPHONY_STATUS, rawStatus);
response.putParcelable(BUNDLE_MMS_URI, mmsUri);
return response;
}
@Override
protected Object processBackgroundResponse(final Bundle response) {
if (response == null) {
// No message download to process; doBackgroundWork sent a notify deferred response
Assert.isTrue(actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS));
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(
true /* failed */, this);
return null;
}
final int status = response.getInt(BUNDLE_REQUEST_STATUS);
final int rawStatus = response.getInt(BUNDLE_RAW_TELEPHONY_STATUS);
final Uri messageUri = response.getParcelable(BUNDLE_MMS_URI, Uri.class);
final boolean autoDownload = actionParameters.getBoolean(KEY_AUTO_DOWNLOAD);
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
// Do post-processing on downloaded message
final MessageData message = processResult(status, rawStatus, messageUri);
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
// If we were trying to auto-download but have failed need to send the deferred response
final boolean needToSendDeferredResp =
autoDownload && (status == MmsUtils.MMS_REQUEST_MANUAL_RETRY);
if (needToSendDeferredResp) {
final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION);
sendDeferredRespStatus(messageId, transactionId, contentLocation, subId);
}
if (autoDownload) {
final DatabaseWrapper db = DataModel.get().getDatabase();
MessageData toastMessage = message;
if (toastMessage == null) {
// If the downloaded failed (message is null), then we should announce the
// receiving of the wap push message. Load the wap push message here instead.
toastMessage = BugleDatabaseOperations.readMessageData(db, messageId);
}
if (toastMessage != null) {
final ParticipantData sender = ParticipantData.getFromId(
db, toastMessage.getParticipantId());
BugleActionToasts.onMessageReceived(
toastMessage.getConversationId(), sender, toastMessage);
}
} else {
final boolean success = message != null && status == MmsUtils.MMS_REQUEST_SUCCEEDED;
BugleActionToasts.onSendMessageOrManualDownloadActionCompleted(
// If download failed, use the wap push message's conversation instead
success ? message.getConversationId()
: actionParameters.getString(KEY_CONVERSATION_ID),
success, status, false/*isSms*/, subId, false /*isSend*/);
}
final boolean failed = (messageUri == null);
// Scheduling pending messages. If auto downloading is failed and it needs to send the
// deferred response, Skip it here and it will be scheduled after sending the response.
if (!needToSendDeferredResp) {
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(failed, this);
}
if (failed) {
BugleNotifications.update(false, BugleNotifications.UPDATE_ERRORS);
}
return message;
}
@Override
protected Object processBackgroundFailure() {
if (actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS)) {
// We can early-out for these failures. processResult is only designed to handle
// post-processing of MMS downloads (whether successful or not).
LogUtil.w(TAG,
"ProcessDownloadedMmsAction: Exception while sending deferred NotifyRespInd");
return null;
}
// Background worker threw an exception; require manual retry
processResult(MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED,
null /* mmsUri */);
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(true /* failed */,
this);
return null;
}
private MessageData processResult(final int status, final int rawStatus, final Uri mmsUri) {
final Context context = Factory.get().getApplicationContext();
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final Uri mmsNotificationUri = actionParameters.getParcelable(KEY_NOTIFICATION_URI,
Uri.class);
final String notificationConversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final String notificationParticipantId = actionParameters.getString(KEY_PARTICIPANT_ID);
final int statusIfFailed = actionParameters.getInt(KEY_STATUS_IF_FAILED);
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
Assert.notNull(messageId);
LogUtil.i(TAG, "ProcessDownloadedMmsAction: Processed MMS download of message " + messageId
+ "; status is " + MmsUtils.getRequestStatusDescription(status));
DatabaseMessages.MmsMessage mms = null;
if (status == MmsUtils.MMS_REQUEST_SUCCEEDED && mmsUri != null) {
// Delete the initial M-Notification.ind from telephony
SqliteWrapper.delete(context, context.getContentResolver(),
mmsNotificationUri, null, null);
// Read the sent MMS from the telephony provider
mms = MmsUtils.loadMms(mmsUri);
}
boolean messageInFocusedConversation;
boolean messageInObservableConversation;
String conversationId = null;
MessageData message = null;
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
if (mms != null) {
final ParticipantData self = ParticipantData.getSelfParticipant(mms.getSubId());
final String selfId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
final List<String> recipients = MmsUtils.getRecipientsByThread(mms.mThreadId);
String from = MmsUtils.getMmsSender(recipients, mms.getUri());
if (from == null) {
LogUtil.w(TAG,
"Downloaded an MMS without sender address; using unknown sender.");
from = ParticipantData.getUnknownSenderDestination();
}
final ParticipantData sender = ParticipantData.getFromRawPhoneBySimLocale(from,
subId);
final String senderParticipantId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender);
if (!senderParticipantId.equals(notificationParticipantId)) {
LogUtil.e(TAG, "ProcessDownloadedMmsAction: Downloaded MMS message "
+ messageId + " has different sender (participantId = "
+ senderParticipantId + ") than notification ("
+ notificationParticipantId + ")");
}
final boolean blockedSender = BugleDatabaseOperations.isBlockedDestination(
db, sender.getNormalizedDestination());
conversationId = BugleDatabaseOperations.getOrCreateConversationFromThreadId(db,
mms.mThreadId, blockedSender, subId);
messageInFocusedConversation =
DataModel.get().isFocusedConversation(conversationId);
messageInObservableConversation =
DataModel.get().isNewMessageObservable(conversationId);
// TODO: Also write these values to the telephony provider
mms.mRead = messageInFocusedConversation;
mms.mSeen = messageInObservableConversation;
// Translate to our format
message = MmsUtils.createMmsMessage(mms, conversationId, senderParticipantId,
selfId, MessageData.BUGLE_STATUS_INCOMING_COMPLETE);
// Update image sizes.
message.updateSizesForImageParts();
// Inform sync that message has been added at local received timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(message.getReceivedTimeStamp());
final MessageData current = BugleDatabaseOperations.readMessageData(db, messageId);
if (current == null) {
LogUtil.w(TAG, "Message deleted prior to update");
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
} else {
// Overwrite existing notification message
message.updateMessageId(messageId);
// Write message
BugleDatabaseOperations.updateMessageInTransaction(db, message);
}
if (!TextUtils.equals(notificationConversationId, conversationId)) {
// If this is a group conversation, the message is moved. So the original
// 1v1 conversation (as referenced by notificationConversationId) could
// be left with no non-draft message. Delete the conversation if that
// happens. See the comment for the method below for why we need to do this.
if (!BugleDatabaseOperations.deleteConversationIfEmptyInTransaction(
db, notificationConversationId)) {
BugleDatabaseOperations.maybeRefreshConversationMetadataInTransaction(
db, notificationConversationId, messageId,
true /*shouldAutoSwitchSelfId*/, blockedSender /*keepArchived*/);
}
}
BugleDatabaseOperations.refreshConversationMetadataInTransaction(db, conversationId,
true /*shouldAutoSwitchSelfId*/, blockedSender /*keepArchived*/);
} else {
messageInFocusedConversation =
DataModel.get().isFocusedConversation(notificationConversationId);
// Default to retry status unless status indicates otherwise
int bugleStatus = statusIfFailed;
if (status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) {
bugleStatus = MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED;
} else if (status == MmsUtils.MMS_REQUEST_NO_RETRY) {
bugleStatus = MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE;
}
DownloadMmsAction.updateMessageStatus(mmsNotificationUri, messageId,
notificationConversationId, bugleStatus, rawStatus);
// Log MMS download failed
final int resultCode = actionParameters.getInt(KEY_RESULT_CODE);
final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE);
// Just in case this was the latest message update the summary data
BugleDatabaseOperations.refreshConversationMetadataInTransaction(db,
notificationConversationId, true /*shouldAutoSwitchSelfId*/,
false /*keepArchived*/);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
if (mmsUri != null) {
// Update mms table with read status now we know the conversation id
final ContentValues values = new ContentValues(1);
values.put(Mms.READ, messageInFocusedConversation);
SqliteWrapper.update(context, context.getContentResolver(), mmsUri, values,
null, null);
}
// Show a notification to let the user know a new message has arrived
BugleNotifications.update(false /*silent*/, conversationId, BugleNotifications.UPDATE_ALL);
// Messages may have changed in two conversations
if (conversationId != null) {
MessagingContentProvider.notifyMessagesChanged(conversationId);
}
MessagingContentProvider.notifyMessagesChanged(notificationConversationId);
MessagingContentProvider.notifyPartsChanged();
return message;
}
private ProcessDownloadedMmsAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ProcessDownloadedMmsAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public ProcessDownloadedMmsAction createFromParcel(final Parcel in) {
return new ProcessDownloadedMmsAction(in);
}
@Override
public ProcessDownloadedMmsAction[] newArray(final int size) {
return new ProcessDownloadedMmsAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,465 @@
/*
* 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.os.Parcel;
import android.os.Parcelable;
import android.telephony.ServiceState;
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.DataModelImpl;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.util.BugleGservicesKeys;
import com.android.messaging.util.BuglePrefs;
import com.android.messaging.util.BuglePrefsKeys;
import com.android.messaging.util.ConnectivityUtil;
import com.android.messaging.util.ConnectivityUtil.ConnectivityListener;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.PhoneUtils;
/**
* Action used to lookup any messages in the pending send/download state and either fail them or
* retry their action based on subscriptions. This action only initiates one retry at a time for
* both sending/downloading. Further retries should be triggered by successful sending/downloading
* of a message, network status change or exponential backoff timer.
*/
public class ProcessPendingMessagesAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
// PENDING_INTENT_BASE_REQUEST_CODE + subId(-1 for pre-L_MR1) is used per subscription uniquely.
private static final int PENDING_INTENT_BASE_REQUEST_CODE = 103;
private static final String KEY_SUB_ID = "sub_id";
public static void processFirstPendingMessage() {
PhoneUtils.forEachActiveSubscription(subId -> {
// Clear any pending alarms or connectivity events
unregister(subId);
// Clear retry count
setRetry(0, subId);
// Start action
final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
action.actionParameters.putInt(KEY_SUB_ID, subId);
action.start();
});
}
public static void scheduleProcessPendingMessagesAction(final boolean failed,
final Action processingAction) {
final int subId = processingAction.actionParameters
.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
LogUtil.i(TAG, "ProcessPendingMessagesAction: Scheduling pending messages"
+ (failed ? "(message failed)" : "") + " for subId " + subId);
// Can safely clear any pending alarms or connectivity events as either an action
// is currently running or we will run now or register if pending actions possible.
unregister(subId);
final boolean isDefaultSmsApp = PhoneUtils.getDefault().isDefaultSmsApp();
boolean scheduleAlarm = false;
// If message succeeded and if Bugle is default SMS app just carry on with next message
if (!failed && isDefaultSmsApp) {
// Clear retry attempt count as something just succeeded
setRetry(0, subId);
// Lookup and queue next message for each sending/downloading for immediate processing
// by background worker. If there are no pending messages, this will do nothing and
// return true.
final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
if (action.queueActions(processingAction)) {
if (processingAction.hasBackgroundActions()) {
LogUtil.v(TAG, "ProcessPendingMessagesAction: Action queued");
} else {
LogUtil.v(TAG, "ProcessPendingMessagesAction: No actions to queue");
}
// Have queued next action if needed, nothing more to do
return;
}
// In case of error queuing schedule a retry
scheduleAlarm = true;
LogUtil.w(TAG, "ProcessPendingMessagesAction: Action failed to queue; retrying");
}
if (getHavePendingMessages(subId) || scheduleAlarm) {
// Still have a pending message that needs to be queued for processing
final ConnectivityListener listener = serviceState -> {
if (serviceState == ServiceState.STATE_IN_SERVICE) {
LogUtil.i(TAG, "ProcessPendingMessagesAction: Now connected for subId "
+ subId + ", starting action");
// Clear any pending alarms or connectivity events but leave attempt count
// alone
unregister(subId);
// Start action
final ProcessPendingMessagesAction action =
new ProcessPendingMessagesAction();
action.actionParameters.putInt(KEY_SUB_ID, subId);
action.start();
}
};
// Read and increment attempt number from shared prefs
final int retryAttempt = getNextRetry(subId);
register(listener, retryAttempt, subId);
} else {
// No more pending messages (presumably the message that failed has expired) or it
// may be possible that a send and a download are already in process.
// Clear retry attempt count.
// TODO Might be premature if send and download in process...
// but worst case means we try to send a bit more often.
setRetry(0, subId);
LogUtil.i(TAG, "ProcessPendingMessagesAction: No more pending messages");
}
}
private static void register(final ConnectivityListener listener, final int retryAttempt,
int subId) {
int retryNumber = retryAttempt;
// Register to be notified about connectivity changes
ConnectivityUtil connectivityUtil = DataModelImpl.getConnectivityUtil(subId);
if (connectivityUtil != null) {
connectivityUtil.register(listener);
}
final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
action.actionParameters.putInt(KEY_SUB_ID, subId);
final long initialBackoffMs = BugleGservicesKeys.INITIAL_MESSAGE_RESEND_DELAY_MS_DEFAULT;
final long maxDelayMs = BugleGservicesKeys.MAX_MESSAGE_RESEND_DELAY_MS_DEFAULT;
long delayMs;
long nextDelayMs = initialBackoffMs;
do {
delayMs = nextDelayMs;
retryNumber--;
nextDelayMs = delayMs * 2;
}
while (retryNumber > 0 && nextDelayMs < maxDelayMs);
LogUtil.i(TAG, "ProcessPendingMessagesAction: Registering for retry #" + retryAttempt
+ " in " + delayMs + " ms for subId " + subId);
action.schedule(PENDING_INTENT_BASE_REQUEST_CODE + subId, delayMs);
}
private static void unregister(final int subId) {
// Clear any pending alarms or connectivity events
ConnectivityUtil connectivityUtil = DataModelImpl.getConnectivityUtil(subId);
if (connectivityUtil != null) {
connectivityUtil.unregister();
}
final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
action.schedule(PENDING_INTENT_BASE_REQUEST_CODE + subId, Long.MAX_VALUE);
LogUtil.v(TAG, "ProcessPendingMessagesAction: Unregistering for connectivity changed "
+ "events and clearing scheduled alarm for subId " + subId);
}
private static void setRetry(final int retryAttempt, int subId) {
final BuglePrefs prefs = Factory.get().getSubscriptionPrefs(subId);
prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt);
}
private static int getNextRetry(int subId) {
final BuglePrefs prefs = Factory.get().getSubscriptionPrefs(subId);
final int retryAttempt =
prefs.getInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, 0) + 1;
prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt);
return retryAttempt;
}
private ProcessPendingMessagesAction() {
}
/**
* Read from the DB and determine if there are any messages we should process
*
* @param subId the subId
* @return true if we have pending messages
*/
private static boolean getHavePendingMessages(final int subId) {
final DatabaseWrapper db = DataModel.get().getDatabase();
final long now = System.currentTimeMillis();
final String selfId = ParticipantData.getParticipantId(db, subId);
if (selfId == null) {
// This could be happened before refreshing participant.
LogUtil.w(TAG, "ProcessPendingMessagesAction: selfId is null for subId " + subId);
return false;
}
final String toSendMessageId = findNextMessageToSend(db, now, selfId);
if (toSendMessageId != null) {
return true;
} else {
final String toDownloadMessageId = findNextMessageToDownload(db, now, selfId);
if (toDownloadMessageId != null) {
return true;
}
}
// Messages may be in the process of sending/downloading even when there are no pending
// messages...
return false;
}
/**
* Queue any pending actions
*
* @return true if action queued (or no actions to queue) else false
*/
private boolean queueActions(final Action processingAction) {
final DatabaseWrapper db = DataModel.get().getDatabase();
final long now = System.currentTimeMillis();
boolean succeeded = true;
final int subId = processingAction.actionParameters
.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
LogUtil.i(TAG, "ProcessPendingMessagesAction: Start queueing for subId " + subId);
final String selfId = ParticipantData.getParticipantId(db, subId);
if (selfId == null) {
// This could be happened before refreshing participant.
LogUtil.w(TAG, "ProcessPendingMessagesAction: selfId is null");
return false;
}
// Will queue no more than one message to send plus one message to download
// This keeps outgoing messages "in order" but allow downloads to happen even if sending
// gets blocked until messages time out. Manual resend bumps messages to head of queue.
final String toSendMessageId = findNextMessageToSend(db, now, selfId);
final String toDownloadMessageId = findNextMessageToDownload(db, now, selfId);
if (toSendMessageId != null) {
LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message " + toSendMessageId
+ " for sending");
// This could queue nothing
if (!SendMessageAction.queueForSendInBackground(toSendMessageId, processingAction)) {
LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message "
+ toSendMessageId + " for sending");
succeeded = false;
}
}
if (toDownloadMessageId != null) {
LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message " + toDownloadMessageId
+ " for download");
// This could queue nothing
if (!DownloadMmsAction.queueMmsForDownloadInBackground(toDownloadMessageId,
processingAction)) {
LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message "
+ toDownloadMessageId + " for download");
succeeded = false;
}
}
if (toSendMessageId == null && toDownloadMessageId == null) {
LogUtil.i(TAG, "ProcessPendingMessagesAction: No messages to send or download");
}
return succeeded;
}
@Override
protected Object executeAction() {
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
// If triggered by alarm will not have unregistered yet
unregister(subId);
if (PhoneUtils.getDefault().isDefaultSmsApp()) {
if (!queueActions(this)) {
LogUtil.v(TAG, "ProcessPendingMessagesAction: rescheduling");
// TODO: Need to clear retry count here?
scheduleProcessPendingMessagesAction(true /* failed */, this);
}
} else {
LogUtil.v(TAG, "ProcessPendingMessagesAction: Not default SMS app; rescheduling");
scheduleProcessPendingMessagesAction(true /* failed */, this);
}
return null;
}
private static String findNextMessageToSend(final DatabaseWrapper db, final long now,
final String selfId) {
String toSendMessageId = null;
Cursor cursor = null;
int sendingCnt;
int pendingCnt;
int failedCnt = 0;
db.beginTransaction();
try {
// First check to see if we have any messages already sending
sendingCnt = (int) db.queryNumEntries(DatabaseHelper.MESSAGES_TABLE,
DatabaseHelper.MessageColumns.STATUS + " IN (?, ?) AND "
+ DatabaseHelper.MessageColumns.SELF_PARTICIPANT_ID + " =? ",
new String[] {
Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_SENDING),
Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_RESENDING),
selfId}
);
// Look for messages we could send
cursor = db.query(DatabaseHelper.MESSAGES_TABLE,
MessageData.getProjection(),
DatabaseHelper.MessageColumns.STATUS + " IN (?, ?) AND "
+ DatabaseHelper.MessageColumns.SELF_PARTICIPANT_ID + " =? ",
new String[] {
Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND),
Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY),
selfId
},
null,
null,
DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");
pendingCnt = cursor.getCount();
final ContentValues values = new ContentValues();
values.put(DatabaseHelper.MessageColumns.STATUS,
MessageData.BUGLE_STATUS_OUTGOING_FAILED);
// Prior to L_MR1, isActiveSubscription is true always
boolean isActiveSubscription = true;
final ParticipantData messageSelf =
BugleDatabaseOperations.getExistingParticipant(db, selfId);
if (messageSelf == null || !messageSelf.isActiveSubscription()) {
isActiveSubscription = false;
}
while (cursor.moveToNext()) {
final MessageData message = new MessageData();
message.bind(cursor);
// Mark this message as failed if the message's self is inactive or the message is
// outside of resend window
if (!isActiveSubscription || !message.getInResendWindow(now)) {
failedCnt++;
// Mark message as failed
BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values);
MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
} else {
// If no messages currently sending
if (sendingCnt == 0) {
// Send this message
toSendMessageId = message.getMessageId();
}
break;
}
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
if (cursor != null) {
cursor.close();
}
}
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "ProcessPendingMessagesAction: "
+ sendingCnt + " messages already sending, "
+ pendingCnt + " messages to send, "
+ failedCnt + " failed messages");
}
return toSendMessageId;
}
private static String findNextMessageToDownload(final DatabaseWrapper db, final long now,
final String selfId) {
String toDownloadMessageId = null;
Cursor cursor = null;
int downloadingCnt;
int pendingCnt;
db.beginTransaction();
try {
// First check if we have any messages already downloading
downloadingCnt = (int) db.queryNumEntries(DatabaseHelper.MESSAGES_TABLE,
DatabaseHelper.MessageColumns.STATUS + " IN (?, ?) AND "
+ DatabaseHelper.MessageColumns.SELF_PARTICIPANT_ID + " =?",
new String[] {
Integer.toString(MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING),
Integer.toString(MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING),
selfId
});
// TODO: This query is not actually needed if downloadingCnt == 0.
cursor = db.query(DatabaseHelper.MESSAGES_TABLE,
MessageData.getProjection(),
DatabaseHelper.MessageColumns.STATUS + " IN (?, ?) AND "
+ DatabaseHelper.MessageColumns.SELF_PARTICIPANT_ID + " =? ",
new String[]{
Integer.toString(MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD),
Integer.toString(
MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD),
selfId
},
null,
null,
DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");
pendingCnt = cursor.getCount();
// If no messages are currently downloading and there is a download pending,
// queue the download of the oldest pending message.
if (downloadingCnt == 0 && cursor.moveToNext()) {
// Always start the next pending message. We will check if a download has
// expired in DownloadMmsAction and mark message failed there.
final MessageData message = new MessageData();
message.bind(cursor);
toDownloadMessageId = message.getMessageId();
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
if (cursor != null) {
cursor.close();
}
}
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "ProcessPendingMessagesAction: "
+ downloadingCnt + " messages already downloading, "
+ pendingCnt + " messages to download");
}
return toDownloadMessageId;
}
private ProcessPendingMessagesAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ProcessPendingMessagesAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public ProcessPendingMessagesAction createFromParcel(final Parcel in) {
return new ProcessPendingMessagesAction(in);
}
@Override
public ProcessPendingMessagesAction[] newArray(final int size) {
return new ProcessPendingMessagesAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,312 @@
/*
* 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.app.Activity;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.v7.mms.pdu.SendConf;
import android.telephony.PhoneNumberUtils;
import android.telephony.SmsManager;
import androidx.annotation.NonNull;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MmsFileProvider;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.MessagePartData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsConfig;
import com.android.messaging.sms.MmsSender;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import java.io.File;
import java.util.ArrayList;
/**
* Update message status to reflect success or failure
* Can also update the message itself if a "final" message is now available from telephony db
*/
public class ProcessSentMessageAction extends Action {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
// These are always set
private static final String KEY_SMS = "is_sms";
private static final String KEY_SENT_BY_PLATFORM = "sent_by_platform";
// These are set when we're processing a message sent by the user. They are null for messages
// sent automatically (e.g. a NotifyRespInd/AcknowledgeInd sent in response to a download).
private static final String KEY_MESSAGE_ID = "message_id";
private static final String KEY_MESSAGE_URI = "message_uri";
private static final String KEY_UPDATED_MESSAGE_URI = "updated_message_uri";
private static final String KEY_SUB_ID = "sub_id";
// These are set for messages sent by the platform (L+)
public static final String KEY_RESULT_CODE = "result_code";
public static final String KEY_HTTP_STATUS_CODE = "http_status_code";
private static final String KEY_CONTENT_URI = "content_uri";
private static final String KEY_RESPONSE = "response";
private static final String KEY_RESPONSE_IMPORTANT = "response_important";
// These are set for messages we sent ourself (legacy), or which we fast-failed before sending.
private static final String KEY_STATUS = "status";
private static final String KEY_RAW_STATUS = "raw_status";
// This is called when MMS lib API returns via PendingIntent
public static void processMmsSent(final int resultCode, final Uri messageUri,
final Bundle extras) {
final ProcessSentMessageAction action = new ProcessSentMessageAction();
final Bundle params = action.actionParameters;
params.putBoolean(KEY_SMS, false);
params.putBoolean(KEY_SENT_BY_PLATFORM, true);
params.putString(KEY_MESSAGE_ID, extras.getString(SendMessageAction.EXTRA_MESSAGE_ID));
params.putParcelable(KEY_MESSAGE_URI, messageUri);
params.putParcelable(KEY_UPDATED_MESSAGE_URI,
extras.getParcelable(SendMessageAction.EXTRA_UPDATED_MESSAGE_URI, Uri.class));
params.putInt(KEY_SUB_ID,
extras.getInt(SendMessageAction.KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID));
params.putInt(KEY_RESULT_CODE, resultCode);
params.putInt(KEY_HTTP_STATUS_CODE, extras.getInt(SmsManager.EXTRA_MMS_HTTP_STATUS, 0));
params.putParcelable(KEY_CONTENT_URI,
extras.getParcelable(SendMessageAction.EXTRA_CONTENT_URI, Uri.class));
params.putByteArray(KEY_RESPONSE, extras.getByteArray(SmsManager.EXTRA_MMS_DATA));
params.putBoolean(KEY_RESPONSE_IMPORTANT,
extras.getBoolean(SendMessageAction.EXTRA_RESPONSE_IMPORTANT));
action.start();
}
public static void processMessageSentFastFailed(final String messageId,
final Uri messageUri, final Uri updatedMessageUri, final int subId, final boolean isSms,
final int status, final int rawStatus, final int resultCode) {
final ProcessSentMessageAction action = new ProcessSentMessageAction();
final Bundle params = action.actionParameters;
params.putBoolean(KEY_SMS, isSms);
params.putBoolean(KEY_SENT_BY_PLATFORM, false);
params.putString(KEY_MESSAGE_ID, messageId);
params.putParcelable(KEY_MESSAGE_URI, messageUri);
params.putParcelable(KEY_UPDATED_MESSAGE_URI, updatedMessageUri);
params.putInt(KEY_SUB_ID, subId);
params.putInt(KEY_STATUS, status);
params.putInt(KEY_RAW_STATUS, rawStatus);
params.putInt(KEY_RESULT_CODE, resultCode);
action.start();
}
private ProcessSentMessageAction() {
// Callers must use one of the static methods above
}
/**
* Update message status to reflect success or failure
* Can also update the message itself if a "final" message is now available from telephony db
*/
@Override
protected Object executeAction() {
final Context context = Factory.get().getApplicationContext();
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final Uri messageUri = actionParameters.getParcelable(KEY_MESSAGE_URI, Uri.class);
final Uri updatedMessageUri = actionParameters.getParcelable(KEY_UPDATED_MESSAGE_URI,
Uri.class);
final boolean isSms = actionParameters.getBoolean(KEY_SMS);
final boolean sentByPlatform = actionParameters.getBoolean(KEY_SENT_BY_PLATFORM);
int status = actionParameters.getInt(KEY_STATUS, MmsUtils.MMS_REQUEST_MANUAL_RETRY);
int rawStatus = actionParameters.getInt(KEY_RAW_STATUS,
MmsUtils.PDU_HEADER_VALUE_UNDEFINED);
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
if (sentByPlatform) {
// Delete temporary file backing the contentUri passed to MMS service
final Uri contentUri = actionParameters.getParcelable(KEY_CONTENT_URI, Uri.class);
Assert.isTrue(contentUri != null);
final File tempFile = MmsFileProvider.getFile(contentUri);
long messageSize = 0;
if (tempFile.exists()) {
messageSize = tempFile.length();
tempFile.delete();
LogUtil.v(TAG, "ProcessSentMessageAction: Deleted temp file with outgoing "
+ "MMS pdu: " + contentUri);
}
final int resultCode = actionParameters.getInt(KEY_RESULT_CODE);
final boolean responseImportant = actionParameters.getBoolean(KEY_RESPONSE_IMPORTANT);
if (resultCode == Activity.RESULT_OK) {
if (responseImportant) {
// Get the status from the response PDU and update telephony
final byte[] response = actionParameters.getByteArray(KEY_RESPONSE);
final SendConf sendConf = MmsSender.parseSendConf(response, subId);
if (sendConf != null) {
final MmsUtils.StatusPlusUri result =
MmsUtils.updateSentMmsMessageStatus(context, messageUri, sendConf);
status = result.status;
rawStatus = result.rawStatus;
}
}
} else {
String errorMsg = "ProcessSentMessageAction: Platform returned error resultCode: "
+ resultCode;
final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE);
if (httpStatusCode != 0) {
errorMsg += (", HTTP status code: " + httpStatusCode);
}
LogUtil.w(TAG, errorMsg);
status = MmsSender.getErrorResultStatus(resultCode, httpStatusCode);
// Check for MMS messages that failed because they exceeded the maximum size,
// indicated by an I/O error from the platform.
if (resultCode == SmsManager.MMS_ERROR_IO_ERROR) {
if (messageSize > MmsConfig.get(subId).getMaxMessageSize()) {
rawStatus = MessageData.RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG;
}
}
}
}
if (messageId != null) {
final int resultCode = actionParameters.getInt(KEY_RESULT_CODE);
final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE);
processResult(
messageId, updatedMessageUri, status, rawStatus, isSms, this, subId,
resultCode, httpStatusCode);
} else {
LogUtil.v(TAG, "ProcessSentMessageAction: No sent message to process (it was "
+ "probably a notify response for an MMS download)");
}
return null;
}
static void processResult(final String messageId, Uri updatedMessageUri, int status,
final int rawStatus, final boolean isSms, final Action processingAction,
final int subId, final int resultCode, final int httpStatusCode) {
final DatabaseWrapper db = DataModel.get().getDatabase();
MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
final MessageData originalMessage = message;
if (message == null) {
LogUtil.w(TAG, "ProcessSentMessageAction: Sent message " + messageId
+ " missing from local database");
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(
true /* failed */, processingAction);
return;
}
final String conversationId = message.getConversationId();
if (updatedMessageUri != null) {
// Update message if we have newly written final message in the telephony db
final MessageData update = MmsUtils.readSendingMmsMessage(updatedMessageUri,
conversationId, message.getParticipantId(), message.getSelfId());
if (update != null) {
// Set message Id of final message to that of the existing place holder.
update.updateMessageId(message.getMessageId());
// Update image sizes.
update.updateSizesForImageParts();
// Temp attachments are no longer needed
for (final MessagePartData part : message.getParts()) {
part.destroySync();
}
message = update;
// processResult will rewrite the complete message as part of update
} else {
updatedMessageUri = null;
status = MmsUtils.MMS_REQUEST_MANUAL_RETRY;
LogUtil.e(TAG, "ProcessSentMessageAction: Unable to read sending message");
}
}
final long timestamp = System.currentTimeMillis();
boolean failed;
if (status == MmsUtils.MMS_REQUEST_SUCCEEDED) {
message.markMessageSent(timestamp);
failed = false;
} else if (status == MmsUtils.MMS_REQUEST_AUTO_RETRY
&& message.getInResendWindow(timestamp)) {
message.markMessageNotSent(timestamp);
message.setRawTelephonyStatus(rawStatus);
failed = false;
} else {
message.markMessageFailed(timestamp);
message.setRawTelephonyStatus(rawStatus);
message.setMessageSeen(false);
failed = true;
}
// We have special handling for when a message to an emergency number fails. In this case,
// we notify immediately of any failure (even if we auto-retry), and instruct the user to
// try calling the emergency number instead.
if (status != MmsUtils.MMS_REQUEST_SUCCEEDED) {
final ArrayList<String> recipients =
BugleDatabaseOperations.getRecipientsForConversation(db, conversationId);
for (final String recipient : recipients) {
if (PhoneNumberUtils.isEmergencyNumber(recipient)) {
BugleNotifications.notifyEmergencySmsFailed(recipient, conversationId);
message.markMessageFailedEmergencyNumber(timestamp);
failed = true;
break;
}
}
}
// Update the message status and optionally refresh the message with final parts/values.
if (SendMessageAction.updateMessageAndStatus(isSms, message, updatedMessageUri, failed)) {
// We shouldn't show any notifications if we're not allowed to modify Telephony for
// this message.
if (failed) {
BugleNotifications.update(false, BugleNotifications.UPDATE_ERRORS);
}
BugleActionToasts.onSendMessageOrManualDownloadActionCompleted(
conversationId, !failed, status, isSms, subId, true/*isSend*/);
}
LogUtil.i(TAG, "ProcessSentMessageAction: Done sending " + (isSms ? "SMS" : "MMS")
+ " message " + message.getMessageId()
+ " in conversation " + conversationId
+ "; status is " + MmsUtils.getRequestStatusDescription(status));
// Whether we succeeded or failed we will check and maybe schedule some more work
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(
status != MmsUtils.MMS_REQUEST_SUCCEEDED, processingAction);
}
private ProcessSentMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ProcessSentMessageAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public ProcessSentMessageAction createFromParcel(final Parcel in) {
return new ProcessSentMessageAction(in);
}
@Override
public ProcessSentMessageAction[] newArray(final int size) {
return new ProcessSentMessageAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,168 @@
/*
* 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.action;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.RunsOnMainThread;
import com.android.messaging.util.LogUtil;
public class ReadDraftDataAction extends Action implements Parcelable {
/**
* Interface for ReadDraftDataAction listeners
*/
public interface ReadDraftDataActionListener {
@RunsOnMainThread
void onReadDraftDataSucceeded(final ReadDraftDataAction action,
final Object data, final MessageData message,
final ConversationListItemData conversation);
@RunsOnMainThread
void onReadDraftDataFailed(final ReadDraftDataAction action, final Object data);
}
/**
* Read draft message and associated data (with listener)
*/
public static ReadDraftDataActionMonitor readDraftData(final String conversationId,
final MessageData incomingDraft, final Object data,
final ReadDraftDataActionListener listener) {
final ReadDraftDataActionMonitor monitor = new ReadDraftDataActionMonitor(data,
listener);
final ReadDraftDataAction action = new ReadDraftDataAction(conversationId,
incomingDraft, monitor.getActionKey());
action.start(monitor);
return monitor;
}
private static final String KEY_CONVERSATION_ID = "conversationId";
private static final String KEY_INCOMING_DRAFT = "draftMessage";
private ReadDraftDataAction(final String conversationId, final MessageData incomingDraft,
final String actionKey) {
super(actionKey);
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
actionParameters.putParcelable(KEY_INCOMING_DRAFT, incomingDraft);
}
class DraftData {
public final MessageData message;
public final ConversationListItemData conversation;
DraftData(final MessageData message, final ConversationListItemData conversation) {
this.message = message;
this.conversation = conversation;
}
}
@Override
protected Object executeAction() {
final DatabaseWrapper db = DataModel.get().getDatabase();
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final MessageData incomingDraft = actionParameters.getParcelable(KEY_INCOMING_DRAFT,
MessageData.class);
final ConversationListItemData conversation =
ConversationListItemData.getExistingConversation(db, conversationId);
MessageData message = null;
if (conversation != null) {
if (incomingDraft == null) {
message = BugleDatabaseOperations.readDraftMessageData(db, conversationId,
conversation.getSelfId());
}
if (message == null) {
message = MessageData.createDraftMessage(conversationId, conversation.getSelfId(),
incomingDraft);
LogUtil.d(LogUtil.BUGLE_TAG, "ReadDraftMessage: created draft. "
+ "conversationId=" + conversationId
+ " selfId=" + conversation.getSelfId());
} else {
LogUtil.d(LogUtil.BUGLE_TAG, "ReadDraftMessage: read draft. "
+ "conversationId=" + conversationId
+ " selfId=" + conversation.getSelfId());
}
return new DraftData(message, conversation);
}
return null;
}
/**
* An operation that notifies a listener upon completion
*/
public static class ReadDraftDataActionMonitor extends ActionMonitor
implements ActionCompletedListener {
private final ReadDraftDataActionListener mListener;
ReadDraftDataActionMonitor(final Object data,
final ReadDraftDataActionListener completed) {
super(STATE_CREATED, generateUniqueActionKey("ReadDraftDataAction"), data);
setCompletedListener(this);
mListener = completed;
}
@Override
public void onActionSucceeded(final ActionMonitor monitor,
final Action action, final Object data, final Object result) {
final DraftData draft = (DraftData) result;
if (draft == null) {
mListener.onReadDraftDataFailed((ReadDraftDataAction) action, data);
} else {
mListener.onReadDraftDataSucceeded((ReadDraftDataAction) action, data,
draft.message, draft.conversation);
}
}
@Override
public void onActionFailed(final ActionMonitor monitor,
final Action action, final Object data, final Object result) {
Assert.fail("Reading draft should not fail");
}
}
private ReadDraftDataAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ReadDraftDataAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public ReadDraftDataAction createFromParcel(final Parcel in) {
return new ReadDraftDataAction(in);
}
@Override
public ReadDraftDataAction[] newArray(final int size) {
return new ReadDraftDataAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,199 @@
/*
* 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.action;
import android.content.Context;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.v7.mms.pdu.PduHeaders;
import androidx.annotation.NonNull;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.DatabaseMessages;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.LogUtil;
import java.util.List;
/**
* Action used to "receive" an incoming message
*/
public class ReceiveMmsMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String KEY_SUB_ID = "sub_id";
private static final String KEY_PUSH_DATA = "push_data";
private static final String KEY_TRANSACTION_ID = "transaction_id";
private static final String KEY_CONTENT_LOCATION = "content_location";
/**
* Create a message received from a particular number in a particular conversation
*/
public ReceiveMmsMessageAction(final int subId, final byte[] pushData) {
actionParameters.putInt(KEY_SUB_ID, subId);
actionParameters.putByteArray(KEY_PUSH_DATA, pushData);
}
@Override
protected Object executeAction() {
final Context context = Factory.get().getApplicationContext();
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
final byte[] pushData = actionParameters.getByteArray(KEY_PUSH_DATA);
final DatabaseWrapper db = DataModel.get().getDatabase();
// Write received message to telephony DB
MessageData message = null;
final ParticipantData self = BugleDatabaseOperations.getOrCreateSelf(db, subId);
final long received = System.currentTimeMillis();
// Inform sync that message has been added at local received timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(received);
// TODO: Should use local time to set received time in MMS message
final DatabaseMessages.MmsMessage mms = MmsUtils.processReceivedPdu(
context, pushData, self.getSubId(), self.getNormalizedDestination());
if (mms != null) {
final List<String> recipients = MmsUtils.getRecipientsByThread(mms.mThreadId);
String from = MmsUtils.getMmsSender(recipients, mms.getUri());
if (from == null) {
LogUtil.w(TAG, "Received an MMS without sender address; using unknown sender.");
from = ParticipantData.getUnknownSenderDestination();
}
final ParticipantData rawSender = ParticipantData.getFromRawPhoneBySimLocale(
from, subId);
final boolean blocked = BugleDatabaseOperations.isBlockedDestination(
db, rawSender.getNormalizedDestination());
final boolean autoDownload = (!blocked && MmsUtils.allowMmsAutoRetrieve(subId));
final String conversationId =
BugleDatabaseOperations.getOrCreateConversationFromThreadId(db, mms.mThreadId,
blocked, subId);
final boolean messageInFocusedConversation =
DataModel.get().isFocusedConversation(conversationId);
final boolean messageInObservableConversation =
DataModel.get().isNewMessageObservable(conversationId);
// TODO: Also write these values to the telephony provider
mms.mRead = messageInFocusedConversation;
mms.mSeen = messageInObservableConversation || blocked;
// Write received placeholder message to our DB
db.beginTransaction();
try {
final String participantId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, rawSender);
final String selfId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
message = MmsUtils.createMmsMessage(mms, conversationId, participantId, selfId,
(autoDownload ? MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD :
MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD));
// Write the message
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
if (!autoDownload) {
BugleDatabaseOperations.updateConversationMetadataInTransaction(db,
conversationId, message.getMessageId(), message.getReceivedTimeStamp(),
blocked, true /* shouldAutoSwitchSelfId */);
final ParticipantData sender = ParticipantData .getFromId(
db, participantId);
BugleActionToasts.onMessageReceived(conversationId, sender, message);
}
// else update the conversation once we have downloaded final message (or failed)
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
// Update conversation if not immediately initiating a download
if (!autoDownload) {
MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
MessagingContentProvider.notifyPartsChanged();
// Show a notification to let the user know a new message has arrived
BugleNotifications.update(false/*silent*/, conversationId,
BugleNotifications.UPDATE_ALL);
// Send the NotifyRespInd with DEFERRED status since no auto download
actionParameters.putString(KEY_TRANSACTION_ID, mms.mTransactionId);
actionParameters.putString(KEY_CONTENT_LOCATION, mms.mContentLocation);
requestBackgroundWork();
}
LogUtil.i(TAG, "ReceiveMmsMessageAction: Received MMS message " + message.getMessageId()
+ " in conversation " + message.getConversationId()
+ ", uri = " + message.getSmsMessageUri());
} else {
LogUtil.e(TAG, "ReceiveMmsMessageAction: Skipping processing of incoming PDU");
}
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
return message;
}
@Override
protected Bundle doBackgroundWork() {
final Context context = Factory.get().getApplicationContext();
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION);
MmsUtils.sendNotifyResponseForMmsDownload(
context,
subId,
MmsUtils.stringToBytes(transactionId, "UTF-8"),
contentLocation,
PduHeaders.STATUS_DEFERRED);
// We don't need to return anything.
return null;
}
private ReceiveMmsMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ReceiveMmsMessageAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public ReceiveMmsMessageAction createFromParcel(final Parcel in) {
return new ReceiveMmsMessageAction(in);
}
@Override
public ReceiveMmsMessageAction[] newArray(final int size) {
return new ReceiveMmsMessageAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,204 @@
/*
* 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.action;
import android.content.ContentValues;
import android.content.Context;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.Telephony.Sms;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsSmsUtils;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.OsUtil;
/**
* Action used to "receive" an incoming message
*/
public class ReceiveSmsMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String KEY_MESSAGE_VALUES = "message_values";
private static final String KEY_SUB_ID = "sub_id";
/**
* Create a message received from a particular number in a particular conversation
*/
public ReceiveSmsMessageAction(final ContentValues messageValues) {
actionParameters.putParcelable(KEY_MESSAGE_VALUES, messageValues);
}
@Override
protected Object executeAction() {
final Context context = Factory.get().getApplicationContext();
final ContentValues messageValues = actionParameters.getParcelable(KEY_MESSAGE_VALUES,
ContentValues.class);
final DatabaseWrapper db = DataModel.get().getDatabase();
// Get the SIM subscription ID
Integer subId = messageValues.getAsInteger(Sms.SUBSCRIPTION_ID);
if (subId == null) {
subId = ParticipantData.DEFAULT_SELF_SUB_ID;
}
// Make sure we have a sender address
String address = messageValues.getAsString(Sms.ADDRESS);
if (TextUtils.isEmpty(address)) {
LogUtil.w(TAG, "Received an SMS without an address; using unknown sender.");
address = ParticipantData.getUnknownSenderDestination();
messageValues.put(Sms.ADDRESS, address);
}
final ParticipantData rawSender = ParticipantData.getFromRawPhoneBySimLocale(
address, subId);
// TODO: Should use local timestamp for this?
final long received = messageValues.getAsLong(Sms.DATE);
// Inform sync that message has been added at local received timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(received);
// Make sure we've got a thread id
final long threadId = MmsSmsUtils.Threads.getOrCreateThreadId(context, address);
messageValues.put(Sms.THREAD_ID, threadId);
final boolean blocked = BugleDatabaseOperations.isBlockedDestination(
db, rawSender.getNormalizedDestination());
final String conversationId = BugleDatabaseOperations.
getOrCreateConversationFromRecipient(db, threadId, blocked, rawSender);
final boolean messageInFocusedConversation =
DataModel.get().isFocusedConversation(conversationId);
final boolean messageInObservableConversation =
DataModel.get().isNewMessageObservable(conversationId);
MessageData message = null;
// Only the primary user gets to insert the message into the telephony db and into bugle's
// db. The secondary user goes through this path, but skips doing the actual insert. It
// goes through this path because it needs to compute messageInFocusedConversation in order
// to calculate whether to skip the notification and play a soft sound if the user is
// already in the conversation.
if (!OsUtil.isSecondaryUser()) {
final boolean read = messageValues.getAsBoolean(Sms.Inbox.READ)
|| messageInFocusedConversation;
// If you have read it you have seen it
final boolean seen = read || messageInObservableConversation || blocked;
messageValues.put(Sms.Inbox.READ, read ? Integer.valueOf(1) : Integer.valueOf(0));
// incoming messages are marked as seen in the telephony db
messageValues.put(Sms.Inbox.SEEN, 1);
// Insert into telephony
final Uri messageUri = context.getContentResolver().insert(Sms.Inbox.CONTENT_URI,
messageValues);
if (messageUri != null) {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "ReceiveSmsMessageAction: Inserted SMS message into telephony, "
+ "uri = " + messageUri);
}
} else {
LogUtil.e(TAG, "ReceiveSmsMessageAction: Failed to insert SMS into telephony!");
}
final String text = messageValues.getAsString(Sms.BODY);
final String subject = messageValues.getAsString(Sms.SUBJECT);
final long sent = messageValues.getAsLong(Sms.DATE_SENT);
final ParticipantData self = ParticipantData.getSelfParticipant(subId);
final Integer pathPresent = messageValues.getAsInteger(Sms.REPLY_PATH_PRESENT);
final String smsServiceCenter = messageValues.getAsString(Sms.SERVICE_CENTER);
String conversationServiceCenter = null;
// Only set service center if message REPLY_PATH_PRESENT = 1
if (pathPresent != null && pathPresent == 1 && !TextUtils.isEmpty(smsServiceCenter)) {
conversationServiceCenter = smsServiceCenter;
}
db.beginTransaction();
try {
final String participantId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, rawSender);
final String selfId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
message = MessageData.createReceivedSmsMessage(messageUri, conversationId,
participantId, selfId, text, subject, sent, received, seen, read);
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
BugleDatabaseOperations.updateConversationMetadataInTransaction(db, conversationId,
message.getMessageId(), message.getReceivedTimeStamp(), blocked,
conversationServiceCenter, true /* shouldAutoSwitchSelfId */);
final ParticipantData sender = ParticipantData.getFromId(db, participantId);
BugleActionToasts.onMessageReceived(conversationId, sender, message);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
LogUtil.i(TAG, "ReceiveSmsMessageAction: Received SMS message " + message.getMessageId()
+ " in conversation " + message.getConversationId()
+ ", uri = " + messageUri);
actionParameters.putInt(KEY_SUB_ID, subId);
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
} else {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "ReceiveSmsMessageAction: Not inserting received SMS message for "
+ "secondary user.");
}
}
// Show a notification to let the user know a new message has arrived
BugleNotifications.update(false/*silent*/, conversationId, BugleNotifications.UPDATE_ALL);
MessagingContentProvider.notifyMessagesChanged(conversationId);
MessagingContentProvider.notifyPartsChanged();
return message;
}
private ReceiveSmsMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ReceiveSmsMessageAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public ReceiveSmsMessageAction createFromParcel(final Parcel in) {
return new ReceiveSmsMessageAction(in);
}
@Override
public ReceiveSmsMessageAction[] newArray(final int size) {
return new ReceiveSmsMessageAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,134 @@
/*
* 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.action;
import android.app.PendingIntent;
import android.content.ContentValues;
import android.content.Context;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.LogUtil;
/**
* Action to manually start an MMS download (after failed or manual mms download)
*/
public class RedownloadMmsAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final int REQUEST_CODE_PENDING_INTENT = 101;
private static final String KEY_SUB_ID = "sub_id";
/**
* Download an MMS message
*/
public static void redownloadMessage(final String messageId) {
final RedownloadMmsAction action = new RedownloadMmsAction(messageId);
action.start();
}
/**
* Get a pending intent of for downloading an MMS
*/
public static PendingIntent getPendingIntentForRedownloadMms(
final Context context, final String messageId) {
final Action action = new RedownloadMmsAction(messageId);
return ActionService.makeStartActionPendingIntent(context,
action, REQUEST_CODE_PENDING_INTENT, false /*launchesAnActivity*/);
}
// Core parameters needed for all types of message
private static final String KEY_MESSAGE_ID = "message_id";
/**
* Constructor used for retrying sending in the background (only message id available)
*/
RedownloadMmsAction(final String messageId) {
super();
actionParameters.putString(KEY_MESSAGE_ID, messageId);
}
/**
* Read message from database and change status to allow downloading
*/
@Override
protected Object executeAction() {
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final DatabaseWrapper db = DataModel.get().getDatabase();
MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
// Check message can be redownloaded
if (message != null && message.canRedownloadMessage()) {
final long timestamp = System.currentTimeMillis();
final ContentValues values = new ContentValues(2);
values.put(DatabaseHelper.MessageColumns.STATUS,
MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD);
values.put(DatabaseHelper.MessageColumns.RETRY_START_TIMESTAMP, timestamp);
// Row must exist as was just loaded above (on ActionService thread)
BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values);
MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
actionParameters.putInt(KEY_SUB_ID,
BugleDatabaseOperations.getSelfSubscriptionId(db, message.getSelfId()));
// Whether we succeeded or failed we will check and maybe schedule some more work
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
} else {
message = null;
LogUtil.e(LogUtil.BUGLE_TAG,
"Attempt to download a missing or un-redownloadable message");
}
// Immediately update the notifications in case we came from the download action from a
// heads-up notification. This will dismiss the heads-up notification.
BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL);
return message;
}
private RedownloadMmsAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<RedownloadMmsAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public RedownloadMmsAction createFromParcel(final Parcel in) {
return new RedownloadMmsAction(in);
}
@Override
public RedownloadMmsAction[] newArray(final int size) {
return new RedownloadMmsAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,134 @@
/*
* 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.action;
import android.content.ContentValues;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.LogUtil;
/**
* Action used to manually resend an outgoing message
*/
public class ResendMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String KEY_SUB_ID = "sub_id";
/**
* Manual send of existing message (no listener)
*/
public static void resendMessage(final String messageId) {
final ResendMessageAction action = new ResendMessageAction(messageId);
action.start();
}
// Core parameters needed for all types of message
private static final String KEY_MESSAGE_ID = "message_id";
/**
* Constructor used for retrying sending in the background (only message id available)
*/
ResendMessageAction(final String messageId) {
super();
actionParameters.putString(KEY_MESSAGE_ID, messageId);
}
/**
* Read message from database and change status to allow sending
*/
@Override
protected Object executeAction() {
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final DatabaseWrapper db = DataModel.get().getDatabase();
final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
// Check message can be resent
if (message != null && message.canResendMessage()) {
final boolean isMms = message.getIsMms();
long timestamp = System.currentTimeMillis();
if (isMms) {
// MMS expects timestamp rounded to nearest second
timestamp = 1000 * ((timestamp + 500) / 1000);
}
LogUtil.i(TAG, "ResendMessageAction: Resending message " + messageId
+ "; changed timestamp from " + message.getReceivedTimeStamp() + " to "
+ timestamp);
final ContentValues values = new ContentValues();
values.put(MessageColumns.STATUS, MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND);
values.put(MessageColumns.RETRY_START_TIMESTAMP, timestamp);
// Row must exist as was just loaded above (on ActionService thread)
BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values);
MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
actionParameters.putInt(KEY_SUB_ID,
BugleDatabaseOperations.getSelfSubscriptionId(db, message.getSelfId()));
// Whether we succeeded or failed we will check and maybe schedule some more work
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
return message;
} else {
String error = "ResendMessageAction: Cannot resend message " + messageId + "; ";
if (message != null) {
error += ("status = " + MessageData.getStatusDescription(message.getStatus()));
} else {
error += "not found in database";
}
LogUtil.e(TAG, error);
}
return null;
}
private ResendMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ResendMessageAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public ResendMessageAction createFromParcel(final Parcel in) {
return new ResendMessageAction(in);
}
@Override
public ResendMessageAction[] newArray(final int size) {
return new ResendMessageAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,448 @@
/*
* 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.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.Telephony.Mms;
import android.provider.Telephony.Sms;
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.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import java.util.ArrayList;
/**
* Action used to send an outgoing message. It writes MMS messages to the telephony db
* ({@link InsertNewMessageAction}) writes SMS messages to the telephony db). It also
* initiates the actual sending. It will all be used for re-sending a failed message.
* <p>
* This class is public (not package-private) because the SMS/MMS (e.g. MmsUtils) classes need to
* access the EXTRA_* fields for setting up the 'sent' pending intent.
*/
public class SendMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
/**
* Queue sending of existing message (can only be called during execute of action)
*/
static boolean queueForSendInBackground(final String messageId,
final Action processingAction) {
final SendMessageAction action = new SendMessageAction();
return action.queueAction(messageId, processingAction);
}
public static final boolean DEFAULT_DELIVERY_REPORT_MODE = false;
public static final int MAX_SMS_RETRY = 3;
// Core parameters needed for all types of message
private static final String KEY_MESSAGE_ID = "message_id";
private static final String KEY_MESSAGE = "message";
private static final String KEY_MESSAGE_URI = "message_uri";
private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number";
// For sms messages a few extra values are included in the bundle
private static final String KEY_RECIPIENT = "recipient";
private static final String KEY_RECIPIENTS = "recipients";
private static final String KEY_SMS_SERVICE_CENTER = "sms_service_center";
// Values we attach to the pending intent that's fired when the message is sent.
// Only applicable when sending via the platform APIs on L+.
public static final String KEY_SUB_ID = "sub_id";
public static final String EXTRA_MESSAGE_ID = "message_id";
public static final String EXTRA_UPDATED_MESSAGE_URI = "updated_message_uri";
public static final String EXTRA_CONTENT_URI = "content_uri";
public static final String EXTRA_RESPONSE_IMPORTANT = "response_important";
/**
* Constructor used for retrying sending in the background (only message id available)
*/
private SendMessageAction() {
super();
}
/**
* Read message from database and queue actual sending
*/
private boolean queueAction(final String messageId, final Action processingAction) {
actionParameters.putString(KEY_MESSAGE_ID, messageId);
final DatabaseWrapper db = DataModel.get().getDatabase();
final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
// Check message can be resent
if (message != null && message.canSendMessage()) {
final boolean isSms = message.getIsSms();
long timestamp = System.currentTimeMillis();
if (!isSms) {
// MMS expects timestamp rounded to nearest second
timestamp = 1000 * ((timestamp + 500) / 1000);
}
final ParticipantData self = BugleDatabaseOperations.getExistingParticipant(
db, message.getSelfId());
final Uri messageUri = message.getSmsMessageUri();
final String conversationId = message.getConversationId();
// Update message status
if (message.getYetToSend()) {
if (message.getReceivedTimeStamp() == message.getRetryStartTimestamp()) {
// Initial sending of message
message.markMessageSending(timestamp);
} else {
// Manual resend of message
message.markMessageManualResend(timestamp);
}
} else {
// Automatic resend of message
message.markMessageResending(timestamp);
}
if (!updateMessageAndStatus(isSms, message, null /* messageUri */, false /*notify*/)) {
// If message is missing in the telephony database we don't need to send it
return false;
}
final ArrayList<String> recipients =
BugleDatabaseOperations.getRecipientsForConversation(db, conversationId);
// Update action state with parameters needed for background sending
actionParameters.putParcelable(KEY_MESSAGE_URI, messageUri);
actionParameters.putParcelable(KEY_MESSAGE, message);
actionParameters.putStringArrayList(KEY_RECIPIENTS, recipients);
actionParameters.putInt(KEY_SUB_ID, self.getSubId());
actionParameters.putString(KEY_SUB_PHONE_NUMBER, self.getNormalizedDestination());
if (isSms) {
final String smsc = BugleDatabaseOperations.getSmsServiceCenterForConversation(
db, conversationId);
actionParameters.putString(KEY_SMS_SERVICE_CENTER, smsc);
if (recipients.size() == 1) {
final String recipient = recipients.get(0);
actionParameters.putString(KEY_RECIPIENT, recipient);
// Queue actual sending for SMS
processingAction.requestBackgroundWork(this);
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SendMessageAction: Queued SMS message " + messageId
+ " for sending");
}
return true;
} else {
LogUtil.wtf(TAG, "Trying to resend a broadcast SMS - not allowed");
}
} else {
// Queue actual sending for MMS
processingAction.requestBackgroundWork(this);
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SendMessageAction: Queued MMS message " + messageId
+ " for sending");
}
return true;
}
}
return false;
}
/**
* Never called
*/
@Override
protected Object executeAction() {
Assert.fail("SendMessageAction must be queued rather than started");
return null;
}
/**
* Send message on background worker thread
*/
@Override
protected Bundle doBackgroundWork() {
final MessageData message = actionParameters.getParcelable(KEY_MESSAGE, MessageData.class);
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
Uri messageUri = actionParameters.getParcelable(KEY_MESSAGE_URI, Uri.class);
Uri updatedMessageUri = null;
final boolean isSms = message.getProtocol() == MessageData.PROTOCOL_SMS;
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
final String subPhoneNumber = actionParameters.getString(KEY_SUB_PHONE_NUMBER);
LogUtil.i(TAG, "SendMessageAction: Sending " + (isSms ? "SMS" : "MMS") + " message "
+ messageId + " in conversation " + message.getConversationId());
int status;
int rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED;
int resultCode = MessageData.UNKNOWN_RESULT_CODE;
if (isSms) {
Assert.notNull(messageUri);
final String recipient = actionParameters.getString(KEY_RECIPIENT);
final String messageText = message.getMessageText();
final String smsServiceCenter = actionParameters.getString(KEY_SMS_SERVICE_CENTER);
final boolean deliveryReportRequired = MmsUtils.isDeliveryReportRequired(subId);
status = MmsUtils.sendSmsMessage(recipient, messageText, messageUri, subId,
smsServiceCenter, deliveryReportRequired);
} else {
final Context context = Factory.get().getApplicationContext();
final ArrayList<String> recipients =
actionParameters.getStringArrayList(KEY_RECIPIENTS);
if (messageUri == null) {
final long timestamp = message.getReceivedTimeStamp();
// Inform sync that message has been added at local received timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(timestamp);
// For MMS messages first need to write to telephony (resizing images if needed)
updatedMessageUri = MmsUtils.insertSendingMmsMessage(context, recipients,
message, subId, subPhoneNumber, timestamp);
if (updatedMessageUri != null) {
messageUri = updatedMessageUri;
// To prevent Sync seeing inconsistent state must write to DB on this thread
updateMessageUri(messageId, updatedMessageUri);
LogUtil.v(TAG, "SendMessageAction: Updated message " + messageId
+ " with new uri " + messageUri);
}
}
if (messageUri != null) {
// Actually send the MMS
final Bundle extras = new Bundle();
extras.putString(EXTRA_MESSAGE_ID, messageId);
extras.putParcelable(EXTRA_UPDATED_MESSAGE_URI, updatedMessageUri);
final MmsUtils.StatusPlusUri result = MmsUtils.sendMmsMessage(context, subId,
messageUri, extras);
if (result == MmsUtils.STATUS_PENDING) {
// Async send, so no status yet
LogUtil.d(TAG, "SendMessageAction: Sending MMS message " + messageId
+ " asynchronously; waiting for callback to finish processing");
return null;
}
status = result.status;
rawStatus = result.rawStatus;
resultCode = result.resultCode;
} else {
status = MmsUtils.MMS_REQUEST_MANUAL_RETRY;
}
}
// When we fast-fail before calling the MMS lib APIs (e.g. airplane mode,
// sending message is deleted).
ProcessSentMessageAction.processMessageSentFastFailed(messageId, messageUri,
updatedMessageUri, subId, isSms, status, rawStatus, resultCode);
return null;
}
private void updateMessageUri(final String messageId, final Uri updatedMessageUri) {
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
final ContentValues values = new ContentValues();
values.put(MessageColumns.SMS_MESSAGE_URI, updatedMessageUri.toString());
BugleDatabaseOperations.updateMessageRow(db, messageId, values);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
@Override
protected Object processBackgroundResponse(final Bundle response) {
// Nothing to do here, post-send tasks handled by ProcessSentMessageAction
return null;
}
/**
* Update message status to reflect success or failure
*/
@Override
protected Object processBackgroundFailure() {
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final MessageData message = actionParameters.getParcelable(KEY_MESSAGE, MessageData.class);
final boolean isSms = message.getProtocol() == MessageData.PROTOCOL_SMS;
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
final int resultCode = actionParameters.getInt(ProcessSentMessageAction.KEY_RESULT_CODE);
final int httpStatusCode =
actionParameters.getInt(ProcessSentMessageAction.KEY_HTTP_STATUS_CODE);
ProcessSentMessageAction.processResult(messageId, null /* updatedMessageUri */,
MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED,
isSms, this, subId, resultCode, httpStatusCode);
return null;
}
/**
* Update the message status (and message itself if necessary)
* @param isSms whether this is an SMS or MMS
* @param message message to update
* @param updatedMessageUri message uri for newly-inserted messages; null otherwise
* @param clearSeen whether the message 'seen' status should be reset if error occurs
*/
public static boolean updateMessageAndStatus(final boolean isSms, final MessageData message,
final Uri updatedMessageUri, final boolean clearSeen) {
final Context context = Factory.get().getApplicationContext();
final DatabaseWrapper db = DataModel.get().getDatabase();
// TODO: We're optimistically setting the type/box of outgoing messages to
// 'SENT' even before they actually are. We should technically be using QUEUED or OUTBOX
// instead, but if we do that, it's possible that the Messaging app will try to send them
// as part of its clean-up logic that runs when it starts (http://b/18155366).
//
// We also use the wrong status when inserting queued SMS messages in
// InsertNewMessageAction.insertBroadcastSmsMessage and insertSendingSmsMessage (should be
// QUEUED or OUTBOX), and in MmsUtils.insertSendReq (should be OUTBOX).
boolean updatedTelephony = true;
int messageBox;
int type;
switch(message.getStatus()) {
case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE:
case MessageData.BUGLE_STATUS_OUTGOING_DELIVERED:
type = Sms.MESSAGE_TYPE_SENT;
messageBox = Mms.MESSAGE_BOX_SENT;
break;
case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND:
case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
type = Sms.MESSAGE_TYPE_SENT;
messageBox = Mms.MESSAGE_BOX_SENT;
break;
case MessageData.BUGLE_STATUS_OUTGOING_SENDING:
case MessageData.BUGLE_STATUS_OUTGOING_RESENDING:
type = Sms.MESSAGE_TYPE_SENT;
messageBox = Mms.MESSAGE_BOX_SENT;
break;
case MessageData.BUGLE_STATUS_OUTGOING_FAILED:
case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER:
type = Sms.MESSAGE_TYPE_FAILED;
messageBox = Mms.MESSAGE_BOX_FAILED;
break;
default:
type = Sms.MESSAGE_TYPE_ALL;
messageBox = Mms.MESSAGE_BOX_ALL;
break;
}
// First in the telephony DB
if (isSms) {
// Ignore update message Uri
if (type != Sms.MESSAGE_TYPE_ALL) {
if (!MmsUtils.updateSmsMessageSendingStatus(context, message.getSmsMessageUri(),
type, message.getReceivedTimeStamp())) {
message.markMessageFailed(message.getSentTimeStamp());
updatedTelephony = false;
}
}
} else if (message.getSmsMessageUri() != null) {
if (messageBox != Mms.MESSAGE_BOX_ALL) {
if (!MmsUtils.updateMmsMessageSendingStatus(context, message.getSmsMessageUri(),
messageBox, message.getReceivedTimeStamp())) {
message.markMessageFailed(message.getSentTimeStamp());
updatedTelephony = false;
}
}
}
if (updatedTelephony) {
LogUtil.v(TAG, "SendMessageAction: Updated " + (isSms ? "SMS" : "MMS")
+ " message " + message.getMessageId()
+ " in telephony (" + message.getSmsMessageUri() + ")");
} else {
LogUtil.w(TAG, "SendMessageAction: Failed to update " + (isSms ? "SMS" : "MMS")
+ " message " + message.getMessageId()
+ " in telephony (" + message.getSmsMessageUri() + "); marking message failed");
}
// Update the local DB
db.beginTransaction();
try {
if (updatedMessageUri != null) {
// Update all message and part fields
BugleDatabaseOperations.updateMessageInTransaction(db, message);
BugleDatabaseOperations.refreshConversationMetadataInTransaction(
db, message.getConversationId(), false/* shouldAutoSwitchSelfId */,
false/*archived*/);
} else {
final ContentValues values = new ContentValues();
values.put(MessageColumns.STATUS, message.getStatus());
if (clearSeen) {
// When a message fails to send, the message needs to
// be unseen to be selected as an error notification.
values.put(MessageColumns.SEEN, 0);
}
values.put(MessageColumns.RECEIVED_TIMESTAMP, message.getReceivedTimeStamp());
values.put(MessageColumns.RAW_TELEPHONY_STATUS, message.getRawTelephonyStatus());
BugleDatabaseOperations.updateMessageRowIfExists(db, message.getMessageId(),
values);
}
db.setTransactionSuccessful();
LogUtil.v(TAG, "SendMessageAction: Updated " + (isSms ? "SMS" : "MMS")
+ " message " + message.getMessageId() + " in local db. Timestamp = "
+ message.getReceivedTimeStamp());
} finally {
db.endTransaction();
}
MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
if (updatedMessageUri != null) {
MessagingContentProvider.notifyPartsChanged();
}
return updatedTelephony;
}
private SendMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<SendMessageAction> CREATOR = new Parcelable.Creator<>() {
@Override
public SendMessageAction createFromParcel(final Parcel in) {
return new SendMessageAction(in);
}
@Override
public SendMessageAction[] newArray(final int size) {
return new SendMessageAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,707 @@
/*
* 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.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.provider.Telephony.Mms;
import android.provider.Telephony.Sms;
import androidx.collection.LongSparseArray;
import android.text.TextUtils;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.SyncManager.ThreadInfoCache;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.mmslib.SqliteWrapper;
import com.android.messaging.sms.DatabaseMessages;
import com.android.messaging.sms.DatabaseMessages.DatabaseMessage;
import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage;
import com.android.messaging.sms.DatabaseMessages.MmsMessage;
import com.android.messaging.sms.DatabaseMessages.SmsMessage;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import com.google.common.collect.Sets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* Class holding a pair of cursors - one for local db and one for telephony provider - allowing
* synchronous stepping through messages as part of sync.
*/
class SyncCursorPair {
private static final String TAG = LogUtil.BUGLE_TAG;
static final long SYNC_COMPLETE = -1L;
static final long SYNC_STARTING = Long.MAX_VALUE;
private CursorIterator mLocalCursorIterator;
private CursorIterator mRemoteCursorsIterator;
private final String mLocalSelection;
private final String mRemoteSmsSelection;
private final String mRemoteMmsSelection;
/**
* Check if SMS has been synchronized. We compare the counts of messages on both
* sides and return true if they are equal.
*
* Note that this may not be the most reliable way to tell if messages are in sync.
* For example, the local misses one message and has one obsolete message.
* However, we have background sms sync once a while, also some other events might
* trigger a full sync. So we will eventually catch up. And this should be rare to
* happen.
*
* @return If sms is in sync with telephony sms/mms providers
*/
static boolean allSynchronized(final DatabaseWrapper db) {
return isSynchronized(db, LOCAL_MESSAGES_SELECTION, null,
getSmsTypeSelectionSql(), null, getMmsTypeSelectionSql(), null);
}
SyncCursorPair(final long lowerBound, final long upperBound) {
mLocalSelection = getTimeConstrainedQuery(
LOCAL_MESSAGES_SELECTION,
MessageColumns.RECEIVED_TIMESTAMP,
lowerBound,
upperBound,
null /* threadColumn */, null /* threadId */);
mRemoteSmsSelection = getTimeConstrainedQuery(
getSmsTypeSelectionSql(),
"date",
lowerBound,
upperBound,
null /* threadColumn */, null /* threadId */);
mRemoteMmsSelection = getTimeConstrainedQuery(
getMmsTypeSelectionSql(),
"date",
((lowerBound < 0) ? lowerBound : (lowerBound + 999) / 1000), /*seconds*/
((upperBound < 0) ? upperBound : (upperBound + 999) / 1000), /*seconds*/
null /* threadColumn */, null /* threadId */);
}
SyncCursorPair(final long threadId, final String conversationId) {
mLocalSelection = getTimeConstrainedQuery(
LOCAL_MESSAGES_SELECTION,
MessageColumns.RECEIVED_TIMESTAMP,
-1L,
-1L,
MessageColumns.CONVERSATION_ID, conversationId);
// Find all SMS messages (excluding drafts) within the sync window
mRemoteSmsSelection = getTimeConstrainedQuery(
getSmsTypeSelectionSql(),
"date",
-1L,
-1L,
Sms.THREAD_ID, Long.toString(threadId));
mRemoteMmsSelection = getTimeConstrainedQuery(
getMmsTypeSelectionSql(),
"date",
-1L, /*seconds*/
-1L, /*seconds*/
Mms.THREAD_ID, Long.toString(threadId));
}
void query(final DatabaseWrapper db) {
// Load local messages in the sync window
mLocalCursorIterator = new LocalCursorIterator(db, mLocalSelection);
// Load remote messages in the sync window
mRemoteCursorsIterator = new RemoteCursorsIterator(mRemoteSmsSelection,
mRemoteMmsSelection);
}
boolean isSynchronized(final DatabaseWrapper db) {
return isSynchronized(db, mLocalSelection, null, mRemoteSmsSelection,
null, mRemoteMmsSelection, null);
}
void close() {
if (mLocalCursorIterator != null) {
mLocalCursorIterator.close();
}
if (mRemoteCursorsIterator != null) {
mRemoteCursorsIterator.close();
}
}
long scan(final int maxMessagesToScan,
final int maxMessagesToUpdate, final ArrayList<SmsMessage> smsToAdd,
final LongSparseArray<MmsMessage> mmsToAdd,
final ArrayList<LocalDatabaseMessage> messagesToDelete,
final SyncManager.ThreadInfoCache threadInfoCache) {
// Set of local messages matched with the timestamp of a remote message
final Set<DatabaseMessage> matchedLocalMessages = Sets.newHashSet();
// Set of remote messages matched with the timestamp of a local message
final Set<DatabaseMessage> matchedRemoteMessages = Sets.newHashSet();
long lastTimestampMillis = SYNC_STARTING;
// Number of messages scanned local and remote
int localCount = 0;
int remoteCount = 0;
// Seed the initial values of remote and local messages for comparison
DatabaseMessage remoteMessage = mRemoteCursorsIterator.next();
DatabaseMessage localMessage = mLocalCursorIterator.next();
// Iterate through messages on both sides in reverse time order
// Import messages in remote not in local, delete messages in local not in remote
while (localCount + remoteCount < maxMessagesToScan && smsToAdd.size()
+ mmsToAdd.size() + messagesToDelete.size() < maxMessagesToUpdate) {
if (remoteMessage == null && localMessage == null) {
// No more message on both sides - scan complete
lastTimestampMillis = SYNC_COMPLETE;
break;
} else if ((remoteMessage == null && localMessage != null) ||
(localMessage != null && remoteMessage != null &&
localMessage.getTimestampInMillis()
> remoteMessage.getTimestampInMillis())) {
// Found a local message that is not in remote db
// Delete the local message
messagesToDelete.add((LocalDatabaseMessage) localMessage);
lastTimestampMillis = Math.min(lastTimestampMillis,
localMessage.getTimestampInMillis());
// Advance to next local message
localMessage = mLocalCursorIterator.next();
localCount += 1;
} else if ((localMessage == null && remoteMessage != null) ||
(localMessage != null && remoteMessage != null &&
localMessage.getTimestampInMillis()
< remoteMessage.getTimestampInMillis())) {
// Found a remote message that is not in local db
// Add the remote message
saveMessageToAdd(smsToAdd, mmsToAdd, remoteMessage, threadInfoCache);
lastTimestampMillis = Math.min(lastTimestampMillis,
remoteMessage.getTimestampInMillis());
// Advance to next remote message
remoteMessage = mRemoteCursorsIterator.next();
remoteCount += 1;
} else {
// Found remote and local messages at the same timestamp
final long matchedTimestamp = localMessage.getTimestampInMillis();
lastTimestampMillis = Math.min(lastTimestampMillis, matchedTimestamp);
// Get the next local and remote messages
final DatabaseMessage remoteMessagePeek = mRemoteCursorsIterator.next();
final DatabaseMessage localMessagePeek = mLocalCursorIterator.next();
// Check if only one message on each side matches the current timestamp
// by looking at the next messages on both sides. If they are either null
// (meaning no more messages) or having a different timestamp. We want
// to optimize for this since this is the most common case when majority
// of the messages are in sync (so they one-to-one pair up at each timestamp),
// by not allocating the data structures required to compare a set of
// messages from both sides.
if ((remoteMessagePeek == null ||
remoteMessagePeek.getTimestampInMillis() != matchedTimestamp) &&
(localMessagePeek == null ||
localMessagePeek.getTimestampInMillis() != matchedTimestamp)) {
// Optimize the common case where only one message on each side
// that matches the same timestamp
if (!remoteMessage.equals(localMessage)) {
// local != remote
// Delete local message
messagesToDelete.add((LocalDatabaseMessage) localMessage);
// Add remote message
saveMessageToAdd(smsToAdd, mmsToAdd, remoteMessage, threadInfoCache);
}
// Get next local and remote messages
localMessage = localMessagePeek;
remoteMessage = remoteMessagePeek;
localCount += 1;
remoteCount += 1;
} else {
// Rare case in which multiple messages are in the same timestamp
// on either or both sides
// Gather all the matched remote messages
matchedRemoteMessages.clear();
matchedRemoteMessages.add(remoteMessage);
remoteCount += 1;
remoteMessage = remoteMessagePeek;
while (remoteMessage != null &&
remoteMessage.getTimestampInMillis() == matchedTimestamp) {
Assert.isTrue(!matchedRemoteMessages.contains(remoteMessage));
matchedRemoteMessages.add(remoteMessage);
remoteCount += 1;
remoteMessage = mRemoteCursorsIterator.next();
}
// Gather all the matched local messages
matchedLocalMessages.clear();
matchedLocalMessages.add(localMessage);
localCount += 1;
localMessage = localMessagePeek;
while (localMessage != null &&
localMessage.getTimestampInMillis() == matchedTimestamp) {
if (matchedLocalMessages.contains(localMessage)) {
// Duplicate message is local database is deleted
messagesToDelete.add((LocalDatabaseMessage) localMessage);
} else {
matchedLocalMessages.add(localMessage);
}
localCount += 1;
localMessage = mLocalCursorIterator.next();
}
// Delete messages local only
for (final DatabaseMessage msg : Sets.difference(
matchedLocalMessages, matchedRemoteMessages)) {
messagesToDelete.add((LocalDatabaseMessage) msg);
}
// Add messages remote only
for (final DatabaseMessage msg : Sets.difference(
matchedRemoteMessages, matchedLocalMessages)) {
saveMessageToAdd(smsToAdd, mmsToAdd, msg, threadInfoCache);
}
}
}
}
return lastTimestampMillis;
}
DatabaseMessage getLocalMessage() {
return mLocalCursorIterator.next();
}
DatabaseMessage getRemoteMessage() {
return mRemoteCursorsIterator.next();
}
int getLocalPosition() {
return mLocalCursorIterator.getPosition();
}
int getRemotePosition() {
return mRemoteCursorsIterator.getPosition();
}
int getLocalCount() {
return mLocalCursorIterator.getCount();
}
int getRemoteCount() {
return mRemoteCursorsIterator.getCount();
}
/**
* An iterator for a database cursor
*/
interface CursorIterator {
/**
* Move to next element in the cursor
*
* @return The next element (which becomes the current)
*/
DatabaseMessage next();
/**
* Close the cursor
*/
void close();
/**
* Get the position
*/
int getPosition();
/**
* Get the count
*/
int getCount();
}
private static final String ORDER_BY_DATE_DESC = "date DESC";
// A subquery that selects SMS/MMS messages in Bugle which are also in telephony
private static final String LOCAL_MESSAGES_SELECTION = String.format(
Locale.US,
"(%s NOTNULL)",
MessageColumns.SMS_MESSAGE_URI);
private static final String ORDER_BY_TIMESTAMP_DESC =
MessageColumns.RECEIVED_TIMESTAMP + " DESC";
// TODO : This should move into the provider
private static class LocalMessageQuery {
private static final String[] PROJECTION = new String[] {
MessageColumns._ID,
MessageColumns.RECEIVED_TIMESTAMP,
MessageColumns.SMS_MESSAGE_URI,
MessageColumns.PROTOCOL,
MessageColumns.CONVERSATION_ID,
};
private static final int INDEX_MESSAGE_ID = 0;
private static final int INDEX_MESSAGE_TIMESTAMP = 1;
private static final int INDEX_SMS_MESSAGE_URI = 2;
private static final int INDEX_MESSAGE_SMS_TYPE = 3;
private static final int INDEX_CONVERSATION_ID = 4;
}
/**
* This class provides the same DatabaseMessage interface over a local SMS db message
*/
private static LocalDatabaseMessage getLocalDatabaseMessage(final Cursor cursor) {
if (cursor == null) {
return null;
}
return new LocalDatabaseMessage(
cursor.getLong(LocalMessageQuery.INDEX_MESSAGE_ID),
cursor.getInt(LocalMessageQuery.INDEX_MESSAGE_SMS_TYPE),
cursor.getString(LocalMessageQuery.INDEX_SMS_MESSAGE_URI),
cursor.getLong(LocalMessageQuery.INDEX_MESSAGE_TIMESTAMP),
cursor.getString(LocalMessageQuery.INDEX_CONVERSATION_ID));
}
/**
* The buffered cursor iterator for local SMS
*/
private static class LocalCursorIterator implements CursorIterator {
private Cursor mCursor;
private final DatabaseWrapper mDatabase;
LocalCursorIterator(final DatabaseWrapper database, final String selection)
throws SQLiteException {
mDatabase = database;
try {
LogUtil.v(TAG, "SyncCursorPair: Querying for local messages; selection = "
+ selection);
mCursor = mDatabase.query(
DatabaseHelper.MESSAGES_TABLE,
LocalMessageQuery.PROJECTION,
selection,
null /*selectionArgs*/,
null/*groupBy*/,
null/*having*/,
ORDER_BY_TIMESTAMP_DESC);
} catch (final SQLiteException e) {
LogUtil.e(TAG, "SyncCursorPair: failed to query local sms/mms", e);
// Can't query local database. So let's throw up the exception and abort sync
// because we may end up import duplicate messages.
throw e;
}
}
@Override
public DatabaseMessage next() {
if (mCursor != null && mCursor.moveToNext()) {
return getLocalDatabaseMessage(mCursor);
}
return null;
}
@Override
public int getCount() {
return (mCursor == null ? 0 : mCursor.getCount());
}
@Override
public int getPosition() {
return (mCursor == null ? 0 : mCursor.getPosition());
}
@Override
public void close() {
if (mCursor != null) {
mCursor.close();
mCursor = null;
}
}
}
/**
* The cursor iterator for remote sms.
* Since SMS and MMS are stored in different tables in telephony provider,
* this class merges the two cursors and provides a unified view of messages
* from both cursors. Note that the order is DESC.
*/
private static class RemoteCursorsIterator implements CursorIterator {
private Cursor mSmsCursor;
private Cursor mMmsCursor;
private DatabaseMessage mNextSms;
private DatabaseMessage mNextMms;
RemoteCursorsIterator(final String smsSelection, final String mmsSelection)
throws SQLiteException {
mSmsCursor = null;
mMmsCursor = null;
try {
final Context context = Factory.get().getApplicationContext();
LogUtil.v(TAG, "SyncCursorPair: Querying for remote SMS; selection = "
+ smsSelection);
mSmsCursor = SqliteWrapper.query(
context,
context.getContentResolver(),
Sms.CONTENT_URI,
SmsMessage.getProjection(),
smsSelection,
null /* selectionArgs */,
ORDER_BY_DATE_DESC);
if (mSmsCursor == null) {
LogUtil.w(TAG, "SyncCursorPair: Remote SMS query returned null cursor; "
+ "need to cancel sync");
throw new RuntimeException("Null cursor from remote SMS query");
}
LogUtil.v(TAG, "SyncCursorPair: Querying for remote MMS; selection = "
+ mmsSelection);
mMmsCursor = SqliteWrapper.query(
context,
context.getContentResolver(),
Mms.CONTENT_URI,
DatabaseMessages.MmsMessage.getProjection(),
mmsSelection,
null /* selectionArgs */,
ORDER_BY_DATE_DESC);
if (mMmsCursor == null) {
LogUtil.w(TAG, "SyncCursorPair: Remote MMS query returned null cursor; "
+ "need to cancel sync");
throw new RuntimeException("Null cursor from remote MMS query");
}
// Move to the first element in the combined stream from both cursors
mNextSms = getSmsCursorNext();
mNextMms = getMmsCursorNext();
} catch (final SQLiteException e) {
LogUtil.e(TAG, "SyncCursorPair: failed to query remote messages", e);
// If we ignore this, the following code would think there is no remote message
// and will delete all the local sms. We should be cautious here. So instead,
// let's throw the exception to the caller and abort sms sync. We do the same
// thing if either of the remote cursors is null.
throw e;
}
}
@Override
public DatabaseMessage next() {
DatabaseMessage result;
if (mNextSms != null && mNextMms != null) {
if (mNextSms.getTimestampInMillis() >= mNextMms.getTimestampInMillis()) {
result = mNextSms;
mNextSms = getSmsCursorNext();
} else {
result = mNextMms;
mNextMms = getMmsCursorNext();
}
} else {
if (mNextSms != null) {
result = mNextSms;
mNextSms = getSmsCursorNext();
} else {
result = mNextMms;
mNextMms = getMmsCursorNext();
}
}
return result;
}
private DatabaseMessage getSmsCursorNext() {
if (mSmsCursor != null && mSmsCursor.moveToNext()) {
return SmsMessage.get(mSmsCursor);
}
return null;
}
private DatabaseMessage getMmsCursorNext() {
if (mMmsCursor != null && mMmsCursor.moveToNext()) {
return MmsMessage.get(mMmsCursor);
}
return null;
}
@Override
// Return approximate cursor position allowing for read ahead on two cursors (hence -1)
public int getPosition() {
return (mSmsCursor == null ? 0 : mSmsCursor.getPosition()) +
(mMmsCursor == null ? 0 : mMmsCursor.getPosition()) - 1;
}
@Override
public int getCount() {
return (mSmsCursor == null ? 0 : mSmsCursor.getCount()) +
(mMmsCursor == null ? 0 : mMmsCursor.getCount());
}
@Override
public void close() {
if (mSmsCursor != null) {
mSmsCursor.close();
mSmsCursor = null;
}
if (mMmsCursor != null) {
mMmsCursor.close();
mMmsCursor = null;
}
}
}
/**
* Type selection for importing sms messages. Only SENT and INBOX messages are imported.
*
* @return The SQL selection for importing sms messages
*/
public static String getSmsTypeSelectionSql() {
return MmsUtils.getSmsTypeSelectionSql();
}
/**
* Type selection for importing mms messages.
*
* Criteria:
* MESSAGE_BOX is INBOX, SENT or OUTBOX
* MESSAGE_TYPE is SEND_REQ (sent), RETRIEVE_CONF (received) or NOTIFICATION_IND (download)
*
* @return The SQL selection for importing mms messages. This selects the message type,
* not including the selection on timestamp.
*/
public static String getMmsTypeSelectionSql() {
return MmsUtils.getMmsTypeSelectionSql();
}
/**
* Get a SQL selection string using an existing selection and time window limits
* The limits are not applied if the value is < 0
*
* @param typeSelection The existing selection
* @param from The inclusive lower bound
* @param to The exclusive upper bound
* @return The created SQL selection
*/
private static String getTimeConstrainedQuery(final String typeSelection,
final String timeColumn, final long from, final long to,
final String threadColumn, final String threadId) {
final StringBuilder queryBuilder = new StringBuilder();
queryBuilder.append(typeSelection);
if (from > 0) {
queryBuilder.append(" AND ").append(timeColumn).append(">=").append(from);
}
if (to > 0) {
queryBuilder.append(" AND ").append(timeColumn).append("<").append(to);
}
if (!TextUtils.isEmpty(threadColumn) && !TextUtils.isEmpty(threadId)) {
queryBuilder.append(" AND ").append(threadColumn).append("=").append(threadId);
}
return queryBuilder.toString();
}
private static final String[] COUNT_PROJECTION = new String[] { "count()" };
private static int getCountFromCursor(final Cursor cursor) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
}
// We should only return a number if we were able to read it from the cursor.
// Otherwise, we throw an exception to cancel the sync.
String cursorDesc = "";
if (cursor == null) {
cursorDesc = "null";
} else if (cursor.getCount() == 0) {
cursorDesc = "empty";
}
throw new IllegalArgumentException("Cannot get count from " + cursorDesc + " cursor");
}
private void saveMessageToAdd(final List<SmsMessage> smsToAdd,
final LongSparseArray<MmsMessage> mmsToAdd, final DatabaseMessage message,
final ThreadInfoCache threadInfoCache) {
long threadId;
if (message.getProtocol() == MessageData.PROTOCOL_MMS) {
final MmsMessage mms = (MmsMessage) message;
mmsToAdd.append(mms.getId(), mms);
threadId = mms.mThreadId;
} else {
final SmsMessage sms = (SmsMessage) message;
smsToAdd.add(sms);
threadId = sms.mThreadId;
}
// Cache the lookup and canonicalization of the phone number outside of the transaction...
threadInfoCache.getThreadRecipients(threadId);
}
/**
* Check if SMS has been synchronized. We compare the counts of messages on both
* sides and return true if they are equal.
*
* Note that this may not be the most reliable way to tell if messages are in sync.
* For example, the local misses one message and has one obsolete message.
* However, we have background sms sync once a while, also some other events might
* trigger a full sync. So we will eventually catch up. And this should be rare to
* happen.
*
* @return If sms is in sync with telephony sms/mms providers
*/
private static boolean isSynchronized(final DatabaseWrapper db, final String localSelection,
final String[] localSelectionArgs, final String smsSelection,
final String[] smsSelectionArgs, final String mmsSelection,
final String[] mmsSelectionArgs) {
final Context context = Factory.get().getApplicationContext();
Cursor localCursor = null;
Cursor remoteSmsCursor = null;
Cursor remoteMmsCursor = null;
try {
localCursor = db.query(
DatabaseHelper.MESSAGES_TABLE,
COUNT_PROJECTION,
localSelection,
localSelectionArgs,
null/*groupBy*/,
null/*having*/,
null/*orderBy*/);
final int localCount = getCountFromCursor(localCursor);
remoteSmsCursor = SqliteWrapper.query(
context,
context.getContentResolver(),
Sms.CONTENT_URI,
COUNT_PROJECTION,
smsSelection,
smsSelectionArgs,
null/*orderBy*/);
final int smsCount = getCountFromCursor(remoteSmsCursor);
remoteMmsCursor = SqliteWrapper.query(
context,
context.getContentResolver(),
Mms.CONTENT_URI,
COUNT_PROJECTION,
mmsSelection,
mmsSelectionArgs,
null/*orderBy*/);
final int mmsCount = getCountFromCursor(remoteMmsCursor);
final int remoteCount = smsCount + mmsCount;
final boolean isInSync = (localCount == remoteCount);
if (isInSync) {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncCursorPair: Same # of local and remote messages = "
+ localCount);
}
} else {
LogUtil.i(TAG, "SyncCursorPair: Not in sync; # local messages = " + localCount
+ ", # remote message = " + remoteCount);
}
return isInSync;
} catch (final Exception e) {
LogUtil.e(TAG, "SyncCursorPair: failed to query local or remote message counts", e);
// If something is wrong in querying database, assume we are synced so
// we don't retry indefinitely
} finally {
if (localCursor != null) {
localCursor.close();
}
if (remoteSmsCursor != null) {
remoteSmsCursor.close();
}
if (remoteMmsCursor != null) {
remoteMmsCursor.close();
}
}
return true;
}
}
@@ -0,0 +1,365 @@
/*
* 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.database.Cursor;
import android.database.sqlite.SQLiteConstraintException;
import android.provider.Telephony;
import android.provider.Telephony.Mms;
import android.provider.Telephony.Sms;
import android.support.v7.mms.pdu.PduHeaders;
import android.text.TextUtils;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.SyncManager.ThreadInfoCache;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage;
import com.android.messaging.sms.DatabaseMessages.MmsMessage;
import com.android.messaging.sms.DatabaseMessages.SmsMessage;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
/**
* Update local database with a batch of messages to add/delete in one transaction
*/
class SyncMessageBatch {
private static final String TAG = LogUtil.BUGLE_TAG;
// Variables used during executeAction
private final HashSet<String> mConversationsToUpdate;
// Cache of thread->conversationId map
private final ThreadInfoCache mCache;
// Set of SMS messages to add
private final ArrayList<SmsMessage> mSmsToAdd;
// Set of MMS messages to add
private final ArrayList<MmsMessage> mMmsToAdd;
// Set of local messages to delete
private final ArrayList<LocalDatabaseMessage> mMessagesToDelete;
SyncMessageBatch(final ArrayList<SmsMessage> smsToAdd,
final ArrayList<MmsMessage> mmsToAdd,
final ArrayList<LocalDatabaseMessage> messagesToDelete,
final ThreadInfoCache cache) {
mSmsToAdd = smsToAdd;
mMmsToAdd = mmsToAdd;
mMessagesToDelete = messagesToDelete;
mCache = cache;
mConversationsToUpdate = new HashSet<>();
}
void updateLocalDatabase() {
// Perform local database changes in one transaction
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
// Store all the SMS messages
for (final SmsMessage sms : mSmsToAdd) {
storeSms(db, sms);
}
// Store all the MMS messages
for (final MmsMessage mms : mMmsToAdd) {
storeMms(db, mms);
}
// Keep track of conversations with messages deleted
for (final LocalDatabaseMessage message : mMessagesToDelete) {
mConversationsToUpdate.add(message.getConversationId());
}
// Batch delete local messages
batchDelete(db, DatabaseHelper.MESSAGES_TABLE, MessageColumns._ID,
messageListToIds(mMessagesToDelete));
for (final LocalDatabaseMessage message : mMessagesToDelete) {
LogUtil.v(TAG, "SyncMessageBatch: Deleted message " + message.getLocalId()
+ " for SMS/MMS " + message.getUri() + " with timestamp "
+ message.getTimestampInMillis());
}
// Update conversation state for imported messages, like snippet,
updateConversations(db);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
private static String[] messageListToIds(final List<LocalDatabaseMessage> messagesToDelete) {
final String[] ids = new String[messagesToDelete.size()];
for (int i = 0; i < ids.length; i++) {
ids[i] = Long.toString(messagesToDelete.get(i).getLocalId());
}
return ids;
}
/**
* Store the SMS message into local database.
*/
private void storeSms(final DatabaseWrapper db, final SmsMessage sms) {
if (sms.mBody == null) {
LogUtil.w(TAG, "SyncMessageBatch: SMS " + sms.mUri + " has no body; adding empty one");
// try to fix it
sms.mBody = "";
}
if (TextUtils.isEmpty(sms.mAddress)) {
LogUtil.e(TAG, "SyncMessageBatch: SMS has no address; using unknown sender");
// try to fix it
sms.mAddress = ParticipantData.getUnknownSenderDestination();
}
// TODO : We need to also deal with messages in a failed/retry state
final boolean isOutgoing = sms.mType != Sms.MESSAGE_TYPE_INBOX;
final String otherPhoneNumber = sms.mAddress;
// A forced resync of all messages should still keep the archived states.
// The database upgrade code notifies sync manager of this. We need to
// honor the original customization to this conversation if created.
final String conversationId = mCache.getOrCreateConversation(db, sms.mThreadId, sms.mSubId,
DataModel.get().getSyncManager().getCustomizationForThread(sms.mThreadId));
if (conversationId == null) {
// Cannot create conversation for this message? This should not happen.
LogUtil.e(TAG, "SyncMessageBatch: Failed to create conversation for SMS thread "
+ sms.mThreadId);
return;
}
final ParticipantData self = ParticipantData.getSelfParticipant(sms.getSubId());
final String selfId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
final ParticipantData sender = isOutgoing ?
self :
ParticipantData.getFromRawPhoneBySimLocale(otherPhoneNumber, sms.getSubId());
final String participantId = (isOutgoing ? selfId :
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender));
final int bugleStatus = bugleStatusForSms(isOutgoing, sms.mType, sms.mStatus);
final MessageData message = MessageData.createSmsMessage(
sms.mUri,
participantId,
selfId,
conversationId,
bugleStatus,
sms.mSeen,
sms.mRead,
sms.mTimestampSentInMillis,
sms.mTimestampInMillis,
sms.mBody);
// Inserting sms content into messages table
try {
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
} catch (SQLiteConstraintException e) {
rethrowSQLiteConstraintExceptionWithDetails(e, db, sms.mUri, sms.mThreadId,
conversationId, selfId, participantId);
}
LogUtil.v(TAG, "SyncMessageBatch: Inserted new message " + message.getMessageId()
+ " for SMS " + message.getSmsMessageUri() + " received at "
+ message.getReceivedTimeStamp());
// Keep track of updated conversation for later updating the conversation snippet, etc.
mConversationsToUpdate.add(conversationId);
}
public static int bugleStatusForSms(final boolean isOutgoing, final int type,
final int status) {
int bugleStatus;
// For a message we sync either
if (isOutgoing) {
// Outgoing message not yet been sent
if (type == Telephony.Sms.MESSAGE_TYPE_FAILED
|| type == Telephony.Sms.MESSAGE_TYPE_OUTBOX
|| type == Telephony.Sms.MESSAGE_TYPE_QUEUED
|| (type == Telephony.Sms.MESSAGE_TYPE_SENT
&& status >= Telephony.Sms.STATUS_FAILED)) {
// Not sent counts as failed and available for manual resend
bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_FAILED;
} else if (status == Sms.STATUS_COMPLETE) {
bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_DELIVERED;
} else {
// Otherwise outgoing message is complete
bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_COMPLETE;
}
} else {
// All incoming SMS messages are complete
bugleStatus = MessageData.BUGLE_STATUS_INCOMING_COMPLETE;
}
return bugleStatus;
}
/**
* Store the MMS message into local database
*/
private void storeMms(final DatabaseWrapper db, final MmsMessage mms) {
if (mms.mParts.size() < 1) {
LogUtil.w(TAG, "SyncMessageBatch: MMS " + mms.mUri + " has no parts");
}
// TODO : We need to also deal with messages in a failed/retry state
final boolean isOutgoing = mms.mType != Mms.MESSAGE_BOX_INBOX;
final boolean isNotification = (mms.mMmsMessageType ==
PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND);
final String senderId = mms.mSender;
// A forced resync of all messages should still keep the archived states.
// The database upgrade code notifies sync manager of this. We need to
// honor the original customization to this conversation if created.
final String conversationId = mCache.getOrCreateConversation(db, mms.mThreadId, mms.mSubId,
DataModel.get().getSyncManager().getCustomizationForThread(mms.mThreadId));
if (conversationId == null) {
LogUtil.e(TAG, "SyncMessageBatch: Failed to create conversation for MMS thread "
+ mms.mThreadId);
return;
}
final ParticipantData self = ParticipantData.getSelfParticipant(mms.getSubId());
final String selfId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
final ParticipantData sender = isOutgoing ?
self : ParticipantData.getFromRawPhoneBySimLocale(senderId, mms.getSubId());
final String participantId = (isOutgoing ? selfId :
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender));
final int bugleStatus = MmsUtils.bugleStatusForMms(isOutgoing, isNotification, mms.mType);
// Import message and all of the parts.
// TODO : For now we are importing these in the order we found them in the MMS
// database. Ideally we would load and parse the SMIL which describes how the parts relate
// to one another.
// TODO: Need to set correct status on message
final MessageData message = MmsUtils.createMmsMessage(mms, conversationId, participantId,
selfId, bugleStatus);
// Inserting mms content into messages table
try {
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
} catch (SQLiteConstraintException e) {
rethrowSQLiteConstraintExceptionWithDetails(e, db, mms.mUri, mms.mThreadId,
conversationId, selfId, participantId);
}
LogUtil.v(TAG, "SyncMessageBatch: Inserted new message " + message.getMessageId()
+ " for MMS " + message.getSmsMessageUri() + " received at "
+ message.getReceivedTimeStamp());
// Keep track of updated conversation for later updating the conversation snippet, etc.
mConversationsToUpdate.add(conversationId);
}
// TODO: Remove this after we no longer see this crash (b/18375758)
private static void rethrowSQLiteConstraintExceptionWithDetails(SQLiteConstraintException e,
DatabaseWrapper db, String messageUri, long threadId, String conversationId,
String selfId, String senderId) {
// Add some extra debug information to the exception for tracking down b/18375758.
// The default detail message for SQLiteConstraintException tells us that a foreign
// key constraint failed, but not which one! Messages have foreign keys to 3 tables:
// conversations, participants (self), participants (sender). We'll query each one
// to determine which one(s) violated the constraint, and then throw a new exception
// with those details.
String foundConversationId = null;
try (Cursor cursor = db.rawQuery("SELECT " + ConversationColumns._ID
+ " FROM " + DatabaseHelper.CONVERSATIONS_TABLE
+ " WHERE " + ConversationColumns._ID + "=" + conversationId,
null)) {
// Look for an existing conversation in the db with the conversation id
if (cursor != null && cursor.moveToFirst()) {
Assert.isTrue(cursor.getCount() == 1);
foundConversationId = cursor.getString(0);
}
}
ParticipantData foundSelfParticipant =
BugleDatabaseOperations.getExistingParticipant(db, selfId);
ParticipantData foundSenderParticipant =
BugleDatabaseOperations.getExistingParticipant(db, senderId);
String errorMsg = "SQLiteConstraintException while inserting message for " + messageUri
+ "; conversation id from getOrCreateConversation = " + conversationId
+ " (lookup thread = " + threadId + "), found conversation id = "
+ foundConversationId + ", found self participant = "
+ LogUtil.sanitizePII(foundSelfParticipant.getNormalizedDestination())
+ " (lookup id = " + selfId + "), found sender participant = "
+ LogUtil.sanitizePII(foundSenderParticipant.getNormalizedDestination())
+ " (lookup id = " + senderId + ")";
throw new RuntimeException(errorMsg, e);
}
/**
* Use the tracked latest message info to update conversations, including
* latest chat message and sort timestamp.
*/
private void updateConversations(final DatabaseWrapper db) {
for (final String conversationId : mConversationsToUpdate) {
if (BugleDatabaseOperations.deleteConversationIfEmptyInTransaction(db,
conversationId)) {
continue;
}
final boolean archived = mCache.isArchived(conversationId);
// Always attempt to auto-switch conversation self id for sync/import case.
BugleDatabaseOperations.maybeRefreshConversationMetadataInTransaction(db,
conversationId, true /*shouldAutoSwitchSelfId*/, archived /*keepArchived*/);
}
}
/**
* Batch delete database rows by matching a column with a list of values, usually some
* kind of IDs.
*
* @return Total number of deleted messages
*/
private static int batchDelete(final DatabaseWrapper db, final String table,
final String column, final String[] ids) {
int totalDeleted = 0;
final int totalIds = ids.length;
for (int start = 0; start < totalIds; start += MmsUtils.MAX_IDS_PER_QUERY) {
final int end = Math.min(start + MmsUtils.MAX_IDS_PER_QUERY, totalIds); //excluding
final int count = end - start;
final String batchSelection = String.format(
Locale.US,
"%s IN %s",
column,
MmsUtils.getSqlInOperand(count));
final String[] batchSelectionArgs = Arrays.copyOfRange(ids, start, end);
final int deleted = db.delete(
table,
batchSelection,
batchSelectionArgs);
totalDeleted += deleted;
}
return totalDeleted;
}
}
@@ -0,0 +1,625 @@
/*
* 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.action;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.os.SystemClock;
import android.provider.Telephony.Mms;
import android.support.v7.mms.pdu.ContentType;
import androidx.annotation.NonNull;
import androidx.collection.LongSparseArray;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.SyncManager.ThreadInfoCache;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.mmslib.SqliteWrapper;
import com.android.messaging.sms.DatabaseMessages;
import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage;
import com.android.messaging.sms.DatabaseMessages.MmsMessage;
import com.android.messaging.sms.DatabaseMessages.SmsMessage;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.BugleGservicesKeys;
import com.android.messaging.util.BuglePrefs;
import com.android.messaging.util.BuglePrefsKeys;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.OsUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Action used to sync messages from smsmms db to local database
*/
public class SyncMessagesAction extends Action implements Parcelable {
static final long SYNC_FAILED = Long.MIN_VALUE;
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String KEY_START_TIMESTAMP = "start_timestamp";
private static final String KEY_MAX_UPDATE = "max_update";
private static final String KEY_LOWER_BOUND = "lower_bound";
private static final String KEY_UPPER_BOUND = "upper_bound";
private static final String BUNDLE_KEY_LAST_TIMESTAMP = "last_timestamp";
private static final String BUNDLE_KEY_SMS_MESSAGES = "sms_to_add";
private static final String BUNDLE_KEY_MMS_MESSAGES = "mms_to_add";
private static final String BUNDLE_KEY_MESSAGES_TO_DELETE = "messages_to_delete";
/**
* Start a full sync (backed off a few seconds to avoid pulling sending/receiving messages).
*/
public static void fullSync() {
final long smsSyncBackoffTimeMillis =
BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS_DEFAULT;
final long now = System.currentTimeMillis();
// TODO: Could base this off most recent message in db but now should be okay...
final long startTimestamp = now - smsSyncBackoffTimeMillis;
final SyncMessagesAction action = new SyncMessagesAction(-1L, startTimestamp,
0, startTimestamp);
action.start();
}
/**
* Start an incremental sync to pull messages since last sync (backed off a few seconds)..
*/
public static void sync() {
final long smsSyncBackoffTimeMillis =
BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS_DEFAULT;
final long now = System.currentTimeMillis();
// TODO: Could base this off most recent message in db but now should be okay...
final long startTimestamp = now - smsSyncBackoffTimeMillis;
sync(startTimestamp);
}
/**
* Start an incremental sync when the application starts up (no back off as not yet
* sending/receiving).
*/
public static void immediateSync() {
sync(System.currentTimeMillis());
}
private static void sync(final long startTimestamp) {
if (!OsUtil.hasSmsPermission()) {
// Sync requires READ_SMS permission
return;
}
final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
// Lower bound is end of previous sync
final long syncLowerBoundTimeMillis = prefs.getLong(BuglePrefsKeys.LAST_SYNC_TIME,
BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT);
final SyncMessagesAction action = new SyncMessagesAction(syncLowerBoundTimeMillis,
startTimestamp, 0, startTimestamp);
action.start();
}
private SyncMessagesAction(final long lowerBound, final long upperBound,
final int maxMessagesToUpdate, final long startTimestamp) {
actionParameters.putLong(KEY_LOWER_BOUND, lowerBound);
actionParameters.putLong(KEY_UPPER_BOUND, upperBound);
actionParameters.putInt(KEY_MAX_UPDATE, maxMessagesToUpdate);
actionParameters.putLong(KEY_START_TIMESTAMP, startTimestamp);
}
@Override
protected Object executeAction() {
final DatabaseWrapper db = DataModel.get().getDatabase();
long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND);
final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND);
final int initialMaxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE);
final long startTimestamp = actionParameters.getLong(KEY_START_TIMESTAMP);
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Request to sync messages from "
+ lowerBoundTimeMillis + " to " + upperBoundTimeMillis + " (start timestamp = "
+ startTimestamp + ", message update limit = " + initialMaxMessagesToUpdate
+ ")");
}
final SyncManager syncManager = DataModel.get().getSyncManager();
if (lowerBoundTimeMillis >= 0) {
// Cursors
final SyncCursorPair cursors = new SyncCursorPair(-1L, lowerBoundTimeMillis);
final boolean inSync = cursors.isSynchronized(db);
if (!inSync) {
if (syncManager.delayUntilFullSync(startTimestamp) == 0) {
lowerBoundTimeMillis = -1;
actionParameters.putLong(KEY_LOWER_BOUND, lowerBoundTimeMillis);
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Messages before "
+ lowerBoundTimeMillis + " not in sync; promoting to full sync");
}
} else if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Messages before "
+ lowerBoundTimeMillis + " not in sync; will do incremental sync");
}
} else {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Messages before " + lowerBoundTimeMillis
+ " are in sync");
}
}
}
// Check if sync allowed (can be too soon after last or one is already running)
if (syncManager.shouldSync(lowerBoundTimeMillis < 0, startTimestamp)) {
syncManager.startSyncBatch(upperBoundTimeMillis);
requestBackgroundWork();
}
return null;
}
@Override
protected Bundle doBackgroundWork() {
final DatabaseWrapper db = DataModel.get().getDatabase();
final int maxMessagesToScan =
BugleGservicesKeys.SMS_SYNC_BATCH_MAX_MESSAGES_TO_SCAN_DEFAULT;
final int initialMaxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE);
final int smsSyncSubsequentBatchSizeMin =
BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MIN_DEFAULT;
final int smsSyncSubsequentBatchSizeMax =
BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MAX_DEFAULT;
// Cap sync size to GServices limits
final int maxMessagesToUpdate = Math.max(smsSyncSubsequentBatchSizeMin,
Math.min(initialMaxMessagesToUpdate, smsSyncSubsequentBatchSizeMax));
final long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND);
final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND);
LogUtil.i(TAG, "SyncMessagesAction: Starting batch for messages from "
+ lowerBoundTimeMillis + " to " + upperBoundTimeMillis
+ " (message update limit = " + maxMessagesToUpdate + ", message scan limit = "
+ maxMessagesToScan + ")");
// Clear last change time so that we can work out if this batch is dirty when it completes
final SyncManager syncManager = DataModel.get().getSyncManager();
// Clear the singleton cache that maps threads to recipients and to conversations.
final SyncManager.ThreadInfoCache cache = syncManager.getThreadInfoCache();
cache.clear();
// Sms messages to store
final ArrayList<SmsMessage> smsToAdd = new ArrayList<>();
// Mms messages to store
final LongSparseArray<MmsMessage> mmsToAdd = new LongSparseArray<>();
// List of local SMS/MMS to remove
final ArrayList<LocalDatabaseMessage> messagesToDelete = new ArrayList<>();
long lastTimestampMillis = SYNC_FAILED;
if (syncManager.isSyncing(upperBoundTimeMillis)) {
// Cursors
final SyncCursorPair cursors = new SyncCursorPair(lowerBoundTimeMillis,
upperBoundTimeMillis);
// Actually compare the messages using cursor pair
lastTimestampMillis = syncCursorPair(db, cursors, smsToAdd, mmsToAdd,
messagesToDelete, maxMessagesToScan, maxMessagesToUpdate, cache);
}
final Bundle response = new Bundle();
// If comparison succeeds bundle up the changes for processing in ActionService
if (lastTimestampMillis > SYNC_FAILED) {
final ArrayList<MmsMessage> mmsToAddList = new ArrayList<>();
for (int i = 0; i < mmsToAdd.size(); i++) {
final MmsMessage mms = mmsToAdd.valueAt(i);
mmsToAddList.add(mms);
}
response.putParcelableArrayList(BUNDLE_KEY_SMS_MESSAGES, smsToAdd);
response.putParcelableArrayList(BUNDLE_KEY_MMS_MESSAGES, mmsToAddList);
response.putParcelableArrayList(BUNDLE_KEY_MESSAGES_TO_DELETE, messagesToDelete);
}
response.putLong(BUNDLE_KEY_LAST_TIMESTAMP, lastTimestampMillis);
return response;
}
/**
* Compare messages based on timestamp and uri
* @param db local database wrapper
* @param cursors cursor pair holding references to local and remote messages
* @param smsToAdd newly found sms messages to add
* @param mmsToAdd newly found mms messages to add
* @param messagesToDelete messages not found needing deletion
* @param maxMessagesToScan max messages to scan for changes
* @param maxMessagesToUpdate max messages to return for updates
* @param cache cache for conversation id / thread id / recipient set mapping
* @return timestamp of the oldest message seen during the sync scan
*/
private long syncCursorPair(final DatabaseWrapper db, final SyncCursorPair cursors,
final ArrayList<SmsMessage> smsToAdd, final LongSparseArray<MmsMessage> mmsToAdd,
final ArrayList<LocalDatabaseMessage> messagesToDelete, final int maxMessagesToScan,
final int maxMessagesToUpdate, final ThreadInfoCache cache) {
long lastTimestampMillis;
final long startTimeMillis = SystemClock.elapsedRealtime();
// Number of messages scanned local and remote
int localPos;
int remotePos;
int localTotal;
int remoteTotal;
// Scan through the messages on both sides and prepare messages for local message table
// changes (including adding and deleting)
try {
cursors.query(db);
localTotal = cursors.getLocalCount();
remoteTotal = cursors.getRemoteCount();
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Scanning cursors (local count = " + localTotal
+ ", remote count = " + remoteTotal + ", message update limit = "
+ maxMessagesToUpdate + ", message scan limit = " + maxMessagesToScan
+ ")");
}
lastTimestampMillis = cursors.scan(maxMessagesToScan, maxMessagesToUpdate,
smsToAdd, mmsToAdd, messagesToDelete, cache);
localPos = cursors.getLocalPosition();
remotePos = cursors.getRemotePosition();
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Scanned cursors (local position = " + localPos
+ " of " + localTotal + ", remote position = " + remotePos + " of "
+ remoteTotal + ")");
}
// Batch loading the parts of the MMS messages in this batch
loadMmsParts(mmsToAdd);
// Lookup senders for incoming mms messages
setMmsSenders(mmsToAdd, cache);
} catch (final SQLiteException e) {
LogUtil.e(TAG, "SyncMessagesAction: Database exception", e);
// Let's abort
lastTimestampMillis = SYNC_FAILED;
} catch (final Exception e) {
// We want to catch anything unexpected since this is running in a separate thread
// and any unexpected exception will just fail this thread silently.
// Let's crash for dogfooders!
LogUtil.wtf(TAG, "SyncMessagesAction: unexpected failure in scan", e);
lastTimestampMillis = SYNC_FAILED;
} finally {
if (cursors != null) {
cursors.close();
}
}
final long endTimeMillis = SystemClock.elapsedRealtime();
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Scan complete (took "
+ (endTimeMillis - startTimeMillis) + " ms). " + smsToAdd.size()
+ " remote SMS to add, " + mmsToAdd.size() + " MMS to add, "
+ messagesToDelete.size() + " local messages to delete. "
+ "Oldest timestamp seen = " + lastTimestampMillis);
}
return lastTimestampMillis;
}
/**
* Perform local database updates and schedule follow on sync actions
*/
@Override
protected Object processBackgroundResponse(final Bundle response) {
final long lastTimestampMillis = response.getLong(BUNDLE_KEY_LAST_TIMESTAMP);
final long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND);
final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND);
final int maxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE);
final long startTimestamp = actionParameters.getLong(KEY_START_TIMESTAMP);
// Check with the sync manager if any conflicting updates have been made to databases
final SyncManager syncManager = DataModel.get().getSyncManager();
final boolean orphan = !syncManager.isSyncing(upperBoundTimeMillis);
// lastTimestampMillis used to indicate failure
if (orphan) {
// This batch does not match current in progress timestamp.
LogUtil.w(TAG, "SyncMessagesAction: Ignoring orphan sync batch for messages from "
+ lowerBoundTimeMillis + " to " + upperBoundTimeMillis);
} else {
final boolean dirty = syncManager.isBatchDirty(lastTimestampMillis);
if (lastTimestampMillis == SYNC_FAILED) {
LogUtil.e(TAG, "SyncMessagesAction: Sync failed - terminating");
// Failed - update last sync times to throttle our failure rate
final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
// Save sync completion time so next sync will start from here
prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, startTimestamp);
// Remember last full sync so that don't start background full sync right away
prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, startTimestamp);
syncManager.complete();
} else if (dirty) {
LogUtil.w(TAG, "SyncMessagesAction: Redoing dirty sync batch of messages from "
+ lowerBoundTimeMillis + " to " + upperBoundTimeMillis);
// Redo this batch
final SyncMessagesAction nextBatch =
new SyncMessagesAction(lowerBoundTimeMillis, upperBoundTimeMillis,
maxMessagesToUpdate, startTimestamp);
syncManager.startSyncBatch(upperBoundTimeMillis);
requestBackgroundWork(nextBatch);
} else {
// Succeeded
final ArrayList<SmsMessage> smsToAdd =
response.getParcelableArrayList(BUNDLE_KEY_SMS_MESSAGES, SmsMessage.class);
final ArrayList<MmsMessage> mmsToAdd =
response.getParcelableArrayList(BUNDLE_KEY_MMS_MESSAGES, MmsMessage.class);
final ArrayList<LocalDatabaseMessage> messagesToDelete =
response.getParcelableArrayList(BUNDLE_KEY_MESSAGES_TO_DELETE,
LocalDatabaseMessage.class);
final int messagesUpdated = smsToAdd.size() + mmsToAdd.size()
+ messagesToDelete.size();
// Perform local database changes in one transaction
long txnTimeMillis = 0;
if (messagesUpdated > 0) {
final long startTimeMillis = SystemClock.elapsedRealtime();
final SyncMessageBatch batch = new SyncMessageBatch(smsToAdd, mmsToAdd,
messagesToDelete, syncManager.getThreadInfoCache());
batch.updateLocalDatabase();
final long endTimeMillis = SystemClock.elapsedRealtime();
txnTimeMillis = endTimeMillis - startTimeMillis;
LogUtil.i(TAG, "SyncMessagesAction: Updated local database "
+ "(took " + txnTimeMillis + " ms). Added "
+ smsToAdd.size() + " SMS, added " + mmsToAdd.size() + " MMS, deleted "
+ messagesToDelete.size() + " messages.");
// TODO: Investigate whether we can make this more fine-grained.
MessagingContentProvider.notifyEverythingChanged();
} else {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: No local database updates to make");
}
if (!syncManager.getHasFirstSyncCompleted()) {
// If we have never completed a sync before (fresh install) and there are
// no messages, still inform the UI of a change so it can update syncing
// messages shown to the user
MessagingContentProvider.notifyConversationListChanged();
MessagingContentProvider.notifyPartsChanged();
}
}
// Determine if there are more messages that need to be scanned
if (lastTimestampMillis >= 0 && lastTimestampMillis >= lowerBoundTimeMillis) {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: More messages to sync; scheduling next "
+ "sync batch now.");
}
// Include final millisecond of last sync in next sync
final long newUpperBoundTimeMillis = lastTimestampMillis + 1;
final int newMaxMessagesToUpdate = nextBatchSize(messagesUpdated,
txnTimeMillis);
final SyncMessagesAction nextBatch =
new SyncMessagesAction(lowerBoundTimeMillis, newUpperBoundTimeMillis,
newMaxMessagesToUpdate, startTimestamp);
// Proceed with next batch
syncManager.startSyncBatch(newUpperBoundTimeMillis);
requestBackgroundWork(nextBatch);
} else {
final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
// Save sync completion time so next sync will start from here
prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, startTimestamp);
if (lowerBoundTimeMillis < 0) {
// Remember last full sync so that don't start another full sync right away
prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, startTimestamp);
}
final long now = System.currentTimeMillis();
// After any sync check if new messages have arrived
final SyncCursorPair recents = new SyncCursorPair(startTimestamp, now);
final SyncCursorPair olders = new SyncCursorPair(-1L, startTimestamp);
final DatabaseWrapper db = DataModel.get().getDatabase();
if (!recents.isSynchronized(db)) {
LogUtil.i(TAG, "SyncMessagesAction: Changed messages after sync; "
+ "scheduling an incremental sync now.");
// Just add a new batch for recent messages
final SyncMessagesAction nextBatch =
new SyncMessagesAction(startTimestamp, now, 0, startTimestamp);
syncManager.startSyncBatch(now);
requestBackgroundWork(nextBatch);
// After partial sync verify sync state
} else if (lowerBoundTimeMillis >= 0 && !olders.isSynchronized(db)) {
// Add a batch going back to start of time
LogUtil.w(TAG, "SyncMessagesAction: Changed messages before sync batch; "
+ "scheduling a full sync now.");
final SyncMessagesAction nextBatch =
new SyncMessagesAction(-1L, startTimestamp, 0, startTimestamp);
syncManager.startSyncBatch(startTimestamp);
requestBackgroundWork(nextBatch);
} else {
LogUtil.i(TAG, "SyncMessagesAction: All messages now in sync");
// All done, in sync
syncManager.complete();
}
}
// Either sync should be complete or we should have a follow up request
Assert.isTrue(hasBackgroundActions() || !syncManager.isSyncing());
}
}
return null;
}
/**
* Decide the next batch size based on the stats we collected with past batch
* @param messagesUpdated number of messages updated in this batch
* @param txnTimeMillis time the transaction took in ms
* @return Target number of messages to sync for next batch
*/
private static int nextBatchSize(final int messagesUpdated, final long txnTimeMillis) {
final long smsSyncSubsequentBatchTimeLimitMillis =
BugleGservicesKeys.SMS_SYNC_BATCH_TIME_LIMIT_MILLIS_DEFAULT;
if (txnTimeMillis <= 0) {
return 0;
}
// Number of messages we can sync within the batch time limit using
// the average sync time calculated based on the stats we collected
// in previous batch
return (int) ((double) (messagesUpdated) / (double) txnTimeMillis
* smsSyncSubsequentBatchTimeLimitMillis);
}
/**
* Batch loading MMS parts for the messages in current batch
*/
private void loadMmsParts(final LongSparseArray<MmsMessage> mmses) {
final Context context = Factory.get().getApplicationContext();
final int totalIds = mmses.size();
for (int start = 0; start < totalIds; start += MmsUtils.MAX_IDS_PER_QUERY) {
final int end = Math.min(start + MmsUtils.MAX_IDS_PER_QUERY, totalIds); //excluding
final int count = end - start;
final String batchSelection = String.format(
Locale.US,
"%s != '%s' AND %s IN %s",
Mms.Part.CONTENT_TYPE,
ContentType.APP_SMIL,
Mms.Part.MSG_ID,
MmsUtils.getSqlInOperand(count));
final String[] batchSelectionArgs = new String[count];
for (int i = 0; i < count; i++) {
batchSelectionArgs[i] = Long.toString(mmses.valueAt(start + i).getId());
}
final Cursor cursor = SqliteWrapper.query(
context,
context.getContentResolver(),
MmsUtils.MMS_PART_CONTENT_URI,
DatabaseMessages.MmsPart.PROJECTION,
batchSelection,
batchSelectionArgs,
null/*sortOrder*/);
if (cursor != null) {
try {
while (cursor.moveToNext()) {
// Delay loading the media content for parsing for efficiency
// TODO: load the media and fill in the dimensions when
// we actually display it
final DatabaseMessages.MmsPart part =
DatabaseMessages.MmsPart.get(cursor, false/*loadMedia*/);
final DatabaseMessages.MmsMessage mms = mmses.get(part.mMessageId);
if (mms != null) {
mms.addPart(part);
}
}
} finally {
cursor.close();
}
}
}
}
/**
* Batch loading MMS sender for the messages in current batch
*/
private void setMmsSenders(final LongSparseArray<MmsMessage> mmses,
final ThreadInfoCache cache) {
// Store all the MMS messages
for (int i = 0; i < mmses.size(); i++) {
final MmsMessage mms = mmses.valueAt(i);
final boolean isOutgoing = mms.mType != Mms.MESSAGE_BOX_INBOX;
String senderId = null;
if (!isOutgoing) {
// We only need to find out sender phone number for received message
senderId = getMmsSender(mms, cache);
if (senderId == null) {
LogUtil.w(TAG, "SyncMessagesAction: Could not find sender of incoming MMS "
+ "message " + mms.getUri() + "; using 'unknown sender' instead");
senderId = ParticipantData.getUnknownSenderDestination();
}
}
mms.setSender(senderId);
}
}
/**
* Find out the sender of an MMS message
*/
private String getMmsSender(final MmsMessage mms, final ThreadInfoCache cache) {
final List<String> recipients = cache.getThreadRecipients(mms.mThreadId);
Assert.notNull(recipients);
Assert.isTrue(recipients.size() > 0);
if (recipients.size() == 1
&& recipients.get(0).equals(ParticipantData.getUnknownSenderDestination())) {
LogUtil.w(TAG, "SyncMessagesAction: MMS message " + mms.mUri + " has unknown sender "
+ "(thread id = " + mms.mThreadId + ")");
}
return MmsUtils.getMmsSender(recipients, mms.mUri);
}
private SyncMessagesAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<SyncMessagesAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public SyncMessagesAction createFromParcel(final Parcel in) {
return new SyncMessagesAction(in);
}
@Override
public SyncMessagesAction[] newArray(final int size) {
return new SyncMessagesAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,96 @@
/*
* 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.action;
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.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.util.Assert;
public class UpdateConversationArchiveStatusAction extends Action {
public static void archiveConversation(final String conversationId) {
final UpdateConversationArchiveStatusAction action =
new UpdateConversationArchiveStatusAction(conversationId, true /* isArchive */);
action.start();
}
public static void unarchiveConversation(final String conversationId) {
final UpdateConversationArchiveStatusAction action =
new UpdateConversationArchiveStatusAction(conversationId, false /* isArchive */);
action.start();
}
private static final String KEY_CONVERSATION_ID = "conversation_id";
private static final String KEY_IS_ARCHIVE = "is_archive";
protected UpdateConversationArchiveStatusAction(
final String conversationId, final boolean isArchive) {
Assert.isTrue(!TextUtils.isEmpty(conversationId));
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
actionParameters.putBoolean(KEY_IS_ARCHIVE, isArchive);
}
@Override
protected Object executeAction() {
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final boolean isArchived = actionParameters.getBoolean(KEY_IS_ARCHIVE);
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
BugleDatabaseOperations.updateConversationArchiveStatusInTransaction(
db, conversationId, isArchived);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
MessagingContentProvider.notifyConversationListChanged();
MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
return null;
}
protected UpdateConversationArchiveStatusAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<UpdateConversationArchiveStatusAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public UpdateConversationArchiveStatusAction createFromParcel(final Parcel in) {
return new UpdateConversationArchiveStatusAction(in);
}
@Override
public UpdateConversationArchiveStatusAction[] newArray(final int size) {
return new UpdateConversationArchiveStatusAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,151 @@
/*
* 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.action;
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.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.util.Assert;
public class UpdateDestinationBlockedAction extends Action {
public interface UpdateDestinationBlockedActionListener {
@Assert.RunsOnMainThread
void onUpdateDestinationBlockedAction(final UpdateDestinationBlockedAction action,
final boolean success,
final boolean block,
final String destination);
}
public static class UpdateDestinationBlockedActionMonitor extends ActionMonitor
implements ActionMonitor.ActionCompletedListener {
private final UpdateDestinationBlockedActionListener mListener;
public UpdateDestinationBlockedActionMonitor(
Object data, UpdateDestinationBlockedActionListener mListener) {
super(STATE_CREATED, generateUniqueActionKey("UpdateDestinationBlockedAction"), data);
setCompletedListener(this);
this.mListener = mListener;
}
private void onActionDone(final boolean succeeded,
final ActionMonitor monitor,
final Action action,
final Object data,
final Object result) {
mListener.onUpdateDestinationBlockedAction(
(UpdateDestinationBlockedAction) action,
succeeded,
action.actionParameters.getBoolean(KEY_BLOCKED),
action.actionParameters.getString(KEY_DESTINATION));
}
@Override
public void onActionSucceeded(final ActionMonitor monitor,
final Action action,
final Object data,
final Object result) {
onActionDone(true, monitor, action, data, result);
}
@Override
public void onActionFailed(final ActionMonitor monitor,
final Action action,
final Object data,
final Object result) {
onActionDone(false, monitor, action, data, result);
}
}
public static UpdateDestinationBlockedActionMonitor updateDestinationBlocked(
final String destination, final boolean blocked, final String conversationId,
final UpdateDestinationBlockedActionListener listener) {
Assert.notNull(listener);
final UpdateDestinationBlockedActionMonitor monitor =
new UpdateDestinationBlockedActionMonitor(null, listener);
final UpdateDestinationBlockedAction action =
new UpdateDestinationBlockedAction(destination, blocked, conversationId,
monitor.getActionKey());
action.start(monitor);
return monitor;
}
private static final String KEY_CONVERSATION_ID = "conversation_id";
private static final String KEY_DESTINATION = "destination";
private static final String KEY_BLOCKED = "blocked";
protected UpdateDestinationBlockedAction(
final String destination, final boolean blocked, final String conversationId,
final String actionKey) {
super(actionKey);
Assert.isTrue(!TextUtils.isEmpty(destination));
actionParameters.putString(KEY_DESTINATION, destination);
actionParameters.putBoolean(KEY_BLOCKED, blocked);
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
}
@Override
protected Object executeAction() {
final String destination = actionParameters.getString(KEY_DESTINATION);
final boolean isBlocked = actionParameters.getBoolean(KEY_BLOCKED);
String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final DatabaseWrapper db = DataModel.get().getDatabase();
BugleDatabaseOperations.updateDestination(db, destination, isBlocked);
if (conversationId == null) {
conversationId = BugleDatabaseOperations
.getConversationFromOtherParticipantDestination(db, destination);
}
if (conversationId != null) {
if (isBlocked) {
UpdateConversationArchiveStatusAction.archiveConversation(conversationId);
} else {
UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId);
}
MessagingContentProvider.notifyParticipantsChanged(conversationId);
}
return null;
}
protected UpdateDestinationBlockedAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<UpdateDestinationBlockedAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public UpdateDestinationBlockedAction createFromParcel(final Parcel in) {
return new UpdateDestinationBlockedAction(in);
}
@Override
public UpdateDestinationBlockedAction[] newArray(final int size) {
return new UpdateDestinationBlockedAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,66 @@
/*
* 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.action;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import com.android.messaging.datamodel.BugleNotifications;
/**
* Updates the message notification (generally, to include voice replies we've
* made since the notification was first posted).
*/
public class UpdateMessageNotificationAction extends Action {
public static void updateMessageNotification() {
new UpdateMessageNotificationAction().start();
}
private UpdateMessageNotificationAction() {
}
@Override
protected Object executeAction() {
BugleNotifications.update(true /* silent */, BugleNotifications.UPDATE_MESSAGES);
return null;
}
private UpdateMessageNotificationAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<UpdateMessageNotificationAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public UpdateMessageNotificationAction createFromParcel(final Parcel in) {
return new UpdateMessageNotificationAction(in);
}
@Override
public UpdateMessageNotificationAction[] newArray(final int size) {
return new UpdateMessageNotificationAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,106 @@
/*
* 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.action;
import android.content.ContentValues;
import android.os.Parcel;
import android.os.Parcelable;
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.DatabaseWrapper;
import com.android.messaging.datamodel.DatabaseHelper.PartColumns;
import com.android.messaging.util.Assert;
/**
* Action used to update size fields of a single part
*/
public class UpdateMessagePartSizeAction extends Action implements Parcelable {
/**
* Update size of part
*/
public static void updateSize(final String partId, final int width, final int height) {
Assert.notNull(partId);
Assert.inRange(width, 0, Integer.MAX_VALUE);
Assert.inRange(height, 0, Integer.MAX_VALUE);
final UpdateMessagePartSizeAction action = new UpdateMessagePartSizeAction(
partId, width, height);
action.start();
}
private static final String KEY_PART_ID = "part_id";
private static final String KEY_WIDTH = "width";
private static final String KEY_HEIGHT = "height";
private UpdateMessagePartSizeAction(final String partId, final int width, final int height) {
actionParameters.putString(KEY_PART_ID, partId);
actionParameters.putInt(KEY_WIDTH, width);
actionParameters.putInt(KEY_HEIGHT, height);
}
@Override
protected Object executeAction() {
final String partId = actionParameters.getString(KEY_PART_ID);
final int width = actionParameters.getInt(KEY_WIDTH);
final int height = actionParameters.getInt(KEY_HEIGHT);
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
final ContentValues values = new ContentValues();
values.put(PartColumns.WIDTH, width);
values.put(PartColumns.HEIGHT, height);
// Part may have been deleted so allow update to fail without asserting
BugleDatabaseOperations.updateRowIfExists(db, DatabaseHelper.PARTS_TABLE,
PartColumns._ID, partId, values);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
return null;
}
private UpdateMessagePartSizeAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<UpdateMessagePartSizeAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public UpdateMessagePartSizeAction createFromParcel(final Parcel in) {
return new UpdateMessagePartSizeAction(in);
}
@Override
public UpdateMessagePartSizeAction[] newArray(final int size) {
return new UpdateMessagePartSizeAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,107 @@
/*
* 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.action;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
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.datamodel.data.ConversationListItemData;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.LogUtil;
public class WriteDraftMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
/**
* Set draft message (no listener)
*/
public static void writeDraftMessage(final String conversationId, final MessageData message) {
final WriteDraftMessageAction action = new WriteDraftMessageAction(conversationId, message);
action.start();
}
private static final String KEY_CONVERSATION_ID = "conversationId";
private static final String KEY_MESSAGE = "message";
private WriteDraftMessageAction(final String conversationId, final MessageData message) {
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
actionParameters.putParcelable(KEY_MESSAGE, message);
}
@Override
protected Object executeAction() {
final DatabaseWrapper db = DataModel.get().getDatabase();
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final MessageData message = actionParameters.getParcelable(KEY_MESSAGE, MessageData.class);
if (message.getSelfId() == null || message.getParticipantId() == null) {
// This could happen when this occurs before the draft message is loaded
// In this case, we just use the conversation's current self id as draft's
// self id and/or participant id
final ConversationListItemData conversation =
ConversationListItemData.getExistingConversation(db, conversationId);
if (conversation != null) {
final String senderAndSelf = conversation.getSelfId();
if (message.getSelfId() == null) {
message.bindSelfId(senderAndSelf);
}
if (message.getParticipantId() == null) {
message.bindParticipantId(senderAndSelf);
}
} else {
LogUtil.w(LogUtil.BUGLE_DATAMODEL_TAG, "Conversation " + conversationId +
"already deleted before saving draft message " +
message.getMessageId() + ". Aborting WriteDraftMessageAction.");
return null;
}
}
// Drafts are only kept in the local DB...
final String messageId = BugleDatabaseOperations.updateDraftMessageData(
db, conversationId, message, BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT);
MessagingContentProvider.notifyConversationListChanged();
MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
return messageId;
}
private WriteDraftMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<WriteDraftMessageAction> CREATOR
= new Parcelable.Creator<>() {
@Override
public WriteDraftMessageAction createFromParcel(final Parcel in) {
return new WriteDraftMessageAction(in);
}
@Override
public WriteDraftMessageAction[] newArray(final int size) {
return new WriteDraftMessageAction[size];
}
};
@Override
public void writeToParcel(@NonNull final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,72 @@
/*
* Copyright (C) 2015 The Android Open Source 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.binding;
/**
* Base class for data objects that will be bound to a piece of the UI
*/
public abstract class BindableData {
/**
* Called by Binding during unbind to allow data to proactively unregister callbacks
* Data instance should release all listeners that may call back to the host UI
*/
protected abstract void unregisterListeners();
/**
* Key used to identify the piece of UI that the data is currently bound to
*/
private String mBindingId;
/**
* Bind this data to the ui host - checks data is currently unbound
*/
public void bind(final String bindingId) {
if (isBound() || bindingId == null) {
throw new IllegalStateException();
}
mBindingId = bindingId;
}
/**
* Unbind this data from the ui host - checks that the data is currently bound to specified id
*/
public void unbind(final String bindingId) {
if (!isBound(bindingId)) {
throw new IllegalStateException();
}
unregisterListeners();
mBindingId = null;
}
/**
* Check to see if the data is bound to anything
*
* TODO: This should be package private because it's supposed to only be used by Binding,
* however, several classes call this directly. We want the classes to track what they are
* bound to.
*/
protected boolean isBound() {
return (mBindingId != null);
}
/**
* Check to see if data is still bound with specified bindingId before calling over to ui
*/
public boolean isBound(final String bindingId) {
return (bindingId.equals(mBindingId));
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (C) 2015 The Android Open Source 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.binding;
/**
* A BindableData that's only used to be bound once. If the client needs to rebind, it needs
* to create a new instance of the BindableOnceData.
*/
public abstract class BindableOnceData extends BindableData {
private boolean boundOnce = false;
@Override
public void bind(final String bindingId) {
// Ensures that we can't re-bind again after the first binding.
if (boundOnce) {
throw new IllegalStateException();
}
super.bind(bindingId);
boundOnce = true;
}
/**
* Checks if the instance is bound to anything.
*/
@Override
public boolean isBound() {
return super.isBound();
}
}
@@ -0,0 +1,95 @@
/*
* 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.binding;
import java.util.concurrent.atomic.AtomicLong;
public class Binding<T extends BindableData> extends BindingBase<T> {
private static final AtomicLong sBindingIdx = new AtomicLong(System.currentTimeMillis() * 1000);
private String mBindingId;
private T mData;
private final Object mOwner;
private boolean mWasBound;
/**
* Initialize a binding instance - the owner is typically the containing class
*/
Binding(final Object owner) {
mOwner = owner;
}
@Override
public T getData() {
ensureBound();
return mData;
}
@Override
public boolean isBound() {
return (mData != null && mData.isBound(mBindingId));
}
@Override
public boolean isBound(final T data) {
return (isBound() && data == mData);
}
@Override
public void ensureBound() {
if (!isBound()) {
throw new IllegalStateException("not bound; wasBound = " + mWasBound);
}
}
@Override
public void ensureBound(final T data) {
if (!isBound()) {
throw new IllegalStateException("not bound; wasBound = " + mWasBound);
} else if (data != mData) {
throw new IllegalStateException("not bound to correct data " + data + " vs " + mData);
}
}
@Override
public String getBindingId() {
return mBindingId;
}
public void bind(final T data) {
// Check both this binding and the data not already bound
if (mData != null || data.isBound()) {
throw new IllegalStateException("already bound when binding to " + data);
}
// Generate a unique identifier for this bind call
mBindingId = Long.toHexString(sBindingIdx.getAndIncrement());
data.bind(mBindingId);
mData = data;
mWasBound = true;
}
public void unbind() {
// Check this binding is bound and that data is bound to this binding
if (mData == null || !mData.isBound(mBindingId)) {
throw new IllegalStateException("not bound when unbind");
}
mData.unbind(mBindingId);
mData = null;
mBindingId = null;
}
}
@@ -0,0 +1,85 @@
/*
* 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.binding;
/*
* The binding class keeps track of a binding between a ui component and an item of BindableData
* It allows each side to ensure that when it communicates with the other they are still bound
* together.
* NOTE: Ensure that the UI component uses the same binding instance for it's whole lifetime
* (DO NOT CREATE A NEW BINDING EACH TIME A NEW PIECE OF DATA IS BOUND)...
*
* The ui component owns the binding instance.
* It can use it [isBound(data)] to see if the binding still binds to the right piece of data
*
* Upon binding the data is informed of a unique binding key generated in this class and can use
* that to ensure that it is still issuing callbacks to the right piece of ui.
*/
public abstract class BindingBase<T extends BindableData> {
/**
* Creates a new exclusively owned binding for the owner object.
*/
public static <T extends BindableData> Binding<T> createBinding(final Object owner) {
return new Binding<>(owner);
}
/**
* Creates a new read-only binding referencing the source binding object.
* TODO: We may want to refcount the Binding references, so that when the binding owner
* calls unbind() when there's still outstanding references we can catch it.
*/
public static <T extends BindableData> ImmutableBindingRef<T> createBindingReference(
final BindingBase<T> srcBinding) {
return new ImmutableBindingRef<>(srcBinding);
}
/**
* Creates a detachable binding for the owner object. Use this if your owner object is a UI
* component that may undergo a "detached from window" -> "re-attached to window" transition.
*/
public static <T extends BindableData> DetachableBinding<T> createDetachableBinding(
final Object owner) {
return new DetachableBinding<>(owner);
}
public abstract T getData();
/**
* Check if binding connects to the specified data instance
*/
public abstract boolean isBound();
/**
* Check if binding connects to the specified data instance
*/
public abstract boolean isBound(final T data);
/**
* Throw if binding connects to the specified data instance
*/
public abstract void ensureBound();
/**
* Throw if binding connects to the specified data instance
*/
public abstract void ensureBound(final T data);
/**
* Return the binding id for this binding (will be null if not bound)
*/
public abstract String getBindingId();
}
@@ -0,0 +1,56 @@
/*
* Copyright (C) 2015 The Android Open Source 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.binding;
import com.android.messaging.util.Assert;
/**
* An extension on {@link Binding} that allows for temporary data detachment from the UI component.
* This is used when, instead of destruction or data rebinding, the owning UI undergoes a
* "detached from window" -> "re-attached to window" transition, in which case we want to
* temporarily unbind the data and remember it so that it can be rebound when the UI is re-attached
* to window later.
*/
public class DetachableBinding<T extends BindableData> extends Binding<T> {
private T mDetachedData;
DetachableBinding(Object owner) {
super(owner);
}
@Override
public void bind(T data) {
super.bind(data);
// Rebinding before re-attaching. Pre-emptively throw away the detached data because
// it's now stale.
mDetachedData = null;
}
public void detach() {
Assert.isNull(mDetachedData);
Assert.isTrue(isBound());
mDetachedData = getData();
unbind();
}
public void reAttachIfPossible() {
if (mDetachedData != null) {
Assert.isFalse(isBound());
bind(mDetachedData);
mDetachedData = null;
}
}
}
@@ -0,0 +1,83 @@
/*
* Copyright (C) 2015 The Android Open Source 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.binding;
import com.android.messaging.util.Assert;
/**
* A immutable wrapper around a Binding object. Callers can only access readonly methods like
* getData(), isBound() and ensureBound() but not bind() and unbind(). This is used for MVC pattern
* where both the View and the Controller needs access to a centrally bound Model object. The View
* is the one that owns the bind/unbind logic of the Binding, whereas controller only serves as a
* consumer.
*/
public class ImmutableBindingRef<T extends BindableData> extends BindingBase<T> {
/**
* The referenced, read-only binding object.
*/
private final BindingBase<T> mBinding;
/**
* Hidden ctor.
*/
ImmutableBindingRef(final BindingBase<T> binding) {
mBinding = resolveBinding(binding);
}
@Override
public T getData() {
return mBinding.getData();
}
@Override
public boolean isBound() {
return mBinding.isBound();
}
@Override
public boolean isBound(final T data) {
return mBinding.isBound(data);
}
@Override
public void ensureBound() {
mBinding.ensureBound();
}
@Override
public void ensureBound(final T data) {
mBinding.ensureBound(data);
}
@Override
public String getBindingId() {
return mBinding.getBindingId();
}
/**
* Resolve the source binding to the real BindingImpl it's referencing. This avoids the
* redundancy of multiple wrapper calls when creating a binding reference from an existing
* binding reference.
*/
private BindingBase<T> resolveBinding(final BindingBase<T> binding) {
BindingBase<T> resolvedBinding = binding;
while (resolvedBinding instanceof ImmutableBindingRef<?>) {
resolvedBinding = ((ImmutableBindingRef<T>) resolvedBinding).mBinding;
}
Assert.isTrue(resolvedBinding instanceof Binding<?>);
return resolvedBinding;
}
}
@@ -0,0 +1,107 @@
/*
* 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 android.net.Uri;
import android.os.Bundle;
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.DatabaseHelper.ParticipantColumns;
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;
/**
* Services data needs for BlockedParticipantsFragment
*/
public class BlockedParticipantsData extends BindableData implements
LoaderManager.LoaderCallbacks<Cursor> {
public interface BlockedParticipantsDataListener {
void onBlockedParticipantsCursorUpdated(final Cursor cursor);
}
private static final String BINDING_ID = "bindingId";
private static final int BLOCKED_PARTICIPANTS_LOADER = 1;
private final Context mContext;
private LoaderManager mLoaderManager;
private BlockedParticipantsDataListener mListener;
public BlockedParticipantsData(final Context context,
final BlockedParticipantsDataListener listener) {
mContext = context;
mListener = listener;
}
@NonNull
@Override
public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
Assert.isTrue(id == BLOCKED_PARTICIPANTS_LOADER);
final String bindingId = args.getString(BINDING_ID);
// Check if data still bound to the requesting ui element
if (isBound(bindingId)) {
final Uri uri = MessagingContentProvider.PARTICIPANTS_URI;
return new BoundCursorLoader(bindingId, mContext, uri,
ParticipantData.ParticipantsQuery.PROJECTION,
ParticipantColumns.BLOCKED + "=1", null, null);
}
return null;
}
@Override
public void onLoadFinished(final Loader<Cursor> loader, final Cursor cursor) {
Assert.isTrue(loader.getId() == BLOCKED_PARTICIPANTS_LOADER);
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
Assert.isTrue(isBound(cursorLoader.getBindingId()));
mListener.onBlockedParticipantsCursorUpdated(cursor);
}
@Override
public void onLoaderReset(final Loader<Cursor> loader) {
Assert.isTrue(loader.getId() == BLOCKED_PARTICIPANTS_LOADER);
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
Assert.isTrue(isBound(cursorLoader.getBindingId()));
mListener.onBlockedParticipantsCursorUpdated(null);
}
public void init(final LoaderManager loaderManager,
final BindingBase<BlockedParticipantsData> binding) {
final Bundle args = new Bundle();
args.putString(BINDING_ID, binding.getBindingId());
mLoaderManager = loaderManager;
mLoaderManager.initLoader(BLOCKED_PARTICIPANTS_LOADER, args, this);
}
@Override
protected void unregisterListeners() {
mListener = null;
if (mLoaderManager != null) {
mLoaderManager.destroyLoader(BLOCKED_PARTICIPANTS_LOADER);
mLoaderManager = null;
}
}
public ParticipantListItemData createParticipantListItemData(Cursor cursor) {
return new ParticipantListItemData(ParticipantData.getFromCursor(cursor));
}
}
@@ -0,0 +1,174 @@
/*
* Copyright (C) 2015 The Android Open Source 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.database.Cursor;
import android.net.Uri;
import android.provider.ContactsContract.DisplayNameSources;
import com.android.ex.chips.RecipientEntry;
import com.android.messaging.util.Assert;
import com.android.messaging.util.ContactRecipientEntryUtils;
import com.android.messaging.util.ContactUtil;
/**
* Data model object used to power ContactListItemViews, which may be displayed either in
* our contact list, or in the chips UI search drop down presented by ContactDropdownLayouter.
*/
public class ContactListItemData {
// Keeps the contact data in the form of RecipientEntry that RecipientEditTextView can
// directly use.
private RecipientEntry mRecipientEntry;
private CharSequence mStyledName;
private CharSequence mStyledDestination;
// If this contact is the first in the list for its first letter, then this will be the
// first letter, otherwise this is null.
private String mAlphabetHeader;
// Is the contact the only item in the list (happens when the user clicks on an
// existing chip for which we show full contact detail for the selected contact).
private boolean mSingleRecipient;
// Is the contact in managed profile.
private boolean mIsWorkContact;
/**
* Bind to a contact cursor in the contact list.
*/
public void bind(final Cursor cursor, final String alphabetHeader) {
final long dataId = cursor.getLong(ContactUtil.INDEX_DATA_ID);
final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID);
final String lookupKey = cursor.getString(ContactUtil.INDEX_LOOKUP_KEY);
final String displayName = cursor.getString(ContactUtil.INDEX_DISPLAY_NAME);
final String photoThumbnailUri = cursor.getString(ContactUtil.INDEX_PHOTO_URI);
final String destination = cursor.getString(ContactUtil.INDEX_PHONE_EMAIL);
final int destinationType = cursor.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE);
final String destinationLabel = cursor.getString(ContactUtil.INDEX_PHONE_EMAIL_LABEL);
mStyledName = null;
mStyledDestination = null;
mAlphabetHeader = alphabetHeader;
mSingleRecipient = false;
// Check whether this contact is first level (i.e. whether it's the first entry of this
// contact in the contact list).
boolean isFirstLevel = true;
if (!cursor.isFirst() && cursor.moveToPrevious()) {
final long contactIdPrevious = cursor.getLong(ContactUtil.INDEX_CONTACT_ID);
if (contactId == contactIdPrevious) {
isFirstLevel = false;
}
cursor.moveToNext();
}
mRecipientEntry = ContactUtil.createRecipientEntry(displayName,
DisplayNameSources.STRUCTURED_NAME, destination, destinationType, destinationLabel,
contactId, lookupKey, dataId, photoThumbnailUri, isFirstLevel);
mIsWorkContact = ContactUtil.isEnterpriseContactId(contactId);
}
/**
* Bind to a RecipientEntry produced by the chips text view in the search drop down, plus
* optional styled name & destination for showing bold search match.
*/
public void bind(final RecipientEntry entry, final CharSequence styledName,
final CharSequence styledDestination, final boolean singleRecipient,
final boolean isWorkContact) {
Assert.isTrue(entry.isValid());
mRecipientEntry = entry;
mStyledName = styledName;
mStyledDestination = styledDestination;
mAlphabetHeader = null;
mSingleRecipient = singleRecipient;
mIsWorkContact = isWorkContact;
}
public CharSequence getDisplayName() {
final CharSequence displayName = mStyledName != null ? mStyledName :
ContactRecipientEntryUtils.getDisplayNameForContactList(mRecipientEntry);
return displayName == null ? "" : displayName;
}
public Uri getPhotoThumbnailUri() {
return mRecipientEntry.getPhotoThumbnailUri() == null ? null :
mRecipientEntry.getPhotoThumbnailUri();
}
public CharSequence getDestination() {
final CharSequence destination = mStyledDestination != null ?
mStyledDestination : ContactRecipientEntryUtils.formatDestination(mRecipientEntry);
return destination == null ? "" : destination;
}
public int getDestinationType() {
return mRecipientEntry.getDestinationType();
}
public String getDestinationLabel() {
return mRecipientEntry.getDestinationLabel();
}
public long getContactId() {
return mRecipientEntry.getContactId();
}
public String getLookupKey() {
return mRecipientEntry.getLookupKey();
}
/**
* Returns if this item is "first-level," i.e. whether it's the first entry of the contact
* that it represents in the list. For example, if John Smith has 3 different phone numbers,
* then the first number is considered first-level, while the other two are considered
* second-level.
*/
public boolean getIsFirstLevel() {
// Treat the item as first level if it's a top-level recipient entry, or if it's the only
// item in the list.
return mRecipientEntry.isFirstLevel() || mSingleRecipient;
}
/**
* Returns if this item is simple, i.e. it has only avatar and a display name with phone number
* embedded so we can hide everything else.
*/
public boolean getIsSimpleContactItem() {
return ContactRecipientEntryUtils.isAvatarAndNumberOnlyContact(mRecipientEntry) ||
ContactRecipientEntryUtils.isSendToDestinationContact(mRecipientEntry);
}
public String getAlphabetHeader() {
return mAlphabetHeader;
}
/**
* Returns a RecipientEntry instance readily usable by the RecipientEditTextView.
*/
public RecipientEntry getRecipientEntry() {
return mRecipientEntry;
}
/**
* @return whether the contact is in managed profile.
*/
public boolean getIsWorkContact() {
return mIsWorkContact;
}
}
@@ -0,0 +1,201 @@
/*
* 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 android.os.Bundle;
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.FrequentContactsCursorBuilder;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.binding.BindableData;
import com.android.messaging.datamodel.binding.BindingBase;
import com.android.messaging.sms.MmsConfig;
import com.android.messaging.util.Assert;
import com.android.messaging.util.ContactUtil;
import com.android.messaging.util.LogUtil;
/**
* Class to access phone contacts.
* The caller is responsible for ensuring that the app has READ_CONTACTS permission (see
* {@link ContactUtil#hasReadContactsPermission()}) before instantiating this class.
*/
public class ContactPickerData extends BindableData implements
LoaderManager.LoaderCallbacks<Cursor> {
public interface ContactPickerDataListener {
void onAllContactsCursorUpdated(Cursor data);
void onFrequentContactsCursorUpdated(Cursor data);
void onContactCustomColorLoaded(ContactPickerData data);
}
private static final String BINDING_ID = "bindingId";
private final Context mContext;
private LoaderManager mLoaderManager;
private ContactPickerDataListener mListener;
private final FrequentContactsCursorBuilder mFrequentContactsCursorBuilder;
public ContactPickerData(final Context context, final ContactPickerDataListener listener) {
mListener = listener;
mContext = context;
mFrequentContactsCursorBuilder = new FrequentContactsCursorBuilder();
}
private static final int ALL_CONTACTS_LOADER = 1;
private static final int FREQUENT_CONTACTS_LOADER = 2;
private static final int PARTICIPANT_LOADER = 3;
@NonNull
@Override
public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
final String bindingId = args.getString(BINDING_ID);
// Check if data still bound to the requesting ui element
if (isBound(bindingId)) {
switch (id) {
case ALL_CONTACTS_LOADER:
return ContactUtil.getPhones(mContext)
.createBoundCursorLoader(bindingId);
case FREQUENT_CONTACTS_LOADER:
return ContactUtil.getFrequentContacts(mContext)
.createBoundCursorLoader(bindingId);
case PARTICIPANT_LOADER:
return new BoundCursorLoader(bindingId, mContext,
MessagingContentProvider.PARTICIPANTS_URI,
ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
default:
Assert.fail("Unknown loader id for contact picker!");
break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding the contacts list");
}
return null;
}
/**
* {@inheritDoc}
*/
@Override
public void onLoadFinished(@NonNull final Loader<Cursor> loader, final Cursor data) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId())) {
switch (loader.getId()) {
case ALL_CONTACTS_LOADER:
mFrequentContactsCursorBuilder.setAllContacts(data);
break;
case FREQUENT_CONTACTS_LOADER:
mFrequentContactsCursorBuilder.setFrequents(data);
break;
case PARTICIPANT_LOADER:
mListener.onContactCustomColorLoaded(this);
break;
default:
Assert.fail("Unknown loader id for contact picker!");
break;
}
if (loader.getId() != PARTICIPANT_LOADER) {
// The frequent contacts cursor to be used in the UI depends on results from both
// all contacts and frequent contacts loader, and we don't know which will finish
// first. Therefore, try to build the cursor and notify the listener if it's
// successfully built.
final Cursor frequentContactsCursor = mFrequentContactsCursorBuilder.build(false);
if (frequentContactsCursor != null) {
mListener.onFrequentContactsCursorUpdated(frequentContactsCursor);
final Cursor allContactsCursor = mFrequentContactsCursorBuilder.build(true);
mListener.onAllContactsCursorUpdated(allContactsCursor);
}
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader finished after unbinding the contacts list");
}
}
/**
* {@inheritDoc}
*/
@Override
public void onLoaderReset(final Loader<Cursor> loader) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId())) {
switch (loader.getId()) {
case ALL_CONTACTS_LOADER:
mListener.onAllContactsCursorUpdated(null);
mFrequentContactsCursorBuilder.setAllContacts(null);
break;
case FREQUENT_CONTACTS_LOADER:
mListener.onFrequentContactsCursorUpdated(null);
mFrequentContactsCursorBuilder.setFrequents(null);
break;
case PARTICIPANT_LOADER:
mListener.onContactCustomColorLoaded(this);
break;
default:
Assert.fail("Unknown loader id for contact picker!");
break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding the contacts list");
}
}
public void init(final LoaderManager loaderManager,
final BindingBase<ContactPickerData> binding) {
final Bundle args = new Bundle();
args.putString(BINDING_ID, binding.getBindingId());
mLoaderManager = loaderManager;
mLoaderManager.initLoader(ALL_CONTACTS_LOADER, args, this);
mLoaderManager.initLoader(FREQUENT_CONTACTS_LOADER, args, this);
mLoaderManager.initLoader(PARTICIPANT_LOADER, args, this);
}
@Override
protected void unregisterListeners() {
mListener = null;
// This could be null if we bind but the caller doesn't init the BindableData
if (mLoaderManager != null) {
mLoaderManager.destroyLoader(ALL_CONTACTS_LOADER);
mLoaderManager.destroyLoader(FREQUENT_CONTACTS_LOADER);
mLoaderManager.destroyLoader(PARTICIPANT_LOADER);
mLoaderManager = null;
}
mFrequentContactsCursorBuilder.resetBuilder();
}
public static boolean isTooManyParticipants(final int participantCount) {
// When creating a conversation, the conversation will be created using the system's
// default SIM, so use the default MmsConfig's recipient limit.
return (participantCount > MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID)
.getRecipientLimit());
}
public static boolean getCanAddMoreParticipants(final int participantCount) {
// When creating a conversation, the conversation will be created using the system's
// default SIM, so use the default MmsConfig's recipient limit.
return (participantCount < MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID)
.getRecipientLimit());
}
}
@@ -0,0 +1,803 @@
/*
* 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 android.database.CursorWrapper;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import com.android.messaging.Factory;
import com.android.messaging.R;
import com.android.messaging.datamodel.BoundCursorLoader;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.action.DeleteConversationAction;
import com.android.messaging.datamodel.action.DeleteMessageAction;
import com.android.messaging.datamodel.action.InsertNewMessageAction;
import com.android.messaging.datamodel.action.RedownloadMmsAction;
import com.android.messaging.datamodel.action.ResendMessageAction;
import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction;
import com.android.messaging.datamodel.binding.BindableData;
import com.android.messaging.datamodel.binding.Binding;
import com.android.messaging.datamodel.binding.BindingBase;
import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
import com.android.messaging.sms.MmsSmsUtils;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.RunsOnMainThread;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.PhoneUtils;
import com.android.messaging.widget.WidgetConversationProvider;
import java.util.ArrayList;
import java.util.List;
public class ConversationData extends BindableData {
private static final String TAG = "bugle_datamodel";
private static final String BINDING_ID = "bindingId";
private static final long LAST_MESSAGE_TIMESTAMP_NaN = -1;
private static final int MESSAGE_COUNT_NaN = -1;
public interface ConversationDataListener {
void onConversationMessagesCursorUpdated(ConversationData data, Cursor cursor,
@Nullable ConversationMessageData newestMessage, boolean isSync);
void onConversationMetadataUpdated(ConversationData data);
void closeConversation(String conversationId);
void onConversationParticipantDataLoaded(ConversationData data);
void onSubscriptionListDataLoaded(ConversationData data);
}
private static class ReversedCursor extends CursorWrapper {
final int mCount;
public ReversedCursor(final Cursor cursor) {
super(cursor);
mCount = cursor.getCount();
}
@Override
public boolean moveToPosition(final int position) {
return super.moveToPosition(mCount - position - 1);
}
@Override
public int getPosition() {
return mCount - super.getPosition() - 1;
}
@Override
public boolean isAfterLast() {
return super.isBeforeFirst();
}
@Override
public boolean isBeforeFirst() {
return super.isAfterLast();
}
@Override
public boolean isFirst() {
return super.isLast();
}
@Override
public boolean isLast() {
return super.isFirst();
}
@Override
public boolean move(final int offset) {
return super.move(-offset);
}
@Override
public boolean moveToFirst() {
return super.moveToLast();
}
@Override
public boolean moveToLast() {
return super.moveToFirst();
}
@Override
public boolean moveToNext() {
return super.moveToPrevious();
}
@Override
public boolean moveToPrevious() {
return super.moveToNext();
}
}
/**
* A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times.
*/
private class MetadataLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
@NonNull
@Override
public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
Assert.equals(CONVERSATION_META_DATA_LOADER, id);
Loader<Cursor> loader = null;
final String bindingId = args.getString(BINDING_ID);
// Check if data still bound to the requesting ui element
if (isBound(bindingId)) {
final Uri uri =
MessagingContentProvider.buildConversationMetadataUri(mConversationId);
loader = new BoundCursorLoader(bindingId, mContext, uri,
ConversationListItemData.PROJECTION, null, null, null);
} else {
LogUtil.w(TAG, "Creating messages loader after unbinding mConversationId = " +
mConversationId);
}
return loader;
}
@Override
public void onLoadFinished(@NonNull final Loader<Cursor> generic, final Cursor data) {
final BoundCursorLoader loader = (BoundCursorLoader) generic;
// Check if data still bound to the requesting ui element
if (isBound(loader.getBindingId())) {
if (data.moveToNext()) {
Assert.isTrue(data.getCount() == 1);
mConversationMetadata.bind(data);
mListeners.onConversationMetadataUpdated(ConversationData.this);
} else {
// Close the conversation, no meta data means conversation was deleted
LogUtil.w(TAG, "Meta data loader returned nothing for mConversationId = " +
mConversationId);
mListeners.closeConversation(mConversationId);
// Notify the widget the conversation is deleted so it can go into its
// configure state.
WidgetConversationProvider.notifyConversationDeleted(
Factory.get().getApplicationContext(),
mConversationId);
}
} else {
LogUtil.w(TAG, "Meta data loader finished after unbinding mConversationId = " +
mConversationId);
}
}
@Override
public void onLoaderReset(@NonNull final Loader<Cursor> generic) {
final BoundCursorLoader loader = (BoundCursorLoader) generic;
// Check if data still bound to the requesting ui element
if (isBound(loader.getBindingId())) {
// Clear the conversation meta data
mConversationMetadata = new ConversationListItemData();
mListeners.onConversationMetadataUpdated(ConversationData.this);
} else {
LogUtil.w(TAG, "Meta data loader reset after unbinding mConversationId = " +
mConversationId);
}
}
}
/**
* A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times.
*/
private class MessagesLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
@NonNull
@Override
public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
Assert.equals(CONVERSATION_MESSAGES_LOADER, id);
Loader<Cursor> loader = null;
final String bindingId = args.getString(BINDING_ID);
// Check if data still bound to the requesting ui element
if (isBound(bindingId)) {
final Uri uri =
MessagingContentProvider.buildConversationMessagesUri(mConversationId);
loader = new BoundCursorLoader(bindingId, mContext, uri,
ConversationMessageData.getProjection(), null, null, null);
mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN;
mMessageCount = MESSAGE_COUNT_NaN;
} else {
LogUtil.w(TAG, "Creating messages loader after unbinding mConversationId = " +
mConversationId);
}
return loader;
}
@Override
public void onLoadFinished(@NonNull final Loader<Cursor> generic, final Cursor rawData) {
final BoundCursorLoader loader = (BoundCursorLoader) generic;
// Check if data still bound to the requesting ui element
if (isBound(loader.getBindingId())) {
// Check if we have a new message, or if we had a message sync.
ConversationMessageData newMessage = null;
boolean isSync = false;
Cursor data = null;
if (rawData != null) {
// Note that the cursor is sorted DESC so here we reverse it.
// This is a performance issue (improvement) for large cursors.
data = new ReversedCursor(rawData);
final int messageCountOld = mMessageCount;
mMessageCount = data.getCount();
final ConversationMessageData lastMessage = getLastMessage(data);
if (lastMessage != null) {
final long lastMessageTimestampOld = mLastMessageTimestamp;
mLastMessageTimestamp = lastMessage.getReceivedTimeStamp();
final String lastMessageIdOld = mLastMessageId;
mLastMessageId = lastMessage.getMessageId();
if (TextUtils.equals(lastMessageIdOld, mLastMessageId) &&
messageCountOld < mMessageCount) {
// Last message stays the same (no incoming message) but message
// count increased, which means there has been a message sync.
isSync = true;
} else if (messageCountOld != MESSAGE_COUNT_NaN && // Ignore initial load
mLastMessageTimestamp != LAST_MESSAGE_TIMESTAMP_NaN &&
mLastMessageTimestamp > lastMessageTimestampOld) {
newMessage = lastMessage;
}
} else {
mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN;
}
} else {
mMessageCount = MESSAGE_COUNT_NaN;
}
mListeners.onConversationMessagesCursorUpdated(ConversationData.this, data,
newMessage, isSync);
} else {
LogUtil.w(TAG, "Messages loader finished after unbinding mConversationId = " +
mConversationId);
}
}
@Override
public void onLoaderReset(@NonNull final Loader<Cursor> generic) {
final BoundCursorLoader loader = (BoundCursorLoader) generic;
// Check if data still bound to the requesting ui element
if (isBound(loader.getBindingId())) {
mListeners.onConversationMessagesCursorUpdated(ConversationData.this, null, null,
false);
mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN;
mMessageCount = MESSAGE_COUNT_NaN;
} else {
LogUtil.w(TAG, "Messages loader reset after unbinding mConversationId = " +
mConversationId);
}
}
private ConversationMessageData getLastMessage(final Cursor cursor) {
if (cursor != null && cursor.getCount() > 0) {
final int position = cursor.getPosition();
if (cursor.moveToLast()) {
final ConversationMessageData messageData = new ConversationMessageData();
messageData.bind(cursor);
cursor.move(position);
return messageData;
}
}
return null;
}
}
/**
* A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times.
*/
private class ParticipantLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
@NonNull
@Override
public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
Assert.equals(PARTICIPANT_LOADER, id);
Loader<Cursor> loader = null;
final String bindingId = args.getString(BINDING_ID);
// Check if data still bound to the requesting ui element
if (isBound(bindingId)) {
final Uri uri =
MessagingContentProvider.buildConversationParticipantsUri(mConversationId);
loader = new BoundCursorLoader(bindingId, mContext, uri,
ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
} else {
LogUtil.w(TAG, "Creating participant loader after unbinding mConversationId = " +
mConversationId);
}
return loader;
}
@Override
public void onLoadFinished(@NonNull final Loader<Cursor> generic, final Cursor data) {
final BoundCursorLoader loader = (BoundCursorLoader) generic;
// Check if data still bound to the requesting ui element
if (isBound(loader.getBindingId())) {
mParticipantData.bind(data);
mListeners.onConversationParticipantDataLoaded(ConversationData.this);
} else {
LogUtil.w(TAG, "Participant loader finished after unbinding mConversationId = " +
mConversationId);
}
}
@Override
public void onLoaderReset(@NonNull final Loader<Cursor> generic) {
final BoundCursorLoader loader = (BoundCursorLoader) generic;
// Check if data still bound to the requesting ui element
if (isBound(loader.getBindingId())) {
mParticipantData.bind(null);
} else {
LogUtil.w(TAG, "Participant loader reset after unbinding mConversationId = " +
mConversationId);
}
}
}
/**
* A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times.
*/
private class SelfParticipantLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
@NonNull
@Override
public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
Assert.equals(SELF_PARTICIPANT_LOADER, id);
Loader<Cursor> loader = null;
final String bindingId = args.getString(BINDING_ID);
// Check if data still bound to the requesting ui element
if (isBound(bindingId)) {
loader = new BoundCursorLoader(bindingId, mContext,
MessagingContentProvider.PARTICIPANTS_URI,
ParticipantData.ParticipantsQuery.PROJECTION,
ParticipantColumns.SUB_ID + " <> ?",
new String[] { String.valueOf(ParticipantData.OTHER_THAN_SELF_SUB_ID) },
null);
} else {
LogUtil.w(TAG, "Creating self loader after unbinding mConversationId = " +
mConversationId);
}
return loader;
}
@Override
public void onLoadFinished(@NonNull final Loader<Cursor> generic, final Cursor data) {
final BoundCursorLoader loader = (BoundCursorLoader) generic;
// Check if data still bound to the requesting ui element
if (isBound(loader.getBindingId())) {
mSelfParticipantsData.bind(data);
mSubscriptionListData.bind(mSelfParticipantsData.getSelfParticipants(true));
mListeners.onSubscriptionListDataLoaded(ConversationData.this);
} else {
LogUtil.w(TAG, "Self loader finished after unbinding mConversationId = " +
mConversationId);
}
}
@Override
public void onLoaderReset(@NonNull final Loader<Cursor> generic) {
final BoundCursorLoader loader = (BoundCursorLoader) generic;
// Check if data still bound to the requesting ui element
if (isBound(loader.getBindingId())) {
mSelfParticipantsData.bind(null);
} else {
LogUtil.w(TAG, "Self loader reset after unbinding mConversationId = " +
mConversationId);
}
}
}
private final ConversationDataEventDispatcher mListeners;
private final MetadataLoaderCallbacks mMetadataLoaderCallbacks;
private final MessagesLoaderCallbacks mMessagesLoaderCallbacks;
private final ParticipantLoaderCallbacks mParticipantsLoaderCallbacks;
private final SelfParticipantLoaderCallbacks mSelfParticipantLoaderCallbacks;
private final Context mContext;
private final String mConversationId;
private final ConversationParticipantsData mParticipantData;
private final SelfParticipantsData mSelfParticipantsData;
private ConversationListItemData mConversationMetadata;
private final SubscriptionListData mSubscriptionListData;
private LoaderManager mLoaderManager;
private long mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN;
private int mMessageCount = MESSAGE_COUNT_NaN;
private String mLastMessageId;
public ConversationData(final Context context, final ConversationDataListener listener,
final String conversationId) {
Assert.isTrue(conversationId != null);
mContext = context;
mConversationId = conversationId;
mMetadataLoaderCallbacks = new MetadataLoaderCallbacks();
mMessagesLoaderCallbacks = new MessagesLoaderCallbacks();
mParticipantsLoaderCallbacks = new ParticipantLoaderCallbacks();
mSelfParticipantLoaderCallbacks = new SelfParticipantLoaderCallbacks();
mParticipantData = new ConversationParticipantsData();
mConversationMetadata = new ConversationListItemData();
mSelfParticipantsData = new SelfParticipantsData();
mSubscriptionListData = new SubscriptionListData(context);
mListeners = new ConversationDataEventDispatcher();
mListeners.add(listener);
}
@RunsOnMainThread
public void addConversationDataListener(final ConversationDataListener listener) {
Assert.isMainThread();
mListeners.add(listener);
}
public String getConversationName() {
return mConversationMetadata.getName();
}
public boolean getIsArchived() {
return mConversationMetadata.getIsArchived();
}
public String getIcon() {
return mConversationMetadata.getIcon();
}
public String getConversationId() {
return mConversationId;
}
public void setFocus() {
DataModel.get().setFocusedConversation(mConversationId);
// As we are loading the conversation assume the user has read the messages...
// Do this late though so that it doesn't get in the way of other actions
BugleNotifications.markMessagesAsRead(mConversationId);
}
public void unsetFocus() {
DataModel.get().setFocusedConversation(null);
}
public boolean isFocused() {
return isBound() && DataModel.get().isFocusedConversation(mConversationId);
}
private static final int CONVERSATION_META_DATA_LOADER = 1;
private static final int CONVERSATION_MESSAGES_LOADER = 2;
private static final int PARTICIPANT_LOADER = 3;
private static final int SELF_PARTICIPANT_LOADER = 4;
public void init(final LoaderManager loaderManager,
final BindingBase<ConversationData> binding) {
// Remember the binding id so that loader callbacks can check if data is still bound
// to same ui component
final Bundle args = new Bundle();
args.putString(BINDING_ID, binding.getBindingId());
mLoaderManager = loaderManager;
mLoaderManager.initLoader(CONVERSATION_META_DATA_LOADER, args, mMetadataLoaderCallbacks);
mLoaderManager.initLoader(CONVERSATION_MESSAGES_LOADER, args, mMessagesLoaderCallbacks);
mLoaderManager.initLoader(PARTICIPANT_LOADER, args, mParticipantsLoaderCallbacks);
mLoaderManager.initLoader(SELF_PARTICIPANT_LOADER, args, mSelfParticipantLoaderCallbacks);
}
public void restart(final BindingBase<ConversationData> binding) {
final Bundle args = new Bundle();
args.putString(BINDING_ID, binding.getBindingId());
mLoaderManager.restartLoader(CONVERSATION_META_DATA_LOADER, args, mMetadataLoaderCallbacks);
mLoaderManager.restartLoader(CONVERSATION_MESSAGES_LOADER, args, mMessagesLoaderCallbacks);
mLoaderManager.restartLoader(PARTICIPANT_LOADER, args, mParticipantsLoaderCallbacks);
mLoaderManager.restartLoader(SELF_PARTICIPANT_LOADER, args,
mSelfParticipantLoaderCallbacks);
}
@Override
protected void unregisterListeners() {
mListeners.clear();
// Make sure focus has moved away from this conversation
// TODO: May false trigger if destroy happens after "new" conversation is focused.
// Assert.isTrue(!DataModel.get().isFocusedConversation(mConversationId));
// This could be null if we bind but the caller doesn't init the BindableData
if (mLoaderManager != null) {
mLoaderManager.destroyLoader(CONVERSATION_META_DATA_LOADER);
mLoaderManager.destroyLoader(CONVERSATION_MESSAGES_LOADER);
mLoaderManager.destroyLoader(PARTICIPANT_LOADER);
mLoaderManager.destroyLoader(SELF_PARTICIPANT_LOADER);
mLoaderManager = null;
}
}
/**
* Gets the default self participant in the participant table (NOT the conversation's self).
* This is available as soon as self participant data is loaded.
*/
public ParticipantData getDefaultSelfParticipant() {
return mSelfParticipantsData.getDefaultSelfParticipant();
}
public List<ParticipantData> getSelfParticipants(final boolean activeOnly) {
return mSelfParticipantsData.getSelfParticipants(activeOnly);
}
public int getSelfParticipantsCountExcludingDefault(final boolean activeOnly) {
return mSelfParticipantsData.getSelfParticipantsCountExcludingDefault(activeOnly);
}
public ParticipantData getSelfParticipantById(final String selfId) {
return mSelfParticipantsData.getSelfParticipantById(selfId);
}
/**
* For a 1:1 conversation return the other (not self) participant (else null)
*/
public ParticipantData getOtherParticipant() {
return mParticipantData.getOtherParticipant();
}
/**
* Return true once the participants are loaded
*/
public boolean getParticipantsLoaded() {
return mParticipantData.isLoaded();
}
public void sendMessage(final BindingBase<ConversationData> binding,
final MessageData message) {
Assert.isTrue(TextUtils.equals(mConversationId, message.getConversationId()));
Assert.isTrue(binding.getData() == this);
if (message.getSelfId() == null) {
InsertNewMessageAction.insertNewMessage(message);
} else {
final int systemDefaultSubId = PhoneUtils.getDefault().getDefaultSmsSubscriptionId();
if (systemDefaultSubId != ParticipantData.DEFAULT_SELF_SUB_ID &&
mSelfParticipantsData.isDefaultSelf(message.getSelfId())) {
// Lock the sub selection to the system default SIM as soon as the user clicks on
// the send button to avoid races between this and when InsertNewMessageAction is
// actually executed on the data model thread, during which the user can potentially
// change the system default SIM in Settings.
InsertNewMessageAction.insertNewMessage(message, systemDefaultSubId);
} else {
InsertNewMessageAction.insertNewMessage(message);
}
}
// Update contacts so Frequents will reflect messaging activity.
if (!getParticipantsLoaded()) {
return; // oh well, not critical
}
final ArrayList<String> phones = new ArrayList<>();
final ArrayList<String> emails = new ArrayList<>();
for (final ParticipantData participant : mParticipantData) {
if (!participant.isSelf()) {
if (participant.isEmail()) {
emails.add(participant.getSendDestination());
} else {
phones.add(participant.getSendDestination());
}
}
}
}
public void downloadMessage(final BindingBase<ConversationData> binding,
final String messageId) {
Assert.isTrue(binding.getData() == this);
Assert.notNull(messageId);
RedownloadMmsAction.redownloadMessage(messageId);
}
public void resendMessage(final BindingBase<ConversationData> binding, final String messageId) {
Assert.isTrue(binding.getData() == this);
Assert.notNull(messageId);
ResendMessageAction.resendMessage(messageId);
}
public void deleteMessage(final BindingBase<ConversationData> binding, final String messageId) {
Assert.isTrue(binding.getData() == this);
Assert.notNull(messageId);
DeleteMessageAction.deleteMessage(messageId);
}
public void deleteConversation(final Binding<ConversationData> binding) {
Assert.isTrue(binding.getData() == this);
// If possible use timestamp of last message shown to delete only messages user is aware of
if (mConversationMetadata == null) {
DeleteConversationAction.deleteConversation(mConversationId,
System.currentTimeMillis());
} else {
mConversationMetadata.deleteConversation();
}
}
public void archiveConversation(final BindingBase<ConversationData> binding) {
Assert.isTrue(binding.getData() == this);
UpdateConversationArchiveStatusAction.archiveConversation(mConversationId);
}
public void unarchiveConversation(final BindingBase<ConversationData> binding) {
Assert.isTrue(binding.getData() == this);
UpdateConversationArchiveStatusAction.unarchiveConversation(mConversationId);
}
public ConversationParticipantsData getParticipants() {
return mParticipantData;
}
/**
* Returns a dialable phone number for the participant if we are in a 1-1 conversation.
* @return the participant phone number, or null if the phone number is not valid or if there
* are more than one participant.
*/
public String getParticipantPhoneNumber() {
final ParticipantData participant = this.getOtherParticipant();
if (participant != null) {
final String phoneNumber = participant.getSendDestination();
if (!TextUtils.isEmpty(phoneNumber) && MmsSmsUtils.isPhoneNumber(phoneNumber)) {
return phoneNumber;
}
}
return null;
}
/**
* Create a message to be forwarded from an existing message.
*/
public MessageData createForwardedMessage(final ConversationMessageData message) {
final MessageData forwardedMessage = new MessageData();
final String originalSubject =
MmsUtils.cleanseMmsSubject(mContext.getResources(), message.getMmsSubject());
if (!TextUtils.isEmpty(originalSubject)) {
forwardedMessage.setMmsSubject(
mContext.getResources().getString(R.string.message_fwd, originalSubject));
}
for (final MessagePartData part : message.getParts()) {
MessagePartData forwardedPart;
// Depending on the part type, if it is text, we can directly create a text part;
// if it is attachment, then we need to create a pending attachment data out of it, so
// that we may persist the attachment locally in the scratch folder when the user picks
// a conversation to forward to.
if (part.isText()) {
forwardedPart = MessagePartData.createTextMessagePart(part.getText());
} else {
final PendingAttachmentData pendingAttachmentData = PendingAttachmentData
.createPendingAttachmentData(part.getContentType(), part.getContentUri());
forwardedPart = pendingAttachmentData;
}
forwardedMessage.addPart(forwardedPart);
}
return forwardedMessage;
}
public int getNumberOfParticipantsExcludingSelf() {
return mParticipantData.getNumberOfParticipantsExcludingSelf();
}
/**
* Returns {@link com.android.messaging.datamodel.data.SubscriptionListData
* .SubscriptionListEntry} for a given self participant so UI can display SIM-related info
* (icon, name etc.) for multi-SIM.
*/
public SubscriptionListEntry getSubscriptionEntryForSelfParticipant(
final String selfParticipantId, final boolean excludeDefault) {
return getSubscriptionEntryForSelfParticipant(selfParticipantId, excludeDefault,
mSubscriptionListData, mSelfParticipantsData);
}
/**
* Returns {@link com.android.messaging.datamodel.data.SubscriptionListData
* .SubscriptionListEntry} for a given self participant so UI can display SIM-related info
* (icon, name etc.) for multi-SIM.
*/
public static SubscriptionListEntry getSubscriptionEntryForSelfParticipant(
final String selfParticipantId, final boolean excludeDefault,
final SubscriptionListData subscriptionListData,
final SelfParticipantsData selfParticipantsData) {
// SIM indicators are shown in the UI only if:
// 1. Framework has MSIM support AND
// 2. The device has had multiple *active* subscriptions. AND
// 3. The message's subscription is active.
if (selfParticipantsData.getSelfParticipantsCountExcludingDefault(true) > 1) {
return subscriptionListData.getActiveSubscriptionEntryBySelfId(selfParticipantId,
excludeDefault);
}
return null;
}
public SubscriptionListData getSubscriptionListData() {
return mSubscriptionListData;
}
/**
* A placeholder implementation of {@link ConversationDataListener} so that subclasses may opt
* to implement some, but not all, of the interface methods.
*/
public static class SimpleConversationDataListener implements ConversationDataListener {
@Override
public void onConversationMessagesCursorUpdated(final ConversationData data, final Cursor cursor,
@Nullable
final
ConversationMessageData newestMessage, final boolean isSync) {}
@Override
public void onConversationMetadataUpdated(final ConversationData data) {}
@Override
public void closeConversation(final String conversationId) {}
@Override
public void onConversationParticipantDataLoaded(final ConversationData data) {}
@Override
public void onSubscriptionListDataLoaded(final ConversationData data) {}
}
private class ConversationDataEventDispatcher
extends ArrayList<ConversationDataListener>
implements ConversationDataListener {
@Override
public void onConversationMessagesCursorUpdated(final ConversationData data, final Cursor cursor,
@Nullable
final ConversationMessageData newestMessage, final boolean isSync) {
for (final ConversationDataListener listener : this) {
listener.onConversationMessagesCursorUpdated(data, cursor, newestMessage, isSync);
}
}
@Override
public void onConversationMetadataUpdated(final ConversationData data) {
for (final ConversationDataListener listener : this) {
listener.onConversationMetadataUpdated(data);
}
}
@Override
public void closeConversation(final String conversationId) {
for (final ConversationDataListener listener : this) {
listener.closeConversation(conversationId);
}
}
@Override
public void onConversationParticipantDataLoaded(final ConversationData data) {
for (final ConversationDataListener listener : this) {
listener.onConversationParticipantDataLoaded(data);
}
}
@Override
public void onSubscriptionListDataLoaded(final ConversationData data) {
for (final ConversationDataListener listener : this) {
listener.onSubscriptionListDataLoaded(data);
}
}
}
}
@@ -0,0 +1,215 @@
/*
* 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 android.os.Bundle;
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.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.binding.BindableData;
import com.android.messaging.datamodel.binding.BindingBase;
import com.android.messaging.datamodel.data.ConversationListItemData.ConversationListViewColumns;
import com.android.messaging.receiver.SmsReceiver;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import java.util.HashSet;
public class ConversationListData extends BindableData
implements LoaderManager.LoaderCallbacks<Cursor> {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String BINDING_ID = "bindingId";
public static final String SORT_ORDER =
ConversationListViewColumns.SORT_TIMESTAMP + " DESC";
private static final String WHERE_ARCHIVED =
"(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 1)";
public static final String WHERE_NOT_ARCHIVED =
"(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 0)";
public interface ConversationListDataListener {
void onConversationListCursorUpdated(ConversationListData data, Cursor cursor);
void setBlockedParticipantsAvailable(boolean blockedAvailable);
}
private ConversationListDataListener mListener;
private final Context mContext;
private final boolean mArchivedMode;
private LoaderManager mLoaderManager;
public ConversationListData(final Context context, final ConversationListDataListener listener,
final boolean archivedMode) {
mListener = listener;
mContext = context;
mArchivedMode = archivedMode;
}
private static final int CONVERSATION_LIST_LOADER = 1;
private static final int BLOCKED_PARTICIPANTS_AVAILABLE_LOADER = 2;
private static final String[] BLOCKED_PARTICIPANTS_PROJECTION = new String[] {
ParticipantColumns._ID,
ParticipantColumns.NORMALIZED_DESTINATION,
};
private static final int INDEX_BLOCKED_PARTICIPANTS_ID = 0;
private static final int INDEX_BLOCKED_PARTICIPANTS_NORMALIZED_DESTINATION = 1;
// all blocked participants
private final HashSet<String> mBlockedParticipants = new HashSet<>();
@NonNull
@Override
public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
final String bindingId = args.getString(BINDING_ID);
Loader<Cursor> loader = null;
// Check if data still bound to the requesting ui element
if (isBound(bindingId)) {
switch (id) {
case BLOCKED_PARTICIPANTS_AVAILABLE_LOADER:
loader = new BoundCursorLoader(bindingId, mContext,
MessagingContentProvider.PARTICIPANTS_URI,
BLOCKED_PARTICIPANTS_PROJECTION,
ParticipantColumns.BLOCKED + "=1", null, null);
break;
case CONVERSATION_LIST_LOADER:
loader = new BoundCursorLoader(bindingId, mContext,
MessagingContentProvider.CONVERSATIONS_URI,
ConversationListItemData.PROJECTION,
mArchivedMode ? WHERE_ARCHIVED : WHERE_NOT_ARCHIVED,
null, // selection args
SORT_ORDER);
break;
default:
Assert.fail("Unknown loader id");
break;
}
} else {
LogUtil.w(TAG, "Creating loader after unbinding list");
}
return loader;
}
/**
* {@inheritDoc}
*/
@Override
public void onLoadFinished(@NonNull final Loader<Cursor> generic, final Cursor data) {
final BoundCursorLoader loader = (BoundCursorLoader) generic;
if (isBound(loader.getBindingId())) {
switch (loader.getId()) {
case BLOCKED_PARTICIPANTS_AVAILABLE_LOADER:
mBlockedParticipants.clear();
for (int i = 0; i < data.getCount(); i++) {
data.moveToPosition(i);
mBlockedParticipants.add(data.getString(
INDEX_BLOCKED_PARTICIPANTS_NORMALIZED_DESTINATION));
}
mListener.setBlockedParticipantsAvailable(data != null && data.getCount() > 0);
break;
case CONVERSATION_LIST_LOADER:
mListener.onConversationListCursorUpdated(this, data);
break;
default:
Assert.fail("Unknown loader id");
break;
}
} else {
LogUtil.w(TAG, "Loader finished after unbinding list");
}
}
/**
* {@inheritDoc}
*/
@Override
public void onLoaderReset(@NonNull final Loader<Cursor> generic) {
final BoundCursorLoader loader = (BoundCursorLoader) generic;
if (isBound(loader.getBindingId())) {
switch (loader.getId()) {
case BLOCKED_PARTICIPANTS_AVAILABLE_LOADER:
mListener.setBlockedParticipantsAvailable(false);
break;
case CONVERSATION_LIST_LOADER:
mListener.onConversationListCursorUpdated(this, null);
break;
default:
Assert.fail("Unknown loader id");
break;
}
} else {
LogUtil.w(TAG, "Loader reset after unbinding list");
}
}
private Bundle mArgs;
public void init(final LoaderManager loaderManager,
final BindingBase<ConversationListData> binding) {
mArgs = new Bundle();
mArgs.putString(BINDING_ID, binding.getBindingId());
mLoaderManager = loaderManager;
mLoaderManager.initLoader(CONVERSATION_LIST_LOADER, mArgs, this);
mLoaderManager.initLoader(BLOCKED_PARTICIPANTS_AVAILABLE_LOADER, mArgs, this);
}
public void handleMessagesSeen() {
BugleNotifications.markAllMessagesAsSeen();
SmsReceiver.cancelSecondaryUserNotification();
}
@Override
protected void unregisterListeners() {
mListener = null;
// This could be null if we bind but the caller doesn't init the BindableData
if (mLoaderManager != null) {
mLoaderManager.destroyLoader(CONVERSATION_LIST_LOADER);
mLoaderManager.destroyLoader(BLOCKED_PARTICIPANTS_AVAILABLE_LOADER);
mLoaderManager = null;
}
}
public boolean getHasFirstSyncCompleted() {
final SyncManager syncManager = DataModel.get().getSyncManager();
return syncManager.getHasFirstSyncCompleted();
}
public void setScrolledToNewestConversation(boolean scrolledToNewestConversation) {
DataModel.get().setConversationListScrolledToNewestConversation(
scrolledToNewestConversation);
if (scrolledToNewestConversation) {
handleMessagesSeen();
}
}
public HashSet<String> getBlockedParticipants() {
return mBlockedParticipants;
}
}
@@ -0,0 +1,502 @@
/*
* 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.database.Cursor;
import android.net.Uri;
import android.provider.BaseColumns;
import android.text.TextUtils;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.action.DeleteConversationAction;
import com.android.messaging.util.Assert;
import com.android.messaging.util.ContactUtil;
import com.android.messaging.util.Dates;
import com.google.common.base.Joiner;
import java.util.ArrayList;
import java.util.List;
/**
* Class wrapping the conversation list view used to display each item in conversation list
*/
public class ConversationListItemData {
private String mConversationId;
private String mName;
private String mIcon;
private boolean mIsRead;
private long mTimestamp;
private String mSnippetText;
private Uri mPreviewUri;
private String mPreviewContentType;
private long mParticipantContactId;
private String mParticipantLookupKey;
private String mOtherParticipantNormalizedDestination;
private String mSelfId;
private int mParticipantCount;
private boolean mIncludeEmailAddress;
private int mMessageStatus;
private int mMessageRawTelephonyStatus;
private boolean mShowDraft;
private Uri mDraftPreviewUri;
private String mDraftPreviewContentType;
private String mDraftSnippetText;
private boolean mIsArchived;
private String mSubject;
private String mDraftSubject;
private String mSnippetSenderFirstName;
private String mSnippetSenderDisplayDestination;
private boolean mIsEnterprise;
public ConversationListItemData() {
}
public void bind(final Cursor cursor) {
bind(cursor, false);
}
public void bind(final Cursor cursor, final boolean ignoreDraft) {
mConversationId = cursor.getString(INDEX_ID);
mName = cursor.getString(INDEX_CONVERSATION_NAME);
mIcon = cursor.getString(INDEX_CONVERSATION_ICON);
mSnippetText = cursor.getString(INDEX_SNIPPET_TEXT);
mTimestamp = cursor.getLong(INDEX_SORT_TIMESTAMP);
mIsRead = cursor.getInt(INDEX_READ) == 1;
final String previewUriString = cursor.getString(INDEX_PREVIEW_URI);
mPreviewUri = TextUtils.isEmpty(previewUriString) ? null : Uri.parse(previewUriString);
mPreviewContentType = cursor.getString(INDEX_PREVIEW_CONTENT_TYPE);
mParticipantContactId = cursor.getLong(INDEX_PARTICIPANT_CONTACT_ID);
mParticipantLookupKey = cursor.getString(INDEX_PARTICIPANT_LOOKUP_KEY);
mOtherParticipantNormalizedDestination = cursor.getString(
INDEX_OTHER_PARTICIPANT_NORMALIZED_DESTINATION);
mSelfId = cursor.getString(INDEX_SELF_ID);
mParticipantCount = cursor.getInt(INDEX_PARTICIPANT_COUNT);
mIncludeEmailAddress = cursor.getInt(INDEX_INCLUDE_EMAIL_ADDRESS) == 1;
mMessageStatus = cursor.getInt(INDEX_MESSAGE_STATUS);
mMessageRawTelephonyStatus = cursor.getInt(INDEX_MESSAGE_RAW_TELEPHONY_STATUS);
if (!ignoreDraft) {
mShowDraft = cursor.getInt(INDEX_SHOW_DRAFT) == 1;
final String draftPreviewUriString = cursor.getString(INDEX_DRAFT_PREVIEW_URI);
mDraftPreviewUri = TextUtils.isEmpty(draftPreviewUriString) ?
null : Uri.parse(draftPreviewUriString);
mDraftPreviewContentType = cursor.getString(INDEX_DRAFT_PREVIEW_CONTENT_TYPE);
mDraftSnippetText = cursor.getString(INDEX_DRAFT_SNIPPET_TEXT);
mDraftSubject = cursor.getString(INDEX_DRAFT_SUBJECT_TEXT);
} else {
mShowDraft = false;
mDraftPreviewUri = null;
mDraftPreviewContentType = null;
mDraftSnippetText = null;
mDraftSubject = null;
}
mIsArchived = cursor.getInt(INDEX_ARCHIVE_STATUS) == 1;
mSubject = cursor.getString(INDEX_SUBJECT_TEXT);
mSnippetSenderFirstName = cursor.getString(INDEX_SNIPPET_SENDER_FIRST_NAME);
mSnippetSenderDisplayDestination =
cursor.getString(INDEX_SNIPPET_SENDER_DISPLAY_DESTINATION);
mIsEnterprise = cursor.getInt(INDEX_IS_ENTERPRISE) == 1;
}
public String getConversationId() {
return mConversationId;
}
public String getName() {
return mName;
}
public String getIcon() {
return mIcon;
}
public boolean getIsRead() {
return mIsRead;
}
public String getFormattedTimestamp() {
return Dates.getConversationTimeString(mTimestamp).toString();
}
public long getTimestamp() {
return mTimestamp;
}
public String getSnippetText() {
return mSnippetText;
}
public Uri getPreviewUri() {
return mPreviewUri;
}
public String getPreviewContentType() {
return mPreviewContentType;
}
/**
* @see ConversationColumns#PARTICIPANT_CONTACT_ID
* @return the contact id of the participant if it is a 1:1 conversation, -1 for group.
*/
public long getParticipantContactId() {
return mParticipantContactId;
}
/**
* @see ConversationColumns#IS_ENTERPRISE
* @return whether the conversation is enterprise.
*/
public boolean isEnterprise() {
return mIsEnterprise;
}
public String getParticipantLookupKey() {
return mParticipantLookupKey;
}
public String getOtherParticipantNormalizedDestination() {
return mOtherParticipantNormalizedDestination;
}
public String getSelfId() {
return mSelfId;
}
public int getParticipantCount() {
return mParticipantCount;
}
public boolean getIsGroup() {
// Participant count excludes self
return (mParticipantCount > 1);
}
public boolean getIncludeEmailAddress() {
return mIncludeEmailAddress;
}
public final boolean getIsFailedStatus() {
return (mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_FAILED ||
mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER ||
mMessageStatus == MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED ||
mMessageStatus == MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE);
}
public final boolean getIsSendRequested() {
return (mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND ||
mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY ||
mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_SENDING ||
mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_RESENDING);
}
public boolean getIsMessageTypeOutgoing() {
return !MessageData.getIsIncoming(mMessageStatus);
}
public int getMessageRawTelephonyStatus() {
return mMessageRawTelephonyStatus;
}
public int getMessageStatus() {
return mMessageStatus;
}
public boolean getShowDraft() {
return mShowDraft;
}
public String getDraftSnippetText() {
return mDraftSnippetText;
}
public Uri getDraftPreviewUri() {
return mDraftPreviewUri;
}
public String getDraftPreviewContentType() {
return mDraftPreviewContentType;
}
public boolean getIsArchived() {
return mIsArchived;
}
public String getSubject() {
return mSubject;
}
public String getDraftSubject() {
return mDraftSubject;
}
public String getSnippetSenderName() {
if (!TextUtils.isEmpty(mSnippetSenderFirstName)) {
return mSnippetSenderFirstName;
}
return mSnippetSenderDisplayDestination;
}
public void deleteConversation() {
DeleteConversationAction.deleteConversation(mConversationId, mTimestamp);
}
/**
* Get the name of the view for this data item
*/
public static String getConversationListView() {
return CONVERSATION_LIST_VIEW;
}
public static String getConversationListViewSql() {
return CONVERSATION_LIST_VIEW_SQL;
}
private static final String CONVERSATION_LIST_VIEW = "conversation_list_view";
private static final String CONVERSATION_LIST_VIEW_PROJECTION =
DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns._ID
+ " as " + ConversationListViewColumns._ID + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.NAME
+ " as " + ConversationListViewColumns.NAME + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.CURRENT_SELF_ID
+ " as " + ConversationListViewColumns.CURRENT_SELF_ID + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ARCHIVE_STATUS
+ " as " + ConversationListViewColumns.ARCHIVE_STATUS + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.READ
+ " as " + ConversationListViewColumns.READ + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ICON
+ " as " + ConversationListViewColumns.ICON + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PARTICIPANT_CONTACT_ID
+ " as " + ConversationListViewColumns.PARTICIPANT_CONTACT_ID + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PARTICIPANT_LOOKUP_KEY
+ " as " + ConversationListViewColumns.PARTICIPANT_LOOKUP_KEY + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.'
+ ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION
+ " as " + ConversationListViewColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SORT_TIMESTAMP
+ " as " + ConversationListViewColumns.SORT_TIMESTAMP + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SHOW_DRAFT
+ " as " + ConversationListViewColumns.SHOW_DRAFT + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.DRAFT_SNIPPET_TEXT
+ " as " + ConversationListViewColumns.DRAFT_SNIPPET_TEXT + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.DRAFT_PREVIEW_URI
+ " as " + ConversationListViewColumns.DRAFT_PREVIEW_URI + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.DRAFT_SUBJECT_TEXT
+ " as " + ConversationListViewColumns.DRAFT_SUBJECT_TEXT + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.'
+ ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE
+ " as " + ConversationListViewColumns.DRAFT_PREVIEW_CONTENT_TYPE + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PREVIEW_URI
+ " as " + ConversationListViewColumns.PREVIEW_URI + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PREVIEW_CONTENT_TYPE
+ " as " + ConversationListViewColumns.PREVIEW_CONTENT_TYPE + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PARTICIPANT_COUNT
+ " as " + ConversationListViewColumns.PARTICIPANT_COUNT + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' +
ConversationColumns.INCLUDE_EMAIL_ADDRESS
+ " as " + ConversationListViewColumns.INCLUDE_EMAIL_ADDRESS + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.STATUS
+ " as " + ConversationListViewColumns.MESSAGE_STATUS + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RAW_TELEPHONY_STATUS
+ " as " + ConversationListViewColumns.MESSAGE_RAW_TELEPHONY_STATUS + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID
+ " as " + ConversationListViewColumns.MESSAGE_ID + ", "
+ DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FIRST_NAME
+ " as " + ConversationListViewColumns.SNIPPET_SENDER_FIRST_NAME + ", "
+ DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.DISPLAY_DESTINATION
+ " as " + ConversationListViewColumns.SNIPPET_SENDER_DISPLAY_DESTINATION + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.IS_ENTERPRISE
+ " as " + ConversationListViewColumns.IS_ENTERPRISE;
private static final String JOIN_PARTICIPANTS =
" LEFT JOIN " + DatabaseHelper.PARTICIPANTS_TABLE + " ON ("
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID
+ '=' + DatabaseHelper.PARTICIPANTS_TABLE + '.' + DatabaseHelper.ParticipantColumns._ID
+ ") ";
// View that makes latest message read flag available with rest of conversation data.
private static final String CONVERSATION_LIST_VIEW_SQL = "CREATE VIEW " +
CONVERSATION_LIST_VIEW + " AS SELECT "
+ CONVERSATION_LIST_VIEW_PROJECTION + ", "
// Snippet not part of the base projection shared with search view
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SNIPPET_TEXT
+ " as " + ConversationListViewColumns.SNIPPET_TEXT + ", "
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SUBJECT_TEXT
+ " as " + ConversationListViewColumns.SUBJECT_TEXT + " "
+ " FROM " + DatabaseHelper.CONVERSATIONS_TABLE
+ " LEFT JOIN " + DatabaseHelper.MESSAGES_TABLE + " ON ("
+ DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.LATEST_MESSAGE_ID
+ '=' + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID + ") "
+ JOIN_PARTICIPANTS
+ "ORDER BY " + DatabaseHelper.CONVERSATIONS_TABLE + '.'
+ ConversationColumns.SORT_TIMESTAMP + " DESC";
public static class ConversationListViewColumns implements BaseColumns {
public static final String _ID = ConversationColumns._ID;
static final String NAME = ConversationColumns.NAME;
static final String ARCHIVE_STATUS = ConversationColumns.ARCHIVE_STATUS;
static final String READ = MessageColumns.READ;
static final String SORT_TIMESTAMP = ConversationColumns.SORT_TIMESTAMP;
static final String PREVIEW_URI = ConversationColumns.PREVIEW_URI;
static final String PREVIEW_CONTENT_TYPE = ConversationColumns.PREVIEW_CONTENT_TYPE;
static final String SNIPPET_TEXT = ConversationColumns.SNIPPET_TEXT;
static final String SUBJECT_TEXT = ConversationColumns.SUBJECT_TEXT;
static final String ICON = ConversationColumns.ICON;
static final String SHOW_DRAFT = ConversationColumns.SHOW_DRAFT;
static final String DRAFT_SUBJECT_TEXT = ConversationColumns.DRAFT_SUBJECT_TEXT;
static final String DRAFT_PREVIEW_URI = ConversationColumns.DRAFT_PREVIEW_URI;
static final String DRAFT_PREVIEW_CONTENT_TYPE =
ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE;
static final String DRAFT_SNIPPET_TEXT = ConversationColumns.DRAFT_SNIPPET_TEXT;
static final String PARTICIPANT_CONTACT_ID = ConversationColumns.PARTICIPANT_CONTACT_ID;
static final String PARTICIPANT_LOOKUP_KEY = ConversationColumns.PARTICIPANT_LOOKUP_KEY;
static final String OTHER_PARTICIPANT_NORMALIZED_DESTINATION =
ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION;
static final String CURRENT_SELF_ID = ConversationColumns.CURRENT_SELF_ID;
static final String PARTICIPANT_COUNT = ConversationColumns.PARTICIPANT_COUNT;
static final String INCLUDE_EMAIL_ADDRESS =
ConversationColumns.INCLUDE_EMAIL_ADDRESS;
static final String MESSAGE_STATUS = MessageColumns.STATUS;
static final String MESSAGE_RAW_TELEPHONY_STATUS = MessageColumns.RAW_TELEPHONY_STATUS;
static final String MESSAGE_ID = "message_id";
static final String SNIPPET_SENDER_FIRST_NAME = "snippet_sender_first_name";
static final String SNIPPET_SENDER_DISPLAY_DESTINATION =
"snippet_sender_display_destination";
static final String IS_ENTERPRISE = ConversationColumns.IS_ENTERPRISE;
}
public static final String[] PROJECTION = {
ConversationListViewColumns._ID,
ConversationListViewColumns.NAME,
ConversationListViewColumns.ICON,
ConversationListViewColumns.SNIPPET_TEXT,
ConversationListViewColumns.SORT_TIMESTAMP,
ConversationListViewColumns.READ,
ConversationListViewColumns.PREVIEW_URI,
ConversationListViewColumns.PREVIEW_CONTENT_TYPE,
ConversationListViewColumns.PARTICIPANT_CONTACT_ID,
ConversationListViewColumns.PARTICIPANT_LOOKUP_KEY,
ConversationListViewColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION,
ConversationListViewColumns.PARTICIPANT_COUNT,
ConversationListViewColumns.CURRENT_SELF_ID,
ConversationListViewColumns.INCLUDE_EMAIL_ADDRESS,
ConversationListViewColumns.MESSAGE_STATUS,
ConversationListViewColumns.SHOW_DRAFT,
ConversationListViewColumns.DRAFT_PREVIEW_URI,
ConversationListViewColumns.DRAFT_PREVIEW_CONTENT_TYPE,
ConversationListViewColumns.DRAFT_SNIPPET_TEXT,
ConversationListViewColumns.ARCHIVE_STATUS,
ConversationListViewColumns.MESSAGE_ID,
ConversationListViewColumns.SUBJECT_TEXT,
ConversationListViewColumns.DRAFT_SUBJECT_TEXT,
ConversationListViewColumns.MESSAGE_RAW_TELEPHONY_STATUS,
ConversationListViewColumns.SNIPPET_SENDER_FIRST_NAME,
ConversationListViewColumns.SNIPPET_SENDER_DISPLAY_DESTINATION,
ConversationListViewColumns.IS_ENTERPRISE,
};
private static final int INDEX_ID = 0;
private static final int INDEX_CONVERSATION_NAME = 1;
private static final int INDEX_CONVERSATION_ICON = 2;
private static final int INDEX_SNIPPET_TEXT = 3;
private static final int INDEX_SORT_TIMESTAMP = 4;
private static final int INDEX_READ = 5;
private static final int INDEX_PREVIEW_URI = 6;
private static final int INDEX_PREVIEW_CONTENT_TYPE = 7;
private static final int INDEX_PARTICIPANT_CONTACT_ID = 8;
private static final int INDEX_PARTICIPANT_LOOKUP_KEY = 9;
private static final int INDEX_OTHER_PARTICIPANT_NORMALIZED_DESTINATION = 10;
private static final int INDEX_PARTICIPANT_COUNT = 11;
private static final int INDEX_SELF_ID = 12;
private static final int INDEX_INCLUDE_EMAIL_ADDRESS = 13;
private static final int INDEX_MESSAGE_STATUS = 14;
private static final int INDEX_SHOW_DRAFT = 15;
private static final int INDEX_DRAFT_PREVIEW_URI = 16;
private static final int INDEX_DRAFT_PREVIEW_CONTENT_TYPE = 17;
private static final int INDEX_DRAFT_SNIPPET_TEXT = 18;
private static final int INDEX_ARCHIVE_STATUS = 19;
private static final int INDEX_MESSAGE_ID = 20;
private static final int INDEX_SUBJECT_TEXT = 21;
private static final int INDEX_DRAFT_SUBJECT_TEXT = 22;
private static final int INDEX_MESSAGE_RAW_TELEPHONY_STATUS = 23;
private static final int INDEX_SNIPPET_SENDER_FIRST_NAME = 24;
private static final int INDEX_SNIPPET_SENDER_DISPLAY_DESTINATION = 25;
private static final int INDEX_IS_ENTERPRISE = 26;
private static final String DIVIDER_TEXT = ", ";
public static boolean hasAnyEnterpriseContact(
final List<ParticipantData> participants) {
for (final ParticipantData participant : participants) {
if (ContactUtil.isEnterpriseContactId(participant.getContactId())) {
return true;
}
}
return false;
}
/**
* Get a conversation from the local DB based on the conversation id.
*
* @param dbWrapper The database
* @param conversationId The conversation Id to read
* @return The existing conversation or null
*/
public static ConversationListItemData getExistingConversation(final DatabaseWrapper dbWrapper,
final String conversationId) {
ConversationListItemData conversation = null;
// Look for an existing conversation in the db with this conversation id
try (Cursor cursor = dbWrapper.query(getConversationListView(),
PROJECTION,
ConversationColumns._ID + "=?",
new String[]{conversationId},
null, null, null)) {
// TODO: Should we be able to read a row from just the conversation table?
Assert.inRange(cursor.getCount(), 0, 1);
if (cursor.moveToFirst()) {
conversation = new ConversationListItemData();
conversation.bind(cursor);
}
}
return conversation;
}
public static String generateConversationName(final List<ParticipantData>
participants) {
if (participants.size() == 1) {
// Prefer full name over first name for 1:1 conversation
return participants.get(0).getDisplayName(true);
}
final ArrayList<String> participantNames = new ArrayList<>();
for (final ParticipantData participant : participants) {
// Prefer first name over full name for group conversation
participantNames.add(participant.getDisplayName(false));
}
final Joiner joiner = Joiner.on(DIVIDER_TEXT).skipNulls();
return joiner.join(participantNames);
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (C) 2015 The Android Open Source 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.text.TextUtils;
/**
* Holds data for conversation message bubble which keeps track of whether it's been bound to
* a new message.
*/
public class ConversationMessageBubbleData {
private String mMessageId;
/**
* Binds to ConversationMessageData instance.
* @return true if we are binding to a different message, false if we are binding to the
* same message (e.g. in order to update the status text)
*/
public boolean bind(final ConversationMessageData data) {
final boolean changed = !TextUtils.equals(mMessageId, data.getMessageId());
mMessageId = data.getMessageId();
return changed;
}
}
@@ -0,0 +1,910 @@
/*
* 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.database.Cursor;
import android.net.Uri;
import android.provider.BaseColumns;
import android.provider.ContactsContract;
import android.support.v7.mms.pdu.ContentType;
import android.text.TextUtils;
import android.text.format.DateUtils;
import androidx.annotation.NonNull;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseHelper.PartColumns;
import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
import com.android.messaging.util.Assert;
import com.android.messaging.util.BugleGservicesKeys;
import com.android.messaging.util.Dates;
import com.android.messaging.util.LogUtil;
import com.google.common.base.Predicate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
/**
* Class representing a message within a conversation sequence. The message parts
* are available via the getParts() method.
*
* TODO: See if we can delegate to MessageData for the logic that this class duplicates
* (e.g. getIsMms).
*/
public class ConversationMessageData {
private static final String TAG = LogUtil.BUGLE_TAG;
private String mMessageId;
private String mConversationId;
private String mParticipantId;
private int mPartsCount;
private List<MessagePartData> mParts;
private long mSentTimestamp;
private long mReceivedTimestamp;
private boolean mSeen;
private boolean mRead;
private int mProtocol;
private int mStatus;
private String mSmsMessageUri;
private int mSmsPriority;
private int mSmsMessageSize;
private String mMmsSubject;
private long mMmsExpiry;
private int mRawTelephonyStatus;
private String mSenderFullName;
private String mSenderFirstName;
private String mSenderDisplayDestination;
private String mSenderNormalizedDestination;
private String mSenderProfilePhotoUri;
private long mSenderContactId;
private String mSenderContactLookupKey;
private String mSelfParticipantId;
/** Are we similar enough to the previous/next messages that we can cluster them? */
private boolean mCanClusterWithPreviousMessage;
private boolean mCanClusterWithNextMessage;
public ConversationMessageData() {
}
public void bind(final Cursor cursor) {
mMessageId = cursor.getString(INDEX_MESSAGE_ID);
mConversationId = cursor.getString(INDEX_CONVERSATION_ID);
mParticipantId = cursor.getString(INDEX_PARTICIPANT_ID);
mPartsCount = cursor.getInt(INDEX_PARTS_COUNT);
mParts = makeParts(
cursor.getString(INDEX_PARTS_IDS),
cursor.getString(INDEX_PARTS_CONTENT_TYPES),
cursor.getString(INDEX_PARTS_CONTENT_URIS),
cursor.getString(INDEX_PARTS_WIDTHS),
cursor.getString(INDEX_PARTS_HEIGHTS),
cursor.getString(INDEX_PARTS_TEXTS),
mPartsCount,
mMessageId);
mSentTimestamp = cursor.getLong(INDEX_SENT_TIMESTAMP);
mReceivedTimestamp = cursor.getLong(INDEX_RECEIVED_TIMESTAMP);
mSeen = (cursor.getInt(INDEX_SEEN) != 0);
mRead = (cursor.getInt(INDEX_READ) != 0);
mProtocol = cursor.getInt(INDEX_PROTOCOL);
mStatus = cursor.getInt(INDEX_STATUS);
mSmsMessageUri = cursor.getString(INDEX_SMS_MESSAGE_URI);
mSmsPriority = cursor.getInt(INDEX_SMS_PRIORITY);
mSmsMessageSize = cursor.getInt(INDEX_SMS_MESSAGE_SIZE);
mMmsSubject = cursor.getString(INDEX_MMS_SUBJECT);
mMmsExpiry = cursor.getLong(INDEX_MMS_EXPIRY);
mRawTelephonyStatus = cursor.getInt(INDEX_RAW_TELEPHONY_STATUS);
mSenderFullName = cursor.getString(INDEX_SENDER_FULL_NAME);
mSenderFirstName = cursor.getString(INDEX_SENDER_FIRST_NAME);
mSenderDisplayDestination = cursor.getString(INDEX_SENDER_DISPLAY_DESTINATION);
mSenderNormalizedDestination = cursor.getString(INDEX_SENDER_NORMALIZED_DESTINATION);
mSenderProfilePhotoUri = cursor.getString(INDEX_SENDER_PROFILE_PHOTO_URI);
mSenderContactId = cursor.getLong(INDEX_SENDER_CONTACT_ID);
mSenderContactLookupKey = cursor.getString(INDEX_SENDER_CONTACT_LOOKUP_KEY);
mSelfParticipantId = cursor.getString(INDEX_SELF_PARTICIPIANT_ID);
if (!cursor.isFirst() && cursor.moveToPrevious()) {
mCanClusterWithPreviousMessage = canClusterWithMessage(cursor);
cursor.moveToNext();
} else {
mCanClusterWithPreviousMessage = false;
}
if (!cursor.isLast() && cursor.moveToNext()) {
mCanClusterWithNextMessage = canClusterWithMessage(cursor);
cursor.moveToPrevious();
} else {
mCanClusterWithNextMessage = false;
}
}
private boolean canClusterWithMessage(final Cursor cursor) {
final String otherParticipantId = cursor.getString(INDEX_PARTICIPANT_ID);
if (!TextUtils.equals(getParticipantId(), otherParticipantId)) {
return false;
}
final int otherStatus = cursor.getInt(INDEX_STATUS);
final boolean otherIsIncoming = (otherStatus >= MessageData.BUGLE_STATUS_FIRST_INCOMING);
if (getIsIncoming() != otherIsIncoming) {
return false;
}
final long otherReceivedTimestamp = cursor.getLong(INDEX_RECEIVED_TIMESTAMP);
final long timestampDeltaMillis = Math.abs(mReceivedTimestamp - otherReceivedTimestamp);
if (timestampDeltaMillis > DateUtils.MINUTE_IN_MILLIS) {
return false;
}
final String otherSelfId = cursor.getString(INDEX_SELF_PARTICIPIANT_ID);
if (!TextUtils.equals(getSelfParticipantId(), otherSelfId)) {
return false;
}
return true;
}
private static final Character QUOTE_CHAR = '\'';
private static final char DIVIDER = '|';
// statics to avoid unnecessary object allocation
private static final StringBuilder sUnquoteStringBuilder = new StringBuilder();
private static final ArrayList<String> sUnquoteResults = new ArrayList<>();
// this lock is used to guard access to the above statics
private static final Object sUnquoteLock = new Object();
private static void addResult(final ArrayList<String> results, final StringBuilder value) {
if (value.length() > 0) {
results.add(value.toString());
} else {
results.add(EMPTY_STRING);
}
}
static String[] splitUnquotedString(final String inputString) {
if (TextUtils.isEmpty(inputString)) {
return new String[0];
}
return inputString.split("\\" + DIVIDER);
}
/**
* Takes a group-concated and quoted string and decomposes it into its constituent
* parts. A quoted string starts and ends with a single quote. Actual single quotes
* within the string are escaped using a second single quote. So, for example, an
* input string with 3 constituent parts might look like this:
*
* 'now is the time'|'I can''t do it'|'foo'
*
* This would be returned as an array of 3 strings as follows:
* now is the time
* I can't do it
* foo
*
* This is achieved by walking through the inputString, character by character,
* ignoring the outer quotes and the divider and replacing any pair of consecutive
* single quotes with a single single quote.
*
* @return array of constituent strings
*/
static String[] splitQuotedString(final String inputString) {
if (TextUtils.isEmpty(inputString)) {
return new String[0];
}
// this method can be called from multiple threads but it uses a static
// string builder
synchronized (sUnquoteLock) {
final int length = inputString.length();
final ArrayList<String> results = sUnquoteResults;
results.clear();
int characterPos = -1;
while (++characterPos < length) {
final char mustBeQuote = inputString.charAt(characterPos);
Assert.isTrue(QUOTE_CHAR == mustBeQuote);
while (++characterPos < length) {
final char currentChar = inputString.charAt(characterPos);
if (currentChar == QUOTE_CHAR) {
final char peekAhead = characterPos < length - 1
? inputString.charAt(characterPos + 1) : 0;
if (peekAhead == QUOTE_CHAR) {
characterPos += 1; // skip the second quote
} else {
addResult(results, sUnquoteStringBuilder);
sUnquoteStringBuilder.setLength(0);
Assert.isTrue((peekAhead == DIVIDER) || (peekAhead == (char) 0));
characterPos += 1; // skip the divider
break;
}
}
sUnquoteStringBuilder.append(currentChar);
}
}
return results.toArray(new String[results.size()]);
}
}
static MessagePartData makePartData(
final String partId,
final String contentType,
final String contentUriString,
final String contentWidth,
final String contentHeight,
final String text,
final String messageId) {
if (ContentType.isTextType(contentType)) {
final MessagePartData textPart = MessagePartData.createTextMessagePart(text);
textPart.updatePartId(partId);
textPart.updateMessageId(messageId);
return textPart;
} else {
final Uri contentUri = Uri.parse(contentUriString);
final int width = Integer.parseInt(contentWidth);
final int height = Integer.parseInt(contentHeight);
final MessagePartData attachmentPart = MessagePartData.createMediaMessagePart(
contentType, contentUri, width, height);
attachmentPart.updatePartId(partId);
attachmentPart.updateMessageId(messageId);
return attachmentPart;
}
}
static List<MessagePartData> makeParts(
final String rawIds,
final String rawContentTypes,
final String rawContentUris,
final String rawWidths,
final String rawHeights,
final String rawTexts,
final int partsCount,
final String messageId) {
final List<MessagePartData> parts = new LinkedList<>();
if (partsCount == 1) {
parts.add(makePartData(
rawIds,
rawContentTypes,
rawContentUris,
rawWidths,
rawHeights,
rawTexts,
messageId));
} else {
unpackMessageParts(
parts,
splitUnquotedString(rawIds),
splitQuotedString(rawContentTypes),
splitQuotedString(rawContentUris),
splitUnquotedString(rawWidths),
splitUnquotedString(rawHeights),
splitQuotedString(rawTexts),
partsCount,
messageId);
}
return parts;
}
static void unpackMessageParts(
final List<MessagePartData> parts,
final String[] ids,
final String[] contentTypes,
final String[] contentUris,
final String[] contentWidths,
final String[] contentHeights,
final String[] texts,
final int partsCount,
final String messageId) {
Assert.equals(partsCount, ids.length);
Assert.equals(partsCount, contentTypes.length);
Assert.equals(partsCount, contentUris.length);
Assert.equals(partsCount, contentWidths.length);
Assert.equals(partsCount, contentHeights.length);
Assert.equals(partsCount, texts.length);
for (int i = 0; i < partsCount; i++) {
parts.add(makePartData(
ids[i],
contentTypes[i],
contentUris[i],
contentWidths[i],
contentHeights[i],
texts[i],
messageId));
}
if (parts.size() != partsCount) {
LogUtil.wtf(TAG, "Only unpacked " + parts.size() + " parts from message (id="
+ messageId + "), expected " + partsCount + " parts");
}
}
public final String getMessageId() {
return mMessageId;
}
public final String getConversationId() {
return mConversationId;
}
public final String getParticipantId() {
return mParticipantId;
}
public List<MessagePartData> getParts() {
return mParts;
}
public boolean hasText() {
for (final MessagePartData part : mParts) {
if (part.isText()) {
return true;
}
}
return false;
}
/**
* Get a concatenation of all text parts
*
* @return the text that is a concatenation of all text parts
*/
public String getText() {
// This is optimized for single text part case, which is the majority
// For single text part, we just return the part without creating the StringBuilder
String firstTextPart = null;
boolean foundText = false;
// For multiple text parts, we need the StringBuilder and the separator for concatenation
StringBuilder sb = null;
String separator = null;
for (final MessagePartData part : mParts) {
if (part.isText()) {
if (!foundText) {
// First text part
firstTextPart = part.getText();
foundText = true;
} else {
// Second and beyond
if (sb == null) {
// Need the StringBuilder and the separator starting from 2nd text part
sb = new StringBuilder();
if (!TextUtils.isEmpty(firstTextPart)) {
sb.append(firstTextPart);
}
separator = BugleGservicesKeys.MMS_TEXT_CONCAT_SEPARATOR_DEFAULT;
}
final String partText = part.getText();
if (!TextUtils.isEmpty(partText)) {
if (!TextUtils.isEmpty(separator) && sb.length() > 0) {
sb.append(separator);
}
sb.append(partText);
}
}
}
}
if (sb == null) {
// Only one text part
return firstTextPart;
} else {
// More than one
return sb.toString();
}
}
public boolean hasAttachments() {
for (final MessagePartData part : mParts) {
if (part.isAttachment()) {
return true;
}
}
return false;
}
public List<MessagePartData> getAttachments() {
return getAttachments(null);
}
public List<MessagePartData> getAttachments(final Predicate<MessagePartData> filter) {
if (mParts.isEmpty()) {
return Collections.emptyList();
}
final List<MessagePartData> attachmentParts = new LinkedList<>();
for (final MessagePartData part : mParts) {
if (part.isAttachment()) {
if (filter == null || filter.apply(part)) {
attachmentParts.add(part);
}
}
}
return attachmentParts;
}
public final long getSentTimeStamp() {
return mSentTimestamp;
}
public final long getReceivedTimeStamp() {
return mReceivedTimestamp;
}
public final String getFormattedReceivedTimeStamp() {
return Dates.getMessageTimeString(mReceivedTimestamp).toString();
}
public final boolean getIsSeen() {
return mSeen;
}
public final boolean getIsRead() {
return mRead;
}
public final boolean getIsMms() {
return (mProtocol == MessageData.PROTOCOL_MMS ||
mProtocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION);
}
public final boolean getIsMmsNotification() {
return (mProtocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION);
}
public final boolean getIsSms() {
return mProtocol == (MessageData.PROTOCOL_SMS);
}
public final int getStatus() {
return mStatus;
}
public final String getSmsMessageUri() {
return mSmsMessageUri;
}
public final int getSmsPriority() {
return mSmsPriority;
}
public final int getSmsMessageSize() {
return mSmsMessageSize;
}
public final String getMmsSubject() {
return mMmsSubject;
}
public final long getMmsExpiry() {
return mMmsExpiry;
}
public final int getRawTelephonyStatus() {
return mRawTelephonyStatus;
}
public final String getSelfParticipantId() {
return mSelfParticipantId;
}
public boolean getIsIncoming() {
return (mStatus >= MessageData.BUGLE_STATUS_FIRST_INCOMING);
}
public boolean hasIncomingErrorStatus() {
return (mStatus == MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE ||
mStatus == MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED);
}
public boolean getIsSendComplete() {
return (mStatus == MessageData.BUGLE_STATUS_OUTGOING_COMPLETE
|| mStatus == MessageData.BUGLE_STATUS_OUTGOING_DELIVERED);
}
public String getSenderFullName() {
return mSenderFullName;
}
public String getSenderFirstName() {
return mSenderFirstName;
}
public String getSenderDisplayDestination() {
return mSenderDisplayDestination;
}
public String getSenderNormalizedDestination() {
return mSenderNormalizedDestination;
}
public Uri getSenderProfilePhotoUri() {
return mSenderProfilePhotoUri == null ? null : Uri.parse(mSenderProfilePhotoUri);
}
public long getSenderContactId() {
return mSenderContactId;
}
public String getSenderDisplayName() {
if (!TextUtils.isEmpty(mSenderFullName)) {
return mSenderFullName;
}
if (!TextUtils.isEmpty(mSenderFirstName)) {
return mSenderFirstName;
}
return mSenderDisplayDestination;
}
public String getSenderContactLookupKey() {
return mSenderContactLookupKey;
}
public boolean getShowDownloadMessage() {
return MessageData.getShowDownloadMessage(mStatus);
}
public boolean getShowResendMessage() {
return MessageData.getShowResendMessage(mStatus);
}
public boolean getCanForwardMessage() {
// Even for outgoing messages, we only allow forwarding if the message has finished sending
// as media often has issues when send isn't complete
return (mStatus == MessageData.BUGLE_STATUS_OUTGOING_COMPLETE
|| mStatus == MessageData.BUGLE_STATUS_OUTGOING_DELIVERED
|| mStatus == MessageData.BUGLE_STATUS_INCOMING_COMPLETE);
}
public boolean getCanCopyMessageToClipboard() {
return (hasText() &&
(!getIsIncoming() || mStatus == MessageData.BUGLE_STATUS_INCOMING_COMPLETE));
}
public boolean getOneClickResendMessage() {
return MessageData.getOneClickResendMessage(mStatus, mRawTelephonyStatus);
}
/**
* Get sender's lookup uri.
* This method doesn't support corp contacts.
*
* @return Lookup uri of sender's contact
*/
public Uri getSenderContactLookupUri() {
if (mSenderContactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED
&& !TextUtils.isEmpty(mSenderContactLookupKey)) {
return ContactsContract.Contacts.getLookupUri(mSenderContactId,
mSenderContactLookupKey);
}
return null;
}
public boolean getCanClusterWithPreviousMessage() {
return mCanClusterWithPreviousMessage;
}
public boolean getCanClusterWithNextMessage() {
return mCanClusterWithNextMessage;
}
@NonNull
@Override
public String toString() {
return MessageData.toString(mMessageId, mParts);
}
// Data definitions
public static String getConversationMessagesQuerySql() {
return CONVERSATION_MESSAGES_QUERY_SQL
+ " AND "
// Inject the conversation id
+ DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?)"
+ CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY;
}
static String getConversationMessageIdsQuerySql() {
return CONVERSATION_MESSAGES_IDS_QUERY_SQL
+ " AND "
// Inject the conversation id
+ DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?)"
+ CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY;
}
public static String getNotificationQuerySql() {
return CONVERSATION_MESSAGES_QUERY_SQL
+ " AND "
+ "(" + DatabaseHelper.MessageColumns.STATUS + " in ("
+ MessageData.BUGLE_STATUS_INCOMING_COMPLETE + ", "
+ MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD + ")"
+ " AND "
+ DatabaseHelper.MessageColumns.SEEN + " = 0)"
+ ")"
+ NOTIFICATION_QUERY_SQL_GROUP_BY;
}
public static String getWearableQuerySql() {
return CONVERSATION_MESSAGES_QUERY_SQL
+ " AND "
+ DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?"
+ " AND "
+ DatabaseHelper.MessageColumns.STATUS + " IN ("
+ MessageData.BUGLE_STATUS_OUTGOING_DELIVERED + ", "
+ MessageData.BUGLE_STATUS_OUTGOING_COMPLETE + ", "
+ MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND + ", "
+ MessageData.BUGLE_STATUS_OUTGOING_SENDING + ", "
+ MessageData.BUGLE_STATUS_OUTGOING_RESENDING + ", "
+ MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY + ", "
+ MessageData.BUGLE_STATUS_INCOMING_COMPLETE + ", "
+ MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD + ")"
+ ")"
+ NOTIFICATION_QUERY_SQL_GROUP_BY;
}
/*
* Generate a sqlite snippet to call the quote function on the columnName argument.
* The columnName doesn't strictly have to be a column name (e.g. it could be an
* expression).
*/
private static String quote(final String columnName) {
return "quote(" + columnName + ")";
}
private static String makeGroupConcatString(final String column) {
return "group_concat(" + column + ", '" + DIVIDER + "')";
}
private static String makeIfNullString(final String column) {
return "ifnull(" + column + "," + "''" + ")";
}
private static String makePartsTableColumnString(final String column) {
return DatabaseHelper.PARTS_TABLE + '.' + column;
}
private static String makeCaseWhenString(final String column,
final boolean quote,
final String asColumn) {
final String fullColumn = makeIfNullString(makePartsTableColumnString(column));
final String groupConcatTerm = quote
? makeGroupConcatString(quote(fullColumn))
: makeGroupConcatString(fullColumn);
return "CASE WHEN (" + CONVERSATION_MESSAGE_VIEW_PARTS_COUNT + ">1) THEN " + groupConcatTerm
+ " ELSE " + makePartsTableColumnString(column) + " END AS " + asColumn;
}
private static final String CONVERSATION_MESSAGE_VIEW_PARTS_COUNT =
"count(" + DatabaseHelper.PARTS_TABLE + '.' + PartColumns._ID + ")";
private static final String EMPTY_STRING = "";
private static final String CONVERSATION_MESSAGES_QUERY_PROJECTION_SQL =
DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID
+ " as " + ConversationMessageViewColumns._ID + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.CONVERSATION_ID
+ " as " + ConversationMessageViewColumns.CONVERSATION_ID + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID
+ " as " + ConversationMessageViewColumns.PARTICIPANT_ID + ", "
+ makeCaseWhenString(PartColumns._ID, false,
ConversationMessageViewColumns.PARTS_IDS) + ", "
+ makeCaseWhenString(PartColumns.CONTENT_TYPE, true,
ConversationMessageViewColumns.PARTS_CONTENT_TYPES) + ", "
+ makeCaseWhenString(PartColumns.CONTENT_URI, true,
ConversationMessageViewColumns.PARTS_CONTENT_URIS) + ", "
+ makeCaseWhenString(PartColumns.WIDTH, false,
ConversationMessageViewColumns.PARTS_WIDTHS) + ", "
+ makeCaseWhenString(PartColumns.HEIGHT, false,
ConversationMessageViewColumns.PARTS_HEIGHTS) + ", "
+ makeCaseWhenString(PartColumns.TEXT, true,
ConversationMessageViewColumns.PARTS_TEXTS) + ", "
+ CONVERSATION_MESSAGE_VIEW_PARTS_COUNT
+ " as " + ConversationMessageViewColumns.PARTS_COUNT + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENT_TIMESTAMP
+ " as " + ConversationMessageViewColumns.SENT_TIMESTAMP + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP
+ " as " + ConversationMessageViewColumns.RECEIVED_TIMESTAMP + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SEEN
+ " as " + ConversationMessageViewColumns.SEEN + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.READ
+ " as " + ConversationMessageViewColumns.READ + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.PROTOCOL
+ " as " + ConversationMessageViewColumns.PROTOCOL + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.STATUS
+ " as " + ConversationMessageViewColumns.STATUS + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SMS_MESSAGE_URI
+ " as " + ConversationMessageViewColumns.SMS_MESSAGE_URI + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SMS_PRIORITY
+ " as " + ConversationMessageViewColumns.SMS_PRIORITY + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SMS_MESSAGE_SIZE
+ " as " + ConversationMessageViewColumns.SMS_MESSAGE_SIZE + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.MMS_SUBJECT
+ " as " + ConversationMessageViewColumns.MMS_SUBJECT + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.MMS_EXPIRY
+ " as " + ConversationMessageViewColumns.MMS_EXPIRY + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RAW_TELEPHONY_STATUS
+ " as " + ConversationMessageViewColumns.RAW_TELEPHONY_STATUS + ", "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SELF_PARTICIPANT_ID
+ " as " + ConversationMessageViewColumns.SELF_PARTICIPANT_ID + ", "
+ DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FULL_NAME
+ " as " + ConversationMessageViewColumns.SENDER_FULL_NAME + ", "
+ DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FIRST_NAME
+ " as " + ConversationMessageViewColumns.SENDER_FIRST_NAME + ", "
+ DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.DISPLAY_DESTINATION
+ " as " + ConversationMessageViewColumns.SENDER_DISPLAY_DESTINATION + ", "
+ DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.NORMALIZED_DESTINATION
+ " as " + ConversationMessageViewColumns.SENDER_NORMALIZED_DESTINATION + ", "
+ DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.PROFILE_PHOTO_URI
+ " as " + ConversationMessageViewColumns.SENDER_PROFILE_PHOTO_URI + ", "
+ DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.CONTACT_ID
+ " as " + ConversationMessageViewColumns.SENDER_CONTACT_ID + ", "
+ DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.LOOKUP_KEY
+ " as " + ConversationMessageViewColumns.SENDER_CONTACT_LOOKUP_KEY + " ";
private static final String CONVERSATION_MESSAGES_QUERY_FROM_WHERE_SQL =
" FROM " + DatabaseHelper.MESSAGES_TABLE
+ " LEFT JOIN " + DatabaseHelper.PARTS_TABLE
+ " ON (" + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns._ID
+ "=" + DatabaseHelper.PARTS_TABLE + "." + PartColumns.MESSAGE_ID + ") "
+ " LEFT JOIN " + DatabaseHelper.PARTICIPANTS_TABLE
+ " ON (" + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID
+ '=' + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns._ID + ")"
// Exclude draft messages from main view
+ " WHERE (" + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.STATUS
+ " <> " + MessageData.BUGLE_STATUS_OUTGOING_DRAFT;
// This query is mostly static, except for the injection of conversation id. This is for
// performance reasons, to ensure that the query uses indices and does not trigger full scans
// of the messages table. See b/17160946 for more details.
private static final String CONVERSATION_MESSAGES_QUERY_SQL = "SELECT "
+ CONVERSATION_MESSAGES_QUERY_PROJECTION_SQL
+ CONVERSATION_MESSAGES_QUERY_FROM_WHERE_SQL;
private static final String CONVERSATION_MESSAGE_IDS_PROJECTION_SQL =
DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID
+ " as " + ConversationMessageViewColumns._ID + " ";
private static final String CONVERSATION_MESSAGES_IDS_QUERY_SQL = "SELECT "
+ CONVERSATION_MESSAGE_IDS_PROJECTION_SQL
+ CONVERSATION_MESSAGES_QUERY_FROM_WHERE_SQL;
// Note that we sort DESC and ConversationData reverses the cursor. This is a performance
// issue (improvement) for large cursors.
private static final String CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY =
" GROUP BY " + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.MESSAGE_ID
+ " ORDER BY "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + " DESC";
private static final String NOTIFICATION_QUERY_SQL_GROUP_BY =
" GROUP BY " + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.MESSAGE_ID
+ " ORDER BY "
+ DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + " DESC";
interface ConversationMessageViewColumns extends BaseColumns {
String _ID = MessageColumns._ID;
String CONVERSATION_ID = MessageColumns.CONVERSATION_ID;
String PARTICIPANT_ID = MessageColumns.SENDER_PARTICIPANT_ID;
String PARTS_COUNT = "parts_count";
String SENT_TIMESTAMP = MessageColumns.SENT_TIMESTAMP;
String RECEIVED_TIMESTAMP = MessageColumns.RECEIVED_TIMESTAMP;
String SEEN = MessageColumns.SEEN;
String READ = MessageColumns.READ;
String PROTOCOL = MessageColumns.PROTOCOL;
String STATUS = MessageColumns.STATUS;
String SMS_MESSAGE_URI = MessageColumns.SMS_MESSAGE_URI;
String SMS_PRIORITY = MessageColumns.SMS_PRIORITY;
String SMS_MESSAGE_SIZE = MessageColumns.SMS_MESSAGE_SIZE;
String MMS_SUBJECT = MessageColumns.MMS_SUBJECT;
String MMS_EXPIRY = MessageColumns.MMS_EXPIRY;
String RAW_TELEPHONY_STATUS = MessageColumns.RAW_TELEPHONY_STATUS;
String SELF_PARTICIPANT_ID = MessageColumns.SELF_PARTICIPANT_ID;
String SENDER_FULL_NAME = ParticipantColumns.FULL_NAME;
String SENDER_FIRST_NAME = ParticipantColumns.FIRST_NAME;
String SENDER_DISPLAY_DESTINATION = ParticipantColumns.DISPLAY_DESTINATION;
String SENDER_NORMALIZED_DESTINATION =
ParticipantColumns.NORMALIZED_DESTINATION;
String SENDER_PROFILE_PHOTO_URI = ParticipantColumns.PROFILE_PHOTO_URI;
String SENDER_CONTACT_ID = ParticipantColumns.CONTACT_ID;
String SENDER_CONTACT_LOOKUP_KEY = ParticipantColumns.LOOKUP_KEY;
String PARTS_IDS = "parts_ids";
String PARTS_CONTENT_TYPES = "parts_content_types";
String PARTS_CONTENT_URIS = "parts_content_uris";
String PARTS_WIDTHS = "parts_widths";
String PARTS_HEIGHTS = "parts_heights";
String PARTS_TEXTS = "parts_texts";
}
private static int sIndexIncrementer = 0;
private static final int INDEX_MESSAGE_ID = sIndexIncrementer++;
private static final int INDEX_CONVERSATION_ID = sIndexIncrementer++;
private static final int INDEX_PARTICIPANT_ID = sIndexIncrementer++;
private static final int INDEX_PARTS_IDS = sIndexIncrementer++;
private static final int INDEX_PARTS_CONTENT_TYPES = sIndexIncrementer++;
private static final int INDEX_PARTS_CONTENT_URIS = sIndexIncrementer++;
private static final int INDEX_PARTS_WIDTHS = sIndexIncrementer++;
private static final int INDEX_PARTS_HEIGHTS = sIndexIncrementer++;
private static final int INDEX_PARTS_TEXTS = sIndexIncrementer++;
private static final int INDEX_PARTS_COUNT = sIndexIncrementer++;
private static final int INDEX_SENT_TIMESTAMP = sIndexIncrementer++;
private static final int INDEX_RECEIVED_TIMESTAMP = sIndexIncrementer++;
private static final int INDEX_SEEN = sIndexIncrementer++;
private static final int INDEX_READ = sIndexIncrementer++;
private static final int INDEX_PROTOCOL = sIndexIncrementer++;
private static final int INDEX_STATUS = sIndexIncrementer++;
private static final int INDEX_SMS_MESSAGE_URI = sIndexIncrementer++;
private static final int INDEX_SMS_PRIORITY = sIndexIncrementer++;
private static final int INDEX_SMS_MESSAGE_SIZE = sIndexIncrementer++;
private static final int INDEX_MMS_SUBJECT = sIndexIncrementer++;
private static final int INDEX_MMS_EXPIRY = sIndexIncrementer++;
private static final int INDEX_RAW_TELEPHONY_STATUS = sIndexIncrementer++;
private static final int INDEX_SELF_PARTICIPIANT_ID = sIndexIncrementer++;
private static final int INDEX_SENDER_FULL_NAME = sIndexIncrementer++;
private static final int INDEX_SENDER_FIRST_NAME = sIndexIncrementer++;
private static final int INDEX_SENDER_DISPLAY_DESTINATION = sIndexIncrementer++;
private static final int INDEX_SENDER_NORMALIZED_DESTINATION = sIndexIncrementer++;
private static final int INDEX_SENDER_PROFILE_PHOTO_URI = sIndexIncrementer++;
private static final int INDEX_SENDER_CONTACT_ID = sIndexIncrementer++;
private static final int INDEX_SENDER_CONTACT_LOOKUP_KEY = sIndexIncrementer++;
private static final String[] sProjection = {
ConversationMessageViewColumns._ID,
ConversationMessageViewColumns.CONVERSATION_ID,
ConversationMessageViewColumns.PARTICIPANT_ID,
ConversationMessageViewColumns.PARTS_IDS,
ConversationMessageViewColumns.PARTS_CONTENT_TYPES,
ConversationMessageViewColumns.PARTS_CONTENT_URIS,
ConversationMessageViewColumns.PARTS_WIDTHS,
ConversationMessageViewColumns.PARTS_HEIGHTS,
ConversationMessageViewColumns.PARTS_TEXTS,
ConversationMessageViewColumns.PARTS_COUNT,
ConversationMessageViewColumns.SENT_TIMESTAMP,
ConversationMessageViewColumns.RECEIVED_TIMESTAMP,
ConversationMessageViewColumns.SEEN,
ConversationMessageViewColumns.READ,
ConversationMessageViewColumns.PROTOCOL,
ConversationMessageViewColumns.STATUS,
ConversationMessageViewColumns.SMS_MESSAGE_URI,
ConversationMessageViewColumns.SMS_PRIORITY,
ConversationMessageViewColumns.SMS_MESSAGE_SIZE,
ConversationMessageViewColumns.MMS_SUBJECT,
ConversationMessageViewColumns.MMS_EXPIRY,
ConversationMessageViewColumns.RAW_TELEPHONY_STATUS,
ConversationMessageViewColumns.SELF_PARTICIPANT_ID,
ConversationMessageViewColumns.SENDER_FULL_NAME,
ConversationMessageViewColumns.SENDER_FIRST_NAME,
ConversationMessageViewColumns.SENDER_DISPLAY_DESTINATION,
ConversationMessageViewColumns.SENDER_NORMALIZED_DESTINATION,
ConversationMessageViewColumns.SENDER_PROFILE_PHOTO_URI,
ConversationMessageViewColumns.SENDER_CONTACT_ID,
ConversationMessageViewColumns.SENDER_CONTACT_LOOKUP_KEY,
};
public static String[] getProjection() {
return sProjection;
}
}
@@ -0,0 +1,122 @@
/*
* 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.database.Cursor;
import androidx.annotation.NonNull;
import androidx.collection.SimpleArrayMap;
import com.android.messaging.util.Assert;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.NoSuchElementException;
/**
* A class that contains the list of all participants potentially involved in a conversation.
* Includes both the participant records for each participant referenced in conversation
* participants table (i.e. "other" phone numbers) plus all participants representing self
* (i.e. one per sim recorded in the subscription manager db).
*/
public class ConversationParticipantsData implements Iterable<ParticipantData> {
// A map from a participant id to a participant
private final SimpleArrayMap<String, ParticipantData> mConversationParticipantsMap;
private int mParticipantCountExcludingSelf = 0;
public ConversationParticipantsData() {
mConversationParticipantsMap = new SimpleArrayMap<>();
}
public void bind(final Cursor cursor) {
mConversationParticipantsMap.clear();
mParticipantCountExcludingSelf = 0;
if (cursor != null) {
while (cursor.moveToNext()) {
final ParticipantData newParticipant = ParticipantData.getFromCursor(cursor);
if (!newParticipant.isSelf()) {
mParticipantCountExcludingSelf++;
}
mConversationParticipantsMap.put(newParticipant.getId(), newParticipant);
}
}
}
ArrayList<ParticipantData> getParticipantListExcludingSelf() {
final ArrayList<ParticipantData> retList =
new ArrayList<>(mConversationParticipantsMap.size());
for (int i = 0; i < mConversationParticipantsMap.size(); i++) {
final ParticipantData participant = mConversationParticipantsMap.valueAt(i);
if (!participant.isSelf()) {
retList.add(participant);
}
}
return retList;
}
/**
* For a 1:1 conversation return the other (not self) participant
*/
public ParticipantData getOtherParticipant() {
if (mParticipantCountExcludingSelf == 1) {
for (int i = 0; i < mConversationParticipantsMap.size(); i++) {
final ParticipantData participant = mConversationParticipantsMap.valueAt(i);
if (!participant.isSelf()) {
return participant;
}
}
Assert.fail("Could not find other participant");
}
return null;
}
public int getNumberOfParticipantsExcludingSelf() {
return mParticipantCountExcludingSelf;
}
public boolean isLoaded() {
return !mConversationParticipantsMap.isEmpty();
}
@NonNull
@Override
public Iterator<ParticipantData> iterator() {
return new Iterator<>() {
private int mCurrentIndex = -1;
@Override
public boolean hasNext() {
return mCurrentIndex < mConversationParticipantsMap.size() - 1;
}
@Override
public ParticipantData next() {
mCurrentIndex++;
if (mCurrentIndex >= mConversationParticipantsMap.size()) {
throw new NoSuchElementException();
}
return mConversationParticipantsMap.valueAt(mCurrentIndex);
}
@Override
public void remove() {
throw new UnsupportedOperationException();
}
};
}
}
@@ -0,0 +1,885 @@
/*
* 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.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import com.android.messaging.datamodel.MessageTextStats;
import com.android.messaging.datamodel.action.ReadDraftDataAction;
import com.android.messaging.datamodel.action.ReadDraftDataAction.ReadDraftDataActionListener;
import com.android.messaging.datamodel.action.ReadDraftDataAction.ReadDraftDataActionMonitor;
import com.android.messaging.datamodel.action.WriteDraftMessageAction;
import com.android.messaging.datamodel.binding.BindableData;
import com.android.messaging.datamodel.binding.Binding;
import com.android.messaging.datamodel.binding.BindingBase;
import com.android.messaging.sms.MmsConfig;
import com.android.messaging.sms.MmsSmsUtils;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
import com.android.messaging.util.Assert.RunsOnMainThread;
import com.android.messaging.util.BugleGservicesKeys;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.PhoneUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public class DraftMessageData extends BindableData implements ReadDraftDataActionListener {
/**
* Interface for DraftMessageData listeners
*/
public interface DraftMessageDataListener {
@RunsOnMainThread
void onDraftChanged(DraftMessageData data, int changeFlags);
@RunsOnMainThread
void onDraftAttachmentLimitReached(DraftMessageData data);
@RunsOnMainThread
void onDraftAttachmentLoadFailed();
}
/**
* Interface for providing subscription-related data to DraftMessageData
*/
public interface DraftMessageSubscriptionDataProvider {
int getConversationSelfSubId();
}
// Flags sent to onDraftChanged to help the receiver limit the amount of work done
public static final int ATTACHMENTS_CHANGED = 0x0001;
public static final int MESSAGE_TEXT_CHANGED = 0x0002;
public static final int MESSAGE_SUBJECT_CHANGED = 0x0004;
// Whether the self participant data has been loaded
public static final int SELF_CHANGED = 0x0008;
public static final int ALL_CHANGED = 0x00FF;
// ALL_CHANGED intentionally doesn't include WIDGET_CHANGED. ConversationFragment needs to
// be notified if the draft it is looking at is changed externally (by a desktop widget) so it
// can reload the draft.
public static final int WIDGET_CHANGED = 0x0100;
private final String mConversationId;
private ReadDraftDataActionMonitor mMonitor;
private final DraftMessageDataEventDispatcher mListeners;
private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider;
private boolean mIncludeEmailAddress;
private boolean mIsGroupConversation;
private String mMessageText;
private String mMessageSubject;
private String mSelfId;
private final MessageTextStats mMessageTextStats;
private boolean mSending;
/** Keeps track of completed attachments in the message draft. This data is persisted to db */
private final List<MessagePartData> mAttachments;
/** A read-only wrapper on mAttachments surfaced to the UI layer for rendering */
private final List<MessagePartData> mReadOnlyAttachments;
/** Keeps track of pending attachments that are being loaded. The pending attachments are
* transient, because they are not persisted to the database and are dropped once we go
* to the background (after the UI calls saveToStorage) */
private final List<PendingAttachmentData> mPendingAttachments;
/** A read-only wrapper on mPendingAttachments surfaced to the UI layer for rendering */
private final List<PendingAttachmentData> mReadOnlyPendingAttachments;
/** Is the current draft a cached copy of what's been saved to the database. If so, we
* may skip loading from database if we are still bound */
private boolean mIsDraftCachedCopy;
/** Whether we are currently asynchronously validating the draft before sending. */
private CheckDraftForSendTask mCheckDraftForSendTask;
public DraftMessageData(final String conversationId) {
mConversationId = conversationId;
mAttachments = new ArrayList<>();
mReadOnlyAttachments = Collections.unmodifiableList(mAttachments);
mPendingAttachments = new ArrayList<>();
mReadOnlyPendingAttachments = Collections.unmodifiableList(mPendingAttachments);
mListeners = new DraftMessageDataEventDispatcher();
mMessageTextStats = new MessageTextStats();
}
public void addListener(final DraftMessageDataListener listener) {
mListeners.add(listener);
}
public void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) {
mSubscriptionDataProvider = provider;
}
public void updateFromMessageData(final MessageData message, final String bindingId) {
// New attachments have arrived - only update if the user hasn't already edited
Assert.notNull(bindingId);
// The draft is now synced with actual MessageData and no longer a cached copy.
mIsDraftCachedCopy = false;
// Do not use the loaded draft if the user began composing a message before the draft loaded
// During config changes (orientation), the text fields preserve their data, so allow them
// to be the same and still consider the draft unchanged by the user
if (isDraftEmpty() || (TextUtils.equals(mMessageText, message.getMessageText()) &&
TextUtils.equals(mMessageSubject, message.getMmsSubject()) &&
mAttachments.isEmpty())) {
// No need to clear as just checked it was empty or a subset
setMessageText(message.getMessageText(), false /* notify */);
setMessageSubject(message.getMmsSubject(), false /* notify */);
for (final MessagePartData part : message.getParts()) {
if (part.isAttachment() && getAttachmentCount() >= getAttachmentLimit()) {
dispatchAttachmentLimitReached();
break;
}
if (part instanceof PendingAttachmentData) {
// This is a pending attachment data from share intent (e.g. an shared image
// that we need to persist locally).
final PendingAttachmentData data = (PendingAttachmentData) part;
Assert.equals(PendingAttachmentData.STATE_PENDING, data.getCurrentState());
addOnePendingAttachmentNoNotify(data, bindingId);
} else if (part.isAttachment()) {
addOneAttachmentNoNotify(part);
}
}
dispatchChanged(ALL_CHANGED);
} else {
// The user has started a new message so we throw out the draft message data if there
// is one but we also loaded the self metadata and need to let our listeners know.
dispatchChanged(SELF_CHANGED);
}
}
/**
* Create a MessageData object containing a copy of all the parts in this DraftMessageData.
*
* @param clearLocalCopy whether we should clear out the in-memory copy of the parts. If we
* are simply pausing/resuming and not sending the message, then we can keep
* @return the MessageData for the draft, null if self id is not set
*/
public MessageData createMessageWithCurrentAttachments(final boolean clearLocalCopy) {
MessageData message;
if (getIsMms()) {
message = MessageData.createDraftMmsMessage(mConversationId, mSelfId,
mMessageText, mMessageSubject);
for (final MessagePartData attachment : mAttachments) {
message.addPart(attachment);
}
} else {
message = MessageData.createDraftSmsMessage(mConversationId, mSelfId,
mMessageText);
}
if (clearLocalCopy) {
// The message now owns all the attachments and the text...
clearLocalDraftCopy();
dispatchChanged(ALL_CHANGED);
} else {
// The draft message becomes a cached copy for UI.
mIsDraftCachedCopy = true;
}
return message;
}
private void clearLocalDraftCopy() {
mIsDraftCachedCopy = false;
mAttachments.clear();
setMessageText("");
setMessageSubject("");
}
public String getConversationId() {
return mConversationId;
}
public String getMessageText() {
return mMessageText;
}
public String getMessageSubject() {
return mMessageSubject;
}
public boolean getIsMms() {
final int selfSubId = getSelfSubId();
return MmsSmsUtils.getRequireMmsForEmailAddress(mIncludeEmailAddress, selfSubId) ||
(mIsGroupConversation && MmsUtils.groupMmsEnabled(selfSubId)) ||
mMessageTextStats.getMessageLengthRequiresMms() || !mAttachments.isEmpty() ||
!TextUtils.isEmpty(mMessageSubject);
}
public boolean getIsGroupMmsConversation() {
return getIsMms() && mIsGroupConversation;
}
public String getSelfId() {
return mSelfId;
}
public int getNumMessagesToBeSent() {
return mMessageTextStats.getNumMessagesToBeSent();
}
public int getCodePointsRemainingInCurrentMessage() {
return mMessageTextStats.getCodePointsRemainingInCurrentMessage();
}
public int getSelfSubId() {
return mSubscriptionDataProvider == null ? ParticipantData.DEFAULT_SELF_SUB_ID :
mSubscriptionDataProvider.getConversationSelfSubId();
}
private void setMessageText(final String messageText, final boolean notify) {
mMessageText = messageText;
mMessageTextStats.updateMessageTextStats(getSelfSubId(), mMessageText);
if (notify) {
dispatchChanged(MESSAGE_TEXT_CHANGED);
}
}
private void setMessageSubject(final String subject, final boolean notify) {
mMessageSubject = subject;
if (notify) {
dispatchChanged(MESSAGE_SUBJECT_CHANGED);
}
}
public void setMessageText(final String messageText) {
setMessageText(messageText, false);
}
public void setMessageSubject(final String subject) {
setMessageSubject(subject, false);
}
public void addAttachments(final Collection<? extends MessagePartData> attachments) {
// If the incoming attachments contains a single-only attachment, we need to clear
// the existing attachments.
for (final MessagePartData data : attachments) {
if (data.isSinglePartOnly()) {
// clear any existing attachments because the attachment we're adding can only
// exist by itself.
destroyAttachments();
break;
}
}
// If the existing attachments contain a single-only attachment, we need to clear the
// existing attachments to make room for the incoming attachment.
for (final MessagePartData data : mAttachments) {
if (data.isSinglePartOnly()) {
// clear any existing attachments because the single attachment can only exist
// by itself
destroyAttachments();
break;
}
}
// If any of the pending attachments contain a single-only attachment, we need to clear the
// existing attachments to make room for the incoming attachment.
for (final MessagePartData data : mPendingAttachments) {
if (data.isSinglePartOnly()) {
// clear any existing attachments because the single attachment can only exist
// by itself
destroyAttachments();
break;
}
}
boolean reachedLimit = false;
for (final MessagePartData data : attachments) {
// Don't break out of loop even if limit has been reached so we can destroy all
// of the over-limit attachments.
reachedLimit |= addOneAttachmentNoNotify(data);
}
if (reachedLimit) {
dispatchAttachmentLimitReached();
}
dispatchChanged(ATTACHMENTS_CHANGED);
}
public boolean containsAttachment(final Uri contentUri) {
for (final MessagePartData existingAttachment : mAttachments) {
if (existingAttachment.getContentUri().equals(contentUri)) {
return true;
}
}
for (final PendingAttachmentData pendingAttachment : mPendingAttachments) {
if (pendingAttachment.getContentUri().equals(contentUri)) {
return true;
}
}
return false;
}
/**
* Try to add one attachment to the attachment list, while guarding against duplicates and
* going over the limit.
* @return true if the attachment limit was reached, false otherwise
*/
private boolean addOneAttachmentNoNotify(final MessagePartData attachment) {
Assert.isTrue(attachment.isAttachment());
// Check duplication.
for (final MessagePartData existingAttachment : mAttachments) {
if (existingAttachment.getContentUri().equals(attachment.getContentUri())) {
// Destroy existing attachment and replace with new attachment instead of destroying
// new one so that mSelectedImages in GalleryGridView could be maintained correctly.
mAttachments.remove(existingAttachment);
existingAttachment.destroyAsync();
addAttachment(attachment, null /*pendingAttachment*/);
return false;
}
}
final boolean reachedLimit = getAttachmentCount() >= getAttachmentLimit();
if (reachedLimit) {
// Never go over the limit.
attachment.destroyAsync();
return true;
} else {
addAttachment(attachment, null /*pendingAttachment*/);
return false;
}
}
private void addAttachment(final MessagePartData attachment,
final PendingAttachmentData pendingAttachment) {
if (attachment != null && attachment.isSinglePartOnly()) {
// clear any existing attachments because the attachment we're adding can only
// exist by itself.
destroyAttachments();
}
if (pendingAttachment != null && pendingAttachment.isSinglePartOnly()) {
// clear any existing attachments because the attachment we're adding can only
// exist by itself.
destroyAttachments();
}
// If the existing attachments contain a single-only attachment, we need to clear the
// existing attachments to make room for the incoming attachment.
for (final MessagePartData data : mAttachments) {
if (data.isSinglePartOnly()) {
// clear any existing attachments because the single attachment can only exist
// by itself
destroyAttachments();
break;
}
}
// If any of the pending attachments contain a single-only attachment, we need to clear the
// existing attachments to make room for the incoming attachment.
for (final MessagePartData data : mPendingAttachments) {
if (data.isSinglePartOnly()) {
// clear any existing attachments because the single attachment can only exist
// by itself
destroyAttachments();
break;
}
}
if (attachment != null) {
mAttachments.add(attachment);
} else if (pendingAttachment != null) {
mPendingAttachments.add(pendingAttachment);
}
}
public void addPendingAttachment(final PendingAttachmentData pendingAttachment,
final BindingBase<DraftMessageData> binding) {
final boolean reachedLimit = addOnePendingAttachmentNoNotify(pendingAttachment,
binding.getBindingId());
if (reachedLimit) {
dispatchAttachmentLimitReached();
}
dispatchChanged(ATTACHMENTS_CHANGED);
}
/**
* Try to add one pending attachment, while guarding against duplicates and
* going over the limit.
* @return true if the attachment limit was reached, false otherwise
*/
private boolean addOnePendingAttachmentNoNotify(final PendingAttachmentData pendingAttachment,
final String bindingId) {
final boolean reachedLimit = getAttachmentCount() >= getAttachmentLimit();
if (reachedLimit || containsAttachment(pendingAttachment.getContentUri())) {
// Never go over the limit. Never add duplicated attachments.
pendingAttachment.destroyAsync();
return reachedLimit;
} else {
Assert.isTrue(!mPendingAttachments.contains(pendingAttachment));
Assert.equals(PendingAttachmentData.STATE_PENDING, pendingAttachment.getCurrentState());
addAttachment(null /*attachment*/, pendingAttachment);
pendingAttachment.loadAttachmentForDraft(this, bindingId);
return false;
}
}
public void setSelfId(final String selfId, final boolean notify) {
LogUtil.d(LogUtil.BUGLE_TAG, "DraftMessageData: set selfId=" + selfId
+ " for conversationId=" + mConversationId);
mSelfId = selfId;
if (notify) {
dispatchChanged(SELF_CHANGED);
}
}
public boolean hasAttachments() {
return !mAttachments.isEmpty();
}
public boolean hasPendingAttachments() {
return !mPendingAttachments.isEmpty();
}
private int getAttachmentCount() {
return mAttachments.size() + mPendingAttachments.size();
}
private int getVideoAttachmentCount() {
int count = 0;
for (MessagePartData part : mAttachments) {
if (part.isVideo()) {
count++;
}
}
for (MessagePartData part : mPendingAttachments) {
if (part.isVideo()) {
count++;
}
}
return count;
}
private int getAttachmentLimit() {
return BugleGservicesKeys.MMS_ATTACHMENT_LIMIT_DEFAULT;
}
public void removeAttachment(final MessagePartData attachment) {
for (final MessagePartData existingAttachment : mAttachments) {
if (existingAttachment.getContentUri().equals(attachment.getContentUri())) {
mAttachments.remove(existingAttachment);
existingAttachment.destroyAsync();
dispatchChanged(ATTACHMENTS_CHANGED);
break;
}
}
}
public void removeExistingAttachments(final Set<MessagePartData> attachmentsToRemove) {
boolean removed = false;
final Iterator<MessagePartData> iterator = mAttachments.iterator();
while (iterator.hasNext()) {
final MessagePartData existingAttachment = iterator.next();
if (attachmentsToRemove.contains(existingAttachment)) {
iterator.remove();
existingAttachment.destroyAsync();
removed = true;
}
}
if (removed) {
dispatchChanged(ATTACHMENTS_CHANGED);
}
}
public void removePendingAttachment(final PendingAttachmentData pendingAttachment) {
for (final PendingAttachmentData existingAttachment : mPendingAttachments) {
if (existingAttachment.getContentUri().equals(pendingAttachment.getContentUri())) {
mPendingAttachments.remove(pendingAttachment);
pendingAttachment.destroyAsync();
dispatchChanged(ATTACHMENTS_CHANGED);
break;
}
}
}
public void updatePendingAttachment(final MessagePartData updatedAttachment,
final PendingAttachmentData pendingAttachment) {
for (final PendingAttachmentData existingAttachment : mPendingAttachments) {
if (existingAttachment.getContentUri().equals(pendingAttachment.getContentUri())) {
mPendingAttachments.remove(pendingAttachment);
if (pendingAttachment.isSinglePartOnly()) {
updatedAttachment.setSinglePartOnly(true);
}
mAttachments.add(updatedAttachment);
dispatchChanged(ATTACHMENTS_CHANGED);
return;
}
}
// If we are here, this means the pending attachment has been dropped before the task
// to load it was completed. In this case destroy the temporarily staged file since it
// is no longer needed.
updatedAttachment.destroyAsync();
}
/**
* Remove the attachments from the draft and notify any listeners.
* @param flags typically this will be ATTACHMENTS_CHANGED. When attachments are cleared in a
* widget, flags will also contain WIDGET_CHANGED.
*/
public void clearAttachments(final int flags) {
destroyAttachments();
dispatchChanged(flags);
}
public List<MessagePartData> getReadOnlyAttachments() {
return mReadOnlyAttachments;
}
public List<PendingAttachmentData> getReadOnlyPendingAttachments() {
return mReadOnlyPendingAttachments;
}
public boolean loadFromStorage(final BindingBase<DraftMessageData> binding,
final MessageData optionalIncomingDraft, boolean clearLocalDraft) {
LogUtil.d(LogUtil.BUGLE_TAG, "DraftMessageData: "
+ (optionalIncomingDraft == null ? "loading" : "setting")
+ " for conversationId=" + mConversationId);
if (clearLocalDraft) {
clearLocalDraftCopy();
}
final boolean isDraftCachedCopy = mIsDraftCachedCopy;
mIsDraftCachedCopy = false;
// Before reading message from db ensure the caller is bound to us (and knows the id)
if (mMonitor == null && !isDraftCachedCopy && isBound(binding.getBindingId())) {
mMonitor = ReadDraftDataAction.readDraftData(mConversationId,
optionalIncomingDraft, binding.getBindingId(), this);
return true;
}
return false;
}
/**
* Saves the current draft to db. This will save the draft and drop any pending attachments
* we have. The UI typically goes into the background when this is called, and instead of
* trying to persist the state of the pending attachments (the app may be killed, the activity
* may be destroyed), we simply drop the pending attachments for consistency.
*/
public void saveToStorage(final BindingBase<DraftMessageData> binding) {
saveToStorageInternal(binding);
dropPendingAttachments();
}
private void saveToStorageInternal(final BindingBase<DraftMessageData> binding) {
// Create MessageData to store to db, but don't clear the in-memory copy so UI will
// continue to display it.
// If self id is null then we'll not attempt to change the conversation's self id.
final MessageData message = createMessageWithCurrentAttachments(false /* clearLocalCopy */);
// Before writing message to db ensure the caller is bound to us (and knows the id)
if (isBound(binding.getBindingId())){
WriteDraftMessageAction.writeDraftMessage(mConversationId, message);
}
}
/**
* Called when we are ready to send the message. This will assemble/return the MessageData for
* sending and clear the local draft data, both from memory and from DB. This will also bind
* the message data with a self Id through which the message will be sent.
*
* @param binding the binding object from our consumer. We need to make sure we are still bound
* to that binding before saving to storage.
*/
public MessageData prepareMessageForSending(final BindingBase<DraftMessageData> binding) {
// We can't send the message while there's still stuff pending.
Assert.isTrue(!hasPendingAttachments());
mSending = true;
// Assembles the message to send and empty working draft data.
// If self id is null then message is sent with conversation's self id.
final MessageData messageToSend =
createMessageWithCurrentAttachments(true /* clearLocalCopy */);
// Note sending message will empty the draft data in DB.
mSending = false;
return messageToSend;
}
public boolean isSending() {
return mSending;
}
@Override // ReadDraftMessageActionListener.onReadDraftMessageSucceeded
public void onReadDraftDataSucceeded(final ReadDraftDataAction action, final Object data,
final MessageData message, final ConversationListItemData conversation) {
final String bindingId = (String) data;
// Before passing draft message on to ui ensure the data is bound to the same bindingid
if (isBound(bindingId)) {
mSelfId = message.getSelfId();
mIsGroupConversation = conversation.getIsGroup();
mIncludeEmailAddress = conversation.getIncludeEmailAddress();
updateFromMessageData(message, bindingId);
LogUtil.d(LogUtil.BUGLE_TAG, "DraftMessageData: draft loaded. "
+ "conversationId=" + mConversationId + " selfId=" + mSelfId);
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "DraftMessageData: draft loaded but not bound. "
+ "conversationId=" + mConversationId);
}
mMonitor = null;
}
@Override // ReadDraftMessageActionListener.onReadDraftDataFailed
public void onReadDraftDataFailed(final ReadDraftDataAction action, final Object data) {
LogUtil.w(LogUtil.BUGLE_TAG, "DraftMessageData: draft not loaded. "
+ "conversationId=" + mConversationId);
// The draft is now synced with actual MessageData and no longer a cached copy.
mIsDraftCachedCopy = false;
// Just clear the monitor - no update to draft data
mMonitor = null;
}
/**
* Check if Bugle is default sms app
*/
public boolean getIsDefaultSmsApp() {
return PhoneUtils.getDefault().isDefaultSmsApp();
}
@Override //BindableData.unregisterListeners
protected void unregisterListeners() {
if (mMonitor != null) {
mMonitor.unregister();
}
mMonitor = null;
mListeners.clear();
}
private void destroyAttachments() {
for (final MessagePartData attachment : mAttachments) {
attachment.destroyAsync();
}
mAttachments.clear();
mPendingAttachments.clear();
}
private void dispatchChanged(final int changeFlags) {
// No change is expected to be made to the draft if it is in cached copy state.
if (mIsDraftCachedCopy) {
return;
}
// Any change in the draft will cancel any pending draft checking task, since the
// size/status of the draft may have changed.
if (mCheckDraftForSendTask != null) {
mCheckDraftForSendTask.cancel();
mCheckDraftForSendTask = null;
}
mListeners.onDraftChanged(this, changeFlags);
}
private void dispatchAttachmentLimitReached() {
mListeners.onDraftAttachmentLimitReached(this);
}
/**
* Drop any pending attachments that haven't finished. This is called after the UI goes to
* the background and we persist the draft data to the database.
*/
private void dropPendingAttachments() {
mPendingAttachments.clear();
}
private boolean isDraftEmpty() {
return TextUtils.isEmpty(mMessageText) && mAttachments.isEmpty() &&
TextUtils.isEmpty(mMessageSubject);
}
public boolean isCheckingDraft() {
return mCheckDraftForSendTask != null && !mCheckDraftForSendTask.isCancelled();
}
public void checkDraftForAction(final boolean checkMessageSize, final int selfSubId,
final CheckDraftTaskCallback callback, final Binding<DraftMessageData> binding) {
new CheckDraftForSendTask(checkMessageSize, selfSubId, callback, binding)
.execute();
}
/**
* Allows us to have multiple data listeners for DraftMessageData
*/
private class DraftMessageDataEventDispatcher
extends ArrayList<DraftMessageDataListener>
implements DraftMessageDataListener {
@Override
@RunsOnMainThread
public void onDraftChanged(DraftMessageData data, int changeFlags) {
Assert.isMainThread();
for (final DraftMessageDataListener listener : this) {
listener.onDraftChanged(data, changeFlags);
}
}
@Override
@RunsOnMainThread
public void onDraftAttachmentLimitReached(DraftMessageData data) {
Assert.isMainThread();
for (final DraftMessageDataListener listener : this) {
listener.onDraftAttachmentLimitReached(data);
}
}
@Override
@RunsOnMainThread
public void onDraftAttachmentLoadFailed() {
Assert.isMainThread();
for (final DraftMessageDataListener listener : this) {
listener.onDraftAttachmentLoadFailed();
}
}
}
public interface CheckDraftTaskCallback {
void onDraftChecked(DraftMessageData data, int result);
}
public class CheckDraftForSendTask {
public static final int RESULT_PASSED = 0;
public static final int RESULT_HAS_PENDING_ATTACHMENTS = 1;
public static final int RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS = 2;
public static final int RESULT_MESSAGE_OVER_LIMIT = 3;
public static final int RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED = 4;
public static final int RESULT_SIM_NOT_READY = 5;
private final boolean mCheckMessageSize;
private final int mSelfSubId;
private final CheckDraftTaskCallback mCallback;
private final String mBindingId;
private final List<MessagePartData> mAttachmentsCopy;
ScheduledExecutorService mExecutor = Executors.newScheduledThreadPool(2);
private final Handler mHandler = new Handler(Looper.getMainLooper());
private Future<?> mFuture;
private boolean mCancelled;
private int mPreExecuteResult = RESULT_PASSED;
public CheckDraftForSendTask(final boolean checkMessageSize, final int selfSubId,
final CheckDraftTaskCallback callback, final Binding<DraftMessageData> binding) {
mCheckMessageSize = checkMessageSize;
mSelfSubId = selfSubId;
mCallback = callback;
mBindingId = binding.getBindingId();
// Obtain an immutable copy of the attachment list so we can operate on it in the
// background thread.
mAttachmentsCopy = new ArrayList<>(mAttachments);
mCheckDraftForSendTask = this;
}
public void execute() {
onPreExecute();
mFuture = mExecutor.submit(() -> {
final int result;
if (mPreExecuteResult != RESULT_PASSED) {
result = mPreExecuteResult;
} else if (mCheckMessageSize && getIsMessageOverLimit()) {
result = RESULT_MESSAGE_OVER_LIMIT;
} else {
result = RESULT_PASSED;
}
mHandler.post(() -> {
mCheckDraftForSendTask = null;
// Only call back if we are bound to the original binding.
if (isBound(mBindingId) && !mCancelled) {
mCallback.onDraftChecked(DraftMessageData.this, result);
} else {
if (!isBound(mBindingId)) {
LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: draft not bound");
}
if (mCancelled) {
LogUtil.w(LogUtil.BUGLE_TAG,
"Message can't be sent: draft is cancelled");
}
}
});
});
mExecutor.schedule(this::cancel, 10, TimeUnit.SECONDS);
}
public void cancel() {
if (!mFuture.isDone()) {
mFuture.cancel(true);
mCancelled = true;
mCheckDraftForSendTask = null;
}
}
public boolean isCancelled() {
return mCancelled;
}
protected void onPreExecute() {
// Perform checking work that can happen on the main thread.
if (hasPendingAttachments()) {
mPreExecuteResult = RESULT_HAS_PENDING_ATTACHMENTS;
return;
}
if (getIsGroupMmsConversation()) {
try {
if (TextUtils.isEmpty(PhoneUtils.get(mSelfSubId).getSelfRawNumber(true))) {
mPreExecuteResult = RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS;
return;
}
} catch (IllegalStateException e) {
// This happens when there is no active subscription, e.g. on Nova
// when the phone switches carrier.
mPreExecuteResult = RESULT_SIM_NOT_READY;
return;
}
}
if (getVideoAttachmentCount() > MmsUtils.MAX_VIDEO_ATTACHMENT_COUNT) {
mPreExecuteResult = RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED;
return;
}
}
/**
* 1. Check if the draft message contains too many attachments to send
* 2. Computes the minimum size that this message could be compressed/downsampled/encoded
* before sending and check if it meets the carrier max size for sending.
* @see MessagePartData#getMinimumSizeInBytesForSending()
*/
@DoesNotRunOnMainThread
private boolean getIsMessageOverLimit() {
Assert.isNotMainThread();
if (mAttachmentsCopy.size() > getAttachmentLimit()) {
return true;
}
// Aggregate the size from all the attachments.
long totalSize = 0;
for (final MessagePartData attachment : mAttachmentsCopy) {
totalSize += attachment.getMinimumSizeInBytesForSending();
}
return totalSize > MmsConfig.get(mSelfSubId).getMaxMessageSize();
}
}
public void onPendingAttachmentLoadFailed(PendingAttachmentData data) {
mListeners.onDraftAttachmentLoadFailed();
}
}
@@ -0,0 +1,90 @@
/*
* Copyright (C) 2015 The Android Open Source 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 com.android.messaging.datamodel.action.ActionMonitor;
import com.android.messaging.datamodel.action.GetOrCreateConversationAction;
import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener;
import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionMonitor;
import com.android.messaging.datamodel.binding.BindableData;
import com.android.messaging.datamodel.binding.BindingBase;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.RunsOnMainThread;
import com.android.messaging.util.LogUtil;
public class LaunchConversationData extends BindableData implements
GetOrCreateConversationActionListener {
public interface LaunchConversationDataListener {
void onGetOrCreateNewConversation(String conversationId);
void onGetOrCreateNewConversationFailed();
}
private LaunchConversationDataListener mListener;
private GetOrCreateConversationActionMonitor mMonitor;
public LaunchConversationData(final LaunchConversationDataListener listener) {
mListener = listener;
}
@Override
protected void unregisterListeners() {
mListener = null;
if (mMonitor != null) {
mMonitor.unregister();
}
mMonitor = null;
}
public void getOrCreateConversation(final BindingBase<LaunchConversationData> binding,
final String[] recipients) {
final String bindingId = binding.getBindingId();
// Start a new conversation from the list of contacts.
if (isBound(bindingId) && mMonitor == null) {
mMonitor = GetOrCreateConversationAction.getOrCreateConversation(recipients,
bindingId, this);
}
}
@Override
@RunsOnMainThread
public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor,
final Object data, final String conversationId) {
Assert.isTrue(monitor == mMonitor);
Assert.isTrue(conversationId != null);
final String bindingId = (String) data;
if (isBound(bindingId) && mListener != null) {
mListener.onGetOrCreateNewConversation(conversationId);
}
mMonitor = null;
}
@Override
@RunsOnMainThread
public void onGetOrCreateConversationFailed(final ActionMonitor monitor,
final Object data) {
Assert.isTrue(monitor == mMonitor);
final String bindingId = (String) data;
if (isBound(bindingId) && mListener != null) {
mListener.onGetOrCreateNewConversationFailed();
}
LogUtil.e(LogUtil.BUGLE_TAG, "onGetOrCreateConversationFailed");
mMonitor = null;
}
}
@@ -0,0 +1,58 @@
/*
* 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 com.android.messaging.datamodel.binding.BindableData;
import com.android.messaging.util.BuglePrefs;
import com.android.messaging.util.BuglePrefsKeys;
/**
* Services data needs for MediaPicker.
*/
public class MediaPickerData extends BindableData {
private final Context mContext;
public MediaPickerData(final Context context) {
mContext = context;
}
/**
* Gets the last selected chooser index, or -1 if no selection has been saved.
*/
public int getSelectedChooserIndex() {
return BuglePrefs.getApplicationPrefs().getInt(
BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX,
BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX_DEFAULT);
}
/**
* Saves the selected media chooser index.
* @param selectedIndex the selected media chooser index.
*/
public void saveSelectedChooserIndex(final int selectedIndex) {
BuglePrefs.getApplicationPrefs().putInt(BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX,
selectedIndex);
}
@Override
protected void unregisterListeners() {
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (C) 2015 The Android Open Source 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.graphics.Rect;
import android.net.Uri;
public class MediaPickerMessagePartData extends MessagePartData {
private final Rect mStartRect;
public MediaPickerMessagePartData(final Rect startRect, final String contentType,
final Uri contentUri, final int width, final int height) {
this(startRect, null /* messageText */, contentType, contentUri, width, height);
}
public MediaPickerMessagePartData(final Rect startRect, final String messageText,
final String contentType, final Uri contentUri, final int width, final int height) {
this(startRect, messageText, contentType, contentUri, width, height,
false /*onlySingleAttachment*/);
}
public MediaPickerMessagePartData(final Rect startRect, final String contentType,
final Uri contentUri, final int width, final int height,
final boolean onlySingleAttachment) {
this(startRect, null /* messageText */, contentType, contentUri, width, height,
onlySingleAttachment);
}
public MediaPickerMessagePartData(final Rect startRect, final String messageText,
final String contentType, final Uri contentUri, final int width, final int height,
final boolean onlySingleAttachment) {
super(messageText, contentType, contentUri, width, height, onlySingleAttachment);
mStartRect = startRect;
}
/**
* @return The starting rect to animate the attachment preview from in order to perform a smooth
* transition
*/
public Rect getStartRect() {
return mStartRect;
}
/**
* Modify the start rect of the attachment.
*/
public void setStartRect(final Rect startRect) {
mStartRect.set(startRect);
}
}
@@ -0,0 +1,911 @@
/*
* 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.data;
import android.content.ContentValues;
import android.database.Cursor;
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.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.BugleGservicesKeys;
import com.android.messaging.util.Dates;
import com.android.messaging.util.OsUtil;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class MessageData implements Parcelable {
private static final String[] sProjection = {
MessageColumns._ID,
MessageColumns.CONVERSATION_ID,
MessageColumns.SENDER_PARTICIPANT_ID,
MessageColumns.SELF_PARTICIPANT_ID,
MessageColumns.SENT_TIMESTAMP,
MessageColumns.RECEIVED_TIMESTAMP,
MessageColumns.SEEN,
MessageColumns.READ,
MessageColumns.PROTOCOL,
MessageColumns.STATUS,
MessageColumns.SMS_MESSAGE_URI,
MessageColumns.SMS_PRIORITY,
MessageColumns.SMS_MESSAGE_SIZE,
MessageColumns.MMS_SUBJECT,
MessageColumns.MMS_TRANSACTION_ID,
MessageColumns.MMS_CONTENT_LOCATION,
MessageColumns.MMS_EXPIRY,
MessageColumns.RAW_TELEPHONY_STATUS,
MessageColumns.RETRY_START_TIMESTAMP,
};
private static final int INDEX_ID = 0;
private static final int INDEX_CONVERSATION_ID = 1;
private static final int INDEX_PARTICIPANT_ID = 2;
private static final int INDEX_SELF_ID = 3;
private static final int INDEX_SENT_TIMESTAMP = 4;
private static final int INDEX_RECEIVED_TIMESTAMP = 5;
private static final int INDEX_SEEN = 6;
private static final int INDEX_READ = 7;
private static final int INDEX_PROTOCOL = 8;
private static final int INDEX_BUGLE_STATUS = 9;
private static final int INDEX_SMS_MESSAGE_URI = 10;
private static final int INDEX_SMS_PRIORITY = 11;
private static final int INDEX_SMS_MESSAGE_SIZE = 12;
private static final int INDEX_MMS_SUBJECT = 13;
private static final int INDEX_MMS_TRANSACTION_ID = 14;
private static final int INDEX_MMS_CONTENT_LOCATION = 15;
private static final int INDEX_MMS_EXPIRY = 16;
private static final int INDEX_RAW_TELEPHONY_STATUS = 17;
private static final int INDEX_RETRY_START_TIMESTAMP = 18;
// SQL statement to insert a "complete" message row (columns based on the projection above).
private static final String INSERT_MESSAGE_SQL =
"INSERT INTO " + DatabaseHelper.MESSAGES_TABLE + " ( "
+ TextUtils.join(", ", Arrays.copyOfRange(sProjection, 1,
INDEX_RETRY_START_TIMESTAMP + 1))
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
private String mMessageId;
private String mConversationId;
private String mParticipantId;
private String mSelfId;
private long mSentTimestamp;
private long mReceivedTimestamp;
private boolean mSeen;
private boolean mRead;
private int mProtocol;
private Uri mSmsMessageUri;
private int mSmsPriority;
private long mSmsMessageSize;
private String mMmsSubject;
private String mMmsTransactionId;
private String mMmsContentLocation;
private long mMmsExpiry;
private int mRawStatus;
private int mStatus;
private final ArrayList<MessagePartData> mParts;
private long mRetryStartTimestamp;
// PROTOCOL Values
public static final int PROTOCOL_UNKNOWN = -1; // Unknown type
public static final int PROTOCOL_SMS = 0; // SMS message
public static final int PROTOCOL_MMS = 1; // MMS message
public static final int PROTOCOL_MMS_PUSH_NOTIFICATION = 2; // MMS WAP push notification
// Bugle STATUS Values
public static final int BUGLE_STATUS_UNKNOWN = 0;
// Outgoing
public static final int BUGLE_STATUS_OUTGOING_COMPLETE = 1;
public static final int BUGLE_STATUS_OUTGOING_DELIVERED = 2;
// Transitions to either YET_TO_SEND or SEND_AFTER_PROCESSING depending attachments.
public static final int BUGLE_STATUS_OUTGOING_DRAFT = 3;
public static final int BUGLE_STATUS_OUTGOING_YET_TO_SEND = 4;
public static final int BUGLE_STATUS_OUTGOING_SENDING = 5;
public static final int BUGLE_STATUS_OUTGOING_RESENDING = 6;
public static final int BUGLE_STATUS_OUTGOING_AWAITING_RETRY = 7;
public static final int BUGLE_STATUS_OUTGOING_FAILED = 8;
public static final int BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER = 9;
// Incoming
public static final int BUGLE_STATUS_INCOMING_COMPLETE = 100;
public static final int BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD = 101;
public static final int BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD = 102;
public static final int BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING = 103;
public static final int BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD = 104;
public static final int BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING = 105;
public static final int BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED = 106;
public static final int BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE = 107;
public static String getStatusDescription(int status) {
switch (status) {
case BUGLE_STATUS_UNKNOWN:
return "UNKNOWN";
case BUGLE_STATUS_OUTGOING_COMPLETE:
return "OUTGOING_COMPLETE";
case BUGLE_STATUS_OUTGOING_DELIVERED:
return "OUTGOING_DELIVERED";
case BUGLE_STATUS_OUTGOING_DRAFT:
return "OUTGOING_DRAFT";
case BUGLE_STATUS_OUTGOING_YET_TO_SEND:
return "OUTGOING_YET_TO_SEND";
case BUGLE_STATUS_OUTGOING_SENDING:
return "OUTGOING_SENDING";
case BUGLE_STATUS_OUTGOING_RESENDING:
return "OUTGOING_RESENDING";
case BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
return "OUTGOING_AWAITING_RETRY";
case BUGLE_STATUS_OUTGOING_FAILED:
return "OUTGOING_FAILED";
case BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER:
return "OUTGOING_FAILED_EMERGENCY_NUMBER";
case BUGLE_STATUS_INCOMING_COMPLETE:
return "INCOMING_COMPLETE";
case BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD:
return "INCOMING_YET_TO_MANUAL_DOWNLOAD";
case BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD:
return "INCOMING_RETRYING_MANUAL_DOWNLOAD";
case BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING:
return "INCOMING_MANUAL_DOWNLOADING";
case BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD:
return "INCOMING_RETRYING_AUTO_DOWNLOAD";
case BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING:
return "INCOMING_AUTO_DOWNLOADING";
case BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED:
return "INCOMING_DOWNLOAD_FAILED";
case BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE:
return "INCOMING_EXPIRED_OR_NOT_AVAILABLE";
default:
return status + " (check MessageData)";
}
}
// All incoming messages expect to have status >= BUGLE_STATUS_FIRST_INCOMING
public static final int BUGLE_STATUS_FIRST_INCOMING = BUGLE_STATUS_INCOMING_COMPLETE;
// Detailed MMS failures. Most of the values are defined in PduHeaders. However, a few are
// defined here instead. These are never returned in the MMS HTTP response, but are used
// internally. The values here must not conflict with any of the existing PduHeader values.
public static final int RAW_TELEPHONY_STATUS_UNDEFINED = MmsUtils.PDU_HEADER_VALUE_UNDEFINED;
public static final int RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG = 10000;
// Unknown result code for MMS sending/downloading. This is used as the default value
// for result code returned from platform MMS API.
public static final int UNKNOWN_RESULT_CODE = 0;
/**
* Create an "empty" message
*/
public MessageData() {
mParts = new ArrayList<>();
}
public static String[] getProjection() {
return sProjection;
}
/**
* Create a draft message for a particular conversation based on supplied content
*/
public static MessageData createDraftMessage(final String conversationId,
final String selfId, final MessageData content) {
final MessageData message = new MessageData();
message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT;
message.mProtocol = PROTOCOL_UNKNOWN;
message.mConversationId = conversationId;
message.mParticipantId = selfId;
message.mReceivedTimestamp = System.currentTimeMillis();
if (content == null) {
message.mParts.add(MessagePartData.createTextMessagePart(""));
} else {
if (!TextUtils.isEmpty(content.mParticipantId)) {
message.mParticipantId = content.mParticipantId;
}
if (!TextUtils.isEmpty(content.mMmsSubject)) {
message.mMmsSubject = content.mMmsSubject;
}
for (final MessagePartData part : content.getParts()) {
message.mParts.add(part);
}
}
message.mSelfId = selfId;
return message;
}
/**
* Create a draft sms message for a particular conversation
*/
public static MessageData createDraftSmsMessage(final String conversationId,
final String selfId, final String messageText) {
final MessageData message = new MessageData();
message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT;
message.mProtocol = PROTOCOL_SMS;
message.mConversationId = conversationId;
message.mParticipantId = selfId;
message.mSelfId = selfId;
message.mParts.add(MessagePartData.createTextMessagePart(messageText));
message.mReceivedTimestamp = System.currentTimeMillis();
return message;
}
/**
* Create a draft mms message for a particular conversation
*/
public static MessageData createDraftMmsMessage(final String conversationId,
final String selfId, final String messageText, final String subjectText) {
final MessageData message = new MessageData();
message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT;
message.mProtocol = PROTOCOL_MMS;
message.mConversationId = conversationId;
message.mParticipantId = selfId;
message.mSelfId = selfId;
message.mMmsSubject = subjectText;
message.mReceivedTimestamp = System.currentTimeMillis();
if (!TextUtils.isEmpty(messageText)) {
message.mParts.add(MessagePartData.createTextMessagePart(messageText));
}
return message;
}
/**
* Create a message received from a particular number in a particular conversation
*/
public static MessageData createReceivedSmsMessage(final Uri uri, final String conversationId,
final String participantId, final String selfId, final String messageText,
final String subject, final long sent, final long recieved,
final boolean seen, final boolean read) {
final MessageData message = new MessageData();
message.mSmsMessageUri = uri;
message.mConversationId = conversationId;
message.mParticipantId = participantId;
message.mSelfId = selfId;
message.mProtocol = PROTOCOL_SMS;
message.mStatus = BUGLE_STATUS_INCOMING_COMPLETE;
message.mMmsSubject = subject;
message.mReceivedTimestamp = recieved;
message.mSentTimestamp = sent;
message.mParts.add(MessagePartData.createTextMessagePart(messageText));
message.mSeen = seen;
message.mRead = read;
return message;
}
/**
* Create a message not yet associated with a particular conversation
*/
public static MessageData createSharedMessage(final String messageText,
final String subjectText) {
final MessageData message = new MessageData();
message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT;
message.mMmsSubject = subjectText;
if (!TextUtils.isEmpty(messageText)) {
message.mParts.add(MessagePartData.createTextMessagePart(messageText));
}
return message;
}
/**
* Create a message from Sms table fields
*/
public static MessageData createSmsMessage(final String messageUri, final String participantId,
final String selfId, final String conversationId, final int bugleStatus,
final boolean seen, final boolean read, final long sent,
final long recieved, final String messageText) {
final MessageData message = new MessageData();
message.mParticipantId = participantId;
message.mSelfId = selfId;
message.mConversationId = conversationId;
message.mSentTimestamp = sent;
message.mReceivedTimestamp = recieved;
message.mSeen = seen;
message.mRead = read;
message.mProtocol = PROTOCOL_SMS;
message.mStatus = bugleStatus;
message.mSmsMessageUri = Uri.parse(messageUri);
message.mParts.add(MessagePartData.createTextMessagePart(messageText));
return message;
}
/**
* Create a message from Mms table fields
*/
public static MessageData createMmsMessage(final String messageUri, final String participantId,
final String selfId, final String conversationId, final boolean isNotification,
final int bugleStatus, final String contentLocation, final String transactionId,
final int smsPriority, final String subject, final boolean seen, final boolean read,
final long size, final int rawStatus, final long expiry, final long sent,
final long received) {
final MessageData message = new MessageData();
message.mParticipantId = participantId;
message.mSelfId = selfId;
message.mConversationId = conversationId;
message.mSentTimestamp = sent;
message.mReceivedTimestamp = received;
message.mMmsContentLocation = contentLocation;
message.mMmsTransactionId = transactionId;
message.mSeen = seen;
message.mRead = read;
message.mStatus = bugleStatus;
message.mProtocol = (isNotification ? PROTOCOL_MMS_PUSH_NOTIFICATION : PROTOCOL_MMS);
message.mSmsMessageUri = Uri.parse(messageUri);
message.mSmsPriority = smsPriority;
message.mSmsMessageSize = size;
message.mMmsSubject = subject;
message.mMmsExpiry = expiry;
message.mRawStatus = rawStatus;
if (bugleStatus == BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD ||
bugleStatus == BUGLE_STATUS_OUTGOING_RESENDING) {
// Set the retry start timestamp if this message is already in process of retrying
// Either as autodownload is starting or sending already in progress (MMS update)
message.mRetryStartTimestamp = received;
}
return message;
}
public void addPart(final MessagePartData part) {
if (part instanceof PendingAttachmentData) {
// Pending attachments may only be added to shared message data that's not associated
// with any particular conversation, in order to store shared images.
Assert.isTrue(mConversationId == null);
}
mParts.add(part);
}
public Iterable<MessagePartData> getParts() {
return mParts;
}
public void bind(final Cursor cursor) {
mMessageId = cursor.getString(INDEX_ID);
mConversationId = cursor.getString(INDEX_CONVERSATION_ID);
mParticipantId = cursor.getString(INDEX_PARTICIPANT_ID);
mSelfId = cursor.getString(INDEX_SELF_ID);
mSentTimestamp = cursor.getLong(INDEX_SENT_TIMESTAMP);
mReceivedTimestamp = cursor.getLong(INDEX_RECEIVED_TIMESTAMP);
mSeen = (cursor.getInt(INDEX_SEEN) != 0);
mRead = (cursor.getInt(INDEX_READ) != 0);
mProtocol = cursor.getInt(INDEX_PROTOCOL);
mStatus = cursor.getInt(INDEX_BUGLE_STATUS);
final String smsMessageUri = cursor.getString(INDEX_SMS_MESSAGE_URI);
mSmsMessageUri = (smsMessageUri == null) ? null : Uri.parse(smsMessageUri);
mSmsPriority = cursor.getInt(INDEX_SMS_PRIORITY);
mSmsMessageSize = cursor.getLong(INDEX_SMS_MESSAGE_SIZE);
mMmsExpiry = cursor.getLong(INDEX_MMS_EXPIRY);
mRawStatus = cursor.getInt(INDEX_RAW_TELEPHONY_STATUS);
mMmsSubject = cursor.getString(INDEX_MMS_SUBJECT);
mMmsTransactionId = cursor.getString(INDEX_MMS_TRANSACTION_ID);
mMmsContentLocation = cursor.getString(INDEX_MMS_CONTENT_LOCATION);
mRetryStartTimestamp = cursor.getLong(INDEX_RETRY_START_TIMESTAMP);
}
/**
* Bind to the draft message data for a conversation. The conversation's self id is used as
* the draft's self id.
*/
public void bindDraft(final Cursor cursor, final String conversationSelfId) {
bind(cursor);
mSelfId = conversationSelfId;
}
protected static String getParticipantId(final Cursor cursor) {
return cursor.getString(INDEX_PARTICIPANT_ID);
}
public void populate(final ContentValues values) {
values.put(MessageColumns.CONVERSATION_ID, mConversationId);
values.put(MessageColumns.SENDER_PARTICIPANT_ID, mParticipantId);
values.put(MessageColumns.SELF_PARTICIPANT_ID, mSelfId);
values.put(MessageColumns.SENT_TIMESTAMP, mSentTimestamp);
values.put(MessageColumns.RECEIVED_TIMESTAMP, mReceivedTimestamp);
values.put(MessageColumns.SEEN, mSeen ? 1 : 0);
values.put(MessageColumns.READ, mRead ? 1 : 0);
values.put(MessageColumns.PROTOCOL, mProtocol);
values.put(MessageColumns.STATUS, mStatus);
final String smsMessageUri = ((mSmsMessageUri == null) ? null : mSmsMessageUri.toString());
values.put(MessageColumns.SMS_MESSAGE_URI, smsMessageUri);
values.put(MessageColumns.SMS_PRIORITY, mSmsPriority);
values.put(MessageColumns.SMS_MESSAGE_SIZE, mSmsMessageSize);
values.put(MessageColumns.MMS_EXPIRY, mMmsExpiry);
values.put(MessageColumns.MMS_SUBJECT, mMmsSubject);
values.put(MessageColumns.MMS_TRANSACTION_ID, mMmsTransactionId);
values.put(MessageColumns.MMS_CONTENT_LOCATION, mMmsContentLocation);
values.put(MessageColumns.RAW_TELEPHONY_STATUS, mRawStatus);
values.put(MessageColumns.RETRY_START_TIMESTAMP, mRetryStartTimestamp);
}
/**
* Note this is not thread safe so callers need to make sure they own the wrapper + statements
* while they call this and use the returned value.
*/
public SQLiteStatement getInsertStatement(final DatabaseWrapper db) {
final SQLiteStatement insert = db.getStatementInTransaction(
DatabaseWrapper.INDEX_INSERT_MESSAGE, INSERT_MESSAGE_SQL);
insert.clearBindings();
insert.bindString(INDEX_CONVERSATION_ID, mConversationId);
insert.bindString(INDEX_PARTICIPANT_ID, mParticipantId);
insert.bindString(INDEX_SELF_ID, mSelfId);
insert.bindLong(INDEX_SENT_TIMESTAMP, mSentTimestamp);
insert.bindLong(INDEX_RECEIVED_TIMESTAMP, mReceivedTimestamp);
insert.bindLong(INDEX_SEEN, mSeen ? 1 : 0);
insert.bindLong(INDEX_READ, mRead ? 1 : 0);
insert.bindLong(INDEX_PROTOCOL, mProtocol);
insert.bindLong(INDEX_BUGLE_STATUS, mStatus);
if (mSmsMessageUri != null) {
insert.bindString(INDEX_SMS_MESSAGE_URI, mSmsMessageUri.toString());
}
insert.bindLong(INDEX_SMS_PRIORITY, mSmsPriority);
insert.bindLong(INDEX_SMS_MESSAGE_SIZE, mSmsMessageSize);
insert.bindLong(INDEX_MMS_EXPIRY, mMmsExpiry);
if (mMmsSubject != null) {
insert.bindString(INDEX_MMS_SUBJECT, mMmsSubject);
}
if (mMmsTransactionId != null) {
insert.bindString(INDEX_MMS_TRANSACTION_ID, mMmsTransactionId);
}
if (mMmsContentLocation != null) {
insert.bindString(INDEX_MMS_CONTENT_LOCATION, mMmsContentLocation);
}
insert.bindLong(INDEX_RAW_TELEPHONY_STATUS, mRawStatus);
insert.bindLong(INDEX_RETRY_START_TIMESTAMP, mRetryStartTimestamp);
return insert;
}
public final String getMessageId() {
return mMessageId;
}
public final String getConversationId() {
return mConversationId;
}
public final String getParticipantId() {
return mParticipantId;
}
public final String getSelfId() {
return mSelfId;
}
public final long getSentTimeStamp() {
return mSentTimestamp;
}
public final long getReceivedTimeStamp() {
return mReceivedTimestamp;
}
public final String getFormattedReceivedTimeStamp() {
return Dates.getMessageTimeString(mReceivedTimestamp).toString();
}
public final int getProtocol() {
return mProtocol;
}
public final int getStatus() {
return mStatus;
}
public final Uri getSmsMessageUri() {
return mSmsMessageUri;
}
public final int getSmsPriority() {
return mSmsPriority;
}
public final long getSmsMessageSize() {
return mSmsMessageSize;
}
public final String getMmsSubject() {
return mMmsSubject;
}
public final void setMmsSubject(final String subject) {
mMmsSubject = subject;
}
public final String getMmsContentLocation() {
return mMmsContentLocation;
}
public final String getMmsTransactionId() {
return mMmsTransactionId;
}
public final boolean getMessageSeen() {
return mSeen;
}
public final long getMmsExpiry() {
return mMmsExpiry;
}
/**
* For incoming MMS messages this returns the retrieve-status value
* For sent MMS messages this returns the response-status value
* See PduHeaders.java for possible values
* Otherwise (SMS etc) this is RAW_TELEPHONY_STATUS_UNDEFINED
*/
public final int getRawTelephonyStatus() {
return mRawStatus;
}
public final void setMessageSeen(final boolean hasSeen) {
mSeen = hasSeen;
}
public final boolean getInResendWindow(final long now) {
final long maxAgeToResend = BugleGservicesKeys.MESSAGE_RESEND_TIMEOUT_MS_DEFAULT;
final long age = now - mRetryStartTimestamp;
return age < maxAgeToResend;
}
public final boolean getInDownloadWindow(final long now) {
final long maxAgeToRedownload = BugleGservicesKeys.MESSAGE_DOWNLOAD_TIMEOUT_MS_DEFAULT;
final long age = now - mRetryStartTimestamp;
return age < maxAgeToRedownload;
}
static boolean getShowDownloadMessage(final int status) {
if (OsUtil.isSecondaryUser()) {
// Secondary users can't download mms's. Mms's are downloaded by bugle running as the
// primary user.
return false;
}
// Should show option for manual download if status is manual download or failed
// If debug is enabled, allow to download an expired or unavailable message.
return (status == BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED ||
status == BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD);
}
public boolean canDownloadMessage() {
if (OsUtil.isSecondaryUser()) {
// Secondary users can't download mms's. Mms's are downloaded by bugle running as the
// primary user.
return false;
}
// Can download if status is retrying auto/manual downloading
return (mStatus == BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD ||
mStatus == BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD);
}
public boolean canRedownloadMessage() {
if (OsUtil.isSecondaryUser()) {
// Secondary users can't download mms's. Mms's are downloaded by bugle running as the
// primary user.
return false;
}
// Can redownload if status is manual download not started or download failed
// If debug is enabled, allow to download an expired or unavailable message.
return (mStatus == BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED ||
mStatus == BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD);
}
static boolean getShowResendMessage(final int status) {
// Should show option to resend if status is failed
return (status == BUGLE_STATUS_OUTGOING_FAILED);
}
static boolean getOneClickResendMessage(final int status, final int rawStatus) {
// Should show option to resend if status is failed
return (status == BUGLE_STATUS_OUTGOING_FAILED
&& rawStatus == RAW_TELEPHONY_STATUS_UNDEFINED);
}
public boolean canResendMessage() {
// Manual retry allowed only from failed
return (mStatus == BUGLE_STATUS_OUTGOING_FAILED);
}
public boolean canSendMessage() {
// Sending messages must be in yet_to_send or awaiting_retry state
return (mStatus == BUGLE_STATUS_OUTGOING_YET_TO_SEND ||
mStatus == BUGLE_STATUS_OUTGOING_AWAITING_RETRY);
}
public final boolean getYetToSend() {
return (mStatus == BUGLE_STATUS_OUTGOING_YET_TO_SEND);
}
public final boolean getIsMms() {
return mProtocol == MessageData.PROTOCOL_MMS
|| mProtocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION;
}
public static boolean getIsSms(final int protocol) {
return protocol == (MessageData.PROTOCOL_SMS);
}
public final boolean getIsSms() {
return getIsSms(mProtocol);
}
public static boolean getIsIncoming(final int status) {
return (status >= MessageData.BUGLE_STATUS_FIRST_INCOMING);
}
public boolean getIsIncoming() {
return getIsIncoming(mStatus);
}
public long getRetryStartTimestamp() {
return mRetryStartTimestamp;
}
public final String getMessageText() {
final String separator = System.lineSeparator();
final StringBuilder text = new StringBuilder();
for (final MessagePartData part : mParts) {
if (!part.isAttachment() && !TextUtils.isEmpty(part.getText())) {
if (text.length() > 0) {
text.append(separator);
}
text.append(part.getText());
}
}
return text.toString();
}
/**
* Takes all captions from attachments and adds them as a prefix to the first text part or
* appends a text part
*/
public final void consolidateText() {
final String separator = System.lineSeparator();
final StringBuilder captionText = new StringBuilder();
MessagePartData firstTextPart = null;
int firstTextPartIndex = -1;
for (int i = 0; i < mParts.size(); i++) {
final MessagePartData part = mParts.get(i);
if (firstTextPart == null && !part.isAttachment()) {
firstTextPart = part;
firstTextPartIndex = i;
}
if (part.isAttachment() && !TextUtils.isEmpty(part.getText())) {
if (captionText.length() > 0) {
captionText.append(separator);
}
captionText.append(part.getText());
}
}
if (captionText.length() == 0) {
// Nothing to consolidate
return;
}
if (firstTextPart == null) {
addPart(MessagePartData.createTextMessagePart(captionText.toString()));
} else {
final String partText = firstTextPart.getText();
if (partText.length() > 0) {
captionText.append(separator);
captionText.append(partText);
}
mParts.set(firstTextPartIndex,
MessagePartData.createTextMessagePart(captionText.toString()));
}
}
public final MessagePartData getFirstAttachment() {
for (final MessagePartData part : mParts) {
if (part.isAttachment()) {
return part;
}
}
return null;
}
/**
* Updates the messageId for this message.
* Can be used to reset the messageId prior to persisting (which will assign a new messageId)
* or can be called on a message that does not yet have a valid messageId to set it.
*/
public void updateMessageId(final String messageId) {
Assert.isTrue(TextUtils.isEmpty(messageId) || TextUtils.isEmpty(mMessageId));
mMessageId = messageId;
// TODO : This should probably also call updateMessageId on the message parts. We
// may also want to make messages effectively immutable once they have a valid message id.
}
public final void updateSendingMessage(final String conversationId, final Uri messageUri,
final long timestamp) {
mConversationId = conversationId;
mSmsMessageUri = messageUri;
mRead = true;
mSeen = true;
mReceivedTimestamp = timestamp;
mSentTimestamp = timestamp;
mStatus = BUGLE_STATUS_OUTGOING_YET_TO_SEND;
mRetryStartTimestamp = timestamp;
}
public final void markMessageManualResend(final long timestamp) {
// Manual send updates timestamp and transitions back to initial sending status.
mReceivedTimestamp = timestamp;
mSentTimestamp = timestamp;
mStatus = BUGLE_STATUS_OUTGOING_SENDING;
}
public final void markMessageSending(final long timestamp) {
// Initial send
mStatus = BUGLE_STATUS_OUTGOING_SENDING;
mSentTimestamp = timestamp;
}
public final void markMessageResending(final long timestamp) {
// Auto resend of message
mStatus = BUGLE_STATUS_OUTGOING_RESENDING;
mSentTimestamp = timestamp;
}
public final void markMessageSent(final long timestamp) {
mSentTimestamp = timestamp;
mStatus = BUGLE_STATUS_OUTGOING_COMPLETE;
}
public final void markMessageFailed(final long timestamp) {
mSentTimestamp = timestamp;
mStatus = BUGLE_STATUS_OUTGOING_FAILED;
}
public final void markMessageFailedEmergencyNumber(final long timestamp) {
mSentTimestamp = timestamp;
mStatus = BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER;
}
public final void markMessageNotSent(final long timestamp) {
mSentTimestamp = timestamp;
mStatus = BUGLE_STATUS_OUTGOING_AWAITING_RETRY;
}
public final void updateSizesForImageParts() {
for (final MessagePartData part : getParts()) {
part.decodeAndSaveSizeIfImage(false /* saveToStorage */);
}
}
public final void setRawTelephonyStatus(final int rawStatus) {
mRawStatus = rawStatus;
}
public boolean hasContent() {
return !TextUtils.isEmpty(mMmsSubject) ||
getFirstAttachment() != null ||
!TextUtils.isEmpty(getMessageText());
}
public final void bindSelfId(final String selfId) {
mSelfId = selfId;
}
public final void bindParticipantId(final String participantId) {
mParticipantId = participantId;
}
protected MessageData(final Parcel in) {
mMessageId = in.readString();
mConversationId = in.readString();
mParticipantId = in.readString();
mSelfId = in.readString();
mSentTimestamp = in.readLong();
mReceivedTimestamp = in.readLong();
mSeen = (in.readInt() != 0);
mRead = (in.readInt() != 0);
mProtocol = in.readInt();
mStatus = in.readInt();
final String smsMessageUri = in.readString();
mSmsMessageUri = (smsMessageUri == null ? null : Uri.parse(smsMessageUri));
mSmsPriority = in.readInt();
mSmsMessageSize = in.readLong();
mMmsExpiry = in.readLong();
mMmsSubject = in.readString();
mMmsTransactionId = in.readString();
mMmsContentLocation = in.readString();
mRawStatus = in.readInt();
mRetryStartTimestamp = in.readLong();
// Read parts
mParts = new ArrayList<>();
final int partCount = in.readInt();
for (int i = 0; i < partCount; i++) {
mParts.add(in.readParcelable(MessagePartData.class.getClassLoader(),
MessagePartData.class));
}
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(final Parcel dest, final int flags) {
dest.writeString(mMessageId);
dest.writeString(mConversationId);
dest.writeString(mParticipantId);
dest.writeString(mSelfId);
dest.writeLong(mSentTimestamp);
dest.writeLong(mReceivedTimestamp);
dest.writeInt(mRead ? 1 : 0);
dest.writeInt(mSeen ? 1 : 0);
dest.writeInt(mProtocol);
dest.writeInt(mStatus);
final String smsMessageUri = (mSmsMessageUri == null) ? null : mSmsMessageUri.toString();
dest.writeString(smsMessageUri);
dest.writeInt(mSmsPriority);
dest.writeLong(mSmsMessageSize);
dest.writeLong(mMmsExpiry);
dest.writeString(mMmsSubject);
dest.writeString(mMmsTransactionId);
dest.writeString(mMmsContentLocation);
dest.writeInt(mRawStatus);
dest.writeLong(mRetryStartTimestamp);
// Write parts
dest.writeInt(mParts.size());
for (final MessagePartData messagePartData : mParts) {
dest.writeParcelable(messagePartData, flags);
}
}
public static final Parcelable.Creator<MessageData> CREATOR
= new Parcelable.Creator<>() {
@Override
public MessageData createFromParcel(final Parcel in) {
return new MessageData(in);
}
@Override
public MessageData[] newArray(final int size) {
return new MessageData[size];
}
};
@NonNull
@Override
public String toString() {
return toString(mMessageId, mParts);
}
public static String toString(String messageId, List<MessagePartData> parts) {
StringBuilder sb = new StringBuilder();
if (messageId != null) {
sb.append(messageId);
sb.append(": ");
}
for (MessagePartData part : parts) {
sb.append(part.toString());
sb.append(" ");
}
return sb.toString();
}
}
@@ -0,0 +1,543 @@
/*
* 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.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteStatement;
import android.graphics.Rect;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.v7.mms.pdu.ContentType;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.PartColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MediaScratchFileProvider;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.action.UpdateMessagePartSizeAction;
import com.android.messaging.datamodel.media.ImageRequest;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
import com.android.messaging.util.GifTranscoder;
import com.android.messaging.util.ImageUtils;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.UriUtil;
import java.util.Arrays;
import java.util.concurrent.Executors;
/**
* Represents a single message part. Messages consist of one or more parts which may contain
* either text or media.
*/
public class MessagePartData implements Parcelable {
public static final int UNSPECIFIED_SIZE = MessagingContentProvider.UNSPECIFIED_SIZE;
public static final String[] ACCEPTABLE_GALLERY_MEDIA_TYPES =
new String[] {
// Acceptable image types
ContentType.IMAGE_JPEG, ContentType.IMAGE_JPG, ContentType.IMAGE_PNG,
ContentType.IMAGE_GIF, ContentType.IMAGE_WBMP, ContentType.IMAGE_X_MS_BMP,
// Acceptable video types
ContentType.VIDEO_3GP, ContentType.VIDEO_3GPP, ContentType.VIDEO_3G2,
ContentType.VIDEO_H263, ContentType.VIDEO_M4V, ContentType.VIDEO_MP4,
ContentType.VIDEO_MPEG, ContentType.VIDEO_MPEG4, ContentType.VIDEO_WEBM,
// Acceptable audio types
ContentType.AUDIO_MP3, ContentType.AUDIO_MP4, ContentType.AUDIO_MIDI,
ContentType.AUDIO_MID, ContentType.AUDIO_AMR, ContentType.AUDIO_X_WAV,
ContentType.AUDIO_AAC, ContentType.AUDIO_X_MIDI, ContentType.AUDIO_X_MID,
ContentType.AUDIO_X_MP3
};
private static final String[] sProjection = {
PartColumns._ID,
PartColumns.MESSAGE_ID,
PartColumns.TEXT,
PartColumns.CONTENT_URI,
PartColumns.CONTENT_TYPE,
PartColumns.WIDTH,
PartColumns.HEIGHT,
};
private static final int INDEX_ID = 0;
private static final int INDEX_MESSAGE_ID = 1;
private static final int INDEX_TEXT = 2;
private static final int INDEX_CONTENT_URI = 3;
private static final int INDEX_CONTENT_TYPE = 4;
private static final int INDEX_WIDTH = 5;
private static final int INDEX_HEIGHT = 6;
// This isn't part of the projection
private static final int INDEX_CONVERSATION_ID = 7;
// SQL statement to insert a "complete" message part row (columns based on projection above).
private static final String INSERT_MESSAGE_PART_SQL =
"INSERT INTO " + DatabaseHelper.PARTS_TABLE + " ( "
+ TextUtils.join(",", Arrays.copyOfRange(sProjection, 1, INDEX_CONVERSATION_ID))
+ ", " + PartColumns.CONVERSATION_ID
+ ") VALUES (?, ?, ?, ?, ?, ?, ?)";
// Used for stuff that's ignored or arbitrarily compressed.
private static final long NO_MINIMUM_SIZE = 0;
private String mPartId;
private String mMessageId;
private String mText;
private Uri mContentUri;
private String mContentType;
private int mWidth;
private int mHeight;
// This kind of part can only be attached once and with no other attachment
private boolean mSinglePartOnly;
/** Transient data: true if destroy was already called */
private boolean mDestroyed;
/**
* Create an "empty" message part
*/
protected MessagePartData() {
this(null, null, UNSPECIFIED_SIZE, UNSPECIFIED_SIZE);
}
/**
* Create a populated text message part
*/
protected MessagePartData(final String messageText) {
this(null, messageText, ContentType.TEXT_PLAIN, null, UNSPECIFIED_SIZE, UNSPECIFIED_SIZE,
false /*singlePartOnly*/);
}
/**
* Create a populated attachment message part
*/
protected MessagePartData(final String contentType, final Uri contentUri,
final int width, final int height) {
this(null, null, contentType, contentUri, width, height, false /*singlePartOnly*/);
}
/**
* Create a populated attachment message part, with additional caption text
*/
protected MessagePartData(final String messageText, final String contentType,
final Uri contentUri, final int width, final int height) {
this(null, messageText, contentType, contentUri, width, height, false /*singlePartOnly*/);
}
/**
* Create a populated attachment message part, with additional caption text, single part only
*/
protected MessagePartData(final String messageText, final String contentType,
final Uri contentUri, final int width, final int height, final boolean singlePartOnly) {
this(null, messageText, contentType, contentUri, width, height, singlePartOnly);
}
/**
* Create a populated message part
*/
private MessagePartData(final String messageId, final String messageText,
final String contentType, final Uri contentUri, final int width, final int height,
final boolean singlePartOnly) {
mMessageId = messageId;
mText = messageText;
mContentType = contentType;
mContentUri = contentUri;
mWidth = width;
mHeight = height;
mSinglePartOnly = singlePartOnly;
}
/**
* Create a "text" message part
*/
public static MessagePartData createTextMessagePart(final String messageText) {
return new MessagePartData(messageText);
}
/**
* Create a "media" message part
*/
public static MessagePartData createMediaMessagePart(final String contentType,
final Uri contentUri, final int width, final int height) {
return new MessagePartData(contentType, contentUri, width, height);
}
/**
* Create a "media" message part with caption
*/
public static MessagePartData createMediaMessagePart(final String caption,
final String contentType, final Uri contentUri, final int width, final int height) {
return new MessagePartData(null, caption, contentType, contentUri, width, height,
false /*singlePartOnly*/
);
}
/**
* Create an empty "text" message part
*/
public static MessagePartData createEmptyMessagePart() {
return new MessagePartData("");
}
/**
* Creates a new message part reading from the cursor
*/
public static MessagePartData createFromCursor(final Cursor cursor) {
final MessagePartData part = new MessagePartData();
part.bind(cursor);
return part;
}
public static String[] getProjection() {
return sProjection;
}
/**
* Updates the part id.
* Can be used to reset the partId just prior to persisting (which will assign a new partId)
* or can be called on a part that does not yet have a valid part id to set it.
*/
public void updatePartId(final String partId) {
Assert.isTrue(TextUtils.isEmpty(partId) || TextUtils.isEmpty(mPartId));
mPartId = partId;
}
/**
* Updates the messageId for the part.
* Can be used to reset the messageId prior to persisting (which will assign a new messageId)
* or can be called on a part that does not yet have a valid messageId to set it.
*/
public void updateMessageId(final String messageId) {
Assert.isTrue(TextUtils.isEmpty(messageId) || TextUtils.isEmpty(mMessageId));
mMessageId = messageId;
}
protected static String getMessageId(final Cursor cursor) {
return cursor.getString(INDEX_MESSAGE_ID);
}
protected void bind(final Cursor cursor) {
mPartId = cursor.getString(INDEX_ID);
mMessageId = cursor.getString(INDEX_MESSAGE_ID);
mText = cursor.getString(INDEX_TEXT);
mContentUri = UriUtil.uriFromString(cursor.getString(INDEX_CONTENT_URI));
mContentType = cursor.getString(INDEX_CONTENT_TYPE);
mWidth = cursor.getInt(INDEX_WIDTH);
mHeight = cursor.getInt(INDEX_HEIGHT);
}
public final void populate(final ContentValues values) {
// Must have a valid messageId on a part
Assert.isTrue(!TextUtils.isEmpty(mMessageId));
values.put(PartColumns.MESSAGE_ID, mMessageId);
values.put(PartColumns.TEXT, mText);
values.put(PartColumns.CONTENT_URI, UriUtil.stringFromUri(mContentUri));
values.put(PartColumns.CONTENT_TYPE, mContentType);
if (mWidth != UNSPECIFIED_SIZE) {
values.put(PartColumns.WIDTH, mWidth);
}
if (mHeight != UNSPECIFIED_SIZE) {
values.put(PartColumns.HEIGHT, mHeight);
}
}
/**
* Note this is not thread safe so callers need to make sure they own the wrapper + statements
* while they call this and use the returned value.
*/
public SQLiteStatement getInsertStatement(final DatabaseWrapper db,
final String conversationId) {
final SQLiteStatement insert = db.getStatementInTransaction(
DatabaseWrapper.INDEX_INSERT_MESSAGE_PART, INSERT_MESSAGE_PART_SQL);
insert.clearBindings();
insert.bindString(INDEX_MESSAGE_ID, mMessageId);
if (mText != null) {
insert.bindString(INDEX_TEXT, mText);
}
if (mContentUri != null) {
insert.bindString(INDEX_CONTENT_URI, mContentUri.toString());
}
if (mContentType != null) {
insert.bindString(INDEX_CONTENT_TYPE, mContentType);
}
insert.bindLong(INDEX_WIDTH, mWidth);
insert.bindLong(INDEX_HEIGHT, mHeight);
insert.bindString(INDEX_CONVERSATION_ID, conversationId);
return insert;
}
public final String getPartId() {
return mPartId;
}
public final String getMessageId() {
return mMessageId;
}
public final String getText() {
return mText;
}
public final Uri getContentUri() {
return mContentUri;
}
public boolean isAttachment() {
return mContentUri != null;
}
public boolean isText() {
return ContentType.isTextType(mContentType);
}
public boolean isImage() {
return ContentType.isImageType(mContentType);
}
public boolean isMedia() {
return ContentType.isMediaType(mContentType);
}
public boolean isVCard() {
return ContentType.isVCardType(mContentType);
}
public boolean isAudio() {
return ContentType.isAudioType(mContentType);
}
public boolean isVideo() {
return ContentType.isVideoType(mContentType);
}
public final String getContentType() {
return mContentType;
}
public final int getWidth() {
return mWidth;
}
public final int getHeight() {
return mHeight;
}
public static boolean isSupportedMediaType(final String contentType) {
return ContentType.isVCardType(contentType)
|| Arrays.asList(ACCEPTABLE_GALLERY_MEDIA_TYPES).contains(contentType);
}
/**
*
* @return true if this part can only exist by itself, with no other attachments
*/
public boolean getSinglePartOnly() {
return mSinglePartOnly;
}
@Override
public int describeContents() {
return 0;
}
protected MessagePartData(final Parcel in) {
mMessageId = in.readString();
mText = in.readString();
mContentUri = UriUtil.uriFromString(in.readString());
mContentType = in.readString();
mWidth = in.readInt();
mHeight = in.readInt();
}
@Override
public void writeToParcel(final Parcel dest, final int flags) {
Assert.isTrue(!mDestroyed);
dest.writeString(mMessageId);
dest.writeString(mText);
dest.writeString(UriUtil.stringFromUri(mContentUri));
dest.writeString(mContentType);
dest.writeInt(mWidth);
dest.writeInt(mHeight);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof MessagePartData)) {
return false;
}
MessagePartData lhs = (MessagePartData) o;
return mWidth == lhs.mWidth && mHeight == lhs.mHeight &&
TextUtils.equals(mMessageId, lhs.mMessageId) &&
TextUtils.equals(mText, lhs.mText) &&
TextUtils.equals(mContentType, lhs.mContentType) &&
(mContentUri == null ? lhs.mContentUri == null
: mContentUri.equals(lhs.mContentUri));
}
@Override public int hashCode() {
int result = 17;
result = 31 * result + mWidth;
result = 31 * result + mHeight;
result = 31 * result + (mMessageId == null ? 0 : mMessageId.hashCode());
result = 31 * result + (mText == null ? 0 : mText.hashCode());
result = 31 * result + (mContentType == null ? 0 : mContentType.hashCode());
result = 31 * result + (mContentUri == null ? 0 : mContentUri.hashCode());
return result;
}
public static final Parcelable.Creator<MessagePartData> CREATOR
= new Parcelable.Creator<>() {
@Override
public MessagePartData createFromParcel(final Parcel in) {
return new MessagePartData(in);
}
@Override
public MessagePartData[] newArray(final int size) {
return new MessagePartData[size];
}
};
protected Uri shouldDestroy() {
// We should never double-destroy.
Assert.isTrue(!mDestroyed);
mDestroyed = true;
Uri contentUri = mContentUri;
mContentUri = null;
mContentType = null;
// Only destroy the image if it's staged in our scratch space.
if (!MediaScratchFileProvider.isMediaScratchSpaceUri(contentUri)) {
contentUri = null;
}
return contentUri;
}
/**
* If application owns content associated with this part delete it (on background thread)
*/
public void destroyAsync() {
final Uri contentUri = shouldDestroy();
if (contentUri != null) {
Executors.newSingleThreadExecutor().execute(() ->
Factory.get().getApplicationContext().getContentResolver().delete(
contentUri, null, null));
}
}
/**
* If application owns content associated with this part delete it
*/
public void destroySync() {
final Uri contentUri = shouldDestroy();
if (contentUri != null) {
Factory.get().getApplicationContext().getContentResolver().delete(
contentUri, null, null);
}
}
/**
* If this is an image part, decode the image header and potentially save the size to the db.
*/
public void decodeAndSaveSizeIfImage(final boolean saveToStorage) {
if (isImage()) {
final Rect imageSize = ImageUtils.decodeImageBounds(
Factory.get().getApplicationContext(), mContentUri);
if (imageSize.width() != ImageRequest.UNSPECIFIED_SIZE &&
imageSize.height() != ImageRequest.UNSPECIFIED_SIZE) {
mWidth = imageSize.width();
mHeight = imageSize.height();
if (saveToStorage) {
UpdateMessagePartSizeAction.updateSize(mPartId, mWidth, mHeight);
}
}
}
}
/**
* Computes the minimum size that this MessagePartData could be compressed/downsampled/encoded
* before sending to meet the maximum message size imposed by the carriers. This is used to
* determine right before sending a message whether a message could possibly be sent. If not
* then the user is given a chance to unselect some/all of the attachments.
*
* TODO: computing the minimum size could be expensive. Should we cache the
* computed value in db to be retrieved later?
*
* @return the carrier-independent minimum size, in bytes.
*/
@DoesNotRunOnMainThread
public long getMinimumSizeInBytesForSending() {
Assert.isNotMainThread();
if (!isAttachment()) {
// No limit is imposed on non-attachment part (i.e. plain text), so treat it as zero.
return NO_MINIMUM_SIZE;
} else if (isImage()) {
// GIFs are resized by the native transcoder (exposed by GifTranscoder).
if (ImageUtils.isGif(mContentType, mContentUri)) {
final long originalImageSize = UriUtil.getContentSize(mContentUri);
// Wish we could save the size here, but we don't have a part id yet
decodeAndSaveSizeIfImage(false /* saveToStorage */);
return GifTranscoder.canBeTranscoded(mWidth, mHeight) ?
GifTranscoder.estimateFileSizeAfterTranscode(originalImageSize)
: originalImageSize;
}
// Other images should be arbitrarily resized by ImageResizer before sending.
return MmsUtils.MIN_IMAGE_BYTE_SIZE;
} else if (isMedia()) {
// We can't compress attachments except images.
return UriUtil.getContentSize(mContentUri);
} else {
// This is some unknown media type that we don't know how to handle. Log an error
// and try sending it anyway.
LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Unknown attachment type " + getContentType());
return NO_MINIMUM_SIZE;
}
}
@NonNull
@Override
public String toString() {
if (isText()) {
return LogUtil.sanitizePII(getText());
} else {
return getContentType() + " (" + getContentUri() + ")";
}
}
/**
*
* @return true if this part can only exist by itself, with no other attachments
*/
public boolean isSinglePartOnly() {
return mSinglePartOnly;
}
public void setSinglePartOnly(final boolean isSinglePartOnly) {
mSinglePartOnly = isSinglePartOnly;
}
}
@@ -0,0 +1,596 @@
/*
* 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.ContentValues;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.Color;
import android.os.Parcel;
import android.os.Parcelable;
import android.telephony.SubscriptionInfo;
import android.text.TextUtils;
import android.support.v7.mms.MmsManager;
import androidx.collection.ArrayMap;
import com.android.ex.chips.RecipientEntry;
import com.android.messaging.Factory;
import com.android.messaging.R;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.sms.MmsSmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.PhoneUtils;
import com.android.messaging.util.TextUtil;
/**
* A class that encapsulates all of the data for a specific participant in a conversation.
*/
public class ParticipantData implements Parcelable {
private static final ArrayMap<Integer, String> sSubIdtoParticipantIdCache = new ArrayMap<>();
// We always use -1 as default/invalid sub id although system may give us anything negative
public static final int DEFAULT_SELF_SUB_ID = MmsManager.DEFAULT_SUB_ID;
// This needs to be something apart from valid or DEFAULT_SELF_SUB_ID
public static final int OTHER_THAN_SELF_SUB_ID = DEFAULT_SELF_SUB_ID - 1;
// Active slot ids are non-negative. Using -1 to designate to inactive self participants.
public static final int INVALID_SLOT_ID = -1;
// TODO: may make sense to move this to common place?
public static final long PARTICIPANT_CONTACT_ID_NOT_RESOLVED = -1;
public static final long PARTICIPANT_CONTACT_ID_NOT_FOUND = -2;
public static class ParticipantsQuery {
public static final String[] PROJECTION = new String[] {
ParticipantColumns._ID,
ParticipantColumns.SUB_ID,
ParticipantColumns.SIM_SLOT_ID,
ParticipantColumns.NORMALIZED_DESTINATION,
ParticipantColumns.SEND_DESTINATION,
ParticipantColumns.DISPLAY_DESTINATION,
ParticipantColumns.FULL_NAME,
ParticipantColumns.FIRST_NAME,
ParticipantColumns.PROFILE_PHOTO_URI,
ParticipantColumns.CONTACT_ID,
ParticipantColumns.LOOKUP_KEY,
ParticipantColumns.BLOCKED,
ParticipantColumns.SUBSCRIPTION_COLOR,
ParticipantColumns.SUBSCRIPTION_NAME,
ParticipantColumns.CONTACT_DESTINATION,
};
public static final int INDEX_ID = 0;
public static final int INDEX_SUB_ID = 1;
public static final int INDEX_SIM_SLOT_ID = 2;
public static final int INDEX_NORMALIZED_DESTINATION = 3;
public static final int INDEX_SEND_DESTINATION = 4;
public static final int INDEX_DISPLAY_DESTINATION = 5;
public static final int INDEX_FULL_NAME = 6;
public static final int INDEX_FIRST_NAME = 7;
public static final int INDEX_PROFILE_PHOTO_URI = 8;
public static final int INDEX_CONTACT_ID = 9;
public static final int INDEX_LOOKUP_KEY = 10;
public static final int INDEX_BLOCKED = 11;
public static final int INDEX_SUBSCRIPTION_COLOR = 12;
public static final int INDEX_SUBSCRIPTION_NAME = 13;
public static final int INDEX_CONTACT_DESTINATION = 14;
}
/**
* @return The MMS unknown sender participant entity
*/
public static String getUnknownSenderDestination() {
// This is a hard coded string rather than a localized one because we don't want it to
// change when you change locale.
return "\u02BCUNKNOWN_SENDER!\u02BC";
}
private String mParticipantId;
private int mSubId;
private int mSlotId;
private String mNormalizedDestination;
private String mSendDestination;
private String mDisplayDestination;
private String mContactDestination;
private String mFullName;
private String mFirstName;
private String mProfilePhotoUri;
private long mContactId;
private String mLookupKey;
private int mSubscriptionColor;
private String mSubscriptionName;
private boolean mIsEmailAddress;
private boolean mBlocked;
// Don't call constructor directly
private ParticipantData() {
}
public static ParticipantData getFromCursor(final Cursor cursor) {
final ParticipantData pd = new ParticipantData();
pd.mParticipantId = cursor.getString(ParticipantsQuery.INDEX_ID);
pd.mSubId = cursor.getInt(ParticipantsQuery.INDEX_SUB_ID);
pd.mSlotId = cursor.getInt(ParticipantsQuery.INDEX_SIM_SLOT_ID);
pd.mNormalizedDestination = cursor.getString(
ParticipantsQuery.INDEX_NORMALIZED_DESTINATION);
pd.mSendDestination = cursor.getString(ParticipantsQuery.INDEX_SEND_DESTINATION);
pd.mDisplayDestination = cursor.getString(ParticipantsQuery.INDEX_DISPLAY_DESTINATION);
pd.mContactDestination = cursor.getString(ParticipantsQuery.INDEX_CONTACT_DESTINATION);
pd.mFullName = cursor.getString(ParticipantsQuery.INDEX_FULL_NAME);
pd.mFirstName = cursor.getString(ParticipantsQuery.INDEX_FIRST_NAME);
pd.mProfilePhotoUri = cursor.getString(ParticipantsQuery.INDEX_PROFILE_PHOTO_URI);
pd.mContactId = cursor.getLong(ParticipantsQuery.INDEX_CONTACT_ID);
pd.mLookupKey = cursor.getString(ParticipantsQuery.INDEX_LOOKUP_KEY);
pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination);
pd.mBlocked = cursor.getInt(ParticipantsQuery.INDEX_BLOCKED) != 0;
pd.mSubscriptionColor = cursor.getInt(ParticipantsQuery.INDEX_SUBSCRIPTION_COLOR);
pd.mSubscriptionName = cursor.getString(ParticipantsQuery.INDEX_SUBSCRIPTION_NAME);
pd.maybeSetupUnknownSender();
return pd;
}
public static ParticipantData getFromId(final DatabaseWrapper dbWrapper,
final String participantId) {
try (Cursor cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE,
ParticipantsQuery.PROJECTION,
ParticipantColumns._ID + " =?",
new String[]{participantId}, null, null, null)) {
if (cursor.moveToFirst()) {
return ParticipantData.getFromCursor(cursor);
} else {
return null;
}
}
}
public static ParticipantData getFromRecipientEntry(final RecipientEntry recipientEntry) {
final ParticipantData pd = new ParticipantData();
pd.mParticipantId = null;
pd.mSubId = OTHER_THAN_SELF_SUB_ID;
pd.mSlotId = INVALID_SLOT_ID;
pd.mSendDestination = TextUtil.replaceUnicodeDigits(recipientEntry.getDestination());
pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination);
pd.mNormalizedDestination = pd.mIsEmailAddress ?
pd.mSendDestination :
PhoneUtils.getDefault().getCanonicalBySystemLocale(pd.mSendDestination);
pd.mDisplayDestination = pd.mIsEmailAddress ?
pd.mNormalizedDestination :
PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination);
pd.mFullName = recipientEntry.getDisplayName();
pd.mFirstName = null;
pd.mProfilePhotoUri = (recipientEntry.getPhotoThumbnailUri() == null) ? null :
recipientEntry.getPhotoThumbnailUri().toString();
pd.mContactId = recipientEntry.getContactId();
if (pd.mContactId < 0) {
// ParticipantData only supports real contact ids (>=0) based on faith that the contacts
// provider will continue to only use non-negative ids. The UI uses contactId < 0 for
// special handling. We convert those to 'not resolved'
pd.mContactId = PARTICIPANT_CONTACT_ID_NOT_RESOLVED;
}
pd.mLookupKey = recipientEntry.getLookupKey();
pd.mBlocked = false;
pd.mSubscriptionColor = Color.TRANSPARENT;
pd.mSubscriptionName = null;
pd.maybeSetupUnknownSender();
return pd;
}
// Shared code for getFromRawPhoneBySystemLocale and getFromRawPhoneBySimLocale
private static ParticipantData getFromRawPhone(final String phoneNumber) {
Assert.isTrue(phoneNumber != null);
final ParticipantData pd = new ParticipantData();
pd.mParticipantId = null;
pd.mSubId = OTHER_THAN_SELF_SUB_ID;
pd.mSlotId = INVALID_SLOT_ID;
pd.mSendDestination = TextUtil.replaceUnicodeDigits(phoneNumber);
pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination);
pd.mFullName = null;
pd.mFirstName = null;
pd.mProfilePhotoUri = null;
pd.mContactId = PARTICIPANT_CONTACT_ID_NOT_RESOLVED;
pd.mLookupKey = null;
pd.mBlocked = false;
pd.mSubscriptionColor = Color.TRANSPARENT;
pd.mSubscriptionName = null;
return pd;
}
/**
* Get an instance from a raw phone number and using system locale to normalize it.
*
* Use this when creating a participant that is for displaying UI and not associated
* with a specific SIM. For example, when creating a conversation using user entered
* phone number.
*
* @param phoneNumber The raw phone number
* @return instance
*/
public static ParticipantData getFromRawPhoneBySystemLocale(final String phoneNumber) {
final ParticipantData pd = getFromRawPhone(phoneNumber);
pd.mNormalizedDestination = pd.mIsEmailAddress ?
pd.mSendDestination :
PhoneUtils.getDefault().getCanonicalBySystemLocale(pd.mSendDestination);
pd.mDisplayDestination = pd.mIsEmailAddress ?
pd.mNormalizedDestination :
PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination);
pd.maybeSetupUnknownSender();
return pd;
}
/**
* Get an instance from a raw phone number and using SIM or system locale to normalize it.
*
* Use this when creating a participant that is associated with a specific SIM. For example,
* the sender of a received message or the recipient of a sending message that is already
* targeted at a specific SIM.
*
* @param phoneNumber The raw phone number
* @return instance
*/
public static ParticipantData getFromRawPhoneBySimLocale(
final String phoneNumber, final int subId) {
final ParticipantData pd = getFromRawPhone(phoneNumber);
pd.mNormalizedDestination = pd.mIsEmailAddress ?
pd.mSendDestination :
PhoneUtils.get(subId).getCanonicalBySimLocale(pd.mSendDestination);
pd.mDisplayDestination = pd.mIsEmailAddress ?
pd.mNormalizedDestination :
PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination);
pd.maybeSetupUnknownSender();
return pd;
}
public static ParticipantData getSelfParticipant(final int subId) {
Assert.isTrue(subId != OTHER_THAN_SELF_SUB_ID);
final ParticipantData pd = new ParticipantData();
pd.mParticipantId = null;
pd.mSubId = subId;
pd.mSlotId = INVALID_SLOT_ID;
pd.mIsEmailAddress = false;
pd.mSendDestination = null;
pd.mNormalizedDestination = null;
pd.mDisplayDestination = null;
pd.mFullName = null;
pd.mFirstName = null;
pd.mProfilePhotoUri = null;
pd.mContactId = PARTICIPANT_CONTACT_ID_NOT_RESOLVED;
pd.mLookupKey = null;
pd.mBlocked = false;
pd.mSubscriptionColor = Color.TRANSPARENT;
pd.mSubscriptionName = null;
return pd;
}
public static String getParticipantId(final DatabaseWrapper db, final int subId) {
String id;
synchronized (sSubIdtoParticipantIdCache) {
id = sSubIdtoParticipantIdCache.get(subId);
}
if (id != null) {
return id;
}
try (final Cursor cursor =
db.query(DatabaseHelper.PARTICIPANTS_TABLE,
new String[] {ParticipantColumns._ID},
ParticipantColumns.SUB_ID + " =?",
new String[] {Integer.toString(subId)}, null, null, null)) {
if (cursor.moveToFirst()) {
// We found an existing participant in the database
id = cursor.getString(0);
synchronized (sSubIdtoParticipantIdCache) {
// Add it to the cache for next time
sSubIdtoParticipantIdCache.put(subId, id);
}
}
}
return id;
}
private void maybeSetupUnknownSender() {
if (isUnknownSender()) {
// Because your locale may change, we setup the display string for the unknown sender
// on the fly rather than relying on the version in the database.
final Resources resources = Factory.get().getApplicationContext().getResources();
mDisplayDestination = resources.getString(R.string.unknown_sender);
mFullName = mDisplayDestination;
}
}
public String getNormalizedDestination() {
return mNormalizedDestination;
}
public String getSendDestination() {
return mSendDestination;
}
public String getDisplayDestination() {
return mDisplayDestination;
}
public String getContactDestination() {
return mContactDestination;
}
public String getFullName() {
return mFullName;
}
public String getFirstName() {
return mFirstName;
}
public String getDisplayName(final boolean preferFullName) {
if (preferFullName) {
// Prefer full name over first name
if (!TextUtils.isEmpty(mFullName)) {
return mFullName;
}
if (!TextUtils.isEmpty(mFirstName)) {
return mFirstName;
}
} else {
// Prefer first name over full name
if (!TextUtils.isEmpty(mFirstName)) {
return mFirstName;
}
if (!TextUtils.isEmpty(mFullName)) {
return mFullName;
}
}
// Fallback to the display destination
if (!TextUtils.isEmpty(mDisplayDestination)) {
return mDisplayDestination;
}
return Factory.get().getApplicationContext().getResources().getString(
R.string.unknown_sender);
}
public String getProfilePhotoUri() {
return mProfilePhotoUri;
}
public long getContactId() {
return mContactId;
}
public String getLookupKey() {
return mLookupKey;
}
public boolean updatePhoneNumberForSelfIfChanged() {
final String phoneNumber =
PhoneUtils.get(mSubId).getCanonicalForSelf(true/*allowOverride*/);
boolean changed = false;
if (isSelf() && !TextUtils.equals(phoneNumber, mNormalizedDestination)) {
mNormalizedDestination = phoneNumber;
mSendDestination = phoneNumber;
mDisplayDestination = mIsEmailAddress ?
phoneNumber :
PhoneUtils.getDefault().formatForDisplay(phoneNumber);
changed = true;
}
return changed;
}
public boolean updateSubscriptionInfoForSelfIfChanged(final SubscriptionInfo subscriptionInfo) {
boolean changed = false;
if (isSelf()) {
if (subscriptionInfo == null) {
// The subscription is inactive. Check if the participant is still active.
if (isActiveSubscription()) {
mSlotId = INVALID_SLOT_ID;
mSubscriptionColor = Color.TRANSPARENT;
mSubscriptionName = "";
changed = true;
}
} else {
final int slotId = subscriptionInfo.getSimSlotIndex();
final int color = subscriptionInfo.getIconTint();
final CharSequence name = subscriptionInfo.getDisplayName();
if (mSlotId != slotId || mSubscriptionColor != color || mSubscriptionName != name) {
mSlotId = slotId;
mSubscriptionColor = color;
mSubscriptionName = name.toString();
changed = true;
}
}
}
return changed;
}
public void setFullName(final String fullName) {
mFullName = fullName;
}
public void setFirstName(final String firstName) {
mFirstName = firstName;
}
public void setProfilePhotoUri(final String profilePhotoUri) {
mProfilePhotoUri = profilePhotoUri;
}
public void setContactId(final long contactId) {
mContactId = contactId;
}
public void setLookupKey(final String lookupKey) {
mLookupKey = lookupKey;
}
public void setSendDestination(final String destination) {
mSendDestination = destination;
}
public void setContactDestination(final String destination) {
mContactDestination = destination;
}
public int getSubId() {
return mSubId;
}
/**
* @return whether this sub is active. Note that {@link ParticipantData#DEFAULT_SELF_SUB_ID} is
* is considered as active if there is any active SIM.
*/
public boolean isActiveSubscription() {
return mSlotId != INVALID_SLOT_ID;
}
public boolean isDefaultSelf() {
return mSubId == ParticipantData.DEFAULT_SELF_SUB_ID;
}
public int getSlotId() {
return mSlotId;
}
/**
* Slot IDs in the subscription manager is zero-based, but we want to show it
* as 1-based in UI.
*/
public int getDisplaySlotId() {
return getSlotId() + 1;
}
public int getSubscriptionColor() {
Assert.isTrue(isActiveSubscription());
// Force the alpha channel to 0xff to ensure the returned color is solid.
return mSubscriptionColor | 0xff000000;
}
public String getSubscriptionName() {
Assert.isTrue(isActiveSubscription());
return mSubscriptionName;
}
public String getId() {
return mParticipantId;
}
public boolean isSelf() {
return (mSubId != OTHER_THAN_SELF_SUB_ID);
}
public boolean isEmail() {
return mIsEmailAddress;
}
public boolean isContactIdResolved() {
return (mContactId != PARTICIPANT_CONTACT_ID_NOT_RESOLVED);
}
public boolean isBlocked() {
return mBlocked;
}
public boolean isUnknownSender() {
final String unknownSender = ParticipantData.getUnknownSenderDestination();
return (TextUtils.equals(mSendDestination, unknownSender));
}
public ContentValues toContentValues() {
final ContentValues values = new ContentValues();
values.put(ParticipantColumns.SUB_ID, mSubId);
values.put(ParticipantColumns.SIM_SLOT_ID, mSlotId);
values.put(DatabaseHelper.ParticipantColumns.SEND_DESTINATION, mSendDestination);
if (!isUnknownSender()) {
values.put(DatabaseHelper.ParticipantColumns.DISPLAY_DESTINATION, mDisplayDestination);
values.put(DatabaseHelper.ParticipantColumns.NORMALIZED_DESTINATION,
mNormalizedDestination);
values.put(ParticipantColumns.FULL_NAME, mFullName);
values.put(ParticipantColumns.FIRST_NAME, mFirstName);
}
values.put(ParticipantColumns.PROFILE_PHOTO_URI, mProfilePhotoUri);
values.put(ParticipantColumns.CONTACT_ID, mContactId);
values.put(ParticipantColumns.LOOKUP_KEY, mLookupKey);
values.put(ParticipantColumns.BLOCKED, mBlocked);
values.put(ParticipantColumns.SUBSCRIPTION_COLOR, mSubscriptionColor);
values.put(ParticipantColumns.SUBSCRIPTION_NAME, mSubscriptionName);
return values;
}
public ParticipantData(final Parcel in) {
mParticipantId = in.readString();
mSubId = in.readInt();
mSlotId = in.readInt();
mNormalizedDestination = in.readString();
mSendDestination = in.readString();
mDisplayDestination = in.readString();
mFullName = in.readString();
mFirstName = in.readString();
mProfilePhotoUri = in.readString();
mContactId = in.readLong();
mLookupKey = in.readString();
mIsEmailAddress = in.readInt() != 0;
mBlocked = in.readInt() != 0;
mSubscriptionColor = in.readInt();
mSubscriptionName = in.readString();
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(final Parcel dest, final int flags) {
dest.writeString(mParticipantId);
dest.writeInt(mSubId);
dest.writeInt(mSlotId);
dest.writeString(mNormalizedDestination);
dest.writeString(mSendDestination);
dest.writeString(mDisplayDestination);
dest.writeString(mFullName);
dest.writeString(mFirstName);
dest.writeString(mProfilePhotoUri);
dest.writeLong(mContactId);
dest.writeString(mLookupKey);
dest.writeInt(mIsEmailAddress ? 1 : 0);
dest.writeInt(mBlocked ? 1 : 0);
dest.writeInt(mSubscriptionColor);
dest.writeString(mSubscriptionName);
}
public static final Parcelable.Creator<ParticipantData> CREATOR = new Parcelable.Creator<>() {
@Override
public ParticipantData createFromParcel(final Parcel in) {
return new ParticipantData(in);
}
@Override
public ParticipantData[] newArray(final int size) {
return new ParticipantData[size];
}
};
}
@@ -0,0 +1,95 @@
/*
* Copyright (C) 2015 The Android Open Source 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.content.Intent;
import android.net.Uri;
import android.text.TextUtils;
import com.android.messaging.datamodel.action.BugleActionToasts;
import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction;
import com.android.messaging.util.AvatarUriUtil;
/**
* Helps visualize a ParticipantData in a PersonItemView
*/
public class ParticipantListItemData extends PersonItemData {
private final Uri mAvatarUri;
private final String mDisplayName;
private final String mDetails;
private final long mContactId;
private final String mLookupKey;
private final String mNormalizedDestination;
/**
* Constructor. Takes necessary info from the incoming ParticipantData.
*/
public ParticipantListItemData(final ParticipantData participant) {
mAvatarUri = AvatarUriUtil.createAvatarUri(participant);
mContactId = participant.getContactId();
mLookupKey = participant.getLookupKey();
mNormalizedDestination = participant.getNormalizedDestination();
if (TextUtils.isEmpty(participant.getFullName())) {
mDisplayName = participant.getSendDestination();
mDetails = null;
} else {
mDisplayName = participant.getFullName();
mDetails = (participant.isUnknownSender()) ? null : participant.getSendDestination();
}
}
@Override
public Uri getAvatarUri() {
return mAvatarUri;
}
@Override
public String getDisplayName() {
return mDisplayName;
}
@Override
public String getDetails() {
return mDetails;
}
@Override
public Intent getClickIntent() {
return null;
}
@Override
public long getContactId() {
return mContactId;
}
@Override
public String getLookupKey() {
return mLookupKey;
}
@Override
public String getNormalizedDestination() {
return mNormalizedDestination;
}
public void unblock(final Context context) {
UpdateDestinationBlockedAction.updateDestinationBlocked(
mNormalizedDestination, false, null,
BugleActionToasts.makeUpdateDestinationBlockedActionListener(context));
}
}
@@ -0,0 +1,183 @@
/*
* 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.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.v7.mms.pdu.ContentType;
import androidx.annotation.NonNull;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.UriUtil;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Represents a "pending" message part that acts as a placeholder for the actual attachment being
* loaded. It handles the task to load and persist the attachment from a Uri to local scratch
* folder. This item is not persisted to the database.
*/
public class PendingAttachmentData extends MessagePartData {
/** The pending state. This is the initial state where we haven't started loading yet */
public static final int STATE_PENDING = 0;
/** The state for when we are currently loading the attachment to the scratch space */
public static final int STATE_LOADING = 1;
/** The attachment has been successfully loaded and no longer pending */
public static final int STATE_LOADED = 2;
/** The attachment failed to load */
public static final int STATE_FAILED = 3;
private static final int LOAD_MEDIA_TIME_LIMIT_MILLIS = 60 * 1000; // 60s
/** The current state of the pending attachment. Refer to the STATE_* states above */
private int mCurrentState;
/**
* Create a new instance of PendingAttachmentData with an output Uri.
* @param sourceUri the source Uri of the attachment. The Uri maybe temporary or remote,
* so we need to persist it to local storage.
*/
protected PendingAttachmentData(final String caption, final String contentType,
@NonNull final Uri sourceUri, final int width, final int height,
final boolean onlySingleAttachment) {
super(caption, contentType, sourceUri, width, height, onlySingleAttachment);
mCurrentState = STATE_PENDING;
}
/**
* Creates a pending attachment data that is able to load from the given source uri and
* persist the media resource locally in the scratch folder.
*/
public static PendingAttachmentData createPendingAttachmentData(final String contentType,
final Uri sourceUri) {
return createPendingAttachmentData(null, contentType, sourceUri, UNSPECIFIED_SIZE,
UNSPECIFIED_SIZE);
}
public static PendingAttachmentData createPendingAttachmentData(final String caption,
final String contentType, final Uri sourceUri, final int width, final int height) {
Assert.isTrue(ContentType.isMediaType(contentType));
return new PendingAttachmentData(caption, contentType, sourceUri, width, height,
false /*onlySingleAttachment*/);
}
public static PendingAttachmentData createPendingAttachmentData(final String caption,
final String contentType, final Uri sourceUri, final int width, final int height,
final boolean onlySingleAttachment) {
Assert.isTrue(ContentType.isMediaType(contentType));
return new PendingAttachmentData(caption, contentType, sourceUri, width, height,
onlySingleAttachment);
}
public int getCurrentState() {
return mCurrentState;
}
public void loadAttachmentForDraft(final DraftMessageData draftMessageData,
final String bindingId) {
if (mCurrentState != STATE_PENDING) {
return;
}
mCurrentState = STATE_LOADING;
// Kick off loading the content of the media and persist it locally.
// Note: we need to persist the media locally even if it's not remote, because we
// want to be able to resend the media in case the message failed to send.
ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
Handler handler = new Handler(Looper.getMainLooper());
final Future<?> future = executor.submit(() -> {
MessagePartData data = null;
final Uri contentUri = getContentUri();
final Uri persistedUri = UriUtil.persistContentToScratchSpace(contentUri);
if (persistedUri != null) {
data = MessagePartData.createMediaMessagePart(
getText(),
getContentType(),
persistedUri,
getWidth(),
getHeight());
}
final MessagePartData attachment = data;
handler.post(() -> {
if (attachment != null) {
mCurrentState = STATE_LOADED;
if (draftMessageData.isBound(bindingId)) {
draftMessageData.updatePendingAttachment(attachment,
PendingAttachmentData.this);
} else {
// The draft message data is no longer bound, drop the loaded attachment.
attachment.destroyAsync();
}
} else {
// Media load failed. We already logged in doInBackground() so don't need to
// do that again.
mCurrentState = STATE_FAILED;
if (draftMessageData.isBound(bindingId)) {
draftMessageData.onPendingAttachmentLoadFailed(PendingAttachmentData.this);
draftMessageData.removePendingAttachment(PendingAttachmentData.this);
}
}
});
});
executor.schedule(() -> {
if (!future.isDone()) {
future.cancel(true);
LogUtil.w(LogUtil.BUGLE_TAG, "Timeout while retrieving media");
mCurrentState = STATE_FAILED;
if (draftMessageData.isBound(bindingId)) {
draftMessageData.removePendingAttachment(PendingAttachmentData.this);
}
}
}, LOAD_MEDIA_TIME_LIMIT_MILLIS, TimeUnit.MILLISECONDS);
}
protected PendingAttachmentData(final Parcel in) {
super(in);
mCurrentState = in.readInt();
}
@Override
public void writeToParcel(final Parcel out, final int flags) {
super.writeToParcel(out, flags);
out.writeInt(mCurrentState);
}
public static final Parcelable.Creator<PendingAttachmentData> CREATOR
= new Parcelable.Creator<>() {
@Override
public PendingAttachmentData createFromParcel(final Parcel in) {
return new PendingAttachmentData(in);
}
@Override
public PendingAttachmentData[] newArray(final int size) {
return new PendingAttachmentData[size];
}
};
}
@@ -0,0 +1,190 @@
/*
* 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 android.net.Uri;
import android.os.Bundle;
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.action.BugleActionToasts;
import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction;
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;
import java.util.List;
/**
* Services data needs for PeopleAndOptionsFragment.
*/
public class PeopleAndOptionsData extends BindableData implements
LoaderManager.LoaderCallbacks<Cursor> {
public interface PeopleAndOptionsDataListener {
void onOptionsCursorUpdated(PeopleAndOptionsData data, Cursor cursor);
void onParticipantsListLoaded(PeopleAndOptionsData data,
List<ParticipantData> participants);
}
private static final String BINDING_ID = "bindingId";
private final Context mContext;
private final String mConversationId;
private final ConversationParticipantsData mParticipantData;
private LoaderManager mLoaderManager;
private PeopleAndOptionsDataListener mListener;
public PeopleAndOptionsData(final String conversationId, final Context context,
final PeopleAndOptionsDataListener listener) {
mListener = listener;
mContext = context;
mConversationId = conversationId;
mParticipantData = new ConversationParticipantsData();
}
private static final int CONVERSATION_OPTIONS_LOADER = 1;
private static final int PARTICIPANT_LOADER = 2;
@NonNull
@Override
public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
final String bindingId = args.getString(BINDING_ID);
// Check if data still bound to the requesting ui element
if (isBound(bindingId)) {
switch (id) {
case CONVERSATION_OPTIONS_LOADER: {
final Uri uri =
MessagingContentProvider.buildConversationMetadataUri(mConversationId);
return new BoundCursorLoader(bindingId, mContext, uri,
new String[]{}, null, null, null);
}
case PARTICIPANT_LOADER: {
final Uri uri =
MessagingContentProvider
.buildConversationParticipantsUri(mConversationId);
return new BoundCursorLoader(bindingId, mContext, uri,
ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
}
default:
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding PeopleAndOptionsFragment");
}
return null;
}
/**
* {@inheritDoc}
*/
@Override
public void onLoadFinished(@NonNull final Loader<Cursor> loader, final Cursor data) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId())) {
switch (loader.getId()) {
case CONVERSATION_OPTIONS_LOADER:
mListener.onOptionsCursorUpdated(this, data);
break;
case PARTICIPANT_LOADER:
mParticipantData.bind(data);
mListener.onParticipantsListLoaded(this,
mParticipantData.getParticipantListExcludingSelf());
break;
default:
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG,
"Loader finished after unbinding PeopleAndOptionsFragment");
}
}
/**
* {@inheritDoc}
*/
@Override
public void onLoaderReset(@NonNull final Loader<Cursor> loader) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId())) {
switch (loader.getId()) {
case CONVERSATION_OPTIONS_LOADER:
mListener.onOptionsCursorUpdated(this, null);
break;
case PARTICIPANT_LOADER:
mParticipantData.bind(null);
break;
default:
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding PeopleAndOptionsFragment");
}
}
public void init(final LoaderManager loaderManager,
final BindingBase<PeopleAndOptionsData> binding) {
final Bundle args = new Bundle();
args.putString(BINDING_ID, binding.getBindingId());
mLoaderManager = loaderManager;
mLoaderManager.initLoader(CONVERSATION_OPTIONS_LOADER, args, this);
mLoaderManager.initLoader(PARTICIPANT_LOADER, args, this);
}
@Override
protected void unregisterListeners() {
mListener = null;
// This could be null if we bind but the caller doesn't init the BindableData
if (mLoaderManager != null) {
mLoaderManager.destroyLoader(CONVERSATION_OPTIONS_LOADER);
mLoaderManager.destroyLoader(PARTICIPANT_LOADER);
mLoaderManager = null;
}
}
public void setDestinationBlocked(final BindingBase<PeopleAndOptionsData> binding,
final boolean blocked) {
final String bindingId = binding.getBindingId();
final ParticipantData participantData = mParticipantData.getOtherParticipant();
if (isBound(bindingId) && participantData != null) {
UpdateDestinationBlockedAction.updateDestinationBlocked(
participantData.getNormalizedDestination(),
blocked, mConversationId,
BugleActionToasts.makeUpdateDestinationBlockedActionListener(mContext));
}
}
public String getConversationId() {
return mConversationId;
}
}
@@ -0,0 +1,79 @@
/*
* 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;
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (C) 2015 The Android Open Source 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.Intent;
import android.net.Uri;
import com.android.messaging.datamodel.binding.BindableData;
/**
* Bridges between any particpant/contact related data and data displayed in the PersonItemView.
*/
public abstract class PersonItemData extends BindableData {
/**
* The UI component that listens for data change and update accordingly.
*/
public interface PersonItemDataListener {
void onPersonDataUpdated(PersonItemData data);
void onPersonDataFailed(PersonItemData data, Exception exception);
}
private PersonItemDataListener mListener;
public abstract Uri getAvatarUri();
public abstract String getDisplayName();
public abstract String getDetails();
public abstract Intent getClickIntent();
public abstract long getContactId();
public abstract String getLookupKey();
public abstract String getNormalizedDestination();
public void setListener(final PersonItemDataListener listener) {
if (isBound()) {
mListener = listener;
}
}
protected void notifyDataUpdated() {
if (isBound() && mListener != null) {
mListener.onPersonDataUpdated(this);
}
}
protected void notifyDataFailed(final Exception exception) {
if (isBound() && mListener != null) {
mListener.onPersonDataFailed(this, exception);
}
}
@Override
protected void unregisterListeners() {
mListener = null;
}
}
@@ -0,0 +1,110 @@
/*
* 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.database.Cursor;
import androidx.collection.ArrayMap;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* A class that contains the list of all self participants potentially involved in a conversation.
* This class contains both active/inactive self entries when there is multi-SIM support.
*/
public class SelfParticipantsData {
/**
* The map from self participant ids to self-participant data entries in the participants table.
* This includes both active, inactive and default (with subId ==
* {@link ParticipantData#DEFAULT_SELF_SUB_ID}) subscriptions.
*/
private final ArrayMap<String, ParticipantData> mSelfParticipantMap;
public SelfParticipantsData() {
mSelfParticipantMap = new ArrayMap<>();
}
public void bind(final Cursor cursor) {
mSelfParticipantMap.clear();
if (cursor != null) {
while (cursor.moveToNext()) {
final ParticipantData newParticipant = ParticipantData.getFromCursor(cursor);
mSelfParticipantMap.put(newParticipant.getId(), newParticipant);
}
}
}
/**
* Gets the list of self participants for all subscriptions.
* @param activeOnly if set, returns active self entries only (i.e. those with SIMs plugged in).
*/
public List<ParticipantData> getSelfParticipants(final boolean activeOnly) {
List<ParticipantData> list = new ArrayList<>();
for (final ParticipantData self : mSelfParticipantMap.values()) {
if (!activeOnly || self.isActiveSubscription()) {
list.add(self);
}
}
list.sort((Comparator) (o1, o2) -> {
int slotId1 = ((ParticipantData) o1).getSlotId();
int slotId2 = ((ParticipantData) o2).getSlotId();
return slotId1 > slotId2 ? 1 : -1;
});
return list;
}
/**
* Gets the self participant corresponding to the given self id.
*/
ParticipantData getSelfParticipantById(final String selfId) {
return mSelfParticipantMap.get(selfId);
}
/**
* Returns if a given self id represents the default self.
*/
boolean isDefaultSelf(final String selfId) {
final ParticipantData self = getSelfParticipantById(selfId);
return self == null ? false : self.getSubId() == ParticipantData.DEFAULT_SELF_SUB_ID;
}
public int getSelfParticipantsCountExcludingDefault(final boolean activeOnly) {
int count = 0;
for (final ParticipantData self : mSelfParticipantMap.values()) {
if (!self.isDefaultSelf() && (!activeOnly || self.isActiveSubscription())) {
count++;
}
}
return count;
}
public ParticipantData getDefaultSelfParticipant() {
for (final ParticipantData self : mSelfParticipantMap.values()) {
if (self.isDefaultSelf()) {
return self;
}
}
return null;
}
boolean isLoaded() {
return !mSelfParticipantMap.isEmpty();
}
}
@@ -0,0 +1,226 @@
/*
* 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 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.R;
import com.android.messaging.datamodel.BoundCursorLoader;
import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
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;
import java.util.ArrayList;
import java.util.List;
/**
* Services SettingsFragment's data needs for loading active self participants to display
* the list of active subscriptions.
*/
public class SettingsData extends BindableData implements
LoaderManager.LoaderCallbacks<Cursor> {
public interface SettingsDataListener {
void onSelfParticipantDataLoaded(SettingsData data);
}
public static class SettingsItem {
public static final int TYPE_GENERAL_SETTINGS = 1;
public static final int TYPE_PER_SUBSCRIPTION_SETTINGS = 2;
private final String mDisplayName;
private final String mDisplayDetail;
private final String mActivityTitle;
private final int mType;
private final int mSubId;
private SettingsItem(final String displayName, final String displayDetail,
final String activityTitle, final int type, final int subId) {
mDisplayName = displayName;
mDisplayDetail = displayDetail;
mActivityTitle = activityTitle;
mType = type;
mSubId = subId;
}
public String getDisplayName() {
return mDisplayName;
}
public String getDisplayDetail() {
return mDisplayDetail;
}
public int getType() {
return mType;
}
public int getSubId() {
return mSubId;
}
public String getActivityTitle() {
return mActivityTitle;
}
public static SettingsItem fromSelfParticipant(final Context context,
final ParticipantData self) {
Assert.isTrue(self.isSelf());
Assert.isTrue(self.isActiveSubscription());
final String displayDetail = TextUtils.isEmpty(self.getDisplayDestination()) ?
context.getString(R.string.sim_settings_unknown_number) :
self.getDisplayDestination();
final String displayName = context.getString(R.string.sim_specific_settings,
self.getSubscriptionName());
return new SettingsItem(displayName, displayDetail, displayName,
TYPE_PER_SUBSCRIPTION_SETTINGS, self.getSubId());
}
public static SettingsItem createGeneralSettingsItem(final Context context) {
return new SettingsItem(context.getString(R.string.general_settings),
null, context.getString(R.string.general_settings_activity_title),
TYPE_GENERAL_SETTINGS, -1);
}
public static SettingsItem createDefaultMmsSettingsItem(final Context context,
final int subId) {
return new SettingsItem(context.getString(R.string.advanced_settings),
null, context.getString(R.string.advanced_settings_activity_title),
TYPE_PER_SUBSCRIPTION_SETTINGS, subId);
}
}
private static final String BINDING_ID = "bindingId";
private final Context mContext;
private final SelfParticipantsData mSelfParticipantsData;
private LoaderManager mLoaderManager;
private SettingsDataListener mListener;
public SettingsData(final Context context,
final SettingsDataListener listener) {
mListener = listener;
mContext = context;
mSelfParticipantsData = new SelfParticipantsData();
}
private static final int SELF_PARTICIPANT_LOADER = 1;
@NonNull
@Override
public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
Assert.equals(SELF_PARTICIPANT_LOADER, id);
Loader<Cursor> loader = null;
final String bindingId = args.getString(BINDING_ID);
// Check if data still bound to the requesting ui element
if (isBound(bindingId)) {
loader = new BoundCursorLoader(bindingId, mContext,
MessagingContentProvider.PARTICIPANTS_URI,
ParticipantData.ParticipantsQuery.PROJECTION,
ParticipantColumns.SUB_ID + " <> ?",
new String[] { String.valueOf(ParticipantData.OTHER_THAN_SELF_SUB_ID) },
null);
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Creating self loader after unbinding");
}
return loader;
}
@Override
public void onLoadFinished(@NonNull final Loader<Cursor> generic, final Cursor data) {
final BoundCursorLoader loader = (BoundCursorLoader) generic;
// Check if data still bound to the requesting ui element
if (isBound(loader.getBindingId())) {
mSelfParticipantsData.bind(data);
mListener.onSelfParticipantDataLoaded(this);
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Self loader finished after unbinding");
}
}
@Override
public void onLoaderReset(final Loader<Cursor> generic) {
final BoundCursorLoader loader = (BoundCursorLoader) generic;
// Check if data still bound to the requesting ui element
if (isBound(loader.getBindingId())) {
mSelfParticipantsData.bind(null);
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Self loader reset after unbinding");
}
}
public void init(final LoaderManager loaderManager,
final BindingBase<SettingsData> binding) {
final Bundle args = new Bundle();
args.putString(BINDING_ID, binding.getBindingId());
mLoaderManager = loaderManager;
mLoaderManager.initLoader(SELF_PARTICIPANT_LOADER, args, this);
}
@Override
protected void unregisterListeners() {
mListener = null;
// This could be null if we bind but the caller doesn't init the BindableData
if (mLoaderManager != null) {
mLoaderManager.destroyLoader(SELF_PARTICIPANT_LOADER);
mLoaderManager = null;
}
}
public List<SettingsItem> getSettingsItems() {
final List<ParticipantData> selfs = mSelfParticipantsData.getSelfParticipants(true);
final List<SettingsItem> settingsItems = new ArrayList<>();
// First goes the general settings, followed by per-subscription settings.
settingsItems.add(SettingsItem.createGeneralSettingsItem(mContext));
// For per-subscription settings, show the actual SIM name with phone number if the
// platorm is at least L-MR1 and there are multiple active SIMs.
final int activeSubCountExcludingDefault =
mSelfParticipantsData.getSelfParticipantsCountExcludingDefault(true);
if (activeSubCountExcludingDefault > 0) {
for (ParticipantData self : selfs) {
if (!self.isDefaultSelf()) {
if (activeSubCountExcludingDefault > 1) {
settingsItems.add(SettingsItem.fromSelfParticipant(mContext, self));
} else {
// This is the only active non-default SIM.
settingsItems.add(SettingsItem.createDefaultMmsSettingsItem(mContext,
self.getSubId()));
break;
}
}
}
} else {
// Either pre-L-MR1, or there's no active SIM, so show the default MMS settings.
settingsItems.add(SettingsItem.createDefaultMmsSettingsItem(mContext,
ParticipantData.DEFAULT_SELF_SUB_ID));
}
return settingsItems;
}
}
@@ -0,0 +1,129 @@
/*
* 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.net.Uri;
import android.text.TextUtils;
import com.android.messaging.R;
import com.android.messaging.util.Assert;
import com.android.messaging.util.AvatarUriUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* This is a UI facing data model component that holds a list of
* {@link SubscriptionListData.SubscriptionListEntry}'s, one for each *active* subscriptions.
*
* This is used to:
* 1) Show a list of SIMs in the SIM Selector
* 2) Show the currently selected SIM in the compose message view
* 3) Show SIM indicators on conversation message views
*
* It builds on top of SelfParticipantsData and performs additional logic such as determining
* the set of icons to use for the individual Subs.
*/
public class SubscriptionListData {
/**
* Represents a single sub that backs UI.
*/
public static class SubscriptionListEntry {
public final String selfParticipantId;
public final Uri iconUri;
public final Uri selectedIconUri;
public final String displayName;
public final int displayColor;
public final String displayDestination;
private SubscriptionListEntry(final String selfParticipantId, final Uri iconUri,
final Uri selectedIconUri, final String displayName, final int displayColor,
final String displayDestination) {
this.selfParticipantId = selfParticipantId;
this.iconUri = iconUri;
this.selectedIconUri = selectedIconUri;
this.displayName = displayName;
this.displayColor = displayColor;
this.displayDestination = displayDestination;
}
static SubscriptionListEntry fromSelfParticipantData(
final ParticipantData selfParticipantData, final Context context) {
Assert.isTrue(selfParticipantData.isSelf());
Assert.isTrue(selfParticipantData.isActiveSubscription());
final int slotId = selfParticipantData.getDisplaySlotId();
final String iconIdentifier = String.format(Locale.getDefault(), "%d", slotId);
final String subscriptionName = selfParticipantData.getSubscriptionName();
final String displayName = TextUtils.isEmpty(subscriptionName) ?
context.getString(R.string.sim_slot_identifier, slotId) : subscriptionName;
return new SubscriptionListEntry(selfParticipantData.getId(),
AvatarUriUtil.createAvatarUri(selfParticipantData, iconIdentifier,
false /* selected */, false /* incoming */),
AvatarUriUtil.createAvatarUri(selfParticipantData, iconIdentifier,
true /* selected */, false /* incoming */),
displayName, selfParticipantData.getSubscriptionColor(),
selfParticipantData.getDisplayDestination());
}
}
private final List<SubscriptionListEntry> mEntriesExcludingDefault;
private SubscriptionListEntry mDefaultEntry;
private final Context mContext;
public SubscriptionListData(final Context context) {
mEntriesExcludingDefault = new ArrayList<>();
mContext = context;
}
public void bind(final List<ParticipantData> subs) {
mEntriesExcludingDefault.clear();
mDefaultEntry = null;
for (final ParticipantData sub : subs) {
final SubscriptionListEntry entry =
SubscriptionListEntry.fromSelfParticipantData(sub, mContext);
if (!sub.isDefaultSelf()) {
mEntriesExcludingDefault.add(entry);
} else {
mDefaultEntry = entry;
}
}
}
public List<SubscriptionListEntry> getActiveSubscriptionEntriesExcludingDefault() {
return mEntriesExcludingDefault;
}
public SubscriptionListEntry getActiveSubscriptionEntryBySelfId(final String selfId,
final boolean excludeDefault) {
if (mDefaultEntry != null && TextUtils.equals(mDefaultEntry.selfParticipantId, selfId)) {
return excludeDefault ? null : mDefaultEntry;
}
for (final SubscriptionListEntry entry : mEntriesExcludingDefault) {
if (TextUtils.equals(entry.selfParticipantId, selfId)) {
return entry;
}
}
return null;
}
public boolean hasData() {
return !mEntriesExcludingDefault.isEmpty() || mDefaultEntry != null;
}
}
@@ -0,0 +1,185 @@
/*
* Copyright (C) 2015 The Android Open Source 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.content.Intent;
import android.net.Uri;
import com.android.messaging.R;
import com.android.messaging.datamodel.binding.Binding;
import com.android.messaging.datamodel.binding.BindingBase;
import com.android.messaging.datamodel.media.BindableMediaRequest;
import com.android.messaging.datamodel.media.MediaRequest;
import com.android.messaging.datamodel.media.MediaResourceManager;
import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener;
import com.android.messaging.datamodel.media.VCardRequestDescriptor;
import com.android.messaging.datamodel.media.VCardResource;
import com.android.messaging.datamodel.media.VCardResourceEntry;
import com.android.messaging.util.Assert;
import com.android.messaging.util.AvatarUriUtil;
import com.android.messaging.util.ContactUtil;
import java.util.List;
/**
* Data class for visualizing and loading data for a VCard contact.
*/
public class VCardContactItemData extends PersonItemData
implements MediaResourceLoadListener<VCardResource> {
private final Context mContext;
private final Uri mVCardUri;
private String mDetails;
private final Binding<BindableMediaRequest<VCardResource>> mBinding =
BindingBase.createBinding(this);
private VCardResource mVCardResource;
private static final Uri sDefaultAvatarUri =
AvatarUriUtil.createAvatarUri(null, null, null, null);
/**
* Constructor. This parses data from the given MessagePartData describing the vcard
*/
public VCardContactItemData(final Context context, final MessagePartData messagePartData) {
this(context, messagePartData.getContentUri());
Assert.isTrue(messagePartData.isVCard());
}
/**
* Constructor. This parses data from the given VCard Uri
*/
public VCardContactItemData(final Context context, final Uri vCardUri) {
mContext = context;
mDetails = mContext.getString(R.string.loading_vcard);
mVCardUri = vCardUri;
}
@Override
public Uri getAvatarUri() {
if (hasValidVCard()) {
final List<VCardResourceEntry> vcards = mVCardResource.getVCards();
Assert.isTrue(vcards.size() > 0);
if (vcards.size() == 1) {
return vcards.get(0).getAvatarUri();
}
}
return sDefaultAvatarUri;
}
@Override
public String getDisplayName() {
if (hasValidVCard()) {
final List<VCardResourceEntry> vcards = mVCardResource.getVCards();
Assert.isTrue(vcards.size() > 0);
if (vcards.size() == 1) {
return vcards.get(0).getDisplayName();
} else {
return mContext.getResources().getQuantityString(
R.plurals.vcard_multiple_display_name, vcards.size(), vcards.size());
}
}
return null;
}
@Override
public String getDetails() {
return mDetails;
}
@Override
public Intent getClickIntent() {
return null;
}
@Override
public long getContactId() {
return ContactUtil.INVALID_CONTACT_ID;
}
@Override
public String getLookupKey() {
return null;
}
@Override
public String getNormalizedDestination() {
return null;
}
public VCardResource getVCardResource() {
return hasValidVCard() ? mVCardResource : null;
}
public Uri getVCardUri() {
return hasValidVCard() ? mVCardUri : null;
}
public boolean hasValidVCard() {
return isBound() && mVCardResource != null;
}
@Override
public void bind(final String bindingId) {
super.bind(bindingId);
// Bind and request the VCard from media resource manager.
mBinding.bind(new VCardRequestDescriptor(mVCardUri).buildAsyncMediaRequest(mContext, this));
MediaResourceManager.get().requestMediaResourceAsync(mBinding.getData());
}
@Override
public void unbind(final String bindingId) {
super.unbind(bindingId);
mBinding.unbind();
if (mVCardResource != null) {
mVCardResource.release();
mVCardResource = null;
}
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (!(o instanceof VCardContactItemData)) {
return false;
}
final VCardContactItemData lhs = (VCardContactItemData) o;
return mVCardUri.equals(lhs.mVCardUri);
}
@Override
public void onMediaResourceLoaded(final MediaRequest<VCardResource> request,
final VCardResource resource, final boolean isCached) {
Assert.isTrue(mVCardResource == null);
mBinding.ensureBound();
mDetails = mContext.getString(R.string.vcard_tap_hint);
mVCardResource = resource;
mVCardResource.addRef();
notifyDataUpdated();
}
@Override
public void onMediaResourceLoadError(final MediaRequest<VCardResource> request,
final Exception exception) {
mBinding.ensureBound();
mDetails = mContext.getString(R.string.failed_loading_vcard);
notifyDataFailed(exception);
}
}
@@ -0,0 +1,75 @@
/*
* 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.media;
import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener;
import java.util.List;
/**
* A mix-in style class that wraps around a normal, threading-agnostic MediaRequest object with
* functionalities offered by {@link BindableMediaRequest} to allow for async processing.
*/
class AsyncMediaRequestWrapper<T extends RefCountedMediaResource> extends BindableMediaRequest<T> {
/**
* Create a new async media request wrapper instance given the listener.
*/
public static <T extends RefCountedMediaResource> AsyncMediaRequestWrapper<T>
createWith(final MediaRequest<T> wrappedRequest,
final MediaResourceLoadListener<T> listener) {
return new AsyncMediaRequestWrapper<>(listener, wrappedRequest);
}
private final MediaRequest<T> mWrappedRequest;
private AsyncMediaRequestWrapper(final MediaResourceLoadListener<T> listener,
final MediaRequest<T> wrappedRequest) {
super(listener);
mWrappedRequest = wrappedRequest;
}
@Override
public String getKey() {
return mWrappedRequest.getKey();
}
@Override
public MediaCache<T> getMediaCache() {
return mWrappedRequest.getMediaCache();
}
@Override
public int getRequestType() {
return mWrappedRequest.getRequestType();
}
@Override
public T loadMediaBlocking(List<MediaRequest<T>> chainedTask) throws Exception {
return mWrappedRequest.loadMediaBlocking(chainedTask);
}
@Override
public int getCacheId() {
return mWrappedRequest.getCacheId();
}
@Override
public MediaRequestDescriptor<T> getDescriptor() {
return mWrappedRequest.getDescriptor();
}
}
@@ -0,0 +1,160 @@
/*
* 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.media;
import android.content.Context;
import android.graphics.RectF;
import android.net.Uri;
import com.android.messaging.util.Assert;
import com.android.messaging.util.AvatarUriUtil;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class AvatarGroupRequestDescriptor extends CompositeImageRequestDescriptor {
private static final int MAX_GROUP_SIZE = 4;
public AvatarGroupRequestDescriptor(final Uri uri, final int desiredWidth,
final int desiredHeight) {
this(convertToDescriptor(uri, desiredWidth, desiredHeight), desiredWidth, desiredHeight);
}
public AvatarGroupRequestDescriptor(final List<? extends ImageRequestDescriptor> descriptors,
final int desiredWidth, final int desiredHeight) {
super(descriptors, desiredWidth, desiredHeight);
Assert.isTrue(descriptors.size() <= MAX_GROUP_SIZE);
}
private static List<? extends ImageRequestDescriptor> convertToDescriptor(final Uri uri,
final int desiredWidth, final int desiredHeight) {
final List<String> participantUriStrings = AvatarUriUtil.getGroupParticipantUris(uri);
final List<AvatarRequestDescriptor> avatarDescriptors =
new ArrayList<>(participantUriStrings.size());
for (final String uriString : participantUriStrings) {
final AvatarRequestDescriptor descriptor = new AvatarRequestDescriptor(
Uri.parse(uriString), desiredWidth, desiredHeight);
avatarDescriptors.add(descriptor);
}
return avatarDescriptors;
}
@Override
public CompositeImageRequest<?> buildBatchImageRequest(final Context context) {
return new CompositeImageRequest<>(context, this);
}
@Override
public List<RectF> getChildRequestTargetRects() {
return Arrays.asList(generateDestRectArray());
}
/**
* Generates an array of {@link RectF} which represents where each of the individual avatar
* should be located in the final group avatar image. The location of each avatar depends on
* the size of the group and the size of the overall group avatar size.
*/
private RectF[] generateDestRectArray() {
final int groupSize = mDescriptors.size();
final float width = desiredWidth;
final float height = desiredHeight;
final float halfWidth = width / 2F;
final float halfHeight = height / 2F;
final RectF[] destArray = new RectF[groupSize];
switch (groupSize) {
case 2:
/**
* +-------+
* | 0 | |
* +-------+
* | | 1 |
* +-------+
*
* We want two circles which touches in the center. To get this we know that the
* diagonal of the overall group avatar is squareRoot(2) * w We also know that the
* two circles touches the at the center of the overall group avatar and the
* distance from the center of the circle to the corner of the group avatar is
* radius * squareRoot(2). Therefore, the following emerges.
*
* w * squareRoot(2) = 2 (radius + radius * squareRoot(2))
* Solving for radius we get:
* d = 2 * radius = ( squareRoot(2) / (squareRoot(2) + 1)) * w
* d = (2 - squareRoot(2)) * w
*/
final float diameter = (float) ((2 - Math.sqrt(2)) * width);
destArray[0] = new RectF(0, 0, diameter, diameter);
destArray[1] = new RectF(width - diameter, height - diameter, width, height);
break;
case 3:
/**
* +-------+
* | | 0 | |
* +-------+
* | 1 | 2 |
* +-------+
* i0
* |\
* a | \ c
* --- i2
* b
*
* a = radius * squareRoot(3) due to the triangle being a 30-60-90 right triangle.
* b = radius of circle
* c = 2 * radius of circle
*
* All three of the images are circles and therefore image zero will not touch
* image one or image two. Move image zero down so it touches image one and image
* two. This can be done by keeping image zero in the center and moving it down
* slightly. The amount to move down can be calculated by solving a right triangle.
* We know that the center x of image two to the center x of image zero is the
* radius of the circle, this is the length of edge b. Also we know that the
* distance from image zero to image two's center is 2 * radius, edge c. From this
* we know that the distance from center y of image two to center y of image one,
* edge a, is equal to radius * squareRoot(3) due to this triangle being a 30-60-90
* right triangle.
*/
final float quarterWidth = width / 4F;
final float threeQuarterWidth = 3 * quarterWidth;
final float radius = height / 4F;
final float imageTwoCenterY = height - radius;
final float lengthOfEdgeA = (float) (radius * Math.sqrt(3));
final float imageZeroCenterY = imageTwoCenterY - lengthOfEdgeA;
final float imageZeroTop = imageZeroCenterY - radius;
final float imageZeroBottom = imageZeroCenterY + radius;
destArray[0] = new RectF(
quarterWidth, imageZeroTop, threeQuarterWidth, imageZeroBottom);
destArray[1] = new RectF(0, halfHeight, halfWidth, height);
destArray[2] = new RectF(halfWidth, halfHeight, width, height);
break;
default:
/**
* +-------+
* | 0 | 1 |
* +-------+
* | 2 | 3 |
* +-------+
*/
destArray[0] = new RectF(0, 0, halfWidth, halfHeight);
destArray[1] = new RectF(halfWidth, 0, width, halfHeight);
destArray[2] = new RectF(0, halfHeight, halfWidth, height);
destArray[3] = new RectF(halfWidth, halfHeight, width, height);
break;
}
return destArray;
}
}
@@ -0,0 +1,178 @@
/*
* 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.media;
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.graphics.drawable.VectorDrawable;
import android.net.Uri;
import android.text.TextUtils;
import androidx.core.content.res.ResourcesCompat;
import androidx.exifinterface.media.ExifInterface;
import com.android.messaging.R;
import com.android.messaging.util.Assert;
import com.android.messaging.util.AvatarUriUtil;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.UriUtil;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
public class AvatarRequest extends UriImageRequest<AvatarRequestDescriptor> {
private static final float SCALING_FACTOR = 1.33f;
private final TypedArray mColors;
public AvatarRequest(final Context context,
final AvatarRequestDescriptor descriptor) {
super(context, descriptor);
mColors = mContext.getResources().obtainTypedArray(R.array.letter_tile_colors);
}
@Override
protected InputStream getInputStreamForResource() throws FileNotFoundException {
if (UriUtil.isLocalResourceUri(mDescriptor.uri)) {
return super.getInputStreamForResource();
} else {
final Uri primaryUri = AvatarUriUtil.getPrimaryUri(mDescriptor.uri);
Assert.isTrue(UriUtil.isLocalResourceUri(primaryUri));
return mContext.getContentResolver().openInputStream(primaryUri);
}
}
/**
* We can load multiple types of images for avatars depending on the uri. The uri should be
* built by {@link com.android.messaging.util.AvatarUriUtil} which will decide on
* what uri to build based on the available profile photo and name. Here we will check if the
* image is a local resource (ie profile photo uri), if the resource isn't a local one we will
* generate a tile with the first letter of the name.
*/
@Override
protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTasks)
throws IOException {
Assert.isNotMainThread();
String avatarType = AvatarUriUtil.getAvatarType(mDescriptor.uri);
Bitmap bitmap = null;
int orientation = ExifInterface.ORIENTATION_NORMAL;
final boolean isLocalResourceUri = UriUtil.isLocalResourceUri(mDescriptor.uri) ||
AvatarUriUtil.TYPE_LOCAL_RESOURCE_URI.equals(avatarType);
if (isLocalResourceUri) {
try {
ImageResource imageResource = super.loadMediaInternal(chainedTasks);
bitmap = imageResource.getBitmap();
orientation = imageResource.mOrientation;
} catch (Exception ex) {
// If we encountered any exceptions trying to load the local avatar resource,
// fall back to generated avatar.
LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "AvatarRequest: failed to load local avatar " +
"resource, switching to fallback rendering", ex);
}
}
final int width = mDescriptor.desiredWidth;
final int height = mDescriptor.desiredHeight;
// Check to see if we already got the bitmap. If not get a fallback avatar
if (bitmap == null) {
Uri generatedUri = mDescriptor.uri;
if (isLocalResourceUri) {
// If we are here, we just failed to load the local resource. Use the fallback Uri
// if possible.
generatedUri = AvatarUriUtil.getFallbackUri(mDescriptor.uri);
if (generatedUri == null) {
// No fallback Uri was provided, use the default avatar.
generatedUri = AvatarUriUtil.DEFAULT_BACKGROUND_AVATAR;
}
}
avatarType = AvatarUriUtil.getAvatarType(generatedUri);
if (AvatarUriUtil.TYPE_LETTER_TILE_URI.equals(avatarType)) {
final String name = AvatarUriUtil.getName(generatedUri);
bitmap = renderLetterTile(name, width, height);
} else {
bitmap = renderDefaultAvatar(width, height);
}
}
return new DecodedImageResource(getKey(), bitmap, orientation);
}
private Bitmap renderDefaultAvatar(final int width, final int height) {
final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height,
getBackgroundColor(AvatarUriUtil.getIdentifier(mDescriptor.uri)));
final Canvas canvas = new Canvas(bitmap);
final VectorDrawable defaultPerson = (VectorDrawable) ResourcesCompat.getDrawable(
mContext.getResources(), R.drawable.ic_person_light, mContext.getTheme());
float dstWidth = Math.min(defaultPerson.getIntrinsicWidth() * SCALING_FACTOR, width);
float dstHeight = Math.min(defaultPerson.getIntrinsicHeight() * SCALING_FACTOR, height);
canvas.translate((width - dstWidth) / 2, (height - dstHeight) / 2);
defaultPerson.setBounds(0, 0, (int)dstWidth, (int)dstHeight);
defaultPerson.draw(canvas);
return bitmap;
}
private Bitmap renderLetterTile(final String name, final int width, final int height) {
final float halfWidth = width / 2;
final float halfHeight = height / 2;
final int minOfWidthAndHeight = Math.min(width, height);
final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height,
getBackgroundColor(AvatarUriUtil.getIdentifier(mDescriptor.uri)));
final Resources resources = mContext.getResources();
final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL));
paint.setColor(resources.getColor(R.color.letter_tile_font_color));
final float letterToTileRatio = resources.getFraction(R.dimen.letter_to_tile_ratio, 1, 1);
paint.setTextSize(letterToTileRatio * minOfWidthAndHeight);
final String firstCharString = name.substring(0, 1).toUpperCase();
final Rect textBound = new Rect();
paint.getTextBounds(firstCharString, 0, 1, textBound);
final Canvas canvas = new Canvas(bitmap);
final float xOffset = halfWidth - textBound.centerX();
final float yOffset = halfHeight - textBound.centerY();
canvas.drawText(firstCharString, xOffset, yOffset, paint);
return bitmap;
}
private int getBackgroundColor(final String identifier) {
if (!TextUtils.isEmpty(identifier) &&
mContext.getResources().getBoolean(R.bool.contact_colors)) {
int idcolor = Math.abs(identifier.hashCode()) % mColors.length();
return mColors.getColor(idcolor,
mContext.getResources().getColor(R.color.primary_color));
} else {
return mContext.getResources().getColor(R.color.primary_color);
}
}
@Override
public int getCacheId() {
return BugleMediaCacheManager.AVATAR_IMAGE_CACHE;
}
}
@@ -0,0 +1,59 @@
/*
* 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.media;
import android.content.Context;
import android.net.Uri;
import com.android.messaging.util.Assert;
import com.android.messaging.util.AvatarUriUtil;
import com.android.messaging.util.ImageUtils;
import com.android.messaging.util.UriUtil;
public class AvatarRequestDescriptor extends UriImageRequestDescriptor {
public AvatarRequestDescriptor(final Uri uri, final int desiredWidth,
final int desiredHeight) {
this(uri, desiredWidth, desiredHeight, true /* cropToCircle */);
}
public AvatarRequestDescriptor(final Uri uri, final int desiredWidth,
final int desiredHeight, final boolean cropToCircle) {
this(uri, desiredWidth, desiredHeight, cropToCircle, false /* isWearBackground */);
}
public AvatarRequestDescriptor(final Uri uri, final int desiredWidth,
final int desiredHeight, boolean cropToCircle, boolean isWearBackground) {
super(uri, desiredWidth, desiredHeight, false /* allowCompression */, true /* isStatic */,
cropToCircle,
ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
Assert.isTrue(uri == null || UriUtil.isLocalResourceUri(uri) ||
AvatarUriUtil.isAvatarUri(uri));
}
@Override
public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) {
final String avatarType = uri == null ? null : AvatarUriUtil.getAvatarType(uri);
if (AvatarUriUtil.TYPE_SIM_SELECTOR_URI.equals(avatarType)) {
return new SimSelectorAvatarRequest(context, this);
} else {
return new AvatarRequest(context, this);
}
}
}
@@ -0,0 +1,63 @@
/*
* Copyright (C) 2015 The Android Open Source 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.media;
import com.android.messaging.datamodel.binding.BindableOnceData;
import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener;
/**
* The {@link MediaRequest} interface is threading-model-oblivious, allowing the implementations to
* be processed synchronously or asynchronously.
* This is a {@link MediaRequest} implementation that includes functionalities such as binding and
* event callbacks for multi-threaded media request processing.
*/
public abstract class BindableMediaRequest<T extends RefCountedMediaResource>
extends BindableOnceData
implements MediaRequest<T>, MediaResourceLoadListener<T> {
private MediaResourceLoadListener<T> mListener;
public BindableMediaRequest(final MediaResourceLoadListener<T> listener) {
mListener = listener;
}
/**
* Delegates the media resource callback to the listener. Performs binding check to ensure
* the listener is still bound to this request.
*/
@Override
public void onMediaResourceLoaded(final MediaRequest<T> request, final T resource,
final boolean cached) {
if (isBound() && mListener != null) {
mListener.onMediaResourceLoaded(request, resource, cached);
}
}
/**
* Delegates the media resource callback to the listener. Performs binding check to ensure
* the listener is still bound to this request.
*/
@Override
public void onMediaResourceLoadError(final MediaRequest<T> request, final Exception exception) {
if (isBound() && mListener != null) {
mListener.onMediaResourceLoadError(request, exception);
}
}
@Override
protected void unregisterListeners() {
mListener = null;
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (C) 2015 The Android Open Source 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.media;
import com.android.messaging.util.Assert;
/**
* An implementation of {@link MediaCacheManager} that creates caches specific to Bugle's needs.
*
* To create a new type of cache, add to the list of cache ids and create a new MediaCache<>
* for your cache id / media resource type in createMediaCacheById().
*/
public class BugleMediaCacheManager extends MediaCacheManager {
// List of available cache ids.
public static final int DEFAULT_IMAGE_CACHE = 1;
public static final int AVATAR_IMAGE_CACHE = 2;
public static final int VCARD_CACHE = 3;
// VCard cache size - we compute the size by count, not by bytes.
private static final int VCARD_CACHE_SIZE = 5;
private static final int SHARED_IMAGE_CACHE_SIZE = 1024 * 10; // 10MB
@Override
protected MediaCache<?> createMediaCacheById(final int id) {
switch (id) {
case DEFAULT_IMAGE_CACHE:
return new PoolableImageCache(SHARED_IMAGE_CACHE_SIZE, id, "DefaultImageCache");
case AVATAR_IMAGE_CACHE:
return new PoolableImageCache(id, "AvatarImageCache");
case VCARD_CACHE:
return new MediaCache<VCardResource>(VCARD_CACHE_SIZE, id, "VCardCache");
default:
Assert.fail("BugleMediaCacheManager: unsupported cache id " + id);
break;
}
return null;
}
}
@@ -0,0 +1,111 @@
/*
* 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.media;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.RectF;
import androidx.exifinterface.media.ExifInterface;
import com.android.messaging.util.Assert;
import com.android.messaging.util.ImageUtils;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.util.List;
/**
* Requests a composite image resource. The composite image resource is constructed by first
* sequentially requesting a number of sub image resources specified by
* {@link CompositeImageRequestDescriptor#getChildRequestDescriptors()}. After this, the
* individual sub images are composed into the final image onto their respective target rects
* returned by {@link CompositeImageRequestDescriptor#getChildRequestTargetRects()}.
*/
public class CompositeImageRequest<D extends CompositeImageRequestDescriptor>
extends ImageRequest<D> {
private final Bitmap mBitmap;
private final Canvas mCanvas;
private final Paint mPaint;
public CompositeImageRequest(final Context context, final D descriptor) {
super(context, descriptor);
mBitmap = getBitmapPool().createOrReuseBitmap(
mDescriptor.desiredWidth, mDescriptor.desiredHeight);
mCanvas = new Canvas(mBitmap);
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
}
@Override
protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTask) {
final List<? extends ImageRequestDescriptor> descriptors =
mDescriptor.getChildRequestDescriptors();
final List<RectF> targetRects = mDescriptor.getChildRequestTargetRects();
Assert.equals(descriptors.size(), targetRects.size());
Assert.isTrue(descriptors.size() > 1);
for (int i = 0; i < descriptors.size(); i++) {
final MediaRequest<ImageResource> request =
descriptors.get(i).buildSyncMediaRequest(mContext);
// Synchronously request the child image.
final ImageResource resource =
MediaResourceManager.get().requestMediaResourceSync(request);
if (resource != null) {
try {
final RectF avatarDestOnGroup = targetRects.get(i);
// Draw the bitmap into a smaller size with a circle mask.
final Bitmap resourceBitmap = resource.getBitmap();
final RectF resourceRect = new RectF(
0, 0, resourceBitmap.getWidth(), resourceBitmap.getHeight());
final Bitmap smallCircleBitmap = getBitmapPool().createOrReuseBitmap(
Math.round(avatarDestOnGroup.width()),
Math.round(avatarDestOnGroup.height()));
final RectF smallCircleRect = new RectF(
0, 0, smallCircleBitmap.getWidth(), smallCircleBitmap.getHeight());
final Canvas smallCircleCanvas = new Canvas(smallCircleBitmap);
ImageUtils.drawBitmapWithCircleOnCanvas(resource.getBitmap(), smallCircleCanvas,
resourceRect, smallCircleRect, null /* bitmapPaint */,
false /* fillBackground */,
ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
final Matrix matrix = new Matrix();
matrix.setRectToRect(smallCircleRect, avatarDestOnGroup,
Matrix.ScaleToFit.FILL);
mCanvas.drawBitmap(smallCircleBitmap, matrix, mPaint);
} finally {
resource.release();
}
}
}
return new DecodedImageResource(getKey(), mBitmap, ExifInterface.ORIENTATION_NORMAL);
}
@Override
public int getCacheId() {
return BugleMediaCacheManager.AVATAR_IMAGE_CACHE;
}
@Override
protected InputStream getInputStreamForResource() throws FileNotFoundException {
throw new IllegalStateException("Composite image request doesn't support input stream!");
}
}
@@ -0,0 +1,61 @@
/*
* Copyright (C) 2015 The Android Open Source 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.media;
import android.content.Context;
import android.graphics.RectF;
import com.google.common.base.Joiner;
import java.util.List;
public abstract class CompositeImageRequestDescriptor extends ImageRequestDescriptor {
protected final List<? extends ImageRequestDescriptor> mDescriptors;
private final String mKey;
public CompositeImageRequestDescriptor(final List<? extends ImageRequestDescriptor> descriptors,
final int desiredWidth, final int desiredHeight) {
super(desiredWidth, desiredHeight);
mDescriptors = descriptors;
final String[] keyParts = new String[descriptors.size()];
for (int i = 0; i < descriptors.size(); i++) {
keyParts[i] = descriptors.get(i).getKey();
}
mKey = Joiner.on(",").skipNulls().join(keyParts);
}
/**
* Gets a key that uniquely identify all the underlying image resource to be loaded (e.g. Uri or
* file path).
*/
@Override
public String getKey() {
return mKey;
}
public List<? extends ImageRequestDescriptor> getChildRequestDescriptors(){
return mDescriptors;
}
public abstract List<RectF> getChildRequestTargetRects();
public abstract CompositeImageRequest<?> buildBatchImageRequest(final Context context);
@Override
public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) {
return buildBatchImageRequest(context);
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (C) 2015 The Android Open Source 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.media;
import android.accounts.Account;
import androidx.collection.ArrayMap;
import com.android.vcard.VCardEntry;
import com.android.vcard.VCardProperty;
import java.util.Map;
/**
* Class which extends VCardEntry to add support for unknown properties. Currently there is a TODO
* to add this in the VCardEntry code, but we have to extend it to add the needed support
*/
public class CustomVCardEntry extends VCardEntry {
// List of properties keyed by their name for easy lookup
private final Map<String, VCardProperty> mAllProperties;
public CustomVCardEntry(int vCardType, Account account) {
super(vCardType, account);
mAllProperties = new ArrayMap<String, VCardProperty>();
}
@Override
public void addProperty(VCardProperty property) {
super.addProperty(property);
mAllProperties.put(property.getName(), property);
}
public VCardProperty getProperty(String name) {
return mAllProperties.get(name);
}
}

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