Forráskód Böngészése

Merge pull request #10052 from nextcloud/file_locking

File locking
Álvaro Brey 3 éve
szülő
commit
f0b77e5751
26 módosított fájl, 1039 hozzáadás és 243 törlés
  1. 1 0
      app/build.gradle
  2. 6 2
      app/detekt.yml
  3. 1 1
      app/src/androidTest/java/com/owncloud/android/datamodel/ContentResolverHelperIT.kt
  4. 211 0
      app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt
  5. 38 0
      app/src/main/java/com/nextcloud/android/files/FileLockingHelper.kt
  6. 78 0
      app/src/main/java/com/nextcloud/android/files/FileLockingMenuCustomization.kt
  7. 42 0
      app/src/main/java/com/nextcloud/android/files/ThemedPopupMenu.kt
  8. 26 0
      app/src/main/java/com/nextcloud/utils/TimeConstants.kt
  9. 83 196
      app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java
  10. 109 2
      app/src/main/java/com/owncloud/android/datamodel/OCFile.java
  11. 19 3
      app/src/main/java/com/owncloud/android/db/ProviderMeta.java
  12. 94 15
      app/src/main/java/com/owncloud/android/files/FileMenuFilter.java
  13. 44 2
      app/src/main/java/com/owncloud/android/providers/FileContentProvider.java
  14. 6 0
      app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java
  15. 23 0
      app/src/main/java/com/owncloud/android/ui/events/FileLockEvent.kt
  16. 50 5
      app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java
  17. 7 0
      app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java
  18. 8 0
      app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
  19. 25 0
      app/src/main/res/color/menu_item_text_color.xml
  20. 33 0
      app/src/main/res/drawable/ic_locked_dots_small.xml
  21. 6 13
      app/src/main/res/layout/list_item.xml
  22. 1 1
      app/src/main/res/layout/toolbar_standard.xml
  23. 40 3
      app/src/main/res/menu/item_file.xml
  24. 6 0
      app/src/main/res/values/strings.xml
  25. 8 0
      app/src/main/res/values/styles.xml
  26. 74 0
      app/src/test/java/com/nextcloud/android/files/FileLockingHelperTest.kt

+ 1 - 0
app/build.gradle

@@ -318,6 +318,7 @@ dependencies {
     // Android JUnit Runner
     androidTestImplementation "androidx.test:runner:$androidxTestVersion"
     androidTestUtil "androidx.test:orchestrator:$androidxTestVersion"
+    androidTestImplementation "androidx.test:core-ktx:$androidxTestVersion"
 
     // Espresso
     androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"

+ 6 - 2
app/detekt.yml

@@ -202,7 +202,9 @@ naming:
     functionPattern: '^([a-z$][a-zA-Z$0-9]*)|(`.*`)$'
     excludeClassPattern: '$^'
     ignoreOverridden: true
-    excludes: "**/*Test.kt"
+    excludes:
+      - "**/*Test.kt"
+      - "**/*IT.kt"
   FunctionParameterNaming:
     active: true
     parameterPattern: '[a-z][A-Za-z0-9]*'
@@ -324,7 +326,9 @@ style:
     ignoreAnnotation: false
     ignoreNamedArgument: true
     ignoreEnums: false
-    excludes: "**/*Test.kt"
+    excludes:
+      - "**/*Test.kt"
+      - "**/*IT.kt"
   MandatoryBracesIfStatements:
     active: false
   MaxLineLength:

+ 1 - 1
app/src/androidTest/java/com/owncloud/android/datamodel/ContentResolverHelperTest.kt → app/src/androidTest/java/com/owncloud/android/datamodel/ContentResolverHelperIT.kt

@@ -36,7 +36,7 @@ import org.mockito.kotlin.eq
 import org.mockito.kotlin.verify
 
 @RunWith(AndroidJUnit4::class)
-class ContentResolverHelperTest {
+class ContentResolverHelperIT {
 
     companion object {
         private val URI = Uri.parse("http://foo.bar")

+ 211 - 0
app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt

@@ -0,0 +1,211 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud 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 <https://www.gnu.org/licenses/>.
+ */
+package com.owncloud.android.files
+
+import android.view.Menu
+import androidx.appcompat.view.menu.MenuBuilder
+import androidx.test.core.app.launchActivity
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.nextcloud.client.TestActivity
+import com.nextcloud.client.account.User
+import com.owncloud.android.AbstractIT
+import com.owncloud.android.R
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.files.services.FileDownloader
+import com.owncloud.android.files.services.FileUploader
+import com.owncloud.android.lib.resources.files.model.FileLockType
+import com.owncloud.android.lib.resources.status.CapabilityBooleanType
+import com.owncloud.android.lib.resources.status.OCCapability
+import com.owncloud.android.services.OperationsService
+import com.owncloud.android.ui.activity.ComponentsGetter
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class FileMenuFilterIT : AbstractIT() {
+
+    @MockK
+    private lateinit var mockComponentsGetter: ComponentsGetter
+
+    @MockK
+    private lateinit var mockStorageManager: FileDataStorageManager
+
+    @MockK
+    private lateinit var mockFileUploaderBinder: FileUploader.FileUploaderBinder
+
+    @MockK
+    private lateinit var mockFileDownloaderBinder: FileDownloader.FileDownloaderBinder
+
+    @MockK
+    private lateinit var mockOperationsServiceBinder: OperationsService.OperationsServiceBinder
+
+    @Before
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { mockFileUploaderBinder.isUploading(any(), any()) } returns false
+        every { mockComponentsGetter.fileUploaderBinder } returns mockFileUploaderBinder
+        every { mockFileDownloaderBinder.isDownloading(any(), any()) } returns false
+        every { mockComponentsGetter.fileDownloaderBinder } returns mockFileDownloaderBinder
+        every { mockOperationsServiceBinder.isSynchronizing(any(), any()) } returns false
+        every { mockComponentsGetter.operationsServiceBinder } returns mockOperationsServiceBinder
+    }
+
+    @Test
+    fun filter_noLockingCapability_lockItemsInvisible() {
+        val capability = OCCapability().apply {
+            endToEndEncryption = CapabilityBooleanType.UNKNOWN
+        }
+
+        val file = OCFile("/foo.md")
+
+        testLockingVisibilities(
+            capability,
+            file,
+            ExpectedLockVisibilities(lockFile = false, unlockFile = false, lockedBy = false, lockedUntil = false)
+        )
+    }
+
+    @Test
+    fun filter_lockingCapability_fileUnlocked_lockVisible() {
+        val capability = OCCapability().apply {
+            endToEndEncryption = CapabilityBooleanType.UNKNOWN
+            filesLockingVersion = "1.0"
+        }
+
+        val file = OCFile("/foo.md")
+
+        testLockingVisibilities(
+            capability,
+            file,
+            ExpectedLockVisibilities(lockFile = true, unlockFile = false, lockedBy = false, lockedUntil = false)
+        )
+    }
+
+    @Test
+    fun filter_lockingCapability_fileLocked_lockedByAndProps() {
+        val capability = OCCapability().apply {
+            endToEndEncryption = CapabilityBooleanType.UNKNOWN
+            filesLockingVersion = "1.0"
+        }
+
+        val file = OCFile("/foo.md").apply {
+            isLocked = true
+            lockType = FileLockType.MANUAL
+            lockOwnerId = user.accountName.split("@")[0]
+            lockOwnerDisplayName = "TEST"
+            lockTimestamp = 1000 // irrelevant
+            lockTimeout = 1000 // irrelevant
+        }
+
+        testLockingVisibilities(
+            capability,
+            file,
+            ExpectedLockVisibilities(lockFile = false, unlockFile = true, lockedBy = true, lockedUntil = true)
+        )
+    }
+
+    @Test
+    fun filter_lockingCapability_fileLockedByOthers_lockedByAndProps() {
+        val capability = OCCapability().apply {
+            endToEndEncryption = CapabilityBooleanType.UNKNOWN
+            filesLockingVersion = "1.0"
+        }
+
+        val file = OCFile("/foo.md").apply {
+            isLocked = true
+            lockType = FileLockType.MANUAL
+            lockOwnerId = "A_DIFFERENT_USER"
+            lockOwnerDisplayName = "A_DIFFERENT_USER"
+            lockTimestamp = 1000 // irrelevant
+            lockTimeout = 1000 // irrelevant
+        }
+        testLockingVisibilities(
+            capability,
+            file,
+            ExpectedLockVisibilities(lockFile = false, unlockFile = false, lockedBy = true, lockedUntil = true)
+        )
+    }
+
+    private data class ExpectedLockVisibilities(
+        val lockFile: Boolean,
+        val unlockFile: Boolean,
+        val lockedBy: Boolean,
+        val lockedUntil: Boolean
+    )
+
+    private fun configureCapability(capability: OCCapability) {
+        every { mockStorageManager.getCapability(any<User>()) } returns capability
+        every { mockStorageManager.getCapability(any<String>()) } returns capability
+        every { mockComponentsGetter.storageManager } returns mockStorageManager
+    }
+
+    private fun getMenu(activity: TestActivity): Menu {
+        val inflater = activity.menuInflater
+        val menu = MenuBuilder(activity)
+        inflater.inflate(R.menu.item_file, menu)
+        return menu
+    }
+
+    private fun testLockingVisibilities(
+        capability: OCCapability,
+        file: OCFile,
+        expectedLockVisibilities: ExpectedLockVisibilities
+    ) {
+        configureCapability(capability)
+
+        launchActivity<TestActivity>().use {
+            it.onActivity { activity ->
+                val menu = getMenu(activity)
+
+                val sut = FileMenuFilter(file, mockComponentsGetter, activity, true, user)
+
+                sut.filter(menu, false)
+
+                Assert.assertEquals(
+                    expectedLockVisibilities.lockFile,
+                    menu.findItem(R.id.action_lock_file).isVisible
+                )
+                Assert.assertEquals(
+                    expectedLockVisibilities.unlockFile,
+                    menu.findItem(R.id.action_unlock_file).isVisible
+                )
+                Assert.assertEquals(
+                    expectedLockVisibilities.lockedBy,
+                    menu.findItem(R.id.action_locked_by).isVisible
+                )
+                Assert.assertEquals(
+                    expectedLockVisibilities.lockedUntil,
+                    menu.findItem(R.id.action_locked_until).isVisible
+                )
+
+                // locked by and until should always be disabled, they're not real actions
+                Assert.assertFalse(menu.findItem(R.id.action_locked_by).isEnabled)
+                Assert.assertFalse(menu.findItem(R.id.action_locked_until).isEnabled)
+            }
+        }
+    }
+}

+ 38 - 0
app/src/main/java/com/nextcloud/android/files/FileLockingHelper.kt

@@ -0,0 +1,38 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud 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 <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.android.files
+
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.resources.files.model.FileLockType
+
+object FileLockingHelper {
+    /**
+     * Checks whether the given `userId` can unlock the [OCFile].
+     */
+    @JvmStatic
+    fun canUserUnlockFile(userId: String, file: OCFile): Boolean {
+        if (!file.isLocked || file.lockOwnerId == null || file.lockType != FileLockType.MANUAL) {
+            return false
+        }
+        return file.lockOwnerId == userId
+    }
+}

+ 78 - 0
app/src/main/java/com/nextcloud/android/files/FileLockingMenuCustomization.kt

@@ -0,0 +1,78 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud 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 <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.android.files
+
+import android.content.Context
+import android.graphics.Typeface
+import android.os.Build
+import android.text.style.StyleSpan
+import android.view.Menu
+import android.view.MenuItem
+import com.nextcloud.utils.TimeConstants
+import com.owncloud.android.R
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.resources.files.model.FileLockType
+import com.owncloud.android.utils.DisplayUtils
+
+/**
+ * Customizes a Menu to show locking information
+ */
+class FileLockingMenuCustomization(val context: Context) {
+    fun customizeMenu(menu: Menu, file: OCFile) {
+        if (file.isLocked) {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+                menu.setGroupDividerEnabled(true)
+            }
+            menu.findItem(R.id.action_locked_by).title = getLockedByText(file)
+            showLockedUntil(menu, file)
+        }
+    }
+
+    private fun getLockedByText(file: OCFile): CharSequence {
+        val username = file.lockOwnerDisplayName ?: file.lockOwnerId
+        val resource = when (file.lockType) {
+            FileLockType.COLLABORATIVE -> R.string.locked_by_app
+            else -> R.string.locked_by
+        }
+        return DisplayUtils.createTextWithSpan(
+            context.getString(resource, username),
+            username,
+            StyleSpan(Typeface.BOLD)
+        )
+    }
+
+    private fun showLockedUntil(menu: Menu, file: OCFile) {
+        val lockedUntil: MenuItem = menu.findItem(R.id.action_locked_until)
+        if (file.lockTimestamp == 0L || file.lockTimeout == 0L) {
+            lockedUntil.isVisible = false
+        } else {
+            lockedUntil.title =
+                context.getString(R.string.lock_expiration_info, getExpirationRelativeText(file))
+            lockedUntil.isVisible = true
+        }
+    }
+
+    private fun getExpirationRelativeText(file: OCFile): CharSequence? {
+        val expirationTimestamp = (file.lockTimestamp + file.lockTimeout) * TimeConstants.MILLIS_PER_SECOND
+        return DisplayUtils.getRelativeTimestamp(context, expirationTimestamp, true)
+    }
+}

+ 42 - 0
app/src/main/java/com/nextcloud/android/files/ThemedPopupMenu.kt

@@ -0,0 +1,42 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud 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 <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.android.files
+
+import android.content.Context
+import android.view.ContextThemeWrapper
+import android.view.View
+import androidx.appcompat.widget.PopupMenu
+import com.owncloud.android.R
+
+/**
+ * This is a [PopupMenu] with grayed out disabled elements
+ */
+class ThemedPopupMenu(
+    context: Context,
+    anchor: View
+) : PopupMenu(wrapContext(context), anchor) {
+
+    companion object {
+        private fun wrapContext(context: Context): Context =
+            ContextThemeWrapper(context, R.style.Nextcloud_Widget_PopupMenu)
+    }
+}

+ 26 - 0
app/src/main/java/com/nextcloud/utils/TimeConstants.kt

@@ -0,0 +1,26 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud 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 <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.utils
+
+object TimeConstants {
+    const val MILLIS_PER_SECOND = 1000
+}

+ 83 - 196
app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java

@@ -45,6 +45,7 @@ import com.owncloud.android.lib.common.network.WebdavEntry;
 import com.owncloud.android.lib.common.operations.RemoteOperationResult;
 import com.owncloud.android.lib.common.utils.Log_OC;
 import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation;
+import com.owncloud.android.lib.resources.files.model.FileLockType;
 import com.owncloud.android.lib.resources.files.model.RemoteFile;
 import com.owncloud.android.lib.resources.shares.OCShare;
 import com.owncloud.android.lib.resources.shares.ShareType;
@@ -70,6 +71,7 @@ import java.util.Set;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.VisibleForTesting;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 
 
 public class FileDataStorageManager {
@@ -83,9 +85,9 @@ public class FileDataStorageManager {
     public static final int ROOT_PARENT_ID = 0;
     public static final String NULL_STRING = "null";
 
-    private ContentResolver contentResolver;
-    private ContentProviderClient contentProviderClient;
-    private User user;
+    private final ContentResolver contentResolver;
+    private final ContentProviderClient contentProviderClient;
+    private final User user;
 
     public FileDataStorageManager(User user, ContentResolver contentResolver) {
         this.contentProviderClient = null;
@@ -208,44 +210,10 @@ public class FileDataStorageManager {
 
     public boolean saveFile(OCFile ocFile) {
         boolean overridden = false;
-        ContentValues cv = new ContentValues();
-        cv.put(ProviderTableMeta.FILE_MODIFIED, ocFile.getModificationTimestamp());
-        cv.put(
-            ProviderTableMeta.FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA,
-            ocFile.getModificationTimestampAtLastSyncForData()
-        );
-        cv.put(ProviderTableMeta.FILE_CREATION, ocFile.getCreationTimestamp());
-        cv.put(ProviderTableMeta.FILE_CONTENT_LENGTH, ocFile.getFileLength());
-        cv.put(ProviderTableMeta.FILE_CONTENT_TYPE, ocFile.getMimeType());
-        cv.put(ProviderTableMeta.FILE_NAME, ocFile.getFileName());
-        cv.put(ProviderTableMeta.FILE_ENCRYPTED_NAME, ocFile.getEncryptedFileName());
-        cv.put(ProviderTableMeta.FILE_PARENT, ocFile.getParentId());
-        cv.put(ProviderTableMeta.FILE_PATH, ocFile.getRemotePath());
-        cv.put(ProviderTableMeta.FILE_PATH_DECRYPTED, ocFile.getDecryptedRemotePath());
-        cv.put(ProviderTableMeta.FILE_IS_ENCRYPTED, ocFile.isEncrypted());
-        if (!ocFile.isFolder()) {
-            cv.put(ProviderTableMeta.FILE_STORAGE_PATH, ocFile.getStoragePath());
+        final ContentValues cv = createContentValuesForFile(ocFile);
+        if (ocFile.isFolder()) {
+            cv.remove(ProviderTableMeta.FILE_STORAGE_PATH);
         }
-        cv.put(ProviderTableMeta.FILE_ACCOUNT_OWNER, user.getAccountName());
-        cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE, ocFile.getLastSyncDateForProperties());
-        cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE_FOR_DATA, ocFile.getLastSyncDateForData());
-        cv.put(ProviderTableMeta.FILE_ETAG, ocFile.getEtag());
-        cv.put(ProviderTableMeta.FILE_ETAG_ON_SERVER, ocFile.getEtagOnServer());
-        cv.put(ProviderTableMeta.FILE_SHARED_VIA_LINK, ocFile.isSharedViaLink() ? 1 : 0);
-        cv.put(ProviderTableMeta.FILE_SHARED_WITH_SHAREE, ocFile.isSharedWithSharee() ? 1 : 0);
-        cv.put(ProviderTableMeta.FILE_PERMISSIONS, ocFile.getPermissions());
-        cv.put(ProviderTableMeta.FILE_REMOTE_ID, ocFile.getRemoteId());
-        cv.put(ProviderTableMeta.FILE_UPDATE_THUMBNAIL, ocFile.isUpdateThumbnailNeeded());
-        cv.put(ProviderTableMeta.FILE_IS_DOWNLOADING, ocFile.isDownloading());
-        cv.put(ProviderTableMeta.FILE_ETAG_IN_CONFLICT, ocFile.getEtagInConflict());
-        cv.put(ProviderTableMeta.FILE_UNREAD_COMMENTS_COUNT, ocFile.getUnreadCommentsCount());
-        cv.put(ProviderTableMeta.FILE_OWNER_ID, ocFile.getOwnerId());
-        cv.put(ProviderTableMeta.FILE_OWNER_DISPLAY_NAME, ocFile.getOwnerDisplayName());
-        cv.put(ProviderTableMeta.FILE_NOTE, ocFile.getNote());
-        cv.put(ProviderTableMeta.FILE_SHAREES, new Gson().toJson(ocFile.getSharees()));
-        cv.put(ProviderTableMeta.FILE_RICH_WORKSPACE, ocFile.getRichWorkspace());
-        cv.put(ProviderTableMeta.FILE_HAS_PREVIEW, ocFile.isPreviewAvailable() ? 1 : 0);
-        cv.put(ProviderTableMeta.FILE_FAVORITE, ocFile.isFavorite());
 
         boolean sameRemotePath = fileExists(ocFile.getRemotePath());
         if (sameRemotePath ||
@@ -369,7 +337,8 @@ public class FileDataStorageManager {
 
         // prepare operations to insert or update files to save in the given folder
         for (OCFile ocFile : updatedFiles) {
-            ContentValues contentValues = createContentValueForFile(ocFile, folder);
+            ContentValues contentValues = createContentValuesForFile(ocFile);
+            contentValues.put(ProviderTableMeta.FILE_PARENT, folder.getFileId());
 
             if (fileExists(ocFile.getFileId()) || fileExists(ocFile.getRemotePath())) {
                 long fileId;
@@ -423,7 +392,7 @@ public class FileDataStorageManager {
         }
 
         // update metadata of folder
-        ContentValues contentValues = createContentValueForFile(folder);
+        ContentValues contentValues = createContentValuesForFolder(folder);
 
         operations.add(ContentProviderOperation.newUpdate(ProviderTableMeta.CONTENT_URI)
                            .withValues(contentValues)
@@ -467,76 +436,76 @@ public class FileDataStorageManager {
         }
     }
 
-    private ContentValues createContentValueForFile(OCFile folder) {
-        ContentValues cv = new ContentValues();
-        cv.put(ProviderTableMeta.FILE_MODIFIED, folder.getModificationTimestamp());
-        cv.put(
-            ProviderTableMeta.FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA,
-            folder.getModificationTimestampAtLastSyncForData()
-        );
-        cv.put(ProviderTableMeta.FILE_CREATION, folder.getCreationTimestamp());
-        cv.put(ProviderTableMeta.FILE_CONTENT_LENGTH, 0);
-        cv.put(ProviderTableMeta.FILE_CONTENT_TYPE, folder.getMimeType());
-        cv.put(ProviderTableMeta.FILE_NAME, folder.getFileName());
-        cv.put(ProviderTableMeta.FILE_PARENT, folder.getParentId());
-        cv.put(ProviderTableMeta.FILE_PATH, folder.getRemotePath());
-        cv.put(ProviderTableMeta.FILE_PATH_DECRYPTED, folder.getDecryptedRemotePath());
+    /**
+     * Returns a {@link ContentValues} filled with values that are common to both files and folders
+     * @see #createContentValuesForFile(OCFile)
+     * @see #createContentValuesForFolder(OCFile)
+     */
+    private ContentValues createContentValuesBase(OCFile fileOrFolder) {
+        final ContentValues cv = new ContentValues();
+        cv.put(ProviderTableMeta.FILE_MODIFIED, fileOrFolder.getModificationTimestamp());
+        cv.put(ProviderTableMeta.FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA, fileOrFolder.getModificationTimestampAtLastSyncForData());
+        cv.put(ProviderTableMeta.FILE_PARENT, fileOrFolder.getParentId());
+        cv.put(ProviderTableMeta.FILE_CREATION, fileOrFolder.getCreationTimestamp());
+        cv.put(ProviderTableMeta.FILE_CONTENT_TYPE, fileOrFolder.getMimeType());
+        cv.put(ProviderTableMeta.FILE_NAME, fileOrFolder.getFileName());
+        cv.put(ProviderTableMeta.FILE_PATH, fileOrFolder.getRemotePath());
+        cv.put(ProviderTableMeta.FILE_PATH_DECRYPTED, fileOrFolder.getDecryptedRemotePath());
         cv.put(ProviderTableMeta.FILE_ACCOUNT_OWNER, user.getAccountName());
-        cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE, folder.getLastSyncDateForProperties());
-        cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE_FOR_DATA, folder.getLastSyncDateForData());
-        cv.put(ProviderTableMeta.FILE_ETAG, folder.getEtag());
-        cv.put(ProviderTableMeta.FILE_ETAG_ON_SERVER, folder.getEtagOnServer());
-        cv.put(ProviderTableMeta.FILE_SHARED_VIA_LINK, folder.isSharedViaLink() ? 1 : 0);
-        cv.put(ProviderTableMeta.FILE_SHARED_WITH_SHAREE, folder.isSharedWithSharee() ? 1 : 0);
-        cv.put(ProviderTableMeta.FILE_PERMISSIONS, folder.getPermissions());
-        cv.put(ProviderTableMeta.FILE_REMOTE_ID, folder.getRemoteId());
-        cv.put(ProviderTableMeta.FILE_FAVORITE, folder.isFavorite());
-        cv.put(ProviderTableMeta.FILE_IS_ENCRYPTED, folder.isEncrypted());
-        cv.put(ProviderTableMeta.FILE_UNREAD_COMMENTS_COUNT, folder.getUnreadCommentsCount());
-        cv.put(ProviderTableMeta.FILE_OWNER_ID, folder.getOwnerId());
-        cv.put(ProviderTableMeta.FILE_OWNER_DISPLAY_NAME, folder.getOwnerDisplayName());
-        cv.put(ProviderTableMeta.FILE_NOTE, folder.getNote());
-        cv.put(ProviderTableMeta.FILE_SHAREES, new Gson().toJson(folder.getSharees()));
-        cv.put(ProviderTableMeta.FILE_RICH_WORKSPACE, folder.getRichWorkspace());
+        cv.put(ProviderTableMeta.FILE_IS_ENCRYPTED, fileOrFolder.isEncrypted());
+        cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE, fileOrFolder.getLastSyncDateForProperties());
+        cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE_FOR_DATA, fileOrFolder.getLastSyncDateForData());
+        cv.put(ProviderTableMeta.FILE_ETAG, fileOrFolder.getEtag());
+        cv.put(ProviderTableMeta.FILE_ETAG_ON_SERVER, fileOrFolder.getEtagOnServer());
+        cv.put(ProviderTableMeta.FILE_SHARED_VIA_LINK, fileOrFolder.isSharedViaLink() ? 1 : 0);
+        cv.put(ProviderTableMeta.FILE_SHARED_WITH_SHAREE, fileOrFolder.isSharedWithSharee() ? 1 : 0);
+        cv.put(ProviderTableMeta.FILE_PERMISSIONS, fileOrFolder.getPermissions());
+        cv.put(ProviderTableMeta.FILE_REMOTE_ID, fileOrFolder.getRemoteId());
+        cv.put(ProviderTableMeta.FILE_FAVORITE, fileOrFolder.isFavorite());
+        cv.put(ProviderTableMeta.FILE_UNREAD_COMMENTS_COUNT, fileOrFolder.getUnreadCommentsCount());
+        cv.put(ProviderTableMeta.FILE_OWNER_ID, fileOrFolder.getOwnerId());
+        cv.put(ProviderTableMeta.FILE_OWNER_DISPLAY_NAME, fileOrFolder.getOwnerDisplayName());
+        cv.put(ProviderTableMeta.FILE_NOTE, fileOrFolder.getNote());
+        cv.put(ProviderTableMeta.FILE_SHAREES, new Gson().toJson(fileOrFolder.getSharees()));
+        cv.put(ProviderTableMeta.FILE_RICH_WORKSPACE, fileOrFolder.getRichWorkspace());
+        return cv;
+    }
 
+    /**
+     * Returns a {@link ContentValues} filled with values for a folder
+     * @see #createContentValuesForFile(OCFile)
+     * @see #createContentValuesBase(OCFile)
+     */
+    private ContentValues createContentValuesForFolder(OCFile folder) {
+        final ContentValues cv = createContentValuesBase(folder);
+        cv.put(ProviderTableMeta.FILE_CONTENT_LENGTH, 0);
         return cv;
     }
 
-    private ContentValues createContentValueForFile(OCFile file, OCFile folder) {
-        ContentValues cv = new ContentValues();
-        cv.put(ProviderTableMeta.FILE_MODIFIED, file.getModificationTimestamp());
-        cv.put(ProviderTableMeta.FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA, file.getModificationTimestampAtLastSyncForData());
-        cv.put(ProviderTableMeta.FILE_CREATION, file.getCreationTimestamp());
+    /**
+     * Returns a {@link ContentValues} filled with values for a file
+     * @see #createContentValuesForFolder(OCFile)
+     * @see #createContentValuesBase(OCFile)
+     */
+    @SuppressFBWarnings("NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE")
+    private ContentValues createContentValuesForFile(OCFile file) {
+        final ContentValues cv = createContentValuesBase(file);
         cv.put(ProviderTableMeta.FILE_CONTENT_LENGTH, file.getFileLength());
-        cv.put(ProviderTableMeta.FILE_CONTENT_TYPE, file.getMimeType());
-        cv.put(ProviderTableMeta.FILE_NAME, file.getFileName());
         cv.put(ProviderTableMeta.FILE_ENCRYPTED_NAME, file.getEncryptedFileName());
-        cv.put(ProviderTableMeta.FILE_PARENT, folder.getFileId());
-        cv.put(ProviderTableMeta.FILE_PATH, file.getRemotePath());
-        cv.put(ProviderTableMeta.FILE_PATH_DECRYPTED, file.getDecryptedRemotePath());
         cv.put(ProviderTableMeta.FILE_STORAGE_PATH, file.getStoragePath());
-        cv.put(ProviderTableMeta.FILE_ACCOUNT_OWNER, user.getAccountName());
-        cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE, file.getLastSyncDateForProperties());
-        cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE_FOR_DATA, file.getLastSyncDateForData());
-        cv.put(ProviderTableMeta.FILE_ETAG, file.getEtag());
-        cv.put(ProviderTableMeta.FILE_ETAG_ON_SERVER, file.getEtagOnServer());
-        cv.put(ProviderTableMeta.FILE_SHARED_VIA_LINK, file.isSharedViaLink() ? 1 : 0);
-        cv.put(ProviderTableMeta.FILE_SHARED_WITH_SHAREE, file.isSharedWithSharee() ? 1 : 0);
-        cv.put(ProviderTableMeta.FILE_PERMISSIONS, file.getPermissions());
-        cv.put(ProviderTableMeta.FILE_REMOTE_ID, file.getRemoteId());
         cv.put(ProviderTableMeta.FILE_UPDATE_THUMBNAIL, file.isUpdateThumbnailNeeded());
         cv.put(ProviderTableMeta.FILE_IS_DOWNLOADING, file.isDownloading());
         cv.put(ProviderTableMeta.FILE_ETAG_IN_CONFLICT, file.getEtagInConflict());
-        cv.put(ProviderTableMeta.FILE_FAVORITE, file.isFavorite());
-        cv.put(ProviderTableMeta.FILE_IS_ENCRYPTED, file.isEncrypted());
-        cv.put(ProviderTableMeta.FILE_MOUNT_TYPE, file.getMountType().ordinal());
         cv.put(ProviderTableMeta.FILE_HAS_PREVIEW, file.isPreviewAvailable() ? 1 : 0);
-        cv.put(ProviderTableMeta.FILE_UNREAD_COMMENTS_COUNT, file.getUnreadCommentsCount());
-        cv.put(ProviderTableMeta.FILE_OWNER_ID, file.getOwnerId());
-        cv.put(ProviderTableMeta.FILE_OWNER_DISPLAY_NAME, file.getOwnerDisplayName());
-        cv.put(ProviderTableMeta.FILE_NOTE, file.getNote());
-        cv.put(ProviderTableMeta.FILE_SHAREES, new Gson().toJson(file.getSharees()));
-        cv.put(ProviderTableMeta.FILE_RICH_WORKSPACE, file.getRichWorkspace());
+        cv.put(ProviderTableMeta.FILE_LOCKED, file.isLocked());
+        final FileLockType lockType = file.getLockType();
+        cv.put(ProviderTableMeta.FILE_LOCK_TYPE, lockType != null ? lockType.getValue() : -1);
+        cv.put(ProviderTableMeta.FILE_LOCK_OWNER, file.getLockOwnerId());
+        cv.put(ProviderTableMeta.FILE_LOCK_OWNER_DISPLAY_NAME, file.getLockOwnerDisplayName());
+        cv.put(ProviderTableMeta.FILE_LOCK_OWNER_EDITOR, file.getLockOwnerEditor());
+        cv.put(ProviderTableMeta.FILE_LOCK_TIMESTAMP, file.getLockTimestamp());
+        cv.put(ProviderTableMeta.FILE_LOCK_TIMEOUT, file.getLockTimeout());
+        cv.put(ProviderTableMeta.FILE_LOCK_TOKEN, file.getLockToken());
 
         return cv;
     }
@@ -1030,6 +999,16 @@ public class FileDataStorageManager {
             ocFile.setOwnerDisplayName(cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_OWNER_DISPLAY_NAME)));
             ocFile.setNote(cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_NOTE)));
             ocFile.setRichWorkspace(cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_RICH_WORKSPACE)));
+            ocFile.setLocked(cursor.getInt(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_LOCKED)) == 1);
+            final int lockTypeInt = cursor.getInt(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_LOCK_TYPE));
+            ocFile.setLockType(lockTypeInt != -1 ? FileLockType.fromValue(lockTypeInt) : null);
+            ocFile.setLockOwnerId(cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_LOCK_OWNER)));
+            ocFile.setLockOwnerDisplayName(cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_LOCK_OWNER_DISPLAY_NAME)));
+            ocFile.setLockOwnerEditor(cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_LOCK_OWNER_EDITOR)));
+            ocFile.setLockTimestamp(cursor.getInt(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_LOCK_TIMESTAMP)));
+            ocFile.setLockTimeout(cursor.getInt(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_LOCK_TIMEOUT)));
+            ocFile.setLockToken(cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_LOCK_TOKEN)));
+
 
             String sharees = cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_SHAREES));
 
@@ -1424,86 +1403,6 @@ public class FileDataStorageManager {
         }
     }
 
-    public void updateSharedFiles(Collection<OCFile> sharedFiles) {
-        resetShareFlagsInAllFiles();
-
-        if (sharedFiles != null) {
-            ArrayList<ContentProviderOperation> operations = new ArrayList<>(sharedFiles.size());
-
-            // prepare operations to insert or update files to save in the given folder
-            for (OCFile file : sharedFiles) {
-                ContentValues cv = new ContentValues();
-                cv.put(ProviderTableMeta.FILE_MODIFIED, file.getModificationTimestamp());
-                cv.put(
-                    ProviderTableMeta.FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA,
-                    file.getModificationTimestampAtLastSyncForData()
-                );
-                cv.put(ProviderTableMeta.FILE_CREATION, file.getCreationTimestamp());
-                cv.put(ProviderTableMeta.FILE_CONTENT_LENGTH, file.getFileLength());
-                cv.put(ProviderTableMeta.FILE_CONTENT_TYPE, file.getMimeType());
-                cv.put(ProviderTableMeta.FILE_NAME, file.getFileName());
-                cv.put(ProviderTableMeta.FILE_PARENT, file.getParentId());
-                cv.put(ProviderTableMeta.FILE_PATH, file.getRemotePath());
-                if (!file.isFolder()) {
-                    cv.put(ProviderTableMeta.FILE_STORAGE_PATH, file.getStoragePath());
-                }
-                cv.put(ProviderTableMeta.FILE_ACCOUNT_OWNER, user.getAccountName());
-                cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE, file.getLastSyncDateForProperties());
-                cv.put(
-                    ProviderTableMeta.FILE_LAST_SYNC_DATE_FOR_DATA,
-                    file.getLastSyncDateForData()
-                );
-                cv.put(ProviderTableMeta.FILE_ETAG, file.getEtag());
-                cv.put(ProviderTableMeta.FILE_ETAG_ON_SERVER, file.getEtagOnServer());
-                cv.put(ProviderTableMeta.FILE_SHARED_VIA_LINK, file.isSharedViaLink() ? 1 : 0);
-                cv.put(ProviderTableMeta.FILE_SHARED_WITH_SHAREE, file.isSharedWithSharee() ? 1 : 0);
-                cv.put(ProviderTableMeta.FILE_PERMISSIONS, file.getPermissions());
-                cv.put(ProviderTableMeta.FILE_REMOTE_ID, file.getRemoteId());
-                cv.put(ProviderTableMeta.FILE_FAVORITE, file.isFavorite());
-                cv.put(ProviderTableMeta.FILE_UPDATE_THUMBNAIL, file.isUpdateThumbnailNeeded() ? 1 : 0);
-                cv.put(ProviderTableMeta.FILE_IS_DOWNLOADING, file.isDownloading() ? 1 : 0);
-                cv.put(ProviderTableMeta.FILE_ETAG_IN_CONFLICT, file.getEtagInConflict());
-
-                boolean existsByPath = fileExists(file.getRemotePath());
-                if (existsByPath || fileExists(file.getFileId())) {
-                    // updating an existing file
-                    operations.add(
-                        ContentProviderOperation.newUpdate(ProviderTableMeta.CONTENT_URI).
-                            withValues(cv).
-                            withSelection(ProviderTableMeta._ID + "=?",
-                                          new String[]{String.valueOf(file.getFileId())})
-                            .build());
-
-                } else {
-                    // adding a new file
-                    operations.add(
-                        ContentProviderOperation.newInsert(ProviderTableMeta.CONTENT_URI).
-                            withValues(cv).
-                            build()
-                    );
-                }
-            }
-
-            // apply operations in batch
-            if (operations.size() > 0) {
-                @SuppressWarnings("unused")
-                ContentProviderResult[] results = null;
-                Log_OC.d(TAG, String.format(Locale.ENGLISH, SENDING_TO_FILECONTENTPROVIDER_MSG, operations.size()));
-                try {
-                    if (getContentResolver() != null) {
-                        results = getContentResolver().applyBatch(MainApp.getAuthority(), operations);
-                    } else {
-                        results = getContentProviderClient().applyBatch(operations);
-                    }
-
-                } catch (OperationApplicationException | RemoteException e) {
-                    Log_OC.e(TAG, EXCEPTION_MSG + e.getMessage(), e);
-                }
-            }
-        }
-
-    }
-
     public void removeShare(OCShare share) {
         Uri contentUriShare = ProviderTableMeta.CONTENT_URI_SHARE;
         String where = ProviderTableMeta.OCSHARES_ACCOUNT_OWNER + AND +
@@ -2075,6 +1974,8 @@ public class FileDataStorageManager {
         contentValues.put(ProviderTableMeta.CAPABILITIES_USER_STATUS, capability.getUserStatus().getValue());
         contentValues.put(ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_EMOJI,
                           capability.getUserStatusSupportsEmoji().getValue());
+        contentValues.put(ProviderTableMeta.CAPABILITIES_FILES_LOCKING_VERSION,
+                          capability.getFilesLockingVersion());
 
         return contentValues;
     }
@@ -2224,6 +2125,8 @@ public class FileDataStorageManager {
             capability.setUserStatus(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_USER_STATUS));
             capability.setUserStatusSupportsEmoji(
                 getBoolean(cursor, ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_EMOJI));
+            capability.setFilesLockingVersion(
+                getString(cursor, ProviderTableMeta.CAPABILITIES_FILES_LOCKING_VERSION));
         }
         return capability;
     }
@@ -2258,22 +2161,6 @@ public class FileDataStorageManager {
         }
     }
 
-    public void saveVirtual(VirtualFolderType type, OCFile file) {
-        ContentValues cv = new ContentValues();
-        cv.put(ProviderTableMeta.VIRTUAL_TYPE, type.toString());
-        cv.put(ProviderTableMeta.VIRTUAL_OCFILE_ID, file.getFileId());
-
-        if (getContentResolver() != null) {
-            getContentResolver().insert(ProviderTableMeta.CONTENT_URI_VIRTUAL, cv);
-        } else {
-            try {
-                getContentProviderClient().insert(ProviderTableMeta.CONTENT_URI_VIRTUAL, cv);
-            } catch (RemoteException e) {
-                Log_OC.e(TAG, FAILED_TO_INSERT_MSG + e.getMessage(), e);
-            }
-        }
-    }
-
     public List<OCFile> getAllGalleryItems() {
         return getGalleryItems(0, Long.MAX_VALUE);
     }

+ 109 - 2
app/src/main/java/com/owncloud/android/datamodel/OCFile.java

@@ -33,6 +33,7 @@ import com.owncloud.android.R;
 import com.owncloud.android.lib.common.network.WebdavEntry;
 import com.owncloud.android.lib.common.network.WebdavUtils;
 import com.owncloud.android.lib.common.utils.Log_OC;
+import com.owncloud.android.lib.resources.files.model.FileLockType;
 import com.owncloud.android.lib.resources.files.model.ServerFileInterface;
 import com.owncloud.android.lib.resources.shares.ShareeUser;
 import com.owncloud.android.utils.MimeType;
@@ -95,10 +96,23 @@ public class OCFile implements Parcelable, Comparable<OCFile>, ServerFileInterfa
     String note;
     private List<ShareeUser> sharees;
     private String richWorkspace;
+    private boolean locked;
+    @Nullable
+    private FileLockType lockType;
+    @Nullable
+    private String lockOwnerId;
+    @Nullable
+    private String lockOwnerDisplayName;
+    @Nullable
+    private String lockOwnerEditor;
+    private long lockTimestamp;
+    private long lockTimeout;
+    @Nullable
+    private String lockToken;
 
     /**
-     * URI to the local path of the file contents, if stored in the device; cached after first call
-     * to {@link #getStorageUri()}
+     * URI to the local path of the file contents, if stored in the device; cached after first call to {@link
+     * #getStorageUri()}
      */
     private Uri localUri;
 
@@ -162,6 +176,14 @@ public class OCFile implements Parcelable, Comparable<OCFile>, ServerFileInterfa
         richWorkspace = source.readString();
         previewAvailable = source.readInt() == 1;
         firstShareTimestamp = source.readLong();
+        locked = source.readInt() == 1;
+        lockType = FileLockType.fromValue(source.readInt());
+        lockOwnerId = source.readString();
+        lockOwnerDisplayName = source.readString();
+        lockOwnerEditor = source.readString();
+        lockTimestamp = source.readLong();
+        lockTimeout = source.readLong();
+        lockToken = source.readString();
     }
 
     @Override
@@ -196,6 +218,14 @@ public class OCFile implements Parcelable, Comparable<OCFile>, ServerFileInterfa
         dest.writeString(richWorkspace);
         dest.writeInt(previewAvailable ? 1 : 0);
         dest.writeLong(firstShareTimestamp);
+        dest.writeInt(locked ? 1 : 0);
+        dest.writeInt(lockType != null ? lockType.getValue() : -1);
+        dest.writeString(lockOwnerId);
+        dest.writeString(lockOwnerDisplayName);
+        dest.writeString(lockOwnerEditor);
+        dest.writeLong(lockTimestamp);
+        dest.writeLong(lockTimeout);
+        dest.writeString(lockToken);
     }
 
     public void setDecryptedRemotePath(String path) {
@@ -459,6 +489,14 @@ public class OCFile implements Parcelable, Comparable<OCFile>, ServerFileInterfa
         mountType = WebdavEntry.MountType.INTERNAL;
         richWorkspace = "";
         firstShareTimestamp = 0;
+        locked = false;
+        lockType = null;
+        lockOwnerId = null;
+        lockOwnerDisplayName = null;
+        lockOwnerEditor = null;
+        lockTimestamp = 0;
+        lockTimeout = 0;
+        lockToken = null;
     }
 
     /**
@@ -831,4 +869,73 @@ public class OCFile implements Parcelable, Comparable<OCFile>, ServerFileInterfa
     public void setFirstShareTimestamp(long firstShareTimestamp) {
         this.firstShareTimestamp = firstShareTimestamp;
     }
+
+    public boolean isLocked() {
+        return locked;
+    }
+
+    public void setLocked(boolean locked) {
+        this.locked = locked;
+    }
+
+    @Nullable
+    public FileLockType getLockType() {
+        return lockType;
+    }
+
+    public void setLockType(@Nullable FileLockType lockType) {
+        this.lockType = lockType;
+    }
+
+    @Nullable
+    public String getLockOwnerId() {
+        return lockOwnerId;
+    }
+
+    public void setLockOwnerId(@Nullable String lockOwnerId) {
+        this.lockOwnerId = lockOwnerId;
+    }
+
+    @Nullable
+    public String getLockOwnerDisplayName() {
+        return lockOwnerDisplayName;
+    }
+
+    public void setLockOwnerDisplayName(@Nullable String lockOwnerDisplayName) {
+        this.lockOwnerDisplayName = lockOwnerDisplayName;
+    }
+
+    @Nullable
+    public String getLockOwnerEditor() {
+        return lockOwnerEditor;
+    }
+
+    public void setLockOwnerEditor(@Nullable String lockOwnerEditor) {
+        this.lockOwnerEditor = lockOwnerEditor;
+    }
+
+    public long getLockTimestamp() {
+        return lockTimestamp;
+    }
+
+    public void setLockTimestamp(long lockTimestamp) {
+        this.lockTimestamp = lockTimestamp;
+    }
+
+    public long getLockTimeout() {
+        return lockTimeout;
+    }
+
+    public void setLockTimeout(long lockTimeout) {
+        this.lockTimeout = lockTimeout;
+    }
+
+    @Nullable
+    public String getLockToken() {
+        return lockToken;
+    }
+
+    public void setLockToken(@Nullable String lockToken) {
+        this.lockToken = lockToken;
+    }
 }

+ 19 - 3
app/src/main/java/com/owncloud/android/db/ProviderMeta.java

@@ -35,7 +35,7 @@ import java.util.List;
  */
 public class ProviderMeta {
     public static final String DB_NAME = "filelist";
-    public static final int DB_VERSION = 62;
+    public static final int DB_VERSION = 63;
 
     private ProviderMeta() {
         // No instance
@@ -117,6 +117,14 @@ public class ProviderMeta {
         public static final String FILE_NOTE = "note";
         public static final String FILE_SHAREES = "sharees";
         public static final String FILE_RICH_WORKSPACE = "rich_workspace";
+        public static final String FILE_LOCKED = "locked";
+        public static final String FILE_LOCK_TYPE = "lock_type";
+        public static final String FILE_LOCK_OWNER = "lock_owner";
+        public static final String FILE_LOCK_OWNER_DISPLAY_NAME = "lock_owner_display_name";
+        public static final String FILE_LOCK_OWNER_EDITOR = "lock_owner_editor";
+        public static final String FILE_LOCK_TIMESTAMP = "lock_timestamp";
+        public static final String FILE_LOCK_TIMEOUT = "lock_timeout";
+        public static final String FILE_LOCK_TOKEN = "lock_token";
 
         public static final List<String> FILE_ALL_COLUMNS = Collections.unmodifiableList(Arrays.asList(
             _ID,
@@ -153,8 +161,15 @@ public class ProviderMeta {
             FILE_OWNER_DISPLAY_NAME,
             FILE_NOTE,
             FILE_SHAREES,
-            FILE_RICH_WORKSPACE));
-
+            FILE_RICH_WORKSPACE,
+            FILE_LOCKED,
+            FILE_LOCK_TYPE,
+            FILE_LOCK_OWNER,
+            FILE_LOCK_OWNER_DISPLAY_NAME,
+            FILE_LOCK_OWNER_EDITOR,
+            FILE_LOCK_TIMESTAMP,
+            FILE_LOCK_TIMEOUT,
+            FILE_LOCK_TOKEN));
         public static final String FILE_DEFAULT_SORT_ORDER = FILE_NAME + " collate nocase asc";
 
         // Columns of ocshares table
@@ -210,6 +225,7 @@ public class ProviderMeta {
         public static final String CAPABILITIES_FILES_BIGFILECHUNKING = "files_bigfilechunking";
         public static final String CAPABILITIES_FILES_UNDELETE = "files_undelete";
         public static final String CAPABILITIES_FILES_VERSIONING = "files_versioning";
+        public static final String CAPABILITIES_FILES_LOCKING_VERSION = "files_locking_version";
         public static final String CAPABILITIES_EXTERNAL_LINKS = "external_links";
         public static final String CAPABILITIES_SERVER_NAME = "server_name";
         public static final String CAPABILITIES_SERVER_COLOR = "server_color";

+ 94 - 15
app/src/main/java/com/owncloud/android/files/FileMenuFilter.java

@@ -21,12 +21,14 @@
 
 package com.owncloud.android.files;
 
+import android.accounts.AccountManager;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.view.Menu;
 import android.view.MenuItem;
 
 import com.google.gson.Gson;
+import com.nextcloud.android.files.FileLockingHelper;
 import com.nextcloud.client.account.User;
 import com.owncloud.android.R;
 import com.owncloud.android.datamodel.ArbitraryDataProvider;
@@ -58,12 +60,13 @@ public class FileMenuFilter {
     private static final int SINGLE_SELECT_ITEMS = 1;
     public static final String SEND_OFF = "off";
 
-    private int numberOfAllFiles;
-    private Collection<OCFile> files;
-    private ComponentsGetter componentsGetter;
-    private Context context;
-    private boolean overflowMenu;
-    private User user;
+    private final int numberOfAllFiles;
+    private final Collection<OCFile> files;
+    private final ComponentsGetter componentsGetter;
+    private final Context context;
+    private final boolean overflowMenu;
+    private final User user;
+    private final String userId;
 
     /**
      * Constructor
@@ -88,6 +91,10 @@ public class FileMenuFilter {
         this.context = context;
         this.overflowMenu = overflowMenu;
         this.user = user;
+        userId = AccountManager
+            .get(context)
+            .getUserData(this.user.toPlatformAccount(),
+                         com.owncloud.android.lib.common.accounts.AccountUtils.Constants.KEY_USER_ID);
     }
 
     /**
@@ -125,11 +132,23 @@ public class FileMenuFilter {
             filter(toShow, toHide, inSingleFileFragment);
 
             for (int i : toShow) {
-                showMenuItem(menu.findItem(i));
+                final MenuItem item = menu.findItem(i);
+                if (item != null) {
+                    showMenuItem(item);
+                } else {
+                    // group
+                    menu.setGroupVisible(i, true);
+                }
             }
 
             for (int i : toHide) {
-                hideMenuItem(menu.findItem(i));
+                final MenuItem item = menu.findItem(i);
+                if (item != null) {
+                    hideMenuItem(item);
+                } else {
+                    // group
+                    menu.setGroupVisible(i, false);
+                }
             }
         }
     }
@@ -181,11 +200,13 @@ public class FileMenuFilter {
         boolean synchronizing = anyFileSynchronizing();
         OCCapability capability = componentsGetter.getStorageManager().getCapability(user.getAccountName());
         boolean endToEndEncryptionEnabled = capability.getEndToEndEncryption().isTrue();
+        boolean fileLockingEnabled = capability.getFilesLockingVersion() != null;
 
         filterEdit(toShow, toHide, capability);
         filterDownload(toShow, toHide, synchronizing);
         filterRename(toShow, toHide, synchronizing);
-        filterMoveCopy(toShow, toHide, synchronizing);
+        filterCopy(toShow, toHide, synchronizing);
+        filterMove(toShow, toHide, synchronizing);
         filterRemove(toShow, toHide, synchronizing);
         filterSelectAll(toShow, toHide, inSingleFileFragment);
         filterDeselectAll(toShow, toHide, inSingleFileFragment);
@@ -201,6 +222,9 @@ public class FileMenuFilter {
         filterUnsetEncrypted(toShow, toHide, endToEndEncryptionEnabled);
         filterSetPictureAs(toShow, toHide);
         filterStream(toShow, toHide);
+        filterLock(toShow, toHide, fileLockingEnabled);
+        filterUnlock(toShow, toHide, fileLockingEnabled);
+        filterLockInfo(toShow, toHide, fileLockingEnabled);
     }
 
     private void filterShareFile(List<Integer> toShow, List<Integer> toHide, OCCapability capability) {
@@ -252,9 +276,48 @@ public class FileMenuFilter {
         }
     }
 
+    private void filterLock(List<Integer> toShow, List<Integer> toHide, boolean fileLockingEnabled) {
+        if (files.isEmpty() || !isSingleSelection() || !fileLockingEnabled) {
+            toHide.add(R.id.action_lock_file);
+        } else {
+            OCFile file = files.iterator().next();
+            if (file.isLocked() || file.isFolder()) {
+                toHide.add(R.id.action_lock_file);
+            } else {
+                toShow.add(R.id.action_lock_file);
+            }
+        }
+    }
+
+    private void filterUnlock(List<Integer> toShow, List<Integer> toHide, boolean fileLockingEnabled) {
+        if (files.isEmpty() || !isSingleSelection() || !fileLockingEnabled) {
+            toHide.add(R.id.action_unlock_file);
+        } else {
+            OCFile file = files.iterator().next();
+            if (FileLockingHelper.canUserUnlockFile(userId, file)) {
+                toShow.add(R.id.action_unlock_file);
+            } else {
+                toHide.add(R.id.action_unlock_file);
+            }
+        }
+    }
+
+    private void filterLockInfo(List<Integer> toShow, List<Integer> toHide, boolean fileLockingEnabled) {
+        if (files.isEmpty() || !isSingleSelection() || !fileLockingEnabled) {
+            toHide.add(R.id.menu_group_lock_info);
+        } else {
+            OCFile file = files.iterator().next();
+            if (file.isLocked()) {
+                toShow.add(R.id.menu_group_lock_info);
+            } else {
+                toHide.add(R.id.menu_group_lock_info);
+            }
+        }
+    }
+
     private void filterEncrypt(List<Integer> toShow, List<Integer> toHide, boolean endToEndEncryptionEnabled) {
         if (files.isEmpty() || !isSingleSelection() || isSingleFile() || isEncryptedFolder()
-                || !endToEndEncryptionEnabled) {
+            || !endToEndEncryptionEnabled) {
             toHide.add(R.id.action_encrypted);
         } else {
             toShow.add(R.id.action_encrypted);
@@ -391,25 +454,32 @@ public class FileMenuFilter {
     }
 
     private void filterRemove(List<Integer> toShow, List<Integer> toHide, boolean synchronizing) {
-        if (files.isEmpty() || synchronizing || containsEncryptedFolder()) {
+        if (files.isEmpty() || synchronizing || containsEncryptedFolder() || containsLockedFile()) {
             toHide.add(R.id.action_remove_file);
         } else {
             toShow.add(R.id.action_remove_file);
         }
     }
 
-    private void filterMoveCopy(List<Integer> toShow, List<Integer> toHide, boolean synchronizing) {
-        if (files.isEmpty() || synchronizing || containsEncryptedFile() || containsEncryptedFolder()) {
+    private void filterMove(List<Integer> toShow, List<Integer> toHide, boolean synchronizing) {
+        if (files.isEmpty() || synchronizing || containsEncryptedFile() || containsEncryptedFolder() || containsLockedFile()) {
             toHide.add(R.id.action_move);
-            toHide.add(R.id.action_copy);
         } else {
             toShow.add(R.id.action_move);
+        }
+    }
+
+    private void filterCopy(List<Integer> toShow, List<Integer> toHide, boolean synchronizing) {
+        if (files.isEmpty() || synchronizing || containsEncryptedFile() || containsEncryptedFolder()) {
+            toHide.add(R.id.action_copy);
+        } else {
             toShow.add(R.id.action_copy);
         }
     }
 
+
     private void filterRename(Collection<Integer> toShow, Collection<Integer> toHide, boolean synchronizing) {
-        if (!isSingleSelection() || synchronizing || containsEncryptedFile() || containsEncryptedFolder()) {
+        if (!isSingleSelection() || synchronizing || containsEncryptedFile() || containsEncryptedFolder() || containsLockedFile()) {
             toHide.add(R.id.action_rename_file);
         } else {
             toShow.add(R.id.action_rename_file);
@@ -528,6 +598,15 @@ public class FileMenuFilter {
         return false;
     }
 
+    private boolean containsLockedFile() {
+        for (OCFile file : files) {
+            if (file.isLocked()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private boolean containsEncryptedFolder() {
         for (OCFile file : files) {
             if (file.isFolder() && file.isEncrypted()) {

+ 44 - 2
app/src/main/java/com/owncloud/android/providers/FileContentProvider.java

@@ -753,7 +753,15 @@ public class FileContentProvider extends ContentProvider {
                        + ProviderTableMeta.FILE_OWNER_DISPLAY_NAME + TEXT
                        + ProviderTableMeta.FILE_NOTE + TEXT
                        + ProviderTableMeta.FILE_SHAREES + TEXT
-                       + ProviderTableMeta.FILE_RICH_WORKSPACE + " TEXT);"
+                       + ProviderTableMeta.FILE_RICH_WORKSPACE + TEXT
+                       + ProviderTableMeta.FILE_LOCKED + INTEGER // boolean
+                       + ProviderTableMeta.FILE_LOCK_TYPE + INTEGER
+                       + ProviderTableMeta.FILE_LOCK_OWNER + TEXT
+                       + ProviderTableMeta.FILE_LOCK_OWNER_DISPLAY_NAME + TEXT
+                       + ProviderTableMeta.FILE_LOCK_OWNER_EDITOR + TEXT
+                       + ProviderTableMeta.FILE_LOCK_TIMESTAMP + INTEGER
+                       + ProviderTableMeta.FILE_LOCK_TIMEOUT + INTEGER
+                       + ProviderTableMeta.FILE_LOCK_TOKEN + " TEXT );"
         );
     }
 
@@ -829,7 +837,8 @@ public class FileContentProvider extends ContentProvider {
                        + ProviderTableMeta.CAPABILITIES_DIRECT_EDITING_ETAG + TEXT
                        + ProviderTableMeta.CAPABILITIES_USER_STATUS + INTEGER
                        + ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_EMOJI + INTEGER
-                       + ProviderTableMeta.CAPABILITIES_ETAG + " TEXT );");
+                       + ProviderTableMeta.CAPABILITIES_ETAG + TEXT
+                       + ProviderTableMeta.CAPABILITIES_FILES_LOCKING_VERSION + " TEXT );");
     }
 
     private void createUploadsTable(SQLiteDatabase db) {
@@ -2454,6 +2463,39 @@ public class FileContentProvider extends ContentProvider {
                 }
             }
 
+            if (oldVersion < 63 && newVersion >= 63) {
+                Log_OC.i(SQL, "Adding file locking columns");
+                db.beginTransaction();
+                try {
+                    // locking capabilities
+                    db.execSQL(ALTER_TABLE + ProviderTableMeta.CAPABILITIES_TABLE_NAME + ADD_COLUMN + ProviderTableMeta.CAPABILITIES_FILES_LOCKING_VERSION + " TEXT ");
+                    // force refresh
+                    db.execSQL("UPDATE capabilities SET etag = '' WHERE 1=1");
+                    // locking properties
+                    db.execSQL(ALTER_TABLE + ProviderTableMeta.FILE_TABLE_NAME +
+                                   ADD_COLUMN + ProviderTableMeta.FILE_LOCKED + " INTEGER "); // boolean
+                    db.execSQL(ALTER_TABLE + ProviderTableMeta.FILE_TABLE_NAME +
+                                   ADD_COLUMN + ProviderTableMeta.FILE_LOCK_TYPE + " INTEGER ");
+                    db.execSQL(ALTER_TABLE + ProviderTableMeta.FILE_TABLE_NAME +
+                                   ADD_COLUMN + ProviderTableMeta.FILE_LOCK_OWNER + " TEXT ");
+                    db.execSQL(ALTER_TABLE + ProviderTableMeta.FILE_TABLE_NAME +
+                                   ADD_COLUMN + ProviderTableMeta.FILE_LOCK_OWNER_DISPLAY_NAME + " TEXT ");
+                    db.execSQL(ALTER_TABLE + ProviderTableMeta.FILE_TABLE_NAME +
+                                   ADD_COLUMN + ProviderTableMeta.FILE_LOCK_OWNER_EDITOR + " TEXT ");
+                    db.execSQL(ALTER_TABLE + ProviderTableMeta.FILE_TABLE_NAME +
+                                   ADD_COLUMN + ProviderTableMeta.FILE_LOCK_TIMESTAMP + " INTEGER ");
+                    db.execSQL(ALTER_TABLE + ProviderTableMeta.FILE_TABLE_NAME +
+                                   ADD_COLUMN + ProviderTableMeta.FILE_LOCK_TIMEOUT + " INTEGER ");
+                    db.execSQL(ALTER_TABLE + ProviderTableMeta.FILE_TABLE_NAME +
+                                   ADD_COLUMN + ProviderTableMeta.FILE_LOCK_TOKEN + " TEXT ");
+                    db.execSQL("UPDATE " + ProviderTableMeta.FILE_TABLE_NAME + " SET " + ProviderTableMeta.FILE_ETAG + " = '' WHERE 1=1");
+
+                    db.setTransactionSuccessful();
+                } finally {
+                    db.endTransaction();
+                }
+            }
+
             if (!upgraded) {
                 Log_OC.i(SQL, String.format(Locale.ENGLISH, UPGRADE_VERSION_MSG, oldVersion, newVersion));
             }

+ 6 - 0
app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java

@@ -466,6 +466,12 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
             holder.getOverflowMenu().setOnClickListener(view -> ocFileListFragmentInterface
                 .onOverflowIconClicked(file, view));
         }
+
+        if (file.isLocked()) {
+            holder.getOverflowMenu().setImageResource(R.drawable.ic_locked_dots_small);
+        } else {
+            holder.getOverflowMenu().setImageResource(R.drawable.ic_dots_vertical);
+        }
     }
 
     private void bindListGridItemViewHolder(ListGridItemViewHolder holder, OCFile file) {

+ 23 - 0
app/src/main/java/com/owncloud/android/ui/events/FileLockEvent.kt

@@ -0,0 +1,23 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud 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 <https://www.gnu.org/licenses/>.
+ */
+package com.owncloud.android.ui.events
+
+data class FileLockEvent(val filePath: String, val shouldLock: Boolean)

+ 50 - 5
app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java

@@ -41,13 +41,15 @@ import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.AbsListView;
-import android.widget.PopupMenu;
 import android.widget.Toast;
 
 import com.google.android.material.appbar.AppBarLayout;
 import com.google.android.material.behavior.HideBottomViewOnScrollBehavior;
 import com.google.android.material.floatingactionbutton.FloatingActionButton;
 import com.google.android.material.snackbar.Snackbar;
+import com.nextcloud.android.files.FileLockingMenuCustomization;
+import com.nextcloud.android.files.ThemedPopupMenu;
+import com.nextcloud.android.lib.resources.files.ToggleFileLockRemoteOperation;
 import com.nextcloud.android.lib.richWorkspace.RichWorkspaceDirectEditingRemoteOperation;
 import com.nextcloud.client.account.User;
 import com.nextcloud.client.account.UserAccountManager;
@@ -56,6 +58,7 @@ import com.nextcloud.client.di.Injectable;
 import com.nextcloud.client.network.ClientFactory;
 import com.nextcloud.client.preferences.AppPreferences;
 import com.nextcloud.client.utils.Throttler;
+import com.nextcloud.common.NextcloudClient;
 import com.owncloud.android.MainApp;
 import com.owncloud.android.R;
 import com.owncloud.android.datamodel.ArbitraryDataProvider;
@@ -92,6 +95,7 @@ import com.owncloud.android.ui.events.ChangeMenuEvent;
 import com.owncloud.android.ui.events.CommentsEvent;
 import com.owncloud.android.ui.events.EncryptionEvent;
 import com.owncloud.android.ui.events.FavoriteEvent;
+import com.owncloud.android.ui.events.FileLockEvent;
 import com.owncloud.android.ui.events.SearchEvent;
 import com.owncloud.android.ui.helpers.FileOperationsHelper;
 import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface;
@@ -181,6 +185,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
     private static final String DIALOG_CREATE_FOLDER = "DIALOG_CREATE_FOLDER";
     private static final String DIALOG_CREATE_DOCUMENT = "DIALOG_CREATE_DOCUMENT";
     private static final String DIALOG_BOTTOM_SHEET = "DIALOG_BOTTOM_SHEET";
+    private static final String DIALOG_LOCK_DETAILS = "DIALOG_LOCK_DETAILS";
 
     private static final int SINGLE_SELECTION = 1;
     private static final int NOT_ENOUGH_SPACE_FRAG_REQUEST_CODE = 2;
@@ -568,7 +573,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
     @Override
     public void onOverflowIconClicked(OCFile file, View view) {
         throttler.run("overflowClick", () -> {
-            PopupMenu popup = new PopupMenu(getActivity(), view);
+            final ThemedPopupMenu popup = new ThemedPopupMenu(requireContext(), view);
             popup.inflate(R.menu.item_file);
             FileMenuFilter mf = new FileMenuFilter(mAdapter.getFiles().size(),
                                                    Collections.singleton(file),
@@ -576,11 +581,13 @@ public class OCFileListFragment extends ExtendedListFragment implements
                                                    true,
                                                    accountManager.getUser());
             mf.filter(popup.getMenu(), true);
+            new FileLockingMenuCustomization(requireContext()).customizeMenu(popup.getMenu(), file);
             popup.setOnMenuItemClickListener(item -> {
                 Set<OCFile> checkedFiles = new HashSet<>();
                 checkedFiles.add(file);
                 return onFileActionChosen(item, checkedFiles);
             });
+
             popup.show();
         });
     }
@@ -589,7 +596,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
     public void newDocument() {
         ChooseRichDocumentsTemplateDialogFragment.newInstance(mFile,
                                                               ChooseRichDocumentsTemplateDialogFragment.Type.DOCUMENT)
-                .show(requireActivity().getSupportFragmentManager(), DIALOG_CREATE_DOCUMENT);
+            .show(requireActivity().getSupportFragmentManager(), DIALOG_CREATE_DOCUMENT);
     }
 
     @Override
@@ -603,7 +610,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
     public void newPresentation() {
         ChooseRichDocumentsTemplateDialogFragment.newInstance(mFile,
                                                               ChooseRichDocumentsTemplateDialogFragment.Type.PRESENTATION)
-                .show(requireActivity().getSupportFragmentManager(), DIALOG_CREATE_DOCUMENT);
+            .show(requireActivity().getSupportFragmentManager(), DIALOG_CREATE_DOCUMENT);
     }
 
     @Override
@@ -616,7 +623,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
     @Override
     public void showTemplate(Creator creator, String headline) {
         ChooseTemplateDialogFragment.newInstance(mFile, creator, headline).show(requireActivity().getSupportFragmentManager(),
-                                                                      DIALOG_CREATE_DOCUMENT);
+                                                                                DIALOG_CREATE_DOCUMENT);
     }
 
     /**
@@ -741,6 +748,9 @@ public class OCFileListFragment extends ExtendedListFragment implements
             // Determine if we need to finish the action mode because there are no items selected
             if (checkedCount == 0 && !mIsActionModeNew) {
                 exitSelectionMode();
+            } else if (checkedCount == 1) {
+                // customize for locking if file is locked
+                new FileLockingMenuCustomization(requireContext()).customizeMenu(menu, checkedFiles.iterator().next());
             }
 
             return true;
@@ -1146,6 +1156,10 @@ public class OCFileListFragment extends ExtendedListFragment implements
             } else if (itemId == R.id.action_unset_encrypted) {
                 mContainerActivity.getFileOperationsHelper().toggleEncryption(singleFile, false);
                 return true;
+            } else if (itemId == R.id.action_lock_file) {
+                mContainerActivity.getFileOperationsHelper().toggleFileLock(singleFile, true);
+            } else if (itemId == R.id.action_unlock_file) {
+                mContainerActivity.getFileOperationsHelper().toggleFileLock(singleFile, false);
             }
         }
 
@@ -1192,6 +1206,8 @@ public class OCFileListFragment extends ExtendedListFragment implements
         } else if (itemId == R.id.action_send_file) {
             mContainerActivity.getFileOperationsHelper().sendFiles(checkedFiles);
             return true;
+        } else if (itemId == R.id.action_lock_file) {
+            // TODO call lock API
         }
 
         return false;
@@ -1642,6 +1658,35 @@ public class OCFileListFragment extends ExtendedListFragment implements
         }
     }
 
+    @Subscribe(threadMode = ThreadMode.BACKGROUND)
+    public void onMessageEvent(FileLockEvent event) {
+        final User user = accountManager.getUser();
+
+        try {
+            new Handler(Looper.getMainLooper()).post(() -> setLoading(true));
+            NextcloudClient client = clientFactory.createNextcloudClient(user);
+            ToggleFileLockRemoteOperation operation = new ToggleFileLockRemoteOperation(event.getShouldLock(), event.getFilePath());
+            RemoteOperationResult<Void> result = operation.execute(client);
+
+            if (result.isSuccess()) {
+                // TODO only refresh the modified file?
+                new Handler(Looper.getMainLooper()).post(this::onRefresh);
+            } else {
+                Snackbar.make(getRecyclerView(),
+                              R.string.error_file_lock,
+                              Snackbar.LENGTH_LONG).show();
+            }
+
+        } catch (ClientFactory.CreationException e) {
+            Log_OC.e(TAG, "Cannot create client", e);
+            Snackbar.make(getRecyclerView(),
+                          R.string.error_file_lock,
+                          Snackbar.LENGTH_LONG).show();
+        } finally {
+            new Handler(Looper.getMainLooper()).post(() -> setLoading(false));
+        }
+    }
+
     protected void setTitle(@StringRes final int title) {
         setTitle(getContext().getString(title));
     }

+ 7 - 0
app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java

@@ -79,6 +79,7 @@ import com.owncloud.android.ui.dialog.SendFilesDialog;
 import com.owncloud.android.ui.dialog.SendShareDialog;
 import com.owncloud.android.ui.events.EncryptionEvent;
 import com.owncloud.android.ui.events.FavoriteEvent;
+import com.owncloud.android.ui.events.FileLockEvent;
 import com.owncloud.android.ui.events.SyncEventFinished;
 import com.owncloud.android.utils.DisplayUtils;
 import com.owncloud.android.utils.FileStorageUtils;
@@ -918,6 +919,12 @@ public class FileOperationsHelper {
         }
     }
 
+    public void toggleFileLock(OCFile file, boolean shouldBeLocked) {
+        if (file.isLocked() != shouldBeLocked) {
+            EventBus.getDefault().post(new FileLockEvent(file.getRemotePath(), shouldBeLocked));
+        }
+    }
+
     public void renameFile(OCFile file, String newFilename) {
         // RenameFile
         Intent service = new Intent(fileActivity, OperationsService.class);

+ 8 - 0
app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java

@@ -233,6 +233,14 @@ public final class FileStorageUtils {
         file.setNote(remote.getNote());
         file.setSharees(new ArrayList<>(Arrays.asList(remote.getSharees())));
         file.setRichWorkspace(remote.getRichWorkspace());
+        file.setLocked(remote.isLocked());
+        file.setLockType(remote.getLockType());
+        file.setLockOwnerId(remote.getLockOwner());
+        file.setLockOwnerDisplayName(remote.getLockOwnerDisplayName());
+        file.setLockOwnerEditor(remote.getLockOwnerEditor());
+        file.setLockTimestamp(remote.getLockTimestamp());
+        file.setLockTimeout(remote.getLockTimeout());
+        file.setLockToken(remote.getLockToken());
 
         return file;
     }

+ 25 - 0
app/src/main/res/color/menu_item_text_color.xml

@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Android client application
+  ~
+  ~ @author Álvaro Brey Vilas
+  ~ Copyright (C) 2022 Álvaro Brey Vilas
+  ~ Copyright (C) 2022 Nextcloud 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 <https://www.gnu.org/licenses/>.
+  -->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:color="@color/text_color" android:state_enabled="true"/>
+    <item android:color="@color/disabled_text" />
+</selector>

+ 33 - 0
app/src/main/res/drawable/ic_locked_dots_small.xml

@@ -0,0 +1,33 @@
+<!--
+  ~ Nextcloud Android client application
+  ~
+  ~ @author Álvaro Brey Vilas
+  ~ Copyright (C) 2022 Álvaro Brey Vilas
+  ~ Copyright (C) 2022 Nextcloud 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 <https://www.gnu.org/licenses/>.
+  -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+  <path
+      android:pathData="m7.9482,15.6104c0.3479,0 0.682,-0.1385 0.9282,-0.3847 0.2462,-0.2462 0.3847,-0.5803 0.3847,-0.9282 0,-0.7286 -0.5908,-1.3129 -1.3129,-1.3129 -0.3479,0 -0.682,0.1385 -0.9282,0.3847 -0.2462,0.2462 -0.3847,0.5803 -0.3847,0.9282 0,0.3479 0.1385,0.682 0.3847,0.9282 0.2462,0.2462 0.5803,0.3847 0.9282,0.3847M11.8868,9.7025c0.3479,0 0.682,0.1385 0.9282,0.3847 0.2462,0.2462 0.3847,0.5803 0.3847,0.9282v6.5644c0,0.3479 -0.1385,0.682 -0.3847,0.9282 -0.2462,0.2462 -0.5803,0.3847 -0.9282,0.3847H4.0095c-0.3479,0 -0.682,-0.1385 -0.9282,-0.3847 -0.2462,-0.2462 -0.3847,-0.5803 -0.3847,-0.9282v-6.5644c0,-0.7286 0.5908,-1.3129 1.3129,-1.3129H4.666V8.3896c0,-0.8704 0.3459,-1.7054 0.961,-2.3212 0.6157,-0.6151 1.4507,-0.961 2.3212,-0.961 0.8704,0 1.7054,0.3459 2.3212,0.961 0.6151,0.6157 0.961,1.4507 0.961,2.3212v1.3129h0.6564M7.9482,6.4203c-0.5219,0 -1.0234,0.2074 -1.3923,0.577C6.1863,7.3662 5.9789,7.8677 5.9789,8.3896V9.7025H9.9175V8.3896c0,-0.5219 -0.2074,-1.0234 -0.577,-1.3923C8.9716,6.6277 8.47,6.4203 7.9482,6.4203Z"
+      android:strokeWidth="0.656438"
+      android:fillColor="#666666"/>
+  <path
+      android:pathData="m19.3033,8c1.1,0 2,-0.9 2,-2 0,-1.1 -0.9,-2 -2,-2 -1.1,0 -2,0.9 -2,2 0,1.1 0.9,2 2,2zM19.3033,10c-1.1,0 -2,0.9 -2,2 0,1.1 0.9,2 2,2 1.1,0 2,-0.9 2,-2 0,-1.1 -0.9,-2 -2,-2zM19.3033,16c-1.1,0 -2,0.9 -2,2 0,1.1 0.9,2 2,2 1.1,0 2,-0.9 2,-2 0,-1.1 -0.9,-2 -2,-2z"
+      android:fillColor="#666666"/>
+</vector>

+ 6 - 13
app/src/main/res/layout/list_item.xml

@@ -18,6 +18,7 @@
  -->
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
     android:id="@+id/ListItemLayout"
     android:layout_width="match_parent"
     android:layout_height="@dimen/standard_list_item_size"
@@ -137,10 +138,11 @@
 
     </LinearLayout>
 
-    <RelativeLayout
+    <LinearLayout
         android:layout_width="wrap_content"
         android:layout_height="match_parent"
-        android:layout_gravity="center_vertical"
+        android:gravity="center_vertical|end"
+        android:orientation="horizontal"
         android:paddingStart="@dimen/standard_half_padding"
         android:paddingEnd="@dimen/zero">
 
@@ -148,7 +150,6 @@
             android:id="@+id/unreadComments"
             android:layout_width="wrap_content"
             android:layout_height="match_parent"
-            android:layout_centerVertical="true"
             android:clickable="true"
             android:contentDescription="@string/unread_comments"
             android:focusable="true"
@@ -161,8 +162,6 @@
             android:id="@+id/sharedIcon"
             android:layout_width="48dp"
             android:layout_height="match_parent"
-            android:layout_centerVertical="true"
-            android:layout_toEndOf="@id/unreadComments"
             android:clickable="true"
             android:contentDescription="@string/shared_icon_share"
             android:focusable="true"
@@ -173,10 +172,8 @@
         <com.owncloud.android.ui.AvatarGroupLayout
             android:id="@+id/sharedAvatars"
             android:layout_width="100dp"
+            android:gravity="center_vertical"
             android:layout_height="@dimen/file_icon_size"
-            android:layout_alignEnd="@id/sharedIcon"
-            android:layout_centerVertical="true"
-            android:layout_toEndOf="@id/sharedIcon"
             android:contentDescription="@string/shared_avatar_desc"
             android:visibility="visible" />
 
@@ -184,8 +181,6 @@
             android:id="@+id/custom_checkbox"
             android:layout_width="wrap_content"
             android:layout_height="match_parent"
-            android:layout_centerVertical="true"
-            android:layout_toEndOf="@id/sharedAvatars"
             android:clickable="false"
             android:contentDescription="@string/checkbox"
             android:focusable="false"
@@ -197,8 +192,6 @@
             android:id="@+id/overflow_menu"
             android:layout_width="48dp"
             android:layout_height="match_parent"
-            android:layout_centerVertical="true"
-            android:layout_toEndOf="@id/custom_checkbox"
             android:clickable="true"
             android:contentDescription="@string/overflow_menu"
             android:focusable="true"
@@ -206,5 +199,5 @@
             android:paddingEnd="12dp"
             android:src="@drawable/ic_dots_vertical" />
 
-    </RelativeLayout>
+    </LinearLayout>
 </LinearLayout>

+ 1 - 1
app/src/main/res/layout/toolbar_standard.xml

@@ -111,7 +111,7 @@
                 android:layout_width="match_parent"
                 android:layout_height="?attr/actionBarSize"
                 android:background="@color/appbar"
-                android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar"
+                android:theme="@style/Theme.ToolbarWithDisabled"
                 app:popupTheme="@style/Theme.AppCompat.DayNight.NoActionBar"
                 tools:visibility="gone">
 

+ 40 - 3
app/src/main/res/menu/item_file.xml

@@ -18,9 +18,40 @@
   along with this program.  If not, see <http://www.gnu.org/licenses/>.
 -->
 <menu xmlns:android="http://schemas.android.com/apk/res/android"
-      xmlns:app="http://schemas.android.com/apk/res-auto"
-      xmlns:tools="http://schemas.android.com/tools"
-      tools:ignore="AppCompatResource">
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:ignore="AppCompatResource">
+
+    <group
+        android:id="@+id/menu_group_lock_info"
+        android:visible="false"
+        android:enabled="false"
+        tools:visible="true">
+
+        <item
+            android:id="@+id/action_locked_by"
+            android:showAsAction="never"
+            android:title="Locked by %1$s"
+            android:enabled="false"
+            app:showAsAction="never"
+            tools:ignore="HardcodedText"
+            tools:title="Locked by Username With Surname at bla bla bla bla bla" />
+
+        <item
+            android:id="@+id/action_locked_until"
+            android:showAsAction="never"
+            android:enabled="false"
+            android:title="Lock expires: %1$s"
+            app:showAsAction="never"
+            tools:ignore="HardcodedText"
+            tools:title="Lock expires: in 20 minutes" />
+    </group>
+
+    <item
+        android:id="@+id/action_unlock_file"
+        android:showAsAction="never"
+        android:title="@string/unlock_file"
+        app:showAsAction="never" />
 
     <item
         android:id="@+id/action_edit"
@@ -46,6 +77,12 @@
         app:showAsAction="never"
         android:showAsAction="never" />
 
+    <item
+        android:id="@+id/action_lock_file"
+        android:showAsAction="never"
+        android:title="@string/lock_file"
+        app:showAsAction="never" />
+
     <item
         android:id="@+id/action_rename_file"
         android:title="@string/common_rename"

+ 6 - 0
app/src/main/res/values/strings.xml

@@ -1008,4 +1008,10 @@
     <string name="pdf_zoom_tip">Tap on a page to zoom in</string>
     <string name="storage_permission_full_access">Full access</string>
     <string name="storage_permission_media_read_only">Media read-only</string>
+    <string name="lock_file">Lock file</string>
+    <string name="unlock_file">Unlock file</string>
+    <string name="error_file_lock">Error changing file lock status</string>
+    <string name="locked_by">Locked by %1$s</string>
+    <string name="locked_by_app">Locked by %1$s app</string>
+    <string name="lock_expiration_info">Expires: %1$s</string>
 </resources>

+ 8 - 0
app/src/main/res/values/styles.xml

@@ -386,6 +386,14 @@
         <item name="android:textColorPrimary">@color/text_color</item>
     </style>
 
+    <style name="Nextcloud.Widget.PopupMenu" parent="@style/Widget.AppCompat.PopupMenu">
+        <item name="android:textColor">@color/menu_item_text_color</item>
+    </style>
+
+    <style name="Theme.ToolbarWithDisabled" parent="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar">
+        <item name="android:textColor">@color/menu_item_text_color</item>
+    </style>
+
     <style name="MaterialListItemSingleLine">
         <item name="android:clickable">true</item>
         <item name="android:background">?android:selectableItemBackground</item>

+ 74 - 0
app/src/test/java/com/nextcloud/android/files/FileLockingHelperTest.kt

@@ -0,0 +1,74 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud 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 <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.android.files
+
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.resources.files.model.FileLockType
+import org.junit.Assert
+import org.junit.Test
+
+class FileLockingHelperTest {
+
+    @Test
+    fun fileNotLocked_cannotUnlock() {
+        val file = OCFile("/foo.md").apply {
+            isLocked = false
+            lockOwnerId = USER_NAME
+            lockType = FileLockType.MANUAL
+        }
+        Assert.assertFalse(FileLockingHelper.canUserUnlockFile(USER_NAME, file))
+    }
+
+    @Test
+    fun ownerNotUser_cannotUnlock() {
+        val file = OCFile("/foo.md").apply {
+            isLocked = true
+            lockOwnerId = "bloop"
+            lockType = FileLockType.MANUAL
+        }
+        Assert.assertFalse(FileLockingHelper.canUserUnlockFile(USER_NAME, file))
+    }
+
+    @Test
+    fun typeNotManual_cannotUnlock() {
+        val file = OCFile("/foo.md").apply {
+            isLocked = true
+            lockOwnerId = USER_NAME
+            lockType = FileLockType.COLLABORATIVE
+        }
+        Assert.assertFalse(FileLockingHelper.canUserUnlockFile(USER_NAME, file))
+    }
+
+    @Test
+    fun canUnlock() {
+        val file = OCFile("/foo.md").apply {
+            isLocked = true
+            lockOwnerId = USER_NAME
+            lockType = FileLockType.MANUAL
+        }
+        Assert.assertTrue(FileLockingHelper.canUserUnlockFile(USER_NAME, file))
+    }
+
+    companion object {
+        private const val USER_NAME = "user"
+    }
+}