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