send and show read markers in private, non-anonymous groups

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/Config.java                         |   2 
src/main/java/eu/siacs/conversations/entities/Conversation.java          |  11 
src/main/java/eu/siacs/conversations/entities/Message.java               |  49 
src/main/java/eu/siacs/conversations/entities/MucOptions.java            |  34 
src/main/java/eu/siacs/conversations/entities/ReadByMarker.java          | 166 
src/main/java/eu/siacs/conversations/generator/MessageGenerator.java     |  11 
src/main/java/eu/siacs/conversations/parser/MessageParser.java           |  21 
src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java    |   7 
src/main/java/eu/siacs/conversations/services/AvatarService.java         |  84 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java |   6 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java        |  57 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java      |  14 
src/main/java/eu/siacs/conversations/utils/UIHelper.java                 |  71 
src/main/res/values/strings.xml                                          |   1 
14 files changed, 481 insertions(+), 53 deletions(-)

Detailed changes

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

@@ -82,7 +82,7 @@ public final class Config {
 	public static final long OMEMO_AUTO_EXPIRY = 7 * MILLISECONDS_IN_DAY;
 	public static final boolean REMOVE_BROKEN_DEVICES = false;
 	public static final boolean OMEMO_PADDING = false;
-	public static boolean PUT_AUTH_TAG_INTO_KEY = true;
+	public static final boolean PUT_AUTH_TAG_INTO_KEY = true;
 
 
 	public static final boolean DISABLE_PROXY_LOOKUP = false; //useful to debug ibb

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

@@ -289,6 +289,17 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
 		return null;
 	}
 
+	public Message findMessageWithRemoteId(String id) {
+		synchronized (this.messages) {
+			for(Message message : this.messages) {
+				if (id.equals(message.getRemoteMsgId()) || id.equals(message.getUuid())) {
+					return message;
+				}
+			}
+		}
+		return null;
+	}
+
 	public boolean hasMessageWithCounterpart(Jid counterpart) {
 		synchronized (this.messages) {
 			for(Message message : this.messages) {

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

@@ -3,9 +3,17 @@ package eu.siacs.conversations.entities;
 import android.content.ContentValues;
 import android.database.Cursor;
 import android.text.SpannableStringBuilder;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONException;
 
 import java.net.MalformedURLException;
 import java.net.URL;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
@@ -62,6 +70,7 @@ public class Message extends AbstractEntity {
 	public static final String FINGERPRINT = "axolotl_fingerprint";
 	public static final String READ = "read";
 	public static final String ERROR_MESSAGE = "errorMsg";
+	public static final String READ_BY_MARKERS = "readByMarkers";
 	public static final String ME_COMMAND = "/me ";
 
 
@@ -88,11 +97,13 @@ public class Message extends AbstractEntity {
 	private Message mPreviousMessage = null;
 	private String axolotlFingerprint = null;
 	private String errorMessage = null;
+	protected Set<ReadByMarker> readByMarkers = new HashSet<>();
 
 	private Boolean isGeoUri = null;
 	private Boolean isEmojisOnly = null;
 	private Boolean treatAsDownloadable = null;
 	private FileParams fileParams = null;
+	private List<MucOptions.User> counterparts;
 
 	private Message(Conversation conversation) {
 		this.conversation = conversation;
@@ -120,6 +131,7 @@ public class Message extends AbstractEntity {
 				true,
 				null,
 				false,
+				null,
 				null);
 	}
 
@@ -128,7 +140,7 @@ public class Message extends AbstractEntity {
 					final int encryption, final int status, final int type, final boolean carbon,
 					final String remoteMsgId, final String relativeFilePath,
 					final String serverMsgId, final String fingerprint, final boolean read,
-					final String edited, final boolean oob, final String errorMessage) {
+					final String edited, final boolean oob, final String errorMessage, final Set<ReadByMarker> readByMarkers) {
 		this.conversation = conversation;
 		this.uuid = uuid;
 		this.conversationUuid = conversationUUid;
@@ -148,6 +160,7 @@ public class Message extends AbstractEntity {
 		this.edited = edited;
 		this.oob = oob;
 		this.errorMessage = errorMessage;
+		this.readByMarkers = new HashSet<>();
 	}
 
 	public static Message fromCursor(Cursor cursor, Conversation conversation) {
@@ -193,7 +206,8 @@ public class Message extends AbstractEntity {
 				cursor.getInt(cursor.getColumnIndex(READ)) > 0,
 				cursor.getString(cursor.getColumnIndex(EDITED)),
 				cursor.getInt(cursor.getColumnIndex(OOB)) > 0,
-				cursor.getString(cursor.getColumnIndex(ERROR_MESSAGE)));
+				cursor.getString(cursor.getColumnIndex(ERROR_MESSAGE)),
+				ReadByMarker.fromJsonString(cursor.getString(cursor.getColumnIndex(READ_BY_MARKERS))));
 	}
 
 	public static Message createStatusMessage(Conversation conversation, String body) {
@@ -248,6 +262,7 @@ public class Message extends AbstractEntity {
 		values.put(EDITED, edited);
 		values.put(OOB, oob ? 1 : 0);
 		values.put(ERROR_MESSAGE,errorMessage);
+		values.put(READ_BY_MARKERS,ReadByMarker.toJson(readByMarkers).toString());
 		return values;
 	}
 
@@ -415,6 +430,25 @@ public class Message extends AbstractEntity {
 		this.transferable = transferable;
 	}
 
+	public boolean addReadByMarker(ReadByMarker readByMarker) {
+		if (readByMarker.getRealJid() != null) {
+			if (readByMarker.getRealJid().toBareJid().equals(trueCounterpart)) {
+				Log.d(Config.LOGTAG,"trying to add read marker by "+readByMarker.getRealJid()+" to "+body);
+				return false;
+			}
+		} else if (readByMarker.getFullJid() != null) {
+			if (readByMarker.getFullJid().equals(counterpart)) {
+				Log.d(Config.LOGTAG,"trying to add read marker by "+readByMarker.getFullJid()+" to "+body);
+				return false;
+			}
+		}
+		return this.readByMarkers.add(readByMarker);
+	}
+
+	public Set<ReadByMarker> getReadByMarkers() {
+		return Collections.unmodifiableSet(this.readByMarkers);
+	}
+
 	public boolean similar(Message message) {
 		if (type != TYPE_PRIVATE && this.serverMsgId != null && message.getServerMsgId() != null) {
 			return this.serverMsgId.equals(message.getServerMsgId());
@@ -515,7 +549,8 @@ public class Message extends AbstractEntity {
 						!this.bodyIsOnlyEmojis() &&
 						!message.bodyIsOnlyEmojis() &&
 						((this.axolotlFingerprint == null && message.axolotlFingerprint == null) || this.axolotlFingerprint.equals(message.getFingerprint())) &&
-						UIHelper.sameDay(message.getTimeSent(),this.getTimeSent())
+						UIHelper.sameDay(message.getTimeSent(),this.getTimeSent()) &&
+						this.getReadByMarkers().equals(message.getReadByMarkers())
 				);
 	}
 
@@ -529,6 +564,14 @@ public class Message extends AbstractEntity {
 		);
 	}
 
+	public void setCounterparts(List<MucOptions.User> counterparts) {
+		this.counterparts = counterparts;
+	}
+
+	public List<MucOptions.User> getCounterparts() {
+		return this.counterparts;
+	}
+
 	public static class MergeSeparator {}
 
 	public SpannableStringBuilder getMergedBody() {

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

@@ -11,6 +11,7 @@ import java.util.Set;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.utils.JidHelper;
+import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.chatstate.ChatState;
 import eu.siacs.conversations.xmpp.forms.Data;
@@ -280,6 +281,10 @@ public class MucOptions {
 			return options.getAccount();
 		}
 
+		public Conversation getConversation() {
+			return options.getConversation();
+		}
+
 		public Jid getFullJid() {
 			return fullJid;
 		}
@@ -521,6 +526,21 @@ public class MucOptions {
 		return null;
 	}
 
+	public User findUser(ReadByMarker readByMarker) {
+		if (readByMarker.getRealJid() != null) {
+			User user = findUserByRealJid(readByMarker.getRealJid().toBareJid());
+			if (user == null) {
+				user = new User(this,readByMarker.getFullJid());
+				user.setRealJid(readByMarker.getRealJid());
+			}
+			return user;
+		} else if (readByMarker.getFullJid() != null) {
+			return findUserByFullJid(readByMarker.getFullJid());
+		} else {
+			return null;
+		}
+	}
+
 	public boolean isContactInRoom(Contact contact) {
 		return findUserByRealJid(contact.getJid().toBareJid()) != null;
 	}
@@ -655,17 +675,9 @@ public class MucOptions {
 				if (builder.length() != 0) {
 					builder.append(", ");
 				}
-				Contact contact = user.getContact();
-				if (contact != null && !contact.getDisplayName().isEmpty()) {
-					builder.append(contact.getDisplayName().split("\\s+")[0]);
-				} else {
-					final String name = user.getName();
-					final Jid jid = user.getRealJid();
-					if (name != null){
-						builder.append(name.split("\\s+")[0]);
-					} else if (jid != null) {
-						builder.append(jid.getLocalpart());
-					}
+				String name = UIHelper.getDisplayName(user);
+				if (name != null) {
+					builder.append(name.split("\\s+")[0]);
 				}
 			}
 			return builder.toString();

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

@@ -0,0 +1,166 @@
+package eu.siacs.conversations.entities;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+
+import eu.siacs.conversations.xmpp.jid.InvalidJidException;
+import eu.siacs.conversations.xmpp.jid.Jid;
+
+public class ReadByMarker {
+
+	private ReadByMarker() {
+
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		if (this == o) return true;
+		if (o == null || getClass() != o.getClass()) return false;
+
+		ReadByMarker marker = (ReadByMarker) o;
+
+		if (fullJid != null ? !fullJid.equals(marker.fullJid) : marker.fullJid != null)
+			return false;
+		return realJid != null ? realJid.equals(marker.realJid) : marker.realJid == null;
+
+	}
+
+	@Override
+	public int hashCode() {
+		int result = fullJid != null ? fullJid.hashCode() : 0;
+		result = 31 * result + (realJid != null ? realJid.hashCode() : 0);
+		return result;
+	}
+
+	private Jid fullJid;
+	private Jid realJid;
+
+	public Jid getFullJid() {
+		return fullJid;
+	}
+
+	public Jid getRealJid() {
+		return realJid;
+	}
+
+	public JSONObject toJson() {
+		JSONObject jsonObject = new JSONObject();
+		if (fullJid != null) {
+			try {
+				jsonObject.put("fullJid", fullJid.toPreppedString());
+			} catch (JSONException e) {
+				//ignore
+			}
+		}
+		if (realJid != null) {
+			try {
+				jsonObject.put("realJid", realJid.toPreppedString());
+			} catch (JSONException e) {
+				//ignore
+			}
+		}
+		return jsonObject;
+	}
+
+	public static Set<ReadByMarker> fromJson(JSONArray jsonArray) {
+		HashSet<ReadByMarker> readByMarkers = new HashSet<>();
+		for(int i = 0; i < jsonArray.length(); ++i) {
+			try {
+				readByMarkers.add(fromJson(jsonArray.getJSONObject(i)));
+			} catch (JSONException e) {
+				//ignored
+			}
+		}
+		return readByMarkers;
+	}
+
+	public static ReadByMarker from(Jid fullJid, Jid realJid) {
+		final ReadByMarker marker = new ReadByMarker();
+		marker.fullJid = fullJid;
+		marker.realJid = realJid;
+		return marker;
+	}
+
+	public static ReadByMarker from(Message message) {
+		final ReadByMarker marker = new ReadByMarker();
+		marker.fullJid = message.getCounterpart();
+		marker.realJid = message.getTrueCounterpart();
+		return marker;
+	}
+
+	public static ReadByMarker from(MucOptions.User user) {
+		final ReadByMarker marker = new ReadByMarker();
+		marker.fullJid = user.getFullJid();
+		marker.realJid = user.getRealJid();
+		return marker;
+	}
+
+	public static Set<ReadByMarker> from(Collection<MucOptions.User> users) {
+		final HashSet<ReadByMarker> markers = new HashSet<>();
+		for(MucOptions.User user : users) {
+			markers.add(from(user));
+		}
+		return markers;
+	}
+
+	public static ReadByMarker fromJson(JSONObject jsonObject) {
+		ReadByMarker marker = new ReadByMarker();
+		try {
+			marker.fullJid = Jid.fromString(jsonObject.getString("fullJid"),true);
+		} catch (JSONException | InvalidJidException e) {
+			marker.fullJid = null;
+		}
+		try {
+			marker.realJid = Jid.fromString(jsonObject.getString("realJid"),true);
+		} catch (JSONException | InvalidJidException e) {
+			marker.realJid = null;
+		}
+		return marker;
+	}
+
+	public static Set<ReadByMarker> fromJsonString(String json) {
+		try {
+			return fromJson(new JSONArray(json));
+		} catch (JSONException | NullPointerException e) {
+			return new HashSet<>();
+		}
+	}
+
+	public static JSONArray toJson(Set<ReadByMarker> readByMarkers) {
+		JSONArray jsonArray = new JSONArray();
+		for(ReadByMarker marker : readByMarkers) {
+			jsonArray.put(marker.toJson());
+		}
+		return jsonArray;
+	}
+
+	public static boolean contains(ReadByMarker needle, Set<ReadByMarker> readByMarkers) {
+		for(ReadByMarker marker : readByMarkers) {
+			if (marker.realJid != null && needle.realJid != null) {
+				if (marker.realJid.toBareJid().equals(needle.realJid.toBareJid())) {
+					return true;
+				}
+			} else if (marker.fullJid != null && needle.fullJid != null) {
+				if (marker.fullJid.equals(needle.fullJid)) {
+					return true;
+				}
+			}
+		}
+		return false;
+	}
+
+	public static boolean allUsersRepresented(Collection<MucOptions.User> users, Set<ReadByMarker> markers) {
+		for(MucOptions.User user : users) {
+			if (!contains(from(user),markers)) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+}

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

@@ -39,7 +39,6 @@ public class MessageGenerator extends AbstractGenerator {
 		if (conversation.getMode() == Conversation.MODE_SINGLE) {
 			packet.setTo(message.getCounterpart());
 			packet.setType(MessagePacket.TYPE_CHAT);
-			packet.addChild("markable", "urn:xmpp:chat-markers:0");
 			if (this.mXmppConnectionService.indicateReceived()) {
 				packet.addChild("request", "urn:xmpp:receipts");
 			}
@@ -54,6 +53,10 @@ public class MessageGenerator extends AbstractGenerator {
 			packet.setTo(message.getCounterpart().toBareJid());
 			packet.setType(MessagePacket.TYPE_GROUPCHAT);
 		}
+		if (conversation.getMode() == Conversation.MODE_SINGLE ||
+				(conversation.getMucOptions().nonanonymous() && conversation.getMucOptions().membersOnly() && message.getType() != Message.TYPE_PRIVATE)) {
+			packet.addChild("markable", "urn:xmpp:chat-markers:0");
+		}
 		packet.setFrom(account.getJid());
 		packet.setId(message.getUuid());
 		packet.addChild("origin-id", Namespace.STANZA_IDS).setAttribute("id",message.getUuid());
@@ -170,10 +173,10 @@ public class MessageGenerator extends AbstractGenerator {
 		return packet;
 	}
 
-	public MessagePacket confirm(final Account account, final Jid to, final String id) {
+	public MessagePacket confirm(final Account account, final Jid to, final String id, final boolean groupChat) {
 		MessagePacket packet = new MessagePacket();
-		packet.setType(MessagePacket.TYPE_CHAT);
-		packet.setTo(to);
+		packet.setType(groupChat ? MessagePacket.TYPE_GROUPCHAT : MessagePacket.TYPE_CHAT);
+		packet.setTo(groupChat ? to.toBareJid() : to);
 		packet.setFrom(account.getJid());
 		Element received = packet.addChild("displayed","urn:xmpp:chat-markers:0");
 		received.setAttribute("id", id);

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

@@ -29,6 +29,7 @@ import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.entities.Presence;
+import eu.siacs.conversations.entities.ReadByMarker;
 import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 import eu.siacs.conversations.http.HttpConnectionManager;
 import eu.siacs.conversations.services.MessageArchiveService;
@@ -700,13 +701,29 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
 		}
 		Element displayed = packet.findChild("displayed", "urn:xmpp:chat-markers:0");
 		if (displayed != null) {
+			final String id = displayed.getAttribute("id");
 			if (packet.fromAccount(account)) {
-				Conversation conversation = mXmppConnectionService.find(account,counterpart.toBareJid());
+				Conversation conversation = mXmppConnectionService.find(account, counterpart.toBareJid());
 				if (conversation != null && (query == null || query.isCatchup())) {
 					mXmppConnectionService.markRead(conversation);
 				}
+			} else if (isTypeGroupChat) {
+				Conversation conversation = mXmppConnectionService.find(account, counterpart.toBareJid());
+				if (conversation != null && id != null) {
+					Message message = conversation.findMessageWithRemoteId(id);
+					if (message != null) {
+						final Jid fallback = conversation.getMucOptions().getTrueCounterpart(counterpart);
+						Jid trueJid = getTrueCounterpart(query != null ? mucUserElement : null, fallback);
+						ReadByMarker readByMarker = ReadByMarker.from(counterpart,trueJid);
+						if (!conversation.getMucOptions().isSelf(counterpart) && message.addReadByMarker(readByMarker)) {
+							Log.d(Config.LOGTAG,account.getJid().toBareJid()+": added read by ("+readByMarker.getRealJid()+") to message '"+message.getBody()+"'");
+							mXmppConnectionService.updateMessage(message);
+						}
+					}
+
+				}
 			} else {
-				final Message displayedMessage = mXmppConnectionService.markMessage(account, from.toBareJid(), displayed.getAttribute("id"), Message.STATUS_SEND_DISPLAYED);
+				final Message displayedMessage = mXmppConnectionService.markMessage(account, from.toBareJid(), id, Message.STATUS_SEND_DISPLAYED);
 				Message message = displayedMessage == null ? null : displayedMessage.prev();
 				while (message != null
 						&& message.getStatus() == Message.STATUS_SEND_RECEIVED

src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java 🔗

@@ -60,7 +60,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 	private static DatabaseBackend instance = null;
 
 	private static final String DATABASE_NAME = "history";
-	private static final int DATABASE_VERSION = 36;
+	private static final int DATABASE_VERSION = 37;
 
 	private static String CREATE_CONTATCS_STATEMENT = "create table "
 			+ Contact.TABLENAME + "(" + Contact.ACCOUNT + " TEXT, "
@@ -197,6 +197,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 				+ Message.READ + " NUMBER DEFAULT 1, "
 				+ Message.OOB + " INTEGER, "
 				+ Message.ERROR_MESSAGE + " TEXT,"
+				+ Message.READ_BY_MARKERS + " TEXT,"
 				+ Message.REMOTE_MSG_ID + " TEXT, FOREIGN KEY("
 				+ Message.CONVERSATION + ") REFERENCES "
 				+ Conversation.TABLENAME + "(" + Conversation.UUID
@@ -454,6 +455,10 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 						+ "=?", new String[]{account.getUuid()});
 			}
 		}
+
+		if (oldVersion < 37 && newVersion >= 37) {
+			db.execSQL("ALTER TABLE " + Message.TABLENAME + " ADD COLUMN " + Message.READ_BY_MARKERS + " TEXTs");
+		}
 	}
 
 	private static ContentValues createFingerprintStatusContentValues(FingerprintStatus.Trust trust, boolean active) {

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

@@ -10,10 +10,14 @@ import android.graphics.Typeface;
 import android.net.Uri;
 import android.util.DisplayMetrics;
 import android.util.Log;
+import android.util.LruCache;
 
 import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
@@ -39,6 +43,7 @@ public class AvatarService implements OnAdvancedStreamFeaturesLoaded {
 	private static final String PREFIX_GENERIC = "generic";
 
 	final private ArrayList<Integer> sizes = new ArrayList<>();
+	final private HashMap<String,Set<String>> conversationDependentKeys = new HashMap<>();
 
 	protected XmppConnectionService mXmppConnectionService = null;
 
@@ -184,6 +189,17 @@ public class AvatarService implements OnAdvancedStreamFeaturesLoaded {
 			clear(conversation.getContact());
 		} else {
 			clear(conversation.getMucOptions());
+			synchronized (this.conversationDependentKeys) {
+				Set<String> keys = this.conversationDependentKeys.get(conversation.getUuid());
+				if (keys == null) {
+					return;
+				}
+				LruCache<String, Bitmap> cache = this.mXmppConnectionService.getBitmapCache();
+				for(String key : keys) {
+					cache.remove(key);
+				}
+				keys.clear();
+			}
 		}
 	}
 
@@ -194,17 +210,36 @@ public class AvatarService implements OnAdvancedStreamFeaturesLoaded {
 			return bitmap;
 		}
 		final List<MucOptions.User> users = mucOptions.getUsers(5);
+		if (users.size() == 0) {
+			bitmap = getImpl(mucOptions.getConversation().getName(),size);
+		} else {
+			bitmap = getImpl(users,size);
+		}
+		this.mXmppConnectionService.getBitmapCache().put(KEY, bitmap);
+		return bitmap;
+	}
+
+	private Bitmap get(List<MucOptions.User> users, int size, boolean cachedOnly) {
+		final String KEY = key(users, size);
+		Bitmap bitmap = this.mXmppConnectionService.getBitmapCache().get(KEY);
+		if (bitmap != null || cachedOnly) {
+			return bitmap;
+		}
+		bitmap = getImpl(users, size);
+		this.mXmppConnectionService.getBitmapCache().put(KEY,bitmap);
+		return bitmap;
+	}
+
+	private Bitmap getImpl(List<MucOptions.User> users, int size) {
 		int count = users.size();
-		bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
+		Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
 		Canvas canvas = new Canvas(bitmap);
 		bitmap.eraseColor(TRANSPARENT);
-
 		if (count == 0) {
-			String name = mucOptions.getConversation().getName();
-			drawTile(canvas, name, 0, 0, size, size);
+			throw new AssertionError("Unable to draw tiles for 0 users");
 		} else if (count == 1) {
 			drawTile(canvas, users.get(0), 0, 0, size / 2 - 1, size);
-			drawTile(canvas, mucOptions.getConversation().getAccount(), size / 2 + 1, 0, size, size);
+			drawTile(canvas, users.get(0).getAccount(), size / 2 + 1, 0, size, size);
 		} else if (count == 2) {
 			drawTile(canvas, users.get(0), 0, 0, size / 2 - 1, size);
 			drawTile(canvas, users.get(1), size / 2 + 1, 0, size, size);
@@ -226,7 +261,6 @@ public class AvatarService implements OnAdvancedStreamFeaturesLoaded {
 			drawTile(canvas, "\u2026", PLACEHOLDER_COLOR, size / 2 + 1, size / 2 + 1,
 					size, size);
 		}
-		this.mXmppConnectionService.getBitmapCache().put(KEY, bitmap);
 		return bitmap;
 	}
 
@@ -248,6 +282,31 @@ public class AvatarService implements OnAdvancedStreamFeaturesLoaded {
 				+ "_" + String.valueOf(size);
 	}
 
+	private String key(List<MucOptions.User> users, int size) {
+		final Conversation conversation = users.get(0).getConversation();
+		StringBuilder builder = new StringBuilder("TILE_");
+		builder.append(conversation.getUuid());
+
+		for(MucOptions.User user : users) {
+			builder.append("\0");
+			builder.append(user.getRealJid() == null ? "" : user.getRealJid().toBareJid().toPreppedString());
+			builder.append("\0");
+			builder.append(user.getFullJid() == null ? "" : user.getFullJid().toPreppedString());
+		}
+		final String key = builder.toString();
+		synchronized (this.conversationDependentKeys) {
+			Set<String> keys;
+			if (this.conversationDependentKeys.containsKey(conversation.getUuid())) {
+				keys = this.conversationDependentKeys.get(conversation.getUuid());
+			} else {
+				keys = new HashSet<>();
+				this.conversationDependentKeys.put(conversation.getUuid(),keys);
+			}
+			keys.add(key);
+		}
+		return key;
+	}
+
 	public Bitmap get(Account account, int size) {
 		return get(account, size, false);
 	}
@@ -268,7 +327,9 @@ public class AvatarService implements OnAdvancedStreamFeaturesLoaded {
 
 	public Bitmap get(Message message, int size, boolean cachedOnly) {
 		final Conversation conversation = message.getConversation();
-		if (message.getStatus() == Message.STATUS_RECEIVED) {
+		if (message.getType() == Message.TYPE_STATUS && message.getCounterparts() != null && message.getCounterparts().size() > 1) {
+			return get(message.getCounterparts(),size,cachedOnly);
+		} else if (message.getStatus() == Message.STATUS_RECEIVED) {
 			Contact c = message.getContact();
 			if (c != null && (c.getProfilePhoto() != null || c.getAvatar() != null)) {
 				return get(c, size, cachedOnly);
@@ -320,11 +381,16 @@ public class AvatarService implements OnAdvancedStreamFeaturesLoaded {
 		if (bitmap != null || cachedOnly) {
 			return bitmap;
 		}
-		bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
+		bitmap = getImpl(name, size);
+		mXmppConnectionService.getBitmapCache().put(KEY, bitmap);
+		return bitmap;
+	}
+
+	private Bitmap getImpl(final String name, final int size) {
+		Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
 		Canvas canvas = new Canvas(bitmap);
 		final String trimmedName = name == null ? "" : name.trim();
 		drawTile(canvas, trimmedName, 0, 0, size, size);
-		mXmppConnectionService.getBitmapCache().put(KEY, bitmap);
 		return bitmap;
 	}
 

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

@@ -3394,11 +3394,13 @@ public class XmppConnectionService extends Service {
 		if (confirmMessages()
 				&& markable != null
 				&& markable.trusted()
-				&& markable.getRemoteMsgId() != null) {
+				&& markable.getRemoteMsgId() != null
+				&& markable.getType() != Message.TYPE_PRIVATE) {
 			Log.d(Config.LOGTAG, conversation.getAccount().getJid().toBareJid() + ": sending read marker to " + markable.getCounterpart().toString());
 			Account account = conversation.getAccount();
 			final Jid to = markable.getCounterpart();
-			MessagePacket packet = mMessageGenerator.confirm(account, to, markable.getRemoteMsgId());
+			final boolean groupChat = conversation.getMode() == Conversation.MODE_MULTI;
+			MessagePacket packet = mMessageGenerator.confirm(account, to, markable.getRemoteMsgId(), groupChat);
 			this.sendMessagePacket(conversation.getAccount(), packet);
 		}
 	}

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

@@ -16,8 +16,6 @@ import android.support.v13.view.inputmethod.InputConnectionCompat;
 import android.support.v13.view.inputmethod.InputContentInfoCompat;
 import android.text.Editable;
 import android.text.InputType;
-import android.text.TextWatcher;
-import android.text.style.StyleSpan;
 import android.util.Log;
 import android.util.Pair;
 import android.view.ContextMenu;
@@ -49,7 +47,9 @@ import net.java.otr4j.session.SessionStatus;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.atomic.AtomicBoolean;
 
@@ -63,6 +63,7 @@ import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.entities.Presence;
+import eu.siacs.conversations.entities.ReadByMarker;
 import eu.siacs.conversations.entities.Transferable;
 import eu.siacs.conversations.entities.TransferablePlaceholder;
 import eu.siacs.conversations.http.HttpDownloadConnection;
@@ -75,7 +76,6 @@ 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.ui.widget.EditMessage;
-import eu.siacs.conversations.ui.widget.ListSelectionManager;
 import eu.siacs.conversations.utils.MessageUtils;
 import eu.siacs.conversations.utils.NickValidityChecker;
 import eu.siacs.conversations.utils.StylingHelper;
@@ -1394,12 +1394,51 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 					}
 				}
 			} else {
+				final MucOptions mucOptions = conversation.getMucOptions();
+				final List<MucOptions.User> allUsers = mucOptions.getUsers();
+				final Set<ReadByMarker> addedMarkers = new HashSet<>();
 				ChatState state = ChatState.COMPOSING;
 				List<MucOptions.User> users = conversation.getMucOptions().getUsersWithChatState(state,5);
 				if (users.size() == 0) {
 					state = ChatState.PAUSED;
 					users = conversation.getMucOptions().getUsersWithChatState(state, 5);
-
+				}
+				int markersAdded = 0;
+				if (mucOptions.membersOnly() && mucOptions.nonanonymous()) {
+					//addedMarkers.addAll(ReadByMarker.from(users));
+					for (int i = this.messageList.size() - 1; i >= 0; --i) {
+						final Set<ReadByMarker> markersForMessage = messageList.get(i).getReadByMarkers();
+						final List<MucOptions.User> shownMarkers = new ArrayList<>();
+						for (ReadByMarker marker : markersForMessage) {
+							if (!ReadByMarker.contains(marker, addedMarkers)) {
+								addedMarkers.add(marker); //may be put outside this condition. set should do dedup anyway
+								MucOptions.User user = mucOptions.findUser(marker);
+								if (user != null && !users.contains(user)) {
+									shownMarkers.add(user);
+								}
+							}
+						}
+						final ReadByMarker markerForSender = ReadByMarker.from(messageList.get(i));
+						final Message statusMessage;
+						if (shownMarkers.size() > 1) {
+							statusMessage = Message.createStatusMessage(conversation, getString(R.string.contacts_have_read_up_to_this_point, UIHelper.concatNames(shownMarkers)));
+							statusMessage.setCounterparts(shownMarkers);
+						} else if (shownMarkers.size() == 1) {
+							statusMessage = Message.createStatusMessage(conversation, getString(R.string.contact_has_read_up_to_this_point, UIHelper.getDisplayName(shownMarkers.get(0))));
+							statusMessage.setCounterpart(shownMarkers.get(0).getFullJid());
+							statusMessage.setTrueCounterpart(shownMarkers.get(0).getRealJid());
+						} else {
+							statusMessage = null;
+						}
+						if (statusMessage != null) {
+							++markersAdded;
+							this.messageList.add(i + 1, statusMessage);
+						}
+						addedMarkers.add(markerForSender);
+						if (ReadByMarker.allUsersRepresented(allUsers, addedMarkers)) {
+							break;
+						}
+					}
 				}
 				if (users.size() > 0) {
 					Message statusMessage;
@@ -1410,15 +1449,9 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa
 						statusMessage.setTrueCounterpart(user.getRealJid());
 						statusMessage.setCounterpart(user.getFullJid());
 					} else {
-						StringBuilder builder = new StringBuilder();
-						for(MucOptions.User user : users) {
-							if (builder.length() != 0) {
-								builder.append(", ");
-							}
-							builder.append(UIHelper.getDisplayName(user));
-						}
 						int id = state == ChatState.COMPOSING ? R.string.contacts_are_typing : R.string.contacts_have_stopped_typing;
-						statusMessage = Message.createStatusMessage(conversation, getString(id, builder.toString()));
+						statusMessage = Message.createStatusMessage(conversation, getString(id, UIHelper.concatNames(users)));
+						statusMessage.setCounterparts(users);
 					}
 					this.messageList.add(statusMessage);
 				}

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

@@ -6,11 +6,13 @@ import android.content.pm.PackageManager;
 import android.content.pm.ResolveInfo;
 import android.content.res.Resources;
 import android.graphics.Bitmap;
+import android.graphics.Color;
 import android.graphics.Typeface;
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
 import android.net.Uri;
 import android.os.AsyncTask;
+import android.support.annotation.ColorInt;
 import android.support.v4.content.ContextCompat;
 import android.text.Spannable;
 import android.text.SpannableString;
@@ -709,7 +711,7 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 				if (conversation.getMode() == Conversation.MODE_SINGLE) {
 					showAvatar = true;
 					loadAvatar(message,viewHolder.contact_picture,activity.getPixel(32));
-				} else if (message.getCounterpart() != null ){
+				} else if (message.getCounterpart() != null || message.getTrueCounterpart() != null || (message.getCounterparts() != null && message.getCounterparts().size() > 0)) {
 					showAvatar = true;
 					loadAvatar(message,viewHolder.contact_picture,activity.getPixel(32));
 				} else {
@@ -1052,9 +1054,15 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 			if (bm != null) {
 				cancelPotentialWork(message, imageView);
 				imageView.setImageBitmap(bm);
-				imageView.setBackgroundColor(0x00000000);
+				imageView.setBackgroundColor(Color.TRANSPARENT);
 			} else {
-				imageView.setBackgroundColor(UIHelper.getColorForName(UIHelper.getMessageDisplayName(message)));
+				@ColorInt int bg;
+				if (message.getType() == Message.TYPE_STATUS && message.getCounterparts() != null && message.getCounterparts().size() > 1) {
+					bg = Color.TRANSPARENT;
+				} else {
+					bg = UIHelper.getColorForName(UIHelper.getMessageDisplayName(message));
+				}
+				imageView.setBackgroundColor(bg);
 				imageView.setImageDrawable(null);
 				final BitmapWorkerTask task = new BitmapWorkerTask(imageView, size);
 				final AsyncDrawable asyncDrawable = new AsyncDrawable(activity.getResources(), null, task);

src/main/java/eu/siacs/conversations/utils/UIHelper.java 🔗

@@ -8,6 +8,9 @@ import android.widget.PopupMenu;
 
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
+import java.math.BigInteger;
+import java.nio.ByteBuffer;
+import java.security.MessageDigest;
 import java.util.Arrays;
 import java.util.Calendar;
 import java.util.Date;
@@ -28,6 +31,35 @@ import eu.siacs.conversations.xmpp.jid.Jid;
 
 public class UIHelper {
 
+
+	private static int COLORS[] = {
+			0xFFE91E63, //pink 500
+			0xFFAD1457, //pink 800
+			0xFF9C27B0, //purple 500
+			0xFF6A1B9A, //purple 800
+			0xFF673AB7, //deep purple 500,
+			0xFF4527A0, //deep purple 800,
+			0xFF3F51B5, //indigo 500,
+			0xFF283593, //indigo 800
+			0xFF2196F3, //blue 500
+			0xFF1565C0, //blue 800
+			0xFF03A9F4, //light blue 500
+			0xFF0277BD, //light blue 800
+			0xFF00BCD4, //cyan 500
+			0xFF00838F, //cyan 800
+			0xFF009688, //teal 500,
+			0xFF00695C, //teal 800,
+			//0xFF558B2F, //light green 800
+			0xFFC0CA33, //lime 600
+			0xFF9E9D24, //lime 800
+			0xFFEF6C00, //orange 800
+			0xFFD84315, //deep orange 800,
+			0xFF795548, //brown 500,
+			//0xFF4E342E, //brown 800
+			0xFF607D8B, //blue grey 500,
+			0xFF37474F //blue grey 800
+	};
+
 	private static final List<String> LOCATION_QUESTIONS = Arrays.asList(
 			"where are you", //en
 			"where are you now", //en
@@ -150,10 +182,18 @@ public class UIHelper {
 		if (name == null || name.isEmpty()) {
 			return 0xFF202020;
 		}
-		int colors[] = {0xFFe91e63, 0xFF9c27b0, 0xFF673ab7, 0xFF3f51b5,
-			0xFF5677fc, 0xFF03a9f4, 0xFF00bcd4, 0xFF009688, 0xFFff5722,
-			0xFF795548, 0xFF607d8b};
-		return colors[(int) ((name.hashCode() & 0xffffffffl) % colors.length)];
+		return COLORS[getIntForName(name) % COLORS.length];
+	}
+
+	private static int getIntForName(String name) {
+		try {
+			final MessageDigest messageDigest = MessageDigest.getInstance("MD5");
+			messageDigest.update(name.getBytes());
+			byte[] bytes = messageDigest.digest();
+			return Math.abs(new BigInteger(bytes).intValue());
+		} catch (Exception e) {
+			return 0;
+		}
 	}
 
 	public static Pair<String,Boolean> getMessagePreview(final Context context, final Message message) {
@@ -312,8 +352,29 @@ public class UIHelper {
 		if (contact != null) {
 			return contact.getDisplayName();
 		} else {
-			return user.getName();
+			final String name = user.getName();
+			if (name != null) {
+				return name;
+			}
+			final Jid realJid = user.getRealJid();
+			if (realJid != null) {
+				return JidHelper.localPartOrFallback(realJid);
+			}
+			return null;
+		}
+	}
+
+	public static String concatNames(List<MucOptions.User> users) {
+		StringBuilder builder = new StringBuilder();
+		final boolean shortNames = users.size() >= 3;
+		for(MucOptions.User user : users) {
+			if (builder.length() != 0) {
+				builder.append(", ");
+			}
+			final String name = UIHelper.getDisplayName(user);
+			builder.append(shortNames ? name.split("\\s+")[0] : name);
 		}
+		return builder.toString();
 	}
 
 	public static String getFileDescriptionString(final Context context, final Message message) {

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

@@ -247,6 +247,7 @@
 	<string name="contact_added_you">Contact added you to contact list</string>
 	<string name="add_back">Add back</string>
 	<string name="contact_has_read_up_to_this_point">%s has read up to this point</string>
+	<string name="contacts_have_read_up_to_this_point">%s have read up to this point</string>
 	<string name="publish">Publish</string>
 	<string name="touch_to_choose_picture">Touch avatar to select picture from gallery</string>
 	<string name="publish_avatar_explanation">Please note: Everyone subscribed to your presence updates will be allowed to see this picture.</string>