diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java index 4280607ea2..7d82c760cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java @@ -26,7 +26,7 @@ import network.loki.messenger.R; public abstract class BaseActionBarActivity extends AppCompatActivity { private static final String TAG = BaseActionBarActivity.class.getSimpleName(); - private ThemeState currentThemeState; + public ThemeState currentThemeState; private TextSecurePreferences getPreferences() { ApplicationContext appContext = (ApplicationContext) getApplicationContext(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationClickListener.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationClickListener.kt new file mode 100644 index 0000000000..690ee78083 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationClickListener.kt @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.home + +import org.thoughtcrime.securesms.database.model.ThreadRecord + +interface ConversationClickListener { + fun onConversationClick(thread: ThreadRecord) + fun onLongConversationClick(thread: ThreadRecord) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 2544b2a1ef..ceb4d58648 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -93,8 +93,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private val publicKey: String get() = textSecurePreferences.getLocalNumber()!! - private val homeAdapter: NewHomeAdapter by lazy { - NewHomeAdapter(context = this, listener = this) + private val homeAdapter: HomeAdapter by lazy { + HomeAdapter(context = this, listener = this) } private val globalSearchAdapter = GlobalSearchAdapter { model -> @@ -172,23 +172,12 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), homeAdapter.glide = glide binding.recyclerView.adapter = homeAdapter binding.globalSearchRecycler.adapter = globalSearchAdapter + // Set up empty state view binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() } IP2Country.configureIfNeeded(this@HomeActivity) - homeViewModel.getObservable(this).observe(this) { newData -> - val manager = binding.recyclerView.layoutManager as LinearLayoutManager - val firstPos = manager.findFirstCompletelyVisibleItemPosition() - val offsetTop = if(firstPos >= 0) { - manager.findViewByPosition(firstPos)?.let { view -> - manager.getDecoratedTop(view) - manager.getTopDecorationHeight(view) - } ?: 0 - } else 0 - homeAdapter.data = newData - if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) } - setupMessageRequestsBanner() - updateEmptyState() - } - homeViewModel.tryUpdateChannel() + startObservingUpdates() + // Set up new conversation button binding.newConversationButton.setOnClickListener { showNewConversation() } // Observe blocked contacts changed events @@ -286,7 +275,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.searchToolbar.isVisible = isShown binding.sessionToolbar.isVisible = !isShown binding.recyclerView.isVisible = !isShown - binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as NewHomeAdapter).itemCount == 0 && binding.recyclerView.isVisible + binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as HomeAdapter).itemCount == 0 && binding.recyclerView.isVisible binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown binding.globalSearchRecycler.isVisible = isShown binding.newConversationButton.isVisible = !isShown @@ -335,11 +324,19 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity) } } + + // If the theme hasn't changed then start observing updates again (if it does change then we + // will recreate the activity resulting in it responding to changes multiple times) + if (currentThemeState == textSecurePreferences.themeState() && !homeViewModel.getObservable(this).hasActiveObservers()) { + startObservingUpdates() + } } override fun onPause() { super.onPause() ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(false) + + homeViewModel.getObservable(this).removeObservers(this) } override fun onDestroy() { @@ -353,6 +350,22 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // endregion // region Updating + private fun startObservingUpdates() { + homeViewModel.getObservable(this).observe(this) { newData -> + val manager = binding.recyclerView.layoutManager as LinearLayoutManager + val firstPos = manager.findFirstCompletelyVisibleItemPosition() + val offsetTop = if(firstPos >= 0) { + manager.findViewByPosition(firstPos)?.let { view -> + manager.getDecoratedTop(view) - manager.getTopDecorationHeight(view) + } ?: 0 + } else 0 + homeAdapter.data = newData + if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) } + setupMessageRequestsBanner() + updateEmptyState() + } + } + private fun updateEmptyState() { val threadCount = (binding.recyclerView.adapter)!!.itemCount binding.emptyStateContainer.isVisible = threadCount == 0 && binding.recyclerView.isVisible diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt index 690ee78083..3efa841b54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -1,8 +1,115 @@ package org.thoughtcrime.securesms.home +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.NO_ID import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.mms.GlideRequests + +class HomeAdapter( + private val context: Context, + private val listener: ConversationClickListener +) : RecyclerView.Adapter(), ListUpdateCallback { + + companion object { + private const val HEADER = 0 + private const val ITEM = 1 + } + + var header: View? = null + + private var _data: List = emptyList() + var data: List + get() = _data.toList() + set(newData) { + val previousData = _data.toList() + val diff = HomeDiffUtil(previousData, newData, context) + val diffResult = DiffUtil.calculateDiff(diff) + _data = newData + diffResult.dispatchUpdatesTo(this as ListUpdateCallback) + } + + fun hasHeaderView(): Boolean = header != null + + private val headerCount: Int + get() = if (header == null) 0 else 1 + + override fun onInserted(position: Int, count: Int) { + notifyItemRangeInserted(position + headerCount, count) + } + + override fun onRemoved(position: Int, count: Int) { + notifyItemRangeRemoved(position + headerCount, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + notifyItemMoved(fromPosition + headerCount, toPosition + headerCount) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + notifyItemRangeChanged(position + headerCount, count, payload) + } + + override fun getItemId(position: Int): Long { + if (hasHeaderView() && position == 0) return NO_ID + val offsetPosition = if (hasHeaderView()) position-1 else position + return _data[offsetPosition].threadId + } + + lateinit var glide: GlideRequests + var typingThreadIDs = setOf() + set(value) { + field = value + // TODO: replace this with a diffed update or a partial change set with payloads + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = + when (viewType) { + HEADER -> { + HeaderFooterViewHolder(header!!) + } + ITEM -> { + val view = ConversationView(context) + view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } } + view.setOnLongClickListener { + view.thread?.let { listener.onLongConversationClick(it) } + true + } + ViewHolder(view) + } + else -> throw Exception("viewType $viewType isn't valid") + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (holder is ViewHolder) { + val offset = if (hasHeaderView()) position - 1 else position + val thread = data[offset] + val isTyping = typingThreadIDs.contains(thread.threadId) + holder.view.bind(thread, isTyping, glide) + } + } + + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + if (holder is ViewHolder) { + holder.view.recycle() + } else { + super.onViewRecycled(holder) + } + } + + override fun getItemViewType(position: Int): Int = + if (hasHeaderView() && position == 0) HEADER + else ITEM + + override fun getItemCount(): Int = data.size + if (hasHeaderView()) 1 else 0 + + class ViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view) + + class HeaderFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) -interface ConversationClickListener { - fun onConversationClick(thread: ThreadRecord) - fun onLongConversationClick(thread: ThreadRecord) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index e68b30d6c2..cb3322e039 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -7,23 +7,22 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.cash.copper.flow.observeQuery import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import kotlinx.coroutines.withContext import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord +import java.lang.ref.WeakReference import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): ViewModel() { private val executor = viewModelScope + SupervisorJob() + private var lastContext: WeakReference? = null + private var updateJobs: MutableList = mutableListOf() private val _conversations = MutableLiveData>() val conversations: LiveData> = _conversations @@ -33,25 +32,38 @@ class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): V fun tryUpdateChannel() = listUpdateChannel.trySend(Unit) fun getObservable(context: Context): LiveData> { - executor.launch(Dispatchers.IO) { - context.contentResolver - .observeQuery(DatabaseContentProviders.ConversationList.CONTENT_URI) - .onEach { listUpdateChannel.trySend(Unit) } - .collect() - } - executor.launch(Dispatchers.IO) { - for (update in listUpdateChannel) { - threadDb.approvedConversationList.use { openCursor -> - val reader = threadDb.readerFor(openCursor) - val threads = mutableListOf() - while (true) { - threads += reader.next ?: break - } - withContext(Dispatchers.Main) { - _conversations.value = threads + // If the context has changed (eg. the activity gets recreated) then + // we need to cancel the old executors and recreate them to prevent + // the app from triggering extra updates when data changes + if (context != lastContext?.get()) { + lastContext = WeakReference(context) + updateJobs.forEach { it.cancel() } + updateJobs.clear() + + updateJobs.add( + executor.launch(Dispatchers.IO) { + context.contentResolver + .observeQuery(DatabaseContentProviders.ConversationList.CONTENT_URI) + .onEach { listUpdateChannel.trySend(Unit) } + .collect() + } + ) + updateJobs.add( + executor.launch(Dispatchers.IO) { + for (update in listUpdateChannel) { + threadDb.approvedConversationList.use { openCursor -> + val reader = threadDb.readerFor(openCursor) + val threads = mutableListOf() + while (true) { + threads += reader.next ?: break + } + withContext(Dispatchers.Main) { + _conversations.value = threads + } + } } } - } + ) } return conversations } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/NewHomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/NewHomeAdapter.kt deleted file mode 100644 index 850a5de69e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/home/NewHomeAdapter.kt +++ /dev/null @@ -1,114 +0,0 @@ -package org.thoughtcrime.securesms.home - -import android.content.Context -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListUpdateCallback -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.NO_ID -import org.thoughtcrime.securesms.database.model.ThreadRecord -import org.thoughtcrime.securesms.mms.GlideRequests - -class NewHomeAdapter(private val context: Context, private val listener: ConversationClickListener): - RecyclerView.Adapter(), - ListUpdateCallback { - - companion object { - private const val HEADER = 0 - private const val ITEM = 1 - } - - var header: View? = null - - private var _data: List = emptyList() - var data: List - get() = _data.toList() - set(newData) { - val previousData = _data.toList() - val diff = HomeDiffUtil(previousData, newData, context) - val diffResult = DiffUtil.calculateDiff(diff) - _data = newData - diffResult.dispatchUpdatesTo(this as ListUpdateCallback) - } - - fun hasHeaderView(): Boolean = header != null - - private val headerCount: Int - get() = if (header == null) 0 else 1 - - override fun onInserted(position: Int, count: Int) { - notifyItemRangeInserted(position + headerCount, count) - } - - override fun onRemoved(position: Int, count: Int) { - notifyItemRangeRemoved(position + headerCount, count) - } - - override fun onMoved(fromPosition: Int, toPosition: Int) { - notifyItemMoved(fromPosition + headerCount, toPosition + headerCount) - } - - override fun onChanged(position: Int, count: Int, payload: Any?) { - notifyItemRangeChanged(position + headerCount, count, payload) - } - - override fun getItemId(position: Int): Long { - if (hasHeaderView() && position == 0) return NO_ID - val offsetPosition = if (hasHeaderView()) position-1 else position - return _data[offsetPosition].threadId - } - - lateinit var glide: GlideRequests - var typingThreadIDs = setOf() - set(value) { - field = value - // TODO: replace this with a diffed update or a partial change set with payloads - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = - when (viewType) { - HEADER -> { - HeaderFooterViewHolder(header!!) - } - ITEM -> { - val view = ConversationView(context) - view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } } - view.setOnLongClickListener { - view.thread?.let { listener.onLongConversationClick(it) } - true - } - ViewHolder(view) - } - else -> throw Exception("viewType $viewType isn't valid") - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - if (holder is ViewHolder) { - val offset = if (hasHeaderView()) position - 1 else position - val thread = data[offset] - val isTyping = typingThreadIDs.contains(thread.threadId) - holder.view.bind(thread, isTyping, glide) - } - } - - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - if (holder is ViewHolder) { - holder.view.recycle() - } else { - super.onViewRecycled(holder) - } - } - - override fun getItemViewType(position: Int): Int = - if (hasHeaderView() && position == 0) HEADER - else ITEM - - override fun getItemCount(): Int = data.size + if (hasHeaderView()) 1 else 0 - - class ViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view) - - class HeaderFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) - -} \ No newline at end of file diff --git a/app/src/main/res/layout/default_group_chip.xml b/app/src/main/res/layout/default_group_chip.xml index 51a5689235..911d67234b 100644 --- a/app/src/main/res/layout/default_group_chip.xml +++ b/app/src/main/res/layout/default_group_chip.xml @@ -7,7 +7,7 @@ android:layout_height="wrap_content" android:layout_margin="4dp" android:ellipsize="end" - android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" + android:textAppearance="@style/TextAppearance.MaterialComponents.Body2" app:chipStrokeWidth="1dp" app:chipStrokeColor="?elementBorderColor" app:chipBackgroundColor="?dialog_background_color" diff --git a/app/src/main/res/layout/reactions_bottom_sheet_dialog_fragment.xml b/app/src/main/res/layout/reactions_bottom_sheet_dialog_fragment.xml index f089846eca..065ba5a622 100644 --- a/app/src/main/res/layout/reactions_bottom_sheet_dialog_fragment.xml +++ b/app/src/main/res/layout/reactions_bottom_sheet_dialog_fragment.xml @@ -9,7 +9,7 @@ android:paddingBottom="@dimen/react_with_any_emoji_bottom_sheet_dialog_fragment_tabs_height"> @style/TextAppearance.Session.Tab + +