rudimentary ChatOverviewAdapter

This commit is contained in:
Daniel Gultsch 2023-03-06 18:55:02 +01:00
parent cfaf6162e6
commit 260654f171
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
14 changed files with 450 additions and 1 deletions

View file

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

View file

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

View file

@ -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<String> 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<Jid> 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<Integer, ChatOverviewItem> getChatOverview();
}

View file

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

View file

@ -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<List<GroupIdentifier>> getGroups() {
return this.database.chatDao().getGroups();
}
public PagingSource<Integer, ChatOverviewItem> getChatOverview() {
return this.database.chatDao().getChatOverview();
}
}

View file

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

View file

@ -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<ChatOverviewItem, ChatOverviewAdapter.ChatOverviewViewHolder> {
private static final Logger LOGGER = LoggerFactory.getLogger(ChatOverviewAdapter.class);
public ChatOverviewAdapter(@NonNull DiffUtil.ItemCallback<ChatOverviewItem> 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;
}
}
}

View file

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

View file

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

View file

@ -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<PagingData<ChatOverviewItem>> getChats() {
final Pager<Integer, ChatOverviewItem> pager =
new Pager<>(
new PagingConfig(20),
() -> {
return this.chatRepository.getChatOverview();
});
LiveData<PagingData<ChatOverviewItem>> foo = PagingLiveData.getLiveData(pager);
CoroutineScope viewModelScope = ViewModelKt.getViewModelScope(this);
return PagingLiveData.cachedIn(PagingLiveData.getLiveData(pager), viewModelScope);
}
}

View file

@ -14,8 +14,11 @@
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chats"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_chatoverview"
android:fillViewport="true"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />

View file

@ -0,0 +1,76 @@
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="72dp"
app:layout_constraintBottom_toBottomOf="@+id/name"
app:layout_constraintTop_toBottomOf="@+id/name">
<TextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:maxLines="1"
android:text="@{chatOverviewItem.name}"
android:textAppearance="?textAppearanceTitleMedium"
app:layout_constraintBottom_toTopOf="@+id/message"
app:layout_constraintEnd_toStartOf="@+id/sentAt"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.45"
tools:text="The City of Verona" />
<TextView
android:id="@+id/sender"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4sp"
android:text="@{chatOverviewItem.sender}"
android:textAppearance="?textAppearanceBodyMedium"
android:textColor="?colorTertiary"
android:visibility="visible"
app:layout_constraintBaseline_toBaselineOf="@+id/message"
app:layout_constraintEnd_toStartOf="@+id/message"
app:layout_constraintStart_toStartOf="@+id/name"
android:maxLines="1"
tools:text="You:" />
<TextView
android:id="@+id/message"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@{chatOverviewItem.message}"
android:textAppearance="?textAppearanceBodyMedium"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/sentAt"
app:layout_constraintStart_toEndOf="@+id/sender"
app:layout_constraintTop_toBottomOf="@+id/name"
tools:text="O Romeo, Romeo! Wherefore art thou Romeo?" />
<TextView
android:id="@+id/sentAt"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textAppearance="?textAppearanceLabelMedium"
app:instant="@{chatOverviewItem.sentAt}"
app:layout_constraintBaseline_toBaselineOf="@+id/name"
app:layout_constraintEnd_toEndOf="parent"
tools:text="23:24" />
</androidx.constraintlayout.widget.ConstraintLayout>
<data>
<import type="android.view.View" />
<variable
name="chatOverviewItem"
type="im.conversations.android.database.model.ChatOverviewItem" />
</data>
</layout>

View file

@ -1046,5 +1046,6 @@
<string name="trust_certificate_instructions">To continue compare this SHA-256 fingerprint with that of the server certificate</string>
<string name="trust_certificate_warning">The server certificate is not trustworthy. If you dont know what this means its best to go back!</string>
<string name="trust_cerficate">Trust certificate</string>
<string name="you">You</string>
</resources>

View file

@ -7,6 +7,7 @@ buildscript {
roomVersion = "2.5.0"
preferenceVersion = "1.2.0"
espressoVersion = "3.5.1"
pagingVersion = "3.1.1"
}
repositories {