From 4c93f4accd07af4025a6567bc63c8b2aadae9eb1 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 9 Feb 2026 16:38:40 +0800 Subject: [PATCH 1/3] Edit text does not cover screen, save scroll position state --- .../conversation/v2/ConversationActivityV2.kt | 31 ++++++++++++++++++- .../conversation/v2/input_bar/InputBar.kt | 3 +- 2 files changed, 32 insertions(+), 2 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 90b0a48643..8b09624ce0 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 @@ -15,6 +15,7 @@ import android.net.Uri import android.os.AsyncTask import android.os.Build import android.os.Bundle +import android.os.Parcelable import android.os.Handler import android.os.Looper import android.os.SystemClock @@ -459,6 +460,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private var isKeyboardVisible = false + private var pendingRecyclerViewScrollState: Parcelable? = null + private var hasRestoredRecyclerViewScrollState: Boolean = false + private lateinit var reactionDelegate: ConversationReactionDelegate private val reactWithAnyEmojiStartPage = -1 @@ -529,6 +533,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // Extras private const val ADDRESS = "address" private const val SCROLL_MESSAGE_ID = "scroll_message_id" + private const val CONVERSATION_SCROLL_STATE = "conversation_scroll_state" const val SHOW_SEARCH = "show_search" @@ -570,6 +575,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) + pendingRecyclerViewScrollState = savedInstanceState?.getParcelable(CONVERSATION_SCROLL_STATE) + hasRestoredRecyclerViewScrollState = false + // Check if address is null before proceeding with initialization if ( IntentCompat.getParcelableExtra( @@ -924,12 +932,22 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, unreadCount = data.threadUnreadCount updateUnreadCountIndicator() + // If we have a saved RecyclerView scroll state (after rotation), restore it and skip first-load autoscroll. + if (!hasRestoredRecyclerViewScrollState && pendingRecyclerViewScrollState != null) { + val lm = binding.conversationRecyclerView.layoutManager + binding.conversationRecyclerView.runWhenLaidOut { + lm?.onRestoreInstanceState(pendingRecyclerViewScrollState) + hasRestoredRecyclerViewScrollState = true + pendingRecyclerViewScrollState = null + } + } + if (messageToScrollTo != null) { if(gotoMessageById(messageToScrollTo!!.id, smoothScroll = messageToScrollTo!!.smoothScroll, highlight = firstLoad)){ messageToScrollTo = null } } else { - if (firstLoad) { + if (firstLoad && pendingRecyclerViewScrollState == null && !hasRestoredRecyclerViewScrollState) { scrollToFirstUnreadMessageOrBottom() // On the first load, check if there unread messages @@ -973,6 +991,17 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + // Persist the current scroll position so rotations (e.g., portrait <-> landscape) don't jump to bottom. + val lm = binding.conversationRecyclerView.layoutManager + val state = lm?.onSaveInstanceState() + if (state != null) { + outState.putParcelable(CONVERSATION_SCROLL_STATE, state) + } + } + override fun onLoaderReset(cursor: Loader) = adapter.changeCursor(null) // called from onCreate diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index 0e16def3d2..52e14b8220 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -142,7 +142,8 @@ class InputBar @JvmOverloads constructor( // Edit text binding.inputBarEditText.setOnEditorActionListener(this) - binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE + // Prevent some IMEs from switching to fullscreen/extracted text mode in landscape (shows IME-owned text field). + binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE or EditorInfo.IME_FLAG_NO_EXTRACT_UI val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0 binding.inputBarEditText.imeOptions = binding.inputBarEditText.imeOptions or incognitoFlag // Always use incognito keyboard if setting enabled From 0ec76e3e2d745f7a08f4c964258f16249fdc2685 Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 10 Feb 2026 08:19:38 +0800 Subject: [PATCH 2/3] Run unread reconciliation regardless of savedstate --- .../conversation/v2/ConversationActivityV2.kt | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 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 8b09624ce0..423f86b7a9 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 @@ -947,28 +947,32 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, messageToScrollTo = null } } else { - if (firstLoad && pendingRecyclerViewScrollState == null && !hasRestoredRecyclerViewScrollState) { - scrollToFirstUnreadMessageOrBottom() + val shouldAutoScrollOnFirstLoad = firstLoad && + pendingRecyclerViewScrollState == null && + !hasRestoredRecyclerViewScrollState + + // We should do this check regardless of whether we're restoring a saved scroll position. + if (firstLoad && unreadCount == 0 && adapter.itemCount > 0) { + lifecycleScope.launch(Dispatchers.Default) { + val isUnread = configFactory.withUserConfigs { + it.convoInfoVolatile.getConversationUnread( + viewModel.address, + ) + } - // On the first load, check if there unread messages - if (unreadCount == 0 && adapter.itemCount > 0) { - lifecycleScope.launch(Dispatchers.Default) { - val isUnread = configFactory.withUserConfigs { - it.convoInfoVolatile.getConversationUnread( - viewModel.address, + viewModel.threadId?.let { threadId -> + if (isUnread) { + storage.markConversationAsRead( + threadId, + clock.currentTimeMillis() ) } - - viewModel.threadId?.let { threadId -> - if (isUnread) { - storage.markConversationAsRead( - threadId, - clock.currentTimeMillis() - ) - } - } } } + } + + if (shouldAutoScrollOnFirstLoad) { + scrollToFirstUnreadMessageOrBottom() } else { // If there are new data updated, we'll try to stay scrolled at the bottom (if we were at the bottom). // scrolled to bottom has a leniency of 50dp, so if we are within the 50dp but not fully at the bottom, scroll down From ae1368849b6ed13bb92a7a9ea533167cddf95ea4 Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 10 Feb 2026 08:21:44 +0800 Subject: [PATCH 3/3] let it scroll first before db udpate --- .../conversation/v2/ConversationActivityV2.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 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 423f86b7a9..e213ad27e5 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 @@ -951,6 +951,17 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, pendingRecyclerViewScrollState == null && !hasRestoredRecyclerViewScrollState + if (shouldAutoScrollOnFirstLoad) { + scrollToFirstUnreadMessageOrBottom() + } else { + // If there are new data updated, we'll try to stay scrolled at the bottom (if we were at the bottom). + // scrolled to bottom has a leniency of 50dp, so if we are within the 50dp but not fully at the bottom, scroll down + if (binding.conversationRecyclerView.isNearBottom && + !binding.conversationRecyclerView.isFullyScrolled) { + gotoConversationEnd() + } + } + // We should do this check regardless of whether we're restoring a saved scroll position. if (firstLoad && unreadCount == 0 && adapter.itemCount > 0) { lifecycleScope.launch(Dispatchers.Default) { @@ -971,17 +982,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } - if (shouldAutoScrollOnFirstLoad) { - scrollToFirstUnreadMessageOrBottom() - } else { - // If there are new data updated, we'll try to stay scrolled at the bottom (if we were at the bottom). - // scrolled to bottom has a leniency of 50dp, so if we are within the 50dp but not fully at the bottom, scroll down - if (binding.conversationRecyclerView.isNearBottom && - !binding.conversationRecyclerView.isFullyScrolled) { - gotoConversationEnd() - } - } - handleRecyclerViewScrolled() } }