From a4f7fb2120ecd486b8ffbe9fc49c73c60624d556 Mon Sep 17 00:00:00 2001 From: Phillip Davis Date: Tue, 24 Feb 2026 18:12:48 -0500 Subject: [PATCH] fix(entities): cache MUC occupants in own DB rows --- build.gradle | 9 + .../persistance/DatabaseBackendTest.java | 197 ++++++++++++++++++ .../conversations/entities/Conversation.java | 106 +++++++++- .../conversations/entities/MucOptions.java | 40 +++- .../persistance/DatabaseBackend.java | 111 ++++++---- .../entities/ConversationTest.java | 91 ++++++++ 6 files changed, 500 insertions(+), 54 deletions(-) create mode 100644 src/androidTest/java/eu/siacs/conversations/persistance/DatabaseBackendTest.java create mode 100644 src/test/java/eu/siacs/conversations/entities/ConversationTest.java diff --git a/build.gradle b/build.gradle index 94e28a41eacf8369500de3823d6569419312f2da..246fc75ae3c4ab944877442831b3c83e0c53af2f 100644 --- a/build.gradle +++ b/build.gradle @@ -301,6 +301,15 @@ android { unitTests { includeAndroidResources = true } + managedDevices { + localDevices { + pixel2api30 { + device = "Pixel 2" + apiLevel = 30 + systemImageSource = "aosp-atd" + } + } + } } subprojects { diff --git a/src/androidTest/java/eu/siacs/conversations/persistance/DatabaseBackendTest.java b/src/androidTest/java/eu/siacs/conversations/persistance/DatabaseBackendTest.java new file mode 100644 index 0000000000000000000000000000000000000000..ae589dab63b917eafb1974463c894af3140adbd2 --- /dev/null +++ b/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 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(); + 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 + ); + } +} diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index 8c68246bb3dfb19a566ddcce77b72b76fd231716..bc74244b39a9f654e1bc8980514e6e9e72fc15f4 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/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, 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 = 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 getMucOccupantCache() { + return this.mucOccupantCache.entrySet().stream().collect( + Collectors.toUnmodifiableMap( + Map.Entry::getKey, + Map.Entry::getValue + ) + ); + } + + public void putAllInMucOccupantCache(Map newEntries) { + this.mucOccupantCache.putAll(newEntries); + } + + public List mucOccupantCacheAsContentValues() { + final var cvs = new ArrayList(); + 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; } diff --git a/src/main/java/eu/siacs/conversations/entities/MucOptions.java b/src/main/java/eu/siacs/conversations/entities/MucOptions.java index ea61ec9756dc5c39bde11bf46ac4a388e21f9e97..69be12d00e6330c90c2c5e761c7a9a625e60b1c3 100644 --- a/src/main/java/eu/siacs/conversations/entities/MucOptions.java +++ b/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_RANKS = new IntMap<>( new ImmutableMap.Builder() @@ -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, 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; diff --git a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java index f8d1a090e863c1314a5a9abbe3442a024275cf9c..c6cfcf9e37e374d455875443afd62ede9152513b 100644 --- a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java +++ b/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 + getMucUsersForConversation(Conversation conversation) + { + HashMap 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 getConversations(int status) { CopyOnWriteArrayList 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 getAccounts() { diff --git a/src/test/java/eu/siacs/conversations/entities/ConversationTest.java b/src/test/java/eu/siacs/conversations/entities/ConversationTest.java new file mode 100644 index 0000000000000000000000000000000000000000..fd33012115720a5068d6ced0d71a78317c2f262f --- /dev/null +++ b/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); + } +}