diff --git a/app/src/androidTest/java/com/nextcloud/test/NextcloudViewMatchers.kt b/app/src/androidTest/java/com/nextcloud/test/NextcloudViewMatchers.kt new file mode 100644 index 000000000000..11fa50fb90b0 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/NextcloudViewMatchers.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.test + +import android.view.View +import android.widget.TextView +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher + +fun withSelectedText(expected: String): Matcher = object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("with selected text \"$expected\"") + } + + @Suppress("ReturnCount") + override fun matchesSafely(view: View): Boolean { + if (view !is TextView) return false + val text = view.text?.toString() ?: "" + val s = view.selectionStart + val e = view.selectionEnd + @Suppress("ComplexCondition") + if (s < 0 || e < 0 || s > e || e > text.length) return false + return text.substring(s, e) == expected + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java index 04482aca2cd2..ac58ccc05fc9 100644 --- a/app/src/androidTest/java/com/owncloud/android/AbstractIT.java +++ b/app/src/androidTest/java/com/owncloud/android/AbstractIT.java @@ -67,7 +67,9 @@ import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; +import java.util.Arrays; import java.util.Collection; +import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Optional; @@ -233,7 +235,7 @@ protected Account[] getAllAccounts() { return AccountManager.get(targetContext).getAccounts(); } - protected static void createDummyFiles() throws IOException { + protected static List createDummyFiles() throws IOException { File tempPath = new File(FileStorageUtils.getTemporalPath(account.name)); if (!tempPath.exists()) { assertTrue(tempPath.mkdirs()); @@ -241,9 +243,11 @@ protected static void createDummyFiles() throws IOException { assertTrue(tempPath.exists()); - createFile("empty.txt", 0); - createFile("nonEmpty.txt", 100); - createFile("chunkedFile.txt", 500000); + return Arrays.asList( + createFile("empty.txt", 0), + createFile("nonEmpty.txt", 100), + createFile("chunkedFile.txt", 500000) + ); } protected static File getDummyFile(String name) throws IOException { diff --git a/app/src/androidTest/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivityIT.kt index d915379aa970..a114c00dbdfa 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivityIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivityIT.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2025 Philipp Hasper * SPDX-FileCopyrightText: 2025 Alper Ozturk * SPDX-FileCopyrightText: 2022 Tobias Kaminsky * SPDX-FileCopyrightText: 2022 Nextcloud GmbH @@ -8,23 +9,68 @@ */ package com.owncloud.android.ui.activity +import android.content.Intent +import android.net.Uri +import android.view.KeyEvent import androidx.test.core.app.launchActivity import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.facebook.testing.screenshot.internal.TestNameDetector +import com.nextcloud.client.preferences.AppPreferencesImpl +import com.nextcloud.test.GrantStoragePermissionRule +import com.nextcloud.test.withSelectedText +import com.nextcloud.utils.extensions.removeFileExtension import com.owncloud.android.AbstractIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile import com.owncloud.android.utils.ScreenshotTest +import org.hamcrest.Matchers.not +import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TestRule +import java.io.File class ReceiveExternalFilesActivityIT : AbstractIT() { - private val testClassName = "com.owncloud.android.ui.activity.ReceiveExternalFilesActivityIT" + + @get:Rule + var storagePermissionRule: TestRule = GrantStoragePermissionRule.grant() + + lateinit var mainFolder: OCFile + lateinit var subFolder: OCFile + lateinit var existingImageFile: OCFile + + @Before + fun setupFolderAndFileStructure() { + // Create folders with the necessary permissions and another test file + mainFolder = OCFile("/folder/").apply { + permissions = OCFile.PERMISSION_CAN_CREATE_FILE_AND_FOLDER + setFolder() + fileDataStorageManager.saveNewFile(this) + } + subFolder = OCFile("${mainFolder.remotePath}sub folder/").apply { + permissions = OCFile.PERMISSION_CAN_CREATE_FILE_AND_FOLDER + setFolder() + fileDataStorageManager.saveNewFile(this) + } + existingImageFile = OCFile("${mainFolder.remotePath}Existing Image File.jpg").apply { + fileDataStorageManager.saveNewFile(this) + } + } @Test @ScreenshotTest fun open() { + // Screenshot name must be constructed outside of the scenario, otherwise it will not be reliably detected + val screenShotName = TestNameDetector.getTestClass() + "_" + TestNameDetector.getTestName() launchActivity().use { scenario -> - val screenShotName = createName(testClassName + "_" + "open", "") onView(isRoot()).check(matches(isDisplayed())) scenario.onActivity { sut -> @@ -40,4 +86,161 @@ class ReceiveExternalFilesActivityIT : AbstractIT() { open() removeAccount(secondAccount) } + + fun createSendIntent(file: File): Intent = Intent(targetContext, ReceiveExternalFilesActivity::class.java).apply { + action = Intent.ACTION_SEND + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file)) + } + + fun createSendIntent(files: Iterable): Intent = + Intent(targetContext, ReceiveExternalFilesActivity::class.java).apply { + action = Intent.ACTION_SEND_MULTIPLE + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(files.map { Uri.fromFile(it) })) + } + + @Test + fun renameSingleFileUpload() { + val imageFile = getDummyFile("image.jpg") + val intent = createSendIntent(imageFile) + + // Store the folder in preferences, so the activity starts from there. + @Suppress("DEPRECATION") + val preferences = AppPreferencesImpl.fromContext(targetContext) + preferences.setLastUploadPath(mainFolder.remotePath) + + launchActivity(intent).use { + val expectedMainFolderTitle = (getCurrentActivity() as ToolbarActivity).getActionBarTitle(mainFolder, false) + // Verify that the test starts in the expected folder. If this fails, change the setup calls above + onView(withId(R.id.toolbar)) + .check(matches(hasDescendant(withText(expectedMainFolderTitle)))) + + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + + // Test the pre-selection behavior (filename, but without extension, shall be selected) + onView(withId(R.id.user_input)) + .check(matches(withText(imageFile.name))) + .perform(ViewActions.click()) + .check(matches(withSelectedText(imageFile.name.removeFileExtension()))) + + // Set a new file name + val secondFileName = "New filename.jpg" + onView(withId(R.id.user_input)) + .perform(ViewActions.typeTextIntoFocusedView(secondFileName.removeFileExtension())) + .check(matches(withText(secondFileName))) + // Leave the field and come back to verify the pre-selection behavior correctly handles the new name + .perform(ViewActions.pressKey(KeyEvent.KEYCODE_TAB)) + .perform(ViewActions.click()) + .check(matches(withSelectedText(secondFileName.removeFileExtension()))) + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + + // Set a file name without file extension + val thirdFileName = "No extension" + onView(withId(R.id.user_input)) + .perform(ViewActions.clearText()) + .perform(ViewActions.typeTextIntoFocusedView(thirdFileName)) + .check(matches(withText(thirdFileName))) + // Leave the field and come back to verify the pre-selection behavior correctly handles the new name + .perform(ViewActions.pressKey(KeyEvent.KEYCODE_TAB)) + .perform(ViewActions.click()) + .check(matches(withSelectedText(thirdFileName))) + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + + // Test an invalid filename. Note: as the user is null, the capabilities are also null, so the name checker + // will not reject any special characters like '/'. So we only test empty and an existing file name + onView(withId(R.id.user_input)) + .perform(ViewActions.clearText()) + .check(matches(withText(""))) + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(not(isEnabled()))) + onView(withId(R.id.user_input)) + .perform(ViewActions.click()) + .perform(ViewActions.typeTextIntoFocusedView(existingImageFile.fileName)) + .check(matches(withText(existingImageFile.fileName))) + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(not(isEnabled()))) + + val fourthFileName = "New file name.jpg" + onView(withId(R.id.user_input)) + .perform(ViewActions.click()) + .perform(ViewActions.clearText()) + .perform(ViewActions.typeTextIntoFocusedView(fourthFileName)) + .check(matches(withText(fourthFileName))) + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + + // Enter the subfolder and verify that the text stays intact + val expectedSubFolderTitle = (getCurrentActivity() as ToolbarActivity).getActionBarTitle(subFolder, false) + onView(withText(expectedSubFolderTitle)) + .perform(ViewActions.click()) + onView(withId(R.id.toolbar)) + .check(matches(hasDescendant(withText(expectedSubFolderTitle)))) + onView(withId(R.id.user_input)) + .check(matches(withText(fourthFileName))) + .perform(ViewActions.click()) + .check(matches(withSelectedText(fourthFileName.removeFileExtension()))) + + // Set a new, shorter file name + val fifthFileName = "short.jpg" + onView(withId(R.id.user_input)) + .perform(ViewActions.typeTextIntoFocusedView(fifthFileName.removeFileExtension())) + .check(matches(withText(fifthFileName))) + + // Start the upload, so the folder is stored in the preferences. + // Even though the upload is expected to fail because the backend is not mocked (yet?) + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + .perform(ViewActions.click()) + } + + // Start a new file receive flow. Should now start in the sub folder, but with the original filename again + launchActivity(intent).use { + val expectedMainFolderTitle = (getCurrentActivity() as ToolbarActivity).getActionBarTitle(subFolder, false) + onView(withId(R.id.toolbar)) + .check(matches(hasDescendant(withText(expectedMainFolderTitle)))) + + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + + onView(withId(R.id.user_input)) + .check(matches(withText(imageFile.name))) + } + } + + @Test + fun noRenameForMultiUpload() { + val testFiles = createDummyFiles() + val intent = createSendIntent(testFiles) + + // Store the folder in preferences, so the activity starts from there. + @Suppress("DEPRECATION") + val preferences = AppPreferencesImpl.fromContext(targetContext) + preferences.setLastUploadPath(mainFolder.remotePath) + + launchActivity(intent).use { + val expectedMainFolderTitle = (getCurrentActivity() as ToolbarActivity).getActionBarTitle(mainFolder, false) + // Verify that the test starts in the expected folder. If this fails, change the setup calls above + onView(withId(R.id.toolbar)) + .check(matches(hasDescendant(withText(expectedMainFolderTitle)))) + + onView(withText(R.string.uploader_btn_upload_text)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + + onView(withId(R.id.user_input)) + .check(matches(not(isDisplayed()))) + } + } } diff --git a/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt index 4f039da6bf63..41631d269609 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/dialog/DialogFragmentIT.kt @@ -63,10 +63,6 @@ import com.owncloud.android.lib.resources.status.OwnCloudVersion import com.owncloud.android.lib.resources.users.Status import com.owncloud.android.lib.resources.users.StatusType import com.owncloud.android.ui.activity.FileDisplayActivity -import com.owncloud.android.ui.dialog.LoadingDialog.Companion.newInstance -import com.owncloud.android.ui.dialog.RenameFileDialogFragment.Companion.newInstance -import com.owncloud.android.ui.dialog.SharePasswordDialogFragment.Companion.newInstance -import com.owncloud.android.ui.dialog.SslUntrustedCertDialog.Companion.newInstanceForEmptySslError import com.owncloud.android.ui.fragment.OCFileListBottomSheetActions import com.owncloud.android.ui.fragment.OCFileListBottomSheetDialog import com.owncloud.android.ui.fragment.ProfileBottomSheetDialog @@ -104,7 +100,7 @@ class DialogFragmentIT : AbstractIT() { Looper.prepare() } - newInstance( + RenameFileDialogFragment.newInstance( OCFile("/Test/"), OCFile("/") ).run { @@ -115,7 +111,7 @@ class DialogFragmentIT : AbstractIT() { @Test @ScreenshotTest fun testLoadingDialog() { - newInstance("Wait…").run { + LoadingDialog.newInstance("Wait…").run { showDialog(this) } } @@ -240,7 +236,7 @@ class DialogFragmentIT : AbstractIT() { if (Looper.myLooper() == null) { Looper.prepare() } - val sut = newInstance(OCFile("/"), true, false) + val sut = SharePasswordDialogFragment.newInstance(OCFile("/"), createShare = true, askForPassword = false) showDialog(sut) } @@ -250,7 +246,7 @@ class DialogFragmentIT : AbstractIT() { if (Looper.myLooper() == null) { Looper.prepare() } - val sut = newInstance(OCFile("/"), true, true) + val sut = SharePasswordDialogFragment.newInstance(OCFile("/"), createShare = true, askForPassword = true) showDialog(sut) } @@ -634,7 +630,7 @@ class DialogFragmentIT : AbstractIT() { val handler = mockk(relaxed = true) - newInstanceForEmptySslError(sslError, handler).run { + SslUntrustedCertDialog.newInstanceForEmptySslError(sslError, handler).run { showDialog(this) } } diff --git a/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameTextWatcher.kt b/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameTextWatcher.kt new file mode 100644 index 000000000000..3ec4ce2963b1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameTextWatcher.kt @@ -0,0 +1,72 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Philipp Hasper + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.fileNameValidator + +import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import androidx.core.util.Consumer +import com.nextcloud.utils.fileNameValidator.FileNameValidator.checkFileName +import com.nextcloud.utils.fileNameValidator.FileNameValidator.isExtensionChanged +import com.nextcloud.utils.fileNameValidator.FileNameValidator.isFileHidden +import com.owncloud.android.R +import com.owncloud.android.lib.resources.status.OCCapability + +/** + * A TextWatcher which wraps around [FileNameValidator] + */ +@Suppress("LongParameterList") +class FileNameTextWatcher( + private val previousFileName: String?, + private val context: Context, + private val capabilitiesProvider: () -> OCCapability, + private val existingFileNamesProvider: () -> Set?, + private val onValidationError: Consumer, + private val onValidationWarning: Consumer, + private val onValidationSuccess: Runnable +) : TextWatcher { + + // Used to trigger the onValidationSuccess callback only once (on "error/warn -> valid" transition) + private var isNameCurrentlyValid: Boolean = true + + override fun afterTextChanged(s: Editable?) { + val currentFileName = s?.toString().orEmpty() + val validationError = checkFileName( + currentFileName, + capabilitiesProvider(), + context, + existingFileNamesProvider() + ) + + when { + isFileHidden(currentFileName) -> { + isNameCurrentlyValid = false + onValidationWarning.accept(context.getString(R.string.hidden_file_name_warning)) + } + + validationError != null -> { + isNameCurrentlyValid = false + onValidationError.accept(validationError) + } + + isExtensionChanged(previousFileName, currentFileName) -> { + isNameCurrentlyValid = false + onValidationWarning.accept(context.getString(R.string.warn_rename_extension)) + } + + !isNameCurrentlyValid -> { + isNameCurrentlyValid = true + onValidationSuccess.run() + } + } + } + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit +} diff --git a/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt b/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt index dd1338ed4ed0..b7e89e2329cf 100644 --- a/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt +++ b/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt @@ -19,6 +19,7 @@ import com.nextcloud.utils.extensions.removeFileExtension import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.resources.status.OCCapability +import java.io.File object FileNameValidator { @@ -29,7 +30,7 @@ object FileNameValidator { * @param capability The capabilities affecting the validation criteria * such as forbiddenFilenames, forbiddenCharacters. * @param context The context used for retrieving error messages. - * @param existedFileNames Set of existing file names to avoid duplicates. + * @param existingFileNames Set of existing file names to avoid duplicates. * @return An error message if the filename is invalid, null otherwise. */ @Suppress("ReturnCount", "NestedBlockDepth") @@ -37,14 +38,14 @@ object FileNameValidator { filename: String, capability: OCCapability, context: Context, - existedFileNames: Set? = null + existingFileNames: Set? = null ): String? { if (filename.isBlank()) { return context.getString(R.string.filename_empty) } - existedFileNames?.let { - if (isFileNameAlreadyExist(filename, existedFileNames)) { + existingFileNames?.let { + if (isFileNameAlreadyExist(filename, existingFileNames)) { return context.getString(R.string.file_already_exists) } } @@ -147,6 +148,19 @@ object FileNameValidator { return null } + /** + * @return True, if the extension of both filenames is different. If either filename is null, function returns false + */ + fun isExtensionChanged(previousFileName: String?, newFileName: String?): Boolean { + if (previousFileName == null || newFileName == null) { + return false + } + val previousExtension = File(previousFileName).extension + val newExtension = File(newFileName).extension + + return previousExtension != newExtension + } + fun isFileHidden(name: String): Boolean = !TextUtils.isEmpty(name) && name[0] == '.' fun isFileNameAlreadyExist(name: String, fileNames: Set): Boolean = fileNames.contains(name) diff --git a/app/src/main/java/com/owncloud/android/datamodel/OCFile.java b/app/src/main/java/com/owncloud/android/datamodel/OCFile.java index 03d85e22a902..7aaf0e05924f 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/OCFile.java +++ b/app/src/main/java/com/owncloud/android/datamodel/OCFile.java @@ -47,16 +47,16 @@ public class OCFile implements Parcelable, Comparable, ServerFileInterface { public final static String PERMISSION_CAN_RESHARE = "R"; - private final static String PERMISSION_SHARED = "S"; - private final static String PERMISSION_MOUNTED = "M"; - private final static String PERMISSION_CAN_CREATE_FILE_INSIDE_FOLDER = "C"; - private final static String PERMISSION_CAN_CREATE_FOLDER_INSIDE_FOLDER = "K"; - private final static String PERMISSION_CAN_READ = "G"; - private final static String PERMISSION_CAN_WRITE = "W"; - private final static String PERMISSION_CAN_DELETE_OR_LEAVE_SHARE = "D"; - private final static String PERMISSION_CAN_RENAME = "N"; - private final static String PERMISSION_CAN_MOVE = "V"; - private final static String PERMISSION_CAN_CREATE_FILE_AND_FOLDER = PERMISSION_CAN_CREATE_FILE_INSIDE_FOLDER + PERMISSION_CAN_CREATE_FOLDER_INSIDE_FOLDER; + public final static String PERMISSION_SHARED = "S"; + public final static String PERMISSION_MOUNTED = "M"; + public final static String PERMISSION_CAN_CREATE_FILE_INSIDE_FOLDER = "C"; + public final static String PERMISSION_CAN_CREATE_FOLDER_INSIDE_FOLDER = "K"; + public final static String PERMISSION_CAN_READ = "G"; + public final static String PERMISSION_CAN_WRITE = "W"; + public final static String PERMISSION_CAN_DELETE_OR_LEAVE_SHARE = "D"; + public final static String PERMISSION_CAN_RENAME = "N"; + public final static String PERMISSION_CAN_MOVE = "V"; + public final static String PERMISSION_CAN_CREATE_FILE_AND_FOLDER = PERMISSION_CAN_CREATE_FILE_INSIDE_FOLDER + PERMISSION_CAN_CREATE_FOLDER_INSIDE_FOLDER; private final static int MAX_FILE_SIZE_FOR_IMMEDIATE_PREVIEW_BYTES = 1024000; diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivity.java index 2e8a6a27cdbf..41c557e61023 100755 --- a/app/src/main/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivity.java @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2025 Philipp Hasper * SPDX-FileCopyrightText: 2023 TSI-mc * SPDX-FileCopyrightText: 2016-2023 Tobias Kaminsky * SPDX-FileCopyrightText: 2019 Chris Narkiewicz @@ -24,6 +25,7 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.res.Resources.NotFoundException; +import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -54,6 +56,7 @@ import com.nextcloud.utils.extensions.BundleExtensionsKt; import com.nextcloud.utils.extensions.FileExtensionsKt; import com.nextcloud.utils.extensions.IntentExtensionsKt; +import com.nextcloud.utils.fileNameValidator.FileNameTextWatcher; import com.nextcloud.utils.fileNameValidator.FileNameValidator; import com.owncloud.android.MainApp; import com.owncloud.android.R; @@ -96,6 +99,7 @@ import java.util.Arrays; import java.util.Calendar; import java.util.List; +import java.util.Objects; import java.util.Stack; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -110,6 +114,7 @@ import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog.Builder; import androidx.appcompat.widget.SearchView; +import androidx.core.util.Function; import androidx.core.view.MenuItemCompat; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentManager; @@ -117,6 +122,7 @@ import androidx.recyclerview.widget.LinearLayoutManager; import static com.owncloud.android.utils.DisplayUtils.openSortingOrderDialogFragment; +import static com.owncloud.android.utils.UriUtils.getDisplayNameForUri; /** * This can be used to upload things to an Nextcloud instance. @@ -141,10 +147,13 @@ public class ReceiveExternalFilesActivity extends FileActivity private AccountManager mAccountManager; private Stack mParents = new Stack<>(); - private List mStreamsToUpload; + @Nullable private List mStreamsToUpload; private String mUploadPath; private OCFile mFile; + @Nullable + private Function mFileDisplayNameTransformer = null; + private SyncBroadcastReceiver mSyncBroadcastReceiver; private ReceiveExternalFilesAdapter receiveExternalFilesAdapter; @@ -785,6 +794,7 @@ private void populateDirectoryList(OCFile file) { files = sortFileList(files); setupReceiveExternalFilesAdapter(files); } + setupFileNameInputField(); MaterialButton btnChooseFolder = binding.uploaderChooseFolder; viewThemeUtils.material.colorMaterialButtonPrimaryFilled(btnChooseFolder); @@ -838,6 +848,59 @@ public void setMessageForEmptyList(@StringRes final int headline, @StringRes fin }); } + private void setupFileNameInputField() { + binding.userInput.setVisibility(View.GONE); + mFileDisplayNameTransformer = null; + if (mStreamsToUpload == null || mStreamsToUpload.size() != 1) { + return; + } + final String fileName = getDisplayNameForUri((Uri) mStreamsToUpload.get(0), getActivity()); + if (fileName == null) { + return; + } + final String userProvidedFileName = Objects.requireNonNullElse(binding.userInput.getText(), "").toString(); + + binding.userInput.setVisibility(View.VISIBLE); + binding.userInput.setText(userProvidedFileName.isEmpty() ? fileName : userProvidedFileName); + binding.userInput.addTextChangedListener(new FileNameTextWatcher( + fileName, + this, + this::getCapabilities, + () -> receiveExternalFilesAdapter.getFileNames(), + validationError -> { + binding.userInputContainer.setError(validationError); + binding.uploaderChooseFolder.setEnabled(false); + }, + validationWarning -> { + binding.userInputContainer.setError(validationWarning); + binding.uploaderChooseFolder.setEnabled(true); + }, + () -> { // onValidationSuccess + binding.userInputContainer.setError(null); + binding.userInputContainer.setErrorEnabled(false); + binding.uploaderChooseFolder.setEnabled(true); + } + )); + + mFileDisplayNameTransformer = uri -> + Objects.requireNonNullElse(binding.userInput.getText(), fileName).toString(); + + // When entering the text field, pre-select the name (without extension if present), for convenient editing + binding.userInput.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + final String currentText = Objects.requireNonNullElse(binding.userInput.getText(), "").toString(); + binding.userInput.post(() -> { + if (currentText.lastIndexOf('.') != -1) { + binding.userInput.setSelection(0, currentText.lastIndexOf('.')); + } else { + // No file extension - select all + binding.userInput.selectAll(); + } + }); + } + }); + } + @Override public void onSavedCertificate() { startSyncFolderOperation(getCurrentDir()); @@ -961,7 +1024,8 @@ public void uploadFiles() { getUser().orElseThrow(RuntimeException::new), FileUploadWorker.LOCAL_BEHAVIOUR_DELETE, true, // Show waiting dialog while file is being copied from private storage - this // Copy temp task listener + this, // Listener for copying to temporary files + mFileDisplayNameTransformer ); UriUploader.UriUploaderResultCode resultCode = uploader.uploadUris(); diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/ReceiveExternalFilesAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/ReceiveExternalFilesAdapter.kt index 12736f80f651..a6b48b20c60e 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/ReceiveExternalFilesAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/ReceiveExternalFilesAdapter.kt @@ -26,6 +26,7 @@ import com.owncloud.android.datamodel.ThumbnailsCacheManager.ThumbnailGeneration import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.theme.ViewThemeUtils +import java.util.Objects @Suppress("LongParameterList") class ReceiveExternalFilesAdapter( @@ -159,4 +160,6 @@ class ReceiveExternalFilesAdapter( } override fun getItemCount() = filteredFiles.size + + fun getFileNames(): Set = files.map { it.fileName }.toSet() } diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/RenameFileDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/RenameFileDialogFragment.kt index 0a1981c11300..ed317b01a673 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/RenameFileDialogFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/RenameFileDialogFragment.kt @@ -13,8 +13,6 @@ package com.owncloud.android.ui.dialog import android.app.Dialog import android.content.DialogInterface import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher import android.view.View import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment @@ -25,8 +23,8 @@ import com.nextcloud.client.account.CurrentAccountProvider import com.nextcloud.client.di.Injectable import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.typedActivity +import com.nextcloud.utils.fileNameValidator.FileNameTextWatcher import com.nextcloud.utils.fileNameValidator.FileNameValidator.checkFileName -import com.nextcloud.utils.fileNameValidator.FileNameValidator.isFileHidden import com.owncloud.android.R import com.owncloud.android.databinding.EditBoxDialogBinding import com.owncloud.android.datamodel.FileDataStorageManager @@ -37,7 +35,6 @@ import com.owncloud.android.ui.activity.FileDisplayActivity import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.KeyboardUtils import com.owncloud.android.utils.theme.ViewThemeUtils -import java.io.File import javax.inject.Inject /** @@ -47,7 +44,6 @@ import javax.inject.Inject class RenameFileDialogFragment : DialogFragment(), DialogInterface.OnClickListener, - TextWatcher, Injectable { @Inject lateinit var viewThemeUtils: ViewThemeUtils @@ -99,7 +95,28 @@ class RenameFileDialogFragment : fileNames?.add(file.fileName) } - binding.userInput.addTextChangedListener(this) + binding.userInput.addTextChangedListener( + FileNameTextWatcher( + previousFileName = mTargetFile?.fileName, + context = binding.userInputContainer.context, + capabilitiesProvider = { oCCapability }, + existingFileNamesProvider = { fileNames }, + onValidationError = { validationError: String -> + binding.userInputContainer.error = validationError + positiveButton?.isEnabled = false + }, + onValidationWarning = { validationWarning: String -> + binding.userInputContainer.error = validationWarning + positiveButton?.isEnabled = true + }, + onValidationSuccess = { + binding.userInputContainer.error = null + // Called to remove extra padding + binding.userInputContainer.isErrorEnabled = false + positiveButton?.isEnabled = true + } + ) + ) val builder = buildMaterialAlertDialog(binding.root) @@ -167,50 +184,6 @@ class RenameFileDialogFragment : } } - override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit - - /** - * When user enters a hidden file name, the 'hidden file' message is shown. - * Otherwise, the message is ensured to be hidden. - */ - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { - var newFileName = "" - if (binding.userInput.text != null) { - newFileName = binding.userInput.text.toString() - } - - val errorMessage = checkFileName(newFileName, oCCapability, requireContext(), fileNames) - - if (isFileHidden(newFileName)) { - binding.userInputContainer.error = getText(R.string.hidden_file_name_warning) - positiveButton?.isEnabled = true - } else if (errorMessage != null) { - binding.userInputContainer.error = errorMessage - positiveButton?.isEnabled = false - } else if (checkExtensionRenamed(newFileName)) { - binding.userInputContainer.error = getText(R.string.warn_rename_extension) - positiveButton?.isEnabled = true - } else if (binding.userInputContainer.error != null) { - binding.userInputContainer.error = null - // Called to remove extra padding - binding.userInputContainer.isErrorEnabled = false - positiveButton?.isEnabled = true - } - } - - override fun afterTextChanged(s: Editable) = Unit - - private fun checkExtensionRenamed(newFileName: String): Boolean { - mTargetFile?.fileName?.let { previousFileName -> - val previousExtension = File(previousFileName).extension - val newExtension = File(newFileName).extension - - return previousExtension != newExtension - } - - return false - } - companion object { private const val ARG_TARGET_FILE = "TARGET_FILE" private const val ARG_PARENT_FOLDER = "PARENT_FOLDER" diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt b/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt index 9f08ad99bbce..6b4c63cd71d2 100644 --- a/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt +++ b/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt @@ -13,6 +13,7 @@ package com.owncloud.android.ui.helpers import android.content.ContentResolver import android.net.Uri import android.os.Parcelable +import androidx.core.util.Function import com.nextcloud.client.account.User import com.nextcloud.client.jobs.upload.FileUploadHelper import com.owncloud.android.R @@ -42,14 +43,16 @@ import com.owncloud.android.utils.UriUtils.getDisplayNameForUri "Detekt.SpreadOperator", "Detekt.TooGenericExceptionCaught" ) // legacy code -class UriUploader( +class UriUploader @JvmOverloads constructor( private val mActivity: FileActivity, private val mUrisToUpload: List, private val mUploadPath: String, private val user: User, private val mBehaviour: Int, private val mShowWaitingDialog: Boolean, - private val mCopyTmpTaskListener: OnCopyTmpFilesTaskListener? + private val mCopyTmpTaskListener: OnCopyTmpFilesTaskListener?, + /** If non-null, this function is called to determine the desired display name (i.e. filename) after upload**/ + private val mFileDisplayNameTransformer: Function? = null ) { enum class UriUploaderResultCode { @@ -113,7 +116,8 @@ class UriUploader( } private fun getRemotePathForUri(sourceUri: Uri): String { - val displayName = getDisplayNameForUri(sourceUri, mActivity) + val displayName = mFileDisplayNameTransformer?.apply(sourceUri) + ?: getDisplayNameForUri(sourceUri, mActivity) require(displayName != null) { "Display name cannot be null" } return mUploadPath + displayName } diff --git a/app/src/main/res/layout/receive_external_files.xml b/app/src/main/res/layout/receive_external_files.xml index 1f85eae78a32..589cae7888da 100644 --- a/app/src/main/res/layout/receive_external_files.xml +++ b/app/src/main/res/layout/receive_external_files.xml @@ -1,6 +1,7 @@