Added initial PdfViewerFragment setup with all public API support
- Added all public APIs as currently live.
- Created fragment ui states and exposed it using state-flow to update ui in reactive manner.
- Saved data using Saved State Handle in viewmodel, re-triggered flows upon process death.
- Entry point to test PdfViewerFragmentV2.
Test: Manually tested sample app
Bug: 379053040
Change-Id: I0bada243f4a81851acac53cc4cd3034dde6ba282
diff --git a/pdf/integration-tests/testapp/src/main/AndroidManifest.xml b/pdf/integration-tests/testapp/src/main/AndroidManifest.xml
index a6925fc..04f1cba1 100644
--- a/pdf/integration-tests/testapp/src/main/AndroidManifest.xml
+++ b/pdf/integration-tests/testapp/src/main/AndroidManifest.xml
@@ -18,12 +18,12 @@
xmlns:tools="http://schemas.android.com/tools">
<application
+ android:name=".PdfSampleApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
- android:name=".PdfSampleApplication"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
@@ -34,6 +34,7 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
+ <activity android:name=".MainActivityV2" />
</application>
diff --git a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/MainActivity.kt b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/MainActivity.kt
index cc5610c..bcca0b7 100644
--- a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/MainActivity.kt
+++ b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/MainActivity.kt
@@ -17,6 +17,7 @@
package androidx.pdf.testapp
import android.annotation.SuppressLint
+import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.FrameLayout
@@ -39,8 +40,8 @@
private lateinit var singlePdfButton: MaterialButton
private lateinit var tabsViewButton: MaterialButton
+ private lateinit var pdfFragmentv2Button: MaterialButton
private lateinit var fragmentContainer: FrameLayout
- private var pdfViewerFragment: Fragment? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -52,10 +53,14 @@
singlePdfButton = scenarioButtons.singlePdf
tabsViewButton = scenarioButtons.tabView
+ pdfFragmentv2Button = scenarioButtons.pdfFragmentV2
fragmentContainer = mainActivity.pdfInteractionFragmentContainerView
singlePdfButton.setOnClickListener { loadFragment(SinglePdfFragment()) }
tabsViewButton.setOnClickListener { loadFragment(TabsViewPdfFragment()) }
+ pdfFragmentv2Button.setOnClickListener {
+ startActivity(Intent(this@MainActivity, MainActivityV2::class.java))
+ }
handleWindowInsets()
handleBackPress()
@@ -120,11 +125,13 @@
private fun showButtons() {
singlePdfButton.visibility = View.VISIBLE
tabsViewButton.visibility = View.VISIBLE
+ pdfFragmentv2Button.visibility = View.VISIBLE
}
private fun hideButtons() {
singlePdfButton.visibility = View.GONE
tabsViewButton.visibility = View.GONE
+ pdfFragmentv2Button.visibility = View.GONE
}
private fun handleWindowInsets() {
diff --git a/pdf/integration-tests/testapp/src/main/res/layout/activity_main.xml b/pdf/integration-tests/testapp/src/main/res/layout/activity_main.xml
index adc2138..c3effdf 100644
--- a/pdf/integration-tests/testapp/src/main/res/layout/activity_main.xml
+++ b/pdf/integration-tests/testapp/src/main/res/layout/activity_main.xml
@@ -43,7 +43,9 @@
app:strokeWidth="1dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="16dp"
+ app:layout_constraintTop_toBottomOf="@+id/fragment_container_view"
app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintStart_toStartOf="parent"/>
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/pdf/integration-tests/testapp/src/main/res/layout/scenario_buttons.xml b/pdf/integration-tests/testapp/src/main/res/layout/scenario_buttons.xml
index 74aba10..ee5e168 100644
--- a/pdf/integration-tests/testapp/src/main/res/layout/scenario_buttons.xml
+++ b/pdf/integration-tests/testapp/src/main/res/layout/scenario_buttons.xml
@@ -25,6 +25,19 @@
app:layout_constraintTop_toBottomOf="@+id/single_pdf"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toTopOf="@+id/pdfFragmentV2"/>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/pdfFragmentV2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/pdf_fragment_v2_string"
+ app:strokeWidth="1dp"
+ android:layout_marginEnd="8dp"
+ android:layout_marginBottom="8dp"
+ app:layout_constraintTop_toBottomOf="@+id/tab_view"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</merge>
diff --git a/pdf/integration-tests/testapp/src/main/res/values/strings.xml b/pdf/integration-tests/testapp/src/main/res/values/strings.xml
index 514daf6..1d73f8f 100644
--- a/pdf/integration-tests/testapp/src/main/res/values/strings.xml
+++ b/pdf/integration-tests/testapp/src/main/res/values/strings.xml
@@ -3,5 +3,6 @@
<string name="launch_string">Open Pdf</string>
<string name="search_string">Search</string>
<string name="tab_view_string">Tab View</string>
+ <string name="pdf_fragment_v2_string">Pdf Fragment v2</string>
<string name="single_pdf_string">Single Pdf</string>
</resources>
\ No newline at end of file
diff --git a/pdf/pdf-viewer-fragment/build.gradle b/pdf/pdf-viewer-fragment/build.gradle
index 5e7bca1..91e8d7b 100644
--- a/pdf/pdf-viewer-fragment/build.gradle
+++ b/pdf/pdf-viewer-fragment/build.gradle
@@ -37,6 +37,13 @@
implementation("androidx.fragment:fragment-ktx:1.8.1")
implementation("com.google.android.material:material:1.11.0")
+
+ androidTestImplementation(libs.testCore)
+ androidTestImplementation(libs.testRules)
+ androidTestImplementation(libs.testRunner)
+ androidTestImplementation(libs.junit)
+ androidTestImplementation(libs.testExtJunit)
+ androidTestImplementation(libs.kotlinCoroutinesTest)
}
android {
diff --git a/pdf/pdf-viewer-fragment/src/androidTest/assets/sample.pdf b/pdf/pdf-viewer-fragment/src/androidTest/assets/sample.pdf
new file mode 100644
index 0000000..4603bd3
--- /dev/null
+++ b/pdf/pdf-viewer-fragment/src/androidTest/assets/sample.pdf
Binary files differ
diff --git a/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/coroutines/FlowExtensions.kt b/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/coroutines/FlowExtensions.kt
new file mode 100644
index 0000000..ecb464f
--- /dev/null
+++ b/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/coroutines/FlowExtensions.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.viewer.coroutines
+
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
+
+suspend fun <T> Flow<T>.toListDuring(durationInMillis: Long): List<T> = coroutineScope {
+ val result = mutableListOf<T>()
+ val job = launch { [email protected](result::add) }
+ delay(durationInMillis)
+ job.cancel()
+ return@coroutineScope result
+}
diff --git a/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/fragment/PdfDocumentViewModelTest.kt b/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/fragment/PdfDocumentViewModelTest.kt
new file mode 100644
index 0000000..e1ca79d
--- /dev/null
+++ b/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/fragment/PdfDocumentViewModelTest.kt
@@ -0,0 +1,115 @@
+/*
+ * 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.viewer.fragment
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.pdf.SandboxedPdfLoader
+import androidx.pdf.viewer.coroutines.toListDuring
+import androidx.pdf.viewer.fragment.TestUtils.openFileAsUri
+import androidx.pdf.viewer.fragment.model.PdfFragmentUiState
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
+import java.util.concurrent.Executors
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class PdfDocumentViewModelTest {
+
+ private val appContext =
+ InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
+ private lateinit var pdfDocumentViewModel: PdfDocumentViewModel
+ val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
+
+ @Before
+ fun setup() {
+ val savedStateHandle = SavedStateHandle()
+
+ pdfDocumentViewModel =
+ PdfDocumentViewModel(savedStateHandle, SandboxedPdfLoader(appContext, dispatcher))
+ }
+
+ @Test
+ fun test_pdfDocumentViewModel_loadDocumentSuccess() = runTest {
+ val documentUri = openFileAsUri(appContext, "sample.pdf")
+
+ pdfDocumentViewModel.loadDocument(uri = documentUri, password = null)
+
+ backgroundScope.launch {
+ // Collect Ui states
+ val uiStates = pdfDocumentViewModel.fragmentUiScreenState.toListDuring(200)
+ // Since we've selected a error-free unprotected pdf,
+ // ideally there should only 2 states transitions.
+ assertTrue(uiStates.size == 2)
+ // Assert the first state emitted was loading
+ assertTrue(uiStates.first() is PdfFragmentUiState.Loading)
+ // Assert the first state emitted was Document load
+ assertTrue(uiStates.last() is PdfFragmentUiState.DocumentLoaded)
+ }
+ }
+
+ @Test
+ fun test_pdfDocumentViewModel_loadDocumentSuccess_withStateSaved() = runTest {
+ val documentUri = openFileAsUri(appContext, "sample.pdf")
+ // Save document uri in savedStateHandle and inject in viewmodel
+ val savedState = SavedStateHandle().also { it["documentUri"] = documentUri }
+ // On init, pdfViewModel will try to load document again as documentUri != null
+ val pdfViewModel =
+ PdfDocumentViewModel(savedState, SandboxedPdfLoader(appContext, dispatcher))
+
+ backgroundScope.launch {
+ val uiStates = pdfViewModel.fragmentUiScreenState.toListDuring(200)
+
+ // Assert there are only 2 state transitions
+ assertTrue(uiStates.size == 2)
+
+ // Assert the first state emitted was loading
+ assertTrue(uiStates.first() is PdfFragmentUiState.Loading)
+ // Assert the first state emitted was Document load
+ assertTrue(uiStates.last() is PdfFragmentUiState.DocumentLoaded)
+ }
+ }
+
+ @Test
+ fun test_pdfDocumentViewModel_loadDocumentNotTriggerForSameUri() = runTest {
+ val documentUri = openFileAsUri(appContext, "sample.pdf")
+ // Save document uri in savedStateHandle and inject in viewmodel
+ val savedState = SavedStateHandle().also { it["documentUri"] = documentUri }
+ // On init, pdfViewModel will try to load document again as documentUri != null
+ val pdfViewModel =
+ PdfDocumentViewModel(savedState, SandboxedPdfLoader(appContext, dispatcher))
+ // Ignore the first 2 fragmentUiStates, as it's the first load
+ pdfViewModel.fragmentUiScreenState.take(2).toList()
+ // try loading the document again with same uri
+ pdfViewModel.loadDocument(uri = documentUri, password = null)
+
+ // Assert fragmentUiState never set to Loading
+ assertTrue(pdfViewModel.fragmentUiScreenState.value !is PdfFragmentUiState.Loading)
+ }
+
+ // TODO: Add tests for password-protected pdf and corrupted pdf after test-artifact b/379743760
+
+}
diff --git a/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/fragment/TestUtils.kt b/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/fragment/TestUtils.kt
new file mode 100644
index 0000000..d2684b3
--- /dev/null
+++ b/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/fragment/TestUtils.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.viewer.fragment
+
+import android.content.Context
+import android.net.Uri
+import java.io.File
+import java.io.FileOutputStream
+
+object TestUtils {
+ private val TEMP_FILE_NAME = "temp"
+ private val TEMP_FILE_TYPE = ".pdf"
+
+ fun openFileAsUri(context: Context, filename: String): Uri {
+ val inputStream = context.assets.open(filename)
+ val tempFile = File.createTempFile(TEMP_FILE_NAME, TEMP_FILE_TYPE, context.cacheDir)
+ FileOutputStream(tempFile).use { outputStream -> inputStream.copyTo(outputStream) }
+ return Uri.fromFile(tempFile)
+ }
+}
diff --git a/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfDocumentViewModel.kt b/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfDocumentViewModel.kt
index 0626f38..351b542 100644
--- a/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfDocumentViewModel.kt
+++ b/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfDocumentViewModel.kt
@@ -18,15 +18,20 @@
import android.net.Uri
import androidx.annotation.RestrictTo
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
+import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.pdf.PdfDocument
import androidx.pdf.PdfLoader
import androidx.pdf.SandboxedPdfLoader
+import androidx.pdf.exceptions.PdfPasswordException
+import androidx.pdf.viewer.fragment.model.PdfFragmentUiState
import java.util.concurrent.Executors
+import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -48,10 +53,56 @@
* @property loader The [PdfLoader] used to open the PDF document.
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
-public class PdfDocumentViewModel(private val loader: PdfLoader) : ViewModel() {
- private val _pdfDocumentStateFlow = MutableStateFlow<Result<PdfDocument>?>(null)
- public val pdfDocumentStateFlow: StateFlow<Result<PdfDocument>?> =
- _pdfDocumentStateFlow.asStateFlow()
+internal class PdfDocumentViewModel(
+ private val state: SavedStateHandle,
+ private val loader: PdfLoader
+) : ViewModel() {
+
+ /** A Coroutine [Job] that manages the PDF loading task. */
+ private var documentLoadJob: Job? = null
+
+ private val _fragmentUiScreenState =
+ MutableStateFlow<PdfFragmentUiState>(PdfFragmentUiState.Loading)
+
+ /**
+ * Represents the UI state of the fragment.
+ *
+ * Exposes the UI state as a StateFlow to enable reactive consumption and ensure that consumers
+ * always receive the latest state.
+ */
+ internal val fragmentUiScreenState: StateFlow<PdfFragmentUiState>
+ get() = _fragmentUiScreenState.asStateFlow()
+
+ /**
+ * Indicates whether the user is entering their password for the first time or making a repeated
+ * attempt.
+ *
+ * This state is used to determine the appropriate error message to display in the password
+ * dialog.
+ */
+ private var passwordFailed = false
+
+ /** DocumentUri as set in [state] */
+ val documentUriFromState: Uri?
+ get() = state[DOCUMENT_URI_KEY]
+
+ /** isTextSearchActive as set in [state] */
+ val isTextSearchActiveFromState: Boolean
+ get() = state[TEXT_SEARCH_STATE_KEY] ?: false
+
+ /** isToolboxVisibleFromState as set in [state] */
+ val isToolboxVisibleFromState: Boolean
+ get() = state[TOOLBOX_STATE_KEY] ?: false
+
+ init {
+ /**
+ * Open PDF if documentUri was previously set in state. This will be required in events like
+ * process death
+ */
+ state.get<Uri>(DOCUMENT_URI_KEY)?.let { uri ->
+ documentLoadJob = viewModelScope.launch { openDocument(uri) }
+ }
+ }
/**
* Initiates the loading of a PDF document from the provided Uri.
@@ -66,32 +117,105 @@
* @param uri The Uri of the PDF document to load.
* @param password The optional password to use if the document is encrypted.
*/
- public fun loadDocument(uri: Uri?, password: String?) {
- viewModelScope.launch {
- _pdfDocumentStateFlow.update {
- uri?.let {
- try {
- val document = loader.openDocument(uri, password)
- Result.success(document)
- } catch (exception: Exception) {
- Result.failure(exception)
- }
- }
+ fun loadDocument(uri: Uri?, password: String?) {
+ uri?.let {
+ /*
+ Triggers the document loading process only under the following conditions:
+ 1. **New Document URI:** The URI of the document to be loaded is different
+ `from the URI of the previously loaded document.
+ 2. **Previous Load Failure or No Previous Load:** This is required when a
+ reload of document is required like document loading failed previous time or opened
+ using an incorrect password.
+ */
+ if (
+ (uri != state[DOCUMENT_URI_KEY] ||
+ fragmentUiScreenState.value !is PdfFragmentUiState.DocumentLoaded)
+ ) {
+ state[DOCUMENT_URI_KEY] = uri
+
+ // Ensure we don't schedule duplicate loading by canceling previous one.
+ if (documentLoadJob?.isActive == true) documentLoadJob?.cancel()
+
+ documentLoadJob = viewModelScope.launch { openDocument(uri, password) }
}
}
}
+ /**
+ * Handles user interaction related to enabling the search view.
+ *
+ * This function ensures that the search view is properly displayed and ready for user input
+ * when triggered.
+ */
+ fun onSearchViewToggle(isTextSearchActive: Boolean) {
+ state[TEXT_SEARCH_STATE_KEY] = isTextSearchActive
+ // TODO: add implementation after integrating PdfSearchView b/379054326
+ }
+
+ /**
+ * Handles user interaction related to enabling the toolbox view.
+ *
+ * This function ensures that the toolbox view is properly displayed and ready for user input
+ * when triggered.
+ */
+ fun onToolboxViewToggle(isToolboxActive: Boolean) {
+ state[TOOLBOX_STATE_KEY] = isToolboxActive
+ // TODO: add implementation after integrating Toolbox view b/379052981
+ }
+
+ private suspend fun openDocument(uri: Uri, password: String? = null) {
+ /** Move to [PdfFragmentUiState.Loading] state before we begin load operation. */
+ _fragmentUiScreenState.update { PdfFragmentUiState.Loading }
+
+ try {
+
+ // Try opening pdf with provided params
+ val document = loader.openDocument(uri, password)
+
+ /** Successful load, move to [PdfFragmentUiState.DocumentLoaded] state. */
+ _fragmentUiScreenState.update { PdfFragmentUiState.DocumentLoaded(document) }
+
+ /** Resets the [passwordFailed] state after a document is successfully loaded. */
+ passwordFailed = false
+ } catch (passwordException: PdfPasswordException) {
+ /** Move to [PdfFragmentUiState.PasswordRequested] for password protected pdf. */
+ _fragmentUiScreenState.update { PdfFragmentUiState.PasswordRequested(passwordFailed) }
+
+ /** Enable [passwordFailed] for subsequent password attempts. */
+ if (!passwordFailed) passwordFailed = true
+ } catch (exception: Exception) {
+ /** Generic exception handling, move to [PdfFragmentUiState.DocumentError] state. */
+ _fragmentUiScreenState.update { PdfFragmentUiState.DocumentError(exception) }
+
+ /** Resets the [passwordFailed] state after a document failed to load. */
+ passwordFailed = false
+ }
+ }
+
@Suppress("UNCHECKED_CAST")
- public companion object {
- public val Factory: ViewModelProvider.Factory =
+ companion object {
+
+ private const val DOCUMENT_URI_KEY = "documentUri"
+ private const val TEXT_SEARCH_STATE_KEY = "textSearchState"
+ private const val TOOLBOX_STATE_KEY = "toolboxState"
+
+ val Factory: ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(
modelClass: Class<T>,
extras: CreationExtras
): T {
+ // Get the Application object from extras
val application = checkNotNull(extras[APPLICATION_KEY])
+ // Create a SavedStateHandle for this ViewModel from extras
+ val savedStateHandle = extras.createSavedStateHandle()
+
val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
- return (PdfDocumentViewModel(SandboxedPdfLoader(application, dispatcher))) as T
+ return (PdfDocumentViewModel(
+ savedStateHandle,
+ SandboxedPdfLoader(application, dispatcher)
+ ))
+ as T
}
}
}
diff --git a/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt b/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt
index 4a544c5..c057893 100644
--- a/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt
+++ b/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt
@@ -18,63 +18,168 @@
import android.net.Uri
import android.os.Bundle
-import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import android.view.ViewGroup.LayoutParams
+import androidx.annotation.CallSuper
import androidx.annotation.RestrictTo
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
-import androidx.pdf.exceptions.PdfPasswordException
+import androidx.lifecycle.repeatOnLifecycle
import androidx.pdf.view.PdfView
+import androidx.pdf.viewer.fragment.model.PdfFragmentUiState.DocumentError
+import androidx.pdf.viewer.fragment.model.PdfFragmentUiState.DocumentLoaded
+import androidx.pdf.viewer.fragment.model.PdfFragmentUiState.Loading
+import androidx.pdf.viewer.fragment.model.PdfFragmentUiState.PasswordRequested
import kotlinx.coroutines.launch
@RestrictTo(RestrictTo.Scope.LIBRARY)
-public class PdfViewerFragmentV2 : Fragment() {
- private val documentViewModel: PdfDocumentViewModel by
- viewModels() { PdfDocumentViewModel.Factory }
- private lateinit var pdfView: PdfView
+public open class PdfViewerFragmentV2 : Fragment() {
- public var documentUri: Uri? = null
+ /**
+ * The URI of the PDF document to display defaulting to `null`.
+ *
+ * When this property is set, the fragment begins loading the PDF document. A visual indicator
+ * is displayed while the document is being loaded. Once the loading is fully completed, the
+ * [onLoadDocumentSuccess] callback is invoked. If an error occurs during the loading phase, the
+ * [onLoadDocumentError] callback is invoked with the exception.
+ *
+ * <p>Note: This property is recommended to be set when the fragment is in the started state.
+ */
+ public var documentUri: Uri?
+ get() = documentViewModel.documentUriFromState
set(value) {
- field = value
- documentViewModel.loadDocument(value, /* password= */ null)
+ documentViewModel.loadDocument(uri = value, password = null)
}
+ /**
+ * Controls whether text search mode is active. Defaults to false.
+ *
+ * When text search mode is activated, the search menu becomes visible, and search functionality
+ * is enabled. Deactivating text search mode hides the search menu, clears search results, and
+ * removes any search-related highlights.
+ *
+ * <p>Note: This property can only be set after the document has successfully loaded
+ * i.e.[onLoadDocumentSuccess] is triggered. Any attempts to change it beforehand will have no
+ * effect.
+ */
+ public var isTextSearchActive: Boolean
+ get() = documentViewModel.isTextSearchActiveFromState
+ set(value) {
+ documentViewModel.onSearchViewToggle(value)
+ }
+
+ /**
+ * Indicates whether the toolbox should be visible.
+ *
+ * The host app can control this property to show/hide the toolbox based on its state and the
+ * `onRequestImmersiveMode` callback. The setter updates the UI elements within the fragment
+ * accordingly.
+ */
+ public var isToolboxVisible: Boolean
+ get() = documentViewModel.isToolboxVisibleFromState
+ set(value) {
+ documentViewModel.onToolboxViewToggle(value)
+ }
+
+ /**
+ * Called when the PDF view wants to enter or exit immersive mode based on user's interaction
+ * with the content. Apps would typically hide their top bar or other navigational interface
+ * when in immersive mode. The default implementation keeps toolbox visibility in sync with the
+ * enterImmersive mode. It is recommended that apps keep this behaviour by calling
+ * super.onRequestImmersiveMode while overriding this method.
+ *
+ * @param enterImmersive true to enter immersive mode, false to exit.
+ */
+ @CallSuper
+ public open fun onRequestImmersiveMode(enterImmersive: Boolean) {
+ // Update toolbox visibility
+ isToolboxVisible = !enterImmersive
+ }
+
+ /**
+ * Invoked when the document has been fully loaded, processed, and the initial pages are
+ * displayed within the viewing area. This callback signifies that the document is ready for
+ * user interaction.
+ *
+ * <p>Note that this callback is dispatched only when the fragment is fully created and not yet
+ * destroyed, i.e., after [onCreate] has fully run and before [onDestroy] runs, and only on the
+ * main thread.
+ */
+ public open fun onLoadDocumentSuccess() {}
+
+ /**
+ * Invoked when a problem arises during the loading process of the PDF document. This callback
+ * provides details about the encountered error, allowing for appropriate error handling and
+ * user notification.
+ *
+ * <p>Note that this callback is dispatched only when the fragment is fully created and not yet
+ * destroyed, i.e., after [onCreate] has fully run and before [onDestroy] runs, and only on the
+ * main thread.
+ *
+ * @param error [Throwable] that occurred during document loading.
+ */
+ @Suppress("UNUSED_PARAMETER") public open fun onLoadDocumentError(error: Throwable) {}
+
+ private val documentViewModel: PdfDocumentViewModel by viewModels {
+ PdfDocumentViewModel.Factory
+ }
+
+ private lateinit var pdfView: PdfView
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
- pdfView =
- PdfView(requireContext()).apply {
- layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
- }
- return pdfView
+ super.onCreateView(inflater, container, savedInstanceState)
+ val root = inflater.inflate(R.layout.pdf_viewer_fragment, container, false)
+ pdfView = root.findViewById(R.id.pdfView)
+
+ return root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
- documentViewModel.pdfDocumentStateFlow.collect { result ->
- if (result != null) {
- if (result.isSuccess) {
- // Document loaded
- pdfView.pdfDocument = result.getOrNull()
- Log.i("DDDDD", "Document has been loaded successfully")
- } else if (result.exceptionOrNull() is PdfPasswordException) {
- // Display password prompt
- Log.i("DDDDD", "Document requires a password")
- } else {
- // Handle generic error
- Log.i("DDDDD", "Error occurred while loading pdf")
- }
- } else {
- // Display loading view
- Log.i("DDDDD", "Null result")
+ /**
+ * [repeatOnLifecycle] launches the block in a new coroutine every time the lifecycle is
+ * in the STARTED state (or above) and cancels it when it's STOPPED.
+ */
+ repeatOnLifecycle(Lifecycle.State.STARTED) { collectFragmentUiScreenState() }
+ }
+ }
+
+ /**
+ * Collects the UI state of the fragment and updates the views accordingly.
+ *
+ * This is a suspend function that continuously observes the fragment's UI state and updates the
+ * corresponding views to reflect the latest state. This ensures that the UI remains
+ * synchronized with any changes in the underlying data or user interactions.
+ */
+ private suspend fun collectFragmentUiScreenState() {
+ documentViewModel.fragmentUiScreenState.collect { uiState ->
+ when (uiState) {
+ is Loading -> {
+ // TODO: Implement loading view b/379226011
+ // Hide all views except loading progress bar
+ // Show progress bar
+ }
+ is PasswordRequested -> {
+ // TODO: Implement password dialog b/373252814
+ // Utilize retry param to show incorrect password on PasswordDialog
+ }
+ is DocumentLoaded -> {
+ onLoadDocumentSuccess()
+ pdfView.pdfDocument = uiState.pdfDocument
+ // TODO: Implement PdfView b/379053734
+ }
+ is DocumentError -> {
+ onLoadDocumentError(uiState.exception)
+ // TODO: Implement error view b/379055053
}
}
}
diff --git a/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/model/PdfFragmentUiState.kt b/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/model/PdfFragmentUiState.kt
new file mode 100644
index 0000000..1c65f9a
--- /dev/null
+++ b/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/model/PdfFragmentUiState.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.viewer.fragment.model
+
+import androidx.annotation.RestrictTo
+import androidx.pdf.PdfDocument
+
+/**
+ * A sealed interface representing the various UI states of the PdfViewerFragment.
+ *
+ * This sealed interface ensures that the UI states are mutually exclusive, meaning the fragment can
+ * only be in one state at a time. Each possible state is defined as a separate object/class that
+ * implements this interface.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+internal sealed interface PdfFragmentUiState {
+ /** Indicates that the PDF document is loading. */
+ object Loading : PdfFragmentUiState
+
+ /**
+ * Indicates that the PDF document has been loaded successfully.
+ *
+ * @property pdfDocument The loaded PDF document.
+ */
+ class DocumentLoaded(val pdfDocument: PdfDocument) : PdfFragmentUiState
+
+ /**
+ * Indicates that an error occurred while loading the PDF document.
+ *
+ * @property exception The exception that occurred.
+ */
+ class DocumentError(val exception: Exception) : PdfFragmentUiState
+
+ /**
+ * Indicates that the PDF document is password-protected and requires a password to be entered.
+ *
+ * @property passwordFailed Whether this is a retry attempt after an incorrect password was
+ * entered.
+ */
+ class PasswordRequested(val passwordFailed: Boolean) : PdfFragmentUiState
+}
diff --git a/pdf/pdf-viewer-fragment/src/main/res/layout/pdf_viewer_fragment.xml b/pdf/pdf-viewer-fragment/src/main/res/layout/pdf_viewer_fragment.xml
new file mode 100644
index 0000000..4527500
--- /dev/null
+++ b/pdf/pdf-viewer-fragment/src/main/res/layout/pdf_viewer_fragment.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.pdf.view.PdfView
+ android:id="@+id/pdfView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file