fix(entities): cache MUC occupants in own DB rows

Phillip Davis created

Change summary

build.gradle                                                                     |   9 
src/androidTest/java/eu/siacs/conversations/persistance/DatabaseBackendTest.java | 197 
src/main/java/eu/siacs/conversations/entities/Conversation.java                  | 106 
src/main/java/eu/siacs/conversations/entities/MucOptions.java                    |  40 
src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java            | 111 
src/test/java/eu/siacs/conversations/entities/ConversationTest.java              |  91 
6 files changed, 500 insertions(+), 54 deletions(-)

Detailed changes

build.gradle 🔗

@@ -301,6 +301,15 @@ android {
         unitTests {
             includeAndroidResources = true
         }
+        managedDevices {
+            localDevices {
+                pixel2api30 {
+                    device = "Pixel 2"
+                    apiLevel = 30
+                    systemImageSource = "aosp-atd"
+                }
+            }
+        }
     }
 
     subprojects {

src/androidTest/java/eu/siacs/conversations/persistance/DatabaseBackendTest.java 🔗

@@ -0,0 +1,197 @@
+package eu.siacs.conversations.persistance;
+
+import android.content.ContentValues;
+import android.database.sqlite.SQLiteDatabase;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.HashMap;
+import java.util.UUID;
+
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.Conversation;
+import eu.siacs.conversations.entities.MucOptions;
+import eu.siacs.conversations.xml.Namespace;
+import im.conversations.android.xmpp.model.disco.info.Feature;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
+
+@RunWith(AndroidJUnit4.class)
+public class DatabaseBackendTest {
+    private record AccountFixture(String uuid, String username, String server) {
+        void write(DatabaseBackend db) {
+            final var cv = new ContentValues();
+            cv.put("uuid", uuid);
+            cv.put("username", username);
+            cv.put("server", server);
+            cv.put("password", "test");
+            cv.put("options", 0);
+            db.getWritableDatabase().insertWithOnConflict(
+                "accounts", null, cv, SQLiteDatabase.CONFLICT_REPLACE);
+        }
+    }
+
+    private record ConversationFixture(
+        String conversationUuid,
+        AccountFixture account,
+        String name,
+        String contactJid,
+        String attributes,
+        HashMap<MucOptions.User.OccupantId, MucOptions.User.CacheEntry> occupantCache
+    ) {
+        void writeConversation(DatabaseBackend db) {
+            final var cv = new ContentValues();
+            cv.put("uuid", conversationUuid);
+            cv.put("name", name);
+            cv.put("contactUuid", "");
+            cv.put("accountUuid", account.uuid());
+            cv.put("contactJid", contactJid);
+            cv.put("created", System.currentTimeMillis());
+            cv.put("status", Conversation.STATUS_AVAILABLE);
+            cv.put("mode", Conversation.MODE_MULTI);
+            cv.put("attributes", attributes);
+            db.getWritableDatabase().insert("conversations", null, cv);
+        }
+
+        void writeOccupants(DatabaseBackend db) {
+            for (final var entry : occupantCache.entrySet()) {
+                final var cv = new ContentValues();
+                cv.put(MucOptions.User.CacheEntry.OCCUPANT_ID, entry.getKey().inner());
+                cv.put(MucOptions.User.CacheEntry.CONVERSATION_UUID, conversationUuid);
+                cv.put(MucOptions.User.CacheEntry.AVATAR, entry.getValue().avatar());
+                cv.put(MucOptions.User.CacheEntry.NICK, entry.getValue().nick());
+                db.getWritableDatabase().insert(
+                    MucOptions.User.CacheEntry.TABLENAME, null, cv);
+            }
+        }
+
+        void writeAll(DatabaseBackend db) {
+            writeConversation(db);
+            writeOccupants(db);
+        }
+
+        Conversation extractAndConfigure(DatabaseBackend db)
+        {
+            final var conversations = db.getConversations(Conversation.STATUS_AVAILABLE);
+            Assert.assertNotNull("getConversations should not return null", conversations);
+
+            Conversation match = null;
+            for (final var c : conversations) {
+                if (conversationUuid.equals(c.getUuid())) {
+                    match = c;
+                    break;
+                }
+            }
+            Assert.assertNotNull(
+                "Fixture conversation " + conversationUuid + " not found", match);
+
+            match.setAccount(db.getAccounts().get(0));
+            match.getMucOptions().updateConfiguration(INFO_QUERY_WITH_OCCUPANT_ID);
+            match.putAllInMucOccupantCache(db.getMucUsersForConversation(match));
+            return match;
+        }
+    }
+
+    private DatabaseBackend db;
+    private static final InfoQuery INFO_QUERY_WITH_OCCUPANT_ID = new InfoQuery();
+
+    private static AccountFixture ACCOUNT;
+    private static ConversationFixture CONFORMING;
+    private static ConversationFixture NO_CACHED_MUC_USERS;
+    private static ConversationFixture[] FIXTURES;
+
+    @BeforeClass
+    public static void setupClass() throws JSONException {
+        final var occupantIdFeature = new Feature();
+        occupantIdFeature.setVar(Namespace.OCCUPANT_ID);
+        INFO_QUERY_WITH_OCCUPANT_ID.addChild(occupantIdFeature);
+
+        ACCOUNT = new AccountFixture(
+            UUID.randomUUID().toString(), "test", "example.com");
+
+        final var conformingCache =
+            new HashMap<MucOptions.User.OccupantId, MucOptions.User.CacheEntry>();
+        conformingCache.put(
+            new MucOptions.User.OccupantId(UUID.randomUUID().toString()),
+            new MucOptions.User.CacheEntry(UUID.randomUUID().toString(), "ConformingUser"));
+
+        CONFORMING = new ConversationFixture(
+            UUID.randomUUID().toString(),
+            ACCOUNT,
+            "Normal MUC",
+            "normalroom@conference.example.com",
+            new JSONObject().put("mucNick", "testMucNick").toString(),
+            conformingCache
+        );
+
+        NO_CACHED_MUC_USERS = new ConversationFixture(
+            UUID.randomUUID().toString(),
+            ACCOUNT,
+            "Empty Cache MUC",
+            "emptycache@conference.example.com",
+            new JSONObject().put("mucNick", "testMucNick").toString(),
+            new HashMap<>()
+        );
+
+        FIXTURES = new ConversationFixture[] { CONFORMING, NO_CACHED_MUC_USERS };
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        db = DatabaseBackend.getInstance(
+                ApplicationProvider.getApplicationContext());
+        ACCOUNT.write(db);
+        for (final var fixture : FIXTURES) {
+            fixture.writeAll(db);
+        }
+    }
+
+    @After
+    public void tearDown() {
+        SQLiteDatabase sqDb = db.getWritableDatabase();
+        sqDb.delete(Conversation.TABLENAME, null, null);
+        sqDb.delete(Account.TABLENAME, null, null);
+        sqDb.delete(MucOptions.User.CacheEntry.TABLENAME, null, null);
+    }
+
+    @Test
+    public void getConversationsCorrectlyReadsMucUsers() throws Exception {
+        Assert.assertTrue(
+            "Occupant cache should be empty when no occupants are written",
+            NO_CACHED_MUC_USERS
+                .extractAndConfigure(db)
+                    .getMucOccupantCache()
+                    .isEmpty()
+        );
+
+        Assert.assertEquals(
+            "Cached entries should match fixture",
+            CONFORMING
+                .extractAndConfigure(db)
+                .getMucOccupantCache(),
+            CONFORMING.occupantCache()
+        );
+    }
+
+    @Test
+    public void updateConversationWritesMucOccupantsCache() throws Exception {
+        final var conversation = NO_CACHED_MUC_USERS.extractAndConfigure(db);
+        conversation.putAllInMucOccupantCache(CONFORMING.occupantCache());
+        db.updateConversation(conversation);
+
+        final var readBackCache = db.getMucUsersForConversation(conversation);
+        Assert.assertEquals(
+            "Cache should match after updateConversation",
+            CONFORMING.occupantCache(),
+            readBackCache
+        );
+    }
+}

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

@@ -48,6 +48,7 @@ import android.webkit.WebView;
 import android.webkit.WebViewClient;
 import android.webkit.WebChromeClient;
 import android.util.DisplayMetrics;
+import android.util.Log;
 import android.util.LruCache;
 import android.util.Pair;
 import android.util.SparseArray;
@@ -77,6 +78,7 @@ import com.cheogram.android.WebxdcPage;
 import com.google.android.material.color.MaterialColors;
 import com.google.android.material.tabs.TabLayout;
 import com.google.android.material.textfield.TextInputLayout;
+import com.google.common.base.Functions;
 import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.collect.ComparisonChain;
@@ -85,6 +87,7 @@ import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
 
+import im.conversations.android.xmpp.model.occupant.OccupantId;
 import io.ipfs.cid.Cid;
 
 import io.michaelrocks.libphonenumber.android.NumberParseException;
@@ -105,10 +108,13 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Iterator;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.ListIterator;
+import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
@@ -116,6 +122,7 @@ import java.util.Set;
 import java.util.Timer;
 import java.util.TimerTask;
 import java.util.function.Function;
+import java.util.function.Predicate;
 
 import me.saket.bettermovementmethod.BetterLinkMovementMethod;
 
@@ -161,21 +168,13 @@ import eu.siacs.conversations.xmpp.chatstate.ChatState;
 import eu.siacs.conversations.xmpp.forms.Data;
 import eu.siacs.conversations.xmpp.forms.Option;
 import eu.siacs.conversations.xmpp.mam.MamReference;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.ListIterator;
-import java.util.concurrent.atomic.AtomicBoolean;
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-
-import static eu.siacs.conversations.entities.Bookmark.printableValue;
 
 import im.conversations.android.xmpp.model.stanza.Iq;
 
 public class Conversation extends AbstractEntity
         implements Blockable, Comparable<Conversation>, Conversational, AvatarService.Avatarable {
+
+    public static final String TAG = "eu.siacs.conversations.entities.Conversation";
     public static final String TABLENAME = "conversations";
 
     public static final int STATUS_AVAILABLE = 0;
@@ -190,6 +189,24 @@ public class Conversation extends AbstractEntity
     public static final String MODE = "mode";
     public static final String ATTRIBUTES = "attributes";
 
+    public static final String[] ALL_COLUMNS = new String[] {
+        UUID,
+        NAME,
+        ACCOUNT,
+        CONTACT,
+        CONTACTJID,
+        STATUS,
+        CREATED,
+        MODE,
+        String.format(
+            "SUBSTR(%s, 0, %d) AS %s",
+            ATTRIBUTES,
+            Short.MAX_VALUE << 1,
+            ATTRIBUTES
+        )
+    };
+
+
     public static final String ATTRIBUTE_MUTED_TILL = "muted_till";
     public static final String ATTRIBUTE_ALWAYS_NOTIFY = "always_notify";
     public static final String ATTRIBUTE_NOTIFY_REPLIES = "notify_replies";
@@ -220,6 +237,9 @@ public class Conversation extends AbstractEntity
     private final JSONObject attributes;
     private Jid nextCounterpart;
     private final transient AtomicReference<MucOptions> mucOptions = new AtomicReference<>();
+    private transient ConcurrentMap<
+        MucOptions.User.OccupantId, MucOptions.User.CacheEntry
+    > mucOccupantCache = new ConcurrentHashMap<>();
     private boolean messagesLeftOnServer = true;
     private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE;
     private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE;
@@ -271,7 +291,7 @@ public class Conversation extends AbstractEntity
         this.attributes = parseAttributes(attributes);
     }
 
-    private static JSONObject parseAttributes(final String attributes) {
+    private static JSONObject parseAttributes(@Nullable final String attributes) {
         if (Strings.isNullOrEmpty(attributes)) {
             return new JSONObject();
         } else {
@@ -1162,6 +1182,70 @@ public class Conversation extends AbstractEntity
         return this.nextCounterpart;
     }
 
+    protected String getCachedOccupantNick(MucOptions.User.OccupantId cacheKey) {
+        final var cacheEntry = this.getMucOccupantCache().get(cacheKey);
+        if (cacheEntry == null) {
+            return null;
+        }
+        return cacheEntry.nick();
+    }
+
+    protected String getCachedOccupantAvatar(MucOptions.User.OccupantId cacheKey) {
+        final var cacheEntry = this.getMucOccupantCache().get(cacheKey);
+        if (cacheEntry == null) {
+            return null;
+        }
+        return cacheEntry.avatar();
+    }
+
+    protected boolean setCachedOccupantAvatar(MucOptions.User.OccupantId cacheKey, String newAvatar) {
+        final var newEntry = new MucOptions.User.CacheEntry(newAvatar, null);
+        return this.mucOccupantCache.merge(
+            cacheKey,
+            newEntry,
+            (prev, next) -> new MucOptions.User.CacheEntry(newAvatar, prev.nick())
+        ).equals(newEntry);
+    }
+
+    protected boolean setCachedOccupantNick(MucOptions.User.OccupantId cacheKey, String newNick) {
+        final var newEntry = new MucOptions.User.CacheEntry(null, newNick);
+        return this.mucOccupantCache.merge(
+            cacheKey,
+            newEntry,
+            (prev, next) -> new MucOptions.User.CacheEntry(prev.avatar(), newNick)
+        ).equals(newEntry);
+    }
+
+    public Map<MucOptions.User.OccupantId, MucOptions.User.CacheEntry> getMucOccupantCache() {
+        return this.mucOccupantCache.entrySet().stream().collect(
+            Collectors.toUnmodifiableMap(
+                Map.Entry::getKey,
+                Map.Entry::getValue
+            )
+        );
+    }
+
+    public void putAllInMucOccupantCache(Map<MucOptions.User.OccupantId, MucOptions.User.CacheEntry> newEntries) {
+        this.mucOccupantCache.putAll(newEntries);
+    }
+
+    public List<ContentValues> mucOccupantCacheAsContentValues() {
+        final var cvs = new ArrayList<ContentValues>();
+        mucOccupantCache.entrySet().forEach((entry) -> {
+            final var cv = new ContentValues();
+            final var occupantId = entry.getKey();
+            final var cacheEntry = entry.getValue();
+            final var avatar = cacheEntry.avatar();
+            final var nick = cacheEntry.nick();
+            cv.put(MucOptions.User.CacheEntry.OCCUPANT_ID, occupantId.inner());
+            cv.put(MucOptions.User.CacheEntry.CONVERSATION_UUID, this.getUuid());
+            cv.put(MucOptions.User.CacheEntry.AVATAR, avatar);
+            cv.put(MucOptions.User.CacheEntry.NICK, nick);
+            cvs.add(cv);
+        });
+        return cvs;
+    }
+
     public void setNextCounterpart(Jid jid) {
         this.nextCounterpart = jid;
     }

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

@@ -1,5 +1,6 @@
 package eu.siacs.conversations.entities;
 
+import android.content.ContentValues;
 import android.content.Context;
 import android.net.Uri;
 import android.text.TextUtils;
@@ -14,6 +15,8 @@ import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.gson.JsonObject;
+
 import de.gultsch.common.IntMap;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.services.AvatarService;
@@ -27,25 +30,32 @@ import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.chatstate.ChatState;
 import eu.siacs.conversations.xmpp.pep.Avatar;
 import eu.siacs.conversations.xml.Element;
-
+import im.conversations.android.xmpp.model.Hash;
 import im.conversations.android.xmpp.model.data.Data;
 import im.conversations.android.xmpp.model.data.Field;
 import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import im.conversations.android.xmpp.model.muc.Affiliation;
 import im.conversations.android.xmpp.model.muc.Item;
 import im.conversations.android.xmpp.model.muc.Role;
+
+import java.io.Serializable;
 import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
 import java.util.Arrays;
 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.Objects;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
-public class MucOptions {
+import org.json.JSONException;
+import org.json.JSONObject;
 
+public class MucOptions {
     private static final IntMap<Affiliation> AFFILIATION_RANKS =
             new IntMap<>(
                     new ImmutableMap.Builder<Affiliation, Integer>()
@@ -121,6 +131,7 @@ public class MucOptions {
         }
     }
 
+
     public void setAutoPushConfiguration(final boolean auto) {
         this.mAutoPushConfiguration = auto;
     }
@@ -338,6 +349,8 @@ public class MucOptions {
 
     // returns true if real jid was new;
     public boolean updateUser(User user) {
+        assert(user.options == null || user.options == this);
+
         User old;
         boolean realJidFound = false;
         if (user.fullJid == null && user.realJid != null) {
@@ -902,6 +915,18 @@ public class MucOptions {
     }
 
     public static class User implements Comparable<User>, AvatarService.Avatarable {
+        public record CacheEntry(String avatar, String nick) {
+            public static final String TABLENAME = "muc_user";
+            public static final String AVATAR = "avatar";
+            public static final String NICK = "nick";
+            public static final String OCCUPANT_ID = "occupant_id";
+            public static final String CONVERSATION_UUID = "conversation_uuid";
+
+            public static CacheEntry fromUser(final User user) {
+                return new CacheEntry(user.getAvatar(), user.getNick());
+            }
+        }
+        public record OccupantId(String inner) { }
         private Role role = Role.NONE;
         private Affiliation affiliation = Affiliation.NONE;
         private Jid realJid;
@@ -922,15 +947,16 @@ public class MucOptions {
             this.nick = nick;
             this.hats = hats;
 
+            final var cacheKey = new OccupantId(occupantId);
             if (occupantId != null && options != null) {
-                avatar = options.getConversation().getAttribute("occupantAvatar/" + occupantId);
+                avatar = options.conversation.getCachedOccupantAvatar(cacheKey);
 
                 if (nick == null) {
-                    this.nick = options.getConversation().getAttribute("occupantNick/" + occupantId);
+                    this.nick = options.conversation.getCachedOccupantNick(cacheKey);
                 } else if (!getNick().equals(getName())) {
-                    options.getConversation().setAttribute("occupantNick/" + occupantId, nick);
+                    options.conversation.setCachedOccupantNick(cacheKey, nick);
                 } else {
-                    options.getConversation().setAttribute("occupantNick/" + occupantId, (String) null);
+                    options.conversation.setCachedOccupantNick(cacheKey, null);
                 }
             }
         }
@@ -1014,7 +1040,7 @@ public class MucOptions {
 
         public boolean setAvatar(final String avatar) {
             if (occupantId != null) {
-                options.getConversation().setAttribute("occupantAvatar/" + occupantId, getContact() == null && avatar != null ? avatar : null);
+                options.conversation.setCachedOccupantAvatar(new OccupantId(occupantId), avatar);
             }
             if (this.avatar != null && this.avatar.equals(avatar)) {
                 return false;

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

@@ -4,7 +4,9 @@ import android.content.ContentValues;
 import android.content.Context;
 import android.database.Cursor;
 import android.database.DatabaseUtils;
+import android.database.sqlite.SQLiteAbortException;
 import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
 import android.database.sqlite.SQLiteOpenHelper;
 import android.text.TextUtils;
 import android.os.Build;
@@ -12,7 +14,6 @@ import android.os.Environment;
 import android.os.SystemClock;
 import android.util.Base64;
 import android.util.Log;
-
 import com.cheogram.android.WebxdcUpdate;
 
 import com.google.common.base.Stopwatch;
@@ -21,6 +22,8 @@ import com.google.common.collect.HashMultimap;
 
 import org.json.JSONException;
 import org.json.JSONObject;
+import org.jxmpp.jid.parts.Localpart;
+import org.jxmpp.stringprep.XmppStringprepException;
 import org.whispersystems.libsignal.IdentityKey;
 import org.whispersystems.libsignal.IdentityKeyPair;
 import org.whispersystems.libsignal.InvalidKeyException;
@@ -32,6 +35,7 @@ import org.whispersystems.libsignal.state.SignedPreKeyRecord;
 import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.security.cert.CertificateEncodingException;
 import java.security.cert.CertificateException;
 import java.security.cert.CertificateFactory;
@@ -47,6 +51,7 @@ import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.CopyOnWriteArrayList;
 
+import im.conversations.android.xmpp.model.occupant.OccupantId;
 import io.ipfs.cid.Cid;
 
 import com.google.common.collect.ImmutableMap;
@@ -68,38 +73,13 @@ import eu.siacs.conversations.utils.CursorUtils;
 import eu.siacs.conversations.utils.FtsUtils;
 import eu.siacs.conversations.utils.MimeUtils;
 import eu.siacs.conversations.utils.Resolver;
+import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.mam.MamReference;
 import im.conversations.android.xml.XmlElementReader;
 import im.conversations.android.xmpp.EntityCapabilities;
 import im.conversations.android.xmpp.EntityCapabilities2;
 import im.conversations.android.xmpp.model.disco.info.InfoQuery;
-import java.io.ByteArrayInputStream;
-import java.io.File;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.security.cert.CertificateEncodingException;
-import java.security.cert.CertificateException;
-import java.security.cert.CertificateFactory;
-import java.security.cert.X509Certificate;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.UUID;
-import java.util.concurrent.CopyOnWriteArrayList;
-import org.json.JSONObject;
-import org.jxmpp.jid.parts.Localpart;
-import org.jxmpp.stringprep.XmppStringprepException;
-import org.whispersystems.libsignal.IdentityKey;
-import org.whispersystems.libsignal.IdentityKeyPair;
-import org.whispersystems.libsignal.InvalidKeyException;
-import org.whispersystems.libsignal.SignalProtocolAddress;
-import org.whispersystems.libsignal.state.PreKeyRecord;
-import org.whispersystems.libsignal.state.SessionRecord;
-import org.whispersystems.libsignal.state.SignedPreKeyRecord;
 
 public class DatabaseBackend extends SQLiteOpenHelper {
 
@@ -541,6 +521,19 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                 db.execSQL("PRAGMA cheogram.user_version = 12");
             }
 
+            if (cheogramVersion < 13) {
+                db.execSQL(
+                        "CREATE TABLE cheogram." + MucOptions.User.CacheEntry.TABLENAME + " (" +
+                                "conversation_uuid TEXT NOT NULL, " +
+                                "occupant_id TEXT NOT NULL, " +
+                                "nick TEXT, " +
+                                "avatar TEXT, " +
+                                "PRIMARY KEY (conversation_uuid, occupant_id)" +
+                                ")"
+                );
+                db.execSQL("PRAGMA cheogram.user_version = 13");
+            }
+
             db.setTransactionSuccessful();
         } finally {
             db.endTransaction();
@@ -549,7 +542,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 
     @Override
     public void onConfigure(SQLiteDatabase db) {
-        db.execSQL("PRAGMA foreign_keys=ON");
+        db.setForeignKeyConstraintsEnabled(true);
         db.rawQuery("PRAGMA secure_delete=ON", null).close();
         db.execSQL("ATTACH DATABASE ? AS cheogram", new Object[]{context.getDatabasePath("cheogram").getPath()});
         cheogramMigrate(db);
@@ -1628,13 +1621,45 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         return templates;
     }
 
+    public HashMap<MucOptions.User.OccupantId, MucOptions.User.CacheEntry>
+    getMucUsersForConversation(Conversation conversation)
+    {
+        HashMap<MucOptions.User.OccupantId, MucOptions.User.CacheEntry> cache = new HashMap<>();
+        Cursor cursor = null;
+        try {
+            SQLiteDatabase db = this.getReadableDatabase();
+            String[] selectionArgs = {conversation.getUuid()};
+            cursor = db.rawQuery(
+                    "select * from "
+                            + MucOptions.User.CacheEntry.TABLENAME
+                            + " where "
+                            + MucOptions.User.CacheEntry.CONVERSATION_UUID
+                            + "= ?",
+                        selectionArgs
+            );
+            while (cursor.moveToNext()) {
+                final var avatar = cursor.getString(cursor.getColumnIndexOrThrow(MucOptions.User.CacheEntry.AVATAR));
+                final var nick = cursor.getString(cursor.getColumnIndexOrThrow(MucOptions.User.CacheEntry.NICK));
+                final var occupantId = cursor.getString(cursor.getColumnIndexOrThrow(MucOptions.User.CacheEntry.OCCUPANT_ID));
+                cache.put(
+                    new MucOptions.User.OccupantId(occupantId),
+                    new MucOptions.User.CacheEntry(avatar, nick)
+                );
+            }
+        } finally {
+            if (cursor != null) cursor.close();
+        }
+
+        return cache;
+    }
+
     public CopyOnWriteArrayList<Conversation> getConversations(int status) {
         CopyOnWriteArrayList<Conversation> list = new CopyOnWriteArrayList<>();
         SQLiteDatabase db = this.getReadableDatabase();
         String[] selectionArgs = {Integer.toString(status)};
         Cursor cursor =
                 db.rawQuery(
-                        "select * from "
+                        "select " + String.join(", ", Conversation.ALL_COLUMNS) + " from "
                                 + Conversation.TABLENAME
                                 + " where "
                                 + Conversation.STATUS
@@ -1649,6 +1674,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
             if (conversation.getJid() instanceof Jid.Invalid) {
                 continue;
             }
+            conversation.putAllInMucOccupantCache(getMucUsersForConversation(conversation));
             list.add(conversation);
         }
         cursor.close();
@@ -2148,14 +2174,27 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         }
     }
 
-    public void updateConversation(final Conversation conversation) {
+    public void updateConversation(final Conversation conversation) throws SQLiteException {
         final SQLiteDatabase db = this.getWritableDatabase();
-        final String[] args = {conversation.getUuid()};
-        db.update(
-                Conversation.TABLENAME,
-                conversation.getContentValues(),
-                Conversation.UUID + "=?",
-                args);
+        db.beginTransaction();
+        try {
+            final String[] args = {conversation.getUuid()};
+            db.update(
+                    Conversation.TABLENAME,
+                    conversation.getContentValues(),
+                    Conversation.UUID + "=?",
+                    args);
+            for (final var cv : conversation.mucOccupantCacheAsContentValues()) {
+                db.insertWithOnConflict(
+                        MucOptions.User.CacheEntry.TABLENAME,
+                        null,
+                        cv,
+                        SQLiteDatabase.CONFLICT_REPLACE);
+            }
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
     }
 
     public List<Account> getAccounts() {

src/test/java/eu/siacs/conversations/entities/ConversationTest.java 🔗

@@ -0,0 +1,91 @@
+package eu.siacs.conversations.entities;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.ConscryptMode;
+
+import android.os.Build;
+import eu.siacs.conversations.Conversations;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.Jid;
+import im.conversations.android.xmpp.model.disco.info.Feature;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
+import junit.framework.Assert;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = Build.VERSION_CODES.TIRAMISU, application = Conversations.class)
+@ConscryptMode(ConscryptMode.Mode.OFF)
+public class ConversationTest {
+    private static final InfoQuery INFO_QUERY_WITH_OCCUPANT_ID = new InfoQuery();
+    private static final InfoQuery INFO_QUERY_WITHOUT_OCCUPANT_ID = new InfoQuery();
+
+    private Conversation withOccupantId;
+    private Conversation withoutOccupantId;
+    private Conversation nullMucOptions;
+
+    @BeforeClass
+    public static void setupClass() {
+        final var occupantIdFeature = new Feature();
+        occupantIdFeature.setVar(Namespace.OCCUPANT_ID);
+        INFO_QUERY_WITH_OCCUPANT_ID.addChild(occupantIdFeature);
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        final var account = mock(Account.class);
+        when(account.getJid()).thenReturn(Jid.ofLocalAndDomain("testAccount", "example.org"));
+
+        withOccupantId = new Conversation(
+            "Test MUC",
+            account,
+            Jid.ofLocalAndDomain("testMuc", "example.org"),
+            Conversation.MODE_MULTI
+        );
+        withOccupantId.getMucOptions().updateConfiguration(INFO_QUERY_WITH_OCCUPANT_ID);
+
+        withoutOccupantId = new Conversation(
+            "Test MUC",
+            account,
+            Jid.ofLocalAndDomain("testMuc", "example.org"),
+            Conversation.MODE_MULTI
+        );
+        withoutOccupantId.getMucOptions().updateConfiguration(INFO_QUERY_WITHOUT_OCCUPANT_ID);
+
+        nullMucOptions = new Conversation(
+            "Test MUC",
+            account,
+            Jid.ofLocalAndDomain("testMuc", "example.org"),
+            Conversation.MODE_MULTI
+        );
+        final var mucOptionsField = Conversation.class.getDeclaredField("mucOptions");
+        mucOptionsField.setAccessible(true);
+        ((AtomicReference<?>) mucOptionsField.get(nullMucOptions)).set(null);
+    }
+
+    @Test
+    public void getMucOccupantsCacheReturnsCacheWhenMucOptionsIsNull() throws Exception {
+        var cache = nullMucOptions.getMucOccupantCache();
+        Assert.assertNotNull("Should return cache when mucOptions is null", cache);
+    }
+
+    @Test
+    public void getMucOccupantsCacheReturnsCacheWhenFeatureSupported() throws Exception {
+        var cache = withOccupantId.getMucOccupantCache();
+        Assert.assertNotNull("Should return cache when occupant-id is supported", cache);
+    }
+
+    @Test
+    public void getMucOccupantsCacheReturnsCacheWhenFeatureNotSupported() throws Exception {
+        var cache = withoutOccupantId.getMucOccupantCache();
+        Assert.assertNotNull("Should return cache even when occupant-id is not supported", cache);
+    }
+}