From 378b241e37d31b0807258c8de593a9bc3e5a9306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Fri, 3 Nov 2023 14:40:59 +0100 Subject: [PATCH 1/5] Add empty constructor for `EpubNavigatorFragment` --- .../readium/r2/navigator/DummyPublication.kt | 19 +++++++++++++++++++ .../navigator/epub/EpubNavigatorFragment.kt | 17 +++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 readium/navigator/src/main/java/org/readium/r2/navigator/DummyPublication.kt diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/DummyPublication.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/DummyPublication.kt new file mode 100644 index 0000000000..4a03933ae1 --- /dev/null +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/DummyPublication.kt @@ -0,0 +1,19 @@ +package org.readium.r2.navigator + +import org.readium.r2.shared.publication.LocalizedString +import org.readium.r2.shared.publication.Manifest +import org.readium.r2.shared.publication.Metadata +import org.readium.r2.shared.publication.Publication + +object RestorationNotSupportedException : Exception( + "Restoration of the navigator fragment after process death is not supported. You must pop it from the back stack or finish the host Activity before `onResume`." +) + +internal val dummyPublication = Publication( + Manifest( + metadata = Metadata( + identifier = "readium:dummy", + localizedTitle = LocalizedString("") + ) + ) +) diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt index 782912dc7d..7bc840c61c 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt @@ -100,6 +100,18 @@ class EpubNavigatorFragment internal constructor( configuration: Configuration, ) : Fragment(), VisualNavigator, SelectableNavigator, DecorableNavigator, Configurable { + constructor() : this( + publication = dummyPublication, + baseUrl = null, + initialLocator = Locator(href = "#", type = "application/xhtml+xml"), + initialPreferences = EpubPreferences(), + listener = null, + paginationListener = null, + epubLayout = EpubLayout.REFLOWABLE, + defaults = EpubDefaults(), + configuration = Configuration() + ) + // Make a copy to prevent the user from modifying the configuration after initialization. internal val config: Configuration = configuration.copy().apply { servedAssets += "readium/.*" @@ -550,6 +562,11 @@ class EpubNavigatorFragment internal constructor( override fun onResume() { super.onResume() + + if (publication == dummyPublication) { + throw RestorationNotSupportedException + } + notifyCurrentLocation() } From bf6e2e1bc458b979cad052282a3c836d04f4ff45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Mon, 6 Nov 2023 14:00:08 +0100 Subject: [PATCH 2/5] Add a dummy fragment factory for all navigators --- .../navigator/epub/EpubNavigatorFragment.kt | 33 ++++++++++------ .../navigator/image/ImageNavigatorFragment.kt | 32 ++++++++++++++-- .../r2/navigator/pdf/PdfNavigatorFragment.kt | 38 +++++++++++++++++-- .../org/readium/r2/testapp/Application.kt | 17 ++++----- .../testapp/bookshelf/BookshelfViewModel.kt | 4 +- .../r2/testapp/reader/EpubReaderFragment.kt | 10 ++++- .../r2/testapp/reader/ImageReaderFragment.kt | 10 ++++- .../r2/testapp/reader/PdfReaderFragment.kt | 13 ++++++- .../r2/testapp/reader/ReaderActivity.kt | 9 ----- .../r2/testapp/reader/ReaderViewModel.kt | 5 +-- 10 files changed, 123 insertions(+), 48 deletions(-) diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt index 7bc840c61c..571bcaa24e 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt @@ -100,18 +100,6 @@ class EpubNavigatorFragment internal constructor( configuration: Configuration, ) : Fragment(), VisualNavigator, SelectableNavigator, DecorableNavigator, Configurable { - constructor() : this( - publication = dummyPublication, - baseUrl = null, - initialLocator = Locator(href = "#", type = "application/xhtml+xml"), - initialPreferences = EpubPreferences(), - listener = null, - paginationListener = null, - epubLayout = EpubLayout.REFLOWABLE, - defaults = EpubDefaults(), - configuration = Configuration() - ) - // Make a copy to prevent the user from modifying the configuration after initialization. internal val config: Configuration = configuration.copy().apply { servedAssets += "readium/.*" @@ -1083,6 +1071,27 @@ class EpubNavigatorFragment internal constructor( ) } + /** + * Creates a factory for a dummy [EpubNavigatorFragment]. + * + * Used when Android restore the [EpubNavigatorFragment] after the process was killed. You + * need to make sure the fragment is removed from the screen before [onResume] is called. + */ + fun createDummyFactory(): FragmentFactory = createFragmentFactory { + EpubNavigatorFragment( + publication = dummyPublication, + baseUrl = null, + initialLocator = Locator(href = "#", type = "application/xhtml+xml"), + readingOrder = null, + initialPreferences = EpubPreferences(), + listener = null, + paginationListener = null, + epubLayout = EpubLayout.REFLOWABLE, + defaults = EpubDefaults(), + configuration = Configuration() + ) + } + /** * Returns a URL to the application asset at [path], served in the web views. */ diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/image/ImageNavigatorFragment.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/image/ImageNavigatorFragment.kt index a09dc468e8..6aaca39a90 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/image/ImageNavigatorFragment.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/image/ImageNavigatorFragment.kt @@ -23,9 +23,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.runBlocking +import org.readium.r2.navigator.RestorationNotSupportedException import org.readium.r2.navigator.SimplePresentation import org.readium.r2.navigator.VisualNavigator import org.readium.r2.navigator.databinding.ActivityR2ViewpagerBinding +import org.readium.r2.navigator.dummyPublication import org.readium.r2.navigator.extensions.layoutDirectionIsRTL import org.readium.r2.navigator.pager.R2CbzPageFragment import org.readium.r2.navigator.pager.R2PagerAdapter @@ -155,6 +157,14 @@ class ImageNavigatorFragment private constructor( notifyCurrentLocation() } + override fun onResume() { + super.onResume() + + if (publication == dummyPublication) { + throw RestorationNotSupportedException + } + } + override fun onDestroyView() { super.onDestroyView() _binding = null @@ -173,10 +183,10 @@ class ImageNavigatorFragment private constructor( } private fun notifyCurrentLocation() { - val locator = positions[resourcePager.currentItem] - if (locator == _currentLocator.value) { - return - } + val locator = positions.getOrNull(resourcePager.currentItem) + ?.takeUnless { it == _currentLocator.value } + ?: return + _currentLocator.value = locator } @@ -240,5 +250,19 @@ class ImageNavigatorFragment private constructor( listener: Listener? = null ): FragmentFactory = createFragmentFactory { ImageNavigatorFragment(publication, initialLocator, listener) } + + /** + * Creates a factory for a dummy [ImageNavigatorFragment]. + * + * Used when Android restore the [ImageNavigatorFragment] after the process was killed. You + * need to make sure the fragment is removed from the screen before `onResume` is called. + */ + fun createDummyFactory(): FragmentFactory = createFragmentFactory { + ImageNavigatorFragment( + publication = dummyPublication, + initialLocator = Locator(href = "#", type = "image/jpg"), + listener = null + ) + } } } diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/pdf/PdfNavigatorFragment.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/pdf/PdfNavigatorFragment.kt index 7ef1bdd98e..ec69069945 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/pdf/PdfNavigatorFragment.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/pdf/PdfNavigatorFragment.kt @@ -18,7 +18,9 @@ import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.readium.r2.navigator.R +import org.readium.r2.navigator.RestorationNotSupportedException import org.readium.r2.navigator.VisualNavigator +import org.readium.r2.navigator.dummyPublication import org.readium.r2.navigator.extensions.page import org.readium.r2.navigator.preferences.Configurable import org.readium.r2.navigator.preferences.PreferencesEditor @@ -87,15 +89,35 @@ class PdfNavigatorFragment> createDummyFactory( + pdfEngineProvider: PdfEngineProvider<*, P, *> + ): FragmentFactory = createFragmentFactory { + PdfNavigatorFragment( + publication = dummyPublication, + initialLocator = Locator(href = "#", type = "application/pdf"), + initialPreferences = pdfEngineProvider.createEmptyPreferences(), + listener = null, + pdfEngineProvider = pdfEngineProvider + ) + } } init { require(!publication.isRestricted) { "The provided publication is restricted. Check that any DRM was properly unlocked using a Content Protection." } - require( - publication.readingOrder.count() == 1 && - publication.readingOrder.first().mediaType.matches(MediaType.PDF) - ) { "[PdfNavigatorFragment] currently supports only publications with a single PDF for reading order" } + if (publication != dummyPublication) { + require( + publication.readingOrder.count() == 1 && + publication.readingOrder.first().mediaType.matches(MediaType.PDF) + ) { "[PdfNavigatorFragment] currently supports only publications with a single PDF for reading order" } + } } // Configurable @@ -167,6 +189,14 @@ class PdfNavigatorFragment? { val link = publication.linkWithHref(locator.href) ?: return null diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index 57aaa248c0..accdf9c510 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -30,7 +30,7 @@ class Application : android.app.Application() { lateinit var bookRepository: BookRepository private set - lateinit var readerRepository: Deferred + lateinit var readerRepository: ReaderRepository private set private val coroutineScope: CoroutineScope = @@ -63,15 +63,12 @@ class Application : android.app.Application() { ) } - readerRepository = - coroutineScope.async { - ReaderRepository( - this@Application, - readium, - bookRepository, - navigatorPreferences, - ) - } + readerRepository = ReaderRepository( + this@Application, + readium, + bookRepository, + navigatorPreferences + ) } private fun computeStorageDir(): File { diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt index ec97f4a1ed..a793a6bc89 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt @@ -92,8 +92,8 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio bookId: Long, activity: Activity ) = viewModelScope.launch { - val readerRepository = app.readerRepository.await() - readerRepository.open(bookId, activity) + app.readerRepository + .open(bookId, activity) .onFailure { exception -> if (exception is ReaderRepository.CancellationException) return@launch diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/EpubReaderFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/EpubReaderFragment.kt index 545dc4c87a..d87d3d4796 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/EpubReaderFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/EpubReaderFragment.kt @@ -49,7 +49,15 @@ class EpubReaderFragment : VisualReaderFragment(), EpubNavigatorFragment.Listene isSearchViewIconified = savedInstanceState.getBoolean(IS_SEARCH_VIEW_ICONIFIED) } - val readerData = model.readerInitData as EpubReaderInitData + val readerData = model.readerInitData as? EpubReaderInitData ?: run { + // We provide a dummy fragment factory if the ReaderActivity is restored after the + // app process was killed because the ReaderRepository is empty. In that case, finish + // the activity as soon as possible and go back to the previous one. + childFragmentManager.fragmentFactory = EpubNavigatorFragment.createDummyFactory() + super.onCreate(savedInstanceState) + requireActivity().finish() + return + } childFragmentManager.fragmentFactory = readerData.navigatorFactory.createFragmentFactory( diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/ImageReaderFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/ImageReaderFragment.kt index 6854ca3ff4..ad7fdbb21e 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/ImageReaderFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/ImageReaderFragment.kt @@ -20,7 +20,15 @@ class ImageReaderFragment : VisualReaderFragment(), ImageNavigatorFragment.Liste override lateinit var navigator: Navigator override fun onCreate(savedInstanceState: Bundle?) { - val readerData = model.readerInitData as VisualReaderInitData + val readerData = model.readerInitData as? VisualReaderInitData ?: run { + // We provide a dummy fragment factory if the ReaderActivity is restored after the + // app process was killed because the ReaderRepository is empty. In that case, finish + // the activity as soon as possible and go back to the previous one. + childFragmentManager.fragmentFactory = ImageNavigatorFragment.createDummyFactory() + super.onCreate(savedInstanceState) + requireActivity().finish() + return + } childFragmentManager.fragmentFactory = ImageNavigatorFragment.createFactory(publication, readerData.initialLocation, this) diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/PdfReaderFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/PdfReaderFragment.kt index f668588454..7605704b69 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/PdfReaderFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/PdfReaderFragment.kt @@ -12,6 +12,7 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.commitNow +import org.readium.adapters.pdfium.navigator.PdfiumEngineProvider import org.readium.adapters.pdfium.navigator.PdfiumPreferences import org.readium.adapters.pdfium.navigator.PdfiumSettings import org.readium.r2.navigator.pdf.PdfNavigatorFragment @@ -27,7 +28,17 @@ class PdfReaderFragment : VisualReaderFragment(), PdfNavigatorFragment.Listener override lateinit var navigator: PdfNavigatorFragment override fun onCreate(savedInstanceState: Bundle?) { - val readerData = model.readerInitData as PdfReaderInitData + val readerData = model.readerInitData as? PdfReaderInitData ?: run { + // We provide a dummy fragment factory if the ReaderActivity is restored after the + // app process was killed because the ReaderRepository is empty. In that case, finish + // the activity as soon as possible and go back to the previous one. + childFragmentManager.fragmentFactory = PdfNavigatorFragment.createDummyFactory( + pdfEngineProvider = PdfiumEngineProvider() + ) + super.onCreate(savedInstanceState) + requireActivity().finish() + return + } childFragmentManager.fragmentFactory = readerData.navigatorFactory.createFragmentFactory( diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderActivity.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderActivity.kt index 42354a09fe..e3b1465a38 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderActivity.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderActivity.kt @@ -47,15 +47,6 @@ open class ReaderActivity : AppCompatActivity() { private lateinit var readerFragment: BaseReaderFragment override fun onCreate(savedInstanceState: Bundle?) { - /* - * We provide dummy publications if the [ReaderActivity] is restored after the app process - * was killed because the [ReaderRepository] is empty. - * In that case, finish the activity as soon as possible and go back to the previous one. - */ - if (model.publication.readingOrder.isEmpty()) { - finish() - } - super.onCreate(savedInstanceState) val binding = ActivityReaderBinding.inflate(layoutInflater) diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt index 2ddf3868ef..a20d3f1152 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt @@ -263,10 +263,7 @@ class ReaderViewModel( companion object { fun createFactory(application: Application, arguments: ReaderActivityContract.Arguments) = createViewModelFactory { - val readerRepository = - application.readerRepository.getCompleted() - - ReaderViewModel(arguments.bookId, readerRepository, application.bookRepository) + ReaderViewModel(arguments.bookId, application.readerRepository, application.bookRepository) } } } From bee39cd2e01f4e829bc6d6935fea412146102687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Mon, 6 Nov 2023 14:09:07 +0100 Subject: [PATCH 3/5] Update changelog --- CHANGELOG.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa1f41ca4..e7ca63fe7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,24 @@ All notable changes to this project will be documented in this file. Take a look * Support for non-linear EPUB resources with an opt-in in reading apps (contributed by @chrfalch in [#375](https://github.com/readium/kotlin-toolkit/pull/375) and [#376](https://github.com/readium/kotlin-toolkit/pull/376)). 1. Override loading non-linear resources with `VisualNavigator.Listener.shouldJumpToLink()`. 2. Present a new `EpubNavigatorFragment` by providing a custom `readingOrder` with only this resource to the constructor. +* Added dummy navigator fragment factories to prevent crashes caused by Android restoring the fragments after a process death. + * To use it, set the dummy fragment factory when you don't have access to the `Publication` instance. Then, either finish the `Activity` or pop the fragment from the UI before it resumes. + ```kotlin + override fun onCreate(savedInstanceState: Bundle?) { + val publication = model.publication ?: run { + childFragmentManager.fragmentFactory = EpubNavigatorFragment.createDummyFactory() + super.onCreate(savedInstanceState) + + requireActivity().finish() + // or + navController?.popBackStack() + + return + } + + // Create the real navigator factory as usual... + } + ``` #### Streamer @@ -39,7 +57,7 @@ All notable changes to this project will be documented in this file. Take a look #### Streamer -* Fix issue with the TTS starting from the beginning of the chapter instead of the current position. +* Fixed issue with the TTS starting from the beginning of the chapter instead of the current position. ## [2.3.0] From e62c5f5b0171156621ec12db4459016f88a41fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Mon, 6 Nov 2023 14:43:24 +0100 Subject: [PATCH 4/5] Restore legacy `PublicationSpeechSynthesizer` for 2.4.0 --- .../r2/navigator/tts/AndroidTtsEngine.kt | 288 +++++++++ .../tts/PublicationSpeechSynthesizer.kt | 556 ++++++++++++++++++ .../org/readium/r2/navigator/tts/TtsEngine.kt | 149 +++++ 3 files changed, 993 insertions(+) create mode 100644 readium/navigator/src/main/java/org/readium/r2/navigator/tts/AndroidTtsEngine.kt create mode 100644 readium/navigator/src/main/java/org/readium/r2/navigator/tts/PublicationSpeechSynthesizer.kt create mode 100644 readium/navigator/src/main/java/org/readium/r2/navigator/tts/TtsEngine.kt diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/tts/AndroidTtsEngine.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/tts/AndroidTtsEngine.kt new file mode 100644 index 0000000000..cfe696fb59 --- /dev/null +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/tts/AndroidTtsEngine.kt @@ -0,0 +1,288 @@ +/* + * Copyright 2022 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package org.readium.r2.navigator.tts + +import android.content.Context +import android.content.Intent +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.speech.tts.Voice as AndroidVoice +import java.util.* +import kotlin.Exception +import kotlin.coroutines.resume +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.onFailure +import org.readium.r2.navigator.tts.TtsEngine.Voice +import org.readium.r2.shared.ExperimentalReadiumApi +import org.readium.r2.shared.extensions.tryOrLog +import org.readium.r2.shared.util.Language +import org.readium.r2.shared.util.Try + +/** + * Default [TtsEngine] implementation using Android's native text to speech engine. + */ +@ExperimentalReadiumApi +class AndroidTtsEngine( + context: Context, + private val listener: TtsEngine.Listener +) : TtsEngine { + + /** + * Android's TTS error code. + * See https://developer.android.com/reference/android/speech/tts/TextToSpeech#ERROR + */ + enum class EngineError(val code: Int) { + /** Denotes a generic operation failure. */ + Unknown(-1), + /** Denotes a failure caused by an invalid request. */ + InvalidRequest(-8), + /** Denotes a failure caused by a network connectivity problems. */ + Network(-6), + /** Denotes a failure caused by network timeout. */ + NetworkTimeout(-7), + /** Denotes a failure caused by an unfinished download of the voice data. */ + NotInstalledYet(-9), + /** Denotes a failure related to the output (audio device or a file). */ + Output(-5), + /** Denotes a failure of a TTS service. */ + Service(-4), + /** Denotes a failure of a TTS engine to synthesize the given input. */ + Synthesis(-3); + + companion object { + fun getOrDefault(key: Int): EngineError = + values() + .firstOrNull { it.code == key } + ?: Unknown + } + } + + class EngineException(code: Int) : Exception("Android TTS engine error: $code") { + val error: EngineError = + EngineError.getOrDefault(code) + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** + * Utterances to be synthesized, in order of [speak] calls. + */ + private val tasks = Channel(Channel.BUFFERED) + + /** Future completed when the [engine] is fully initialized. */ + private val init = CompletableDeferred() + + init { + scope.launch { + init.await() + + for (task in tasks) { + ensureActive() + task.run() + } + } + } + + override val rateMultiplierRange: ClosedRange = 0.1..4.0 + + override var availableVoices: List = emptyList() + private set(value) { + field = value + listener.onAvailableVoicesChange(value) + } + + override suspend fun close() { + scope.cancel() + tasks.cancel() + engine.shutdown() + } + + override suspend fun speak( + utterance: TtsEngine.Utterance, + onSpeakRange: (IntRange) -> Unit + ): TtsTry = + suspendCancellableCoroutine { cont -> + val result = tasks.trySend( + UtteranceTask( + utterance = utterance, + continuation = cont, + onSpeakRange = onSpeakRange + ) + ) + + result.onFailure { + listener.onEngineError( + TtsEngine.Exception.Other(IllegalStateException("Failed to schedule a new utterance task")) + ) + } + } + + /** + * Start the activity to install additional language data. + * To be called if you receive a [TtsEngine.Exception.LanguageSupportIncomplete] error. + * + * Returns whether the request was successful. + * + * See https://android-developers.googleblog.com/2009/09/introduction-to-text-to-speech-in.html + */ + fun requestInstallMissingVoice( + context: Context, + intentFlags: Int = Intent.FLAG_ACTIVITY_NEW_TASK + ): Boolean { + val intent = Intent() + .setAction(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA) + .setFlags(intentFlags) + + if (context.packageManager.queryIntentActivities(intent, 0).isEmpty()) { + return false + } + + context.startActivity(intent) + return true + } + + // Engine + + /** Underlying Android [TextToSpeech] engine. */ + private val engine = TextToSpeech(context, EngineInitListener()) + + private inner class EngineInitListener : TextToSpeech.OnInitListener { + override fun onInit(status: Int) { + if (status == TextToSpeech.SUCCESS) { + scope.launch { + tryOrLog { + availableVoices = engine.voices.map { it.toVoice() } + } + init.complete(Unit) + } + } else { + listener.onEngineError(TtsEngine.Exception.InitializationFailed()) + } + } + } + + /** + * Holds a single utterance to be synthesized and the continuation for the [speak] call. + */ + private inner class UtteranceTask( + val utterance: TtsEngine.Utterance, + val continuation: CancellableContinuation>, + val onSpeakRange: (IntRange) -> Unit, + ) { + fun run() { + if (!continuation.isActive) return + + // Interrupt the engine when the task is cancelled. + continuation.invokeOnCancellation { + tryOrLog { + engine.stop() + engine.setOnUtteranceProgressListener(null) + } + } + + try { + val id = UUID.randomUUID().toString() + engine.setup() + engine.setOnUtteranceProgressListener(Listener(id)) + engine.speak(utterance.text, TextToSpeech.QUEUE_FLUSH, null, id) + } catch (e: Exception) { + finish(TtsEngine.Exception.wrap(e)) + } + } + + /** + * Terminates this task. + */ + private fun finish(error: TtsEngine.Exception? = null) { + continuation.resume( + error?.let { Try.failure(error) } + ?: Try.success(Unit) + ) + } + + /** + * Setups the [engine] using the [utterance]'s configuration. + */ + private fun TextToSpeech.setup() { + setSpeechRate(utterance.rateMultiplier.toFloat()) + + utterance.voiceOrLanguage + .onLeft { voice -> + // Setup the user selected voice. + engine.voice = engine.voices + .firstOrNull { it.name == voice.id } + ?: throw IllegalStateException("Unknown Android voice: ${voice.id}") + } + .onRight { language -> + // Or fallback on the language. + val localeResult = engine.setLanguage(language.locale) + if (localeResult < TextToSpeech.LANG_AVAILABLE) { + if (localeResult == TextToSpeech.LANG_MISSING_DATA) + throw TtsEngine.Exception.LanguageSupportIncomplete(language) + else + throw TtsEngine.Exception.LanguageNotSupported(language) + } + } + } + + inner class Listener(val id: String) : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) {} + + override fun onStop(utteranceId: String?, interrupted: Boolean) { + require(utteranceId == id) + finish() + } + + override fun onDone(utteranceId: String?) { + require(utteranceId == id) + finish() + } + + @Deprecated("Deprecated in the interface", ReplaceWith("onError(utteranceId, -1)")) + override fun onError(utteranceId: String?) { + onError(utteranceId, -1) + } + + override fun onError(utteranceId: String?, errorCode: Int) { + require(utteranceId == id) + + val error = EngineException(errorCode) + finish( + when (error.error) { + EngineError.Network, EngineError.NetworkTimeout -> + TtsEngine.Exception.Network(error) + EngineError.NotInstalledYet -> + TtsEngine.Exception.LanguageSupportIncomplete(utterance.language, cause = error) + else -> TtsEngine.Exception.Other(error) + } + ) + } + + override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) { + require(utteranceId == id) + onSpeakRange(start until end) + } + } + } +} + +@OptIn(ExperimentalReadiumApi::class) +private fun AndroidVoice.toVoice(): Voice = + Voice( + id = name, + name = null, + language = Language(locale), + quality = when (quality) { + AndroidVoice.QUALITY_VERY_HIGH -> Voice.Quality.Highest + AndroidVoice.QUALITY_HIGH -> Voice.Quality.High + AndroidVoice.QUALITY_LOW -> Voice.Quality.Low + AndroidVoice.QUALITY_VERY_LOW -> Voice.Quality.Lowest + else -> Voice.Quality.Normal + }, + requiresNetwork = isNetworkConnectionRequired + ) diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/tts/PublicationSpeechSynthesizer.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/tts/PublicationSpeechSynthesizer.kt new file mode 100644 index 0000000000..d96c6c5ae5 --- /dev/null +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/tts/PublicationSpeechSynthesizer.kt @@ -0,0 +1,556 @@ +/* + * Copyright 2022 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package org.readium.r2.navigator.tts + +import android.content.Context +import java.util.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.readium.r2.shared.DelicateReadiumApi +import org.readium.r2.shared.ExperimentalReadiumApi +import org.readium.r2.shared.extensions.tryOrLog +import org.readium.r2.shared.publication.Locator +import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.publication.services.content.Content +import org.readium.r2.shared.publication.services.content.ContentTokenizer +import org.readium.r2.shared.publication.services.content.TextContentTokenizer +import org.readium.r2.shared.publication.services.content.content +import org.readium.r2.shared.util.* +import org.readium.r2.shared.util.tokenizer.TextUnit + +/** + * [PublicationSpeechSynthesizer] orchestrates the rendition of a [publication] by iterating through + * its content, splitting it into individual utterances using a [ContentTokenizer], then using a + * [TtsEngine] to read them aloud. + * + * Don't forget to call [close] when you are done using the [PublicationSpeechSynthesizer]. + */ +@OptIn(DelicateReadiumApi::class) +@ExperimentalReadiumApi +@Deprecated("The API described in this guide will be changed in the next version of the Kotlin toolkit to support background TTS playback and media notifications. It is recommended that you wait before integrating it in your app.") +class PublicationSpeechSynthesizer private constructor( + private val publication: Publication, + config: Configuration, + engineFactory: (listener: TtsEngine.Listener) -> E, + private val tokenizerFactory: (defaultLanguage: Language?) -> ContentTokenizer, + var listener: Listener? = null, +) : SuspendingCloseable { + + companion object { + + /** + * Creates a [PublicationSpeechSynthesizer] using the default native [AndroidTtsEngine]. + * + * @param publication Publication which will be iterated through and synthesized. + * @param config Initial TTS configuration. + * @param tokenizerFactory Factory to create a [ContentTokenizer] which will be used to + * split each [Content.Element] item into smaller chunks. Splits by sentences by default. + * @param listener Optional callbacks listener. + */ + operator fun invoke( + context: Context, + publication: Publication, + config: Configuration = Configuration(), + tokenizerFactory: (defaultLanguage: Language?) -> ContentTokenizer = defaultTokenizerFactory, + listener: Listener? = null, + ): PublicationSpeechSynthesizer? = invoke( + publication, + config = config, + engineFactory = { AndroidTtsEngine(context, listener = it) }, + tokenizerFactory = tokenizerFactory, + listener = listener + ) + + /** + * Creates a [PublicationSpeechSynthesizer] using a custom [TtsEngine]. + * + * @param publication Publication which will be iterated through and synthesized. + * @param config Initial TTS configuration. + * @param engineFactory Factory to create an instance of [TtsEngine]. + * @param tokenizerFactory Factory to create a [ContentTokenizer] which will be used to + * split each [Content.Element] item into smaller chunks. Splits by sentences by default. + * @param listener Optional callbacks listener. + */ + operator fun invoke( + publication: Publication, + config: Configuration = Configuration(), + engineFactory: (TtsEngine.Listener) -> E, + tokenizerFactory: (defaultLanguage: Language?) -> ContentTokenizer = defaultTokenizerFactory, + listener: Listener? = null, + ): PublicationSpeechSynthesizer? { + if (!canSpeak(publication)) return null + + return PublicationSpeechSynthesizer(publication, config, engineFactory, tokenizerFactory, listener) + } + + /** + * The default content tokenizer will split the [Content.Element] items into individual sentences. + */ + val defaultTokenizerFactory: (Language?) -> ContentTokenizer = { language -> + TextContentTokenizer( + language = language, + unit = TextUnit.Sentence + ) + } + + /** + * Returns whether the [publication] can be played with a [PublicationSpeechSynthesizer]. + */ + fun canSpeak(publication: Publication): Boolean = + publication.content() != null + } + + @ExperimentalReadiumApi + interface Listener { + /** Called when an [error] occurs while speaking [utterance]. */ + fun onUtteranceError(utterance: Utterance, error: Exception) + + /** Called when a global [error] occurs. */ + fun onError(error: Exception) + } + + @ExperimentalReadiumApi + sealed class Exception private constructor( + override val message: String, + cause: Throwable? = null + ) : kotlin.Exception(message, cause) { + + /** Underlying [TtsEngine] error. */ + class Engine(val error: TtsEngine.Exception) : + Exception(error.message, error) + } + + /** + * An utterance is an arbitrary text (e.g. sentence) extracted from the [publication], that can + * be synthesized by the TTS [engine]. + * + * @param text Text to be spoken. + * @param locator Locator to the utterance in the [publication]. + * @param language Language of this utterance, if it differs from the default publication + * language. + */ + @ExperimentalReadiumApi + data class Utterance( + val text: String, + val locator: Locator, + val language: Language?, + ) + + /** + * Represents a state of the [PublicationSpeechSynthesizer]. + */ + sealed class State { + /** The [PublicationSpeechSynthesizer] is completely stopped and must be (re)started from a given locator. */ + object Stopped : State() + + /** The [PublicationSpeechSynthesizer] is paused at the given utterance. */ + data class Paused(val utterance: Utterance) : State() + + /** + * The TTS engine is synthesizing [utterance]. + * + * [range] will be regularly updated while the [utterance] is being played. + */ + data class Playing(val utterance: Utterance, val range: Locator? = null) : State() + } + + private val _state = MutableStateFlow(State.Stopped) + + /** + * Current state of the [PublicationSpeechSynthesizer]. + */ + val state: StateFlow = _state.asStateFlow() + + private val scope = MainScope() + + init { + require(canSpeak(publication)) { + "The content of the publication cannot be synthesized, as it is not iterable" + } + } + + /** + * Underlying [TtsEngine] instance. + * + * WARNING: Don't control the playback or set the config directly with the engine. Use the + * [PublicationSpeechSynthesizer] APIs instead. This property is used to access engine-specific APIs such as + * [AndroidTtsEngine.requestInstallMissingVoice]. + */ + @DelicateReadiumApi + val engine: E by lazy { + engineFactory(object : TtsEngine.Listener { + override fun onEngineError(error: TtsEngine.Exception) { + listener?.onError(Exception.Engine(error)) + stop() + } + + override fun onAvailableVoicesChange(voices: List) { + _availableVoices.value = voices + } + }) + } + + /** + * Interrupts the [TtsEngine] and closes this [PublicationSpeechSynthesizer]. + */ + override suspend fun close() { + tryOrLog { + scope.cancel() + if (::engine.isLazyInitialized) { + engine.close() + } + } + } + + /** + * User configuration for the text-to-speech engine. + * + * @param defaultLanguage Language overriding the publication one. + * @param voiceId Identifier for the voice used to speak the utterances. + * @param rateMultiplier Multiplier for the voice speech rate. Normal is 1.0. See [rateMultiplierRange] + * for the range of values supported by the [TtsEngine]. + * @param extras Extensibility for custom TTS engines. + */ + @ExperimentalReadiumApi + data class Configuration( + val defaultLanguage: Language? = null, + val voiceId: String? = null, + val rateMultiplier: Double = 1.0, + val extras: Any? = null + ) + + private val _config = MutableStateFlow(config) + + /** + * Current user configuration. + */ + val config: StateFlow = _config.asStateFlow() + + /** + * Updates the user configuration. + * + * The change is not immediate, it will be applied for the next utterance. + */ + fun setConfig(config: Configuration) { + _config.value = config.copy( + rateMultiplier = config.rateMultiplier.coerceIn(engine.rateMultiplierRange), + ) + } + + /** + * Range for the speech rate multiplier. Normal is 1.0. + */ + val rateMultiplierRange: ClosedRange + get() = engine.rateMultiplierRange + + private val _availableVoices = MutableStateFlow>(emptyList()) + + /** + * List of synthesizer voices supported by the TTS [engine]. + */ + val availableVoices: StateFlow> = _availableVoices.asStateFlow() + + /** + * Returns the first voice with the given [id] supported by the TTS [engine]. + * + * This can be used to restore the user selected voice after storing it in the shared + * preferences. + */ + fun voiceWithId(id: String): TtsEngine.Voice? { + val voice = lastUsedVoice?.takeIf { it.id == id } + ?: engine.voiceWithId(id) + ?: return null + + lastUsedVoice = voice + return voice + } + + /** + * Cache for the last requested voice, for performance. + */ + private var lastUsedVoice: TtsEngine.Voice? = null + + /** + * (Re)starts the TTS from the given locator or the beginning of the publication. + */ + fun start(fromLocator: Locator? = null) { + replacePlaybackJob { + publicationIterator = publication.content(fromLocator)?.iterator() + playNextUtterance(Direction.Forward) + } + } + + /** + * Stops the synthesizer. + * + * Use [start] to restart it. + */ + fun stop() { + replacePlaybackJob { + _state.value = State.Stopped + publicationIterator = null + } + } + + /** + * Interrupts a played utterance. + * + * Use [resume] to restart the playback from the same utterance. + */ + fun pause() { + replacePlaybackJob { + _state.update { state -> + when (state) { + is State.Playing -> State.Paused(state.utterance) + else -> state + } + } + } + } + + /** + * Resumes an utterance interrupted with [pause]. + */ + fun resume() { + replacePlaybackJob { + (state.value as? State.Paused)?.let { paused -> + play(paused.utterance) + } + } + } + + /** + * Pauses or resumes the playback of the current utterance. + */ + fun pauseOrResume() { + when (state.value) { + is State.Stopped -> return + is State.Playing -> pause() + is State.Paused -> resume() + } + } + + /** + * Skips to the previous utterance. + */ + fun previous() { + replacePlaybackJob { + playNextUtterance(Direction.Backward) + } + } + + /** + * Skips to the next utterance. + */ + fun next() { + replacePlaybackJob { + playNextUtterance(Direction.Forward) + } + } + + /** + * [Content.Iterator] used to iterate through the [publication]. + */ + private var publicationIterator: Content.Iterator? = null + set(value) { + field = value + utterances = CursorList() + } + + /** + * Utterances for the current publication [Content.Element] item. + */ + private var utterances: CursorList = CursorList() + + /** + * Plays the next utterance in the given [direction]. + */ + private suspend fun playNextUtterance(direction: Direction) { + val utterance = nextUtterance(direction) + if (utterance == null) { + _state.value = State.Stopped + return + } + play(utterance) + } + + /** + * Plays the given [utterance] with the TTS [engine]. + */ + private suspend fun play(utterance: Utterance) { + _state.value = State.Playing(utterance) + + engine + .speak( + utterance = TtsEngine.Utterance( + text = utterance.text, + rateMultiplier = config.value.rateMultiplier, + voiceOrLanguage = utterance.voiceOrLanguage() + ), + onSpeakRange = { range -> + _state.value = State.Playing( + utterance = utterance, + range = utterance.locator.copy( + text = utterance.locator.text.substring(range) + ) + ) + } + ) + .onSuccess { + playNextUtterance(Direction.Forward) + } + .onFailure { + _state.value = State.Paused(utterance) + listener?.onUtteranceError(utterance, Exception.Engine(it)) + } + } + + /** + * Returns the user selected voice if it's compatible with the utterance language. Otherwise, + * falls back on the languages. + */ + private fun Utterance.voiceOrLanguage(): Either { + // User selected voice, if it's compatible with the utterance language. + // Or fallback on the languages. + val voice = config.value.voiceId + ?.let { voiceWithId(it) } + ?.takeIf { language == null || it.language.removeRegion() == language.removeRegion() } + + return Either( + voice + ?: language + ?: config.value.defaultLanguage + ?: publication.metadata.language + ?: Language(Locale.getDefault()) + ) + } + + /** + * Gets the next utterance in the given [direction], or null when reaching the beginning or the + * end. + */ + private suspend fun nextUtterance(direction: Direction): Utterance? { + val utterance = utterances.nextIn(direction) + if (utterance == null && loadNextUtterances(direction)) { + return nextUtterance(direction) + } + return utterance + } + + /** + * Loads the utterances for the next publication [Content.Element] item in the given [direction]. + */ + private suspend fun loadNextUtterances(direction: Direction): Boolean { + val content = publicationIterator?.nextIn(direction) + ?: return false + + val nextUtterances = content + .tokenize() + .flatMap { it.utterances() } + + if (nextUtterances.isEmpty()) { + return loadNextUtterances(direction) + } + + utterances = CursorList( + list = nextUtterances, + index = when (direction) { + Direction.Forward -> -1 + Direction.Backward -> nextUtterances.size + } + ) + + return true + } + + /** + * Splits a publication [Content.Element] item into smaller chunks using the provided tokenizer. + * + * This is used to split a paragraph into sentences, for example. + */ + private fun Content.Element.tokenize(): List = + tokenizerFactory(config.value.defaultLanguage ?: publication.metadata.language) + .tokenize(this) + + /** + * Splits a publication [Content.Element] item into the utterances to be spoken. + */ + private fun Content.Element.utterances(): List { + fun utterance(text: String, locator: Locator, language: Language? = null): Utterance? { + if (!text.any { it.isLetterOrDigit() }) + return null + + return Utterance( + text = text, + locator = locator, + language = language + // If the language is the same as the one declared globally in the publication, + // we omit it. This way, the app can customize the default language used in the + // configuration. + ?.takeIf { it != publication.metadata.language } + ) + } + + return when (this) { + is Content.TextElement -> { + segments.mapNotNull { segment -> + utterance( + text = segment.text, + locator = segment.locator, + language = segment.language + ) + } + } + + is Content.TextualElement -> { + listOfNotNull( + text + ?.takeIf { it.isNotBlank() } + ?.let { utterance(text = it, locator = locator) } + ) + } + + else -> emptyList() + } + } + + /** + * Cancels the previous playback-related job and starts a new one with the given suspending + * [block]. + * + * This is used to interrupt on-going commands. + */ + private fun replacePlaybackJob(block: suspend CoroutineScope.() -> Unit) { + scope.launch { + playbackJob?.cancelAndJoin() + playbackJob = launch { + block() + } + } + } + + private var playbackJob: Job? = null + + private enum class Direction { + Forward, Backward; + } + + private fun CursorList.nextIn(direction: Direction): E? = + when (direction) { + Direction.Forward -> next() + Direction.Backward -> previous() + } + + private suspend fun Content.Iterator.nextIn(direction: Direction): Content.Element? = + when (direction) { + Direction.Forward -> nextOrNull() + Direction.Backward -> previousOrNull() + } +} diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/tts/TtsEngine.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/tts/TtsEngine.kt new file mode 100644 index 0000000000..ae3a454f97 --- /dev/null +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/tts/TtsEngine.kt @@ -0,0 +1,149 @@ +/* + * Copyright 2022 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package org.readium.r2.navigator.tts + +import org.readium.r2.shared.ExperimentalReadiumApi +import org.readium.r2.shared.util.Either +import org.readium.r2.shared.util.Language +import org.readium.r2.shared.util.SuspendingCloseable +import org.readium.r2.shared.util.Try + +@ExperimentalReadiumApi +typealias TtsTry = Try + +/** + * A text-to-speech engine synthesizes text utterances (e.g. sentence). + * + * Implement this interface to support third-party engines with [PublicationSpeechSynthesizer]. + */ +@ExperimentalReadiumApi +interface TtsEngine : SuspendingCloseable { + + @ExperimentalReadiumApi + sealed class Exception private constructor( + override val message: String, + cause: Throwable? = null + ) : kotlin.Exception(message, cause) { + /** Failed to initialize the TTS engine. */ + class InitializationFailed(cause: Throwable? = null) : + Exception("The TTS engine failed to initialize", cause) + + /** Tried to synthesize an utterance with an unsupported language. */ + class LanguageNotSupported(val language: Language, cause: Throwable? = null) : + Exception("The language ${language.code} is not supported by the TTS engine", cause) + + /** The selected language is missing downloadable data. */ + class LanguageSupportIncomplete(val language: Language, cause: Throwable? = null) : + Exception("The language ${language.code} requires additional files by the TTS engine", cause) + + /** Error during network calls. */ + class Network(cause: Throwable? = null) : + Exception("A network error occurred", cause) + + /** Other engine-specific errors. */ + class Other(override val cause: Throwable) : + Exception(cause.message ?: "An unknown error occurred", cause) + + companion object { + fun wrap(e: Throwable): Exception = when (e) { + is Exception -> e + else -> Other(e) + } + } + } + + /** + * TTS engine callbacks. + */ + @ExperimentalReadiumApi + interface Listener { + /** + * Called when a general engine error occurred. + */ + fun onEngineError(error: Exception) + + /** + * Called when the list of available voices is updated. + */ + fun onAvailableVoicesChange(voices: List) + } + + /** + * An utterance is an arbitrary text (e.g. sentence) that can be synthesized by the TTS engine. + * + * @param text Text to be spoken. + * @param rateMultiplier Multiplier for the speech rate. + * @param voiceOrLanguage Either an explicit voice or the language of the text. If a language + * is provided, the default voice for this language will be used. + */ + @ExperimentalReadiumApi + data class Utterance( + val text: String, + val rateMultiplier: Double, + val voiceOrLanguage: Either + ) { + val language: Language = + when (val vl = voiceOrLanguage) { + is Either.Left -> vl.value.language + is Either.Right -> vl.value + } + } + + /** + * Represents a voice provided by the TTS engine which can speak an utterance. + * + * @param id Unique and stable identifier for this voice. Can be used to store and retrieve the + * voice from the user preferences. + * @param name Human-friendly name for this voice, when available. + * @param language Language (and region) this voice belongs to. + * @param quality Voice quality. + * @param requiresNetwork Indicates whether using this voice requires an Internet connection. + */ + @ExperimentalReadiumApi + data class Voice( + val id: String, + val name: String? = null, + val language: Language, + val quality: Quality = Quality.Normal, + val requiresNetwork: Boolean = false, + ) { + enum class Quality { + Lowest, Low, Normal, High, Highest + } + } + + /** + * Synthesizes the given [utterance] and returns its status. + * + * [onSpeakRange] is called repeatedly while the engine plays portions (e.g. words) of the + * utterance. + * + * To interrupt the utterance, cancel the parent coroutine job. + */ + suspend fun speak( + utterance: Utterance, + onSpeakRange: (IntRange) -> Unit = { _ -> } + ): TtsTry + + /** + * Supported range for the speech rate multiplier. + */ + val rateMultiplierRange: ClosedRange + + /** + * List of available synthesizer voices. + * + * Implement [Listener.onAvailableVoicesChange] to be aware of changes in the available voices. + */ + val availableVoices: List + + /** + * Returns the voice with given identifier, if it exists. + */ + fun voiceWithId(id: String): Voice? = + availableVoices.firstOrNull { it.id == id } +} From 8b0089bae7f5f71a20fd4410f56f63f763c9d3b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Mon, 6 Nov 2023 17:49:33 +0100 Subject: [PATCH 5/5] Fix crash with PdfiumDocumentFragment --- .../navigator/PdfiumDocumentFragment.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumDocumentFragment.kt b/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumDocumentFragment.kt index 3b34dd3ee6..d8248eccfc 100644 --- a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumDocumentFragment.kt +++ b/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumDocumentFragment.kt @@ -23,6 +23,9 @@ import org.readium.r2.navigator.preferences.ReadingProgression import org.readium.r2.shared.ExperimentalReadiumApi import org.readium.r2.shared.fetcher.Resource import org.readium.r2.shared.publication.Link +import org.readium.r2.shared.publication.LocalizedString +import org.readium.r2.shared.publication.Manifest +import org.readium.r2.shared.publication.Metadata import org.readium.r2.shared.publication.Publication import timber.log.Timber @@ -36,6 +39,28 @@ class PdfiumDocumentFragment internal constructor( private val navigatorListener: PdfDocumentFragment.Listener? ) : PdfDocumentFragment() { + // Dummy constructor to address https://github.com/readium/kotlin-toolkit/issues/395 + constructor() : this( + publication = Publication( + manifest = Manifest( + metadata = Metadata( + identifier = "readium:dummy", + localizedTitle = LocalizedString("") + ) + ) + ), + link = Link(href = "publication.pdf", type = "application/pdf"), + initialPageIndex = 0, + settings = PdfiumSettings( + fit = Fit.WIDTH, + pageSpacing = 0.0, + readingProgression = ReadingProgression.LTR, + scrollAxis = Axis.VERTICAL + ), + appListener = null, + navigatorListener = null + ) + interface Listener { /** Called when configuring [PDFView]. */ fun onConfigurePdfView(configurator: PDFView.Configurator) {}