diff --git a/app/build.gradle b/app/build.gradle index fc1efb7d1..cf2ed0c84 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -86,8 +86,11 @@ dependencies { implementation "androidx.room:room-runtime:$rootProject.ext.roomVersion" implementation "androidx.room:room-guava:$rootProject.ext.roomVersion" + implementation "androidx.room:room-paging:$rootProject.ext.roomVersion" annotationProcessor "androidx.room:room-compiler:$rootProject.ext.roomVersion" + implementation "androidx.paging:paging-runtime:$rootProject.ext.pagingVersion" + implementation "androidx.preference:preference:$rootProject.ext.preferenceVersion" diff --git a/app/src/androidTest/java/im/conversations/android/xmpp/MessageTransformationTest.java b/app/src/androidTest/java/im/conversations/android/xmpp/MessageTransformationTest.java index 0c0771f5c..2597d4ec7 100644 --- a/app/src/androidTest/java/im/conversations/android/xmpp/MessageTransformationTest.java +++ b/app/src/androidTest/java/im/conversations/android/xmpp/MessageTransformationTest.java @@ -38,6 +38,7 @@ public class MessageTransformationTest { private static final BareJid ACCOUNT = JidCreate.bareFromOrThrowUnchecked("user@example.com"); private static final BareJid REMOTE = JidCreate.bareFromOrThrowUnchecked("juliet@example.com"); + private static final BareJid REMOTE_2 = JidCreate.bareFromOrThrowUnchecked("romeo@example.com"); private static final String GREETING = "Hi Juliet. How are you?"; @@ -551,4 +552,37 @@ public class MessageTransformationTest { Assert.assertEquals(Modification.RETRACTION, message.modification); Assert.assertEquals(PartType.RETRACTION, Iterables.getOnlyElement(message.contents).type); } + + @Test + public void twoChatThreeMessages() throws XmppStringprepException { + final var m1 = new Message(); + m1.setId("1"); + m1.setTo(REMOTE); + m1.setFrom(JidCreate.fullFrom(ACCOUNT, Resourcepart.from("junit"))); + m1.addExtension(new Body("Hi. How are you?")); + + this.transformer.transform( + MessageTransformation.of( + m1, Instant.now(), REMOTE, null, m1.getFrom().asBareJid(), null)); + + final var m2 = new Message(); + m2.setId("2"); + m2.setTo(REMOTE); + m2.setFrom(JidCreate.fullFrom(ACCOUNT, Resourcepart.from("junit"))); + m2.addExtension(new Body("Please answer")); + + this.transformer.transform( + MessageTransformation.of( + m2, Instant.now(), REMOTE, null, m2.getFrom().asBareJid(), null)); + + final var m3 = new Message(); + m3.setId("3"); + m3.setTo(REMOTE_2); + m3.setFrom(JidCreate.fullFrom(ACCOUNT, Resourcepart.from("junit"))); + m3.addExtension(new Body("Another message")); + + this.transformer.transform( + MessageTransformation.of( + m3, Instant.now(), REMOTE, null, m3.getFrom().asBareJid(), null)); + } } diff --git a/app/src/main/java/im/conversations/android/database/dao/ChatDao.java b/app/src/main/java/im/conversations/android/database/dao/ChatDao.java index 665ec914d..6a198f8b0 100644 --- a/app/src/main/java/im/conversations/android/database/dao/ChatDao.java +++ b/app/src/main/java/im/conversations/android/database/dao/ChatDao.java @@ -1,6 +1,7 @@ package im.conversations.android.database.dao; import androidx.lifecycle.LiveData; +import androidx.paging.PagingSource; import androidx.room.Dao; import androidx.room.Insert; import androidx.room.Query; @@ -10,6 +11,7 @@ import im.conversations.android.database.entity.ChatEntity; import im.conversations.android.database.entity.MucStatusCodeEntity; import im.conversations.android.database.model.Account; import im.conversations.android.database.model.ChatIdentifier; +import im.conversations.android.database.model.ChatOverviewItem; import im.conversations.android.database.model.ChatType; import im.conversations.android.database.model.GroupIdentifier; import im.conversations.android.database.model.MucState; @@ -129,7 +131,7 @@ public abstract class ChatDao { protected abstract List getChatsNotInBookmarks(long account, ChatType chatType); @Query( - "SELECT bookmark.address FROM bookmark WHERE bookmark.accountId=accountId AND" + "SELECT bookmark.address FROM bookmark WHERE bookmark.accountId=:account AND" + " bookmark.autoJoin=1 EXCEPT SELECT chat.address FROM chat WHERE" + " chat.accountId=:account AND chat.type=:chatType AND archived=0") protected abstract List getBookmarksNotInChats(long account, ChatType chatType); @@ -186,4 +188,25 @@ public abstract class ChatDao { @Query("DELETE FROM muc_status_code WHERE chatId=:chatId") protected abstract void deleteStatusCodes(final long chatId); + + // TODO select vCardPhoto for c.type='MUC_PM' + @Transaction + @Query( + "SELECT c.id,c.accountId,c.address,c.type,m.sentAt,m.outgoing,m.latestVersion as" + + " version,m.toBare,m.toResource,m.fromBare,m.fromResource,(SELECT count(id) FROM" + + " message WHERE chatId=c.id) as unread,(SELECT name FROM roster WHERE" + + " roster.address=c.address) as rosterName,(SELECT nick FROM nick WHERE" + + " nick.address=c.address) as nick,(SELECT identity.name FROM disco_item JOIN" + + " disco_identity identity ON disco_item.discoId=identity.discoId WHERE" + + " disco_item.address=c.address LIMIT 1) as discoIdentityName,(SELECT name FROM" + + " bookmark WHERE bookmark.address=c.address) as bookmarkName,(CASE WHEN" + + " c.type='MUC' THEN (SELECT vCardPhoto FROM presence WHERE address=c.address AND" + + " resource='') WHEN c.type='INDIVIDUAL' THEN (SELECT vCardPhoto FROM presence" + + " WHERE address=c.address AND vCardPhoto NOT NULL LIMIT 1) ELSE NULL END) as" + + " vCardPhoto,(SELECT thumb_id FROM avatar WHERE avatar.address=c.address) as" + + " avatar FROM CHAT c LEFT JOIN message m ON (c.id=m.chatId) LEFT OUTER JOIN" + + " message m2 ON (c.id = m2.chatId AND (m.receivedAt < m2.receivedAt OR" + + " (m.receivedAt = m2.receivedAt AND m.id < m2.id))) WHERE c.archived=0 AND m2.id" + + " IS NULL ORDER by m.receivedAt DESC") + public abstract PagingSource getChatOverview(); } diff --git a/app/src/main/java/im/conversations/android/database/model/ChatOverviewItem.java b/app/src/main/java/im/conversations/android/database/model/ChatOverviewItem.java new file mode 100644 index 000000000..61b02704a --- /dev/null +++ b/app/src/main/java/im/conversations/android/database/model/ChatOverviewItem.java @@ -0,0 +1,122 @@ +package im.conversations.android.database.model; + +import androidx.room.Relation; +import com.google.common.collect.Iterables; +import im.conversations.android.database.entity.MessageContentEntity; +import java.time.Instant; +import java.util.List; +import org.jxmpp.jid.Jid; +import org.jxmpp.jid.impl.JidCreate; + +public class ChatOverviewItem { + + public long id; + public long accountId; + public String address; + public ChatType type; + + public Instant sentAt; + + public boolean outgoing; + + public Jid toBare; + public String toResource; + public Jid fromBare; + public String fromResource; + public long version; + + public String rosterName; + public String nick; + public String discoIdentityName; + public String bookmarkName; + + public String vCardPhoto; + public String avatar; + + public int unread; + + @Relation( + entity = MessageContentEntity.class, + parentColumn = "version", + entityColumn = "messageVersionId") + public List contents; + + public String name() { + return switch (type) { + case MUC -> mucName(); + case INDIVIDUAL -> individualName(); + default -> address; + }; + } + + public String message() { + final var firstMessageContent = Iterables.getFirst(contents, null); + return firstMessageContent == null ? null : firstMessageContent.body; + } + + public Sender getSender() { + if (outgoing) { + return new SenderYou(); + } else if (type == ChatType.MUC) { + if (fromResource != null) { + return new SenderName(fromResource); + } else { + return null; + } + } else { + return null; + } + } + + private String individualName() { + if (notNullNotEmpty(rosterName)) { + return rosterName.trim(); + } + if (notNullNotEmpty(nick)) { + return nick.trim(); + } + return fallbackName(); + } + + private String fallbackName() { + final Jid jid = getJidAddress(); + if (jid == null) { + return this.address; + } + if (jid.hasLocalpart()) { + return jid.getLocalpartOrThrow().toString(); + } else { + return jid.toString(); + } + } + + private String mucName() { + if (notNullNotEmpty(this.bookmarkName)) { + return this.bookmarkName.trim(); + } + if (notNullNotEmpty(this.discoIdentityName)) { + return this.discoIdentityName.trim(); + } + return fallbackName(); + } + + private Jid getJidAddress() { + return address == null ? null : JidCreate.fromOrNull(address); + } + + private static boolean notNullNotEmpty(final String value) { + return value != null && !value.trim().isEmpty(); + } + + public sealed interface Sender permits SenderYou, SenderName {} + + public static final class SenderYou implements Sender {} + + public static final class SenderName implements Sender { + public final String name; + + public SenderName(String name) { + this.name = name; + } + } +} diff --git a/app/src/main/java/im/conversations/android/repository/ChatRepository.java b/app/src/main/java/im/conversations/android/repository/ChatRepository.java index 9050ed915..01e710c53 100644 --- a/app/src/main/java/im/conversations/android/repository/ChatRepository.java +++ b/app/src/main/java/im/conversations/android/repository/ChatRepository.java @@ -2,6 +2,8 @@ package im.conversations.android.repository; import android.content.Context; import androidx.lifecycle.LiveData; +import androidx.paging.PagingSource; +import im.conversations.android.database.model.ChatOverviewItem; import im.conversations.android.database.model.GroupIdentifier; import java.util.List; @@ -14,4 +16,8 @@ public class ChatRepository extends AbstractRepository { public LiveData> getGroups() { return this.database.chatDao().getGroups(); } + + public PagingSource getChatOverview() { + return this.database.chatDao().getChatOverview(); + } } diff --git a/app/src/main/java/im/conversations/android/ui/BindingAdapters.java b/app/src/main/java/im/conversations/android/ui/BindingAdapters.java index 9d9796fee..76172995f 100644 --- a/app/src/main/java/im/conversations/android/ui/BindingAdapters.java +++ b/app/src/main/java/im/conversations/android/ui/BindingAdapters.java @@ -1,15 +1,29 @@ package im.conversations.android.ui; +import android.content.Context; +import android.text.format.DateUtils; import android.view.KeyEvent; +import android.view.View; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.databinding.BindingAdapter; import androidx.lifecycle.LiveData; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; import com.google.common.base.Supplier; +import im.conversations.android.R; +import im.conversations.android.database.model.ChatOverviewItem; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; public class BindingAdapters { + private static final Duration SIX_HOURS = Duration.ofHours(6); + private static final Duration THREE_MONTH = Duration.ofDays(90); + @BindingAdapter("errorText") public static void setErrorText( final TextInputLayout textInputLayout, final LiveData error) { @@ -28,4 +42,63 @@ public class BindingAdapters { return true; }); } + + private static boolean sameYear(final Instant a, final Instant b) { + final ZoneId local = ZoneId.systemDefault(); + return LocalDateTime.ofInstant(a, local).getYear() + == LocalDateTime.ofInstant(b, local).getYear(); + } + + private static boolean sameDay(final Instant a, final Instant b) { + return a.truncatedTo(ChronoUnit.DAYS).equals(b.truncatedTo(ChronoUnit.DAYS)); + } + + @BindingAdapter("instant") + public static void setInstant(final TextView textView, final Instant instant) { + if (instant == null || instant.getEpochSecond() <= 0) { + textView.setVisibility(View.GONE); + } else { + final Context context = textView.getContext(); + final Instant now = Instant.now(); + textView.setVisibility(View.VISIBLE); + if (sameDay(instant, now) || now.minus(SIX_HOURS).isBefore(instant)) { + textView.setText( + DateUtils.formatDateTime( + context, + instant.getEpochSecond() * 1000, + DateUtils.FORMAT_SHOW_TIME)); + } else if (sameYear(instant, now) || now.minus(THREE_MONTH).isBefore(instant)) { + textView.setText( + DateUtils.formatDateTime( + context, + instant.getEpochSecond() * 1000, + DateUtils.FORMAT_SHOW_DATE + | DateUtils.FORMAT_NO_YEAR + | DateUtils.FORMAT_ABBREV_ALL)); + } else { + textView.setText( + DateUtils.formatDateTime( + context, + instant.getEpochSecond() * 1000, + DateUtils.FORMAT_SHOW_DATE + | DateUtils.FORMAT_NO_MONTH_DAY + | DateUtils.FORMAT_ABBREV_ALL)); + } + } + } + + @BindingAdapter("android:text") + public static void setSender(final TextView textView, final ChatOverviewItem.Sender sender) { + if (sender == null) { + textView.setVisibility(View.GONE); + } else { + if (sender instanceof ChatOverviewItem.SenderYou) { + textView.setText( + String.format("%s:", textView.getContext().getString(R.string.you))); + } else if (sender instanceof ChatOverviewItem.SenderName senderName) { + textView.setText(String.format("%s:", senderName.name)); + } + textView.setVisibility(View.VISIBLE); + } + } } diff --git a/app/src/main/java/im/conversations/android/ui/adapter/ChatOverviewAdapter.java b/app/src/main/java/im/conversations/android/ui/adapter/ChatOverviewAdapter.java new file mode 100644 index 000000000..c69699dc2 --- /dev/null +++ b/app/src/main/java/im/conversations/android/ui/adapter/ChatOverviewAdapter.java @@ -0,0 +1,51 @@ +package im.conversations.android.ui.adapter; + +import android.view.LayoutInflater; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.databinding.DataBindingUtil; +import androidx.paging.PagingDataAdapter; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; +import im.conversations.android.R; +import im.conversations.android.database.model.ChatOverviewItem; +import im.conversations.android.databinding.ItemChatoverviewBinding; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ChatOverviewAdapter + extends PagingDataAdapter { + + private static final Logger LOGGER = LoggerFactory.getLogger(ChatOverviewAdapter.class); + + public ChatOverviewAdapter(@NonNull DiffUtil.ItemCallback diffCallback) { + super(diffCallback); + } + + @NonNull + @Override + public ChatOverviewViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ChatOverviewViewHolder( + DataBindingUtil.inflate( + LayoutInflater.from(parent.getContext()), + R.layout.item_chatoverview, + parent, + false)); + } + + @Override + public void onBindViewHolder(@NonNull ChatOverviewViewHolder holder, int position) { + final var chatOverviewItem = getItem(position); + holder.binding.setChatOverviewItem(chatOverviewItem); + } + + public static class ChatOverviewViewHolder extends RecyclerView.ViewHolder { + + private final ItemChatoverviewBinding binding; + + public ChatOverviewViewHolder(@NonNull ItemChatoverviewBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + } +} diff --git a/app/src/main/java/im/conversations/android/ui/adapter/ChatOverviewComparator.java b/app/src/main/java/im/conversations/android/ui/adapter/ChatOverviewComparator.java new file mode 100644 index 000000000..60d4ef221 --- /dev/null +++ b/app/src/main/java/im/conversations/android/ui/adapter/ChatOverviewComparator.java @@ -0,0 +1,25 @@ +package im.conversations.android.ui.adapter; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import im.conversations.android.database.model.ChatOverviewItem; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ChatOverviewComparator extends DiffUtil.ItemCallback { + + private static final Logger LOGGER = LoggerFactory.getLogger(ChatOverviewComparator.class); + + @Override + public boolean areItemsTheSame( + @NonNull ChatOverviewItem oldItem, @NonNull ChatOverviewItem newItem) { + // LOGGER.info("areItemsTheSame({},{})", oldItem.id, newItem.id); + return oldItem.id == newItem.id; + } + + @Override + public boolean areContentsTheSame( + @NonNull ChatOverviewItem oldItem, @NonNull ChatOverviewItem newItem) { + return false; + } +} diff --git a/app/src/main/java/im/conversations/android/ui/fragment/main/OverviewFragment.java b/app/src/main/java/im/conversations/android/ui/fragment/main/OverviewFragment.java index b7425ebcc..8ee5a72fe 100644 --- a/app/src/main/java/im/conversations/android/ui/fragment/main/OverviewFragment.java +++ b/app/src/main/java/im/conversations/android/ui/fragment/main/OverviewFragment.java @@ -25,6 +25,8 @@ import im.conversations.android.databinding.FragmentOverviewBinding; import im.conversations.android.ui.Intents; import im.conversations.android.ui.activity.SettingsActivity; import im.conversations.android.ui.activity.SetupActivity; +import im.conversations.android.ui.adapter.ChatOverviewAdapter; +import im.conversations.android.ui.adapter.ChatOverviewComparator; import im.conversations.android.ui.model.OverviewViewModel; import java.util.List; import org.slf4j.Logger; @@ -82,6 +84,15 @@ public class OverviewFragment extends Fragment { .getChatFilterAvailable() .observe(getViewLifecycleOwner(), this::onChatFilterAvailable); this.configureDrawerLayoutToCloseOnBackPress(); + final var chatOverviewAdapter = new ChatOverviewAdapter(new ChatOverviewComparator()); + binding.chats.setAdapter(chatOverviewAdapter); + this.overviewViewModel + .getChats() + .observe( + getViewLifecycleOwner(), + pagingData -> { + chatOverviewAdapter.submitData(getLifecycle(), pagingData); + }); return binding.getRoot(); } diff --git a/app/src/main/java/im/conversations/android/ui/model/OverviewViewModel.java b/app/src/main/java/im/conversations/android/ui/model/OverviewViewModel.java index 714a780c4..1b584ab37 100644 --- a/app/src/main/java/im/conversations/android/ui/model/OverviewViewModel.java +++ b/app/src/main/java/im/conversations/android/ui/model/OverviewViewModel.java @@ -6,12 +6,19 @@ import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import androidx.lifecycle.MediatorLiveData; import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModelKt; +import androidx.paging.Pager; +import androidx.paging.PagingConfig; +import androidx.paging.PagingData; +import androidx.paging.PagingLiveData; import im.conversations.android.database.model.AccountIdentifier; import im.conversations.android.database.model.ChatFilter; +import im.conversations.android.database.model.ChatOverviewItem; import im.conversations.android.database.model.GroupIdentifier; import im.conversations.android.repository.AccountRepository; import im.conversations.android.repository.ChatRepository; import java.util.List; +import kotlinx.coroutines.CoroutineScope; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -65,4 +72,17 @@ public class OverviewViewModel extends AndroidViewModel { this.chatFilter = chatFilter; LOGGER.info("Setting chat filter to {}", chatFilter); } + + public LiveData> getChats() { + final Pager pager = + new Pager<>( + new PagingConfig(20), + () -> { + return this.chatRepository.getChatOverview(); + }); + + LiveData> foo = PagingLiveData.getLiveData(pager); + CoroutineScope viewModelScope = ViewModelKt.getViewModelScope(this); + return PagingLiveData.cachedIn(PagingLiveData.getLiveData(pager), viewModelScope); + } } diff --git a/app/src/main/res/layout/fragment_overview.xml b/app/src/main/res/layout/fragment_overview.xml index d2e220c18..be8dfd1e8 100644 --- a/app/src/main/res/layout/fragment_overview.xml +++ b/app/src/main/res/layout/fragment_overview.xml @@ -14,8 +14,11 @@ android:layout_height="match_parent"> diff --git a/app/src/main/res/layout/item_chatoverview.xml b/app/src/main/res/layout/item_chatoverview.xml new file mode 100644 index 000000000..0557910b0 --- /dev/null +++ b/app/src/main/res/layout/item_chatoverview.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fe08c3317..51a544018 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1046,5 +1046,6 @@ To continue compare this SHA-256 fingerprint with that of the server certificate The server certificate is not trustworthy. If you don’t know what this means it’s best to go back! Trust certificate + You diff --git a/build.gradle b/build.gradle index 7d6870180..c2352a174 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,7 @@ buildscript { roomVersion = "2.5.0" preferenceVersion = "1.2.0" espressoVersion = "3.5.1" + pagingVersion = "3.1.1" } repositories {