blob: 7a9d701fbd848698fd741c87c012bc509b206d9c [file] [log] [blame]
/*
* Copyright (C) 2016 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.providers.telephony;
import com.google.android.mms.ContentType;
import com.google.android.mms.pdu.CharacterSets;
import com.android.internal.annotations.VisibleForTesting;
import android.annotation.TargetApi;
import android.app.AlarmManager;
import android.app.IntentService;
import android.app.backup.BackupAgent;
import android.app.backup.BackupDataInput;
import android.app.backup.BackupDataOutput;
import android.app.backup.FullBackupDataOutput;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.net.Uri;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import android.os.PowerManager;
import android.provider.BaseColumns;
import android.provider.Telephony;
import android.telephony.PhoneNumberUtils;
import android.telephony.SubscriptionInfo;
import android.telephony.SubscriptionManager;
import android.text.TextUtils;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.JsonReader;
import android.util.JsonWriter;
import android.util.Log;
import android.util.SparseArray;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileFilter;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.InflaterInputStream;
/***
* Backup agent for backup and restore SMS's and text MMS's.
*
* This backup agent stores SMS's into "sms_backup" file as a JSON array. Example below.
* [{"self_phone":"+1234567891011","address":"+1234567891012","body":"Example sms",
* "date":"1450893518140","date_sent":"1450893514000","status":"-1","type":"1"},
* {"self_phone":"+1234567891011","address":"12345","body":"Example 2","date":"1451328022316",
* "date_sent":"1451328018000","status":"-1","type":"1"}]
*
* Text MMS's are stored into "mms_backup" file as a JSON array. Example below.
* [{"self_phone":"+1234567891011","date":"1451322716","date_sent":"0","m_type":"128","v":"18",
* "msg_box":"2","mms_addresses":[{"type":137,"address":"+1234567891011","charset":106},
* {"type":151,"address":"example@example.com","charset":106}],"mms_body":"Mms to email",
* "mms_charset":106},
* {"self_phone":"+1234567891011","sub":"MMS subject","date":"1451322955","date_sent":"0",
* "m_type":"132","v":"17","msg_box":"1","ct_l":"http://promms/servlets/NOK5BBqgUHAqugrQNM",
* "mms_addresses":[{"type":151,"address":"+1234567891011","charset":106}],
* "mms_body":"Mms\nBody\r\n",
* "mms_charset":106,"sub_cs":"106"}]
*
* It deflates the files on the flight.
* Every 1000 messages it backs up file, deletes it and creates a new one with the same name.
*
* It stores how many bytes we are over the quota and don't backup the oldest messages.
*/
@TargetApi(Build.VERSION_CODES.M)
public class TelephonyBackupAgent extends BackupAgent {
private static final String TAG = "TelephonyBackupAgent";
private static final boolean DEBUG = false;
// Copied from packages/apps/Messaging/src/com/android/messaging/sms/MmsUtils.java.
private static final int DEFAULT_DURATION = 5000; //ms
// Copied from packages/apps/Messaging/src/com/android/messaging/sms/MmsUtils.java.
@VisibleForTesting
static final String sSmilTextOnly =
"<smil>" +
"<head>" +
"<layout>" +
"<root-layout/>" +
"<region id=\"Text\" top=\"0\" left=\"0\" "
+ "height=\"100%%\" width=\"100%%\"/>" +
"</layout>" +
"</head>" +
"<body>" +
"%s" + // constructed body goes here
"</body>" +
"</smil>";
// Copied from packages/apps/Messaging/src/com/android/messaging/sms/MmsUtils.java.
@VisibleForTesting
static final String sSmilTextPart =
"<par dur=\"" + DEFAULT_DURATION + "ms\">" +
"<text src=\"%s\" region=\"Text\" />" +
"</par>";
// JSON key for phone number a message was sent from or received to.
private static final String SELF_PHONE_KEY = "self_phone";
// JSON key for list of addresses of MMS message.
private static final String MMS_ADDRESSES_KEY = "mms_addresses";
// JSON key for list of recipients of the message.
private static final String RECIPIENTS = "recipients";
// JSON key for MMS body.
private static final String MMS_BODY_KEY = "mms_body";
// JSON key for MMS charset.
private static final String MMS_BODY_CHARSET_KEY = "mms_charset";
// File names suffixes for backup/restore.
private static final String SMS_BACKUP_FILE_SUFFIX = "_sms_backup";
private static final String MMS_BACKUP_FILE_SUFFIX = "_mms_backup";
// File name formats for backup. It looks like 000000_sms_backup, 000001_sms_backup, etc.
private static final String SMS_BACKUP_FILE_FORMAT = "%06d"+SMS_BACKUP_FILE_SUFFIX;
private static final String MMS_BACKUP_FILE_FORMAT = "%06d"+MMS_BACKUP_FILE_SUFFIX;
// Charset being used for reading/writing backup files.
private static final String CHARSET_UTF8 = "UTF-8";
// Order by ID entries from database.
private static final String ORDER_BY_ID = BaseColumns._ID + " ASC";
// Order by Date entries from database. We start backup from the oldest.
private static final String ORDER_BY_DATE = "date ASC";
// This is a hard coded string rather than a localized one because we don't want it to
// change when you change locale.
@VisibleForTesting
static final String UNKNOWN_SENDER = "\u02BCUNKNOWN_SENDER!\u02BC";
// Thread id for UNKNOWN_SENDER.
private long mUnknownSenderThreadId;
// Columns from SMS database for backup/restore.
@VisibleForTesting
static final String[] SMS_PROJECTION = new String[] {
Telephony.Sms._ID,
Telephony.Sms.SUBSCRIPTION_ID,
Telephony.Sms.ADDRESS,
Telephony.Sms.BODY,
Telephony.Sms.SUBJECT,
Telephony.Sms.DATE,
Telephony.Sms.DATE_SENT,
Telephony.Sms.STATUS,
Telephony.Sms.TYPE,
Telephony.Sms.THREAD_ID
};
// Columns to fetch recepients of SMS.
private static final String[] SMS_RECIPIENTS_PROJECTION = {
Telephony.Threads._ID,
Telephony.Threads.RECIPIENT_IDS
};
// Columns from MMS database for backup/restore.
@VisibleForTesting
static final String[] MMS_PROJECTION = new String[] {
Telephony.Mms._ID,
Telephony.Mms.SUBSCRIPTION_ID,
Telephony.Mms.SUBJECT,
Telephony.Mms.SUBJECT_CHARSET,
Telephony.Mms.DATE,
Telephony.Mms.DATE_SENT,
Telephony.Mms.MESSAGE_TYPE,
Telephony.Mms.MMS_VERSION,
Telephony.Mms.MESSAGE_BOX,
Telephony.Mms.CONTENT_LOCATION,
Telephony.Mms.THREAD_ID,
Telephony.Mms.TRANSACTION_ID
};
// Columns from addr database for backup/restore. This database is used for fetching addresses
// for MMS message.
@VisibleForTesting
static final String[] MMS_ADDR_PROJECTION = new String[] {
Telephony.Mms.Addr.TYPE,
Telephony.Mms.Addr.ADDRESS,
Telephony.Mms.Addr.CHARSET
};
// Columns from part database for backup/restore. This database is used for fetching body text
// and charset for MMS message.
@VisibleForTesting
static final String[] MMS_TEXT_PROJECTION = new String[] {
Telephony.Mms.Part.TEXT,
Telephony.Mms.Part.CHARSET
};
static final int MMS_TEXT_IDX = 0;
static final int MMS_TEXT_CHARSET_IDX = 1;
// Buffer size for Json writer.
public static final int WRITER_BUFFER_SIZE = 32*1024; //32Kb
// We increase how many bytes backup size over quota by 10%, so we will fit into quota on next
// backup
public static final double BYTES_OVER_QUOTA_MULTIPLIER = 1.1;
// Maximum messages for one backup file. After reaching the limit the agent backs up the file,
// deletes it and creates a new one with the same name.
// Not final for the testing.
@VisibleForTesting
int mMaxMsgPerFile = 1000;
// Default values for SMS, MMS, Addresses restore.
private static ContentValues sDefaultValuesSms = new ContentValues(5);
private static ContentValues sDefaultValuesMms = new ContentValues(6);
private static final ContentValues sDefaultValuesAddr = new ContentValues(2);
// Shared preferences for the backup agent.
private static final String BACKUP_PREFS = "backup_shared_prefs";
// Key for storing quota bytes.
private static final String QUOTA_BYTES = "backup_quota_bytes";
// Key for storing backup data size.
private static final String BACKUP_DATA_BYTES = "backup_data_bytes";
// Key for storing timestamp when backup agent resets quota. It does that to get onQuotaExceeded
// call so it could get the new quota if it changed.
private static final String QUOTA_RESET_TIME = "reset_quota_time";
private static final long QUOTA_RESET_INTERVAL = 30 * AlarmManager.INTERVAL_DAY; // 30 days.
static {
// Consider restored messages read and seen.
sDefaultValuesSms.put(Telephony.Sms.READ, 1);
sDefaultValuesSms.put(Telephony.Sms.SEEN, 1);
sDefaultValuesSms.put(Telephony.Sms.ADDRESS, UNKNOWN_SENDER);
// If there is no sub_id with self phone number on restore set it to -1.
sDefaultValuesSms.put(Telephony.Sms.SUBSCRIPTION_ID, -1);
sDefaultValuesMms.put(Telephony.Mms.READ, 1);
sDefaultValuesMms.put(Telephony.Mms.SEEN, 1);
sDefaultValuesMms.put(Telephony.Mms.SUBSCRIPTION_ID, -1);
sDefaultValuesMms.put(Telephony.Mms.MESSAGE_BOX, Telephony.Mms.MESSAGE_BOX_ALL);
sDefaultValuesMms.put(Telephony.Mms.TEXT_ONLY, 1);
sDefaultValuesAddr.put(Telephony.Mms.Addr.TYPE, 0);
sDefaultValuesAddr.put(Telephony.Mms.Addr.CHARSET, CharacterSets.DEFAULT_CHARSET);
}
private SparseArray<String> mSubId2phone = new SparseArray<String>();
private Map<String, Integer> mPhone2subId = new ArrayMap<String, Integer>();
private Map<Long, Boolean> mThreadArchived = new HashMap<>();
private ContentResolver mContentResolver;
// How many bytes we can backup to fit into quota.
private long mBytesOverQuota;
// Cache list of recipients by threadId. It reduces db requests heavily. Used during backup.
@VisibleForTesting
Map<Long, List<String>> mCacheRecipientsByThread = null;
// Cache threadId by list of recipients. Used during restore.
@VisibleForTesting
Map<Set<String>, Long> mCacheGetOrCreateThreadId = null;
@Override
public void onCreate() {
super.onCreate();
final SubscriptionManager subscriptionManager = SubscriptionManager.from(this);
if (subscriptionManager != null) {
final List<SubscriptionInfo> subInfo =
subscriptionManager.getActiveSubscriptionInfoList();
if (subInfo != null) {
for (SubscriptionInfo sub : subInfo) {
final String phoneNumber = getNormalizedNumber(sub);
mSubId2phone.append(sub.getSubscriptionId(), phoneNumber);
mPhone2subId.put(phoneNumber, sub.getSubscriptionId());
}
}
}
mContentResolver = getContentResolver();
initUnknownSender();
}
@VisibleForTesting
void setContentResolver(ContentResolver contentResolver) {
mContentResolver = contentResolver;
}
@VisibleForTesting
void setSubId(SparseArray<String> subId2Phone, Map<String, Integer> phone2subId) {
mSubId2phone = subId2Phone;
mPhone2subId = phone2subId;
}
@VisibleForTesting
void initUnknownSender() {
mUnknownSenderThreadId = getOrCreateThreadId(null);
sDefaultValuesSms.put(Telephony.Sms.THREAD_ID, mUnknownSenderThreadId);
sDefaultValuesMms.put(Telephony.Mms.THREAD_ID, mUnknownSenderThreadId);
}
@Override
public void onFullBackup(FullBackupDataOutput data) throws IOException {
SharedPreferences sharedPreferences = getSharedPreferences(BACKUP_PREFS, MODE_PRIVATE);
if (sharedPreferences.getLong(QUOTA_RESET_TIME, Long.MAX_VALUE) <
System.currentTimeMillis()) {
clearSharedPreferences();
}
mBytesOverQuota = sharedPreferences.getLong(BACKUP_DATA_BYTES, 0) -
sharedPreferences.getLong(QUOTA_BYTES, Long.MAX_VALUE);
if (mBytesOverQuota > 0) {
mBytesOverQuota *= BYTES_OVER_QUOTA_MULTIPLIER;
}
try (
Cursor smsCursor = mContentResolver.query(Telephony.Sms.CONTENT_URI, SMS_PROJECTION,
null, null, ORDER_BY_DATE);
// Do not backup non text-only MMS's.
Cursor mmsCursor = mContentResolver.query(Telephony.Mms.CONTENT_URI, MMS_PROJECTION,
Telephony.Mms.TEXT_ONLY+"=1", null, ORDER_BY_DATE)) {
if (smsCursor != null) {
smsCursor.moveToFirst();
}
if (mmsCursor != null) {
mmsCursor.moveToFirst();
}
// It backs up messages from the oldest to newest. First it looks at the timestamp of
// the next SMS messages and MMS message. If the SMS is older it backs up 1000 SMS
// messages, otherwise 1000 MMS messages. Repeat until out of SMS's or MMS's.
// It ensures backups are incremental.
int fileNum = 0;
while (smsCursor != null && !smsCursor.isAfterLast() &&
mmsCursor != null && !mmsCursor.isAfterLast()) {
final long smsDate = TimeUnit.MILLISECONDS.toSeconds(getMessageDate(smsCursor));
final long mmsDate = getMessageDate(mmsCursor);
if (smsDate < mmsDate) {
backupAll(data, smsCursor,
String.format(Locale.US, SMS_BACKUP_FILE_FORMAT, fileNum++));
} else {
backupAll(data, mmsCursor, String.format(Locale.US,
MMS_BACKUP_FILE_FORMAT, fileNum++));
}
}
while (smsCursor != null && !smsCursor.isAfterLast()) {
backupAll(data, smsCursor,
String.format(Locale.US, SMS_BACKUP_FILE_FORMAT, fileNum++));
}
while (mmsCursor != null && !mmsCursor.isAfterLast()) {
backupAll(data, mmsCursor,
String.format(Locale.US, MMS_BACKUP_FILE_FORMAT, fileNum++));
}
}
mThreadArchived = new HashMap<>();
}
@VisibleForTesting
void clearSharedPreferences() {
getSharedPreferences(BACKUP_PREFS, MODE_PRIVATE).edit()
.remove(BACKUP_DATA_BYTES)
.remove(QUOTA_BYTES)
.remove(QUOTA_RESET_TIME)
.apply();
}
private static long getMessageDate(Cursor cursor) {
return cursor.getLong(cursor.getColumnIndex(Telephony.Sms.DATE));
}
@Override
public void onQuotaExceeded(long backupDataBytes, long quotaBytes) {
SharedPreferences sharedPreferences = getSharedPreferences(BACKUP_PREFS, MODE_PRIVATE);
if (sharedPreferences.contains(BACKUP_DATA_BYTES)
&& sharedPreferences.contains(QUOTA_BYTES)) {
// Increase backup size by the size we skipped during previous backup.
backupDataBytes += (sharedPreferences.getLong(BACKUP_DATA_BYTES, 0)
- sharedPreferences.getLong(QUOTA_BYTES, 0)) * BYTES_OVER_QUOTA_MULTIPLIER;
}
sharedPreferences.edit()
.putLong(BACKUP_DATA_BYTES, backupDataBytes)
.putLong(QUOTA_BYTES, quotaBytes)
.putLong(QUOTA_RESET_TIME, System.currentTimeMillis() + QUOTA_RESET_INTERVAL)
.apply();
}
private void backupAll(FullBackupDataOutput data, Cursor cursor, String fileName)
throws IOException {
if (cursor == null || cursor.isAfterLast()) {
return;
}
int messagesWritten = 0;
try (JsonWriter jsonWriter = getJsonWriter(fileName)) {
if (fileName.endsWith(SMS_BACKUP_FILE_SUFFIX)) {
messagesWritten = putSmsMessagesToJson(cursor, jsonWriter);
} else {
messagesWritten = putMmsMessagesToJson(cursor, jsonWriter);
}
}
backupFile(messagesWritten, fileName, data);
}
@VisibleForTesting
int putMmsMessagesToJson(Cursor cursor,
JsonWriter jsonWriter) throws IOException {
jsonWriter.beginArray();
int msgCount;
for (msgCount = 0; msgCount < mMaxMsgPerFile && !cursor.isAfterLast();
cursor.moveToNext()) {
msgCount += writeMmsToWriter(jsonWriter, cursor);
}
jsonWriter.endArray();
return msgCount;
}
@VisibleForTesting
int putSmsMessagesToJson(Cursor cursor, JsonWriter jsonWriter) throws IOException {
jsonWriter.beginArray();
int msgCount;
for (msgCount = 0; msgCount < mMaxMsgPerFile && !cursor.isAfterLast();
++msgCount, cursor.moveToNext()) {
writeSmsToWriter(jsonWriter, cursor);
}
jsonWriter.endArray();
return msgCount;
}
private void backupFile(int messagesWritten, String fileName, FullBackupDataOutput data)
throws IOException {
final File file = new File(getFilesDir().getPath() + "/" + fileName);
try {
if (messagesWritten > 0) {
if (mBytesOverQuota > 0) {
mBytesOverQuota -= file.length();
return;
}
super.fullBackupFile(file, data);
}
} finally {
file.delete();
}
}
public static class DeferredSmsMmsRestoreService extends IntentService {
private static final String TAG = "DeferredSmsMmsRestoreService";
private final Comparator<File> mFileComparator = new Comparator<File>() {
@Override
public int compare(File lhs, File rhs) {
return rhs.getName().compareTo(lhs.getName());
}
};
public DeferredSmsMmsRestoreService() {
super(TAG);
setIntentRedelivery(true);
}
private TelephonyBackupAgent mTelephonyBackupAgent;
private PowerManager.WakeLock mWakeLock;
@Override
protected void onHandleIntent(Intent intent) {
try {
mWakeLock.acquire();
File[] files = getFilesToRestore(this);
if (files == null || files.length == 0) {
return;
}
Arrays.sort(files, mFileComparator);
for (File file : files) {
final String fileName = file.getName();
try (FileInputStream fileInputStream = new FileInputStream(file)) {
mTelephonyBackupAgent.doRestoreFile(fileName, fileInputStream.getFD());
} catch (Exception e) {
// Either IOException or RuntimeException.
Log.e(TAG, e.toString());
} finally {
file.delete();
}
}
} finally {
mWakeLock.release();
}
}
@Override
public void onCreate() {
super.onCreate();
mTelephonyBackupAgent = new TelephonyBackupAgent();
mTelephonyBackupAgent.attach(this);
mTelephonyBackupAgent.onCreate();
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
}
@Override
public void onDestroy() {
if (mTelephonyBackupAgent != null) {
mTelephonyBackupAgent.onDestroy();
mTelephonyBackupAgent = null;
}
super.onDestroy();
}
static void startIfFilesExist(Context context) {
File[] files = getFilesToRestore(context);
if (files == null || files.length == 0) {
return;
}
context.startService(new Intent(context, DeferredSmsMmsRestoreService.class));
}
private static File[] getFilesToRestore(Context context) {
return context.getFilesDir().listFiles(new FileFilter() {
@Override
public boolean accept(File file) {
return file.getName().endsWith(SMS_BACKUP_FILE_SUFFIX) ||
file.getName().endsWith(MMS_BACKUP_FILE_SUFFIX);
}
});
}
}
@Override
public void onRestoreFinished() {
super.onRestoreFinished();
DeferredSmsMmsRestoreService.startIfFilesExist(this);
}
private void doRestoreFile(String fileName, FileDescriptor fd) throws IOException {
if (DEBUG) {
Log.i(TAG, "Restoring file " + fileName);
}
try (JsonReader jsonReader = getJsonReader(fd)) {
if (fileName.endsWith(SMS_BACKUP_FILE_SUFFIX)) {
if (DEBUG) {
Log.i(TAG, "Restoring SMS");
}
putSmsMessagesToProvider(jsonReader);
} else if (fileName.endsWith(MMS_BACKUP_FILE_SUFFIX)) {
if (DEBUG) {
Log.i(TAG, "Restoring text MMS");
}
putMmsMessagesToProvider(jsonReader);
} else {
if (DEBUG) {
Log.e(TAG, "Unknown file to restore:" + fileName);
}
}
}
}
@VisibleForTesting
void putSmsMessagesToProvider(JsonReader jsonReader) throws IOException {
jsonReader.beginArray();
int msgCount = 0;
final int bulkInsertSize = mMaxMsgPerFile;
ContentValues[] values = new ContentValues[bulkInsertSize];
while (jsonReader.hasNext()) {
ContentValues cv = readSmsValuesFromReader(jsonReader);
if (doesSmsExist(cv)) {
continue;
}
values[(msgCount++) % bulkInsertSize] = cv;
if (msgCount % bulkInsertSize == 0) {
mContentResolver.bulkInsert(Telephony.Sms.CONTENT_URI, values);
}
}
if (msgCount % bulkInsertSize > 0) {
mContentResolver.bulkInsert(Telephony.Sms.CONTENT_URI,
Arrays.copyOf(values, msgCount % bulkInsertSize));
}
jsonReader.endArray();
}
@VisibleForTesting
void putMmsMessagesToProvider(JsonReader jsonReader) throws IOException {
jsonReader.beginArray();
while (jsonReader.hasNext()) {
final Mms mms = readMmsFromReader(jsonReader);
if (doesMmsExist(mms)) {
if (DEBUG) {
Log.e(TAG, String.format("Mms: %s already exists", mms.toString()));
}
continue;
}
addMmsMessage(mms);
}
}
@VisibleForTesting
static final String[] PROJECTION_ID = {BaseColumns._ID};
private static final int ID_IDX = 0;
private boolean doesSmsExist(ContentValues smsValues) {
final String where = String.format(Locale.US, "%s = %d and %s = %s",
Telephony.Sms.DATE, smsValues.getAsLong(Telephony.Sms.DATE),
Telephony.Sms.BODY,
DatabaseUtils.sqlEscapeString(smsValues.getAsString(Telephony.Sms.BODY)));
try (Cursor cursor = mContentResolver.query(Telephony.Sms.CONTENT_URI, PROJECTION_ID, where,
null, null)) {
return cursor != null && cursor.getCount() > 0;
}
}
private boolean doesMmsExist(Mms mms) {
final String where = String.format(Locale.US, "%s = %d",
Telephony.Sms.DATE, mms.values.getAsLong(Telephony.Mms.DATE));
try (Cursor cursor = mContentResolver.query(Telephony.Mms.CONTENT_URI, PROJECTION_ID, where,
null, null)) {
if (cursor != null && cursor.moveToFirst()) {
do {
final int mmsId = cursor.getInt(ID_IDX);
final MmsBody body = getMmsBody(mmsId);
if (body != null && body.equals(mms.body)) {
return true;
}
} while (cursor.moveToNext());
}
}
return false;
}
private static String getNormalizedNumber(SubscriptionInfo subscriptionInfo) {
if (subscriptionInfo == null) {
return null;
}
return PhoneNumberUtils.formatNumberToE164(subscriptionInfo.getNumber(),
subscriptionInfo.getCountryIso().toUpperCase(Locale.US));
}
private void writeSmsToWriter(JsonWriter jsonWriter, Cursor cursor) throws IOException {
jsonWriter.beginObject();
for (int i=0; i<cursor.getColumnCount(); ++i) {
final String name = cursor.getColumnName(i);
final String value = cursor.getString(i);
if (value == null) {
continue;
}
switch (name) {
case Telephony.Sms.SUBSCRIPTION_ID:
final int subId = cursor.getInt(i);
final String selfNumber = mSubId2phone.get(subId);
if (selfNumber != null) {
jsonWriter.name(SELF_PHONE_KEY).value(selfNumber);
}
break;
case Telephony.Sms.THREAD_ID:
final long threadId = cursor.getLong(i);
handleThreadId(jsonWriter, threadId);
break;
case Telephony.Sms._ID:
break;
default:
jsonWriter.name(name).value(value);
break;
}
}
jsonWriter.endObject();
}
private void handleThreadId(JsonWriter jsonWriter, long threadId) throws IOException {
final List<String> recipients = getRecipientsByThread(threadId);
if (recipients == null || recipients.isEmpty()) {
return;
}
writeRecipientsToWriter(jsonWriter.name(RECIPIENTS), recipients);
if (!mThreadArchived.containsKey(threadId)) {
boolean isArchived = isThreadArchived(threadId);
if (isArchived) {
jsonWriter.name(Telephony.Threads.ARCHIVED).value(true);
}
mThreadArchived.put(threadId, isArchived);
}
}
private static String[] THREAD_ARCHIVED_PROJECTION =
new String[] { Telephony.Threads.ARCHIVED };
private static int THREAD_ARCHIVED_IDX = 0;
private boolean isThreadArchived(long threadId) {
Uri.Builder builder = Telephony.Threads.CONTENT_URI.buildUpon();
builder.appendPath(String.valueOf(threadId)).appendPath("recipients");
Uri uri = builder.build();
try (Cursor cursor = getContentResolver().query(uri, THREAD_ARCHIVED_PROJECTION, null, null,
null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(THREAD_ARCHIVED_IDX) == 1;
}
}
return false;
}
private static void writeRecipientsToWriter(JsonWriter jsonWriter, List<String> recipients)
throws IOException {
jsonWriter.beginArray();
if (recipients != null) {
for (String s : recipients) {
jsonWriter.value(s);
}
}
jsonWriter.endArray();
}
private ContentValues readSmsValuesFromReader(JsonReader jsonReader)
throws IOException {
ContentValues values = new ContentValues(6+sDefaultValuesSms.size());
values.putAll(sDefaultValuesSms);
long threadId = -1;
boolean isArchived = false;
jsonReader.beginObject();
while (jsonReader.hasNext()) {
String name = jsonReader.nextName();
switch (name) {
case Telephony.Sms.BODY:
case Telephony.Sms.DATE:
case Telephony.Sms.DATE_SENT:
case Telephony.Sms.STATUS:
case Telephony.Sms.TYPE:
case Telephony.Sms.SUBJECT:
case Telephony.Sms.ADDRESS:
values.put(name, jsonReader.nextString());
break;
case RECIPIENTS:
threadId = getOrCreateThreadId(getRecipients(jsonReader));
values.put(Telephony.Sms.THREAD_ID, threadId);
break;
case Telephony.Threads.ARCHIVED:
isArchived = jsonReader.nextBoolean();
break;
case SELF_PHONE_KEY:
final String selfPhone = jsonReader.nextString();
if (mPhone2subId.containsKey(selfPhone)) {
values.put(Telephony.Sms.SUBSCRIPTION_ID, mPhone2subId.get(selfPhone));
}
break;
default:
if (DEBUG) {
Log.w(TAG, "Unknown name:" + name);
}
jsonReader.skipValue();
break;
}
}
jsonReader.endObject();
archiveThread(threadId, isArchived);
return values;
}
private static Set<String> getRecipients(JsonReader jsonReader) throws IOException {
Set<String> recipients = new ArraySet<String>();
jsonReader.beginArray();
while (jsonReader.hasNext()) {
recipients.add(jsonReader.nextString());
}
jsonReader.endArray();
return recipients;
}
private int writeMmsToWriter(JsonWriter jsonWriter, Cursor cursor) throws IOException {
final int mmsId = cursor.getInt(ID_IDX);
final MmsBody body = getMmsBody(mmsId);
if (body == null || body.text == null) {
return 0;
}
boolean subjectNull = true;
jsonWriter.beginObject();
for (int i=0; i<cursor.getColumnCount(); ++i) {
final String name = cursor.getColumnName(i);
final String value = cursor.getString(i);
if (value == null) {
continue;
}
switch (name) {
case Telephony.Mms.SUBSCRIPTION_ID:
final int subId = cursor.getInt(i);
final String selfNumber = mSubId2phone.get(subId);
if (selfNumber != null) {
jsonWriter.name(SELF_PHONE_KEY).value(selfNumber);
}
break;
case Telephony.Mms.THREAD_ID:
final long threadId = cursor.getLong(i);
handleThreadId(jsonWriter, threadId);
break;
case Telephony.Mms._ID:
case Telephony.Mms.SUBJECT_CHARSET:
break;
case Telephony.Mms.SUBJECT:
subjectNull = false;
default:
jsonWriter.name(name).value(value);
break;
}
}
// Addresses.
writeMmsAddresses(jsonWriter.name(MMS_ADDRESSES_KEY), mmsId);
// Body (text of the message).
jsonWriter.name(MMS_BODY_KEY).value(body.text);
// Charset of the body text.
jsonWriter.name(MMS_BODY_CHARSET_KEY).value(body.charSet);
if (!subjectNull) {
// Subject charset.
writeStringToWriter(jsonWriter, cursor, Telephony.Mms.SUBJECT_CHARSET);
}
jsonWriter.endObject();
return 1;
}
private Mms readMmsFromReader(JsonReader jsonReader) throws IOException {
Mms mms = new Mms();
mms.values = new ContentValues(5+sDefaultValuesMms.size());
mms.values.putAll(sDefaultValuesMms);
jsonReader.beginObject();
String bodyText = null;
long threadId = -1;
boolean isArchived = false;
int bodyCharset = CharacterSets.DEFAULT_CHARSET;
while (jsonReader.hasNext()) {
String name = jsonReader.nextName();
switch (name) {
case SELF_PHONE_KEY:
final String selfPhone = jsonReader.nextString();
if (mPhone2subId.containsKey(selfPhone)) {
mms.values.put(Telephony.Mms.SUBSCRIPTION_ID, mPhone2subId.get(selfPhone));
}
break;
case MMS_ADDRESSES_KEY:
getMmsAddressesFromReader(jsonReader, mms);
break;
case MMS_BODY_KEY:
bodyText = jsonReader.nextString();
break;
case MMS_BODY_CHARSET_KEY:
bodyCharset = jsonReader.nextInt();
break;
case RECIPIENTS:
threadId = getOrCreateThreadId(getRecipients(jsonReader));
mms.values.put(Telephony.Sms.THREAD_ID, threadId);
break;
case Telephony.Threads.ARCHIVED:
isArchived = jsonReader.nextBoolean();
break;
case Telephony.Mms.SUBJECT:
case Telephony.Mms.SUBJECT_CHARSET:
case Telephony.Mms.DATE:
case Telephony.Mms.DATE_SENT:
case Telephony.Mms.MESSAGE_TYPE:
case Telephony.Mms.MMS_VERSION:
case Telephony.Mms.MESSAGE_BOX:
case Telephony.Mms.CONTENT_LOCATION:
case Telephony.Mms.TRANSACTION_ID:
mms.values.put(name, jsonReader.nextString());
break;
default:
if (DEBUG) {
Log.w(TAG, "Unknown name:" + name);
}
jsonReader.skipValue();
break;
}
}
jsonReader.endObject();
if (bodyText != null) {
mms.body = new MmsBody(bodyText, bodyCharset);
}
// Set default charset for subject.
if (mms.values.get(Telephony.Mms.SUBJECT) != null &&
mms.values.get(Telephony.Mms.SUBJECT_CHARSET) == null) {
mms.values.put(Telephony.Mms.SUBJECT_CHARSET, CharacterSets.DEFAULT_CHARSET);
}
archiveThread(threadId, isArchived);
return mms;
}
private static final String ARCHIVE_THREAD_SELECTION = Telephony.Threads._ID + "=?";
private void archiveThread(long threadId, boolean isArchived) {
if (threadId < 0 || !isArchived) {
return;
}
final ContentValues values = new ContentValues(1);
values.put(Telephony.Threads.ARCHIVED, 1);
if (mContentResolver.update(
Telephony.Threads.CONTENT_URI,
values,
ARCHIVE_THREAD_SELECTION,
new String[] { Long.toString(threadId)}) != 1) {
if (DEBUG) {
Log.e(TAG, "archiveThread: failed to update database");
}
}
}
private MmsBody getMmsBody(int mmsId) {
Uri MMS_PART_CONTENT_URI = Telephony.Mms.CONTENT_URI.buildUpon()
.appendPath(String.valueOf(mmsId)).appendPath("part").build();
String body = null;
int charSet = 0;
try (Cursor cursor = mContentResolver.query(MMS_PART_CONTENT_URI, MMS_TEXT_PROJECTION,
Telephony.Mms.Part.CONTENT_TYPE + "=?", new String[]{ContentType.TEXT_PLAIN},
ORDER_BY_ID)) {
if (cursor != null && cursor.moveToFirst()) {
do {
body = (body == null ? cursor.getString(MMS_TEXT_IDX)
: body.concat(cursor.getString(MMS_TEXT_IDX)));
charSet = cursor.getInt(MMS_TEXT_CHARSET_IDX);
} while (cursor.moveToNext());
}
}
return (body == null ? null : new MmsBody(body, charSet));
}
private void writeMmsAddresses(JsonWriter jsonWriter, int mmsId) throws IOException {
Uri.Builder builder = Telephony.Mms.CONTENT_URI.buildUpon();
builder.appendPath(String.valueOf(mmsId)).appendPath("addr");
Uri uriAddrPart = builder.build();
jsonWriter.beginArray();
try (Cursor cursor = mContentResolver.query(uriAddrPart, MMS_ADDR_PROJECTION,
null/*selection*/, null/*selectionArgs*/, ORDER_BY_ID)) {
if (cursor != null && cursor.moveToFirst()) {
do {
if (cursor.getString(cursor.getColumnIndex(Telephony.Mms.Addr.ADDRESS))
!= null) {
jsonWriter.beginObject();
writeIntToWriter(jsonWriter, cursor, Telephony.Mms.Addr.TYPE);
writeStringToWriter(jsonWriter, cursor, Telephony.Mms.Addr.ADDRESS);
writeIntToWriter(jsonWriter, cursor, Telephony.Mms.Addr.CHARSET);
jsonWriter.endObject();
}
} while (cursor.moveToNext());
}
}
jsonWriter.endArray();
}
private static void getMmsAddressesFromReader(JsonReader jsonReader, Mms mms)
throws IOException {
mms.addresses = new ArrayList<ContentValues>();
jsonReader.beginArray();
while (jsonReader.hasNext()) {
jsonReader.beginObject();
ContentValues addrValues = new ContentValues(sDefaultValuesAddr);
while (jsonReader.hasNext()) {
final String name = jsonReader.nextName();
switch (name) {
case Telephony.Mms.Addr.TYPE:
case Telephony.Mms.Addr.CHARSET:
addrValues.put(name, jsonReader.nextInt());
break;
case Telephony.Mms.Addr.ADDRESS:
addrValues.put(name, jsonReader.nextString());
break;
default:
if (DEBUG) {
Log.w(TAG, "Unknown name:" + name);
}
jsonReader.skipValue();
break;
}
}
jsonReader.endObject();
if (addrValues.containsKey(Telephony.Mms.Addr.ADDRESS)) {
mms.addresses.add(addrValues);
}
}
jsonReader.endArray();
}
private void addMmsMessage(Mms mms) {
if (DEBUG) {
Log.e(TAG, "Add mms:\n" + mms.toString());
}
final long dummyId = System.currentTimeMillis(); // Dummy ID of the msg.
final Uri partUri = Telephony.Mms.CONTENT_URI.buildUpon()
.appendPath(String.valueOf(dummyId)).appendPath("part").build();
final String srcName = String.format(Locale.US, "text.%06d.txt", 0);
{ // Insert SMIL part.
final String smilBody = String.format(sSmilTextPart, srcName);
final String smil = String.format(sSmilTextOnly, smilBody);
final ContentValues values = new ContentValues(7);
values.put(Telephony.Mms.Part.MSG_ID, dummyId);
values.put(Telephony.Mms.Part.SEQ, -1);
values.put(Telephony.Mms.Part.CONTENT_TYPE, ContentType.APP_SMIL);
values.put(Telephony.Mms.Part.NAME, "smil.xml");
values.put(Telephony.Mms.Part.CONTENT_ID, "<smil>");
values.put(Telephony.Mms.Part.CONTENT_LOCATION, "smil.xml");
values.put(Telephony.Mms.Part.TEXT, smil);
if (mContentResolver.insert(partUri, values) == null) {
if (DEBUG) {
Log.e(TAG, "Could not insert SMIL part");
}
return;
}
}
{ // Insert body part.
final ContentValues values = new ContentValues(8);
values.put(Telephony.Mms.Part.MSG_ID, dummyId);
values.put(Telephony.Mms.Part.SEQ, 0);
values.put(Telephony.Mms.Part.CONTENT_TYPE, ContentType.TEXT_PLAIN);
values.put(Telephony.Mms.Part.NAME, srcName);
values.put(Telephony.Mms.Part.CONTENT_ID, "<"+srcName+">");
values.put(Telephony.Mms.Part.CONTENT_LOCATION, srcName);
values.put(Telephony.Mms.Part.CHARSET, mms.body.charSet);
values.put(Telephony.Mms.Part.TEXT, mms.body.text);
if (mContentResolver.insert(partUri, values) == null) {
if (DEBUG) {
Log.e(TAG, "Could not insert body part");
}
return;
}
}
// Insert mms.
final Uri mmsUri = mContentResolver.insert(Telephony.Mms.CONTENT_URI, mms.values);
if (mmsUri == null) {
if (DEBUG) {
Log.e(TAG, "Could not insert mms");
}
return;
}
final long mmsId = ContentUris.parseId(mmsUri);
{ // Update parts with the right mms id.
ContentValues values = new ContentValues(1);
values.put(Telephony.Mms.Part.MSG_ID, mmsId);
mContentResolver.update(partUri, values, null, null);
}
{ // Insert adderesses into "addr".
final Uri addrUri = Uri.withAppendedPath(mmsUri, "addr");
for (ContentValues mmsAddress : mms.addresses) {
ContentValues values = new ContentValues(mmsAddress);
values.put(Telephony.Mms.Addr.MSG_ID, mmsId);
mContentResolver.insert(addrUri, values);
}
}
}
private static final class MmsBody {
public String text;
public int charSet;
public MmsBody(String text, int charSet) {
this.text = text;
this.charSet = charSet;
}
@Override
public boolean equals(Object obj) {
if (obj == null || !(obj instanceof MmsBody)) {
return false;
}
MmsBody typedObj = (MmsBody) obj;
return this.text.equals(typedObj.text) && this.charSet == typedObj.charSet;
}
@Override
public String toString() {
return "Text:" + text + " charSet:" + charSet;
}
}
private static final class Mms {
public ContentValues values;
public List<ContentValues> addresses;
public MmsBody body;
@Override
public String toString() {
return "Values:" + values.toString() + "\nRecipients:"+addresses.toString()
+ "\nBody:" + body;
}
}
private JsonWriter getJsonWriter(final String fileName) throws IOException {
return new JsonWriter(new BufferedWriter(new OutputStreamWriter(new DeflaterOutputStream(
openFileOutput(fileName, MODE_PRIVATE)), CHARSET_UTF8), WRITER_BUFFER_SIZE));
}
private static JsonReader getJsonReader(final FileDescriptor fileDescriptor)
throws IOException {
return new JsonReader(new InputStreamReader(new InflaterInputStream(
new FileInputStream(fileDescriptor)), CHARSET_UTF8));
}
private static void writeStringToWriter(JsonWriter jsonWriter, Cursor cursor, String name)
throws IOException {
final String value = cursor.getString(cursor.getColumnIndex(name));
if (value != null) {
jsonWriter.name(name).value(value);
}
}
private static void writeIntToWriter(JsonWriter jsonWriter, Cursor cursor, String name)
throws IOException {
final int value = cursor.getInt(cursor.getColumnIndex(name));
if (value != 0) {
jsonWriter.name(name).value(value);
}
}
private long getOrCreateThreadId(Set<String> recipients) {
if (recipients == null) {
recipients = new ArraySet<String>();
}
if (recipients.isEmpty()) {
recipients.add(UNKNOWN_SENDER);
}
if (mCacheGetOrCreateThreadId == null) {
mCacheGetOrCreateThreadId = new HashMap<>();
}
if (!mCacheGetOrCreateThreadId.containsKey(recipients)) {
long threadId = mUnknownSenderThreadId;
try {
threadId = Telephony.Threads.getOrCreateThreadId(this, recipients);
} catch (RuntimeException e) {
if (DEBUG) {
Log.e(TAG, e.toString());
}
}
mCacheGetOrCreateThreadId.put(recipients, threadId);
return threadId;
}
return mCacheGetOrCreateThreadId.get(recipients);
}
@VisibleForTesting
static final Uri THREAD_ID_CONTENT_URI = Uri.parse("content://mms-sms/threadID");
// Mostly copied from packages/apps/Messaging/src/com/android/messaging/sms/MmsUtils.java.
private List<String> getRecipientsByThread(final long threadId) {
if (mCacheRecipientsByThread == null) {
mCacheRecipientsByThread = new HashMap<>();
}
if (!mCacheRecipientsByThread.containsKey(threadId)) {
final String spaceSepIds = getRawRecipientIdsForThread(threadId);
if (!TextUtils.isEmpty(spaceSepIds)) {
mCacheRecipientsByThread.put(threadId, getAddresses(spaceSepIds));
} else {
mCacheRecipientsByThread.put(threadId, new ArrayList<String>());
}
}
return mCacheRecipientsByThread.get(threadId);
}
@VisibleForTesting
static final Uri ALL_THREADS_URI =
Telephony.Threads.CONTENT_URI.buildUpon().
appendQueryParameter("simple", "true").build();
private static final int RECIPIENT_IDS = 1;
// Copied from packages/apps/Messaging/src/com/android/messaging/sms/MmsUtils.java.
// NOTE: There are phones on which you can't get the recipients from the thread id for SMS
// until you have a message in the conversation!
private String getRawRecipientIdsForThread(final long threadId) {
if (threadId <= 0) {
return null;
}
final Cursor thread = mContentResolver.query(
ALL_THREADS_URI,
SMS_RECIPIENTS_PROJECTION, "_id=?", new String[]{String.valueOf(threadId)}, null);
if (thread != null) {
try {
if (thread.moveToFirst()) {
// recipientIds will be a space-separated list of ids into the
// canonical addresses table.
return thread.getString(RECIPIENT_IDS);
}
} finally {
thread.close();
}
}
return null;
}
@VisibleForTesting
static final Uri SINGLE_CANONICAL_ADDRESS_URI =
Uri.parse("content://mms-sms/canonical-address");
// Copied from packages/apps/Messaging/src/com/android/messaging/sms/MmsUtils.java.
private List<String> getAddresses(final String spaceSepIds) {
final List<String> numbers = new ArrayList<String>();
final String[] ids = spaceSepIds.split(" ");
for (final String id : ids) {
long longId;
try {
longId = Long.parseLong(id);
if (longId < 0) {
if (DEBUG) {
Log.e(TAG, "getAddresses: invalid id " + longId);
}
continue;
}
} catch (final NumberFormatException ex) {
if (DEBUG) {
Log.e(TAG, "getAddresses: invalid id. " + ex, ex);
}
// skip this id
continue;
}
// TODO: build a single query where we get all the addresses at once.
Cursor c = null;
try {
c = mContentResolver.query(
ContentUris.withAppendedId(SINGLE_CANONICAL_ADDRESS_URI, longId),
null, null, null, null);
} catch (final Exception e) {
if (DEBUG) {
Log.e(TAG, "getAddresses: query failed for id " + longId, e);
}
}
if (c != null) {
try {
if (c.moveToFirst()) {
final String number = c.getString(0);
if (!TextUtils.isEmpty(number)) {
numbers.add(number);
} else {
if (DEBUG) {
Log.w(TAG, "Canonical MMS/SMS address is empty for id: " + longId);
}
}
}
} finally {
c.close();
}
}
}
if (numbers.isEmpty()) {
if (DEBUG) {
Log.w(TAG, "No MMS addresses found from ids string [" + spaceSepIds + "]");
}
}
return numbers;
}
@Override
public void onBackup(ParcelFileDescriptor oldState, BackupDataOutput data,
ParcelFileDescriptor newState) throws IOException {
// Empty because is not used during full backup.
}
@Override
public void onRestore(BackupDataInput data, int appVersionCode,
ParcelFileDescriptor newState) throws IOException {
// Empty because is not used during full restore.
}
}