From 75e53c86b124122873fc76239b386f37dcdf9ce0 Mon Sep 17 00:00:00 2001 From: fanchao Date: Tue, 21 May 2024 11:43:25 +1000 Subject: [PATCH 1/7] Fixes SES-1936 --- .../attachments/AttachmentServer.java | 2 +- .../components/ProfilePictureView.kt | 2 +- .../conversation/v2/ConversationActivityV2.kt | 1 + .../v2/input_bar/InputBarRecordingView.kt | 7 ++++++- .../glide/PlaceholderAvatarLoader.kt | 9 +++++---- .../securesms/home/HomeViewModel.kt | 11 ++++++++++- .../securesms/home/PathActivity.kt | 19 +++++++++++-------- .../securesms/mms/SignalGlideModule.java | 2 +- .../thoughtcrime/securesms/util/IP2Country.kt | 2 +- .../avatars/PlaceholderAvatarPhoto.kt | 5 +---- .../utilities/recipients/Recipient.java | 6 +++--- 11 files changed, 41 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java index e186007ee3..176a8c290f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java @@ -50,7 +50,7 @@ public class AttachmentServer implements Runnable { throws IOException { try { - this.context = context; + this.context = context.getApplicationContext(); this.attachment = attachment; this.socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1})); this.port = socket.getLocalPort(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt index 9a5eb730de..52e2d52ab1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -122,7 +122,7 @@ class ProfilePictureView @JvmOverloads constructor( glide.clear(imageView) - val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") + val placeholder = PlaceholderAvatarPhoto(publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") if (signalProfilePicture != null && avatar != "0" && avatar != "") { glide.load(signalProfilePicture) 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 84f43e014b..9aef9a68e8 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 @@ -833,6 +833,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onDestroy() { viewModel.saveDraft(binding?.inputBar?.text?.trim() ?: "") + cancelVoiceMessage() tearDownRecipientObserver() super.onDestroy() binding = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt index ec45b6ca82..14ac2cb99b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt @@ -132,7 +132,12 @@ class InputBarRecordingView : RelativeLayout { private fun updateTimer() { val duration = (Date().time - startTimestamp) / 1000L binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration) - snHandler.postDelayed({ updateTimer() }, 500) + + if (isAttachedToWindow) { + // Should only update the timer if the view is still attached to the window. + // Otherwise, the timer will keep running even after the view is detached. + snHandler.postDelayed({ updateTimer() }, 500) + } } fun lock() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt index 69c9b8c4f5..b163b5ed90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.glide +import android.content.Context import android.graphics.drawable.BitmapDrawable import com.bumptech.glide.load.Options import com.bumptech.glide.load.model.ModelLoader @@ -8,7 +9,7 @@ import com.bumptech.glide.load.model.ModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory import org.session.libsession.avatars.PlaceholderAvatarPhoto -class PlaceholderAvatarLoader(): ModelLoader { +class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader { override fun buildLoadData( model: PlaceholderAvatarPhoto, @@ -16,14 +17,14 @@ class PlaceholderAvatarLoader(): ModelLoader { - return LoadData(model, PlaceholderAvatarFetcher(model.context, model)) + return LoadData(model, PlaceholderAvatarFetcher(appContext, model)) } override fun handles(model: PlaceholderAvatarPhoto): Boolean = true - class Factory() : ModelLoaderFactory { + class Factory(private val appContext: Context) : ModelLoaderFactory { override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { - return PlaceholderAvatarLoader() + return PlaceholderAvatarLoader(appContext) } override fun teardown() {} } 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 cb3322e039..672d2e0a1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -22,7 +22,7 @@ class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): V private val executor = viewModelScope + SupervisorJob() private var lastContext: WeakReference? = null - private var updateJobs: MutableList = mutableListOf() + private val updateJobs: MutableList = mutableListOf() private val _conversations = MutableLiveData>() val conversations: LiveData> = _conversations @@ -31,6 +31,15 @@ class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): V 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt index 1f3f2ff537..f100e06c0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt @@ -240,19 +240,22 @@ class PathActivity : PassphraseRequiredActionBarActivity() { dotViewLayoutParams.addRule(CENTER_IN_PARENT) dotView.layoutParams = dotViewLayoutParams addView(dotView) - Handler().postDelayed({ + postDelayed({ performAnimation() }, dotAnimationStartDelay) } private fun performAnimation() { - expand() - Handler().postDelayed({ - collapse() - Handler().postDelayed({ - performAnimation() - }, dotAnimationRepeatInterval) - }, 1000) + if (isAttachedToWindow) { + expand() + + postDelayed({ + collapse() + postDelayed({ + performAnimation() + }, dotAnimationRepeatInterval) + }, 1000) + } } private fun expand() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java index 0a24c26fad..02172b7248 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java @@ -73,7 +73,7 @@ public class SignalGlideModule extends AppGlideModule { registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context)); registry.append(AttachmentModel.class, InputStream.class, new AttachmentStreamUriLoader.Factory()); registry.append(ChunkedImageUrl.class, InputStream.class, new ChunkedImageUrlLoader.Factory()); - registry.append(PlaceholderAvatarPhoto.class, BitmapDrawable.class, new PlaceholderAvatarLoader.Factory()); + registry.append(PlaceholderAvatarPhoto.class, BitmapDrawable.class, new PlaceholderAvatarLoader.Factory(context)); registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt index 479a54fafa..bc76b80f2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt @@ -55,7 +55,7 @@ class IP2Country private constructor(private val context: Context) { public fun configureIfNeeded(context: Context) { if (isInitialized) { return; } - shared = IP2Country(context) + shared = IP2Country(context.applicationContext) } } diff --git a/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt b/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt index 0fcbe36e90..916e9112de 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt +++ b/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt @@ -1,13 +1,10 @@ package org.session.libsession.avatars -import android.content.Context import com.bumptech.glide.load.Key import java.security.MessageDigest -class PlaceholderAvatarPhoto(val context: Context, - val hashString: String, +class PlaceholderAvatarPhoto(val hashString: String, val displayName: String): Key { - override fun updateDiskCacheKey(messageDigest: MessageDigest) { messageDigest.update(hashString.encodeToByteArray()) messageDigest.update(displayName.encodeToByteArray()) diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java index 094a9fc349..0601f3c1e9 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java +++ b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java @@ -70,7 +70,7 @@ public class Recipient implements RecipientModifiedListener { private final @NonNull Address address; private final @NonNull List participants = new LinkedList<>(); - private Context context; + private final Context context; private @Nullable String name; private @Nullable String customLabel; private boolean resolving; @@ -132,7 +132,7 @@ public class Recipient implements RecipientModifiedListener { @NonNull Optional details, @NonNull ListenableFutureTask future) { - this.context = context; + this.context = context.getApplicationContext(); this.address = address; this.color = null; this.resolving = true; @@ -259,7 +259,7 @@ public class Recipient implements RecipientModifiedListener { } Recipient(@NonNull Context context, @NonNull Address address, @NonNull RecipientDetails details) { - this.context = context; + this.context = context.getApplicationContext(); this.address = address; this.contactUri = details.contactUri; this.name = details.name; From c7c0519a20f85341ce06409dcd3ce46120c1cdb3 Mon Sep 17 00:00:00 2001 From: fanchao Date: Wed, 22 May 2024 10:54:56 +1000 Subject: [PATCH 2/7] Feedback --- .../v2/input_bar/InputBarRecordingView.kt | 20 +++++++++++++++---- .../securesms/home/PathActivity.kt | 13 +++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt index 14ac2cb99b..39d2c43645 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt @@ -4,8 +4,6 @@ import android.animation.FloatEvaluator import android.animation.IntEvaluator import android.animation.ValueAnimator import android.content.Context -import android.os.Handler -import android.os.Looper import android.util.AttributeSet import android.view.LayoutInflater import android.widget.ImageView @@ -25,10 +23,12 @@ import java.util.Date class InputBarRecordingView : RelativeLayout { private lateinit var binding: ViewInputBarRecordingBinding private var startTimestamp = 0L - private val snHandler = Handler(Looper.getMainLooper()) private var dotViewAnimation: ValueAnimator? = null private var pulseAnimation: ValueAnimator? = null var delegate: InputBarRecordingViewDelegate? = null + private val updateTimerRunnable = Runnable { + updateTimer() + } val lockView: LinearLayout get() = binding.lockView @@ -134,9 +134,21 @@ class InputBarRecordingView : RelativeLayout { binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration) if (isAttachedToWindow) { + // Make sure there's only one runnable in the handler at a time. + removeCallbacks(updateTimerRunnable) + // Should only update the timer if the view is still attached to the window. // Otherwise, the timer will keep running even after the view is detached. - snHandler.postDelayed({ updateTimer() }, 500) + postDelayed(updateTimerRunnable, 500) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + if (isVisible) { + // If the view was visible (i.e. recording) when it was detached, start the timer again. + updateTimer() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt index f100e06c0c..f6df7e99e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt @@ -6,7 +6,6 @@ import android.content.Intent import android.content.IntentFilter import android.net.Uri import android.os.Bundle -import android.os.Handler import android.util.AttributeSet import android.util.TypedValue import android.view.Gravity @@ -21,7 +20,6 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ActivityPathBinding import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.utilities.getColorFromAttr -import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.util.GlowViewUtilities @@ -240,9 +238,6 @@ class PathActivity : PassphraseRequiredActionBarActivity() { dotViewLayoutParams.addRule(CENTER_IN_PARENT) dotView.layoutParams = dotViewLayoutParams addView(dotView) - postDelayed({ - performAnimation() - }, dotAnimationStartDelay) } private fun performAnimation() { @@ -258,6 +253,14 @@ class PathActivity : PassphraseRequiredActionBarActivity() { } } + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + postDelayed({ + performAnimation() + }, dotAnimationStartDelay) + } + private fun expand() { dotView.animateSizeChange(R.dimen.path_row_dot_size, R.dimen.path_row_expanded_dot_size) @ColorRes val startColorID = if (UiModeUtilities.isDayUiMode(context)) R.color.transparent_black_30 else R.color.black From 90f0caebbd419eacff05b4d3bcb58ebd8a1f30d2 Mon Sep 17 00:00:00 2001 From: fanchao Date: Thu, 23 May 2024 13:48:06 +1000 Subject: [PATCH 3/7] Tidy up --- .../conversation/v2/ConversationActivityV2.kt | 4 +- .../securesms/database/ThreadDatabase.java | 4 + .../securesms/home/HomeActivity.kt | 69 +++++------ .../securesms/home/HomeAdapter.kt | 17 ++- .../securesms/home/HomeViewModel.kt | 109 ++++++++---------- .../securesms/util/ContentResolverUtils.kt | 31 +++++ 6 files changed, 121 insertions(+), 113 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt 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) + + fun tryUpdateChannel() = manualReloadTrigger.tryEmit(Unit) -} \ No newline at end of file + 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) + } + } +} From c1d82cc57466fdf805cfef343546291422c6102c Mon Sep 17 00:00:00 2001 From: fanchao Date: Thu, 23 May 2024 13:49:38 +1000 Subject: [PATCH 4/7] Naming --- .../org/thoughtcrime/securesms/home/HomeActivity.kt | 10 +++------- .../org/thoughtcrime/securesms/home/HomeViewModel.kt | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) 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 980550cce2..1ade41587e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -2,12 +2,9 @@ package org.thoughtcrime.securesms.home import android.Manifest import android.app.NotificationManager -import android.content.BroadcastReceiver import android.content.ClipData import android.content.ClipboardManager -import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.os.Build import android.os.Bundle import android.text.SpannableString @@ -18,7 +15,6 @@ import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint @@ -426,7 +422,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), if (event.recipient.isLocalNumber) { updateProfileButton() } else { - homeViewModel.tryUpdateChannel() + homeViewModel.tryReload() } } @@ -597,7 +593,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun setConversationPinned(threadId: Long, pinned: Boolean) { lifecycleScope.launch(Dispatchers.IO) { storage.setPinned(threadId, pinned) - homeViewModel.tryUpdateChannel() + homeViewModel.tryReload() } } @@ -673,7 +669,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), button(R.string.yes) { textSecurePreferences.setHasHiddenMessageRequests() setupMessageRequestsBanner() - homeViewModel.tryUpdateChannel() + homeViewModel.tryReload() } button(R.string.no) } 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 8402f0f690..093cf6e21c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -59,7 +59,7 @@ class HomeViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.Eagerly, null) - fun tryUpdateChannel() = manualReloadTrigger.tryEmit(Unit) + fun tryReload() = manualReloadTrigger.tryEmit(Unit) companion object { private const val CHANGE_NOTIFICATION_DEBOUNCE_MILLS = 100L From 31f4de22cd7ed8594be54e01516df1883d995df1 Mon Sep 17 00:00:00 2001 From: fanchao Date: Thu, 23 May 2024 14:17:38 +1000 Subject: [PATCH 5/7] More changes --- .../securesms/home/HomeActivity.kt | 4 -- .../securesms/home/HomeAdapter.kt | 19 ++---- .../securesms/home/HomeDiffUtil.kt | 17 ++--- .../securesms/home/HomeViewModel.kt | 63 +++++++++++++------ 4 files changed, 58 insertions(+), 45 deletions(-) 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 1ade41587e..ada4ceda66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -202,10 +202,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() } IP2Country.configureIfNeeded(this@HomeActivity) - 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 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 d7f887ed00..071e421bf8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -9,7 +9,6 @@ import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID import network.loki.messenger.R -import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.mms.GlideRequests @@ -26,7 +25,7 @@ class HomeAdapter( var header: View? = null - var data: List = emptyList() + var data: HomeViewModel.HomeData = HomeViewModel.HomeData(emptyList(), emptySet()) set(newData) { if (field !== newData) { val diff = HomeDiffUtil(field, newData, context, configFactory) @@ -60,18 +59,10 @@ 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.threads[offsetPosition].threadId } lateinit var glide: GlideRequests - var typingThreadIDs = setOf() - set(value) { - if (field == value) { return } - - 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) { @@ -94,8 +85,8 @@ class HomeAdapter( override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { if (holder is ConversationViewHolder) { val offset = if (hasHeaderView()) position - 1 else position - val thread = data[offset] - val isTyping = typingThreadIDs.contains(thread.threadId) + val thread = data.threads[offset] + val isTyping = data.typingThreadIDs.contains(thread.threadId) holder.view.bind(thread, isTyping, glide) } } @@ -112,7 +103,7 @@ class HomeAdapter( if (hasHeaderView() && position == 0) HEADER else ITEM - override fun getItemCount(): Int = data.size + if (hasHeaderView()) 1 else 0 + override fun getItemCount(): Int = data.threads.size + if (hasHeaderView()) 1 else 0 class ConversationViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt index 0fe93d41de..41b7e581fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt @@ -7,22 +7,22 @@ import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.getConversationUnread class HomeDiffUtil( - private val old: List, - private val new: List, + private val old: HomeViewModel.HomeData, + private val new: HomeViewModel.HomeData, private val context: Context, private val configFactory: ConfigFactory ): DiffUtil.Callback() { - override fun getOldListSize(): Int = old.size + override fun getOldListSize(): Int = old.threads.size - override fun getNewListSize(): Int = new.size + override fun getNewListSize(): Int = new.threads.size override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = - old[oldItemPosition].threadId == new[newItemPosition].threadId + old.threads[oldItemPosition].threadId == new.threads[newItemPosition].threadId override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val oldItem = old[oldItemPosition] - val newItem = new[newItemPosition] + val oldItem = old.threads[oldItemPosition] + val newItem = new.threads[newItemPosition] // return early to save getDisplayBody or expensive calls var isSameItem = true @@ -47,7 +47,8 @@ class HomeDiffUtil( oldItem.isSent == newItem.isSent && oldItem.isPending == newItem.isPending && oldItem.lastSeen == newItem.lastSeen && - configFactory.convoVolatile?.getConversationUnread(newItem) != true + configFactory.convoVolatile?.getConversationUnread(newItem) != true && + old.typingThreadIDs.contains(oldItem.threadId) == new.typingThreadIDs.contains(newItem.threadId) ) } 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 093cf6e21c..0e4817994c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -1,31 +1,37 @@ package org.thoughtcrime.securesms.home +import android.content.ContentResolver import android.content.Context import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -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.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged 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.ApplicationContext import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.util.observeChanges import javax.inject.Inject +import dagger.hilt.android.qualifiers.ApplicationContext as ApplicationContextQualifier @HiltViewModel class HomeViewModel @Inject constructor( private val threadDb: ThreadDatabase, - @ApplicationContext appContext: Context, + contentResolver: ContentResolver, + @ApplicationContextQualifier context: Context, ) : ViewModel() { // SharedFlow that emits whenever the user asks us to reload the conversation private val manualReloadTrigger = MutableSharedFlow( @@ -34,33 +40,52 @@ class HomeViewModel @Inject constructor( ) /** - * A [StateFlow] that emits the list of threads in the conversation list. + * A [StateFlow] that emits the list of threads and the typing status of each thread. * * 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) { - add(reader.next ?: break) + val threads: StateFlow = + combine( + // The conversation list data + merge( + manualReloadTrigger, + 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) { + add(reader.next ?: break) + } + } + } } - } - } - } - } + }, + + // The typing status of each thread + ApplicationContext.getInstance(context).typingStatusRepository + .typingThreads + .asFlow() + .onStart { emit(emptySet()) } + .distinctUntilChanged(), + + // The final result that we emit to the UI + ::HomeData + ) .stateIn(viewModelScope, SharingStarted.Eagerly, null) fun tryReload() = manualReloadTrigger.tryEmit(Unit) + data class HomeData( + val threads: List, + val typingThreadIDs: Set + ) + companion object { private const val CHANGE_NOTIFICATION_DEBOUNCE_MILLS = 100L } From c0128b88de718e10c20dcb9df930e4c90197074d Mon Sep 17 00:00:00 2001 From: fanchao Date: Mon, 27 May 2024 10:29:53 +1000 Subject: [PATCH 6/7] OOM feedback --- .../securesms/database/ThreadDatabase.java | 2 +- .../securesms/home/HomeAdapter.kt | 14 ++-- .../securesms/home/HomeDiffUtil.kt | 9 ++- .../securesms/home/HomeViewModel.kt | 68 +++++++++---------- .../securesms/util/ContentResolverUtils.kt | 2 +- 5 files changed, 46 insertions(+), 49 deletions(-) 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 8f93d823c2..f5c6da5fb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -881,7 +881,7 @@ public class ThreadDatabase extends Database { this.cursor = cursor; } - public int getLength() { + public int getCount() { return cursor == null ? 0 : cursor.getCount(); } 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 071e421bf8..63e0fed5b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -25,14 +25,16 @@ class HomeAdapter( var header: View? = null - var data: HomeViewModel.HomeData = HomeViewModel.HomeData(emptyList(), emptySet()) + var data: HomeViewModel.Data = HomeViewModel.Data(emptyList(), emptySet()) set(newData) { - if (field !== newData) { - val diff = HomeDiffUtil(field, newData, context, configFactory) - val diffResult = DiffUtil.calculateDiff(diff) - field = newData - diffResult.dispatchUpdatesTo(this as ListUpdateCallback) + if (field === newData) { + return } + + val diff = HomeDiffUtil(field, newData, context, configFactory) + val diffResult = DiffUtil.calculateDiff(diff) + field = newData + diffResult.dispatchUpdatesTo(this as ListUpdateCallback) } fun hasHeaderView(): Boolean = header != null diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt index 41b7e581fb..89f02ee21a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt @@ -2,15 +2,14 @@ package org.thoughtcrime.securesms.home import android.content.Context import androidx.recyclerview.widget.DiffUtil -import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.getConversationUnread class HomeDiffUtil( - private val old: HomeViewModel.HomeData, - private val new: HomeViewModel.HomeData, - private val context: Context, - private val configFactory: ConfigFactory + private val old: HomeViewModel.Data, + private val new: HomeViewModel.Data, + private val context: Context, + private val configFactory: ConfigFactory ): DiffUtil.Callback() { override fun getOldListSize(): Int = old.threads.size 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 0e4817994c..dccebd4156 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -30,8 +31,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext as ApplicationContextQu @HiltViewModel class HomeViewModel @Inject constructor( private val threadDb: ThreadDatabase, - contentResolver: ContentResolver, - @ApplicationContextQualifier context: Context, + private val contentResolver: ContentResolver, + @ApplicationContextQualifier private val context: Context, ) : ViewModel() { // SharedFlow that emits whenever the user asks us to reload the conversation private val manualReloadTrigger = MutableSharedFlow( @@ -45,45 +46,40 @@ class HomeViewModel @Inject constructor( * 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 = - combine( - // The conversation list data - merge( - manualReloadTrigger, - 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) { - add(reader.next ?: break) - } - } - } - } - }, + val threads: StateFlow = combine(observeConversationList(), observeTypingStatus(), ::Data) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) - // The typing status of each thread - ApplicationContext.getInstance(context).typingStatusRepository - .typingThreads - .asFlow() - .onStart { emit(emptySet()) } - .distinctUntilChanged(), + private fun observeTypingStatus(): Flow> = + ApplicationContext.getInstance(context).typingStatusRepository + .typingThreads + .asFlow() + .onStart { emit(emptySet()) } + .distinctUntilChanged() - // The final result that we emit to the UI - ::HomeData - ) - .stateIn(viewModelScope, SharingStarted.Eagerly, null) + @Suppress("OPT_IN_USAGE") + private fun observeConversationList(): Flow> = merge( + manualReloadTrigger, + 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.count) { + while (true) { + add(reader.next ?: break) + } + } + } + } + } fun tryReload() = manualReloadTrigger.tryEmit(Unit) - data class HomeData( - val threads: List, - val typingThreadIDs: Set + data class Data( + val threads: List, + val typingThreadIDs: Set ) companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt index 0b89a3edf9..f228eb57a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt @@ -11,7 +11,7 @@ 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 + * 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 From 2d7f23a2fbf879065bbfd8ef44fc73ac5b4fb1f8 Mon Sep 17 00:00:00 2001 From: fanchao Date: Mon, 27 May 2024 13:18:51 +1000 Subject: [PATCH 7/7] More work on animation views --- .../conversation/v2/ConversationActivityV2.kt | 2 +- .../v2/input_bar/InputBarRecordingView.kt | 55 +++++++++---------- .../securesms/home/PathActivity.kt | 53 +++++++++++++----- 3 files changed, 66 insertions(+), 44 deletions(-) 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 ee1b51cfa0..1f075e99dc 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 @@ -1024,7 +1024,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun showVoiceMessageUI() { - binding?.inputBarRecordingView?.show() + binding?.inputBarRecordingView?.show(lifecycleScope) binding?.inputBar?.alpha = 0.0f val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) animation.duration = 250L diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt index 39d2c43645..6d7281dc47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt @@ -12,6 +12,11 @@ import android.widget.RelativeLayout import android.widget.TextView import androidx.core.content.res.ResourcesCompat import androidx.core.view.isVisible +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ViewInputBarRecordingBinding import org.thoughtcrime.securesms.util.DateUtils @@ -26,9 +31,7 @@ class InputBarRecordingView : RelativeLayout { private var dotViewAnimation: ValueAnimator? = null private var pulseAnimation: ValueAnimator? = null var delegate: InputBarRecordingViewDelegate? = null - private val updateTimerRunnable = Runnable { - updateTimer() - } + private var timerJob: Job? = null val lockView: LinearLayout get() = binding.lockView @@ -50,9 +53,10 @@ class InputBarRecordingView : RelativeLayout { binding = ViewInputBarRecordingBinding.inflate(LayoutInflater.from(context), this, true) binding.inputBarMiddleContentContainer.disableClipping() binding.inputBarCancelButton.setOnClickListener { hide() } + } - fun show() { + fun show(scope: CoroutineScope) { startTimestamp = Date().time binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_microphone, context.theme)) binding.inputBarCancelButton.alpha = 0.0f @@ -69,7 +73,7 @@ class InputBarRecordingView : RelativeLayout { animateDotView() pulse() animateLockViewUp() - updateTimer() + startTimer(scope) } fun hide() { @@ -86,6 +90,24 @@ class InputBarRecordingView : RelativeLayout { } animation.start() delegate?.handleVoiceMessageUIHidden() + stopTimer() + } + + private fun startTimer(scope: CoroutineScope) { + timerJob?.cancel() + timerJob = scope.launch { + while (isActive) { + val duration = (Date().time - startTimestamp) / 1000L + binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration) + + delay(500) + } + } + } + + private fun stopTimer() { + timerJob?.cancel() + timerJob = null } private fun animateDotView() { @@ -129,29 +151,6 @@ class InputBarRecordingView : RelativeLayout { animation.start() } - private fun updateTimer() { - val duration = (Date().time - startTimestamp) / 1000L - binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration) - - if (isAttachedToWindow) { - // Make sure there's only one runnable in the handler at a time. - removeCallbacks(updateTimerRunnable) - - // Should only update the timer if the view is still attached to the window. - // Otherwise, the timer will keep running even after the view is detached. - postDelayed(updateTimerRunnable, 500) - } - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - - if (isVisible) { - // If the view was visible (i.e. recording) when it was detached, start the timer again. - updateTimer() - } - } - fun lock() { val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) fadeOutAnimation.duration = 250L diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt index f6df7e99e2..db0c4d11cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt @@ -16,6 +16,13 @@ import android.widget.TextView import android.widget.Toast import androidx.annotation.ColorRes import androidx.localbroadcastmanager.content.LocalBroadcastManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityPathBinding import org.session.libsession.snode.OnionRequestAPI @@ -182,6 +189,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() { private lateinit var location: Location private var dotAnimationStartDelay: Long = 0 private var dotAnimationRepeatInterval: Long = 0 + private var job: Job? = null private val dotView by lazy { val result = PathDotView(context) @@ -240,25 +248,36 @@ class PathActivity : PassphraseRequiredActionBarActivity() { addView(dotView) } - private fun performAnimation() { - if (isAttachedToWindow) { - expand() + override fun onAttachedToWindow() { + super.onAttachedToWindow() - postDelayed({ - collapse() - postDelayed({ - performAnimation() - }, dotAnimationRepeatInterval) - }, 1000) - } + startAnimation() } - override fun onAttachedToWindow() { - super.onAttachedToWindow() + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + + stopAnimation() + } + + private fun startAnimation() { + job?.cancel() + job = GlobalScope.launch { + withContext(Dispatchers.Main) { + while (isActive) { + delay(dotAnimationStartDelay) + expand() + delay(EXPAND_ANIM_DELAY_MILLS) + collapse() + delay(dotAnimationRepeatInterval) + } + } + } + } - postDelayed({ - performAnimation() - }, dotAnimationStartDelay) + private fun stopAnimation() { + job?.cancel() + job = null } private fun expand() { @@ -276,6 +295,10 @@ class PathActivity : PassphraseRequiredActionBarActivity() { val endColor = context.resources.getColorWithID(endColorID, context.theme) GlowViewUtilities.animateShadowColorChange(dotView, startColor, endColor) } + + companion object { + private const val EXPAND_ANIM_DELAY_MILLS = 1000L + } } // endregion } \ No newline at end of file