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..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 @@ -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,33 +932,27 @@ 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) { - scrollToFirstUnreadMessageOrBottom() - - // 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, - ) - } + val shouldAutoScrollOnFirstLoad = firstLoad && + pendingRecyclerViewScrollState == null && + !hasRestoredRecyclerViewScrollState - 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 @@ -960,6 +962,26 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } + // 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, + ) + } + + viewModel.threadId?.let { threadId -> + if (isUnread) { + storage.markConversationAsRead( + threadId, + clock.currentTimeMillis() + ) + } + } + } + } + handleRecyclerViewScrolled() } } @@ -973,6 +995,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