diff --git a/app/build.gradle.kts b/app/build.gradle.kts index faab97578ab..5bfb41bd28b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -213,6 +213,7 @@ dependencies { implementationWithCoverage(projects.core.di) implementationWithCoverage(projects.core.media) implementationWithCoverage(projects.core.mediaPlayer) + implementationWithCoverage(projects.core.pdfViewer) implementationWithCoverage(projects.core.notification) implementationWithCoverage(projects.core.navigation) implementationWithCoverage(projects.core.search) diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt index e0bed7382b4..80515214ace 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt @@ -21,6 +21,7 @@ import com.wire.android.di.CurrentAccount import com.wire.android.di.KaliumCoreLogic import com.wire.android.feature.cells.util.FileNameResolver import com.wire.android.ui.home.conversations.model.messagetypes.multipart.CellAssetRefreshHelper +import com.wire.android.pdfviewer.PdfRemoteLoader import com.wire.kalium.cells.CellsScope import com.wire.kalium.cells.domain.CellUploadManager import com.wire.kalium.cells.domain.usecase.AddAttachmentDraftUseCase @@ -72,11 +73,14 @@ import com.wire.kalium.cells.domain.usecase.versioning.GetNodeVersionsUseCase import com.wire.kalium.cells.domain.usecase.versioning.RestoreNodeVersionUseCase import com.wire.kalium.cells.paginatedConversationsFlowUseCase import com.wire.kalium.cells.paginatedFilesFlowUseCase +import com.wire.kalium.common.functional.fold import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.featureFlags.KaliumConfigs import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.Provides +import java.io.IOException +import okio.Path.Companion.toOkioPath @Suppress("TooManyFunctions") @BindingContainer @@ -253,4 +257,20 @@ class CellsModule { @Provides fun provideGetUserNamesUseCase(cellsScope: CellsScope): GetUserNameUseCase = cellsScope.getUserName + + @Provides + fun providePdfRemoteLoader(download: DownloadCellFileUseCase): PdfRemoteLoader = + PdfRemoteLoader { assetId, remotePath, conversationId, assetSize, outFile -> + download( + assetId = assetId, + conversationId = conversationId, + outFilePath = outFile.toPath().toOkioPath(), + assetSize = assetSize, + remoteFilePath = remotePath, + onProgressUpdate = {}, + ).fold( + { failure -> Result.failure(IOException("PDF download failed: $failure")) }, + { Result.success(Unit) }, + ) + } } diff --git a/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt index 22c93c6c327..6bb6fd3b28c 100644 --- a/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt @@ -41,6 +41,7 @@ import com.wire.android.feature.meetings.ui.MeetingsManualViewModelFactoryMetroB import com.wire.android.feature.meetings.ui.MeetingsMetroViewModelBindings import com.wire.android.feature.sketch.SketchMetroViewModelBindings import com.wire.android.mediaplayer.MediaPlayerManualViewModelFactoryMetroBindings +import com.wire.android.pdfviewer.PdfViewerManualViewModelFactoryMetroBindings import com.wire.android.search.SearchManualViewModelFactoryMetroBindings import com.wire.android.ui.authentication.AuthenticationViewModelGraph import com.wire.android.ui.calling.CallingMetroViewModelBindings @@ -111,6 +112,7 @@ annotation class MetroSessionScope CoreUICommonManualViewModelFactoryMetroBindings::class, SearchManualViewModelFactoryMetroBindings::class, MediaPlayerManualViewModelFactoryMetroBindings::class, + PdfViewerManualViewModelFactoryMetroBindings::class, ImageLoadingModule::class, ] ) diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Entries.kt index 8fe153c6165..fb61869064b 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Entries.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Entries.kt @@ -12,6 +12,7 @@ import com.wire.android.navigation.navigation3.WireNavigation3ResultType import com.wire.android.navigation.navigation3.WireNavigation3Runtime import com.wire.android.navigation.navigation3.wireEntry import com.wire.android.mediaplayer.VideoPlayer +import com.wire.android.pdfviewer.PdfViewer import com.wire.android.ui.home.FeatureFlagState import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.checkAssetRestrictionsViewModel @@ -122,6 +123,17 @@ internal fun mediaNavigation3Entries( onNavigateBack = runtime.navigator::goBack, ) } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + PdfViewer( + localPath = route.localPath, + assetId = route.assetId, + remotePath = route.remotePath, + conversationId = route.conversationId, + assetSize = route.assetSize, + fileName = route.fileName, + onNavigateBack = runtime.navigator::goBack, + ) + } wireEntry(presentation = WireEntryPresentation.PopUp) { route -> MessageDetailsRouteScreen(messageDetailsViewModel(route.toViewModelArgs()), runtime.navigator::goBack) } diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaRoutes.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaRoutes.kt index b1e29bd649b..ba4e9dd34d9 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaRoutes.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaRoutes.kt @@ -75,6 +75,21 @@ data class VideoPlayerRoute( companion object { const val ROUTE_ID = "app/video_player_screen" } } +@Serializable +data class PdfViewerRoute( + override val sessionId: WireSessionId, + val localPath: String? = null, + val assetId: String? = null, + val remotePath: String? = null, + val conversationId: String? = null, + val assetSize: Long = 0L, + val fileName: String? = null, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "app/pdf_viewer_screen" } +} + @Serializable data class MessageDetailsRoute( override val sessionId: WireSessionId, diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt index ad061270856..08868526c54 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt @@ -95,7 +95,7 @@ internal data class WireNavigation3ContributionCatalog( * app-lock overlays finish the registry. An entry type must be owned by exactly one contribution. */ internal object WireNavigation3Contributions { - const val EXPECTED_ROUTE_REGISTRATION_COUNT: Int = 107 + const val EXPECTED_ROUTE_REGISTRATION_COUNT: Int = 109 const val EXPECTED_INSTALLER_COUNT: Int = 19 fun create( diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt index 1ba53f2fc06..bd7ce44021d 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt @@ -24,6 +24,7 @@ import com.wire.android.feature.cells.navigation.CellImageViewerRoute import com.wire.android.feature.cells.navigation.CellsFilesArguments import com.wire.android.feature.cells.navigation.CellsSearchType import com.wire.android.feature.cells.navigation.ConversationFilesRoute +import com.wire.android.feature.cells.navigation.PdfViewerRoute import com.wire.android.feature.cells.navigation.PublicLinkRoute import com.wire.android.feature.cells.navigation.SearchRoute import com.wire.android.feature.cells.navigation.VideoPlayerRoute @@ -196,6 +197,19 @@ internal class WireNavigation3ProductionActions( ) ) }, + showPdfViewer = { + navigate( + PdfViewerRoute( + sessionId = requireSession(), + localPath = it.localPath, + assetId = it.uuid, + remotePath = it.remotePath, + conversationId = it.conversationId, + assetSize = it.size ?: 0L, + fileName = it.name, + ) + ) + }, ) override val conversationList: ConversationListNavigationActions = ConversationListNavigationActions( openConversation = { openConversation(it.toProfileId()) }, diff --git a/app/src/main/kotlin/com/wire/android/ui/common/multipart/MultipartAttachmentUi.kt b/app/src/main/kotlin/com/wire/android/ui/common/multipart/MultipartAttachmentUi.kt index f036b8449b1..7212a94f8b2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/multipart/MultipartAttachmentUi.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/multipart/MultipartAttachmentUi.kt @@ -33,6 +33,7 @@ data class MultipartAttachmentUi( val contentUrl: String? = null, val contentUrlExpiresAt: Long? = null, val previewUrl: String? = null, + val remotePath: String? = null, val mimeType: String, val assetType: AttachmentFileType, val assetSize: Long?, @@ -60,6 +61,7 @@ fun CellAssetContent.toUiModel(progress: Float?, isAvailableOffline: Boolean = f contentUrl = this.contentUrl, contentUrlExpiresAt = this.contentUrlExpiresAt, previewUrl = this.previewUrl, + remotePath = this.assetPath, mimeType = this.mimeType, assetType = AttachmentFileType.fromMimeType(mimeType), assetSize = this.assetSize, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationMessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationMessageComposer.kt index 5da7f936ddf..1e7d534e7b6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationMessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationMessageComposer.kt @@ -58,6 +58,7 @@ internal fun ConversationMessageComposer( onAssetItemClicked: (String) -> Unit, onImageFullScreenMode: (UIMessage.Regular, Boolean, String?) -> Unit, onVideoClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, + onPdfClick: (localPath: String?, assetId: String?, remotePath: String?, assetSize: Long, fileName: String?) -> Unit, onReactionClicked: (String, String) -> Unit, onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit, onOpenProfile: (senderId: MessageSenderId) -> Unit, @@ -115,6 +116,7 @@ internal fun ConversationMessageComposer( onAssetClicked = onAssetItemClicked, onImageClicked = onImageFullScreenMode, onVideoClicked = onVideoClick, + onPdfClicked = onPdfClick, onLinkClicked = onLinkClick, onReplyClicked = onNavigateToReplyOriginalMessage, onResetSessionClicked = onResetSessionClicked, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt index 17e88003738..60a9cf43d6b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt @@ -29,6 +29,7 @@ import com.wire.android.navigation.routes.media.MediaGalleryNavigation3ResultTyp import com.wire.android.navigation.routes.media.MediaGalleryResult import com.wire.android.navigation.routes.media.MediaGalleryResultAction import com.wire.android.navigation.routes.media.MediaGalleryRoute +import com.wire.android.navigation.routes.media.PdfViewerRoute import com.wire.android.navigation.routes.media.VideoPlayerRoute import com.wire.android.navigation.routes.media.MessageDetailsRoute import com.wire.android.navigation.routes.media.toLegacy @@ -251,6 +252,21 @@ private fun ConversationNavigation3Entry( ) } + override fun openPdfViewer(localPath: String?, assetId: String?, remotePath: String?, assetSize: Long, fileName: String?) { + runtime.navigator.navigate( + WireNavigationCommand( + PdfViewerRoute( + sessionId = route.sessionId, + localPath = localPath, + assetId = assetId, + remotePath = remotePath, + assetSize = assetSize, + fileName = fileName, + ) + ) + ) + } + override fun openDrawingCanvas(conversationName: String, tempWritableUri: Uri?) { drawingRequestId = runtime.navigateForResult( DrawingCanvasRoute( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationRouteScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationRouteScreen.kt index 5a429b66d70..be993a37423 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationRouteScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationRouteScreen.kt @@ -52,6 +52,8 @@ internal interface ConversationRouteScreenNavigation { fun openVideoPlayer(localPath: String?, contentUrl: String?, fileName: String?) + fun openPdfViewer(localPath: String?, assetId: String?, remotePath: String?, assetSize: Long, fileName: String?) + fun openDrawingCanvas( conversationName: String, tempWritableUri: Uri?, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index de7ba41a68a..0fc6e210110 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -438,6 +438,7 @@ internal fun ConversationScreenRouteContent( } }, onVideoClick = navigation::openVideoPlayer, + onPdfClick = navigation::openPdfViewer, onStartCall = { conversationCallViewModel.startCallIfPossible(conversationInfoViewModel.conversationInfoViewState.conversationType) }, @@ -642,6 +643,7 @@ private fun ConversationScreenContent( onAssetItemClicked: (String) -> Unit, onImageFullScreenMode: (UIMessage.Regular, Boolean, String?) -> Unit, onVideoClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, + onPdfClick: (localPath: String?, assetId: String?, remotePath: String?, assetSize: Long, fileName: String?) -> Unit, onStartCall: () -> Unit, onJoinCall: () -> Unit, onReactionClick: (messageId: String, reactionEmoji: String) -> Unit, @@ -754,6 +756,7 @@ private fun ConversationScreenContent( onAssetItemClicked = onAssetItemClicked, onImageFullScreenMode = onImageFullScreenMode, onVideoClick = onVideoClick, + onPdfClick = onPdfClick, onReactionClicked = onReactionClick, onResetSessionClicked = onResetSessionClick, onOpenProfile = onOpenProfile, @@ -896,6 +899,7 @@ fun PreviewConversationScreen() = WireTheme { onAssetItemClicked = { }, onImageFullScreenMode = { _, _, _ -> }, onVideoClick = { _, _, _ -> }, + onPdfClick = { _, _, _, _, _ -> }, onStartCall = { }, onJoinCall = { }, onReactionClick = { _, _ -> }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt index 62274c1d983..ce08adbe92e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt @@ -30,6 +30,7 @@ sealed class MessageClickActions { open val onAssetClicked: (String) -> Unit = {} open val onImageClicked: (UIMessage.Regular, Boolean, String?) -> Unit = { _, _, _ -> } open val onVideoClicked: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit = { _, _, _ -> } + open val onPdfClicked: (localPath: String?, assetId: String?, remotePath: String?, assetSize: Long, fileName: String?) -> Unit = { _, _, _, _, _ -> } open val onLinkClicked: (String) -> Unit = {} open val onReplyClicked: (UIMessage.Regular) -> Unit = {} open val onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit = { _, _ -> } @@ -48,6 +49,7 @@ sealed class MessageClickActions { override val onAssetClicked: (String) -> Unit = {}, override val onImageClicked: (UIMessage.Regular, Boolean, String?) -> Unit = { _, _, _ -> }, override val onVideoClicked: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit = { _, _, _ -> }, + override val onPdfClicked: (localPath: String?, assetId: String?, remotePath: String?, assetSize: Long, fileName: String?) -> Unit = { _, _, _, _, _ -> }, override val onLinkClicked: (String) -> Unit = {}, override val onReplyClicked: (UIMessage.Regular) -> Unit = {}, override val onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit = { _, _ -> }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index 007570c5d4b..656fcb3477e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -73,6 +73,7 @@ internal fun UIMessage.Regular.MessageContentAndStatus( onAssetClicked: (String) -> Unit, onImageClicked: (UIMessage.Regular, Boolean, String?) -> Unit, onVideoClicked: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, + onPdfClicked: (localPath: String?, assetId: String?, remotePath: String?, assetSize: Long, fileName: String?) -> Unit, onProfileClicked: (senderId: MessageSenderId) -> Unit, onLinkClicked: (String) -> Unit, onReplyClicked: (UIMessage.Regular) -> Unit, @@ -121,6 +122,7 @@ internal fun UIMessage.Regular.MessageContentAndStatus( onImageClick = onImageClickable, onMultipartImageClick = onMultipartImageClickable, onMultipartVideoClick = onVideoClicked, + onMultipartPdfClick = onPdfClicked, onOpenProfile = onProfileClicked, onLinkClick = onLinkClicked, onReplyClick = onReplyClickable, @@ -170,6 +172,7 @@ private fun MessageContent( onImageClick: Clickable, onMultipartImageClick: (String) -> Unit, onMultipartVideoClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, + onMultipartPdfClick: (localPath: String?, assetId: String?, remotePath: String?, assetSize: Long, fileName: String?) -> Unit, onOpenProfile: (senderId: MessageSenderId) -> Unit, onLinkClick: (String) -> Unit, onReplyClick: Clickable, @@ -464,6 +467,7 @@ private fun MessageContent( messageStyle = messageStyle, onImageAttachmentClick = onMultipartImageClick, onVideoAttachmentClick = onMultipartVideoClick, + onPdfAttachmentClick = onMultipartPdfClick, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentItem.kt index 59184ca075e..b8bae4501d4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentItem.kt @@ -96,6 +96,7 @@ fun MessageContentItem( onAssetClicked = clickActions.onAssetClicked, onImageClicked = clickActions.onImageClicked, onVideoClicked = clickActions.onVideoClicked, + onPdfClicked = clickActions.onPdfClicked, searchQuery = searchQuery, accent = accent, onProfileClicked = clickActions.onProfileClicked, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt index d9581475c57..7d06bfc7234 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt @@ -59,6 +59,7 @@ fun MultipartAttachmentsView( messageStyle: MessageStyle, onImageAttachmentClick: (String) -> Unit, onVideoAttachmentClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, + onPdfAttachmentClick: (localPath: String?, assetId: String?, remotePath: String?, assetSize: Long, fileName: String?) -> Unit, modifier: Modifier = Modifier, viewModel: MultipartAttachmentsViewModel = when { LocalInspectionMode.current -> MultipartAttachmentsViewModelPreview @@ -68,6 +69,15 @@ fun MultipartAttachmentsView( // Collect to trigger recomposition when offline availability changes. val offlineAttachmentIds by viewModel.offlineAttachmentIds.collectAsStateWithLifecycle() + val handleClick: (MultipartAttachmentUi) -> Unit = { clicked -> + viewModel.onClick( + attachment = clicked, + openInImageViewer = onImageAttachmentClick, + openInVideoPlayer = { att -> onVideoAttachmentClick(att.localPath, att.contentUrl, att.fileName) }, + openInPdfViewer = { att -> onPdfAttachmentClick(att.localPath, att.uuid, att.remotePath, att.assetSize ?: 0L, att.fileName) }, + ) + } + // TODO I found out that empty attachments list is not handled here and it shows empty message with no information if (attachments.size == 1) { val attachment = attachments.first() @@ -86,15 +96,7 @@ fun MultipartAttachmentsView( }, item = it, messageStyle = messageStyle, - onClick = { - viewModel.onClick( - attachment = it, - openInImageViewer = onImageAttachmentClick, - openInVideoPlayer = { att -> - onVideoAttachmentClick(att.localPath, att.contentUrl, att.fileName) - }, - ) - }, + onClick = { handleClick(it) }, ) } } else { @@ -119,30 +121,14 @@ fun MultipartAttachmentsView( AttachmentsGrid( attachments = group.attachments, messageStyle = messageStyle, - onClick = { - viewModel.onClick( - attachment = it, - openInImageViewer = onImageAttachmentClick, - openInVideoPlayer = { att -> - onVideoAttachmentClick(att.localPath, att.contentUrl, att.fileName) - }, - ) - }, + onClick = handleClick, ) is MultipartAttachmentsViewModel.MultipartAttachmentGroup.Files -> AttachmentsList( attachments = group.attachments, messageStyle = messageStyle, - onClick = { - viewModel.onClick( - attachment = it, - openInImageViewer = onImageAttachmentClick, - openInVideoPlayer = { att -> - onVideoAttachmentClick(att.localPath, att.contentUrl, att.fileName) - }, - ) - }, + onClick = handleClick, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt index e074186eb1e..4b3b57ba3f3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt @@ -62,6 +62,7 @@ interface MultipartAttachmentsViewModel { attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit, openInVideoPlayer: (MultipartAttachmentUi) -> Unit, + openInPdfViewer: (MultipartAttachmentUi) -> Unit, ) fun mapAttachment(attachment: MessageAttachment): MultipartAttachmentUi { val isAvailableOffline = attachment.assetId() in offlineAttachmentIds.value @@ -128,6 +129,7 @@ object MultipartAttachmentsViewModelPreview : MultipartAttachmentsViewModel { attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit, openInVideoPlayer: (MultipartAttachmentUi) -> Unit, + openInPdfViewer: (MultipartAttachmentUi) -> Unit, ) {} override fun onAttachmentsVisible(attachments: List) {} override fun onAttachmentsHidden(attachments: List) {} @@ -168,6 +170,7 @@ class MultipartAttachmentsViewModelImpl @AssistedInject constructor( attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit, openInVideoPlayer: (MultipartAttachmentUi) -> Unit, + openInPdfViewer: (MultipartAttachmentUi) -> Unit, ) { when { attachment.isImage() && !attachment.fileNotFound() -> openInImageViewer(attachment.uuid) @@ -181,6 +184,9 @@ class MultipartAttachmentsViewModelImpl @AssistedInject constructor( attachment.isVideo() && (attachment.localFileAvailable() || attachment.canOpenWithUrl()) -> openInVideoPlayer(attachment) + attachment.isPdf() && (attachment.localFileAvailable() || attachment.canDownloadRemotely()) -> + openInPdfViewer(attachment) + attachment.localFileAvailable() -> openLocalFile(attachment) attachment.canOpenWithUrl() -> openUrl(attachment) else -> downloadAsset(attachment) @@ -275,6 +281,8 @@ private fun MultipartAttachmentUi.isImage() = AttachmentFileType.fromMimeType(mi private fun MultipartAttachmentUi.isVideo() = assetType == VIDEO +private fun MultipartAttachmentUi.isPdf() = assetType == PDF + private fun MessageAttachment.isMediaAttachment() = when (AttachmentFileType.fromMimeType(mimeType())) { IMAGE, VIDEO -> true @@ -283,4 +291,5 @@ private fun MessageAttachment.isMediaAttachment() = private fun MultipartAttachmentUi.fileNotFound() = transferStatus == AssetTransferStatus.NOT_FOUND private fun MultipartAttachmentUi.localFileAvailable() = localPath != null -private fun MultipartAttachmentUi.canOpenWithUrl() = contentUrl != null && assetType in listOf(IMAGE, VIDEO, PDF) +private fun MultipartAttachmentUi.canOpenWithUrl() = contentUrl != null && assetType in listOf(IMAGE, VIDEO) +private fun MultipartAttachmentUi.canDownloadRemotely() = remotePath != null && assetType == PDF diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt index 3517a55f27b..cc2f2b3c132 100644 --- a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt @@ -90,7 +90,7 @@ class WireNavigation3ContributionsTest { } assertEquals(WireNavigation3Contributions.EXPECTED_ROUTE_REGISTRATION_COUNT, registrationCount) - assertEquals(107, registrationCount) + assertEquals(109, registrationCount) } @Test diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt index b26d87e3df3..237e59bce57 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt @@ -50,6 +50,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith typealias OpenImageCallback = (s: String) -> Unit +typealias OpenAttachmentCallback = (attachment: MultipartAttachmentUi) -> Unit @ExtendWith(CoroutineTestExtension::class) class MultipartAttachmentsViewModelTest { @@ -186,7 +187,7 @@ class MultipartAttachmentsViewModelTest { val callback = mockk(relaxed = true) - viewModel.onClick(testAttachmentUi, callback, {}) + viewModel.onClick(testAttachmentUi, callback, {}, {}) coVerify(exactly = 1) { callback.invoke(testAttachmentUi.uuid) } } @@ -203,7 +204,8 @@ class MultipartAttachmentsViewModelTest { transferStatus = AssetTransferStatus.NOT_FOUND, ), openInImageViewer = callback, - openInVideoPlayer = { } + openInVideoPlayer = { }, + openInPdfViewer = { }, ) coVerify(exactly = 0) { callback.invoke(testAttachmentUi.uuid) } @@ -220,10 +222,12 @@ class MultipartAttachmentsViewModelTest { viewModel.onClick( attachment = testAttachmentUi.copy( mimeType = "application/pdf", + assetType = AttachmentFileType.PDF, transferStatus = AssetTransferStatus.NOT_FOUND, ), openInImageViewer = callback, - openInVideoPlayer = { } + openInVideoPlayer = { }, + openInPdfViewer = { }, ) coVerify(exactly = 0) { callback.invoke(testAttachmentUi.uuid) } @@ -239,11 +243,13 @@ class MultipartAttachmentsViewModelTest { viewModel.onClick( attachment = testAttachmentUi.copy( - mimeType = "application/pdf", + mimeType = "application/zip", + assetType = AttachmentFileType.ARCHIVE, localPath = "local/path", ), openInImageViewer = callback, - openInVideoPlayer = { } + openInVideoPlayer = { }, + openInPdfViewer = { }, ) coVerify(exactly = 1) { arrangement.fileManager.openWithExternalApp(any(), any(), any(), any()) } @@ -258,16 +264,85 @@ class MultipartAttachmentsViewModelTest { viewModel.onClick( attachment = testAttachmentUi.copy( - mimeType = "application/pdf", + mimeType = "application/zip", + assetType = AttachmentFileType.ARCHIVE, contentUrl = "content/url", ), openInImageViewer = callback, - openInVideoPlayer = { } + openInVideoPlayer = { }, + openInPdfViewer = { }, ) coVerify(exactly = 1) { arrangement.fileManager.openUrlWithExternalApp(any(), any(), any()) } } + @Test + fun `with pdf attachment with local file available when clicked then pdf opened in internal viewer`() = runTest { + val (arrangement, viewModel) = Arrangement() + .arrange() + + val callback = mockk(relaxed = true) + val attachment = testAttachmentUi.copy( + mimeType = "application/pdf", + assetType = AttachmentFileType.PDF, + localPath = "local/path", + ) + + viewModel.onClick( + attachment = attachment, + openInImageViewer = { }, + openInVideoPlayer = { }, + openInPdfViewer = callback, + ) + + coVerify(exactly = 1) { callback.invoke(attachment) } + coVerify(exactly = 0) { arrangement.fileManager.openWithExternalApp(any(), any(), any(), any()) } + } + + @Test + fun `with pdf attachment openable via remote path when clicked then pdf opened in internal viewer`() = runTest { + val (arrangement, viewModel) = Arrangement() + .arrange() + + val callback = mockk(relaxed = true) + val attachment = testAttachmentUi.copy( + mimeType = "application/pdf", + assetType = AttachmentFileType.PDF, + remotePath = "/cells/path/doc.pdf", + ) + + viewModel.onClick( + attachment = attachment, + openInImageViewer = { }, + openInVideoPlayer = { }, + openInPdfViewer = callback, + ) + + coVerify(exactly = 1) { callback.invoke(attachment) } + coVerify(exactly = 0) { arrangement.fileManager.openUrlWithExternalApp(any(), any(), any()) } + } + + @Test + fun `with pdf attachment not downloaded yet when clicked then the viewer is not opened`() = runTest { + val (arrangement, viewModel) = Arrangement() + .arrange() + + val callback = mockk(relaxed = true) + + viewModel.onClick( + attachment = testAttachmentUi.copy( + mimeType = "application/pdf", + assetType = AttachmentFileType.PDF, + ), + openInImageViewer = { }, + openInVideoPlayer = { }, + openInPdfViewer = callback, + ) + + coVerify(exactly = 0) { callback.invoke(any()) } + coVerify(exactly = 0) { arrangement.fileManager.openWithExternalApp(any(), any(), any(), any()) } + } + // TODO: Refresh asset tests (part of refresh update PR) private class Arrangement { diff --git a/core/pdf-viewer/build.gradle.kts b/core/pdf-viewer/build.gradle.kts new file mode 100644 index 00000000000..ddc0ca08878 --- /dev/null +++ b/core/pdf-viewer/build.gradle.kts @@ -0,0 +1,58 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +plugins { + id(libs.plugins.wire.android.library.get().pluginId) + id(libs.plugins.wire.kover.get().pluginId) + id(BuildPlugins.junit5) + id(libs.plugins.wire.compose.compiler.get().pluginId) + alias(libs.plugins.compose.stability.analyzer) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.wire.android.pdfviewer" +} + +dependencies { + + implementation(project(":core:di")) + implementation(project(":core:ui-common")) + + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + implementation(libs.coroutines.android) + + val composeBom = enforcedPlatform(libs.compose.bom) + implementation(composeBom) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.material3) + implementation(libs.compose.activity) + implementation(libs.androidx.lifecycle.viewModelCompose) + implementation(libs.compose.ui.preview) + implementation(libs.metrox.viewModelCompose) + debugImplementation(libs.compose.ui.tooling) + + testImplementation(libs.junit5.core) + testImplementation(libs.coroutines.test) + testImplementation(libs.mockk.core) + testImplementation(libs.turbine) + testRuntimeOnly(libs.junit5.engine) + testImplementation(testFixtures(project(":core:ui-common"))) + ksp(project(":ksp")) +} diff --git a/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PageBitmapCache.kt b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PageBitmapCache.kt new file mode 100644 index 00000000000..d530edb7f39 --- /dev/null +++ b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PageBitmapCache.kt @@ -0,0 +1,69 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.pdfviewer + +import android.graphics.Bitmap + +/** + * Least-recently-used cache of rendered pages, bounded by the total bitmap size in bytes rather + * than by a page count, because page bitmaps vary a lot in size. + * + * Deliberately not [android.util.LruCache]: that one is stubbed out in unit tests, and the byte + * accounting is the part worth covering. + */ +internal class PageBitmapCache(private val maxBytes: Long) { + + private val entries = LinkedHashMap(0, LOAD_FACTOR, true) + private var currentBytes = 0L + + @Synchronized + fun get(key: String): Bitmap? = entries[key] + + @Synchronized + fun put(key: String, bitmap: Bitmap) { + entries.put(key, bitmap)?.let { replaced -> currentBytes -= replaced.byteCount } + currentBytes += bitmap.byteCount + trimToSize() + } + + @Synchronized + fun clear() { + entries.clear() + currentBytes = 0 + } + + @Synchronized + fun size(): Int = entries.size + + /** Drops the least recently used entries until the cache fits again, always keeping the newest. */ + private fun trimToSize() { + val iterator = entries.entries.iterator() + while (currentBytes > maxBytes && entries.size > 1 && iterator.hasNext()) { + currentBytes -= iterator.next().value.byteCount + iterator.remove() + } + } + + companion object { + private const val LOAD_FACTOR = 0.75f + private const val HEAP_FRACTION = 8 + + /** Roughly an eighth of the heap, the same budget the platform LRU caches usually take. */ + fun defaultMaxBytes(): Long = (Runtime.getRuntime().maxMemory() / HEAP_FRACTION).coerceAtLeast(1) + } +} diff --git a/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfDocument.kt b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfDocument.kt new file mode 100644 index 00000000000..386bb608e03 --- /dev/null +++ b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfDocument.kt @@ -0,0 +1,120 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.pdfviewer + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import java.io.Closeable +import java.io.File +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** + * Thin coroutine-friendly wrapper around the platform [PdfRenderer]. + * + * Everything goes through [mutex] because [PdfRenderer] only allows a single open page at a time + * and is not thread safe. Rendering happens entirely in-process — no network access and no + * third party parser — which keeps documents from ever leaving the device. + */ +internal class PdfDocument private constructor( + private val descriptor: ParcelFileDescriptor, + private val renderer: PdfRenderer, +) : Closeable { + + /** + * Guards every access to [renderer], including [close]. + * + * A blocking lock rather than a coroutine `Mutex` on purpose: [close] is not a suspending + * function, so it could never have joined a `Mutex`, and releasing the native handle while a + * render is in flight crashes inside PdfRenderer — below the level any `runCatching` could + * recover from. Callers must therefore be off the main thread; [PdfViewerViewModel] dispatches + * all of them to IO. + */ + private val lock = ReentrantLock() + private var closed = false + + val pageCount: Int = renderer.pageCount + + /** Width / height of [pageIndex], used to reserve the right amount of space before rendering. */ + fun aspectRatio(pageIndex: Int): Float = lock.withLock { + if (closed) return DEFAULT_ASPECT_RATIO + renderer.openPage(pageIndex).use { page -> + if (page.height == 0) DEFAULT_ASPECT_RATIO else page.width.toFloat() / page.height + } + } + + /** + * Renders [pageIndex] into a bitmap [widthPx] wide, keeping the page aspect ratio. + * + * Returns `null` when the document was closed while the caller was waiting for the lock. + */ + fun renderPage(pageIndex: Int, widthPx: Int): Bitmap? = lock.withLock { + if (closed) return null + renderer.openPage(pageIndex).use { page -> + val safeWidth = widthPx.coerceIn(MIN_RENDER_WIDTH_PX, MAX_RENDER_WIDTH_PX) + val height = if (page.width == 0) { + safeWidth + } else { + (safeWidth.toLong() * page.height / page.width).toInt() + }.coerceIn(MIN_RENDER_WIDTH_PX, MAX_RENDER_WIDTH_PX) + + Bitmap.createBitmap(safeWidth, height, Bitmap.Config.ARGB_8888).apply { + // PdfRenderer draws only the page content, so the paper itself has to be painted. + eraseColor(Color.WHITE) + page.render(this, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + } + } + } + + /** + * Releases the renderer and the file descriptor. Blocks until any in-flight page has finished + * rendering, so it must not be called from the main thread. + */ + override fun close() { + lock.withLock { + if (closed) return + closed = true + runCatching { renderer.close() } + runCatching { descriptor.close() } + } + } + + companion object { + const val DEFAULT_ASPECT_RATIO = 1f / 1.414f // A4 portrait + private const val MIN_RENDER_WIDTH_PX = 1 + private const val MAX_RENDER_WIDTH_PX = 4_096 + + /** + * Opens [file] for rendering, translating the platform failures into a [PdfViewerError]. + * + * [PdfRenderer] throws [SecurityException] for password protected documents and + * [java.io.IOException] for anything it cannot parse. + */ + fun open(file: File): Result = runCatching { + val descriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + try { + PdfDocument(descriptor, PdfRenderer(descriptor)) + } catch (error: Throwable) { + runCatching { descriptor.close() } + throw error + } + } + } +} diff --git a/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfRemoteLoader.kt b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfRemoteLoader.kt new file mode 100644 index 00000000000..e8d47e19eca --- /dev/null +++ b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfRemoteLoader.kt @@ -0,0 +1,47 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.pdfviewer + +import java.io.File + +/** + * Downloads a remote PDF asset to a local [File]. + * + * Implementations are provided by the host module so that the pdf-viewer module + * stays decoupled from any specific networking or authentication stack. + * In production this is backed by [DownloadCellFileUseCase] which goes through + * the authenticated kalium S3 client. + */ +fun interface PdfRemoteLoader { + /** + * Downloads the asset identified by [assetId] / [remotePath] into [outFile]. + * + * @param assetId UUID of the cell asset. + * @param remotePath S3 object key / remote path of the asset. + * @param conversationId Optional conversation the asset belongs to (used for DB metadata). + * @param assetSize Expected byte size of the asset (used for progress tracking). + * @param outFile Target file to write the downloaded bytes into. + */ + suspend fun load( + assetId: String, + remotePath: String, + conversationId: String?, + assetSize: Long, + outFile: File, + ): Result +} diff --git a/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfSourceResolver.kt b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfSourceResolver.kt new file mode 100644 index 00000000000..a940c105789 --- /dev/null +++ b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfSourceResolver.kt @@ -0,0 +1,98 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.pdfviewer + +import android.content.Context +import com.wire.android.di.ApplicationContext +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Turns the arguments of the PDF screen into a readable local file. + * + * [android.graphics.pdf.PdfRenderer] needs a seekable file descriptor, so a remote asset has + * to be fetched into the app cache first. Already downloaded files are reused, which keeps + * re-opening the same attachment instant. + * + * Remote downloads are delegated to [PdfRemoteLoader], which is backed in production by + * `DownloadCellFileUseCase` — the same authenticated kalium S3 client used for offline file + * downloads. This ensures authentication, retry logic, and download progress tracking are + * handled consistently with the rest of the app. + */ +class PdfSourceResolver @Inject constructor( + @ApplicationContext private val context: Context, + private val remoteLoader: PdfRemoteLoader, +) { + + suspend fun resolve( + localPath: String?, + assetId: String?, + remotePath: String?, + conversationId: String?, + assetSize: Long, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ): Result = withContext(dispatcher) { + val localFile = localPath?.let(::File) + when { + localFile != null && localFile.isReadableFile() -> Result.success(localFile) + assetId != null && remotePath != null -> + download(assetId, remotePath, conversationId, assetSize) + else -> Result.failure(PdfSourceException(PdfViewerError.FILE_NOT_FOUND)) + } + } + + private suspend fun download( + assetId: String, + remotePath: String, + conversationId: String?, + assetSize: Long, + ): Result { + val target = cacheFileFor(assetId) + if (target.isReadableFile()) return Result.success(target) + + val partial = File(target.parentFile, "${target.name}$PARTIAL_SUFFIX") + return runCatching { + partial.parentFile?.mkdirs() + remoteLoader.load(assetId, remotePath, conversationId, assetSize, partial).getOrThrow() + check(partial.renameTo(target)) { "Could not move the downloaded document into place" } + target + }.recoverCatching { cause -> + partial.delete() + throw PdfSourceException(PdfViewerError.DOWNLOAD_FAILED, cause) + } + } + + private fun cacheFileFor(assetId: String): File = + File(File(context.cacheDir, CACHE_DIR_NAME), "$assetId.pdf") + + private fun File.isReadableFile(): Boolean = isFile && canRead() && length() > 0 + + private companion object { + const val CACHE_DIR_NAME = "pdf-viewer" + const val PARTIAL_SUFFIX = ".part" + } +} + +/** Carries the user-facing [error] out of [PdfSourceResolver]. */ +class PdfSourceException( + val error: PdfViewerError, + cause: Throwable? = null, +) : Exception(cause) diff --git a/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfViewer.kt b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfViewer.kt new file mode 100644 index 00000000000..d6cb3be7c9d --- /dev/null +++ b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfViewer.kt @@ -0,0 +1,379 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.pdfviewer + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.common.progress.CenteredCircularProgressBarIndicator +import com.wire.android.ui.common.progress.WireCircularProgressIndicator +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.topappbar.NavigationIconType +import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.common.typography +import com.wire.android.ui.theme.WireTheme +import kotlin.math.abs +import kotlin.math.ceil + +private const val MIN_ZOOM = 1f +private const val MAX_ZOOM = 5f + +/** Beyond this the extra pixels are no longer visible but the bitmaps get very expensive. */ +private const val MAX_RENDER_SCALE = 3f +private const val DOUBLE_TAP_ZOOM = 2.5f + +/** + * Reusable full screen PDF viewer. Shows either a local file ([localPath]) or a remote asset + * identified by [assetId] and [remotePath], which is fetched into the app cache before rendering. + * + * Callers own navigation via [onNavigateBack]; the ViewModel is resolved from the shared + * pdf-viewer Metro graph so any module can host this screen. + */ +@Composable +fun PdfViewer( + localPath: String?, + assetId: String?, + remotePath: String?, + conversationId: String?, + assetSize: Long, + fileName: String?, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PdfViewerViewModel = pdfViewerViewModel(localPath, assetId, remotePath, conversationId, assetSize, fileName), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + PdfViewerContent( + state = state, + fileName = fileName, + onRetry = viewModel::retry, + onNavigateBack = onNavigateBack, + renderPage = viewModel::renderPage, + modifier = modifier, + ) +} + +@Composable +internal fun PdfViewerContent( + state: PdfViewerState, + fileName: String?, + onRetry: () -> Unit, + onNavigateBack: () -> Unit, + renderPage: suspend (pageIndex: Int, widthPx: Int) -> Bitmap?, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + + WireScaffold( + modifier = modifier, + topBar = { + WireCenterAlignedTopAppBar( + title = fileName ?: stringResource(R.string.pdf_viewer_title), + navigationIconType = NavigationIconType.Back(), + onNavigationPressed = onNavigateBack, + subtitleContent = { + if (state is PdfViewerState.Content) { + PageIndicator(listState = listState, pageCount = state.pageCount) + } + }, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .background(colorsScheme().background), + ) { + when (state) { + PdfViewerState.Loading -> CenteredCircularProgressBarIndicator() + is PdfViewerState.Failure -> PdfViewerFailure(error = state.error, onRetry = onRetry) + is PdfViewerState.Content -> PdfPages( + state = state, + listState = listState, + renderPage = renderPage, + ) + } + } + } +} + +@Composable +private fun PageIndicator(listState: LazyListState, pageCount: Int) { + val currentPage by remember(pageCount) { + derivedStateOf { (listState.firstVisibleItemIndex + 1).coerceIn(1, pageCount) } + } + Text( + text = stringResource(R.string.pdf_viewer_page_indicator, currentPage, pageCount), + style = typography().subline01, + color = colorsScheme().secondaryText, + ) +} + +@Composable +private fun PdfPages( + state: PdfViewerState.Content, + listState: LazyListState, + renderPage: suspend (pageIndex: Int, widthPx: Int) -> Bitmap?, +) { + var scale by remember { mutableFloatStateOf(MIN_ZOOM) } + var horizontalOffset by remember { mutableFloatStateOf(0f) } + var viewportWidthPx by remember { mutableIntStateOf(0) } + + fun applyTransform(zoomChange: Float, panX: Float) { + scale = (scale * zoomChange).coerceIn(MIN_ZOOM, MAX_ZOOM) + // Panning is only meaningful once the content is wider than the viewport. + val maxOffset = viewportWidthPx * (scale - MIN_ZOOM) / 2f + horizontalOffset = (horizontalOffset + panX).coerceIn(-maxOffset, maxOffset) + } + + Box( + modifier = Modifier + .fillMaxSize() + .clipToBounds() + .onSizeChanged { viewportWidthPx = it.width } + .zoomAndPan(currentScale = { scale }, onTransform = ::applyTransform) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + if (scale > MIN_ZOOM) { + scale = MIN_ZOOM + horizontalOffset = 0f + } else { + scale = DOUBLE_TAP_ZOOM + } + }, + ) + }, + ) { + // Pages are rasterised at the zoomed width so text stays sharp instead of being upscaled. + val renderScale = ceil(scale).coerceIn(MIN_ZOOM, MAX_RENDER_SCALE) + val pageWidthPx = (viewportWidthPx * renderScale).toInt() + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = horizontalOffset + transformOrigin = TransformOrigin(pivotFractionX = 0.5f, pivotFractionY = 0f) + }, + contentPadding = PaddingValues(dimensions().spacing8x), + verticalArrangement = Arrangement.spacedBy(dimensions().spacing8x), + ) { + items(count = state.pageCount, key = { it }) { pageIndex -> + PdfPage( + pageIndex = pageIndex, + fallbackAspectRatio = state.firstPageAspectRatio, + widthPx = pageWidthPx, + renderPage = renderPage, + ) + } + } + } +} + +@Composable +private fun PdfPage( + pageIndex: Int, + fallbackAspectRatio: Float, + widthPx: Int, + renderPage: suspend (pageIndex: Int, widthPx: Int) -> Bitmap?, +) { + // Deliberately keyed on the page only: while a sharper bitmap is rendered after a zoom the + // previous one stays on screen instead of flashing back to a spinner. + var bitmap by remember(pageIndex) { mutableStateOf(null) } + + LaunchedEffect(pageIndex, widthPx) { + if (widthPx > 0) { + renderPage(pageIndex, widthPx)?.let { bitmap = it } + } + } + + val rendered = bitmap + val aspectRatio = when { + rendered != null && rendered.height > 0 -> rendered.width.toFloat() / rendered.height + else -> fallbackAspectRatio + } + + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspectRatio) + .background(Color.White), + contentAlignment = Alignment.Center, + ) { + if (rendered != null) { + Image( + bitmap = rendered.asImageBitmap(), + contentDescription = stringResource( + R.string.pdf_viewer_page_content_description, + pageIndex + 1, + ), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } else { + WireCircularProgressIndicator( + progressColor = colorsScheme().secondaryText, + size = dimensions().spacing32x, + ) + } + } +} + +@Composable +private fun PdfViewerFailure(error: PdfViewerError, onRetry: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(dimensions().spacing24x), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(error.messageResId()), + style = typography().body01, + color = colorsScheme().onBackground, + textAlign = TextAlign.Center, + ) + if (error.isRetryable()) { + WirePrimaryButton( + onClick = onRetry, + text = stringResource(R.string.pdf_viewer_retry), + fillMaxWidth = false, + modifier = Modifier.padding(top = dimensions().spacing16x), + ) + } + } +} + +private fun PdfViewerError.messageResId(): Int = when (this) { + PdfViewerError.FILE_NOT_FOUND -> R.string.pdf_viewer_error_file_not_found + PdfViewerError.DOWNLOAD_FAILED -> R.string.pdf_viewer_error_download_failed + PdfViewerError.PASSWORD_PROTECTED -> R.string.pdf_viewer_error_password_protected + PdfViewerError.INVALID_DOCUMENT -> R.string.pdf_viewer_error_invalid_document +} + +private fun PdfViewerError.isRetryable(): Boolean = this == PdfViewerError.DOWNLOAD_FAILED + +/** + * Pinch to zoom plus horizontal panning, layered on top of the list's own vertical scrolling. + * + * Events are inspected on [PointerEventPass.Initial] so a two finger pinch is claimed before the + * list turns it into a scroll. Single finger gestures are only taken over when the content is + * zoomed in *and* the drag is mostly horizontal, which leaves vertical scrolling to the list. + */ +private fun Modifier.zoomAndPan( + currentScale: () -> Float, + onTransform: (zoomChange: Float, panX: Float) -> Unit, +): Modifier = pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + do { + val event = awaitPointerEvent(PointerEventPass.Initial) + val pressedPointers = event.changes.count { it.pressed } + val pan = event.calculatePan() + val handled = when { + pressedPointers > 1 -> { + onTransform(event.calculateZoom(), pan.x) + true + } + + currentScale() > MIN_ZOOM && abs(pan.x) > abs(pan.y) -> { + onTransform(1f, pan.x) + true + } + + else -> false + } + if (handled) { + event.changes.forEach { it.consume() } + } + } while (event.changes.any { it.pressed }) + } +} + +@MultipleThemePreviews +@Composable +fun PreviewPdfViewerLoading() = WireTheme { + PdfViewerContent( + state = PdfViewerState.Loading, + fileName = "Quarterly report.pdf", + onRetry = {}, + onNavigateBack = {}, + renderPage = { _, _ -> null }, + ) +} + +@MultipleThemePreviews +@Composable +fun PreviewPdfViewerFailure() = WireTheme { + PdfViewerContent( + state = PdfViewerState.Failure(PdfViewerError.DOWNLOAD_FAILED), + fileName = "Quarterly report.pdf", + onRetry = {}, + onNavigateBack = {}, + renderPage = { _, _ -> null }, + ) +} diff --git a/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfViewerState.kt b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfViewerState.kt new file mode 100644 index 00000000000..a0c992b0870 --- /dev/null +++ b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfViewerState.kt @@ -0,0 +1,48 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.pdfviewer + +/** Everything the PDF screen needs to draw itself. */ +sealed interface PdfViewerState { + + /** The document is being fetched and/or parsed. */ + data object Loading : PdfViewerState + + /** The document is ready; [pageCount] pages can be requested from the ViewModel. */ + data class Content( + val pageCount: Int, + val firstPageAspectRatio: Float, + ) : PdfViewerState + + /** The document could not be shown. */ + data class Failure(val error: PdfViewerError) : PdfViewerState +} + +enum class PdfViewerError { + /** No local path and no content URL were given, or the local file is gone. */ + FILE_NOT_FOUND, + + /** The content URL could not be fetched. */ + DOWNLOAD_FAILED, + + /** The document is encrypted and needs a password, which is not supported. */ + PASSWORD_PROTECTED, + + /** The bytes are not a PDF we can parse. */ + INVALID_DOCUMENT, +} diff --git a/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfViewerViewModel.kt b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfViewerViewModel.kt new file mode 100644 index 00000000000..276eb366938 --- /dev/null +++ b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfViewerViewModel.kt @@ -0,0 +1,164 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.pdfviewer + +import android.graphics.Bitmap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.di.metro.WireAssistedViewModelBinding +import com.wire.android.util.dispatchers.DispatcherProvider +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.IOException + +/** + * Opens a single PDF from either a local file ([localPath]) or a remote asset identified by + * [assetId] and [remotePath], and renders its pages on demand. + * + * Arguments are passed through the assisted [Factory] instead of a navigation destination so the + * screen can be hosted from any module. + */ +@WireAssistedViewModelBinding(PdfViewerManualViewModelFactoryGroup::class) +class PdfViewerViewModel @AssistedInject constructor( + private val sourceResolver: PdfSourceResolver, + private val dispatchers: DispatcherProvider, + @Assisted val localPath: String?, + @Assisted val assetId: String?, + @Assisted val remotePath: String?, + @Assisted val conversationId: String?, + @Assisted val assetSize: Long, + @Assisted val fileName: String?, +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create( + localPath: String?, + assetId: String?, + remotePath: String?, + conversationId: String?, + assetSize: Long, + fileName: String?, + ): PdfViewerViewModel + } + + private val _state = MutableStateFlow(PdfViewerState.Loading) + val state: StateFlow = _state.asStateFlow() + + // Written from the main thread by load()/onCleared(), read from IO by renderPage(). + @Volatile + private var document: PdfDocument? = null + private var loadJob: Job? = null + + /** + * Releasing the document has to outlive [viewModelScope]: [PdfDocument.close] waits for an + * in-flight render before it frees the native handle, and that wait must neither block the + * main thread nor be cancelled halfway through. + */ + private val releaseScope = CoroutineScope(SupervisorJob() + dispatchers.io()) + + /** Keeps recently rendered pages around so scrolling back does not re-rasterise them. */ + private val pageCache = PageBitmapCache(PageBitmapCache.defaultMaxBytes()) + + init { + load() + } + + fun retry() { + if (_state.value is PdfViewerState.Loading) return + load() + } + + /** + * Renders [pageIndex] at [widthPx] and caches the result. Returns `null` when the document is + * not open (yet) or the page could not be rendered. + */ + suspend fun renderPage(pageIndex: Int, widthPx: Int): Bitmap? { + if (widthPx <= 0) return null + val current = document ?: return null + val key = "$pageIndex@$widthPx" + pageCache.get(key)?.let { return it } + + val rendered = withContext(dispatchers.io()) { + runCatching { current.renderPage(pageIndex, widthPx) }.getOrNull() + } ?: return null + + pageCache.put(key, rendered) + return rendered + } + + private fun load() { + loadJob?.cancel() + closeDocument() + _state.value = PdfViewerState.Loading + loadJob = viewModelScope.launch { + val file = sourceResolver.resolve(localPath, assetId, remotePath, conversationId, assetSize, dispatchers.io()) + .getOrElse { cause -> + _state.value = PdfViewerState.Failure(cause.toViewerError()) + return@launch + } + + val opened = withContext(dispatchers.io()) { PdfDocument.open(file) } + .getOrElse { cause -> + _state.value = PdfViewerState.Failure(cause.toViewerError()) + return@launch + } + + if (opened.pageCount == 0) { + opened.close() + _state.value = PdfViewerState.Failure(PdfViewerError.INVALID_DOCUMENT) + return@launch + } + + document = opened + _state.value = PdfViewerState.Content( + pageCount = opened.pageCount, + firstPageAspectRatio = withContext(dispatchers.io()) { opened.aspectRatio(0) }, + ) + } + } + + private fun closeDocument() { + pageCache.clear() + val open = document ?: return + document = null + releaseScope.launch { open.close() } + } + + override fun onCleared() { + super.onCleared() + loadJob?.cancel() + closeDocument() + } +} + +private fun Throwable.toViewerError(): PdfViewerError = when (this) { + is PdfSourceException -> error + is SecurityException -> PdfViewerError.PASSWORD_PROTECTED + is IOException -> PdfViewerError.INVALID_DOCUMENT + else -> PdfViewerError.INVALID_DOCUMENT +} diff --git a/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfViewerViewModelGraph.kt b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfViewerViewModelGraph.kt new file mode 100644 index 00000000000..d184310ccc1 --- /dev/null +++ b/core/pdf-viewer/src/main/kotlin/com/wire/android/pdfviewer/PdfViewerViewModelGraph.kt @@ -0,0 +1,42 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +@file:Suppress("MatchingDeclarationName") + +package com.wire.android.pdfviewer + +import androidx.compose.runtime.Composable +import com.wire.android.di.metro.WireAssistedViewModelFactoryGroup +import com.wire.android.di.metro.wireAssistedMetroViewModel + +@WireAssistedViewModelFactoryGroup +object PdfViewerManualViewModelFactoryGroup + +@Composable +fun pdfViewerViewModel( + localPath: String?, + assetId: String?, + remotePath: String?, + conversationId: String?, + assetSize: Long, + fileName: String?, +): PdfViewerViewModel = + wireAssistedMetroViewModel( + instanceKey = "pdf_viewer_${localPath ?: assetId}" + ) { + pdfViewerViewModel(localPath, assetId, remotePath, conversationId, assetSize, fileName) + } diff --git a/core/pdf-viewer/src/main/res/values/strings.xml b/core/pdf-viewer/src/main/res/values/strings.xml new file mode 100644 index 00000000000..b0c8f4a0ec8 --- /dev/null +++ b/core/pdf-viewer/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + Document + Page %1$d of %2$d + Page %1$d + This document is no longer available. + This document could not be downloaded. + This document is password protected and cannot be opened here. + This document could not be opened. + Try again + \ No newline at end of file diff --git a/core/pdf-viewer/src/test/kotlin/com/wire/android/pdfviewer/PageBitmapCacheTest.kt b/core/pdf-viewer/src/test/kotlin/com/wire/android/pdfviewer/PageBitmapCacheTest.kt new file mode 100644 index 00000000000..d59877c32ed --- /dev/null +++ b/core/pdf-viewer/src/test/kotlin/com/wire/android/pdfviewer/PageBitmapCacheTest.kt @@ -0,0 +1,108 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.pdfviewer + +import android.graphics.Bitmap +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test + +internal class PageBitmapCacheTest { + + @Test + fun `given a cached page, when reading it back, then the same bitmap is returned`() { + val cache = PageBitmapCache(maxBytes = 1_000) + val page = bitmapOf(100) + + cache.put("0@100", page) + + assertSame(page, cache.get("0@100")) + assertNull(cache.get("1@100")) + } + + @Test + fun `given the budget is exceeded, when adding a page, then the least recently used one is dropped`() { + val cache = PageBitmapCache(maxBytes = 250) + cache.put("0", bitmapOf(100)) + cache.put("1", bitmapOf(100)) + + cache.put("2", bitmapOf(100)) + + assertNull(cache.get("0")) + assertNotNull(cache.get("1")) + assertNotNull(cache.get("2")) + } + + @Test + fun `given a page was read recently, when the budget is exceeded, then the other one is dropped first`() { + val cache = PageBitmapCache(maxBytes = 250) + cache.put("0", bitmapOf(100)) + cache.put("1", bitmapOf(100)) + + cache.get("0") + cache.put("2", bitmapOf(100)) + + assertNotNull(cache.get("0")) + assertNull(cache.get("1")) + } + + @Test + fun `given a page larger than the whole budget, when adding it, then it is still served`() { + val cache = PageBitmapCache(maxBytes = 10) + val huge = bitmapOf(5_000) + + cache.put("0", huge) + + assertSame(huge, cache.get("0")) + assertEquals(1, cache.size()) + } + + @Test + fun `given a replaced page, when accounting for the budget, then the old size is released`() { + val cache = PageBitmapCache(maxBytes = 250) + cache.put("0", bitmapOf(200)) + cache.put("0", bitmapOf(100)) + + cache.put("1", bitmapOf(100)) + + assertNotNull(cache.get("0")) + assertNotNull(cache.get("1")) + assertEquals(2, cache.size()) + } + + @Test + fun `given cached pages, when clearing, then nothing is served and the budget is free again`() { + val cache = PageBitmapCache(maxBytes = 250) + cache.put("0", bitmapOf(200)) + + cache.clear() + + assertNull(cache.get("0")) + assertEquals(0, cache.size()) + cache.put("1", bitmapOf(200)) + assertNotNull(cache.get("1")) + } + + private fun bitmapOf(bytes: Int): Bitmap = mockk(relaxed = true).also { + every { it.byteCount } returns bytes + } +} diff --git a/core/pdf-viewer/src/test/kotlin/com/wire/android/pdfviewer/PdfSourceResolverTest.kt b/core/pdf-viewer/src/test/kotlin/com/wire/android/pdfviewer/PdfSourceResolverTest.kt new file mode 100644 index 00000000000..8a9fe36188b --- /dev/null +++ b/core/pdf-viewer/src/test/kotlin/com/wire/android/pdfviewer/PdfSourceResolverTest.kt @@ -0,0 +1,139 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.pdfviewer + +import android.content.Context +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +internal class PdfSourceResolverTest { + + @TempDir + lateinit var tempDir: File + + @Test + fun givenAReadableLocalFile_whenResolving_thenThatFileIsReturnedWithoutDownloading() = runTest { + val document = File(tempDir, "document.pdf").apply { writeText("%PDF-1.4") } + val loader = mockk(relaxed = true) + val resolver = resolver(loader) + + val result = resolver.resolve(document.absolutePath, assetId = null, remotePath = null, conversationId = null, assetSize = 0L, dispatcher = Dispatchers.Default) + + assertEquals(document, result.getOrNull()) + coVerify(exactly = 0) { loader.load(any(), any(), any(), any(), any()) } + } + + @Test + fun givenNoLocalFileAndNoAssetInfo_whenResolving_thenItFailsAsNotFound() = runTest { + val resolver = resolver() + + val result = resolver.resolve(localPath = null, assetId = null, remotePath = null, conversationId = null, assetSize = 0L, dispatcher = Dispatchers.Default) + + assertEquals(PdfViewerError.FILE_NOT_FOUND, result.viewerError()) + } + + @Test + fun givenAnEmptyLocalFileAndNoAssetInfo_whenResolving_thenItFailsAsNotFound() = runTest { + val empty = File(tempDir, "empty.pdf").apply { createNewFile() } + val resolver = resolver() + + val result = resolver.resolve(empty.absolutePath, assetId = null, remotePath = null, conversationId = null, assetSize = 0L, dispatcher = Dispatchers.Default) + + assertEquals(PdfViewerError.FILE_NOT_FOUND, result.viewerError()) + } + + @Test + fun givenAssetIdAndRemotePath_whenLoaderFails_thenItFailsAsDownloadFailed() = runTest { + val loader = mockk { + coEvery { load(any(), any(), any(), any(), any()) } returns Result.failure(Exception("network error")) + } + val resolver = resolver(loader) + + val result = resolver.resolve(localPath = null, assetId = "asset-123", remotePath = "/cells/path/doc.pdf", conversationId = null, assetSize = 1024L, dispatcher = Dispatchers.Default) + + assertEquals(PdfViewerError.DOWNLOAD_FAILED, result.viewerError()) + } + + @Test + fun givenAssetIdButNoRemotePath_whenResolving_thenItFailsAsNotFound() = runTest { + val resolver = resolver() + + val result = resolver.resolve(localPath = null, assetId = "asset-123", remotePath = null, conversationId = null, assetSize = 0L, dispatcher = Dispatchers.Default) + + assertEquals(PdfViewerError.FILE_NOT_FOUND, result.viewerError()) + } + + @Test + fun givenAMissingLocalPathAndValidAssetInfo_whenResolving_thenTheDownloadPathIsUsed() = runTest { + val loader = mockk { + coEvery { load(any(), any(), any(), any(), any()) } returns Result.failure(Exception("network error")) + } + val resolver = resolver(loader) + + val result = resolver.resolve( + localPath = File(tempDir, "gone.pdf").absolutePath, + assetId = "asset-123", + remotePath = "/cells/path/doc.pdf", + conversationId = null, + assetSize = 0L, + dispatcher = Dispatchers.Default, + ) + + assertTrue(result.isFailure) + assertEquals(PdfViewerError.DOWNLOAD_FAILED, result.viewerError()) + } + + @Test + fun givenSuccessfulDownload_whenCachedFileExists_thenLoaderIsNotCalledAgain() = runTest { + val loader = mockk { + coEvery { load(any(), any(), any(), any(), any()) } coAnswers { + val outFile = arg(4) + outFile.writeText("%PDF-1.4") + Result.success(Unit) + } + } + val resolver = resolver(loader) + val args = arrayOf(null, "asset-abc", "/cells/path/doc.pdf", null, 0L, Dispatchers.Default) + + // First call — triggers download + resolver.resolve(null, "asset-abc", "/cells/path/doc.pdf", null, 0L, Dispatchers.Default) + // Second call — should use cache + val result = resolver.resolve(null, "asset-abc", "/cells/path/doc.pdf", null, 0L, Dispatchers.Default) + + assertTrue(result.isSuccess) + coVerify(exactly = 1) { loader.load(any(), any(), any(), any(), any()) } + } + + private fun resolver(loader: PdfRemoteLoader = mockk(relaxed = true)): PdfSourceResolver { + val context = mockk() + every { context.cacheDir } returns File(tempDir, "cache") + return PdfSourceResolver(context, loader) + } + + private fun Result.viewerError(): PdfViewerError? = + (exceptionOrNull() as? PdfSourceException)?.error +} diff --git a/core/pdf-viewer/src/test/kotlin/com/wire/android/pdfviewer/PdfViewerViewModelTest.kt b/core/pdf-viewer/src/test/kotlin/com/wire/android/pdfviewer/PdfViewerViewModelTest.kt new file mode 100644 index 00000000000..70bffce837b --- /dev/null +++ b/core/pdf-viewer/src/test/kotlin/com/wire/android/pdfviewer/PdfViewerViewModelTest.kt @@ -0,0 +1,295 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.pdfviewer + +import android.graphics.Bitmap +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.TestDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.io.File +import java.io.IOException + +@ExtendWith(CoroutineTestExtension::class) +internal class PdfViewerViewModelTest { + + @AfterEach + fun tearDown() { + unmockkObject(PdfDocument.Companion) + } + + @Test + fun `given the document opens, when the view model is created, then the state exposes the page count`() = runTest { + val (_, viewModel) = Arrangement() + .withPageCount(7) + .withFirstPageAspectRatio(0.5f) + .arrange() + + assertEquals(PdfViewerState.Content(pageCount = 7, firstPageAspectRatio = 0.5f), viewModel.state.value) + } + + @Test + fun `given the source cannot be resolved, when the view model is created, then that error is exposed`() = runTest { + val (_, viewModel) = Arrangement() + .withResolveFailure(PdfViewerError.FILE_NOT_FOUND) + .arrange() + + assertEquals(PdfViewerState.Failure(PdfViewerError.FILE_NOT_FOUND), viewModel.state.value) + } + + @Test + fun `given the download fails, when the view model is created, then the failure is reported as a download error`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withResolveFailure(PdfViewerError.DOWNLOAD_FAILED) + .arrange() + + assertEquals(PdfViewerState.Failure(PdfViewerError.DOWNLOAD_FAILED), viewModel.state.value) + verify(exactly = 0) { PdfDocument.open(any()) } + coVerify(exactly = 1) { arrangement.sourceResolver.resolve(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `given the document is password protected, when opening it, then the state reports it as protected`() = runTest { + val (_, viewModel) = Arrangement() + .withOpenFailure(SecurityException("password required")) + .arrange() + + assertEquals(PdfViewerState.Failure(PdfViewerError.PASSWORD_PROTECTED), viewModel.state.value) + } + + @Test + fun `given the bytes cannot be parsed, when opening the document, then the state reports an invalid document`() = runTest { + val (_, viewModel) = Arrangement() + .withOpenFailure(IOException("not a pdf")) + .arrange() + + assertEquals(PdfViewerState.Failure(PdfViewerError.INVALID_DOCUMENT), viewModel.state.value) + } + + @Test + fun `given a document without pages, when opening it, then it is closed and reported as invalid`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withPageCount(0) + .arrange() + + assertEquals(PdfViewerState.Failure(PdfViewerError.INVALID_DOCUMENT), viewModel.state.value) + verify(exactly = 1) { arrangement.document.close() } + } + + @Test + fun `given an open document, when rendering the same page twice, then the second call comes from the cache`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange() + + val first = viewModel.renderPage(pageIndex = 0, widthPx = 100) + val second = viewModel.renderPage(pageIndex = 0, widthPx = 100) + + assertSame(arrangement.bitmap, first) + assertSame(first, second) + verify(exactly = 1) { arrangement.document.renderPage(0, 100) } + } + + @Test + fun `given an open document, when the requested width changes, then the page is rendered again`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange() + + viewModel.renderPage(pageIndex = 0, widthPx = 100) + viewModel.renderPage(pageIndex = 0, widthPx = 200) + + verify(exactly = 1) { arrangement.document.renderPage(0, 100) } + verify(exactly = 1) { arrangement.document.renderPage(0, 200) } + } + + @Test + fun `given a non positive width, when rendering, then nothing is rendered`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange() + + assertNull(viewModel.renderPage(pageIndex = 0, widthPx = 0)) + assertNull(viewModel.renderPage(pageIndex = 0, widthPx = -10)) + + verify(exactly = 0) { arrangement.document.renderPage(any(), any()) } + } + + @Test + fun `given the document failed to open, when rendering, then no bitmap is returned`() = runTest { + val (_, viewModel) = Arrangement() + .withOpenFailure(IOException("not a pdf")) + .arrange() + + assertNull(viewModel.renderPage(pageIndex = 0, widthPx = 100)) + } + + @Test + fun `given rendering a page fails, when rendering it again, then nothing was cached`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withRenderedPage(null) + .arrange() + + assertNull(viewModel.renderPage(pageIndex = 0, widthPx = 100)) + assertNull(viewModel.renderPage(pageIndex = 0, widthPx = 100)) + + verify(exactly = 2) { arrangement.document.renderPage(0, 100) } + } + + @Test + fun `given rendering throws, when rendering, then the failure is swallowed and no bitmap is returned`() = runTest { + val (_, viewModel) = Arrangement() + .withRenderFailure(OutOfMemoryError("bitmap too large")) + .arrange() + + assertNull(viewModel.renderPage(pageIndex = 0, widthPx = 100)) + } + + @Test + fun `given a loaded document, when reloading, then the previous one is closed and a new one is opened`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange() + + viewModel.retry() + + verify(exactly = 1) { arrangement.document.close() } + verify(exactly = 2) { PdfDocument.open(any()) } + } + + @Test + fun `given the document was closed by a reload that then failed, when rendering, then nothing is returned`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange() + + arrangement.withOpenFailure(IOException("gone")) + viewModel.retry() + + // The old document is detached before it is closed, so no render can reach a closed renderer. + assertNull(viewModel.renderPage(pageIndex = 0, widthPx = 100)) + verify(exactly = 0) { arrangement.document.renderPage(any(), any()) } + } + + @Test + fun `given a failed load, when retrying, then the document is opened again`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withResolveFailure(PdfViewerError.DOWNLOAD_FAILED) + .arrange() + + arrangement.withResolveSuccess().withPageCount(3) + viewModel.retry() + + assertEquals(PdfViewerState.Content(pageCount = 3, firstPageAspectRatio = DEFAULT_ASPECT_RATIO), viewModel.state.value) + coVerify(exactly = 2) { arrangement.sourceResolver.resolve(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `given a load already in flight, when retrying, then the second load is ignored`() = runTest { + val gate = CompletableDeferred() + val (arrangement, viewModel) = Arrangement() + .withResolveGatedBy(gate) + .arrange() + + assertEquals(PdfViewerState.Loading, viewModel.state.value) + viewModel.retry() + coVerify(exactly = 1) { arrangement.sourceResolver.resolve(any(), any(), any(), any(), any(), any()) } + + gate.complete(Unit) + + assertEquals( + PdfViewerState.Content(pageCount = DEFAULT_PAGE_COUNT, firstPageAspectRatio = DEFAULT_ASPECT_RATIO), + viewModel.state.value, + ) + coVerify(exactly = 1) { arrangement.sourceResolver.resolve(any(), any(), any(), any(), any(), any()) } + } + + private class Arrangement { + + val sourceResolver: PdfSourceResolver = mockk() + val document: PdfDocument = mockk(relaxed = true) + val bitmap: Bitmap = mockk(relaxed = true).also { every { it.byteCount } returns BITMAP_BYTES } + + private val file = File("document.pdf") + + init { + mockkObject(PdfDocument.Companion) + every { PdfDocument.open(any()) } returns Result.success(document) + every { document.pageCount } returns DEFAULT_PAGE_COUNT + every { document.aspectRatio(any()) } returns DEFAULT_ASPECT_RATIO + every { document.renderPage(any(), any()) } returns bitmap + withResolveSuccess() + } + + fun withResolveSuccess() = apply { + coEvery { sourceResolver.resolve(any(), any(), any(), any(), any(), any()) } returns Result.success(file) + } + + fun withResolveFailure(error: PdfViewerError) = apply { + coEvery { sourceResolver.resolve(any(), any(), any(), any(), any(), any()) } returns + Result.failure(PdfSourceException(error)) + } + + fun withResolveGatedBy(gate: CompletableDeferred) = apply { + coEvery { sourceResolver.resolve(any(), any(), any(), any(), any(), any()) } coAnswers { + gate.await() + Result.success(file) + } + } + + fun withOpenFailure(cause: Throwable) = apply { + every { PdfDocument.open(any()) } returns Result.failure(cause) + } + + fun withPageCount(count: Int) = apply { + every { document.pageCount } returns count + } + + fun withFirstPageAspectRatio(ratio: Float) = apply { + every { document.aspectRatio(0) } returns ratio + } + + fun withRenderedPage(bitmap: Bitmap?) = apply { + every { document.renderPage(any(), any()) } returns bitmap + } + + fun withRenderFailure(cause: Throwable) = apply { + every { document.renderPage(any(), any()) } throws cause + } + + fun arrange(): Pair = this to PdfViewerViewModel( + sourceResolver = sourceResolver, + dispatchers = TestDispatcherProvider(), + localPath = "local/document.pdf", + assetId = null, + remotePath = null, + conversationId = null, + assetSize = 0L, + fileName = "document.pdf", + ) + } + + private companion object { + const val DEFAULT_PAGE_COUNT = 3 + const val DEFAULT_ASPECT_RATIO = 0.7f + const val BITMAP_BYTES = 1024 + } +} diff --git a/crowdin.yml b/crowdin.yml index 60b5730b396..f0da0b601b9 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -15,6 +15,10 @@ files: [ "source": "/core/media-player/src/main/res/values/strings.xml", "translation": "/core/media-player/src/main/res/values-%two_letters_code%/%original_file_name%" }, + { + "source": "/core/pdf-viewer/src/main/res/values/strings.xml", + "translation": "/core/pdf-viewer/src/main/res/values-%two_letters_code%/%original_file_name%" + }, { "source": "/core/search/src/main/res/values/strings.xml", "translation": "/core/search/src/main/res/values-%two_letters_code%/%original_file_name%" diff --git a/features/cells/build.gradle.kts b/features/cells/build.gradle.kts index 703ff8e6b92..ac6b9de0768 100644 --- a/features/cells/build.gradle.kts +++ b/features/cells/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(project(":core:navigation")) implementation(project(":core:ui-common")) implementation(project(":core:media-player")) + implementation(project(":core:pdf-viewer")) implementation(libs.compose.activity) implementation(libs.androidx.core) implementation(libs.androidx.appcompat) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3.kt b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3.kt index e8a91a51dc7..bce61de2226 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3.kt @@ -201,6 +201,21 @@ data class AudioPlayerRoute( companion object { const val ROUTE_ID = "cells/audio_player_screen" } } +@Serializable +data class PdfViewerRoute( + override val sessionId: WireSessionId, + val localPath: String? = null, + val assetId: String? = null, + val remotePath: String? = null, + val conversationId: String? = null, + val assetSize: Long = 0L, + val fileName: String? = null, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/pdf_viewer_screen" } +} + @Serializable enum class CellsSearchType { SHARED_DRIVE, DRIVE } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Entries.kt b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Entries.kt index 75fede95524..77727952de4 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Entries.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Entries.kt @@ -30,7 +30,7 @@ val PublicLinkExpirationNavigation3ResultType: ) object CellsNavigation3Contribution { - const val ROUTE_REGISTRATION_COUNT: Int = 16 + const val ROUTE_REGISTRATION_COUNT: Int = 17 val resultTypes: List> = listOf( @@ -94,6 +94,9 @@ internal fun cellsNavigation3Entries( wireEntry(presentation = WireEntryPresentation.PopUp) { CellsNavigation3RouteScreen(it, runtime, onExitCells) } + wireEntry(presentation = WireEntryPresentation.PopUp) { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } wireEntry(presentation = WireEntryPresentation.PopUp) { CellsNavigation3RouteScreen(it, runtime, onExitCells) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt index fcf99164090..6b96017b09c 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt @@ -54,6 +54,7 @@ import com.wire.android.feature.cells.ui.tags.AddRemoveTagsRouteScreen import com.wire.android.feature.cells.ui.versionHistoryViewModel import com.wire.android.feature.cells.ui.versioning.VersionHistoryRouteScreen import com.wire.android.mediaplayer.VideoPlayer +import com.wire.android.pdfviewer.PdfViewer import com.wire.android.navigation.navigation3.WireNavigation3ResultType import com.wire.android.navigation.navigation3.WireNavigation3Runtime import androidx.compose.ui.platform.LocalContext @@ -236,6 +237,15 @@ internal fun CellsNavigation3RouteScreen( fileName = route.fileName, onNavigateBack = navigateBack, ) + is PdfViewerRoute -> PdfViewer( + localPath = route.localPath, + assetId = route.assetId, + remotePath = route.remotePath, + conversationId = route.conversationId, + assetSize = route.assetSize, + fileName = route.fileName, + onNavigateBack = navigateBack, + ) is AudioPlayerRoute -> CellAudioPlayerRouteScreen( onNavigateBack = navigateBack, viewModel = cellAudioPlayerViewModel( @@ -375,6 +385,22 @@ private class Navigation3CellsFilesNavigation( ) ) } + + override fun pdf(file: CellNodeUi.File) { + runtime.navigator.navigate( + WireNavigationCommand( + PdfViewerRoute( + sessionId = sessionId, + localPath = file.localPath, + assetId = file.uuid, + remotePath = file.remotePath, + conversationId = file.conversationId, + assetSize = file.size ?: 0L, + fileName = file.name, + ) + ) + ) + } } private fun completeBooleanResult(runtime: WireNavigation3Runtime, value: Boolean) { diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesNavigationActions.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesNavigationActions.kt index 80c38c3aaeb..ed46f497095 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesNavigationActions.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesNavigationActions.kt @@ -27,4 +27,5 @@ data class AllFilesNavigationActions( val showImageViewer: (CellNodeUi.File) -> Unit, val showVideoPlayer: (CellNodeUi.File) -> Unit, val showAudioPlayer: (CellNodeUi.File) -> Unit, + val showPdfViewer: (CellNodeUi.File) -> Unit, ) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt index 2c029e9d8ea..44615c66c49 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt @@ -106,6 +106,7 @@ fun AllFilesScreen( showImageViewer = navigationActions.showImageViewer, showVideoViewer = navigationActions.showVideoPlayer, showAudioPlayer = navigationActions.showAudioPlayer, + showPdfViewer = navigationActions.showPdfViewer, fileReadyFlow = viewModel.fileReadyFlow, showViewerOnlyIcon = viewModel.drivePermissionsEnabled ) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt index 58d79d34ebd..855eef174e6 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt @@ -105,6 +105,7 @@ internal fun CellScreenContent( showImageViewer: (CellNodeUi.File) -> Unit = {}, showVideoViewer: (CellNodeUi.File) -> Unit = {}, showAudioPlayer: (CellNodeUi.File) -> Unit = {}, + showPdfViewer: (CellNodeUi.File) -> Unit = {}, fileReadyFlow: Flow? = emptyFlow(), ) { @@ -266,6 +267,7 @@ internal fun CellScreenContent( is OpenImageViewer -> showImageViewer(action.file) is OpenVideoViewer -> showVideoViewer(action.file) is OpenAudioPlayer -> showAudioPlayer(action.file) + is OpenPdfViewer -> showPdfViewer(action.file) } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index 27c9131d313..ff77fcd6fcb 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -434,22 +434,9 @@ class CellViewModel @AssistedInject constructor( @Suppress("ReturnCount") private fun openFileContentUrl(file: CellNodeUi.File) { - when (file.assetType) { - AttachmentFileType.IMAGE -> { - if (file.shouldOpenInAppImageViewer()) { - sendAction(OpenImageViewer(file)) - return - } - } - AttachmentFileType.VIDEO -> { - sendAction(OpenVideoViewer(file)) - return - } - AttachmentFileType.AUDIO -> { - sendAction(OpenAudioPlayer(file)) - return - } - else -> Unit + inAppViewerAction(file)?.let { + sendAction(it) + return } file.contentUrl?.let { url -> fileHelper.openAssetUrlWithExternalApp( @@ -464,22 +451,9 @@ class CellViewModel @AssistedInject constructor( @Suppress("ReturnCount") private fun openLocalFile(file: CellNodeUi.File) { - when (file.assetType) { - AttachmentFileType.IMAGE -> { - if (file.shouldOpenInAppImageViewer()) { - sendAction(OpenImageViewer(file)) - return - } - } - AttachmentFileType.VIDEO -> { - sendAction(OpenVideoViewer(file)) - return - } - AttachmentFileType.AUDIO -> { - sendAction(OpenAudioPlayer(file)) - return - } - else -> Unit + inAppViewerAction(file)?.let { + sendAction(it) + return } file.localPath?.let { path -> fileHelper.openAssetFileWithExternalApp( @@ -493,6 +467,18 @@ class CellViewModel @AssistedInject constructor( } } + /** + * The in-app viewer that can show [file], or null when the file has to be handed over to + * another app. + */ + private fun inAppViewerAction(file: CellNodeUi.File): CellViewAction? = when (file.assetType) { + AttachmentFileType.IMAGE -> OpenImageViewer(file).takeIf { file.shouldOpenInAppImageViewer() } + AttachmentFileType.VIDEO -> OpenVideoViewer(file) + AttachmentFileType.AUDIO -> OpenAudioPlayer(file) + AttachmentFileType.PDF -> OpenPdfViewer(file) + else -> null + } + private fun CellNodeUi.File.shouldOpenInAppImageViewer(): Boolean = inAppImageViewerEnabled && assetType == AttachmentFileType.IMAGE @@ -733,6 +719,7 @@ internal data object ShowOfflineFileSaved : CellViewAction internal data class OpenImageViewer(val file: CellNodeUi.File) : CellViewAction internal data class OpenVideoViewer(val file: CellNodeUi.File) : CellViewAction internal data class OpenAudioPlayer(val file: CellNodeUi.File) : CellViewAction +internal data class OpenPdfViewer(val file: CellNodeUi.File) : CellViewAction internal enum class CellError(val message: Int) { NO_APP_FOUND(R.string.no_app_found), diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsFilesNavigation.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsFilesNavigation.kt index 78532835241..5eb2560c9bb 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsFilesNavigation.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsFilesNavigation.kt @@ -26,6 +26,7 @@ internal interface CellsFilesNavigation { fun image(file: CellNodeUi.File) fun video(file: CellNodeUi.File) fun audio(file: CellNodeUi.File) + fun pdf(file: CellNodeUi.File) } @Suppress("TooManyFunctions") @@ -44,4 +45,5 @@ internal object NoOpCellsFilesNavigation : CellsFilesNavigation { override fun image(file: CellNodeUi.File) = Unit override fun video(file: CellNodeUi.File) = Unit override fun audio(file: CellNodeUi.File) = Unit + override fun pdf(file: CellNodeUi.File) = Unit } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt index cebffa044db..a0766bb19f4 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt @@ -327,6 +327,7 @@ internal fun ConversationFilesScreenContent( showImageViewer = navigation::image, showVideoViewer = navigation::video, showAudioPlayer = navigation::audio, + showPdfViewer = navigation::pdf, retryEditNodeError = { retryEditNodeError(it) }, isRefreshing = isRefreshing, onRefresh = onRefresh, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt index 04c2d447ba1..355489a022c 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt @@ -211,6 +211,7 @@ internal fun SearchRouteScreen( showImageViewer = navigation::image, showVideoViewer = navigation::video, showAudioPlayer = navigation::audio, + showPdfViewer = navigation::pdf, retryEditNodeError = { cellViewModel.editNode(it) }, isRefreshing = remember { mutableStateOf(false) }, onRefresh = { }, diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/navigation/CellsNavigation3Test.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/navigation/CellsNavigation3Test.kt index 575050c32d0..2e5696edf49 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/navigation/CellsNavigation3Test.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/navigation/CellsNavigation3Test.kt @@ -41,6 +41,7 @@ class CellsNavigation3Test { CellImageViewerRoute(sessionId), VideoPlayerRoute(sessionId), AudioPlayerRoute(sessionId), + PdfViewerRoute(sessionId), SearchRoute(sessionId), ) diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt index 874d0cee107..4c52df18ff0 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt @@ -261,13 +261,30 @@ class CellViewModelTest { .withLoadSuccess() .arrange() - val nonImageFile = testFiles[0].copy(mimeType = "application/pdf").toUiModel() + val nonImageFile = testFiles[0].copy(mimeType = "application/zip").toUiModel() viewModel.sendIntent(CellViewIntent.OnItemClick(nonImageFile)) coVerify(exactly = 1) { arrangement.fileHelper.openAssetFileWithExternalApp(any(), any(), any(), any()) } } + @Test + fun `given view model when pdf file clicked and local file is present then in-app pdf viewer is opened`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withLoadSuccess() + .arrange() + + val pdfFile = testFiles[0].copy(mimeType = "application/pdf").toUiModel() + + viewModel.actions.test { + viewModel.sendIntent(CellViewIntent.OnItemClick(pdfFile)) + + val action = awaitItem() + assert(action is OpenPdfViewer) + } + coVerify(exactly = 0) { arrangement.fileHelper.openAssetFileWithExternalApp(any(), any(), any(), any()) } + } + @Test fun `given in-app image viewer disabled when image file clicked and local file is not present and url is openable then url is opened`() = runTest { val (arrangement, viewModel) = Arrangement() @@ -316,7 +333,7 @@ class CellViewModelTest { .arrange() val testFile = testFiles[0].copy( - mimeType = "application/pdf", + mimeType = "text/plain", localPath = null, contentUrl = "https://example.com/file" ) @@ -326,6 +343,27 @@ class CellViewModelTest { coVerify(exactly = 1) { arrangement.fileHelper.openAssetUrlWithExternalApp(any(), any(), any()) } } + @Test + fun `given view model when pdf file clicked and only url is available then in-app pdf viewer is opened`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withLoadSuccess() + .arrange() + + val testFile = testFiles[0].copy( + mimeType = "application/pdf", + localPath = null, + contentUrl = "https://example.com/file" + ) + + viewModel.actions.test { + viewModel.sendIntent(CellViewIntent.OnItemClick(testFile.toUiModel())) + + val action = awaitItem() + assert(action is OpenPdfViewer) + } + coVerify(exactly = 0) { arrangement.fileHelper.openAssetUrlWithExternalApp(any(), any(), any()) } + } + @Test fun `given view model when file clicked and local file is not present and url is not openable then download starts immediately`() = runTest {