Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ section the body of the GitHub release, and refuses a version without one. `scri
- Long text files, PDFs, spreadsheets and presentations open much faster, because the app
lays out only the part near the screen.
- Images in long documents load when you scroll near them.
- Search finds every match in a long document, also far from the screen. It ignores case
and accents, so "cafe" finds "Café".
- A font that the device does not have falls back to one of the same kind, for example a
sans-serif font for Calibri.
- Spaces between words in Word documents no longer go missing.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ class LargeTextTests {
waitFor(TIMEOUT_MS) { needlesInDom(pageView).also { needles = it } == lines },
)

// webview 69 and 83 sometimes report a finished count of 0 for a page whose dom holds
// every line, and the same search a moment later finds them all. so a short count is
// searched again, and the counts go into the failure if it never comes right
// the page's own search, as the find bar runs it: the WebView's find skips the lines that
// lazy layout keeps off screen. A short count is searched again, and the counts go into
// the failure if it never comes right
val counts = mutableListOf<Int>()
waitFor(SEARCH_TIMEOUT_MS) { findAll(pageView, NEEDLE).also { counts += it } == lines }
val elapsed = SystemClock.elapsedRealtime() - start
Expand Down Expand Up @@ -141,16 +141,19 @@ class LargeTextTests {
val found = AtomicInteger(-1)

InstrumentationRegistry.getInstrumentation().runOnMainSync {
pageView.setFindListener { _, numberOfMatches, isDoneCounting ->
if (isDoneCounting) {
found.set(numberOfMatches)
pageView.searchListener = PageView.SearchListener { count, _, complete ->
if (complete) {
found.set(count)
latch.countDown()
}
}
pageView.findAllAsync(needle)
pageView.search(needle)
}

latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)
InstrumentationRegistry.getInstrumentation().runOnMainSync {
pageView.searchListener = null
}

return found.get()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,12 +280,10 @@ class ScreenshotTests {
// a word letter by letter is a dozen searches of the whole document for nothing
onView(withId(R.id.edit)).perform(replaceText(query), closeSoftKeyboard())

// and then the next-match button, as somebody searching would: the web view tints every
// match on findAllAsync but only picks one of them out, and a picture of a search with no
// match picked out is a picture of a search that has not been used yet
// and then the next-match button, as somebody searching would
onView(withId(R.id.find_next)).perform(click())

// the highlights are drawn by the web view a beat after the field says the word
// the page marks the hits a beat after the field says the word
settle()

shoot(locale, Shot.TEXT)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,24 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.webkit.WebView
import android.widget.EditText
import android.widget.TextView
import androidx.appcompat.view.ActionMode
import app.opendocument.droid.R
import app.opendocument.droid.ui.widget.PageView

// https://github.com/aosp-mirror/platform_frameworks_base/blob/a8b1b1a2e62bcca18f52ed31549c93d43728d152/core/java/android/webkit/FindActionModeCallback.java
// drives the page's odr.search rather than the WebView's find, which skips what lazy layout keeps
// off screen
class FindActionModeCallback(context: Context) :
ActionMode.Callback, TextWatcher, View.OnClickListener, WebView.FindListener {
ActionMode.Callback, TextWatcher, View.OnClickListener, PageView.SearchListener {

private val customView: View = LayoutInflater.from(context).inflate(R.layout.webview_find, null)
private val editText: EditText = customView.findViewById(R.id.edit)
private val matches: TextView
private val input: InputMethodManager = context.getSystemService(InputMethodManager::class.java)

private var webView: WebView? = null
private var webView: PageView? = null
private var matchesFound = false
private var numberOfMatches = 0
private var actionMode: ActionMode? = null
Expand Down Expand Up @@ -70,27 +72,23 @@ class FindActionModeCallback(context: Context) :
matchesFound = false
}

/**
* Set the WebView to search.
*
* @param webView an implementation of WebView
*/
fun setWebView(webView: WebView) {
/** Set the page to search. */
fun setWebView(webView: PageView) {
this.webView = webView
webView.setFindListener(this)
webView.searchListener = this
}

private fun requireWebView(caller: String): WebView =
private fun requireWebView(caller: String): PageView =
webView ?: throw AssertionError("No WebView for FindActionModeCallback::$caller")

override fun onFindResultReceived(
activeMatchOrdinal: Int,
numberOfMatches: Int,
isDoneCounting: Boolean,
) {
if (isDoneCounting) {
updateMatchCount(activeMatchOrdinal, numberOfMatches, numberOfMatches == 0)
override fun onSearchChanged(count: Int, current: Int, complete: Boolean) {
// an empty field resets the search, which reports too. A later report brings the hits
// that the page has not counted yet
if (!matchesFound) {
return
}

updateMatchCount(current, count, count == 0)
}

/**
Expand All @@ -110,22 +108,22 @@ class FindActionModeCallback(context: Context) :
// anything.
return
}
webView.findNext(next)
webView.searchNext(editText.text.toString(), next)
}

/** Highlight all the instances of the string from [editText] in the WebView. */
fun findAll() {
val webView = requireWebView("findAll")
val find: CharSequence = editText.text
if (0 == find.length) {
webView.clearMatches()
webView.resetSearch()
matches.visibility = View.GONE
matchesFound = false
} else {
matchesFound = true
matches.visibility = View.INVISIBLE
numberOfMatches = 0
webView.findAllAsync(find.toString())
webView.search(find.toString())
}
}

Expand Down Expand Up @@ -178,7 +176,8 @@ class FindActionModeCallback(context: Context) :
// tolerant, unlike the paths that need a page to search: the mode is started before
// setWebView and dismissing it must not throw out of the framework's own callback
webView?.let {
it.setFindListener(null)
it.searchListener = null
it.resetSearch()
input.hideSoftInputFromWindow(it.windowToken, 0)
}
}
Expand Down
43 changes: 43 additions & 0 deletions app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ constructor(context: Context, attributeSet: AttributeSet?) :
/** Told what the page's editor reports, on the main thread - see [postMessage]. */
var editingListener: EditingListener? = null

/** Told what the page's search reports, on the main thread - see [postMessage]. */
var searchListener: SearchListener? = null

/** What [setEditing] was last told, applied again to every page that loads. */
private var editingKind = EditingKind.NONE
private var isEditing = false
Expand Down Expand Up @@ -385,6 +388,7 @@ constructor(context: Context, attributeSet: AttributeSet?) :
scrollFractionToRestore = null
paragraphListener = null
editingListener = null
searchListener = null
destroyed = true
super.destroy()
}
Expand Down Expand Up @@ -536,6 +540,27 @@ constructor(context: Context, attributeSet: AttributeSet?) :
}
}

/** Marks every hit of [text] and selects the first; [searchListener] hears the count. */
fun search(text: String) {
evaluateJavascript(
"window.odr && odr.search && odr.search(${JSONObject.quote(text)})",
null,
)
}

/** Selects the hit after or before the current one; searches first if [text] is new. */
fun searchNext(text: String, forward: Boolean) {
val function = if (forward) "searchNext" else "searchPrevious"
evaluateJavascript(
"window.odr && odr.$function && odr.$function(${JSONObject.quote(text)})",
null,
)
}

fun resetSearch() {
evaluateJavascript("window.odr && odr.resetSearch && odr.resetSearch()", null)
}

/** What a save hands the core: the page's operations, or a pdf's marks. Null if none. */
fun requestEditPayload(kind: EditingKind, callback: (String?) -> Unit) {
val expression =
Expand Down Expand Up @@ -577,6 +602,16 @@ constructor(context: Context, attributeSet: AttributeSet?) :
val detail = message.optJSONObject("detail") ?: JSONObject()

post {
if (message.optString("type") == "searchChange") {
searchListener?.onSearchChanged(
detail.optInt("count"),
detail.optInt("current", -1),
detail.optBoolean("complete"),
)

return@post
}

val listener = editingListener ?: return@post

when (message.optString("type")) {
Expand Down Expand Up @@ -656,6 +691,14 @@ constructor(context: Context, attributeSet: AttributeSet?) :
fun onCellsStale(count: Int)
}

fun interface SearchListener {

/**
* [current] is the place of the selected hit, -1 if none; [complete] once all are counted.
*/
fun onSearchChanged(count: Int, current: Int, complete: Boolean)
}

companion object {

private const val BRIDGE_NAME = "paragraphListener"
Expand Down
Loading