From f6c3f85fbf8cb0ebccea2e13597918a15848efee Mon Sep 17 00:00:00 2001 From: "goksu.turker" Date: Thu, 4 Dec 2025 13:48:26 +0300 Subject: [PATCH 1/4] ANDDEV-9176 | Add body preview while searching in event list - Protect the state after user navigates back from event detail - Fix potential crash in DetailFragment.kt while highlighting - Add unit tests - Add robolectric --- gradle/libs.versions.toml | 2 + libraries/analytics-logger/build.gradle.kts | 5 +- .../internal/ext/TextExtensions.kt | 108 ++++ .../internal/ui/EventAdapter.kt | 13 +- .../internal/ui/MainViewModel.kt | 29 +- .../internal/ui/detail/DetailFragment.kt | 52 +- .../internal/ui/events/EventsFragment.kt | 119 ++++- .../internal/ui/model/EventItemViewState.kt | 74 +++ .../layout/analytics_logger_item_event.xml | 20 +- .../analyticslogger/EventItemViewStateTest.kt | 234 +++++++++ .../analyticslogger/TextExtensionsTest.kt | 468 ++++++++++++++++++ 11 files changed, 1069 insertions(+), 55 deletions(-) create mode 100644 libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/TextExtensions.kt create mode 100644 libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/model/EventItemViewState.kt create mode 100644 libraries/analytics-logger/src/test/java/com/trendyol/android/devtools/analyticslogger/EventItemViewStateTest.kt create mode 100644 libraries/analytics-logger/src/test/java/com/trendyol/android/devtools/analyticslogger/TextExtensionsTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b419a3f..d8c62e9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,7 @@ paging-common = "3.1.1" paging-runtime = "3.1.1" preference = "1.2.0" recyclerview = "1.3.0" +robolectric = "4.16" room = "2.7.2" room-common = "2.7.2" rx-android = "3.0.2" @@ -78,6 +79,7 @@ androidx-paging-runtime-ktx = { group = "androidx.paging", name = "paging-runtim androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preference" } androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } androidx-room-common = { module = "androidx.room:room-common", version.ref = "room-common" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } diff --git a/libraries/analytics-logger/build.gradle.kts b/libraries/analytics-logger/build.gradle.kts index 31889d5..ccb48a3 100644 --- a/libraries/analytics-logger/build.gradle.kts +++ b/libraries/analytics-logger/build.gradle.kts @@ -39,7 +39,7 @@ android { } group = "com.trendyol.android.devtools" -version = "0.8.0" +version = "0.9.0-LOCAL" publishConfig { defaultConfiguration( @@ -59,4 +59,7 @@ dependencies { ksp(libs.androidx.room.compiler) implementation(libs.embeddedKoinCore) implementation(libs.embeddedKoinAndroid) + + testImplementation(libs.junit) + testImplementation(libs.robolectric) } diff --git a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/TextExtensions.kt b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/TextExtensions.kt new file mode 100644 index 0000000..a77f331 --- /dev/null +++ b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/TextExtensions.kt @@ -0,0 +1,108 @@ +package com.trendyol.android.devtools.analyticslogger.internal.ext + +import android.graphics.Typeface +import android.text.SpannableString +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan + +/** + * Highlights all occurrences of the query string in the text with background and foreground colors + * Safe implementation that handles edge cases and prevents crashes + */ +internal fun String.highlightQuery(query: String?, highlightBackgroundColor: Int, defaultTextColor: Int): SpannableString { + val spannable = SpannableString(this) + + if (query.isNullOrEmpty() || query.length < 2) { + return spannable + } + + try { + var startIndex = 0 + while (startIndex < this.length) { + val index = this.indexOf(query, startIndex, ignoreCase = true) + if (index == -1) break + + // Safety check: ensure end index doesn't exceed text length + val endIndex = (index + query.length).coerceAtMost(this.length) + + // Only apply span if we have a valid range + if (index >= 0 && endIndex <= this.length && index < endIndex) { + spannable.setSpan( + BackgroundColorSpan(highlightBackgroundColor), + index, + endIndex, + SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + spannable.setSpan( + ForegroundColorSpan(defaultTextColor), + index, + endIndex, + SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + spannable.setSpan( + StyleSpan(Typeface.BOLD), + index, + endIndex, + SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + startIndex = index + query.length + } + } catch (e: Exception) { + // If anything goes wrong, just return the non-highlighted text + android.util.Log.w("TextExtensions", "Error highlighting query: ${e.message}") + return spannable + } + + return spannable +} + +/** + * Finds the first line in the text that contains the query and returns a preview with context + */ +internal fun String?.findMatchingLinePreview(query: String?): String? { + if (this.isNullOrEmpty() || query.isNullOrEmpty() || query.length < 2) { + return null + } + + // Split by common JSON separators to get individual lines/fields + val lines = this.split("\n", ",", "{", "}", "[", "]") + .map { it.trim() } + .filter { it.isNotEmpty() } + + // Find the first line that contains the query + val matchingLine = lines.firstOrNull { + it.contains(query, ignoreCase = true) + } ?: return null + + // Create a preview with ellipsis if needed + val maxPreviewLength = 100 + return if (matchingLine.length > maxPreviewLength) { + val queryIndex = matchingLine.indexOf(query, ignoreCase = true) + + // Try to center the query in the preview + val start = (queryIndex - 30).coerceAtLeast(0) + val end = (start + maxPreviewLength).coerceAtMost(matchingLine.length) + + val preview = matchingLine.substring(start, end) + val prefix = if (start > 0) "..." else "" + val suffix = if (end < matchingLine.length) "..." else "" + + "$prefix$preview$suffix" + } else { + matchingLine + } +} + +/** + * Checks if the text contains the query string (case insensitive) + */ +internal fun String?.containsQuery(query: String?): Boolean { + if (this.isNullOrEmpty() || query.isNullOrEmpty()) { + return false + } + return this.contains(query, ignoreCase = true) +} + diff --git a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/EventAdapter.kt b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/EventAdapter.kt index e041512..f67ad63 100644 --- a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/EventAdapter.kt +++ b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/EventAdapter.kt @@ -7,6 +7,7 @@ import android.graphics.drawable.GradientDrawable import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView @@ -14,6 +15,7 @@ import com.trendyol.android.devtools.analyticslogger.R import com.trendyol.android.devtools.analyticslogger.databinding.AnalyticsLoggerItemEventBinding import com.trendyol.android.devtools.analyticslogger.internal.domain.model.Event import com.trendyol.android.devtools.analyticslogger.internal.factory.ColorFactory +import com.trendyol.android.devtools.analyticslogger.internal.ui.model.EventItemViewState internal class EventAdapter : PagingDataAdapter( diffCallback = object : DiffUtil.ItemCallback() { @@ -28,6 +30,7 @@ internal class EventAdapter : PagingDataAdapter Unit)? = null + var searchQuery: String? = null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EventViewHolder { return EventViewHolder( @@ -58,12 +61,20 @@ internal class EventAdapter : PagingDataAdapter("") + val queryState: StateFlow = _queryState - private val queryState = MutableStateFlow("") private val platformState = MutableStateFlow("") private val _detailState = MutableStateFlow(DetailState.Initial) @@ -34,20 +30,35 @@ internal class MainViewModel( private val _platformsState = MutableStateFlow>(emptyList()) val platformsState: StateFlow> = _platformsState + init { + viewModelScope.launch { + _platformsState.value = eventManager.getPlatforms() + } + } + val eventsFlow: Flow> = Pager(PagingConfig(pageSize = PAGE_SIZE)) { EventPagingSource( eventManager = eventManager, - query = queryState.value, + query = _queryState.value, platform = platformState.value ) } .flow .cachedIn(viewModelScope) - fun setQuery(query: String?) { - queryState.value = query.orEmpty() + /** + * Updates the search query + * This is the main entry point for search functionality + */ + fun setQuery(query: String) { + _queryState.value = query } + /** + * Gets the current search query + */ + fun getQuery(): String = _queryState.value + fun setFilterState(platform: String) { platformState.value = if (platform == "All") "" else platform } diff --git a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/detail/DetailFragment.kt b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/detail/DetailFragment.kt index 3e0599b..2085c9c 100644 --- a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/detail/DetailFragment.kt +++ b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/detail/DetailFragment.kt @@ -9,6 +9,7 @@ import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.text.SpannableString import android.text.style.BackgroundColorSpan +import android.util.Log import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -129,26 +130,39 @@ internal class DetailFragment : Fragment(), AnalyticsLoggerKoinComponent { return } - val spannableString = SpannableString(originalJsonText) - val searchTextLower = searchText.lowercase() - val originalTextLower = originalJsonText.lowercase() - val highlightColor = Color.YELLOW - - var startIndex = 0 - while (true) { - val index = originalTextLower.indexOf(searchTextLower, startIndex) - if (index == -1) break - - spannableString.setSpan( - BackgroundColorSpan(highlightColor), - index, - index + searchText.length, - SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE - ) - startIndex = index + 1 - } + runCatching { + val spannableString = SpannableString(originalJsonText) + val searchTextLower = searchText.lowercase() + val originalTextLower = originalJsonText.lowercase() + val highlightColor = Color.YELLOW + + var startIndex = 0 + while (startIndex < originalTextLower.length) { + val index = originalTextLower.indexOf(searchTextLower, startIndex) + if (index == -1) break + + // Safety check: calculate actual matching length in case of case-insensitive differences + val endIndex = (index + searchTextLower.length).coerceAtMost(originalJsonText.length) + + // Only apply span if we have a valid range + if (index >= 0 && endIndex <= originalJsonText.length && index < endIndex) { + spannableString.setSpan( + BackgroundColorSpan(highlightColor), + index, + endIndex, + SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + startIndex = index + searchTextLower.length + } - binding.textViewValue.text = spannableString + binding.textViewValue.text = spannableString + }.onFailure { e -> + // If highlighting fails, just show the original text without highlighting + Log.w("DetailFragment", "Error highlighting text: ${e.message}") + binding.textViewValue.text = originalJsonText + } } private fun copyToClipboard() { diff --git a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/events/EventsFragment.kt b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/events/EventsFragment.kt index 19c361e..9735fc7 100644 --- a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/events/EventsFragment.kt +++ b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/events/EventsFragment.kt @@ -1,7 +1,5 @@ package com.trendyol.android.devtools.analyticslogger.internal.ui.events -import android.app.SearchManager -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.Menu @@ -37,6 +35,7 @@ internal class EventsFragment : Fragment(), AnalyticsLoggerKoinComponent { private val binding get() = _binding!! private var eventAdapter: EventAdapter? = null + private var searchView: SearchView? = null private lateinit var eventPlatformAdapter: EventPlatformAdapter @@ -52,6 +51,20 @@ internal class EventsFragment : Fragment(), AnalyticsLoggerKoinComponent { observeData() } + override fun onPause() { + super.onPause() + // CRITICAL: Remove listeners to prevent SearchView from clearing query during collapse + searchView?.setOnQueryTextListener(null) + searchView?.setOnCloseListener(null) + } + + override fun onDestroyView() { + _binding = null + eventAdapter = null + searchView = null + super.onDestroyView() + } + private fun initView() { eventPlatformAdapter = EventPlatformAdapter() binding.platformsRecyclerView.adapter = eventPlatformAdapter @@ -83,11 +96,32 @@ internal class EventsFragment : Fragment(), AnalyticsLoggerKoinComponent { eventPlatformAdapter.submitData(it) } } + + // Observe query changes from ViewModel (single source of truth) + launch { + viewModel.queryState.collectLatest { query -> + // Update adapter's search query for highlighting + eventAdapter?.searchQuery = query + + // Update SearchView if different + if (searchView?.query?.toString() != query) { + searchView?.setQuery(query, false) + } + + // Trigger rebind for highlighting + if (query.isNotEmpty()) { + val itemCount = eventAdapter?.snapshot()?.items?.size ?: 0 + if (itemCount > 0) { + eventAdapter?.notifyItemRangeChanged(0, itemCount, "HIGHLIGHT_UPDATE") + } + } + } + } } } private fun setQuery(query: String?) { - viewModel.setQuery(query) + viewModel.setQuery(query.orEmpty()) eventAdapter?.refresh() } @@ -104,23 +138,17 @@ internal class EventsFragment : Fragment(), AnalyticsLoggerKoinComponent { private fun initSearchView(menu: Menu) { val searchItem = menu.findItem(R.id.action_search) - val searchManager = requireActivity().getSystemService(Context.SEARCH_SERVICE) as SearchManager - val searchView = searchItem.actionView as SearchView - - searchView.setSearchableInfo( - searchManager.getSearchableInfo(requireActivity().componentName) - ) - - searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String?): Boolean { - return true - } - - override fun onQueryTextChange(newText: String?): Boolean { - setQuery(newText) - return true - } - }) + searchView = searchItem.actionView as SearchView + + // Attach listeners + initSearchViewListeners(searchItem) + + // Restore query from ViewModel + val currentQuery = viewModel.getQuery() + if (currentQuery.isNotEmpty()) { + searchItem.expandActionView() + searchView?.setQuery(currentQuery, false) + } } @Deprecated("Deprecated in Java") @@ -138,10 +166,53 @@ internal class EventsFragment : Fragment(), AnalyticsLoggerKoinComponent { super.onCreateOptionsMenu(menu, inflater) } - override fun onDestroyView() { - _binding = null - eventAdapter = null - super.onDestroyView() + @Deprecated("Deprecated in Java") + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + + // Get SearchView reference and re-attach listeners + val searchItem = menu.findItem(R.id.action_search) + if (searchView == null && searchItem != null) { + searchView = searchItem.actionView as? SearchView + initSearchViewListeners(searchItem) + } + + // Restore query from ViewModel + val currentQuery = viewModel.getQuery() + if (currentQuery.isNotEmpty() && searchView?.query?.toString() != currentQuery) { + searchItem?.expandActionView() + searchView?.setQuery(currentQuery, false) + } + } + + private fun initSearchViewListeners(searchItem: MenuItem) { + // Prevent query loss when SearchView collapses + searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem): Boolean = true + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + searchView?.query?.toString()?.let { viewModel.setQuery(it) } + return true + } + }) + + // Prevent close button from clearing query + searchView?.setOnCloseListener { + searchView?.query?.toString()?.let { viewModel.setQuery(it) } + false + } + + searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean = true + + override fun onQueryTextChange(newText: String?): Boolean { + // Only accept query changes when SearchView is actively expanded + if (searchItem.isActionViewExpanded) { + setQuery(newText) + } + return true + } + }) } companion object { diff --git a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/model/EventItemViewState.kt b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/model/EventItemViewState.kt new file mode 100644 index 0000000..5febe4e --- /dev/null +++ b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/model/EventItemViewState.kt @@ -0,0 +1,74 @@ +package com.trendyol.android.devtools.analyticslogger.internal.ui.model + +import android.text.SpannableString +import androidx.core.graphics.toColorInt +import com.trendyol.android.devtools.analyticslogger.internal.domain.model.Event +import com.trendyol.android.devtools.analyticslogger.internal.ext.containsQuery +import com.trendyol.android.devtools.analyticslogger.internal.ext.findMatchingLinePreview +import com.trendyol.android.devtools.analyticslogger.internal.ext.highlightQuery + +/** + * ViewState for an Event item in the list + * Contains both data and presentation logic + */ +internal data class EventItemViewState( + val event: Event, + val searchQuery: String +) { + private val keyMatchesQuery: Boolean + get() = event.key.containsQuery(searchQuery) + + private val valueMatchesQuery: Boolean + get() = event.value.containsQuery(searchQuery) && !keyMatchesQuery + + private val bodyPreviewRawText: String? + get() = if (valueMatchesQuery) { + event.value.findMatchingLinePreview(searchQuery) + } else { + null + } + + /** + * Whether the event key should be highlighted + */ + val shouldHighlightKey: Boolean + get() = keyMatchesQuery && searchQuery.isNotEmpty() + + /** + * Whether the body preview should be visible + */ + val isBodyPreviewVisible: Boolean + get() = valueMatchesQuery && bodyPreviewRawText != null + + /** + * Gets the event key text (plain or highlighted) + */ + fun getKeyText(): CharSequence { + return if (shouldHighlightKey) { + event.key.orEmpty().highlightQuery(searchQuery, highlightColor, defaultTextColor) + } else { + event.key.orEmpty() + } + } + + /** + * Gets the body preview text with highlighting + * Returns null if preview should not be shown + */ + fun getBodyPreviewText(): SpannableString? { + return if (isBodyPreviewVisible && bodyPreviewRawText != null) { + bodyPreviewRawText?.highlightQuery(searchQuery, highlightColor, defaultTextColor) + } else { + null + } + } + + companion object { + private val highlightColor = "#FFD54F".toColorInt() + private val defaultTextColor = "#000000".toColorInt() + + fun createDefault(event: Event): EventItemViewState { + return EventItemViewState(event = event, searchQuery = "") + } + } +} diff --git a/libraries/analytics-logger/src/main/res/layout/analytics_logger_item_event.xml b/libraries/analytics-logger/src/main/res/layout/analytics_logger_item_event.xml index e7a75cc..8238a8c 100644 --- a/libraries/analytics-logger/src/main/res/layout/analytics_logger_item_event.xml +++ b/libraries/analytics-logger/src/main/res/layout/analytics_logger_item_event.xml @@ -35,11 +35,29 @@ android:textSize="13sp" android:textColor="@color/textColorSecondary" app:layout_constraintTop_toBottomOf="@id/textViewKey" - app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toStartOf="@id/textViewPlatform" tools:text="com.example.package.ClassName" /> + + Date: Thu, 4 Dec 2025 14:23:36 +0300 Subject: [PATCH 2/4] ANDDEV-9176 | Add hide keyboard when click outside input field --- .../internal/ext/ViewExtensions.kt | 111 ++++++++++++++++++ .../internal/ui/detail/DetailFragment.kt | 5 + .../internal/ui/events/EventsFragment.kt | 17 ++- 3 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/ViewExtensions.kt diff --git a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/ViewExtensions.kt b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/ViewExtensions.kt new file mode 100644 index 0000000..ed306fd --- /dev/null +++ b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/ViewExtensions.kt @@ -0,0 +1,111 @@ +package com.trendyol.android.devtools.analyticslogger.internal.ext + +import android.annotation.SuppressLint +import android.content.Context +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.widget.SearchView + +/** + * Hides the soft keyboard from the current view + */ +internal fun View.hideKeyboard() { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(windowToken, 0) +} + +/** + * Sets up RecyclerView to hide keyboard when scrolling or touching + */ +@SuppressLint("ClickableViewAccessibility") +internal fun androidx.recyclerview.widget.RecyclerView.setupHideKeyboardOnScroll() { + addOnScrollListener(object : androidx.recyclerview.widget.RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: androidx.recyclerview.widget.RecyclerView, newState: Int) { + if (newState == androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_DRAGGING) { + hideKeyboard() + } + } + }) + + // Also hide on touch to handle taps on items + setOnTouchListener { v, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + v.hideKeyboard() + } + false // Don't consume the event + } +} + +/** + * Sets up NestedScrollView to hide keyboard when scrolling or touching + */ +@SuppressLint("ClickableViewAccessibility") +internal fun androidx.core.widget.NestedScrollView.setupHideKeyboardOnScroll() { + setOnScrollChangeListener { _: androidx.core.widget.NestedScrollView, _: Int, _: Int, _: Int, _: Int -> + hideKeyboard() + } + + // Also hide on touch + setOnTouchListener { v, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + v.hideKeyboard() + } + false // Don't consume the event + } +} + +/** + * Sets up touch listener to hide keyboard when clicking outside of SearchView + * + * Note: SuppressLint is used because we're not handling clicks - we're only hiding the keyboard + * and letting the event propagate normally (returning false). This is a UX enhancement, not + * an accessibility feature, so performClick() is not needed here. + */ +@SuppressLint("ClickableViewAccessibility") +internal fun View.setupHideKeyboardOnTouch() { + // Skip if not a container + if (this !is ViewGroup) return + + setOnTouchListener { v, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + // Check if the touch is outside any SearchView + if (!isTouchInsideSearchView(this, event)) { + v.hideKeyboard() + v.clearFocus() + } + } + false // Don't consume the event, let it propagate + } +} + +/** + * Recursively checks if the touch event is inside a SearchView or its children + */ +private fun isTouchInsideSearchView(viewGroup: ViewGroup, event: MotionEvent): Boolean { + for (i in 0 until viewGroup.childCount) { + val child = viewGroup.getChildAt(i) + + if (child is SearchView) { + // Check if touch is within SearchView bounds + val location = IntArray(2) + child.getLocationOnScreen(location) + val x = location[0] + val y = location[1] + + if (event.rawX >= x && event.rawX <= x + child.width && + event.rawY >= y && event.rawY <= y + child.height) { + return true + } + } + + if (child is ViewGroup) { + if (isTouchInsideSearchView(child, event)) { + return true + } + } + } + return false +} + diff --git a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/detail/DetailFragment.kt b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/detail/DetailFragment.kt index 2085c9c..2aeb2cc 100644 --- a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/detail/DetailFragment.kt +++ b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/detail/DetailFragment.kt @@ -26,6 +26,8 @@ import com.trendyol.android.devtools.analyticslogger.R import com.trendyol.android.devtools.analyticslogger.databinding.AnalyticsLoggerFragmentDetailBinding import com.trendyol.android.devtools.analyticslogger.internal.di.AnalyticsLoggerKoinComponent import com.trendyol.android.devtools.analyticslogger.internal.domain.usecase.ExcludeKeysUseCase +import com.trendyol.android.devtools.analyticslogger.internal.ext.setupHideKeyboardOnScroll +import com.trendyol.android.devtools.analyticslogger.internal.ext.setupHideKeyboardOnTouch import com.trendyol.android.devtools.analyticslogger.internal.factory.ColorFactory import com.trendyol.android.devtools.analyticslogger.internal.ui.MainViewModel import com.trendyol.android.devtools.analyticslogger.internal.util.executeJS @@ -60,6 +62,9 @@ internal class DetailFragment : Fragment(), AnalyticsLoggerKoinComponent { } private fun initializeViews() = with(binding) { + root.setupHideKeyboardOnTouch() + root.setupHideKeyboardOnScroll() + webViewJsExecutor.settings.javaScriptEnabled = true editTextjsTransformFunction.setText(AnalyticsLogger.getEventTransformFunction()) editTextjsTransformFunction.doAfterTextChanged { diff --git a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/events/EventsFragment.kt b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/events/EventsFragment.kt index 9735fc7..ba97ae0 100644 --- a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/events/EventsFragment.kt +++ b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/events/EventsFragment.kt @@ -20,6 +20,8 @@ import com.trendyol.android.devtools.analyticslogger.internal.ui.EventAdapter import com.trendyol.android.devtools.analyticslogger.internal.ui.MainActivity import com.trendyol.android.devtools.analyticslogger.internal.ui.MainViewModel import com.trendyol.android.devtools.analyticslogger.internal.ui.detail.DetailFragment +import com.trendyol.android.devtools.analyticslogger.internal.ext.setupHideKeyboardOnScroll +import com.trendyol.android.devtools.analyticslogger.internal.ext.setupHideKeyboardOnTouch import embedded.koin.android.ext.android.inject import embedded.koin.androidx.viewmodel.ext.android.activityViewModel import kotlinx.coroutines.flow.collectLatest @@ -57,7 +59,7 @@ internal class EventsFragment : Fragment(), AnalyticsLoggerKoinComponent { searchView?.setOnQueryTextListener(null) searchView?.setOnCloseListener(null) } - + override fun onDestroyView() { _binding = null eventAdapter = null @@ -66,6 +68,9 @@ internal class EventsFragment : Fragment(), AnalyticsLoggerKoinComponent { } private fun initView() { + binding.root.setupHideKeyboardOnTouch() + binding.recyclerView.setupHideKeyboardOnScroll() + eventPlatformAdapter = EventPlatformAdapter() binding.platformsRecyclerView.adapter = eventPlatformAdapter @@ -142,7 +147,7 @@ internal class EventsFragment : Fragment(), AnalyticsLoggerKoinComponent { // Attach listeners initSearchViewListeners(searchItem) - + // Restore query from ViewModel val currentQuery = viewModel.getQuery() if (currentQuery.isNotEmpty()) { @@ -169,14 +174,14 @@ internal class EventsFragment : Fragment(), AnalyticsLoggerKoinComponent { @Deprecated("Deprecated in Java") override fun onPrepareOptionsMenu(menu: Menu) { super.onPrepareOptionsMenu(menu) - + // Get SearchView reference and re-attach listeners val searchItem = menu.findItem(R.id.action_search) if (searchView == null && searchItem != null) { searchView = searchItem.actionView as? SearchView initSearchViewListeners(searchItem) } - + // Restore query from ViewModel val currentQuery = viewModel.getQuery() if (currentQuery.isNotEmpty() && searchView?.query?.toString() != currentQuery) { @@ -184,7 +189,7 @@ internal class EventsFragment : Fragment(), AnalyticsLoggerKoinComponent { searchView?.setQuery(currentQuery, false) } } - + private fun initSearchViewListeners(searchItem: MenuItem) { // Prevent query loss when SearchView collapses searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { @@ -195,7 +200,7 @@ internal class EventsFragment : Fragment(), AnalyticsLoggerKoinComponent { return true } }) - + // Prevent close button from clearing query searchView?.setOnCloseListener { searchView?.query?.toString()?.let { viewModel.setQuery(it) } From 3f1db3c2c0fb38b0af13b860d03ceedfd0a6748d Mon Sep 17 00:00:00 2001 From: "goksu.turker" Date: Thu, 4 Dec 2025 14:54:11 +0300 Subject: [PATCH 3/4] ANDDEV-9176 | Change analytics-logger version 0.9.0 --- libraries/analytics-logger/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/analytics-logger/build.gradle.kts b/libraries/analytics-logger/build.gradle.kts index ccb48a3..034d58b 100644 --- a/libraries/analytics-logger/build.gradle.kts +++ b/libraries/analytics-logger/build.gradle.kts @@ -39,7 +39,7 @@ android { } group = "com.trendyol.android.devtools" -version = "0.9.0-LOCAL" +version = "0.9.0" publishConfig { defaultConfiguration( From f7f5ad15f93522013e1a30ed4b979651cc6efa05 Mon Sep 17 00:00:00 2001 From: "goksu.turker" Date: Thu, 4 Dec 2025 15:14:39 +0300 Subject: [PATCH 4/4] ANDDEV-9176 | Fix lint issues --- .../internal/ext/TextExtensions.kt | 1 - .../internal/ext/ViewExtensions.kt | 16 ++++++++-------- .../analyticslogger/internal/ui/EventAdapter.kt | 4 ++-- .../internal/ui/detail/DetailFragment.kt | 2 +- .../analyticslogger/EventItemViewStateTest.kt | 2 +- .../analyticslogger/TextExtensionsTest.kt | 3 +-- 6 files changed, 13 insertions(+), 15 deletions(-) diff --git a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/TextExtensions.kt b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/TextExtensions.kt index a77f331..f27a8e1 100644 --- a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/TextExtensions.kt +++ b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/TextExtensions.kt @@ -105,4 +105,3 @@ internal fun String?.containsQuery(query: String?): Boolean { } return this.contains(query, ignoreCase = true) } - diff --git a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/ViewExtensions.kt b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/ViewExtensions.kt index ed306fd..b2ec1d8 100644 --- a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/ViewExtensions.kt +++ b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ext/ViewExtensions.kt @@ -28,7 +28,7 @@ internal fun androidx.recyclerview.widget.RecyclerView.setupHideKeyboardOnScroll } } }) - + // Also hide on touch to handle taps on items setOnTouchListener { v, event -> if (event.action == MotionEvent.ACTION_DOWN) { @@ -46,7 +46,7 @@ internal fun androidx.core.widget.NestedScrollView.setupHideKeyboardOnScroll() { setOnScrollChangeListener { _: androidx.core.widget.NestedScrollView, _: Int, _: Int, _: Int, _: Int -> hideKeyboard() } - + // Also hide on touch setOnTouchListener { v, event -> if (event.action == MotionEvent.ACTION_DOWN) { @@ -58,7 +58,7 @@ internal fun androidx.core.widget.NestedScrollView.setupHideKeyboardOnScroll() { /** * Sets up touch listener to hide keyboard when clicking outside of SearchView - * + * * Note: SuppressLint is used because we're not handling clicks - we're only hiding the keyboard * and letting the event propagate normally (returning false). This is a UX enhancement, not * an accessibility feature, so performClick() is not needed here. @@ -86,20 +86,21 @@ internal fun View.setupHideKeyboardOnTouch() { private fun isTouchInsideSearchView(viewGroup: ViewGroup, event: MotionEvent): Boolean { for (i in 0 until viewGroup.childCount) { val child = viewGroup.getChildAt(i) - + if (child is SearchView) { // Check if touch is within SearchView bounds val location = IntArray(2) child.getLocationOnScreen(location) val x = location[0] val y = location[1] - + if (event.rawX >= x && event.rawX <= x + child.width && - event.rawY >= y && event.rawY <= y + child.height) { + event.rawY >= y && event.rawY <= y + child.height + ) { return true } } - + if (child is ViewGroup) { if (isTouchInsideSearchView(child, event)) { return true @@ -108,4 +109,3 @@ private fun isTouchInsideSearchView(viewGroup: ViewGroup, event: MotionEvent): B } return false } - diff --git a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/EventAdapter.kt b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/EventAdapter.kt index f67ad63..e363804 100644 --- a/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/EventAdapter.kt +++ b/libraries/analytics-logger/src/main/java/com/trendyol/android/devtools/analyticslogger/internal/ui/EventAdapter.kt @@ -63,7 +63,7 @@ internal class EventAdapter : PagingDataAdapter