Skip to content

Commit 60a05e5

Browse files
authored
Merge pull request #4445 from kiwix/Fixes#41
Introduced similarly spelled suggestions results.
2 parents bfa8fed + 5779b6a commit 60a05e5

File tree

19 files changed

+360
-55
lines changed

19 files changed

+360
-55
lines changed

app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/local/LocalLibraryFragment.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,7 @@ class LocalLibraryFragment : BaseFragment(), SelectedZimFileCallback {
513513
@Suppress("InjectDispatcher")
514514
override fun addBookToLibkiwixBookOnDisk(file: File) {
515515
CoroutineScope(Dispatchers.IO).launch {
516-
zimReaderFactory.create(ZimReaderSource(file))
516+
zimReaderFactory.create(ZimReaderSource(file), false)
517517
?.let { zimFileReader ->
518518
val book = Book().apply { update(zimFileReader.jniKiwixReader) }
519519
mainRepositoryActions.saveBook(book)

buildSrc/src/main/kotlin/custom/CustomApp.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ data class CustomApp(
3838
val disableTitle: Boolean = false,
3939
val disableExternalLinks: Boolean = false,
4040
val disableHelpMenu: Boolean = false,
41+
val showSearchSuggestionsSpellChecked: Boolean = false,
4142
val aboutAppUrl: String = "",
4243
val supportUrl: String = ""
4344
) {
@@ -53,6 +54,7 @@ data class CustomApp(
5354
parsedJson.getAndCast("disable_title") ?: false,
5455
parsedJson.getAndCast("disable_external_links") ?: false,
5556
parsedJson.getAndCast("disable_help_menu") ?: false,
57+
parsedJson.getAndCast("show_search_suggestions_spellchecked") ?: false,
5658
parsedJson.getAndCast("about_app_url") ?: "",
5759
parsedJson.getAndCast("support_url") ?: ""
5860
)

buildSrc/src/main/kotlin/custom/CustomApps.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ fun ProductFlavors.create(customApps: List<CustomApp>) {
4848
buildConfigField("String", "ENFORCED_LANG", "\"${customApp.enforcedLanguage}\"")
4949
buildConfigField("String", "ABOUT_APP_URL", "\"${customApp.aboutAppUrl}\"")
5050
buildConfigField("String", "SUPPORT_URL", "\"${customApp.supportUrl}\"")
51+
buildConfigField(
52+
"Boolean",
53+
"SHOW_SEARCH_SUGGESTIONS_SPELLCHECKED", "${customApp.showSearchSuggestionsSpellChecked}"
54+
)
5155
buildConfigField("Boolean", "DISABLE_SIDEBAR", "${customApp.disableSideBar}")
5256
buildConfigField("Boolean", "DISABLE_TABS", "${customApp.disableTabs}")
5357
buildConfigField("Boolean", "DISABLE_READ_ALOUD", "${customApp.disableReadAloud}")

core/src/main/java/org/kiwix/kiwixmobile/core/StorageObserver.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class StorageObserver @Inject constructor(
6363
downloads.none { file.absolutePath.endsWith(it.fileNameFromUrl) }
6464

6565
private suspend fun convertToLibkiwixBook(file: File) =
66-
zimReaderFactory.create(ZimReaderSource(file))
66+
zimReaderFactory.create(ZimReaderSource(file), false)
6767
?.let { zimFileReader ->
6868
libkiwixBookFactory.create().apply {
6969
update(zimFileReader.jniKiwixReader)

core/src/main/java/org/kiwix/kiwixmobile/core/main/reader/CoreReaderFragment.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1543,10 +1543,20 @@ abstract class CoreReaderFragment :
15431543
)
15441544
}
15451545

1546-
private suspend fun openAndSetInContainer(zimReaderSource: ZimReaderSource) {
1546+
/**
1547+
* Creates the ZimFileReader and loads the MainPage.
1548+
* Subclasses override this method to provide the showSearchSuggestion based on configuration.
1549+
*
1550+
* WARNING: If modifying this method, ensure thorough testing with custom apps
1551+
* to verify proper functionality.
1552+
*/
1553+
open suspend fun openAndSetInContainer(
1554+
zimReaderSource: ZimReaderSource,
1555+
showSearchSuggestionsSpellChecked: Boolean = false
1556+
) {
15471557
clearWebViewListIfNotPreviouslyOpenZimFile(zimReaderSource)
15481558
zimReaderContainer?.let { zimReaderContainer ->
1549-
zimReaderContainer.setZimReaderSource(zimReaderSource)
1559+
zimReaderContainer.setZimReaderSource(zimReaderSource, showSearchSuggestionsSpellChecked)
15501560

15511561
zimReaderContainer.zimFileReader?.let { zimFileReader ->
15521562
// uninitialized the service worker to fix https://github.com/kiwix/kiwix-android/issues/2561

core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimFileReader.kt

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,23 @@ import android.util.Base64
2424
import androidx.core.net.toUri
2525
import eu.mhutti1.utils.storage.KB
2626
import kotlinx.coroutines.CoroutineDispatcher
27+
import kotlinx.coroutines.CoroutineScope
2728
import kotlinx.coroutines.Dispatchers
29+
import kotlinx.coroutines.launch
30+
import kotlinx.coroutines.sync.Mutex
31+
import kotlinx.coroutines.sync.withLock
2832
import kotlinx.coroutines.withContext
2933
import org.kiwix.kiwixmobile.core.CoreApp
3034
import org.kiwix.kiwixmobile.core.entity.LibkiwixBook
3135
import org.kiwix.kiwixmobile.core.main.UNINITIALISER_ADDRESS
3236
import org.kiwix.kiwixmobile.core.main.UNINITIALISE_HTML
3337
import org.kiwix.kiwixmobile.core.reader.ZimFileReader.Companion.CONTENT_PREFIX
38+
import org.kiwix.kiwixmobile.core.utils.TAG_KIWIX
3439
import org.kiwix.kiwixmobile.core.utils.files.FileUtils
40+
import org.kiwix.kiwixmobile.core.utils.files.FileUtils.getSpellingDBDir
3541
import org.kiwix.kiwixmobile.core.utils.files.Log
3642
import org.kiwix.libkiwix.JNIKiwixException
43+
import org.kiwix.libkiwix.SpellingsDB
3744
import org.kiwix.libzim.Archive
3845
import org.kiwix.libzim.DirectAccessInfo
3946
import org.kiwix.libzim.Item
@@ -57,20 +64,33 @@ class ZimFileReader constructor(
5764
private val searcher: SuggestionSearcher
5865
) {
5966
interface Factory {
60-
suspend fun create(zimReaderSource: ZimReaderSource): ZimFileReader?
67+
suspend fun create(
68+
zimReaderSource: ZimReaderSource,
69+
showSearchSuggestionsSpellChecked: Boolean
70+
): ZimFileReader?
6171

6272
class Impl @Inject constructor() : Factory {
6373
@Suppress("InjectDispatcher")
64-
override suspend fun create(zimReaderSource: ZimReaderSource): ZimFileReader? =
74+
override suspend fun create(
75+
zimReaderSource: ZimReaderSource,
76+
showSearchSuggestionsSpellChecked: Boolean
77+
): ZimFileReader? =
6578
withContext(Dispatchers.IO) { // Bug Fix #3805
6679
try {
6780
zimReaderSource.createArchive()?.let {
6881
ZimFileReader(
6982
zimReaderSource,
7083
jniKiwixReader = it,
71-
searcher = SuggestionSearcher(it)
72-
).also {
84+
searcher = SuggestionSearcher(it),
85+
).also { zimFileReader ->
7386
Log.e(TAG, "create: ${zimReaderSource.toDatabase()}")
87+
if (showSearchSuggestionsSpellChecked) {
88+
// Prepare the SpellingsDB asynchronously(when it configure to create) so that creating the
89+
// ZIM reader doesn’t block the user experience.
90+
CoroutineScope(Dispatchers.IO).launch {
91+
zimFileReader.prepareSpellingsDB(zimFileReader.jniKiwixReader)
92+
}
93+
}
7494
}
7595
} ?: kotlin.run {
7696
Log.e(
@@ -101,6 +121,8 @@ class ZimFileReader constructor(
101121
}
102122
}
103123

124+
private var spellingsDB: SpellingsDB? = null
125+
104126
/**
105127
* Note that the value returned is NOT unique for each zim file. Versions of the same wiki
106128
* (complete, nopic, novid, etc) may return the same title.
@@ -173,6 +195,52 @@ class ZimFileReader constructor(
173195
null
174196
}
175197

198+
/**
199+
* Initializes the `SpellingsDB` instance using the currently opened ZIM archive.
200+
*
201+
* This prepares the spell correction database used by libkiwix
202+
* for suggesting alternative or corrected search terms.
203+
*/
204+
suspend fun prepareSpellingsDB(archive: Archive) {
205+
spellingsDBCreationMutex.withLock {
206+
if (spellingsDB != null) {
207+
Log.d(TAG_KIWIX, "SpellingsDB already initialized, skipping.")
208+
return
209+
}
210+
runCatching {
211+
Log.d(TAG_KIWIX, "Initializing SpellingsDB")
212+
val cachedDir = getSpellingDBDir(CoreApp.instance)?.absolutePath
213+
spellingsDB = SpellingsDB(archive, cachedDir)
214+
Log.d(TAG_KIWIX, "SpellingsDB successfully initialized.")
215+
}.onFailure {
216+
Log.e(
217+
TAG_KIWIX,
218+
"Failed to initialize SpellingsDB: ${it.message}",
219+
it
220+
)
221+
}
222+
}
223+
}
224+
225+
/**
226+
* Retrieves a list of suggested or corrected spellings for a given search term.
227+
*
228+
* @param word The search term for which to retrieve spelling suggestions.
229+
* @param maxCount The maximum number of suggestions to return.
230+
* @return A list of suggested words ordered by relevance, or an empty list if
231+
* suggestions are unavailable or the SpellingsDB is not initialized.
232+
*/
233+
fun getSuggestedSpelledWords(word: String, maxCount: Int): List<String> =
234+
runCatching {
235+
spellingsDB?.getSpellingCorrections(word, maxCount)?.toList().orEmpty()
236+
}.onFailure {
237+
Log.e(
238+
TAG,
239+
"Error fetching suggested spellings: ${it.message}",
240+
it
241+
)
242+
}.getOrDefault(emptyList())
243+
176244
fun getPageUrlFrom(title: String): String? =
177245
try {
178246
jniKiwixReader.getEntryByTitle(title).path
@@ -386,6 +454,7 @@ class ZimFileReader constructor(
386454
fun dispose() {
387455
jniKiwixReader.dispose()
388456
searcher.dispose()
457+
spellingsDB?.dispose()
389458
}
390459

391460
@Suppress("TooGenericExceptionCaught")
@@ -397,6 +466,8 @@ class ZimFileReader constructor(
397466
}
398467

399468
companion object {
469+
private val spellingsDBCreationMutex = Mutex()
470+
400471
/*
401472
* these uris aren't actually nullable but unit tests fail to compile as
402473
* Uri.parse returns null without android dependencies loaded

core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimReaderContainer.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,16 @@ class ZimReaderContainer @Inject constructor(private val zimFileReaderFactory: F
3535
}
3636

3737
@Suppress("InjectDispatcher")
38-
suspend fun setZimReaderSource(zimReaderSource: ZimReaderSource?) {
38+
suspend fun setZimReaderSource(
39+
zimReaderSource: ZimReaderSource?,
40+
showSearchSuggestionsSpellChecked: Boolean = false
41+
) {
3942
if (zimReaderSource == zimFileReader?.zimReaderSource) {
4043
return
4144
}
4245
zimFileReader = withContext(Dispatchers.IO) {
4346
if (zimReaderSource?.exists() == true && zimReaderSource.canOpenInLibkiwix()) {
44-
zimFileReaderFactory.create(zimReaderSource)
47+
zimFileReaderFactory.create(zimReaderSource, showSearchSuggestionsSpellChecked)
4548
} else {
4649
null
4750
}

core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimReaderSource.kt

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import androidx.appcompat.app.AppCompatActivity
2424
import androidx.core.content.FileProvider
2525
import androidx.core.net.toUri
2626
import org.kiwix.kiwixmobile.core.CoreApp
27+
import org.kiwix.kiwixmobile.core.downloader.downloadManager.ZERO
2728
import org.kiwix.kiwixmobile.core.extensions.canReadFile
2829
import org.kiwix.kiwixmobile.core.extensions.isFileExist
2930
import org.kiwix.kiwixmobile.core.utils.files.FileUtils.getAssetFileDescriptorFromUri
@@ -60,7 +61,7 @@ class ZimReaderSource(
6061
return when {
6162
file != null -> file.isFileExist()
6263
assetFileDescriptorList?.isNotEmpty() == true ->
63-
assetFileDescriptorList[0].parcelFileDescriptor.fileDescriptor.valid()
64+
assetFileDescriptorList.first().parcelFileDescriptor.fileDescriptor.valid()
6465

6566
else -> false
6667
}
@@ -69,7 +70,7 @@ class ZimReaderSource(
6970
suspend fun canOpenInLibkiwix(): Boolean {
7071
return when {
7172
file?.canReadFile() == true -> true
72-
assetFileDescriptorList?.get(0)?.parcelFileDescriptor?.fd
73+
assetFileDescriptorList?.first()?.parcelFileDescriptor?.fd
7374
?.let(::isFileDescriptorCanOpenWithLibkiwix) == true -> true
7475

7576
else -> false
@@ -109,12 +110,25 @@ class ZimReaderSource(
109110

110111
fun toDatabase(): String = file?.canonicalPath ?: uri.toString()
111112

113+
/**
114+
* Compares two sources for equality based on the underlying file, URI,
115+
* or descriptor list.
116+
*/
112117
override fun equals(other: Any?): Boolean {
118+
if (this === other) return true
119+
if (other !is ZimReaderSource) return false
113120
return when {
114-
file != null && other is ZimReaderSource && other.file != null ->
121+
file != null && other.file != null ->
115122
file.canonicalPath == other.file.canonicalPath
116123

117-
uri != null && other is ZimReaderSource && other.uri != null -> uri == other.uri
124+
uri != null && other.uri != null -> uri == other.uri
125+
126+
!assetFileDescriptorList.isNullOrEmpty() && !other.assetFileDescriptorList.isNullOrEmpty() ->
127+
assetFileDescriptorList.size == other.assetFileDescriptorList.size &&
128+
assetFileDescriptorList.zip(other.assetFileDescriptorList).all { (a, b) ->
129+
a.startOffset == b.startOffset && a.length == b.length
130+
}
131+
118132
else -> false
119133
}
120134
}
@@ -133,5 +147,12 @@ class ZimReaderSource(
133147
}
134148
}
135149

136-
override fun hashCode(): Int = file?.hashCode() ?: assetFileDescriptorList.hashCode()
150+
override fun hashCode(): Int = when {
151+
file != null -> file.canonicalPath.hashCode()
152+
uri != null -> uri.hashCode()
153+
!assetFileDescriptorList.isNullOrEmpty() ->
154+
assetFileDescriptorList.sumOf { it.startOffset.hashCode() + it.length.hashCode() }
155+
156+
else -> ZERO
157+
}
137158
}

core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ClickedSearchInText
5151
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnItemClick
5252
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnItemLongClick
5353
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnOpenInNewTabClick
54+
import org.kiwix.kiwixmobile.core.search.viewmodel.MAX_SUGGEST_WORD_COUNT
5455
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
5556
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchState
5657
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchViewModel
@@ -102,7 +103,9 @@ class SearchFragment : BaseFragment() {
102103
onLoadMore = { loadMoreSearchResult() },
103104
onKeyboardSubmitButtonClick = {
104105
getSearchListItemForQuery(it)?.let(::onItemClick)
105-
}
106+
},
107+
spellingCorrectionSuggestions = emptyList(),
108+
onSuggestionClick = { onSuggestionItemClick(it) }
106109
)
107110
)
108111

@@ -247,6 +250,11 @@ class SearchFragment : BaseFragment() {
247250
searchViewModel.searchResults(searchText.trim())
248251
}
249252

253+
private fun onSuggestionItemClick(suggestionText: String) {
254+
searchScreenState.update { copy(spellingCorrectionSuggestions = emptyList()) }
255+
onSearchValueChanged(suggestionText)
256+
}
257+
250258
private fun actionMenuItems() = listOfNotNull(
251259
// Check if the `FIND_IN_PAGE` is visible or not.
252260
// If visible then show it in menu.
@@ -304,11 +312,34 @@ class SearchFragment : BaseFragment() {
304312
"Error in getting searched result\nOriginal exception ${ignore.message}"
305313
)
306314
} finally {
307-
searchScreenState.update { copy(isLoading = false) }
315+
updateSuggestedWords()
308316
}
309317
}
310318
}
311319

320+
/**
321+
* Updates the suggested word list using the libkiwix spellings database.
322+
*/
323+
private suspend fun updateSuggestedWords() {
324+
val onlyRecentSearches =
325+
searchScreenState.value.searchList.all { it is SearchListItem.RecentSearchListItem }
326+
327+
if (onlyRecentSearches && searchScreenState.value.searchText.isNotEmpty()) {
328+
val suggestedWords = searchViewModel.getSuggestedSpelledWords(
329+
searchScreenState.value.searchText,
330+
MAX_SUGGEST_WORD_COUNT
331+
)
332+
333+
searchScreenState.update {
334+
copy(spellingCorrectionSuggestions = suggestedWords, isLoading = false)
335+
}
336+
} else {
337+
searchScreenState.update {
338+
copy(spellingCorrectionSuggestions = emptyList(), isLoading = false)
339+
}
340+
}
341+
}
342+
312343
private fun setIsPageSearchEnabled(searchText: String) {
313344
findInPageMenuItem.value = searchText.isNotBlank() to findInPageMenuItem.value.second
314345
}

0 commit comments

Comments
 (0)