Files
briar/briar-android/src/org/briarproject/android/contact/ConversationActivity.java
Torsten Grote b03d0a206b Add Message Dependencies to Database
This adds a new table to the database to hold message dependencies.
It introduces two more message states: pending and delivered
The valid column in the database was renamed to state to better reflect
its new extended meaning.

The DatabaseComponent was extended with three methods for:
* adding dependencies
* getting dependencies of a message
* getting messages that depend on a message (dependents)
* getting messages to be delivered (by startup hook)
* getting pending messages to be possibly delivered (by startup hook)

In order to reflect the new states, things that were previously true for
VALID messages have been changed to now be true for DELIVERED messages.

Since pending messages should not be available to clients, many database
queries have been modified to only return results for delivered
messages.

All added methods and changes should come with updated unit tests.

Please note that the database version was bumped in this commit.
2016-05-26 13:49:03 -03:00

751 lines
23 KiB
Java

package org.briarproject.android.contact;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
import android.support.v4.app.ActivityCompat;
import android.support.v4.app.ActivityOptionsCompat;
import android.support.v4.content.ContextCompat;
import android.support.v4.view.ViewCompat;
import android.support.v7.app.ActionBar;
import android.support.v7.app.AlertDialog;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.Toolbar;
import android.util.SparseArray;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import org.briarproject.R;
import org.briarproject.android.ActivityComponent;
import org.briarproject.android.BriarActivity;
import org.briarproject.android.api.AndroidNotificationManager;
import org.briarproject.android.introduction.IntroductionActivity;
import org.briarproject.android.util.BriarRecyclerView;
import org.briarproject.api.FormatException;
import org.briarproject.api.clients.SessionId;
import org.briarproject.api.contact.Contact;
import org.briarproject.api.contact.ContactId;
import org.briarproject.api.contact.ContactManager;
import org.briarproject.api.crypto.CryptoExecutor;
import org.briarproject.api.db.DbException;
import org.briarproject.api.db.NoSuchContactException;
import org.briarproject.api.db.NoSuchMessageException;
import org.briarproject.api.event.ContactConnectedEvent;
import org.briarproject.api.event.ContactDisconnectedEvent;
import org.briarproject.api.event.ContactRemovedEvent;
import org.briarproject.api.event.Event;
import org.briarproject.api.event.EventBus;
import org.briarproject.api.event.EventListener;
import org.briarproject.api.event.ForumInvitationReceivedEvent;
import org.briarproject.api.event.IntroductionRequestReceivedEvent;
import org.briarproject.api.event.IntroductionResponseReceivedEvent;
import org.briarproject.api.event.MessageStateChangedEvent;
import org.briarproject.api.event.MessagesAckedEvent;
import org.briarproject.api.event.MessagesSentEvent;
import org.briarproject.api.forum.ForumInvitationMessage;
import org.briarproject.api.forum.ForumSharingManager;
import org.briarproject.api.introduction.IntroductionManager;
import org.briarproject.api.introduction.IntroductionMessage;
import org.briarproject.api.introduction.IntroductionRequest;
import org.briarproject.api.introduction.IntroductionResponse;
import org.briarproject.api.messaging.MessagingManager;
import org.briarproject.api.messaging.PrivateMessage;
import org.briarproject.api.messaging.PrivateMessageFactory;
import org.briarproject.api.messaging.PrivateMessageHeader;
import org.briarproject.api.plugins.ConnectionRegistry;
import org.briarproject.api.sync.GroupId;
import org.briarproject.api.sync.Message;
import org.briarproject.api.sync.MessageId;
import org.briarproject.util.StringUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.logging.Logger;
import javax.inject.Inject;
import de.hdodenhof.circleimageview.CircleImageView;
import im.delight.android.identicons.IdenticonDrawable;
import static android.widget.Toast.LENGTH_SHORT;
import static java.util.logging.Level.INFO;
import static java.util.logging.Level.WARNING;
import static org.briarproject.android.contact.ConversationItem.IncomingItem;
import static org.briarproject.android.contact.ConversationItem.OutgoingItem;
import static org.briarproject.api.sync.ValidationManager.State.DELIVERED;
public class ConversationActivity extends BriarActivity
implements EventListener, OnClickListener,
ConversationAdapter.IntroductionHandler {
private static final Logger LOG =
Logger.getLogger(ConversationActivity.class.getName());
@Inject
protected AndroidNotificationManager notificationManager;
@Inject
protected ConnectionRegistry connectionRegistry;
@Inject
@CryptoExecutor
protected Executor cryptoExecutor;
private Map<MessageId, byte[]> bodyCache = new HashMap<>();
private ConversationAdapter adapter;
private CircleImageView toolbarAvatar;
private ImageView toolbarStatus;
private TextView toolbarTitle;
private BriarRecyclerView list;
private EditText content;
private ImageButton sendButton;
// Fields that are accessed from background threads must be volatile
@Inject
protected volatile ContactManager contactManager;
@Inject
protected volatile MessagingManager messagingManager;
@Inject
protected volatile EventBus eventBus;
@Inject
protected volatile PrivateMessageFactory privateMessageFactory;
@Inject
protected volatile IntroductionManager introductionManager;
@Inject
protected volatile ForumSharingManager forumSharingManager;
private volatile GroupId groupId = null;
private volatile ContactId contactId = null;
private volatile String contactName = null;
private volatile byte[] contactIdenticonKey = null;
private volatile boolean connected = false;
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
Intent i = getIntent();
byte[] b = i.getByteArrayExtra("briar.GROUP_ID");
if (b == null) throw new IllegalStateException();
groupId = new GroupId(b);
setContentView(R.layout.activity_conversation);
// Custom Toolbar
Toolbar tb = (Toolbar) findViewById(R.id.toolbar);
toolbarAvatar = (CircleImageView) tb.findViewById(R.id.contactAvatar);
toolbarStatus = (ImageView) tb.findViewById(R.id.contactStatus);
toolbarTitle = (TextView) tb.findViewById(R.id.contactName);
setSupportActionBar(tb);
ActionBar ab = getSupportActionBar();
if (ab != null) {
ab.setDisplayShowHomeEnabled(true);
ab.setDisplayHomeAsUpEnabled(true);
ab.setDisplayShowCustomEnabled(true);
ab.setDisplayShowTitleEnabled(false);
}
String hexGroupId = StringUtils.toHexString(b);
ViewCompat.setTransitionName(toolbarAvatar, "avatar" + hexGroupId);
ViewCompat.setTransitionName(toolbarStatus, "bulb" + hexGroupId);
adapter = new ConversationAdapter(this, this);
list = (BriarRecyclerView) findViewById(R.id.conversationView);
list.setLayoutManager(new LinearLayoutManager(this));
list.setAdapter(adapter);
list.setEmptyText(getString(R.string.no_private_messages));
content = (EditText) findViewById(R.id.contentView);
sendButton = (ImageButton) findViewById(R.id.sendButton);
sendButton.setEnabled(false); // Enabled after loading the conversation
sendButton.setOnClickListener(this);
}
@Override
public void injectActivity(ActivityComponent component) {
component.inject(this);
}
@Override
public void onResume() {
super.onResume();
eventBus.addListener(this);
notificationManager.blockNotification(groupId);
notificationManager.clearPrivateMessageNotification(groupId);
loadData();
}
@Override
public void onPause() {
super.onPause();
eventBus.removeListener(this);
notificationManager.unblockNotification(groupId);
if (isFinishing()) markMessagesRead();
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
// Inflate the menu items for use in the action bar
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.conversation_actions, menu);
hideIntroductionActionWhenOneContact(
menu.findItem(R.id.action_introduction));
return super.onCreateOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
// Handle presses on the action bar items
switch (item.getItemId()) {
case android.R.id.home:
onBackPressed();
return true;
case R.id.action_introduction:
if (contactId == null) return false;
Intent intent = new Intent(this, IntroductionActivity.class);
intent.putExtra(IntroductionActivity.CONTACT_ID,
contactId.getInt());
ActivityOptionsCompat options = ActivityOptionsCompat
.makeCustomAnimation(this, android.R.anim.slide_in_left,
android.R.anim.slide_out_right);
ActivityCompat.startActivity(this, intent, options.toBundle());
return true;
case R.id.action_social_remove_person:
askToRemoveContact();
return true;
default:
return super.onOptionsItemSelected(item);
}
}
@Override
public void onBackPressed() {
// FIXME disabled exit transition, because it doesn't work for some reason
//supportFinishAfterTransition();
finish();
}
private void loadData() {
runOnDbThread(new Runnable() {
@Override
public void run() {
try {
long now = System.currentTimeMillis();
if (contactId == null)
contactId = messagingManager.getContactId(groupId);
if (contactName == null || contactIdenticonKey == null) {
Contact contact = contactManager.getContact(contactId);
contactName = contact.getAuthor().getName();
contactIdenticonKey =
contact.getAuthor().getId().getBytes();
}
connected = connectionRegistry.isConnected(contactId);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Loading contact took " + duration + " ms");
displayContactDetails();
// Load the messages here to make sure we have a contactId
loadMessages();
} catch (NoSuchContactException e) {
finishOnUiThread();
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
private void displayContactDetails() {
runOnUiThread(new Runnable() {
@Override
public void run() {
toolbarAvatar.setImageDrawable(
new IdenticonDrawable(contactIdenticonKey));
toolbarTitle.setText(contactName);
if (connected) {
toolbarStatus.setImageDrawable(ContextCompat
.getDrawable(ConversationActivity.this,
R.drawable.contact_online));
toolbarStatus
.setContentDescription(getString(R.string.online));
} else {
toolbarStatus.setImageDrawable(ContextCompat
.getDrawable(ConversationActivity.this,
R.drawable.contact_offline));
toolbarStatus
.setContentDescription(getString(R.string.offline));
}
adapter.setContactName(contactName);
}
});
}
private void loadMessages() {
runOnDbThread(new Runnable() {
@Override
public void run() {
try {
long now = System.currentTimeMillis();
if (contactId == null)
contactId = messagingManager.getContactId(groupId);
Collection<PrivateMessageHeader> headers =
messagingManager.getMessageHeaders(contactId);
Collection<IntroductionMessage> introductions =
introductionManager
.getIntroductionMessages(contactId);
Collection<ForumInvitationMessage> invitations =
forumSharingManager
.getForumInvitationMessages(contactId);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Loading headers took " + duration + " ms");
displayMessages(headers, introductions, invitations);
} catch (NoSuchContactException e) {
finishOnUiThread();
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
private void displayMessages(final Collection<PrivateMessageHeader> headers,
final Collection<IntroductionMessage> introductions,
final Collection<ForumInvitationMessage> invitations) {
runOnUiThread(new Runnable() {
@Override
public void run() {
sendButton.setEnabled(true);
if (headers.isEmpty() && introductions.isEmpty() &&
invitations.isEmpty()) {
// we have no messages,
// so let the list know to hide progress bar
list.showData();
} else {
List<ConversationItem> items = new ArrayList<>();
for (PrivateMessageHeader h : headers) {
ConversationMessageItem item =
(ConversationMessageItem) ConversationItem
.from(h);
byte[] body = bodyCache.get(h.getId());
if (body == null) loadMessageBody(h);
else item.setBody(body);
items.add(item);
}
for (IntroductionMessage m : introductions) {
ConversationItem item;
if (m instanceof IntroductionRequest) {
item = ConversationItem
.from((IntroductionRequest) m);
} else {
item = ConversationItem
.from(ConversationActivity.this,
contactName,
(IntroductionResponse) m);
}
items.add(item);
}
for (ForumInvitationMessage i : invitations) {
ConversationItem item = ConversationItem.from(i);
items.add(item);
}
adapter.addAll(items);
// Scroll to the bottom
list.scrollToPosition(adapter.getItemCount() - 1);
}
}
});
}
private void loadMessageBody(final PrivateMessageHeader h) {
runOnDbThread(new Runnable() {
@Override
public void run() {
try {
long now = System.currentTimeMillis();
byte[] body = messagingManager.getMessageBody(h.getId());
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Loading message took " + duration + " ms");
displayMessageBody(h.getId(), body);
} catch (NoSuchMessageException e) {
// The item will be removed when we get the event
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
private void displayMessageBody(final MessageId m, final byte[] body) {
runOnUiThread(new Runnable() {
@Override
public void run() {
bodyCache.put(m, body);
SparseArray<ConversationMessageItem> messages =
adapter.getPrivateMessages();
for (int i = 0; i < messages.size(); i++) {
ConversationMessageItem item = messages.valueAt(i);
if (item.getId().equals(m)) {
item.setBody(body);
adapter.notifyItemChanged(messages.keyAt(i));
list.scrollToPosition(adapter.getItemCount() - 1);
return;
}
}
}
});
}
private void addIntroduction(final ConversationItem item) {
runOnUiThread(new Runnable() {
@Override
public void run() {
adapter.add(item);
// Scroll to the bottom
list.scrollToPosition(adapter.getItemCount() - 1);
}
});
}
private void markMessagesRead() {
List<MessageId> unread = new ArrayList<>();
SparseArray<IncomingItem> list = adapter.getIncomingMessages();
for (int i = 0; i < list.size(); i++) {
IncomingItem item = list.valueAt(i);
if (!item.isRead()) unread.add(item.getId());
}
if (unread.isEmpty()) return;
if (LOG.isLoggable(INFO))
LOG.info("Marking " + unread.size() + " messages read");
markMessagesRead(Collections.unmodifiableList(unread));
}
private void markMessagesRead(final Collection<MessageId> unread) {
runOnDbThread(new Runnable() {
@Override
public void run() {
try {
long now = System.currentTimeMillis();
for (MessageId m : unread)
// not really clean, but the messaging manager can
// handle introduction messages as well
messagingManager.setReadFlag(m, true);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Marking read took " + duration + " ms");
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
@Override
public void eventOccurred(Event e) {
if (e instanceof ContactRemovedEvent) {
ContactRemovedEvent c = (ContactRemovedEvent) e;
if (c.getContactId().equals(contactId)) {
LOG.info("Contact removed");
finishOnUiThread();
}
} else if (e instanceof MessageStateChangedEvent) {
MessageStateChangedEvent m = (MessageStateChangedEvent) e;
if (m.getState() == DELIVERED &&
m.getMessage().getGroupId().equals(groupId)) {
LOG.info("Message added, reloading");
// Mark new incoming messages as read directly
if (m.isLocal()) loadMessages();
else markMessageReadIfNew(m.getMessage());
}
} else if (e instanceof MessagesSentEvent) {
MessagesSentEvent m = (MessagesSentEvent) e;
if (m.getContactId().equals(contactId)) {
LOG.info("Messages sent");
markMessages(m.getMessageIds(), true, false);
}
} else if (e instanceof MessagesAckedEvent) {
MessagesAckedEvent m = (MessagesAckedEvent) e;
if (m.getContactId().equals(contactId)) {
LOG.info("Messages acked");
markMessages(m.getMessageIds(), true, true);
}
} else if (e instanceof ContactConnectedEvent) {
ContactConnectedEvent c = (ContactConnectedEvent) e;
if (c.getContactId().equals(contactId)) {
LOG.info("Contact connected");
connected = true;
displayContactDetails();
}
} else if (e instanceof ContactDisconnectedEvent) {
ContactDisconnectedEvent c = (ContactDisconnectedEvent) e;
if (c.getContactId().equals(contactId)) {
LOG.info("Contact disconnected");
connected = false;
displayContactDetails();
}
} else if (e instanceof IntroductionRequestReceivedEvent) {
IntroductionRequestReceivedEvent event =
(IntroductionRequestReceivedEvent) e;
if (event.getContactId().equals(contactId)) {
IntroductionRequest ir = event.getIntroductionRequest();
ConversationItem item = new ConversationIntroductionInItem(ir);
addIntroduction(item);
}
} else if (e instanceof IntroductionResponseReceivedEvent) {
IntroductionResponseReceivedEvent event =
(IntroductionResponseReceivedEvent) e;
if (event.getContactId().equals(contactId)) {
IntroductionResponse ir = event.getIntroductionResponse();
ConversationItem item =
ConversationItem.from(this, contactName, ir);
addIntroduction(item);
}
} else if (e instanceof ForumInvitationReceivedEvent) {
ForumInvitationReceivedEvent event =
(ForumInvitationReceivedEvent) e;
if (event.getContactId().equals(contactId)) {
loadMessages();
}
}
}
private void markMessageReadIfNew(final Message m) {
runOnUiThread(new Runnable() {
@Override
public void run() {
ConversationItem item = adapter.getLastItem();
if (item != null) {
// Mark the message read if it's the newest message
long lastMsgTime = item.getTime();
long newMsgTime = m.getTimestamp();
if (newMsgTime > lastMsgTime) markNewMessageRead(m);
else loadMessages();
} else {
// mark the message as read as well if it is the first one
markNewMessageRead(m);
}
}
});
}
private void markNewMessageRead(final Message m) {
runOnDbThread(new Runnable() {
@Override
public void run() {
try {
messagingManager.setReadFlag(m.getId(), true);
loadMessages();
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
private void markMessages(final Collection<MessageId> messageIds,
final boolean sent, final boolean seen) {
runOnUiThread(new Runnable() {
@Override
public void run() {
Set<MessageId> messages = new HashSet<>(messageIds);
SparseArray<OutgoingItem> list = adapter.getOutgoingMessages();
for (int i = 0; i < list.size(); i++) {
OutgoingItem item = list.valueAt(i);
if (messages.contains(item.getId())) {
item.setSent(sent);
item.setSeen(seen);
adapter.notifyItemChanged(list.keyAt(i));
}
}
}
});
}
@Override
public void onClick(View view) {
markMessagesRead();
String message = content.getText().toString();
if (message.equals("")) return;
long timestamp = System.currentTimeMillis();
timestamp = Math.max(timestamp, getMinTimestampForNewMessage());
createMessage(StringUtils.toUtf8(message), timestamp);
content.setText("");
}
private long getMinTimestampForNewMessage() {
// Don't use an earlier timestamp than the newest message
ConversationItem item = adapter.getLastItem();
return item == null ? 0 : item.getTime() + 1;
}
private void createMessage(final byte[] body, final long timestamp) {
cryptoExecutor.execute(new Runnable() {
@Override
public void run() {
try {
storeMessage(privateMessageFactory.createPrivateMessage(
groupId, timestamp, null, "text/plain", body));
} catch (FormatException e) {
throw new RuntimeException(e);
}
}
});
}
private void storeMessage(final PrivateMessage m) {
runOnDbThread(new Runnable() {
@Override
public void run() {
try {
long now = System.currentTimeMillis();
messagingManager.addLocalMessage(m);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Storing message took " + duration + " ms");
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
private void askToRemoveContact() {
DialogInterface.OnClickListener okListener =
new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
removeContact();
}
};
AlertDialog.Builder builder =
new AlertDialog.Builder(ConversationActivity.this,
R.style.BriarDialogTheme);
builder.setTitle(getString(R.string.dialog_title_delete_contact));
builder.setMessage(getString(R.string.dialog_message_delete_contact));
builder.setPositiveButton(android.R.string.ok, okListener);
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
}
private void removeContact() {
runOnDbThread(new Runnable() {
@Override
public void run() {
try {
// make sure contactId is initialised
if (contactId == null)
contactId = messagingManager.getContactId(groupId);
// remove contact with that ID
contactManager.removeContact(contactId);
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
} finally {
finishAfterContactRemoved();
}
}
});
}
private void finishAfterContactRemoved() {
runOnUiThread(new Runnable() {
@Override
public void run() {
String deleted = getString(R.string.contact_deleted_toast);
Toast.makeText(ConversationActivity.this, deleted, LENGTH_SHORT)
.show();
finish();
}
});
}
private void hideIntroductionActionWhenOneContact(final MenuItem item) {
runOnDbThread(new Runnable() {
@Override
public void run() {
try {
if (contactManager.getActiveContacts().size() < 2) {
hideIntroductionAction(item);
}
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
private void hideIntroductionAction(final MenuItem item) {
runOnUiThread(new Runnable() {
@Override
public void run() {
item.setVisible(false);
}
});
}
@Override
public void respondToIntroduction(final SessionId sessionId,
final boolean accept) {
runOnDbThread(new Runnable() {
@Override
public void run() {
long timestamp = System.currentTimeMillis();
timestamp = Math.max(timestamp, getMinTimestampForNewMessage());
try {
if (accept) {
introductionManager
.acceptIntroduction(contactId, sessionId,
timestamp);
} else {
introductionManager
.declineIntroduction(contactId, sessionId,
timestamp);
}
loadMessages();
} catch (DbException | FormatException e) {
introductionResponseError();
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
private void introductionResponseError() {
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(ConversationActivity.this,
R.string.introduction_response_error,
Toast.LENGTH_SHORT).show();
}
});
}
}