added typing notifications through XEP-0085. fixed #210

iNPUTmice created

Change summary

src/main/java/eu/siacs/conversations/Config.java                         |  5 
src/main/java/eu/siacs/conversations/crypto/OtrEngine.java               | 14 
src/main/java/eu/siacs/conversations/entities/Conversation.java          | 31 
src/main/java/eu/siacs/conversations/entities/Message.java               |  3 
src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java    |  3 
src/main/java/eu/siacs/conversations/generator/MessageGenerator.java     | 20 
src/main/java/eu/siacs/conversations/parser/MessageParser.java           | 25 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java | 22 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java        | 91 
src/main/java/eu/siacs/conversations/ui/EditMessage.java                 | 59 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java      |  4 
src/main/java/eu/siacs/conversations/xmpp/chatstate/ChatState.java       | 32 
src/main/res/values/strings.xml                                          |  4 
src/main/res/xml/preferences.xml                                         |  7 
14 files changed, 267 insertions(+), 53 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/Config.java 🔗

@@ -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",

src/main/java/eu/siacs/conversations/crypto/OtrEngine.java 🔗

@@ -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);
 	}

src/main/java/eu/siacs/conversations/entities/Conversation.java 🔗

@@ -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();

src/main/java/eu/siacs/conversations/entities/Message.java 🔗

@@ -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;
 	}
 

src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java 🔗

@@ -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"

src/main/java/eu/siacs/conversations/generator/MessageGenerator.java 🔗

@@ -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;
 	}
 

src/main/java/eu/siacs/conversations/parser/MessageParser.java 🔗

@@ -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);

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -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);
 	}

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java 🔗

@@ -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);
+		}
+	}
+
 }

src/main/java/eu/siacs/conversations/ui/EditMessage.java 🔗

@@ -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 void setKeyboardListener(KeyboardListener listener) {
+		this.keyboardListener = listener;
+		if (listener != null) {
+			this.isUserTyping = false;
+		}
 	}
 
-	public interface OnEnterPressed {
-		public boolean onEnterPressed();
+	public interface KeyboardListener {
+		public void onEnterPressed();
+		public void onTypingStarted();
+		public void onTypingStopped();
+		public void onTextDeleted();
 	}
 
 }

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java 🔗

@@ -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) {

src/main/java/eu/siacs/conversations/xmpp/chatstate/ChatState.java 🔗

@@ -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;
+	}
+}

src/main/res/values/strings.xml 🔗

@@ -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>

src/main/res/xml/preferences.xml 🔗

@@ -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