Add support for "delivery notifications." Currently SMS-only.

This commit is contained in:
Moxie Marlinspike 2013-01-06 21:38:36 -08:00
parent 118560cf0d
commit 5cb02445e8
19 changed files with 174 additions and 61 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -96,6 +96,13 @@
android:orientation="horizontal"
android:gravity="left">
<ImageView android:id="@+id/delivered_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingRight="3dip"
android:src="@drawable/ic_sms_mms_delivered"
android:visibility="gone" />
<TextView android:id="@+id/group_message_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -105,8 +112,7 @@
android:textColor="#ffcccccc"
android:visibility="gone"
android:layout_marginRight="8dip"
android:paddingTop="1dip"/>
android:paddingTop="1dip"/>
<TextView android:id="@+id/conversation_item_date"
android:autoLink="all"

View file

@ -118,6 +118,13 @@
android:orientation="horizontal"
android:gravity="right">
<ImageView android:id="@+id/delivered_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingRight="3dip"
android:src="@drawable/ic_sms_mms_delivered"
android:visibility="gone" />
<TextView android:id="@+id/group_message_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View file

@ -1,22 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
/*
* Copyright (C) 2007-2008 Esmertec AG.
* Copyright (C) 2007-2008 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.
*/
-->
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory android:title="@string/preferences__use_settings">
<CheckBoxPreference android:defaultValue="true"
@ -31,6 +14,19 @@
</PreferenceCategory>
<PreferenceCategory android:title="Delivery Reports">
<CheckBoxPreference android:defaultValue="false"
android:key="pref_delivery_report_sms"
android:summary="Request a delivery report for each SMS message you send"
android:title="SMS delivery reports" />
<CheckBoxPreference android:defaultValue="false"
android:key="pref_delivery_report_mms"
android:summary="Request a delivery report for each MMS message you send"
android:enabled="false"
android:title="MMS delivery reports" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/preferences__input_settings">
<CheckBoxPreference android:defaultValue="false"
android:key="pref_enter_sends"

View file

@ -80,6 +80,9 @@ public class ApplicationPreferencesActivity extends SherlockPreferenceActivity {
public static final String MMSC_PROXY_HOST_PREF = "pref_apn_mms_proxy";
public static final String MMSC_PROXY_PORT_PREF = "pref_apn_mms_proxy_port";
public static final String SMS_DELIVERY_REPORT_PREF = "pref_delivery_report_sms";
public static final String MMS_DELIVERY_REPORT_PREF = "pref_delivery_report_mms";
@Override
protected void onCreate(Bundle icicle) {
super.onCreate(icicle);

View file

@ -208,6 +208,7 @@ public class ConversationAdapter extends CursorAdapter {
long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsDatabase.DATE_RECEIVED));
long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsDatabase.DATE_SENT));
long type = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.TYPE));
int status = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.STATUS));
String body = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.BODY));
String address = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.ADDRESS));
Recipient recipient = getIndividualRecipientFor(address);
@ -226,7 +227,7 @@ public class ConversationAdapter extends CursorAdapter {
SmsMessageRecord messageRecord = new SmsMessageRecord(context, messageId, recipients,
recipient, dateSent, dateReceived,
type, threadId, groupData);
type, threadId, status, groupData);
if (body == null) {
body = "";

View file

@ -84,6 +84,7 @@ public class ConversationItem extends LinearLayout {
private ImageView failedImage;
private ImageView keyImage;
private ImageView contactPhoto;
private ImageView deliveredImage;
private ImageView mmsThumbnail;
private Button mmsDownloadButton;
@ -118,6 +119,7 @@ public class ConversationItem extends LinearLayout {
this.mmsDownloadButton = (Button) findViewById(R.id.mms_download_button);
this.mmsDownloadingLabel = (TextView) findViewById(R.id.mms_label_downloading);
this.contactPhoto = (ImageView)findViewById(R.id.contact_photo);
this.deliveredImage = (ImageView)findViewById(R.id.delivered_indicator);
setOnClickListener(clickListener);
this.failedImage.setOnClickListener(failedIconClickListener);
@ -182,6 +184,7 @@ public class ConversationItem extends LinearLayout {
failedImage.setVisibility(messageRecord.isFailed() ? View.VISIBLE : View.GONE);
secureImage.setVisibility(messageRecord.isSecure() ? View.VISIBLE : View.GONE);
keyImage.setVisibility(messageRecord.isKeyExchange() ? View.VISIBLE : View.GONE);
deliveredImage.setVisibility(!messageRecord.isKeyExchange() && messageRecord.isDelivered() ? View.VISIBLE : View.GONE);
mmsThumbnail.setVisibility(View.GONE);
mmsDownloadButton.setVisibility(View.GONE);

View file

@ -93,7 +93,7 @@ public class MmsSmsDatabase extends Database {
String[] projection = {"_id", "body", "type", "address", "subject",
"normalized_date_sent AS date_sent",
"normalized_date_received AS date_received",
"m_type", "msg_box", "transport_type"};
"m_type", "msg_box", "status", "transport_type"};
String order = "normalized_date_received ASC";
String selection = "thread_id = " + threadId;
@ -135,8 +135,8 @@ public class MmsSmsDatabase extends Database {
}
private Cursor queryTables(String[] projection, String selection, String order, String groupBy, String limit) {
String[] mmsProjection = {"date * 1000 AS normalized_date_sent", "date_received * 1000 AS normalized_date_received", "_id", "body", "read", "thread_id", "type", "address", "subject", "date", "m_type", "msg_box", "transport_type"};
String[] smsProjection = {"date_sent * 1 AS normalized_date_sent", "date * 1 AS normalized_date_received", "_id", "body", "read", "thread_id", "type", "address", "subject", "date", "m_type", "msg_box", "transport_type"};
String[] mmsProjection = {"date * 1000 AS normalized_date_sent", "date_received * 1000 AS normalized_date_received", "_id", "body", "read", "thread_id", "type", "address", "subject", "date", "m_type", "msg_box", "status", "transport_type"};
String[] smsProjection = {"date_sent * 1 AS normalized_date_sent", "date * 1 AS normalized_date_received", "_id", "body", "read", "thread_id", "type", "address", "subject", "date", "m_type", "msg_box", "status", "transport_type"};
SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder();
SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder();
@ -166,6 +166,7 @@ public class MmsSmsDatabase extends Database {
smsColumnsPresent.add("date");
smsColumnsPresent.add("read");
smsColumnsPresent.add("thread_id");
smsColumnsPresent.add("status");
String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery("transport_type", mmsProjection, mmsColumnsPresent, 2, "mms", selection, null, null, null);
String smsSubQuery = smsQueryBuilder.buildUnionSubQuery("transport_type", smsProjection, smsColumnsPresent, 2, "sms", selection, null, null, null);

View file

@ -170,6 +170,16 @@ public class SmsDatabase extends Database {
updateType(id, Types.SENT_TYPE);
}
public void markStatus(long id, int status) {
Log.w("MessageDatabase", "Updating ID: " + id + " to status: " + status);
ContentValues contentValues = new ContentValues();
contentValues.put(STATUS, status);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {id+""});
notifyConversationListeners(getThreadIdForMessage(id));
}
public void markAsSentFailed(long id) {
updateType(id, Types.FAILED_TYPE);
}
@ -317,6 +327,13 @@ public class SmsDatabase extends Database {
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
}
public static class Status {
public static final int STATUS_NONE = -1;
public static final int STATUS_COMPLETE = 0;
public static final int STATUS_PENDING = 32;
public static final int STATUS_FAILED = 64;
}
public static class Types {
public static final int INBOX_TYPE = 1;
public static final int SENT_TYPE = 2;

View file

@ -46,7 +46,8 @@ public class MediaMmsMessageRecord extends MessageRecord {
long threadId, SlideDeck slideDeck, long mailbox,
GroupData groupData)
{
super(id, recipients, individualRecipient, dateSent, dateReceived, threadId, groupData);
super(id, recipients, individualRecipient, dateSent, dateReceived,
threadId, DELIVERY_STATUS_NONE, groupData);
this.slideDeck = slideDeck;
this.mailbox = mailbox;

View file

@ -29,18 +29,26 @@ import org.thoughtcrime.securesms.recipients.Recipients;
*/
public abstract class MessageRecord extends DisplayRecord {
public static final int DELIVERY_STATUS_NONE = 0;
public static final int DELIVERY_STATUS_RECEIVED = 1;
public static final int DELIVERY_STATUS_PENDING = 2;
public static final int DELIVERY_STATUS_FAILED = 3;
private final Recipient individualRecipient;
private final long id;
private final int deliveryStatus;
private final GroupData groupData;
public MessageRecord(long id, Recipients recipients,
Recipient individualRecipient,
long dateSent, long dateReceived,
long threadId, GroupData groupData)
long threadId, int deliveryStatus,
GroupData groupData)
{
super(recipients, dateSent, dateReceived, threadId);
this.id = id;
this.individualRecipient = individualRecipient;
this.deliveryStatus = deliveryStatus;
this.groupData = groupData;
}
@ -58,6 +66,14 @@ public abstract class MessageRecord extends DisplayRecord {
return id;
}
public int getDeliveryStatus() {
return deliveryStatus;
}
public boolean isDelivered() {
return getDeliveryStatus() == DELIVERY_STATUS_RECEIVED;
}
public boolean isStaleKeyExchange() {
return this.staleKeyExchange;
}

View file

@ -41,7 +41,9 @@ public class NotificationMmsMessageRecord extends MessageRecord {
byte[] contentLocation, long messageSize, long expiry,
int status, byte[] transactionId)
{
super(id, recipients, individualRecipient, dateSent, dateReceived, threadId, null);
super(id, recipients, individualRecipient, dateSent, dateReceived,
threadId, DELIVERY_STATUS_NONE, null);
this.contentLocation = contentLocation;
this.messageSize = messageSize;
this.expiry = expiry;

View file

@ -36,19 +36,18 @@ public class SmsMessageRecord extends MessageRecord {
private final Context context;
private final long type;
private final long dateSent;
public SmsMessageRecord(Context context, long id,
Recipients recipients,
Recipient individualRecipient,
long dateSent, long dateReceived,
long type, long threadId,
GroupData groupData)
int status, GroupData groupData)
{
super(id, recipients, individualRecipient, dateSent, dateReceived, threadId, groupData);
super(id, recipients, individualRecipient, dateSent, dateReceived,
threadId, getGenericDeliveryStatus(status), groupData);
this.context = context.getApplicationContext();
this.type = type;
this.dateSent = dateSent;
}
public long getType() {
@ -76,7 +75,8 @@ public class SmsMessageRecord extends MessageRecord {
@Override
public boolean isFailed() {
return SmsDatabase.Types.isFailedMessageType(getType());
return SmsDatabase.Types.isFailedMessageType(getType()) ||
getDeliveryStatus() == DELIVERY_STATUS_FAILED;
}
@Override
@ -99,4 +99,15 @@ public class SmsMessageRecord extends MessageRecord {
return false;
}
private static int getGenericDeliveryStatus(int status) {
if (status == SmsDatabase.Status.STATUS_NONE) {
return MessageRecord.DELIVERY_STATUS_NONE;
} else if (status >= SmsDatabase.Status.STATUS_FAILED) {
return MessageRecord.DELIVERY_STATUS_FAILED;
} else if (status >= SmsDatabase.Status.STATUS_PENDING) {
return MessageRecord.DELIVERY_STATUS_PENDING;
} else {
return MessageRecord.DELIVERY_STATUS_RECEIVED;
}
}
}

View file

@ -47,6 +47,7 @@ public class SendReceiveService extends Service {
public static final String SEND_SMS_ACTION = "org.thoughtcrime.securesms.SendReceiveService.SEND_SMS_ACTION";
public static final String SENT_SMS_ACTION = "org.thoughtcrime.securesms.SendReceiveService.SENT_SMS_ACTION";
public static final String DELIVERED_SMS_ACTION = "org.thoughtcrime.securesms.SendReceiveService.DELIVERED_SMS_ACTION";
public static final String RECEIVE_SMS_ACTION = "org.thoughtcrime.securesms.SendReceiveService.RECEIVE_SMS_ACTION";
public static final String SEND_MMS_ACTION = "org.thoughtcrime.securesms.SendReceiveService.SEND_MMS_ACTION";
public static final String SEND_MMS_CONNECTIVITY_ACTION = "org.thoughtcrime.securesms.SendReceiveService.SEND_MMS_CONNECTIVITY_ACTION";
@ -93,6 +94,8 @@ public class SendReceiveService extends Service {
scheduleIntent(RECEIVE_SMS, intent);
else if (intent.getAction().equals(SENT_SMS_ACTION))
scheduleIntent(RECEIVE_SMS, intent);
else if (intent.getAction().equals(DELIVERED_SMS_ACTION))
scheduleIntent(RECEIVE_SMS, intent);
else if (intent.getAction().equals(SEND_MMS_ACTION) || intent.getAction().equals(SEND_MMS_CONNECTIVITY_ACTION))
scheduleSecretRequiredIntent(SEND_MMS, intent);
else if (intent.getAction().equals(RECEIVE_MMS_ACTION))
@ -191,8 +194,8 @@ public class SendReceiveService extends Service {
public void run() {
switch (what) {
case RECEIVE_SMS: smsReceiver.process(masterSecret, intent); return;
case SEND_SMS: smsSender.process(masterSecret, intent); return;
case RECEIVE_SMS: smsReceiver.process(masterSecret, intent); return;
case SEND_SMS: smsSender.process(masterSecret, intent); return;
case RECEIVE_MMS: mmsReceiver.process(masterSecret, intent); return;
case SEND_MMS: mmsSender.process(masterSecret, intent); return;
case DOWNLOAD_MMS: mmsDownloader.process(masterSecret, intent); return;

View file

@ -1,6 +1,6 @@
/**
/**
* Copyright (C) 2011 Whisper Systems
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
@ -10,14 +10,12 @@
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.service;
import org.thoughtcrime.securesms.protocol.WirePrefix;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
@ -26,6 +24,8 @@ import android.preference.PreferenceManager;
import android.telephony.SmsMessage;
import android.util.Log;
import org.thoughtcrime.securesms.protocol.WirePrefix;
public class SmsListener extends BroadcastReceiver {
private static final String SMS_RECEIVED_ACTION = "android.provider.Telephony.SMS_RECEIVED";
@ -40,58 +40,58 @@ public class SmsListener extends BroadcastReceiver {
if (messageBody.startsWith("Sparebank1://otp?")) {
return true;
}
// Sprint Visual Voicemail
return
message.getOriginatingAddress().length() < 7 &&
(messageBody.startsWith("//ANDROID:") || messageBody.startsWith("//Android:") ||
return
message.getOriginatingAddress().length() < 7 &&
(messageBody.startsWith("//ANDROID:") || messageBody.startsWith("//Android:") ||
messageBody.startsWith("//android:") || messageBody.startsWith("//BREW:"));
}
private SmsMessage getSmsMessageFromIntent(Intent intent) {
Bundle bundle = intent.getExtras();
Object[] pdus = (Object[])bundle.get("pdus");
if (pdus == null || pdus.length == 0)
return null;
return SmsMessage.createFromPdu((byte[])pdus[0]);
}
private String getSmsMessageBodyFromIntent(Intent intent) {
Bundle bundle = intent.getExtras();
Object[] pdus = (Object[])bundle.get("pdus");
StringBuilder bodyBuilder = new StringBuilder();
if (pdus == null)
return null;
for (int i=0;i<pdus.length;i++)
bodyBuilder.append(SmsMessage.createFromPdu((byte[])pdus[i]).getDisplayMessageBody());
return bodyBuilder.toString();
}
private boolean isRelevent(Context context, Intent intent) {
SmsMessage message = getSmsMessageFromIntent(intent);
String messageBody = getSmsMessageBodyFromIntent(intent);
if (message == null && messageBody == null)
return false;
if (isExemption(message, messageBody))
return false;
if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean("pref_all_sms", true))
return true;
return true;
return WirePrefix.isEncryptedMessage(messageBody) || WirePrefix.isKeyExchange(messageBody);
}
@Override
public void onReceive(Context context, Intent intent) {
public void onReceive(Context context, Intent intent) {
Log.w("SMSListener", "Got SMS broadcast...");
if (intent.getAction().equals(SMS_RECEIVED_ACTION) && isRelevent(context, intent)) {
intent.setAction(SendReceiveService.RECEIVE_SMS_ACTION);
intent.putExtra("ResultCode", this.getResultCode());
@ -103,6 +103,10 @@ public class SmsListener extends BroadcastReceiver {
intent.putExtra("ResultCode", this.getResultCode());
intent.setClass(context, SendReceiveService.class);
context.startService(intent);
} else if (intent.getAction().equals(SendReceiveService.DELIVERED_SMS_ACTION)) {
intent.putExtra("ResultCode", this.getResultCode());
intent.setClass(context, SendReceiveService.class);
context.startService(intent);
}
}
}

View file

@ -176,10 +176,26 @@ public class SmsReceiver {
DatabaseFactory.getSmsDatabase(context).markAsSentFailed(messageId);
}
private void handleDeliveredMessage(Intent intent) {
long messageId = intent.getLongExtra("message_id", -1);
long type = intent.getLongExtra("type", -1);
byte[] pdu = intent.getByteArrayExtra("pdu");
String format = intent.getStringExtra("format");
SmsMessage message = SmsMessage.createFromPdu(pdu);
if (message == null) {
return;
}
DatabaseFactory.getSmsDatabase(context).markStatus(messageId, message.getStatus());
}
public void process(MasterSecret masterSecret, Intent intent) {
if (intent.getAction().equals(SendReceiveService.RECEIVE_SMS_ACTION))
handleReceiveMessage(masterSecret, intent);
else if (intent.getAction().equals(SendReceiveService.SENT_SMS_ACTION))
handleSentMessage(intent);
else if (intent.getAction().equals(SendReceiveService.DELIVERED_SMS_ACTION))
handleDeliveredMessage(intent);
}
}

View file

@ -21,9 +21,11 @@ import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.preference.PreferenceManager;
import android.telephony.SmsManager;
import android.util.Log;
import org.thoughtcrime.securesms.ApplicationPreferencesActivity;
import org.thoughtcrime.securesms.crypto.MasterCipher;
import org.thoughtcrime.securesms.crypto.MasterSecret;
import org.thoughtcrime.securesms.crypto.SessionCipher;
@ -111,15 +113,36 @@ public class SmsSender {
return sentIntents;
}
private ArrayList<PendingIntent> constructDeliveredIntents(long messageId, long type, ArrayList<String> messages) {
if (!PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(ApplicationPreferencesActivity.SMS_DELIVERY_REPORT_PREF, false))
{
return null;
}
ArrayList<PendingIntent> deliveredIntents = new ArrayList<PendingIntent>(messages.size());
for (int i=0;i<messages.size();i++) {
Intent pending = new Intent(SendReceiveService.DELIVERED_SMS_ACTION, Uri.parse("custom://" + messageId + System.currentTimeMillis()), context, SmsListener.class);
pending.putExtra("type", type);
pending.putExtra("message_id", messageId);
deliveredIntents.add(PendingIntent.getBroadcast(context, 0, pending, 0));
}
return deliveredIntents;
}
private void deliverGSMTransportTextMessage(String recipient, String text, long messageId, long type) {
ArrayList<String> messages = SmsManager.getDefault().divideMessage(text);
ArrayList<PendingIntent> sentIntents = constructSentIntents(messageId, type, messages);
ArrayList<String> messages = SmsManager.getDefault().divideMessage(text);
ArrayList<PendingIntent> sentIntents = constructSentIntents(messageId, type, messages);
ArrayList<PendingIntent> deliveredIntents = constructDeliveredIntents(messageId, type, messages);
// XXX moxie@thoughtcrime.org 1/7/11 -- There's apparently a bug where for some unknown recipients
// and messages, this will throw an NPE. I have no idea why, so I'm just catching it and marking
// the message as a failure. That way at least it doesn't repeatedly crash every time you start
// the app.
try {
SmsManager.getDefault().sendMultipartTextMessage(recipient, null, messages, sentIntents, null);
SmsManager.getDefault().sendMultipartTextMessage(recipient, null, messages, sentIntents, deliveredIntents);
} catch (NullPointerException npe) {
Log.w("SmsSender", npe);
DatabaseFactory.getSmsDatabase(context).markAsSentFailed(messageId);
@ -142,15 +165,18 @@ public class SmsSender {
return;
}
ArrayList<String> messages = multipartMessageHandler.divideMessage(recipient, text, prefix);
ArrayList<PendingIntent> sentIntents = constructSentIntents(messageId, type, messages);
ArrayList<String> messages = multipartMessageHandler.divideMessage(recipient, text, prefix);
ArrayList<PendingIntent> sentIntents = constructSentIntents(messageId, type, messages);
ArrayList<PendingIntent> deliveredIntents = constructDeliveredIntents(messageId, type, messages);
for (int i=0;i<messages.size();i++) {
// XXX moxie@thoughtcrime.org 1/7/11 -- There's apparently a bug where for some unknown recipients
// and messages, this will throw an NPE. I have no idea why, so I'm just catching it and marking
// the message as a failure. That way at least it doesn't repeatedly crash every time you start
// the app.
try {
SmsManager.getDefault().sendTextMessage(recipient, null, messages.get(i), sentIntents.get(i), null);
SmsManager.getDefault().sendTextMessage(recipient, null, messages.get(i), sentIntents.get(i),
deliveredIntents == null ? null : deliveredIntents.get(i));
} catch (NullPointerException npe) {
Log.w("SmsSender", npe);
DatabaseFactory.getSmsDatabase(context).markAsSentFailed(messageId);