diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2121f66b5c..0c5f45a3fe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ configurations.configureEach { exclude(module = "commons-logging") } -val canonicalVersionCode = 448 -val canonicalVersionName = "1.33.2" +val canonicalVersionCode = 449 +val canonicalVersionName = "1.33.3" val postFixSize = 10 val abiPostFix = mapOf( diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt index b0a9a3bf8b..fb4e5679b4 100644 --- a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -2,7 +2,9 @@ package org.session.libsession.network.onion import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -15,7 +17,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock - +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withTimeoutOrNull import org.session.libsession.network.model.Path import org.session.libsession.network.model.PathStatus @@ -59,9 +61,7 @@ open class PathManager @Inject constructor( private val pathSize: Int = 3 private val targetPathCount: Int = 2 - private val _paths = MutableStateFlow( - sanitizePaths(storage.getOnionRequestPaths()) - ) + private val _paths = MutableStateFlow>(emptyList()) val paths: StateFlow> = _paths.asStateFlow() // Used for synchronization @@ -91,17 +91,22 @@ open class PathManager @Inject constructor( if (_paths.value.isEmpty()) PathStatus.ERROR else PathStatus.READY ) + // Warm up from persisted paths without blocking construction. + // Stored as a Deferred so getPath() can await it for deterministic completion. + private val warmUpJob: Deferred = scope.async { + val persisted = sanitizePaths(storage.getOnionRequestPaths()) + _paths.update { current -> if (current.isEmpty()) persisted else current } + } + init { // persist to DB whenever paths change scope.launch { _paths.drop(1).collectLatest { paths -> - if (paths.isEmpty()) storage.clearOnionRequestPaths() - else { - try { - storage.setOnionRequestPaths(paths) - } catch (e: Exception) { - Log.e("Onion Request", "Failed to persist paths to storage, keeping in-memory only", e) - } + try { + if (paths.isEmpty()) storage.clearOnionRequestPaths() + else storage.setOnionRequestPaths(paths) + } catch (e: Exception) { + Log.e("Onion Request", "Failed to persist paths to storage, keeping in-memory only", e) } } } @@ -112,6 +117,9 @@ open class PathManager @Inject constructor( // ----------------------------- suspend fun getPath(exclude: Snode? = null): Path { + // Ensure persisted paths are loaded before checking. No-op after first completion. + warmUpJob.await() + directory.refreshPoolIfStaleAsync() rotatePathsIfStale() @@ -374,7 +382,6 @@ open class PathManager @Inject constructor( } _paths.value = storage.getOnionRequestPaths() - } } 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 78e1f81051..393b23457f 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 @@ -1384,6 +1384,25 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } } + + // bottom search bar + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.searchOpened.collectLatest { isSearchOpen -> + + if (isSearchOpen) { + binding.searchBottomBar.visibility = View.VISIBLE + } else { + binding.searchBottomBar.visibility = View.GONE + + adapter.onSearchQueryUpdated(null) + invalidateOptionsMenu() + } + + binding.root.requestApplyInsets() + } + } + } } private fun scrollToFirstUnreadMessageOrBottom() { @@ -3095,36 +3114,44 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // region Search private fun setUpSearchResultObserver() { - searchViewModel.searchResults.observe(this, Observer { result: SearchViewModel.SearchResult? -> - if (result == null) return@Observer - if (result.getResults().isNotEmpty()) { - result.getResults()[result.position]?.let { - if(!gotoMessageById(it.messageId, smoothScroll = false, highlight = true)) { - searchViewModel.onMissingResult() + searchViewModel.searchResults.observe(this) { result -> + if (result == null) return@observe + + val query = searchViewModel.searchQuery.value + + try { + val results = result.getResults() + val size = results.size + val position = result.position + + if (position in 0 until size) { + results[position]?.let { message -> + if (!gotoMessageById(message.messageId, smoothScroll = false, highlight = true)) { + searchViewModel.onMissingResult() + } } } - } - binding.searchBottomBar.setData(result.position, result.getResults().size, searchViewModel.searchQuery.value) - }) + binding.searchBottomBar.setData(position, size, query) + } catch (e: android.database.StaleDataException) { + binding.searchBottomBar.setData(0, 0, query) + } + } } fun onSearchOpened() { viewModel.onSearchOpened() searchViewModel.onSearchOpened() - binding.searchBottomBar.visibility = View.VISIBLE - binding.searchBottomBar.setData(0, 0, searchViewModel.searchQuery.value) - binding.root.requestApplyInsets() - + binding.searchBottomBar.setData( + 0, + 0, + searchViewModel.searchQuery.value + ) } fun onSearchClosed() { viewModel.onSearchClosed() searchViewModel.onSearchClosed() - binding.searchBottomBar.visibility = View.GONE - binding.root.requestApplyInsets() - adapter.onSearchQueryUpdated(null) - invalidateOptionsMenu() } fun onSearchQueryUpdated(query: String) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index e2837ca3eb..cabf3915f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -260,6 +260,7 @@ class ConversationViewModel @AssistedInject constructor( } private val _searchOpened = MutableStateFlow(false) + val searchOpened : StateFlow get() = _searchOpened val appBarData: StateFlow = combine( recipientFlow.repeatedWithEffectiveNotifyTypeChange(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt index 53642aca3d..536fcfa5ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt @@ -20,20 +20,18 @@ class SearchBottomBar : LinearLayout { fun initialize() { binding = ViewSearchBottomBarBinding.inflate(LayoutInflater.from(context), this, true) + + binding.searchUp.setOnClickListener { + eventListener?.onSearchMoveUpPressed() + } + + binding.searchDown.setOnClickListener { + eventListener?.onSearchMoveDownPressed() + } } fun setData(position: Int, count: Int, searchQuery: String?) = with(binding) { binding.loading.visibility = GONE - searchUp.setOnClickListener { v: View? -> - if (eventListener != null) { - eventListener!!.onSearchMoveUpPressed() - } - } - searchDown.setOnClickListener { v: View? -> - if (eventListener != null) { - eventListener!!.onSearchMoveDownPressed() - } - } if (count > 0) { // we have results searchPosition.text = resources.getQuantityString(R.plurals.searchMatches, count, position + 1, count) } else if ( // we have a legitimate query but no results diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt index 6b315ceeb6..1786f0fe92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt @@ -27,6 +27,7 @@ class SearchViewModel @Inject constructor( private val debouncer: Debouncer = Debouncer(200) private var searchOpen = false private var activeThreadId: Long = 0 + private var currentPosition: Int = 0 val searchResults: LiveData get() = result @@ -42,21 +43,25 @@ class SearchViewModel @Inject constructor( fun onMissingResult() { if (mutableSearchQuery.value != null) { - updateQuery(mutableSearchQuery.value!!, activeThreadId) + updateQuery(mutableSearchQuery.value!!, activeThreadId, currentPosition) } } fun onMoveUp() { debouncer.clear() - val messages = result.value!!.getResults() as CursorList - val position = Math.min(result.value!!.position + 1, messages.size - 1) + val currentResult = result.value ?: return + val messages = currentResult.getResults() as CursorList + val position = minOf(currentResult.position + 1, messages.size - 1) + currentPosition = position result.setValue(SearchResult(messages, position), false) } fun onMoveDown() { debouncer.clear() - val messages = result.value!!.getResults() as CursorList - val position = Math.max(result.value!!.position - 1, 0) + val currentResult = result.value ?: return + val messages = currentResult.getResults() as CursorList + val position = maxOf(currentResult.position - 1, 0) + currentPosition = position result.setValue(SearchResult(messages, position), false) } @@ -67,6 +72,7 @@ class SearchViewModel @Inject constructor( fun onSearchClosed() { searchOpen = false mutableSearchQuery.value = null + currentPosition = 0 debouncer.clear() result.close() } @@ -76,11 +82,12 @@ class SearchViewModel @Inject constructor( result.close() } - private fun updateQuery(query: String, threadId: Long) { + private fun updateQuery(query: String, threadId: Long, requestedPosition: Int = currentPosition) { mutableSearchQuery.value = query activeThreadId = threadId if(query.length < MIN_QUERY_SIZE) { + currentPosition = 0 result.value = SearchResult(CursorList.emptyList(), 0) return } @@ -89,7 +96,13 @@ class SearchViewModel @Inject constructor( searchRepository.query(query, threadId) { messages: CursorList -> runOnMain { if (searchOpen && query == mutableSearchQuery.value) { - result.setValue(SearchResult(messages, 0)) + val position = if (messages.isEmpty()) { + 0 + } else { + requestedPosition.coerceIn(0, messages.size - 1) + } + currentPosition = position + result.setValue(SearchResult(messages, position)) } else { messages.close() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyStoreHelper.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyStoreHelper.java index b6a1db9633..26b14731b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyStoreHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyStoreHelper.java @@ -71,7 +71,7 @@ public static byte[] unseal(@NonNull SealedData sealedData) { } } - private static SecretKey getOrCreateKeyStoreEntry() { + private synchronized static SecretKey getOrCreateKeyStoreEntry() { if (hasKeyStoreEntry()) return getKeyStoreEntry(); else return createKeyStoreEntry(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt index b86b5ad3b6..70171b3be6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt @@ -14,12 +14,14 @@ import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException @Singleton class TokenDataManager @Inject constructor( private val loginStateRepository: LoginStateRepository, - private val tokenRepository: TokenRepository, + private val tokenRepository: Provider, @param:ManagerScope private val scope: CoroutineScope ) : OnAppStartupComponent { private val TAG = "TokenDataManager" @@ -65,7 +67,7 @@ class TokenDataManager @Inject constructor( return try { // Fetch the InfoResponse on an IO dispatcher val response = withContext(Dispatchers.IO) { - tokenRepository.getInfoResponse() + tokenRepository.get().getInfoResponse() } // Ensure the minimum delay to avoid janky UI updates forceWaitAtLeast500ms(requestStartTimestamp) @@ -77,8 +79,10 @@ class TokenDataManager @Inject constructor( updateLastUpdateTimeMillis() Log.w(TAG, "Fetched infoResponse: $response") } catch (e: Exception) { - Log.w(TAG, "InfoResponse error: $e") - _infoResponse.value =InfoResponseState.Failure(e) + if (e is CancellationException) throw e + + Log.w(TAG, "InfoResponse error", e) + _infoResponse.value = InfoResponseState.Failure(e) } } @@ -145,5 +149,4 @@ class TokenDataManager @Inject constructor( data class Data(val data: InfoResponse) : InfoResponseState() data class Failure(val exception: Exception) : InfoResponseState() } - } diff --git a/app/src/main/res/layout-land/view_search_bottom_bar.xml b/app/src/main/res/layout-land/view_search_bottom_bar.xml new file mode 100644 index 0000000000..1cbb5e4fcb --- /dev/null +++ b/app/src/main/res/layout-land/view_search_bottom_bar.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file