diff --git a/app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt b/app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt index a4f61c7325f9..157a91d2b99c 100644 --- a/app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt @@ -330,6 +330,39 @@ class FileMenuFilterIT : AbstractIT() { } } + @Test + fun filter_openWithOnlyOffice_onlyForDownloadedFiles() { + every { + mockArbitraryDataProvider.getValue(any(), ArbitraryDataProvider.DIRECT_EDITING) + } returns ONLYOFFICE_DIRECT_EDITING_JSON + + configureCapability(OCCapability()) + + val downloadedFile = OCFile("/downloaded.docx").apply { + mimeType = OFFICE_MIMETYPE + storagePath = getDummyFile("downloaded.docx").absolutePath + } + val onlineOnlyFile = OCFile("/online.docx").apply { + mimeType = OFFICE_MIMETYPE + } + + launchActivity().use { + it.onActivity { activity -> + val filterFactory = FileMenuFilter.Factory(mockStorageManager, activity, editorUtils) + + val downloadedToHide = filterFactory + .newInstance(downloadedFile, mockComponentsGetter, true, user) + .getToHide(false) + assertFalse(downloadedToHide.contains(R.id.action_open_with_office)) + + val onlineToHide = filterFactory + .newInstance(onlineOnlyFile, mockComponentsGetter, true, user) + .getToHide(false) + assertTrue(onlineToHide.contains(R.id.action_open_with_office)) + } + } + } + private data class ExpectedLockVisibilities(val lockFile: Boolean, val unlockFile: Boolean) private fun configureCapability(capability: OCCapability) { @@ -363,4 +396,25 @@ class FileMenuFilterIT : AbstractIT() { } } } + + companion object { + private const val OFFICE_MIMETYPE = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + + private val ONLYOFFICE_DIRECT_EDITING_JSON = + """ + { + "editors": { + "onlyoffice": { + "id": "onlyoffice", + "name": "OnlyOffice", + "mimetypes": [], + "optionalMimetypes": ["$OFFICE_MIMETYPE"], + "secure": false + } + }, + "creators": {} + } + """.trimIndent() + } } diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt b/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt index f30dbefbe86a..aa4ae09195d9 100644 --- a/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt +++ b/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt @@ -48,6 +48,7 @@ enum class FileAction( SEND_SHARE_FILE(R.id.action_send_share_file, R.string.action_send_share, R.drawable.ic_share), SEND_FILE(R.id.action_send_file, R.string.common_send, R.drawable.ic_share), OPEN_FILE_WITH(R.id.action_open_file_with, R.string.actionbar_open_with, R.drawable.ic_external), + OPEN_WITH_OFFICE(R.id.action_open_with_office, R.string.action_open_with_office, R.drawable.file_doc), STREAM_MEDIA(R.id.action_stream_media, R.string.stream, R.drawable.ic_play_arrow), SET_AS_WALLPAPER(R.id.action_set_as_wallpaper, R.string.set_picture_as, R.drawable.ic_wallpaper), @@ -87,6 +88,7 @@ enum class FileAction( SEND_SHARE_FILE, SEND_FILE, OPEN_FILE_WITH, + OPEN_WITH_OFFICE, SYNC_FOLDER ) diff --git a/app/src/main/java/com/nextcloud/utils/EditorUtils.kt b/app/src/main/java/com/nextcloud/utils/EditorUtils.kt index 3c1630e64308..8224fa316ca1 100644 --- a/app/src/main/java/com/nextcloud/utils/EditorUtils.kt +++ b/app/src/main/java/com/nextcloud/utils/EditorUtils.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2022 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only @@ -16,17 +17,42 @@ import javax.inject.Inject class EditorUtils @Inject constructor(private val arbitraryDataProvider: ArbitraryDataProvider) { + /** + * Returns an editor matching only the strictly supported mimetypes. + */ fun getEditor(user: User?, mimeType: String?): Editor? { - val json = arbitraryDataProvider.getValue(user, ArbitraryDataProvider.DIRECT_EDITING) - if (json.isEmpty()) { - return null - } - val editors = Gson().fromJson(json, DirectEditing::class.java).editors.values + val editors = getEditors(user) ?: return null + return editors.firstOrNull { mimeType in it.mimetypes } + } + + /** + * Returns an editor matching the supported mimetypes or, as a fallback, the optional ones. + */ + fun getAvailableEditor(user: User?, mimeType: String?): Editor? { + val editors = getEditors(user) ?: return null return editors.firstOrNull { mimeType in it.mimetypes } ?: editors.firstOrNull { mimeType in it.optionalMimetypes } } - fun isEditorAvailable(user: User?, mimeType: String?): Boolean = getEditor(user, mimeType) != null + /** + * Returns supported mimetypes along with optional ones + */ + fun isEditorAvailable(user: User?, mimeType: String?): Boolean = getAvailableEditor(user, mimeType) != null + + /** + * Returns true when an office editor (e.g. OnlyOffice) can open the mimetype, + * either as a supported or optional mimetype. + */ + fun isOfficeEditorAvailable(user: User?, mimeType: String?): Boolean { + val editors = getEditors(user) ?: return false + return editors.any { usesOfficeUserAgent(it) && (mimeType in it.mimetypes || mimeType in it.optionalMimetypes) } + } + + private fun getEditors(user: User?): Collection? { + val json = arbitraryDataProvider.getValue(user, ArbitraryDataProvider.DIRECT_EDITING) + if (json.isEmpty()) return null + return Gson().fromJson(json, DirectEditing::class.java).editors.values + } fun usesOfficeUserAgent(editor: Editor?): Boolean = editor?.id in OFFICE_EDITOR_IDS diff --git a/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java b/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java index 9f29055f94fe..7f41da91458a 100644 --- a/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java +++ b/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java @@ -158,6 +158,7 @@ private List filter(boolean inSingleFileFragment) { filterSelectAll(toHide, inSingleFileFragment); filterDeselectAll(toHide, inSingleFileFragment); filterOpenWith(toHide, synchronizing); + filterOpenWithOffice(toHide); filterCancelSync(toHide, synchronizing); filterSync(toHide, synchronizing); filterShareFile(toHide, capability); @@ -313,9 +314,15 @@ private void filterEdit( */ @NextcloudServer(max = 18) private boolean isRichDocumentEditingSupported(OCCapability capability, String mimeType) { + final var mimeTypeList = capability.getRichDocumentsMimeTypeList(); + final var isMimeTypeListContainsMimeType = (mimeTypeList != null && mimeTypeList.contains(mimeType)); + + final var optionalMimeTypeList = capability.getRichDocumentsOptionalMimeTypeList(); + final var isOptionalMimeTypeListContainsMimeType = (optionalMimeTypeList != null && + optionalMimeTypeList.contains(mimeType)); + return isSingleFile() && - (capability.getRichDocumentsMimeTypeList().contains(mimeType) || - capability.getRichDocumentsOptionalMimeTypeList().contains(mimeType)) && + (isMimeTypeListContainsMimeType || isOptionalMimeTypeListContainsMimeType) && capability.getRichDocumentsDirectEditing().isTrue(); } @@ -339,6 +346,19 @@ private void filterOpenWith(Collection toHide, boolean synchronizing) { } } + private void filterOpenWithOffice(Collection toHide) { + if (!isSingleFile()) { + toHide.add(R.id.action_open_with_office); + return; + } + + OCFile file = files.iterator().next(); + boolean canOpenLocally = file.isDown() && !file.isEncrypted(); + if (!canOpenLocally || !editorUtils.isOfficeEditorAvailable(user, file.getMimeType())) { + toHide.add(R.id.action_open_with_office); + } + } + private void filterDeselectAll(List toHide, boolean inSingleFileFragment) { if (inSingleFileFragment) { // Always hide in single file fragments diff --git a/app/src/main/java/com/owncloud/android/ui/activity/TextEditorWebView.kt b/app/src/main/java/com/owncloud/android/ui/activity/TextEditorWebView.kt index e3fe221abd9b..d175a8b081a7 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/TextEditorWebView.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/TextEditorWebView.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: Alper Ozturk * SPDX-FileCopyrightText: 2019 Tobias Kaminsky * SPDX-FileCopyrightText: 2019 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only @@ -8,17 +9,18 @@ package com.owncloud.android.ui.activity import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent import androidx.core.net.toUri import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewFeature import com.nextcloud.android.common.ui.util.PlatformThemeUtil import com.nextcloud.client.appinfo.AppInfo import com.nextcloud.client.device.DeviceInfo -import com.nextcloud.utils.EditorUtils import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile import com.owncloud.android.ui.asynctasks.TextEditorLoadUrlTask import com.owncloud.android.utils.DisplayUtils -import com.owncloud.android.utils.theme.ThemeUtils import javax.inject.Inject class TextEditorWebView : EditorWebView() { @@ -28,11 +30,20 @@ class TextEditorWebView : EditorWebView() { @Inject lateinit var deviceInfo: DeviceInfo - @Inject - lateinit var themeUtils: ThemeUtils - - @Inject - lateinit var editorUtils: EditorUtils + companion object { + fun startTextEditor(file: OCFile?, context: Context?) { + val context = context ?: return + val file = file ?: return + + Intent(context, TextEditorWebView::class.java).apply { + putExtra(EXTRA_TITLE, "Text") + putExtra(EXTRA_FILE, file) + putExtra(EXTRA_SHOW_SIDEBAR, false) + }.also { + context.startActivity(it) + } + } + } @SuppressLint("AddJavascriptInterface") // suppress warning as webview is only used > Lollipop override fun postOnCreate() { @@ -43,7 +54,7 @@ class TextEditorWebView : EditorWebView() { finish() } - val editor = editorUtils.getEditor(user.get(), file?.mimeType) + val editor = editorUtils.getAvailableEditor(user.get(), file?.mimeType) if (editorUtils.usesOfficeUserAgent(editor)) { webView.settings.userAgentString = generateOfficeUserAgent() diff --git a/app/src/main/java/com/owncloud/android/ui/asynctasks/TextEditorLoadUrlTask.java b/app/src/main/java/com/owncloud/android/ui/asynctasks/TextEditorLoadUrlTask.java index 14c18dba9b72..360e8a347316 100644 --- a/app/src/main/java/com/owncloud/android/ui/asynctasks/TextEditorLoadUrlTask.java +++ b/app/src/main/java/com/owncloud/android/ui/asynctasks/TextEditorLoadUrlTask.java @@ -41,7 +41,7 @@ protected String doInBackground(Void... voids) { return ""; } - Editor editor = editorUtils.getEditor(user, file.getMimeType()); + Editor editor = editorUtils.getAvailableEditor(user, file.getMimeType()); if (editor == null) { return ""; diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 3d618b1eba57..39b106051bb9 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -91,6 +91,7 @@ import com.owncloud.android.ui.activity.FileDisplayActivity; import com.owncloud.android.ui.activity.FolderPickerActivity; import com.owncloud.android.ui.activity.OnEnforceableRefreshListener; +import com.owncloud.android.ui.activity.TextEditorWebView; import com.owncloud.android.ui.activity.UploadFilesActivity; import com.owncloud.android.ui.adapter.CommonOCFileListAdapterInterface; import com.owncloud.android.ui.adapter.OCFileListAdapter; @@ -147,7 +148,6 @@ import androidx.fragment.app.FragmentManager; import androidx.media3.common.util.UnstableApi; import kotlin.Unit; -import kotlin.jvm.functions.Function1; import static com.owncloud.android.datamodel.OCFile.ROOT_PATH; import static com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment.SETUP_ENCRYPTION_DIALOG_TAG; @@ -1216,8 +1216,8 @@ private void handlePendingDownloadFile(OCFile file) { if (PreviewMediaActivity.Companion.canBePreviewed(file) && !file.isEncrypted() && mContainerActivity instanceof FileDisplayActivity fda) { setFabVisible(false); fda.startMediaPreview(file, 0, true, true, true, true); - } else if (editorUtils.isEditorAvailable(accountManager.getUser(), file.getMimeType()) && !file.isEncrypted()) { - mContainerActivity.getFileOperationsHelper().openFileWithTextEditor(file, getContext()); + } else if (editorUtils.getEditor(accountManager.getUser(), file.getMimeType()) != null && !file.isEncrypted()) { + TextEditorWebView.Companion.startTextEditor(file, getContext()); } else if (capability.getRichDocumentsMimeTypeList() != null && capability.getRichDocumentsMimeTypeList().contains(file.getMimeType()) && capability.getRichDocumentsDirectEditing().isTrue() && !file.isEncrypted()) { @@ -1351,14 +1351,16 @@ public boolean onFileActionChosen(@IdRes final int itemId, Set checkedFi } else if (itemId == R.id.action_open_file_with) { mContainerActivity.getFileOperationsHelper().openFile(singleFile); return true; + } else if (itemId == R.id.action_open_with_office) { + TextEditorWebView.Companion.startTextEditor(singleFile, getContext()); + return true; } else if (itemId == R.id.action_stream_media) { mContainerActivity.getFileOperationsHelper().streamMediaFile(singleFile); return true; } else if (itemId == R.id.action_edit) { // should not be necessary, as menu item is filtered, but better play safe - if (editorUtils.isEditorAvailable(accountManager.getUser(), - singleFile.getMimeType())) { - mContainerActivity.getFileOperationsHelper().openFileWithTextEditor(singleFile, getContext()); + if (editorUtils.isEditorAvailable(accountManager.getUser(), singleFile.getMimeType())) { + TextEditorWebView.Companion.startTextEditor(singleFile,getContext()); } else if (EditImageActivity.Companion.canBePreviewed(singleFile)) { ((FileDisplayActivity) mContainerActivity).startImageEditor(singleFile); } else { diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java index a1b22caa96e5..86d326d5c6ae 100755 --- a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java +++ b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java @@ -291,11 +291,12 @@ public void openFile(OCFile file) { List availableApps = fileActivity.getPackageManager(). queryIntentActivities(openFileWithIntent, PackageManager.GET_RESOLVED_FILTER); + // first always try to use available apps if (availableApps.isEmpty()) { Optional optionalUser = fileActivity.getUser(); if (optionalUser.isPresent() && editorUtils.isEditorAvailable(optionalUser.get(), file.getMimeType())) { - openFileWithTextEditor(file, fileActivity); + TextEditorWebView.Companion.startTextEditor(file, fileActivity); return; } @@ -364,14 +365,6 @@ public void openFileAsRichDocument(OCFile file, Context context) { context.startActivity(collaboraWebViewIntent); } - public void openFileWithTextEditor(OCFile file, Context context) { - Intent textEditorIntent = new Intent(context, TextEditorWebView.class); - textEditorIntent.putExtra(ExternalSiteWebView.EXTRA_TITLE, "Text"); - textEditorIntent.putExtra(ExternalSiteWebView.EXTRA_FILE, file); - textEditorIntent.putExtra(ExternalSiteWebView.EXTRA_SHOW_SIDEBAR, false); - context.startActivity(textEditorIntent); - } - public void openRichWorkspaceWithTextEditor(OCFile file, String url, Context context) { Intent textEditorIntent = new Intent(context, TextEditorWebView.class); textEditorIntent.putExtra(ExternalSiteWebView.EXTRA_TITLE, "Text"); diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewTextFileFragment.java b/app/src/main/java/com/owncloud/android/ui/preview/PreviewTextFileFragment.java index 0b32cc0a9a92..dd6240c4cf2e 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewTextFileFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewTextFileFragment.java @@ -27,6 +27,7 @@ import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.ui.activity.FileActivity; +import com.owncloud.android.ui.activity.TextEditorWebView; import com.owncloud.android.ui.dialog.ConfirmationDialogFragment; import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment; import com.owncloud.android.utils.DisplayUtils; @@ -310,7 +311,7 @@ private void onFileActionChosen(final int itemId) { } else if(itemId == R.id.action_cancel_sync){ containerActivity.getFileOperationsHelper().cancelTransference(getFile()); } else if (itemId == R.id.action_edit) { - containerActivity.getFileOperationsHelper().openFileWithTextEditor(getFile(), getContext()); + TextEditorWebView.Companion.startTextEditor(getFile(), getContext()); } } diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 34075e4237cf..b658bceaa75b 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -23,6 +23,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d662ed36f1bf..a67a9acfca14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1194,6 +1194,7 @@ Internal share link only works for users with access to this file Share internal link Edit + Open with office Failed to start editor Add folder description We couldnt locate the file on server. Another user may have deleted the file