diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 9aef9a68e8..ee1b51cfa0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -287,8 +287,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (hexEncodedSeed == null) { hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(this).hexEncodedPrivateKey // Legacy account } + + val appContext = applicationContext val loadFileContents: (String) -> String = { fileName -> - MnemonicUtilities.loadFileContents(this, fileName) + MnemonicUtilities.loadFileContents(appContext, fileName) } MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 209e7f187d..8f93d823c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -881,6 +881,10 @@ public class ThreadDatabase extends Database { this.cursor = cursor; } + public int getLength() { + return cursor == null ? 0 : cursor.getCount(); + } + public ThreadRecord getNext() { if (cursor == null || !cursor.moveToNext()) return null; 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 ccfa16beef..980550cce2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -24,7 +24,9 @@ import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -81,7 +83,6 @@ import org.thoughtcrime.securesms.util.IP2Country import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show -import org.thoughtcrime.securesms.util.themeState import java.io.IOException import java.util.Locale import javax.inject.Inject @@ -99,7 +100,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private lateinit var binding: ActivityHomeBinding private lateinit var glide: GlideRequests - private var broadcastReceiver: BroadcastReceiver? = null @Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @@ -205,18 +205,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // Set up empty state view binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() } IP2Country.configureIfNeeded(this@HomeActivity) - startObservingUpdates() + + ApplicationContext.getInstance(this@HomeActivity).typingStatusRepository.typingThreads.observe(this) { threadIds -> + homeAdapter.typingThreadIDs = (threadIds ?: setOf()) + } // Set up new conversation button binding.newConversationButton.setOnClickListener { showNewConversation() } // Observe blocked contacts changed events - val broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - binding.recyclerView.adapter!!.notifyDataSetChanged() - } - } - this.broadcastReceiver = broadcastReceiver - LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged")) // subscribe to outdated config updates, this should be removed after long enough time for device migration lifecycleScope.launch { @@ -227,6 +223,27 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } + // Subscribe to threads and update the UI + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + homeViewModel.threads + .filterNotNull() // We don't actually want the null value here as it indicates a loading state (maybe we need a loading state?) + .collectLatest { threads -> + 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 = threads + if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) } + setupMessageRequestsBanner() + updateEmptyState() + } + } + } + lifecycleScope.launchWhenStarted { launch(Dispatchers.IO) { // Double check that the long poller is up @@ -385,52 +402,20 @@ 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() { - val broadcastReceiver = this.broadcastReceiver - if (broadcastReceiver != null) { - LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver) - } super.onDestroy() EventBus.getDefault().unregister(this) } // 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() - } - - ApplicationContext.getInstance(this@HomeActivity).typingStatusRepository.typingThreads.observe(this) { threadIds -> - homeAdapter.typingThreadIDs = (threadIds ?: setOf()) - } - } - 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 eaf242aae3..d7f887ed00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -26,15 +26,14 @@ class HomeAdapter( var header: View? = null - private var _data: List = emptyList() - var data: List - get() = _data.toList() + var data: List = emptyList() set(newData) { - val previousData = _data.toList() - val diff = HomeDiffUtil(previousData, newData, context, configFactory) - val diffResult = DiffUtil.calculateDiff(diff) - _data = newData - diffResult.dispatchUpdatesTo(this as ListUpdateCallback) + if (field !== newData) { + val diff = HomeDiffUtil(field, newData, context, configFactory) + val diffResult = DiffUtil.calculateDiff(diff) + field = newData + diffResult.dispatchUpdatesTo(this as ListUpdateCallback) + } } fun hasHeaderView(): Boolean = header != null @@ -61,7 +60,7 @@ class HomeAdapter( 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 + return data[offsetPosition].threadId } lateinit var glide: GlideRequests 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 672d2e0a1f..8402f0f690 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -1,80 +1,67 @@ package org.thoughtcrime.securesms.home import android.content.Context -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.cash.copper.flow.observeQuery import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.onEach +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +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 org.thoughtcrime.securesms.util.observeChanges import javax.inject.Inject @HiltViewModel -class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): ViewModel() { +class HomeViewModel @Inject constructor( + private val threadDb: ThreadDatabase, + @ApplicationContext appContext: Context, +) : ViewModel() { + // SharedFlow that emits whenever the user asks us to reload the conversation + private val manualReloadTrigger = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) - private val executor = viewModelScope + SupervisorJob() - private var lastContext: WeakReference? = null - private val updateJobs: MutableList = mutableListOf() - - private val _conversations = MutableLiveData>() - val conversations: LiveData> = _conversations - - private val listUpdateChannel = Channel(capacity = Channel.CONFLATED) - - fun tryUpdateChannel() = listUpdateChannel.trySend(Unit) - - override fun onCleared() { - super.onCleared() - - for (job in updateJobs) { - job.cancel() - } - updateJobs.clear() - } - - fun getObservable(context: Context): LiveData> { - // 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() + /** + * A [StateFlow] that emits the list of threads in the conversation list. + * + * This flow will emit whenever the user asks us to reload the conversation list or + * whenever the conversation list changes. + */ + @Suppress("OPT_IN_USAGE") + val threads: StateFlow?> = merge( + manualReloadTrigger, + appContext.contentResolver.observeChanges(DatabaseContentProviders.ConversationList.CONTENT_URI)) + .debounce(CHANGE_NOTIFICATION_DEBOUNCE_MILLS) + .onStart { emit(Unit) } + .mapLatest { _ -> + withContext(Dispatchers.IO) { + threadDb.approvedConversationList.use { openCursor -> + val reader = threadDb.readerFor(openCursor) + buildList(reader.length) { while (true) { - threads += reader.next ?: break - } - withContext(Dispatchers.Main) { - _conversations.value = threads + add(reader.next ?: break) } } } } - ) - } - return conversations - } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) -} \ No newline at end of file + fun tryUpdateChannel() = manualReloadTrigger.tryEmit(Unit) + + companion object { + private const val CHANGE_NOTIFICATION_DEBOUNCE_MILLS = 100L + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt new file mode 100644 index 0000000000..0b89a3edf9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.util + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import android.os.Handler +import android.os.Looper +import androidx.annotation.CheckResult +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Observe changes to a content URI. This function will emit the URI whenever the content or + * its descendants change, according to the parameter [notifyForDescendants]. + */ +@CheckResult +fun ContentResolver.observeChanges(uri: Uri, notifyForDescendants: Boolean = false): Flow { + return callbackFlow { + val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + trySend(uri) + } + } + + registerContentObserver(uri, notifyForDescendants, observer) + awaitClose { + unregisterContentObserver(observer) + } + } +}