Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ crashlytics-build.properties
com_crashlytics_export_strings.xml
extras
app/google-services.json
feature/voicesearch/libs/*.aar
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ android {
resources {
excludes += setOf("META-INF/*.kotlin_module", "META-INF/DEPENDENCIES", "META-INF/INDEX.LIST")
}
jniLibs {
pickFirsts += setOf("**/libonnxruntime.so")
}
}
}

Expand Down Expand Up @@ -155,6 +158,7 @@ dependencies {
implementation(project(":feature:audiobar"))
implementation(project(":feature:downloadmanager"))
implementation(project(":feature:qarilist"))
implementation(project(":feature:voicesearch"))

// android auto support
implementation(project(":feature:autoquran"))
Expand Down
179 changes: 176 additions & 3 deletions app/src/main/java/com/quran/labs/androidquran/SearchActivity.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.quran.labs.androidquran

import android.Manifest
import android.annotation.SuppressLint
import android.app.SearchManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.database.Cursor
import android.os.Bundle
import android.text.Html
Expand All @@ -20,13 +22,18 @@ import android.widget.Button
import android.widget.CursorAdapter
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.loader.app.LoaderManager
import androidx.loader.content.CursorLoader
import androidx.loader.content.Loader
Expand All @@ -43,7 +50,11 @@ import com.quran.labs.androidquran.ui.PagerActivity
import com.quran.labs.androidquran.ui.TranslationManagerActivity
import com.quran.labs.androidquran.util.QuranFileUtils
import com.quran.labs.androidquran.util.QuranUtils
import com.quran.labs.androidquran.view.PulsingMicView
import com.quran.mobile.di.InlineVoiceSearchController
import com.quran.mobile.di.InlineVoiceSearchState
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.launch

/**
* Activity for searching the Quran
Expand All @@ -60,6 +71,10 @@ class SearchActivity : AppCompatActivity(), SimpleDownloadListener,
private lateinit var warningView: TextView
private lateinit var buttonGetTranslations: Button

private var searchView: SearchView? = null
private var voiceSearchItem: MenuItem? = null
private var pulsingMicView: PulsingMicView? = null

@Inject
lateinit var quranInfo: QuranInfo

Expand All @@ -69,6 +84,20 @@ class SearchActivity : AppCompatActivity(), SimpleDownloadListener,
@Inject
lateinit var quranFileUtils: QuranFileUtils

@Inject
lateinit var inlineVoiceSearchControllers: Set<@JvmSuppressWildcards InlineVoiceSearchController>

private val inlineController: InlineVoiceSearchController?
get() = inlineVoiceSearchControllers.firstOrNull()

private val recordPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
startInlineRecording()
}
}

public override fun onCreate(savedInstanceState: Bundle?) {
// override these to always be dark since the app doesn't really
// have a light theme until now. without this, the clock color in
Expand Down Expand Up @@ -127,22 +156,150 @@ class SearchActivity : AppCompatActivity(), SimpleDownloadListener,
}
}
handleIntent(intent)

observeInlineVoiceSearchState()
}

private fun observeInlineVoiceSearchState() {
val controller = inlineController ?: return
lifecycleScope.launch {
controller.state.collect { state ->
when (state) {
is InlineVoiceSearchState.Recording -> {
showPulsingMic()
lockSearchInput()
if (state.partialText.isNotEmpty()) {
searchView?.setQuery(state.partialText, false)
}
}
is InlineVoiceSearchState.FinalResult -> {
stopPulsingMic()
unlockSearchInput()
if (state.text.isNotEmpty()) {
showResults(state.text)
}
controller.reset()
}
is InlineVoiceSearchState.ModelNotReady -> {
stopPulsingMic()
unlockSearchInput()
showModelNotReadyDialog()
controller.reset()
}
is InlineVoiceSearchState.Error -> {
stopPulsingMic()
unlockSearchInput()
Toast.makeText(this@SearchActivity, state.message, Toast.LENGTH_SHORT).show()
controller.reset()
}
is InlineVoiceSearchState.Idle -> {
stopPulsingMic()
unlockSearchInput()
}
}
}
}
}

private fun showModelNotReadyDialog() {
AlertDialog.Builder(this)
.setTitle(R.string.voice_search)
.setMessage(R.string.voice_search_model_not_ready)
.setPositiveButton(R.string.menu_settings) { dialog, _ ->
dialog.dismiss()
startActivity(Intent(this, QuranPreferenceActivity::class.java))
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}

private fun lockSearchInput() {
val sv = searchView ?: return
if (!sv.isEnabled) return
sv.clearFocus()
sv.isEnabled = false
}

private fun unlockSearchInput() {
val sv = searchView ?: return
sv.isEnabled = true
}

private fun showPulsingMic() {
val item = voiceSearchItem ?: return
if (pulsingMicView != null) return

val color = ContextCompat.getColor(this, R.color.voice_search_recording)
val micView = PulsingMicView(this, color)
micView.setOnClickListener { toggleRecording() }
item.actionView = micView
micView.startAnimation()
pulsingMicView = micView
}

private fun stopPulsingMic() {
pulsingMicView?.stopAnimation()
pulsingMicView = null
voiceSearchItem?.actionView = null
}

private fun toggleRecording() {
val controller = inlineController ?: return
when (controller.state.value) {
is InlineVoiceSearchState.Recording -> controller.stopRecording()
is InlineVoiceSearchState.Idle -> {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED
) {
startInlineRecording()
} else {
recordPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
else -> { /* ignore during transitional states */ }
}
}

private fun startInlineRecording() {
inlineController?.startRecording()
}

override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.search_menu, menu)
val searchItem = menu.findItem(R.id.search)
val searchView = searchItem.actionView as SearchView?
searchView = searchItem.actionView as SearchView?
val searchManager = (getSystemService(SEARCH_SERVICE) as SearchManager)
searchView?.setSearchableInfo(searchManager.getSearchableInfo(componentName))

// Only show voice search icon if the feature is enabled in settings
voiceSearchItem = menu.findItem(R.id.voice_search)
val voiceSearchEnabled = inlineController?.isEnabled == true

searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
voiceSearchItem?.isVisible = voiceSearchEnabled
return true
}

override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
// Post to avoid interfering with the toolbar collapse re-layout
findViewById<Toolbar>(R.id.toolbar).post {
voiceSearchItem?.isVisible = false
}
return true
}
})

val intent = getIntent()
if (Intent.ACTION_SEARCH == intent.action) {
// Make sure the keyboard is hidden if doing a search from within this activity
searchItem.expandActionView()
val query = intent.getStringExtra(SearchManager.QUERY)
searchView?.setQuery(query, false)
searchView?.clearFocus()
voiceSearchItem?.isVisible = voiceSearchEnabled
} else if (intent.action == null) {
// If no action is specified, just open the keyboard so the user can quickly start searching
// Open the keyboard so the user can quickly start searching
searchItem.expandActionView()
}
return true
Expand All @@ -152,6 +309,9 @@ class SearchActivity : AppCompatActivity(), SimpleDownloadListener,
if (item.itemId == android.R.id.home) {
finish()
return true
} else if (item.itemId == R.id.voice_search) {
toggleRecording()
return true
}
return super.onOptionsItemSelected(item)
}
Expand All @@ -166,6 +326,14 @@ class SearchActivity : AppCompatActivity(), SimpleDownloadListener,
super.onPause()
}

override fun onDestroy() {
val controller = inlineController
if (controller != null && controller.state.value is InlineVoiceSearchState.Recording) {
controller.stopRecording()
}
super.onDestroy()
}

private fun downloadArabicSearchDb() {
if (downloadReceiver == null) {
val receiver = DefaultDownloadReceiver(
Expand Down Expand Up @@ -349,6 +517,11 @@ class SearchActivity : AppCompatActivity(), SimpleDownloadListener,
}

private fun jumpToResult(sura: Int, ayah: Int) {
val controller = inlineController
if (controller != null && controller.state.value is InlineVoiceSearchState.Recording) {
controller.reset()
}

val page = quranInfo.getPageFromSuraAyah(sura, ayah)
val intent = Intent(this, PagerActivity::class.java)
intent.putExtra(PagerActivity.EXTRA_HIGHLIGHT_SURA, sura)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import com.quran.data.di.AppScope
import com.quran.data.source.DisplaySize
import com.quran.data.source.PageProvider
import com.quran.data.source.PageSizeCalculator
import com.quran.labs.androidquran.model.translation.QuranVerseProviderImpl
import com.quran.labs.androidquran.util.QuranFileUtils
import com.quran.labs.androidquran.util.QuranSettings
import com.quran.labs.androidquran.util.SettingsImpl
import com.quran.mobile.di.ExtraPreferencesProvider
import com.quran.mobile.di.ExtraScreenProvider
import com.quran.mobile.feature.voicesearch.matching.QuranVerseProvider
import com.quran.mobile.di.qualifier.ApplicationContext
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ElementsIntoSet
Expand Down Expand Up @@ -108,4 +110,10 @@ object ApplicationModule {
fun provideExtraScreens(): Set<ExtraScreenProvider> {
return emptySet()
}

@Provides
@SingleIn(AppScope::class)
fun provideQuranVerseProvider(impl: QuranVerseProviderImpl): QuranVerseProvider {
return impl
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.quran.labs.androidquran.model.translation

import android.content.Context
import com.quran.labs.androidquran.data.QuranDataProvider
import com.quran.labs.androidquran.data.QuranFileConstants
import com.quran.labs.androidquran.database.DatabaseHandler
import com.quran.labs.androidquran.database.DatabaseUtils
import com.quran.labs.androidquran.util.QuranFileUtils
import com.quran.mobile.di.qualifier.ApplicationContext
import com.quran.common.search.SearchTextUtil
import com.quran.mobile.feature.voicesearch.matching.IndexedVerse
import com.quran.mobile.feature.voicesearch.matching.QuranVerseProvider
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import com.quran.data.di.AppScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

@Suppress("DEPRECATION")
@SingleIn(AppScope::class)
class QuranVerseProviderImpl @Inject constructor(
@ApplicationContext private val appContext: Context,
private val quranFileUtils: QuranFileUtils
) : QuranVerseProvider {

private var cachedVerses: List<IndexedVerse>? = null

override suspend fun getAllVerses(): List<IndexedVerse> {
cachedVerses?.let { return it }

return withContext(Dispatchers.IO) {
val verses = mutableListOf<IndexedVerse>()
try {
val handler = DatabaseHandler.getDatabaseHandler(
appContext,
QuranDataProvider.QURAN_ARABIC_DATABASE,
quranFileUtils
)

var cursor: android.database.Cursor? = null
try {
cursor = handler.getVerses(1, 1, 114, 6, QuranFileConstants.ARABIC_SHARE_TABLE)
while (cursor?.moveToNext() == true) {
val sura = cursor.getInt(1)
val ayah = cursor.getInt(2)
val rawText = cursor.getString(3)
val cleanText = ArabicDatabaseUtils.getAyahWithoutBasmallah(sura, ayah, rawText)
val normalized = SearchTextUtil.normalizeArabic(cleanText)
verses.add(
IndexedVerse(
sura = sura,
ayah = ayah,
rawText = cleanText,
normalizedText = normalized,
words = SearchTextUtil.tokenizeArabic(normalized)
)
)
}
} finally {
DatabaseUtils.closeCursor(cursor)
}
} catch (e: Exception) {
// Database not available yet - return empty list
}

cachedVerses = verses
verses
}
}
}
Loading