Refactored SearchRepository to expose search results through single flow.

- Combined search results and query index to be provided from single flow, as both fields were highly cohesive. This will make easier for collectors to maintain parity.
- Updated prev() and next() implementation to provide a shallow copy of query results while modifying only queryResultIndex.

Test: ./gradlew :pdf:pdf-viewer:connectedDebugAndroidTest
Bug: 379054326
Change-Id: I3279c55fe4511d876748e9a53631acc52472be14
diff --git a/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/SandboxedPdfDocumentTest.kt b/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/SandboxedPdfDocumentTest.kt
index f40d303..dbc7510 100644
--- a/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/SandboxedPdfDocumentTest.kt
+++ b/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/SandboxedPdfDocumentTest.kt
@@ -33,6 +33,7 @@
 import junit.framework.TestCase.assertFalse
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
 import org.junit.Test
 import org.junit.runner.RunWith
 
@@ -151,6 +152,21 @@
         }
     }
 
+    @Test
+    fun searchDocument_fullDocumentSearch_withSinglePageResults() = runTest {
+        withDocument(PDF_DOCUMENT) { document ->
+            val query = "pages are all the same size"
+            val pageRange = 0..2
+
+            val results = document.searchDocument(query, pageRange)
+
+            // Assert sparse array doesn't contain empty result lists
+            assertEquals(1, results.size())
+            // Assert single result on first page
+            assertEquals(1, results[0].size)
+        }
+    }
+
     @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
     @Test
     fun getSelectionBounds_returnsPageSelection() = runTest {
diff --git a/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/SandboxedPdfDocument.kt b/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/SandboxedPdfDocument.kt
index 774bb7a..097d35b 100644
--- a/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/SandboxedPdfDocument.kt
+++ b/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/SandboxedPdfDocument.kt
@@ -88,11 +88,14 @@
         pageRange: IntRange
     ): SparseArray<List<PageMatchBounds>> {
         return withDocument { document ->
-            pageRange
-                .map { pageNum ->
-                    document.searchPageText(pageNum, query).map { it.toContentClass() }
+            SparseArray<List<PageMatchBounds>>(pageRange.last + 1).apply {
+                pageRange.forEach { pageNum ->
+                    document
+                        .searchPageText(pageNum, query)
+                        .takeIf { it.isNotEmpty() }
+                        ?.let { put(pageNum, it.map { result -> result.toContentClass() }) }
                 }
-                .toSparseArray(pageRange)
+            }
         }
     }
 
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/search/SearchRepositoryTest.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/search/SearchRepositoryTest.kt
index 1b39c3ec..4c0c976 100644
--- a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/search/SearchRepositoryTest.kt
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/search/SearchRepositoryTest.kt
@@ -17,11 +17,9 @@
 package androidx.pdf.search
 
 import android.util.SparseArray
-import androidx.core.util.isEmpty
-import androidx.core.util.isNotEmpty
 import androidx.pdf.content.PageMatchBounds
-import androidx.pdf.search.model.SearchResults
-import androidx.pdf.search.model.SelectedSearchResult
+import androidx.pdf.search.model.NoQuery
+import androidx.pdf.search.model.QueryResults
 import androidx.pdf.view.FakePdfDocument
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
@@ -59,38 +57,42 @@
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 5)
+            produceSearchResults(query = "test", currentVisiblePage = 5)
 
-            val results = searchResults.value
+            var results = queryResults.value as QueryResults.Matched
             // Assert results exists on 3 pages
-            assertEquals(3, results.results.size())
-            assertEquals(5, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(3, results.resultBounds.size())
+            assertEquals(5, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
 
             // fetch next result
-            next()
+            produceNextResult()
+            results = queryResults.value as QueryResults.Matched
             // Assert selectedSearchResult point to next result on same page
-            assertEquals(5, selectedSearchResult.value?.pageNum)
-            assertEquals(1, selectedSearchResult.value?.currentIndex)
+            assertEquals(5, results.queryResultsIndex.pageNum)
+            assertEquals(1, results.queryResultsIndex.resultBoundsIndex)
 
             // fetch next result
-            next()
+            produceNextResult()
+            results = queryResults.value as QueryResults.Matched
             // Assert selectedSearchResult point to next result on next page
             // in forward direction
-            assertEquals(10, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(10, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
 
             // fetch next result
-            next()
+            produceNextResult()
+            results = queryResults.value as QueryResults.Matched
             // Assert selectedSearchResult point to next result cyclically
-            assertEquals(1, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(1, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
 
             // fetch previous result
-            prev()
+            producePreviousResult()
+            results = queryResults.value as QueryResults.Matched
             // Assert selectedSearchResult point to previous result cyclically
-            assertEquals(10, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(10, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
         }
     }
 
@@ -101,19 +103,20 @@
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 7)
+            produceSearchResults(query = "test", currentVisiblePage = 7)
 
-            val results = searchResults.value
+            var results = queryResults.value as QueryResults.Matched
             // Assert results exists on 3 pages
-            assertEquals(3, results.results.size())
-            assertEquals(10, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(3, results.resultBounds.size())
+            assertEquals(10, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
 
             // fetch next result
-            next()
+            produceNextResult()
+            results = queryResults.value as QueryResults.Matched
             // Assert selectedSearchResult point to next result cyclically
-            assertEquals(1, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(1, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
         }
     }
 
@@ -124,20 +127,21 @@
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 11)
+            produceSearchResults(query = "test", currentVisiblePage = 11)
 
-            val results = searchResults.value
+            var results = queryResults.value as QueryResults.Matched
             // Assert results exists on 3 pages
-            assertEquals(3, results.results.size())
+            assertEquals(3, results.resultBounds.size())
             // Assert selectedSearchResult point to next result cyclically
-            assertEquals(1, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(1, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
 
             // fetch next result
-            next()
+            produceNextResult()
+            results = queryResults.value as QueryResults.Matched
             // Assert selectedSearchResult point to next result on next page
-            assertEquals(5, selectedSearchResult.value?.pageNum)
-            assertEquals(0, selectedSearchResult.value?.currentIndex)
+            assertEquals(5, results.queryResultsIndex.pageNum)
+            assertEquals(0, results.queryResultsIndex.resultBoundsIndex)
         }
     }
 
@@ -148,46 +152,47 @@
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 11)
+            produceSearchResults(query = "test", currentVisiblePage = 11)
 
-            val results = searchResults.value
+            val results = queryResults.value
 
             // Assert no results returned
-            assertEquals(0, results.results.size())
+            assertTrue(results is QueryResults.NoMatch)
+            assertEquals("test", (results as QueryResults.NoMatch).query)
         }
     }
 
     @Test(expected = NoSuchElementException::class)
-    fun testPrevOperation_noMatchingResults() = runTest {
+    fun testFindPrevOperation_noMatchingResults() = runTest {
         val fakeResults = createFakeSearchResults()
         val fakePdfDocument = FakePdfDocument(searchResults = fakeResults)
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 11)
+            produceSearchResults(query = "test", currentVisiblePage = 11)
 
-            val results = searchResults.value
-            assertEquals(0, results.results.size())
+            val results = queryResults.value
+            assertTrue(results is QueryResults.NoMatch)
 
             // fetch previous result, should throw [NoSuchElementException]
-            prev()
+            producePreviousResult()
         }
     }
 
     @Test(expected = NoSuchElementException::class)
-    fun testNextOperation_noMatchingResults() = runTest {
+    fun testFindNextOperation_noMatchingResults() = runTest {
         val fakeResults = createFakeSearchResults()
         val fakePdfDocument = FakePdfDocument(searchResults = fakeResults)
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 10)
+            produceSearchResults(query = "test", currentVisiblePage = 10)
 
-            val results = searchResults.value
-            assertEquals(0, results.results.size())
+            val results = queryResults.value
+            assertTrue(results is QueryResults.NoMatch)
 
             // fetch next result, should throw [NoSuchElementException]
-            next()
+            produceNextResult()
         }
     }
 
@@ -198,67 +203,16 @@
 
         with(SearchRepository(fakePdfDocument)) {
             // search document
-            searchDocument(query = "test", currentVisiblePage = 11)
+            produceSearchResults(query = "test", currentVisiblePage = 11)
 
-            assertEquals(3, searchResults.value.results.size())
+            val results = queryResults.value as QueryResults.Matched
+            assertEquals(3, results.resultBounds.size())
 
             // clear results
             clearSearchResults()
 
             // assert results are cleared
-            assertTrue(searchResults.value.results.isEmpty())
-        }
-    }
-
-    @Test
-    fun testSettingStateToRepository() = runTest {
-        val fakeResults = createFakeSearchResults()
-        val fakePdfDocument = FakePdfDocument(searchResults = fakeResults)
-        val currentVisiblePage = 11
-
-        with(SearchRepository(fakePdfDocument)) {
-            // search document
-            searchDocument(query = "test", currentVisiblePage = currentVisiblePage)
-
-            // assert there are no results
-            assertEquals(0, searchResults.value.results.size())
-
-            // set results
-            setState(
-                searchResults = SearchResults("test", createFakeSearchResults(1, 5, 5, 10)),
-                selectedSearchResult = SelectedSearchResult(5, 1),
-                currentVisiblePage = currentVisiblePage
-            )
-
-            // assert results are set
-            assertTrue(searchResults.value.results.isNotEmpty())
-            assertEquals(5, selectedSearchResult.value?.pageNum)
-            assertEquals(1, selectedSearchResult.value?.currentIndex)
-        }
-    }
-
-    @Test(expected = NoSuchElementException::class)
-    fun testSettingEmptyResultsToRepository() = runTest {
-        val fakeResults = createFakeSearchResults()
-        val fakePdfDocument = FakePdfDocument(searchResults = fakeResults)
-        val currentVisiblePage = 11
-
-        with(SearchRepository(fakePdfDocument)) {
-            // search document
-            searchDocument(query = "test", currentVisiblePage = currentVisiblePage)
-
-            // assert there are no results
-            assertEquals(0, searchResults.value.results.size())
-
-            // set results
-            setState(
-                searchResults = SearchResults("test", SparseArray()),
-                selectedSearchResult = null,
-                currentVisiblePage = currentVisiblePage
-            )
-
-            // fetch next result, should throw [NoSuchElementException]
-            next()
+            assertTrue(queryResults.value is NoQuery)
         }
     }
 }
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/CyclicSparseArrayIterator.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/CyclicSparseArrayIterator.kt
index 8b7895a..8a16283 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/CyclicSparseArrayIterator.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/CyclicSparseArrayIterator.kt
@@ -19,7 +19,7 @@
 import android.util.SparseArray
 import androidx.annotation.RestrictTo
 import androidx.pdf.content.PageMatchBounds
-import androidx.pdf.search.model.SelectedSearchResult
+import androidx.pdf.search.model.QueryResultsIndex
 
 /**
  * A cyclic iterator implementation over SparseArray.
@@ -54,13 +54,13 @@
     }
 
     /** Get the current state of selected search result. */
-    fun current(): SelectedSearchResult {
+    fun current(): QueryResultsIndex {
         val currentPageNum = pageNumList[pageNumIndex]
-        return SelectedSearchResult(currentPageNum, searchIndexOnPage)
+        return QueryResultsIndex(pageNum = currentPageNum, resultBoundsIndex = searchIndexOnPage)
     }
 
     /** Move to the nex element in the current page, or to the next page cyclically. */
-    fun next(): SelectedSearchResult {
+    fun next(): QueryResultsIndex {
         if (totalPages == 0) {
             throw NoSuchElementException("No elements to iterate.")
         }
@@ -80,7 +80,7 @@
     }
 
     /** Move to the previous element in the page list, or to the previous page cyclically. */
-    fun prev(): SelectedSearchResult {
+    fun prev(): QueryResultsIndex {
         if (totalPages == 0) {
             throw NoSuchElementException("No elements to iterate.")
         }
@@ -94,6 +94,8 @@
         // If we're at the beginning of the current page, move to the previous page
         if (searchIndexOnPage == resultsOnPage.size - 1) {
             pageNumIndex = (pageNumIndex - 1 + totalPages) % totalPages
+            // update the search index of page to last result on updated page
+            searchIndexOnPage = searchData.valueAt(pageNumIndex).lastIndex
         }
 
         return current()
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/SearchRepository.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/SearchRepository.kt
index 80cce7e..3de7dbb 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/SearchRepository.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/SearchRepository.kt
@@ -17,11 +17,11 @@
 package androidx.pdf.search
 
 import androidx.annotation.RestrictTo
-import androidx.core.util.isEmpty
 import androidx.core.util.isNotEmpty
 import androidx.pdf.PdfDocument
-import androidx.pdf.search.model.SearchResults
-import androidx.pdf.search.model.SelectedSearchResult
+import androidx.pdf.search.model.NoQuery
+import androidx.pdf.search.model.QueryResults
+import androidx.pdf.search.model.SearchResultState
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -46,23 +46,17 @@
  *   to Dispatcher.IO.
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-internal class SearchRepository(
+public class SearchRepository(
     private val pdfDocument: PdfDocument,
+    // TODO(b/384001800) Remove dispatcher
     private val dispatcher: CoroutineDispatcher = Dispatchers.IO
 ) {
 
-    private val _searchResults: MutableStateFlow<SearchResults> = MutableStateFlow(SearchResults())
+    private val _queryResults: MutableStateFlow<SearchResultState> = MutableStateFlow(NoQuery)
 
     /** Stream of search results for a given query. */
-    val searchResults: StateFlow<SearchResults>
-        get() = _searchResults.asStateFlow()
-
-    private val _selectedSearchResult: MutableStateFlow<SelectedSearchResult?> =
-        MutableStateFlow(null)
-
-    /** Stream of selected search results. */
-    val selectedSearchResult: StateFlow<SelectedSearchResult?>
-        get() = _selectedSearchResult.asStateFlow()
+    public val queryResults: StateFlow<SearchResultState>
+        get() = _queryResults.asStateFlow()
 
     private lateinit var cyclicIterator: CyclicSparseArrayIterator
 
@@ -71,93 +65,108 @@
      *
      * @param query: The search query string.
      * @param currentVisiblePage: Provides current visible document page, which is required to
-     *   search from specific page and to calculate initial [selectedSearchResult]
+     *   search from specific page and to calculate initial QueryResultsIndex.
      *
-     * Results would be updated to [searchResults] in the coroutine collecting the flow.
+     * Results would be updated to [queryResults] in the coroutine collecting the flow.
      */
-    suspend fun searchDocument(query: String, currentVisiblePage: Int) {
-        if (query.isEmpty()) return
+    public suspend fun produceSearchResults(query: String, currentVisiblePage: Int) {
+        if (query.isBlank()) {
+            clearSearchResults()
+            return
+        }
 
-        // Clear the existing results
-        clearSearchResults()
+        val searchPageRange = IntRange(start = 0, endInclusive = pdfDocument.pageCount - 1)
 
         // search should be a background work, move execution on to provided [dispatcher]
         // to make [searchDocument] main-safe
-        val currentResult =
+        val searchResults =
             withContext(dispatcher) {
-                SearchResults(
-                    searchQuery = query,
-                    results =
-                        pdfDocument.searchDocument(
-                            query = query,
-                            pageRange = IntRange(start = 0, endInclusive = pdfDocument.pageCount)
-                        )
-                )
+                pdfDocument.searchDocument(query = query, pageRange = searchPageRange)
             }
 
-        // update results
-        _searchResults.update { currentResult }
+        val queryResults =
+            if (searchResults.isNotEmpty()) {
+                /*
+                 When search results are available for a query, we initialize a cyclic iterator.
+                 This iterator is used to traverse the results when `findPrev()` and `findNext()` are called.
+                */
+                cyclicIterator = CyclicSparseArrayIterator(searchResults, currentVisiblePage)
 
-        if (currentResult.results.isNotEmpty()) {
-            // Init cyclic iterator
-            cyclicIterator = CyclicSparseArrayIterator(currentResult.results, currentVisiblePage)
+                QueryResults.Matched(
+                    query = query,
+                    pageRange = searchPageRange,
+                    resultBounds = searchResults,
+                    /* Set [queryResultsIndex] to cyclicIterator.current() which points to first result
+                    on or nearest page to currentVisiblePage in forward direction. */
+                    queryResultsIndex = cyclicIterator.current()
+                )
+            } else {
+                QueryResults.NoMatch(query = query, pageRange = searchPageRange)
+            }
 
-            // update initial selection
-            _selectedSearchResult.update { cyclicIterator.current() }
-        }
+        _queryResults.update { queryResults }
     }
 
     /**
      * Iterate through searchResults in backward direction.
      *
-     * Results would be updated to [selectedSearchResult] in the coroutine collecting the flow.
+     * Results would be updated to [queryResults] in the coroutine collecting the flow.
      *
      * Throws [NoSuchElementException] is search results are empty.
      */
-    suspend fun prev() {
-        if (searchResults.value.results.isEmpty())
+    public suspend fun producePreviousResult() {
+        val currentResult = queryResults.value
+
+        if (currentResult !is QueryResults.Matched)
             throw NoSuchElementException("Iteration not possible over empty results")
 
-        _selectedSearchResult.update { cyclicIterator.prev() }
+        /*
+         Create a shallow copy of the query result, updating only the `queryResultIndex`
+         to point to the previous element in the `resultsBounds` of the current query result.
+        */
+        val prevResult =
+            QueryResults.Matched(
+                query = currentResult.query,
+                resultBounds = currentResult.resultBounds,
+                pageRange = currentResult.pageRange,
+                queryResultsIndex = cyclicIterator.prev()
+            )
+
+        _queryResults.update { prevResult }
     }
 
     /**
      * Iterate through searchResults in forward direction.
      *
-     * Results would be updated to [selectedSearchResult] in the coroutine collecting the flow.
+     * Results would be updated to [queryResults] in the coroutine collecting the flow.
      *
      * Throws [NoSuchElementException] is search results are empty.
      */
-    suspend fun next() {
-        if (searchResults.value.results.isEmpty())
+    public suspend fun produceNextResult() {
+        val currentResult = queryResults.value
+
+        if (currentResult !is QueryResults.Matched)
             throw NoSuchElementException("Iteration not possible over empty results")
 
-        _selectedSearchResult.update { cyclicIterator.next() }
+        /*
+         Create a shallow copy of the query result, updating only the `queryResultIndex`
+         to point to the next element in the `resultsBounds` of the current query result.
+        */
+        val nextResult =
+            QueryResults.Matched(
+                query = currentResult.query,
+                resultBounds = currentResult.resultBounds,
+                pageRange = currentResult.pageRange,
+                queryResultsIndex = cyclicIterator.next()
+            )
+
+        _queryResults.update { nextResult }
     }
 
     /**
-     * Resets [searchResults] and [selectedSearchResult] to initial state. This would be required to
-     * handle close/cancel action.
+     * Resets [queryResults] to initial state. This would be required to handle close/cancel action.
      */
-    fun clearSearchResults() {
-        _searchResults.update { SearchResults() }
-        _selectedSearchResult.update { null }
-    }
-
-    /**
-     * Set [searchResults] and [selectedSearchResult] flows to provided value.
-     *
-     * This should be utilized when result state is already available(muck like in restore scenario)
-     */
-    fun setState(
-        searchResults: SearchResults,
-        selectedSearchResult: SelectedSearchResult?,
-        currentVisiblePage: Int
-    ) {
-        _searchResults.update { searchResults }
-        _selectedSearchResult.update { selectedSearchResult }
-        // initiate iterator is results are not empty
-        if (searchResults.results.isNotEmpty())
-            cyclicIterator = CyclicSparseArrayIterator(searchResults.results, currentVisiblePage)
+    public fun clearSearchResults() {
+        _queryResults.update { NoQuery }
     }
 }
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/QueryResults.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/QueryResults.kt
new file mode 100644
index 0000000..52c2f36
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/QueryResults.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.pdf.search.model
+
+import android.util.SparseArray
+import androidx.annotation.RestrictTo
+import androidx.pdf.content.PageMatchBounds
+
+/** A sealed interface that encapsulates the various states of a search operation's result. */
+@RestrictTo(RestrictTo.Scope.LIBRARY) public sealed interface SearchResultState
+
+/**
+ * Represents the initial state when no query has been submitted to trigger a search operation. This
+ * state occurs before any search is initiated.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY) public object NoQuery : SearchResultState
+
+/**
+ * A sealed class representing the outcome of a search operation.
+ *
+ * @param query The search query that initiated the search.
+ * @param pageRange The range of PDF pages involved in the search.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public sealed class QueryResults(public val query: String, public val pageRange: IntRange) :
+    SearchResultState {
+
+    /**
+     * Represents the state when no results are found after a search operation. This indicates that
+     * the search yielded no matching results.
+     *
+     * @param query The search query that was executed.
+     * @param pageRange The range of PDF pages included in the search.
+     */
+    public class NoMatch(query: String, pageRange: IntRange) : QueryResults(query, pageRange)
+
+    /**
+     * Represents the state when a search operation returns results.
+     *
+     * @param query The search query that was executed.
+     * @param pageRange The range of PDF pages included in the search.
+     * @param resultBounds A mapping of match bounds for the results, indexed by their position.
+     * @param queryResultsIndex Represents an index pointer to an element in [resultBounds].
+     */
+    public class Matched(
+        query: String,
+        pageRange: IntRange,
+        public val resultBounds: SparseArray<List<PageMatchBounds>>,
+        public val queryResultsIndex: QueryResultsIndex,
+    ) : QueryResults(query, pageRange)
+}
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/SelectedSearchResult.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/QueryResultsIndex.kt
similarity index 66%
rename from pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/SelectedSearchResult.kt
rename to pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/QueryResultsIndex.kt
index cb39cb7..36aa484 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/SelectedSearchResult.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/QueryResultsIndex.kt
@@ -18,13 +18,13 @@
 
 import androidx.annotation.RestrictTo
 
-/** Model class to hold current selected search result. */
+/** A model class that holds the index of a data element within [QueryResults]'s resultBounds. */
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-internal class SelectedSearchResult(
+public class QueryResultsIndex(
 
-    /** Represents document page number where current search result is selected */
-    val pageNum: Int,
+    /** The page number of the document where the current search result is located. */
+    public val pageNum: Int,
 
-    /** index of result on the page specified by [pageNum] */
-    val currentIndex: Int
+    /** The index of the search result on the page specified by [pageNum]. */
+    public val resultBoundsIndex: Int
 )
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/SearchResults.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/SearchResults.kt
deleted file mode 100644
index 407cd13..0000000
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/search/model/SearchResults.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Copyright 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.pdf.search.model
-
-import android.util.SparseArray
-import androidx.annotation.RestrictTo
-import androidx.pdf.content.PageMatchBounds
-
-/** Model class to hold search results over pdf document for a search query. */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-internal class SearchResults(
-    /**
-     * search query provided to initiate search
-     *
-     * By default it will be empty string.
-     */
-    val searchQuery: String = "",
-    /**
-     * search results in pdf document for [searchQuery]
-     *
-     * By default it will be initialized to empty [SparseArray].
-     */
-    val results: SparseArray<List<PageMatchBounds>> = SparseArray()
-)