diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt index a5678e5b8751..793a6bcb4d71 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentStaticServerIT.kt @@ -231,6 +231,13 @@ class OCFileListFragmentStaticServerIT : AbstractIT() { sut.storageManager.saveFile(this) } + OCFile("/Foo%e2%80%aedm.exe").apply { + remoteId = "000000011" + parentId = sut.storageManager.getFileByEncryptedRemotePath("/").fileId + modificationTimestamp = 1000 + sut.storageManager.saveFile(this) + } + sut.addFragment(fragment) val root = sut.storageManager.getFileByEncryptedRemotePath("/") fragment.listDirectory(root, false, false) diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt index 336954ea7f44..63ccd3b66f2c 100644 --- a/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt +++ b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt @@ -33,6 +33,7 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.CurrentAccountProvider import com.nextcloud.client.di.Injectable import com.nextcloud.client.di.ViewModelFactory +import com.nextcloud.utils.extensions.setVisibleIf import com.owncloud.android.R import com.owncloud.android.databinding.FileActionsBottomSheetBinding import com.owncloud.android.databinding.FileActionsBottomSheetItemBinding @@ -44,6 +45,7 @@ import com.owncloud.android.lib.resources.files.model.FileLockType import com.owncloud.android.ui.activity.ComponentsGetter import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.DisplayUtils.AvatarGenerationListener +import com.owncloud.android.utils.FileStorageUtils import com.owncloud.android.utils.theme.ViewThemeUtils import javax.inject.Inject @@ -204,11 +206,23 @@ class FileActionsBottomSheet : private fun displayTitle(titleFile: OCFile?) { val decryptedFileName = titleFile?.decryptedFileName if (decryptedFileName != null) { - decryptedFileName.let { - binding.title.text = it + val isFolder = titleFile.isFolder + val isRTL = DisplayUtils.isRTL() + val (base, ext) = FileStorageUtils.getFilenameAndExtension(decryptedFileName, isFolder, isRTL) + val titleMaxWidth = DisplayUtils.convertDpToPixel( + requireContext().resources.configuration.screenWidthDp.times(FILENAME_MAX_WIDTH_PERCENTAGE).toFloat(), + context + ) + + binding.title.maxWidth = titleMaxWidth + binding.title.text = base + binding.extension.setVisibleIf(!isFolder) + if (!isFolder) { + binding.extension.text = ext } } else { binding.title.isVisible = false + binding.extension.isVisible = false } } @@ -300,6 +314,7 @@ class FileActionsBottomSheet : companion object { private const val REQUEST_KEY = "REQUEST_KEY_ACTION" private const val RESULT_KEY_ACTION_ID = "RESULT_KEY_ACTION_ID" + private const val FILENAME_MAX_WIDTH_PERCENTAGE = 0.6 @JvmStatic @JvmOverloads diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GroupfolderListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GroupfolderListAdapter.kt index 7f15ae9610c1..b82e665d596d 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/GroupfolderListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GroupfolderListAdapter.kt @@ -53,6 +53,7 @@ class GroupfolderListAdapter( listHolder.apply { fileName.text = file.name fileSize.text = file.parentFile?.path ?: "/" + extension.visibility = View.GONE fileSizeSeparator.visibility = View.GONE lastModification.visibility = View.GONE checkbox.visibility = View.GONE diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt index 3132a7fafc60..bbbfc9ade682 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/ListGridItemViewHolder.kt @@ -11,4 +11,5 @@ import android.widget.TextView internal interface ListGridItemViewHolder : ListViewHolder { val fileName: TextView + val extension: TextView? } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java index de8746cd5b78..47eb68ae6284 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java @@ -102,6 +102,7 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import kotlin.Pair; import me.zhanghai.android.fastscroll.PopupTextProvider; /** @@ -123,6 +124,7 @@ public class OCFileListAdapter extends RecyclerView.Adapter filenamePair, OCFile file) { + boolean containsBidiControlCharacters = FileStorageUtils.containsBidiControlCharacters(filename); + ViewExtensionsKt.setVisibleIf(holder.getFileName(),!containsBidiControlCharacters); + ViewExtensionsKt.setVisibleIf(holder.getBinding().bidiFilenameContainer, containsBidiControlCharacters); + final var extension = holder.getExtension(); + + if (containsBidiControlCharacters) { + holder.getBidiFilename().setText(filenamePair.getFirst()); + if (extension != null) { + extension.setText(filenamePair.getSecond()); + } + holder.getBinding().more.setVisibility(View.GONE); + holder.getBinding().bidiMore.setOnClickListener(v -> ocFileListFragmentInterface.onOverflowIconClicked(file, v)); + } else { + holder.getFileName().setText(filename); + if (extension != null) { + extension.setVisibility(View.GONE); + } + } + } + + private void handleListMode(ListGridItemViewHolder holder, + Pair filenamePair, + boolean isFolder) { + holder.getFileName().setText(filenamePair.getFirst()); + + final var extension = holder.getExtension(); + if (extension != null) { + if (isFolder) { + extension.setVisibility(View.GONE); + } else { + extension.setVisibility(View.VISIBLE); + extension.setText(filenamePair.getSecond()); + } + } } private void bindListItemViewHolder(ListItemViewHolder holder, OCFile file) { @@ -1145,6 +1193,10 @@ public void cancelAllPendingTasks() { ocFileListDelegate.cancelAllPendingTasks(); } + public boolean isGridView() { + return gridView; + } + public void setGridView(boolean bool) { gridView = bool; } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt index 05ee4731e994..dc00b032e3c7 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListGridItemViewHolder.kt @@ -12,17 +12,26 @@ import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.elyeproj.loaderviewlibrary.LoaderImageView import com.owncloud.android.databinding.GridItemBinding -internal class OCFileListGridItemViewHolder(var binding: GridItemBinding) : +class OCFileListGridItemViewHolder(var binding: GridItemBinding) : RecyclerView.ViewHolder( binding.root ), ListGridItemViewHolder { + val bidiFilename: TextView + get() = binding.bidiFilename override val fileName: TextView get() = binding.Filename + override val extension: TextView? + get() = if (binding.bidiFilenameContainer.isVisible) { + binding.bidiExtension + } else { + null + } override val thumbnail: ImageView get() = binding.thumbnail @@ -56,8 +65,11 @@ internal class OCFileListGridItemViewHolder(var binding: GridItemBinding) : override val fileFeaturesLayout: LinearLayout get() = binding.fileFeaturesLayout override val more: ImageButton - get() = binding.more - + get() = if (binding.bidiFilenameContainer.isVisible) { + binding.bidiMore + } else { + binding.more + } init { binding.favoriteAction.drawable.mutate() } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt index 38b3310f0bbe..96da2b7e5c67 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListItemViewHolder.kt @@ -43,6 +43,8 @@ internal class OCFileListItemViewHolder(private var binding: ListItemBinding) : get() = binding.sharedAvatars override val fileName: TextView get() = binding.Filename + override val extension: TextView + get() = binding.extension override val thumbnail: ImageView get() = binding.thumbnailLayout.thumbnail override val tagsGroup: ChipGroup diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListViewHolder.kt index 7805c21c7f6d..b0a0c037cd3a 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListViewHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListViewHolder.kt @@ -12,6 +12,7 @@ import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.elyeproj.loaderviewlibrary.LoaderImageView import com.owncloud.android.databinding.GridItemBinding @@ -26,7 +27,11 @@ internal class OCFileListViewHolder(var binding: GridItemBinding) : get() = binding.thumbnail override val imageFileName: TextView - get() = binding.Filename + get() = if (binding.bidiFilenameContainer.isVisible) { + binding.bidiFilename + } else { + binding.Filename + } override fun showVideoOverlay() { // noop @@ -47,7 +52,11 @@ internal class OCFileListViewHolder(var binding: GridItemBinding) : override val unreadComments: ImageView get() = binding.unreadComments override val more: ImageButton - get() = binding.more + get() = if (binding.bidiFilenameContainer.isVisible) { + binding.bidiMore + } else { + binding.more + } override val fileFeaturesLayout: LinearLayout get() = binding.fileFeaturesLayout override val gridLivePhotoIndicator: ImageView diff --git a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java index 60b34db026e6..1850a9c8f855 100644 --- a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java @@ -26,6 +26,7 @@ import android.app.Activity; import android.content.Context; import android.content.Intent; +import android.content.res.Configuration; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Color; @@ -685,6 +686,14 @@ public static float convertPixelToDp(int px, Context context) { return px * (DisplayMetrics.DENSITY_DEFAULT / (float) metrics.densityDpi); } + public static boolean isRTL() { + return TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == View.LAYOUT_DIRECTION_RTL; + } + + public static boolean isOrientationLandscape() { + return MainApp.getAppContext().getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + } + static public void showServerOutdatedSnackbar(Activity activity, int length) { Snackbar.make(activity.findViewById(android.R.id.content), R.string.outdated_server, length) diff --git a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java index e1106e09d12f..550b0e855e21 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java @@ -27,15 +27,19 @@ import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.lib.resources.files.model.RemoteFile; -import com.owncloud.android.lib.resources.shares.ShareeUser; import com.owncloud.android.ui.helpers.FileOperationsHelper; +import org.apache.commons.io.FilenameUtils; + import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; @@ -53,6 +57,7 @@ import androidx.annotation.VisibleForTesting; import androidx.core.app.ActivityCompat; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import kotlin.Pair; /** * Static methods to help in access to local file system. @@ -69,6 +74,56 @@ private FileStorageUtils() { // utility class -> private constructor } + public static boolean containsBidiControlCharacters(String filename) { + if (filename == null) return false; + + String decoded; + try { + decoded = URLDecoder.decode(filename, StandardCharsets.UTF_8.toString()); + } catch (UnsupportedEncodingException e) { + return false; + } + + int[] bidiControlCharacters = { + 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, + 0x200E, 0x200F, 0x2066, 0x2067, 0x2068, + 0x2069, 0x061C + }; + + for (int i = 0; i < decoded.length(); i++) { + int codePoint = decoded.codePointAt(i); + for (int chars : bidiControlCharacters) { + if (codePoint == chars) { + return true; + } + } + } + + for (char c : decoded.toCharArray()) { + if (c < 32) return true; + } + + return false; + } + + public static Pair getFilenameAndExtension(String filename, boolean isFolder, boolean isRTL) { + if (isFolder) { + return new Pair<>(filename, ""); + } + + final String base = FilenameUtils.getBaseName(filename); + String extension = FilenameUtils.getExtension(filename); + if (!extension.isEmpty()) { + extension = StringConstants.DOT + extension; + } + + if (isRTL) { + return new Pair<>(extension, base); + } else { + return new Pair<>(base, extension); + } + } + public static boolean isValidExtFilename(String name) { for (int i = 0; i < name.length(); i++) { char c = name.charAt(i); diff --git a/app/src/main/res/layout/file_actions_bottom_sheet.xml b/app/src/main/res/layout/file_actions_bottom_sheet.xml index 92c93182a241..cf2c493069b9 100644 --- a/app/src/main/res/layout/file_actions_bottom_sheet.xml +++ b/app/src/main/res/layout/file_actions_bottom_sheet.xml @@ -40,7 +40,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginBottom="@dimen/standard_margin" - android:gravity="center" + android:gravity="start" android:orientation="horizontal" android:visibility="visible"> @@ -53,13 +53,22 @@ + + + tools:text="@string/placeholder_extension" /> diff --git a/app/src/main/res/layout/grid_item.xml b/app/src/main/res/layout/grid_item.xml index 6b458c334f16..ae065d33d73a 100644 --- a/app/src/main/res/layout/grid_item.xml +++ b/app/src/main/res/layout/grid_item.xml @@ -8,7 +8,6 @@ ~ SPDX-FileCopyrightText: 2015 ownCloud Inc. ~ SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) --> - - - - + + + + + + + + + + + + + + + tools:ignore="TouchTargetSizeCheck" + tools:visibility="visible" /> + \ No newline at end of file diff --git a/app/src/main/res/layout/list_item.xml b/app/src/main/res/layout/list_item.xml index aa2632ed7e46..fdc82c874448 100644 --- a/app/src/main/res/layout/list_item.xml +++ b/app/src/main/res/layout/list_item.xml @@ -77,15 +77,31 @@ android:orientation="vertical" android:paddingTop="@dimen/standard_half_padding"> - + android:orientation="horizontal"> + + + + + 100dp 100dp 4dp + + 20dp 10dp 2dp 22sp + 2dp + 4dp + 18dp + 6dp + 130dp + 120dp + 80dp + 140dp 180dp 2dp @@ -100,13 +110,6 @@ 48dp 24dp 24dp - 2dp - 4dp - 18dp - 6dp - 20dp - 130dp - 120dp 21dp -8dp 40dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63f0a73f3a3b..523c4f26b3bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -449,7 +449,8 @@ - No information about the error This is a placeholder - placeholder.txt + placeholder + .txt 389 KB 2012/05/18 12:23 PM 12:23:45 diff --git a/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt b/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt index c6f223426328..f01451085400 100644 --- a/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt +++ b/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt @@ -16,7 +16,9 @@ import org.junit.Test import java.io.File import java.util.Locale +@Suppress("TooManyFunctions") class FileStorageUtilsTest { + @Test fun testValidFilenames() { assertTrue(FileStorageUtils.isValidExtFilename("example.txt")) @@ -207,4 +209,99 @@ class FileStorageUtilsTest { assertEquals(expected, result) } + + @Test + fun testGetFilenameAndExtensionWhenGivenInvalidFilenamesWithSpecialChars() { + val result = FileStorageUtils.getFilenameAndExtension("invoice\u202Ecod.exe", false, false) + assertEquals("invoice\u202Ecod", result.first) + assertEquals(".exe", result.second) + } + + @Test + fun testGetFilenameAndExtensionWhenGivenMultipleDotsInFilename() { + val result = FileStorageUtils.getFilenameAndExtension("archive.tar.gz", false, false) + assertEquals("archive.tar", result.first) + assertEquals(".gz", result.second) + } + + @Test + fun testGetFilenameAndExtensionWhenGivenFolderName() { + val result = FileStorageUtils.getFilenameAndExtension("myFolder", true, false) + assertEquals("myFolder", result.first) + assertEquals("", result.second) + } + + @Test + fun testGetFilenameAndExtensionWhenGivenNormalFile() { + val result = FileStorageUtils.getFilenameAndExtension("document.txt", false, false) + assertEquals("document", result.first) + assertEquals(".txt", result.second) + } + + @Test + fun testGetFilenameAndExtensionRTL() { + val result = FileStorageUtils.getFilenameAndExtension("document.txt", false, true) + assertEquals(".txt", result.first) + assertEquals("document", result.second) + } + + @Test + fun testGetFilenameAndExtensionRTLEmptyExtension() { + val result = FileStorageUtils.getFilenameAndExtension("document", false, true) + assertEquals("", result.first) + assertEquals("document", result.second) + } + + @Test + fun testGetFilenameAndExtensionEmptyExtension() { + val result = FileStorageUtils.getFilenameAndExtension("document", false, false) + assertEquals("document", result.first) + assertEquals("", result.second) + } + + @Test + fun testGetFilenameAndExtensionWithRLO() { + val filename = "Foo\u202Edm.exe" + val result = FileStorageUtils.getFilenameAndExtension(filename, false, false) + + // we are not touching the filename that's why expected filename must stay same + assertEquals("Foo\u202Edm", result.first) + assertEquals(".exe", result.second) + } + + @Test + fun testContainsBidiControlCharactersWhenGivenNormalFilenameShouldReturnFalse() { + val result = FileStorageUtils.containsBidiControlCharacters("myfile.txt") + assertFalse(result) + } + + @Test + fun testContainsBidiControlCharactersWhenGivenFilenameWithEncodedRtlOverrideShouldReturnTrue() { + val result = FileStorageUtils.containsBidiControlCharacters("myfile%e2%80%aetxt.exe") + assertTrue(result) + } + + @Test + fun testContainsBidiControlCharactersWhenGivenFilenameWithRawRtlOverrideCharShouldReturnTrue() { + val result = FileStorageUtils.containsBidiControlCharacters("safe\u202Enotsafe.exe") + assertTrue(result) + } + + @Test + fun testContainsBidiControlCharactersWhenGivenFilenameWithControlCharacterShouldReturnTrue() { + val result = FileStorageUtils.containsBidiControlCharacters("hello\u0001world.txt") + assertTrue(result) + } + + @Test + fun testContainsBidiControlCharactersWhenGivenNullShouldReturnFalse() { + val result = FileStorageUtils.containsBidiControlCharacters(null) + assertFalse(result) + } + + @Test + fun testContainsBidiControlCharactersWhenGivenValidFilenameShouldReturnTrue() { + val result = FileStorageUtils.containsBidiControlCharacters("/Foo%e2%80%aedm.exe") + assertTrue(result) + } }