blob: b04e6679a24af12b04ec16dde1aa0cc5ada8846f [file]
/*
* Copyright 2025 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.ink
import android.graphics.Matrix
import android.net.Uri
import androidx.annotation.RestrictTo
import androidx.annotation.VisibleForTesting
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.EditablePdfDocument
import androidx.pdf.PdfDocument
import androidx.pdf.PdfLoader
import androidx.pdf.SandboxedPdfLoader
import androidx.pdf.annotation.PdfAnnotationsEditor
import androidx.pdf.annotation.history.AnnotationRecordsHistoryManager
import androidx.pdf.annotation.manager.PdfAnnotationsManager
import androidx.pdf.annotation.models.AnnotationsDisplayState
import androidx.pdf.annotation.models.PdfAnnotation
import androidx.pdf.annotation.models.PdfEdits
import androidx.pdf.annotation.models.VisiblePdfAnnotations
import androidx.pdf.ink.model.ApplyEditsState
import androidx.pdf.ink.state.AnnotationDrawingMode
import androidx.pdf.ink.state.PdfEditMode
import androidx.pdf.ink.state.PdfEditMode.Companion.EDITING_JOURNEY_ANNOTATIONS
import androidx.pdf.ink.util.InkDefaults
import androidx.pdf.ink.view.tool.AnnotationToolInfo
import androidx.pdf.ink.view.tool.Eraser
import androidx.pdf.ink.view.tool.Highlighter
import androidx.pdf.ink.view.tool.Pen
import androidx.pdf.viewer.fragment.PdfDocumentViewModel
import androidx.pdf.viewer.fragment.model.PdfFragmentUiState
import java.util.BitSet
import java.util.concurrent.Executors
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@RestrictTo(RestrictTo.Scope.LIBRARY)
public class EditableDocumentViewModel(private val state: SavedStateHandle, loader: PdfLoader) :
PdfDocumentViewModel(state, loader) {
private var recordsHistoryManager: AnnotationRecordsHistoryManager? = null
private var annotationsEditor: PdfAnnotationsEditor? = null
private var annotationsManager: PdfAnnotationsManager? = null
private var historyCollectionJob: Job? = null
private val bitmapAvailabilityMap = BitSet()
private val _annotationDisplayStateFlow = MutableStateFlow(AnnotationsDisplayState.EMPTY)
internal val annotationsDisplayStateFlow: StateFlow<AnnotationsDisplayState> =
_annotationDisplayStateFlow.asStateFlow()
private val _canUndo = MutableStateFlow(false)
internal val canUndo: StateFlow<Boolean> = _canUndo.asStateFlow()
private val _canRedo = MutableStateFlow(false)
internal val canRedo: StateFlow<Boolean> = _canRedo.asStateFlow()
internal val pdfEditModeFlow: StateFlow<PdfEditMode> =
state.getStateFlow(EDIT_MODE_ENABLED_KEY, PdfEditMode.Disabled)
internal var pdfEditMode: PdfEditMode
get() = state[EDIT_MODE_ENABLED_KEY] ?: PdfEditMode.Disabled
set(value) {
if (pdfEditMode == value) return
// Cannot switch journeys in the same session
if (pdfEditMode is PdfEditMode.Enabled && value is PdfEditMode.Enabled) return
state[EDIT_MODE_ENABLED_KEY] = value
if (value !is PdfEditMode.Enabled) {
// Discard any draft changes when exiting edit mode
discardUnsavedChanges()
forceLoadDocument()
}
}
internal var areAnnotationsVisible: Boolean
get() = state[ANNOTATION_VISIBLE_KEY] ?: true
set(value) {
state[ANNOTATION_VISIBLE_KEY] = value
}
internal var areAnnotationsVisibleFlow: StateFlow<Boolean> =
state.getStateFlow(ANNOTATION_VISIBLE_KEY, true)
private val _applyEditsStatus = MutableStateFlow<ApplyEditsState>(ApplyEditsState.Ready)
internal val applyEditsStatus: StateFlow<ApplyEditsState> = _applyEditsStatus.asStateFlow()
// TODO: b/441634479 Refactor to extract the document from `DocumentLoaded` UI state.
internal var editablePdfDocument: EditablePdfDocument? = null
private val _drawingMode =
MutableStateFlow<AnnotationDrawingMode>(
AnnotationDrawingMode.PenMode(
InkDefaults.DEFAULT_BRUSH_SIZE,
InkDefaults.DEFAULT_INK_COLOR,
)
)
internal val drawingMode: StateFlow<AnnotationDrawingMode> = _drawingMode.asStateFlow()
private val _isPdfViewGestureActive = MutableStateFlow(false)
internal var isPdfViewGestureActive: Boolean
get() = _isPdfViewGestureActive.value
set(value) {
_isPdfViewGestureActive.value = value
}
internal var visiblePageRange: IntRange = 0..0
internal val shouldShowAnnotationToolbar: StateFlow<Boolean> =
combine(pdfEditModeFlow, isTextSearchActiveFlow) { pdfEditMode, isTextSearchActive ->
pdfEditMode is PdfEditMode.Enabled &&
pdfEditMode.journey == EDITING_JOURNEY_ANNOTATIONS &&
!isTextSearchActive
}
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/** Reactive state that combines multiple flows to determine if interaction is enabled. */
internal val isAnnotationInteractionEnabled: StateFlow<Boolean> =
combine(
pdfEditModeFlow,
areAnnotationsVisibleFlow,
_applyEditsStatus,
_isPdfViewGestureActive,
isTextSearchActiveFlow,
) { pdfEditMode, isVisible, status, isGestureActive, isTextSearchActive ->
(pdfEditMode is PdfEditMode.Enabled &&
pdfEditMode.journey == EDITING_JOURNEY_ANNOTATIONS) &&
isVisible &&
status != ApplyEditsState.InProgress &&
!isGestureActive &&
!isTextSearchActive
}
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
private var didApplyEdits: Boolean = false
init {
viewModelScope.launch {
_fragmentUiScreenState.collect { state ->
if (state is PdfFragmentUiState.DocumentLoaded) {
maybeInitialiseForDocument(state.pdfDocument)
}
}
}
}
public override fun forceLoadDocument() {
if (didApplyEdits || formEditInfos.isNotEmpty()) {
resetState()
super.forceLoadDocument()
}
}
@VisibleForTesting
public override fun resetState() {
super.resetState()
pdfEditMode = PdfEditMode.Disabled
editablePdfDocument = null
_annotationDisplayStateFlow.value = AnnotationsDisplayState.EMPTY
didApplyEdits = false
bitmapAvailabilityMap.clear()
}
internal fun maybeInitialiseForDocument(document: PdfDocument) {
if (document is EditablePdfDocument) {
val documentUri = document.uri
setupManagersAndHandlers(documentUri, document)
} else {
editablePdfDocument = null
}
}
// Annotation Operations
internal fun addDraftAnnotation(annotation: PdfAnnotation) {
withEditor { editor ->
editor.addDraftAnnotation(annotation)
refreshVisibleAnnotations(visiblePageRange)
}
}
internal fun undo() {
withEditor { editor ->
editor.undo()
refreshVisibleAnnotations(visiblePageRange)
}
}
internal fun redo() {
withEditor { editor ->
editor.redo()
refreshVisibleAnnotations(visiblePageRange)
}
}
internal fun removeAnnotation(annotationKey: String) {
withEditor { editor ->
editor.removeAnnotation(annotationKey)
refreshVisibleAnnotations(visiblePageRange)
}
}
// Data Loading & Saving
/** Updates the transformation matrices for rendering annotations. */
internal fun updateTransformationMatrices(transformationMatrices: Map<Int, Matrix>) {
if (editablePdfDocument != null) {
_annotationDisplayStateFlow.update {
it.copy(transformationMatrices = transformationMatrices)
}
}
}
/**
* Fetches annotations from the [PdfAnnotationsManager] for the defined page range.
*
* @param startPage The starting page number (inclusive).
* @param endPage The ending page number (inclusive).
*/
internal fun fetchAnnotationsForPageRange(startPage: Int, endPage: Int) {
if (editablePdfDocument == null) return
viewModelScope.launch { refreshVisibleAnnotations(startPage..endPage) }
}
internal fun applyDraftEdits() {
val document = editablePdfDocument
val localAnnotationsManager = annotationsManager
if (document == null || localAnnotationsManager == null) {
_applyEditsStatus.value =
ApplyEditsState.Failure(IllegalStateException("Document not available"))
return
}
_applyEditsStatus.value = ApplyEditsState.InProgress
if (hasUnsavedChanges()) didApplyEdits = true
viewModelScope.launch {
try {
val editsDraft = localAnnotationsManager.getAnnotationModifications()
document.applyEdits(editsDraft)
val handle = document.createWriteHandle()
recordsHistoryManager?.clear()
annotationsManager?.discardChanges()
_applyEditsStatus.value = ApplyEditsState.Success(handle)
} catch (e: Exception) {
_applyEditsStatus.value = ApplyEditsState.Failure(e)
}
}
}
internal fun resetApplyEditsStatus() {
_applyEditsStatus.value = ApplyEditsState.Ready
}
/**
* Checks for unsaved changes by verifying if there are any edits in the history.
*
* @return `true` if unsaved changes exist, `false` if the document is not loaded or there are
* no changes.
*/
internal fun hasUnsavedChanges(): Boolean =
editablePdfDocument != null &&
((recordsHistoryManager?.canUndo?.value ?: false) || formEditInfos.isNotEmpty())
/** Discards all uncommitted edits, reverting the document to its last saved state. */
private fun discardUnsavedChanges() {
withEditor { editor ->
editor.clear()
refreshVisibleAnnotations(visiblePageRange)
}
}
/**
* Shared logic to re-fetch annotations for the screen and update the UI flow. Consolidating
* this reduces code duplication and allocation errors.
*/
private suspend fun refreshVisibleAnnotations(range: IntRange) {
val manager = annotationsManager ?: return
// Defensive check to avoid stale obsolete get invocations
if (visiblePageRange != range) return
val pageAnnotations =
range
.associateWith { pageNum ->
// Display Annotation only for pages whose bitmap is available
if (bitmapAvailabilityMap.get(pageNum)) {
manager.getAnnotations(pageNum)
} else {
listOf()
}
}
.filterValues { it.isNotEmpty() }
// This check ensures that the flow is not updated with stale data
if (visiblePageRange != range) return
_annotationDisplayStateFlow.update {
it.copy(visiblePageAnnotations = VisiblePdfAnnotations(pageAnnotations))
}
}
private fun setupManagersAndHandlers(
documentUri: Uri?,
document: EditablePdfDocument?,
initialMatrices: Map<Int, Matrix> = emptyMap(),
) {
// Cleanup previous flows to prevent memory leaks
historyCollectionJob?.cancel()
if (documentUri != null && document != null) {
state[LOADED_DOCUMENT_URI_KEY] = documentUri
editablePdfDocument = document
val manager = PdfAnnotationsManager.create(document)
val history = AnnotationRecordsHistoryManager()
val editor = PdfAnnotationsEditor(history, manager)
recordsHistoryManager = history
annotationsEditor = editor
annotationsManager = manager
// Collect history states in a tracked job
historyCollectionJob =
viewModelScope.launch {
launch { history.canUndo.collect { _canUndo.value = it } }
launch { history.canRedo.collect { _canRedo.value = it } }
}
viewModelScope.launch {
val visiblePdfAnnotations =
VisiblePdfAnnotations(
pageAnnotations =
visiblePageRange
.associateWith { pageNum -> manager.getAnnotations(pageNum) }
.filterValues { it.isNotEmpty() }
)
_annotationDisplayStateFlow.value =
AnnotationsDisplayState(
edits = PdfEdits(editsByPage = emptyMap()),
transformationMatrices = initialMatrices,
visiblePageAnnotations = visiblePdfAnnotations,
)
}
} else {
editablePdfDocument = null
recordsHistoryManager = null
annotationsEditor = null
annotationsManager = null
}
}
internal fun setCurrentToolInfo(toolInfo: AnnotationToolInfo) {
val pdfDocument = editablePdfDocument
when (toolInfo) {
is Pen ->
_drawingMode.value =
AnnotationDrawingMode.PenMode(toolInfo.brushSize, toolInfo.color)
is Highlighter -> {
if (toolInfo.color != null && pdfDocument != null) {
_drawingMode.value =
AnnotationDrawingMode.HighlighterMode(
toolInfo.brushSize,
toolInfo.color,
pdfDocument,
)
} else {
// TODO: Add support for emoji highlighter
}
}
is Eraser -> _drawingMode.value = AnnotationDrawingMode.EraserMode
}
}
internal var initialFormFillingEnabledState: Boolean?
get() = state[INITIAL_FORM_FILLING_STATE_KEY]
set(value) {
state[INITIAL_FORM_FILLING_STATE_KEY] = value
}
private fun withEditor(block: suspend (PdfAnnotationsEditor) -> Unit) {
viewModelScope.launch {
if (annotationsEditor == null) {
setupManagersAndHandlers(
documentUri = state.get<Uri>(LOADED_DOCUMENT_URI_KEY),
document = editablePdfDocument,
)
}
annotationsEditor?.let { block(it) }
}
}
internal fun onBitmapFetched(pageNum: Int) {
bitmapAvailabilityMap.set(pageNum)
if (pageNum in visiblePageRange) {
viewModelScope.launch { refreshVisibleAnnotations(visiblePageRange) }
}
}
internal fun onBitmapCleared(pageNum: Int) {
bitmapAvailabilityMap.clear(pageNum)
if (pageNum in visiblePageRange) {
viewModelScope.launch { refreshVisibleAnnotations(visiblePageRange) }
}
}
@Suppress("UNCHECKED_CAST")
internal companion object {
const val LOADED_DOCUMENT_URI_KEY = "loadedDocumentUri"
private const val EDIT_MODE_ENABLED_KEY = "isEditModeEnabled"
private const val ANNOTATION_VISIBLE_KEY = "isAnnotationVisible"
private const val INITIAL_FORM_FILLING_STATE_KEY = "initialFormFillingState"
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 (EditableDocumentViewModel(
savedStateHandle,
SandboxedPdfLoader(application, dispatcher),
))
as T
}
}
}
}