added typing notifications through XEP-0085. fixed #210

This commit is contained in:
iNPUTmice 2015-02-21 11:06:52 +01:00
parent 3f248e0d89
commit 7ee5e95959
14 changed files with 267 additions and 53 deletions

View File

@ -2,6 +2,8 @@ package eu.siacs.conversations;
import android.graphics.Bitmap;
import eu.siacs.conversations.xmpp.chatstate.ChatState;
public final class Config {
public static final String LOGTAG = "conversations";
@ -30,6 +32,9 @@ public final class Config {
public static final long MAM_MAX_CATCHUP = MILLISECONDS_IN_DAY / 2;
public static final int MAM_MAX_MESSAGES = 500;
public static final ChatState DEFAULT_CHATSTATE = ChatState.ACTIVE;
public static final int TYPING_TIMEOUT = 8;
public static final String ENABLED_CIPHERS[] = {
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA384",

View File

@ -21,6 +21,7 @@ import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Conversation;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.utils.CryptoHelper;
import eu.siacs.conversations.xmpp.chatstate.ChatState;
import eu.siacs.conversations.xmpp.jid.InvalidJidException;
import eu.siacs.conversations.xmpp.jid.Jid;
import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
@ -182,6 +183,19 @@ public class OtrEngine extends OtrCryptoEngineImpl implements OtrEngineHost {
packet.addChild("private", "urn:xmpp:carbons:2");
packet.addChild("no-copy", "urn:xmpp:hints");
packet.addChild("no-store", "urn:xmpp:hints");
try {
Jid jid = Jid.fromSessionID(session);
Conversation conversation = mXmppConnectionService.find(account,jid);
if (conversation != null && conversation.setOutgoingChatState(Config.DEFAULT_CHATSTATE)) {
if (mXmppConnectionService.sendChatStates()) {
packet.addChild(ChatState.toElement(conversation.getOutgoingChatState()));
}
}
} catch (final InvalidJidException ignored) {
}
packet.setType(MessagePacket.TYPE_CHAT);
account.getXmppConnection().sendMessagePacket(packet);
}

View File

@ -21,6 +21,7 @@ import java.util.Comparator;
import java.util.List;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.xmpp.chatstate.ChatState;
import eu.siacs.conversations.xmpp.jid.InvalidJidException;
import eu.siacs.conversations.xmpp.jid.Jid;
@ -77,6 +78,8 @@ public class Conversation extends AbstractEntity implements Blockable {
private Bookmark bookmark;
private boolean messagesLeftOnServer = true;
private ChatState mOutgoingChatState = Config.DEFAULT_CHATSTATE;
private ChatState mIncomingChatState = Config.DEFAULT_CHATSTATE;
public boolean hasMessagesLeftOnServer() {
return messagesLeftOnServer;
@ -138,6 +141,34 @@ public class Conversation extends AbstractEntity implements Blockable {
}
}
public boolean setIncomingChatState(ChatState state) {
if (this.mIncomingChatState == state) {
return false;
}
this.mIncomingChatState = state;
return true;
}
public ChatState getIncomingChatState() {
return this.mIncomingChatState;
}
public boolean setOutgoingChatState(ChatState state) {
if (mode == MODE_MULTI) {
return false;
}
if (this.mOutgoingChatState != state) {
this.mOutgoingChatState = state;
return true;
} else {
return false;
}
}
public ChatState getOutgoingChatState() {
return this.mOutgoingChatState;
}
public void trim() {
synchronized (this.messages) {
final int size = messages.size();

View File

@ -147,10 +147,11 @@ public class Message extends AbstractEntity {
cursor.getString(cursor.getColumnIndex(SERVER_MSG_ID)));
}
public static Message createStatusMessage(Conversation conversation) {
public static Message createStatusMessage(Conversation conversation, String body) {
Message message = new Message();
message.setType(Message.TYPE_STATUS);
message.setConversation(conversation);
message.setBody(body);
return message;
}

View File

@ -27,7 +27,8 @@ public abstract class AbstractGenerator {
"http://jabber.org/protocol/disco#info",
"urn:xmpp:avatar:metadata+notify",
"urn:xmpp:ping",
"jabber:iq:version"};
"jabber:iq:version",
"http://jabber.org/protocol/chatstates"};
private final String[] MESSAGE_CONFIRMATION_FEATURES = {
"urn:xmpp:chat-markers:0",
"urn:xmpp:receipts"

View File

@ -12,6 +12,7 @@ import eu.siacs.conversations.entities.Conversation;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xmpp.chatstate.ChatState;
import eu.siacs.conversations.xmpp.jid.Jid;
import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
@ -102,21 +103,12 @@ public class MessageGenerator extends AbstractGenerator {
return packet;
}
public MessagePacket generateNotAcceptable(MessagePacket origin) {
MessagePacket packet = generateError(origin);
Element error = packet.addChild("error");
error.setAttribute("type", "modify");
error.setAttribute("code", "406");
error.addChild("not-acceptable");
return packet;
}
private MessagePacket generateError(MessagePacket origin) {
public MessagePacket generateChatState(Conversation conversation) {
final Account account = conversation.getAccount();
MessagePacket packet = new MessagePacket();
packet.setId(origin.getId());
packet.setTo(origin.getFrom());
packet.setBody(origin.getBody());
packet.setType(MessagePacket.TYPE_ERROR);
packet.setTo(conversation.getJid().toBareJid());
packet.setFrom(account.getJid());
packet.addChild(ChatState.toElement(conversation.getOutgoingChatState()));
return packet;
}

View File

@ -1,8 +1,11 @@
package eu.siacs.conversations.parser;
import android.util.Log;
import net.java.otr4j.session.Session;
import net.java.otr4j.session.SessionStatus;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.entities.Conversation;
@ -14,6 +17,7 @@ import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.utils.CryptoHelper;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xmpp.OnMessagePacketReceived;
import eu.siacs.conversations.xmpp.chatstate.ChatState;
import eu.siacs.conversations.xmpp.jid.Jid;
import eu.siacs.conversations.xmpp.pep.Avatar;
import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
@ -24,6 +28,21 @@ public class MessageParser extends AbstractParser implements
super(service);
}
private boolean extractChatState(Conversation conversation, final Element element) {
ChatState state = ChatState.parse(element);
if (state != null && conversation != null) {
final Account account = conversation.getAccount();
Jid from = element.getAttributeAsJid("from");
if (from != null && from.toBareJid().equals(account.getJid().toBareJid())) {
conversation.setOutgoingChatState(state);
return false;
} else {
return conversation.setIncomingChatState(state);
}
}
return false;
}
private Message parseChat(MessagePacket packet, Account account) {
final Jid jid = packet.getFrom();
if (jid == null) {
@ -55,6 +74,7 @@ public class MessageParser extends AbstractParser implements
}
finishedMessage.setCounterpart(jid);
finishedMessage.setTime(getTimestamp(packet));
extractChatState(conversation,packet);
return finishedMessage;
}
@ -123,6 +143,7 @@ public class MessageParser extends AbstractParser implements
finishedMessage.setRemoteMsgId(packet.getId());
finishedMessage.markable = isMarkable(packet);
finishedMessage.setCounterpart(from);
extractChatState(conversation,packet);
return finishedMessage;
} catch (Exception e) {
conversation.resetOtrSession();
@ -275,6 +296,7 @@ public class MessageParser extends AbstractParser implements
finishedMessage = new Message(conversation, body,
Message.ENCRYPTION_NONE, status);
}
extractChatState(conversation,message);
finishedMessage.setTime(getTimestamp(message));
finishedMessage.setRemoteMsgId(message.getAttribute("id"));
finishedMessage.markable = isMarkable(message);
@ -362,6 +384,9 @@ public class MessageParser extends AbstractParser implements
private void parseNonMessage(Element packet, Account account) {
final Jid from = packet.getAttributeAsJid("from");
if (extractChatState(from == null ? null : mXmppConnectionService.find(account,from), packet)) {
mXmppConnectionService.updateConversationUi();
}
Element invite = extractInvite(packet);
if (invite != null) {
Conversation conversation = mXmppConnectionService.findOrCreateConversation(account, from, true);

View File

@ -86,6 +86,7 @@ import eu.siacs.conversations.xmpp.OnPresencePacketReceived;
import eu.siacs.conversations.xmpp.OnStatusChanged;
import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
import eu.siacs.conversations.xmpp.XmppConnection;
import eu.siacs.conversations.xmpp.chatstate.ChatState;
import eu.siacs.conversations.xmpp.forms.Data;
import eu.siacs.conversations.xmpp.forms.Field;
import eu.siacs.conversations.xmpp.jid.InvalidJidException;
@ -603,6 +604,13 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
return connection;
}
public void sendChatState(Conversation conversation) {
if (sendChatStates()) {
MessagePacket packet = mMessageGenerator.generateChatState(conversation);
sendMessagePacket(conversation.getAccount(), packet);
}
}
public void sendMessage(final Message message) {
final Account account = message.getConversation().getAccount();
account.deactivateGracePeriod();
@ -703,6 +711,11 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
}
}
if ((send) && (packet != null)) {
if (conv.setOutgoingChatState(Config.DEFAULT_CHATSTATE)) {
if (this.sendChatStates()) {
packet.addChild(ChatState.toElement(conv.getOutgoingChatState()));
}
}
sendMessagePacket(account, packet);
}
updateConversationUi();
@ -784,6 +797,11 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
} else {
markMessage(message, Message.STATUS_UNSEND);
}
if (message.getConversation().setOutgoingChatState(Config.DEFAULT_CHATSTATE)) {
if (this.sendChatStates()) {
packet.addChild(ChatState.toElement(message.getConversation().getOutgoingChatState()));
}
}
sendMessagePacket(account, packet);
}
}
@ -2046,6 +2064,10 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
return getPreferences().getBoolean("confirm_messages", true);
}
public boolean sendChatStates() {
return getPreferences().getBoolean("chat_states", false);
}
public boolean saveEncryptedMessages() {
return !getPreferences().getBoolean("dont_save_encrypted", false);
}

View File

@ -40,6 +40,7 @@ import java.util.List;
import java.util.NoSuchElementException;
import java.util.concurrent.ConcurrentLinkedQueue;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.crypto.PgpEngine;
import eu.siacs.conversations.entities.Account;
@ -52,15 +53,15 @@ import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.MucOptions;
import eu.siacs.conversations.entities.Presences;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.ui.EditMessage.OnEnterPressed;
import eu.siacs.conversations.ui.XmppActivity.OnPresenceSelected;
import eu.siacs.conversations.ui.XmppActivity.OnValueEdited;
import eu.siacs.conversations.ui.adapter.MessageAdapter;
import eu.siacs.conversations.ui.adapter.MessageAdapter.OnContactPictureClicked;
import eu.siacs.conversations.ui.adapter.MessageAdapter.OnContactPictureLongClicked;
import eu.siacs.conversations.xmpp.chatstate.ChatState;
import eu.siacs.conversations.xmpp.jid.Jid;
public class ConversationFragment extends Fragment {
public class ConversationFragment extends Fragment implements EditMessage.KeyboardListener {
protected Conversation conversation;
private OnClickListener leaveMuc = new OnClickListener() {
@ -327,18 +328,6 @@ public class ConversationFragment extends Fragment {
}
});
mEditMessage.setOnEditorActionListener(mEditorActionListener);
mEditMessage.setOnEnterPressedListener(new OnEnterPressed() {
@Override
public boolean onEnterPressed() {
if (activity.enterIsSend()) {
sendMessage();
return true;
} else {
return false;
}
}
});
mSendButton = (ImageButton) view.findViewById(R.id.textSendButton);
mSendButton.setOnClickListener(this.mSendButtonListener);
@ -558,7 +547,17 @@ public class ConversationFragment extends Fragment {
mDecryptJobRunning = false;
super.onStop();
if (this.conversation != null) {
this.conversation.setNextMessage(mEditMessage.getText().toString());
final String msg = mEditMessage.getText().toString();
this.conversation.setNextMessage(msg);
updateChatState(this.conversation,msg);
}
}
private void updateChatState(final Conversation conversation, final String msg) {
ChatState state = msg.length() == 0 ? Config.DEFAULT_CHATSTATE : ChatState.PAUSED;
Account.State status = conversation.getAccount().getStatus();
if (status == Account.State.ONLINE && conversation.setOutgoingChatState(state)) {
activity.xmppConnectionService.sendChatState(conversation);
}
}
@ -566,11 +565,18 @@ public class ConversationFragment extends Fragment {
if (conversation == null) {
return;
}
this.activity = (ConversationActivity) getActivity();
if (this.conversation != null) {
this.conversation.setNextMessage(mEditMessage.getText().toString());
final String msg = mEditMessage.getText().toString();
this.conversation.setNextMessage(msg);
if (this.conversation != conversation) {
updateChatState(this.conversation,msg);
}
this.conversation.trim();
}
this.activity = (ConversationActivity) getActivity();
this.askForPassphraseIntent = null;
this.conversation = conversation;
this.mDecryptJobRunning = false;
@ -578,8 +584,10 @@ public class ConversationFragment extends Fragment {
if (this.conversation.getMode() == Conversation.MODE_MULTI) {
this.conversation.setNextCounterpart(null);
}
this.mEditMessage.setKeyboardListener(null);
this.mEditMessage.setText("");
this.mEditMessage.append(this.conversation.getNextMessage());
this.mEditMessage.setKeyboardListener(this);
this.messagesView.setAdapter(messageListAdapter);
updateMessages();
this.messagesLoaded = true;
@ -834,13 +842,21 @@ public class ConversationFragment extends Fragment {
protected void updateStatusMessages() {
synchronized (this.messageList) {
if (conversation.getMode() == Conversation.MODE_SINGLE) {
for (int i = this.messageList.size() - 1; i >= 0; --i) {
if (this.messageList.get(i).getStatus() == Message.STATUS_RECEIVED) {
return;
} else {
if (this.messageList.get(i).getStatus() == Message.STATUS_SEND_DISPLAYED) {
this.messageList.add(i + 1,Message.createStatusMessage(conversation));
ChatState state = conversation.getIncomingChatState();
if (state == ChatState.COMPOSING) {
this.messageList.add(Message.createStatusMessage(conversation, getString(R.string.contact_is_typing, conversation.getName())));
} else if (state == ChatState.PAUSED) {
this.messageList.add(Message.createStatusMessage(conversation, getString(R.string.contact_has_stopped_typing, conversation.getName())));
} else {
for (int i = this.messageList.size() - 1; i >= 0; --i) {
if (this.messageList.get(i).getStatus() == Message.STATUS_RECEIVED) {
return;
} else {
if (this.messageList.get(i).getStatus() == Message.STATUS_SEND_DISPLAYED) {
this.messageList.add(i + 1,
Message.createStatusMessage(conversation, getString(R.string.contact_has_read_up_to_this_point, conversation.getName())));
return;
}
}
}
}
@ -995,4 +1011,33 @@ public class ConversationFragment extends Fragment {
this.mEditMessage.append(text);
}
@Override
public void onEnterPressed() {
sendMessage();
}
@Override
public void onTypingStarted() {
Account.State status = conversation.getAccount().getStatus();
if (status == Account.State.ONLINE && conversation.setOutgoingChatState(ChatState.COMPOSING)) {
activity.xmppConnectionService.sendChatState(conversation);
}
}
@Override
public void onTypingStopped() {
Account.State status = conversation.getAccount().getStatus();
if (status == Account.State.ONLINE && conversation.setOutgoingChatState(ChatState.PAUSED)) {
activity.xmppConnectionService.sendChatState(conversation);
}
}
@Override
public void onTextDeleted() {
Account.State status = conversation.getAccount().getStatus();
if (status == Account.State.ONLINE && conversation.setOutgoingChatState(Config.DEFAULT_CHATSTATE)) {
activity.xmppConnectionService.sendChatState(conversation);
}
}
}

View File

@ -1,10 +1,13 @@
package eu.siacs.conversations.ui;
import android.content.Context;
import android.os.Handler;
import android.util.AttributeSet;
import android.view.KeyEvent;
import android.widget.EditText;
import eu.siacs.conversations.Config;
public class EditMessage extends EditText {
public EditMessage(Context context, AttributeSet attrs) {
@ -15,28 +18,62 @@ public class EditMessage extends EditText {
super(context);
}
protected OnEnterPressed mOnEnterPressed;
protected Handler mTypingHandler = new Handler();
protected Runnable mTypingTimeout = new Runnable() {
@Override
public void run() {
if (isUserTyping && keyboardListener != null) {
keyboardListener.onTypingStopped();
isUserTyping = false;
}
}
};
private boolean isUserTyping = false;
protected KeyboardListener keyboardListener;
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_ENTER) {
if (mOnEnterPressed != null) {
if (mOnEnterPressed.onEnterPressed()) {
return true;
} else {
return super.onKeyDown(keyCode, event);
}
if (keyboardListener != null) {
keyboardListener.onEnterPressed();
}
return true;
}
return super.onKeyDown(keyCode, event);
}
public void setOnEnterPressedListener(OnEnterPressed listener) {
this.mOnEnterPressed = listener;
@Override
public void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) {
super.onTextChanged(text,start,lengthBefore,lengthAfter);
if (this.mTypingHandler != null && this.keyboardListener != null) {
this.mTypingHandler.removeCallbacks(mTypingTimeout);
this.mTypingHandler.postDelayed(mTypingTimeout, Config.TYPING_TIMEOUT * 1000);
final int length = text.length();
if (!isUserTyping && length > 0) {
this.isUserTyping = true;
this.keyboardListener.onTypingStarted();
} else if (length == 0) {
this.isUserTyping = false;
this.keyboardListener.onTextDeleted();
}
}
}
public interface OnEnterPressed {
public boolean onEnterPressed();
public void setKeyboardListener(KeyboardListener listener) {
this.keyboardListener = listener;
if (listener != null) {
this.isUserTyping = false;
}
}
public interface KeyboardListener {
public void onEnterPressed();
public void onTypingStarted();
public void onTypingStopped();
public void onTextDeleted();
}
}

View File

@ -410,9 +410,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
.avatarService().get(conversation.getContact(),
activity.getPixel(32)));
viewHolder.contact_picture.setAlpha(0.5f);
viewHolder.status_message.setText(
activity.getString(R.string.contact_has_read_up_to_this_point, conversation.getName()));
viewHolder.status_message.setText(message.getBody());
}
return view;
} else if (type == NULL) {

View File

@ -0,0 +1,32 @@
package eu.siacs.conversations.xmpp.chatstate;
import eu.siacs.conversations.xml.Element;
public enum ChatState {
ACTIVE, INACTIVE, GONE, COMPOSING, PAUSED, mIncomingChatState;
public static ChatState parse(Element element) {
final String NAMESPACE = "http://jabber.org/protocol/chatstates";
if (element.hasChild("active",NAMESPACE)) {
return ACTIVE;
} else if (element.hasChild("inactive",NAMESPACE)) {
return INACTIVE;
} else if (element.hasChild("composing",NAMESPACE)) {
return COMPOSING;
} else if (element.hasChild("gone",NAMESPACE)) {
return GONE;
} else if (element.hasChild("paused",NAMESPACE)) {
return PAUSED;
} else {
return null;
}
}
public static Element toElement(ChatState state) {
final String NAMESPACE = "http://jabber.org/protocol/chatstates";
final Element element = new Element(state.toString().toLowerCase());
element.setAttribute("xmlns",NAMESPACE);
return element;
}
}

View File

@ -445,4 +445,8 @@
<string name="offering_x_file">Offering %s</string>
<string name="hide_offline">Hide offline</string>
<string name="disable_account">Disable Account</string>
<string name="contact_is_typing">%s is typing...</string>
<string name="contact_has_stopped_typing">%s has stopped typing</string>
<string name="pref_chat_states">Typing notifications</string>
<string name="pref_chat_states_summary">Let your contact know when you are writing a new message</string>
</resources>

View File

@ -28,6 +28,13 @@
android:key="confirm_messages"
android:summary="@string/pref_confirm_messages_summary"
android:title="@string/pref_confirm_messages" />
<CheckBoxPreference
android:defaultValue="false"
android:key="chat_states"
android:summary="@string/pref_chat_states_summary"
android:title="@string/pref_chat_states" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/pref_notification_settings" >
<CheckBoxPreference