瀏覽代碼

Merge pull request #7568 from nextcloud/unifiedSearch

Unified search
Tobias Kaminsky 3 年之前
父節點
當前提交
cf8021059e
共有 43 個文件被更改,包括 1946 次插入226 次删除
  1. 1 0
      .github/workflows/screenShotTest.yml
  2. 4 0
      build.gradle
  3. 1 1
      scripts/analysis/findbugs-results.txt
  4. 1 1
      scripts/analysis/lint-results.txt
  5. 106 0
      src/androidTest/java/com/owncloud/android/ui/fragment/UnifiedSearchFakeRepository.kt
  6. 98 0
      src/androidTest/java/com/owncloud/android/ui/fragment/UnifiedSearchFragmentIT.kt
  7. 4 3
      src/main/java/com/nextcloud/client/di/ComponentsModule.java
  8. 6 0
      src/main/java/com/nextcloud/client/di/ViewModelModule.kt
  9. 1 1
      src/main/java/com/nextcloud/client/jobs/OfflineSyncWork.kt
  10. 2 1
      src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java
  11. 199 167
      src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java
  12. 1 1
      src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java
  13. 4 2
      src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java
  14. 2 2
      src/main/java/com/owncloud/android/ui/adapter/RichDocumentsTemplateAdapter.java
  15. 1 1
      src/main/java/com/owncloud/android/ui/adapter/TemplateAdapter.java
  16. 42 0
      src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchFooterViewHolder.kt
  17. 37 0
      src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchHeaderViewHolder.kt
  18. 135 0
      src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchItemViewHolder.kt
  19. 158 0
      src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapter.kt
  20. 1 17
      src/main/java/com/owncloud/android/ui/asynctasks/FetchRemoteFileTask.java
  21. 67 0
      src/main/java/com/owncloud/android/ui/asynctasks/GetRemoteFileTask.kt
  22. 35 13
      src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.java
  23. 228 0
      src/main/java/com/owncloud/android/ui/fragment/UnifiedSearchFragment.kt
  24. 32 0
      src/main/java/com/owncloud/android/ui/interfaces/UnifiedSearchListInterface.kt
  25. 52 0
      src/main/java/com/owncloud/android/ui/unifiedsearch/GetSearchProvidersTask.kt
  26. 46 0
      src/main/java/com/owncloud/android/ui/unifiedsearch/IUnifiedSearchRepository.kt
  27. 20 0
      src/main/java/com/owncloud/android/ui/unifiedsearch/IUnifiedSearchViewModel.kt
  28. 54 0
      src/main/java/com/owncloud/android/ui/unifiedsearch/SearchOnProviderTask.kt
  29. 12 0
      src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchModel.kt
  30. 117 0
      src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchRemoteRepository.kt
  31. 241 0
      src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchViewModel.kt
  32. 1 1
      src/main/java/com/owncloud/android/utils/DisplayUtils.java
  33. 5 5
      src/main/java/com/owncloud/android/utils/glide/CustomGlideStreamLoader.java
  34. 5 5
      src/main/java/com/owncloud/android/utils/glide/CustomGlideUriLoader.java
  35. 2 3
      src/main/java/com/owncloud/android/utils/glide/HttpStreamFetcher.kt
  36. 18 0
      src/main/res/drawable/ic_deck.xml
  37. 3 1
      src/main/res/layout/list_fragment.xml
  38. 0 1
      src/main/res/layout/list_item.xml
  39. 4 0
      src/main/res/layout/unified_search_empty.xml
  40. 46 0
      src/main/res/layout/unified_search_footer.xml
  41. 41 0
      src/main/res/layout/unified_search_header.xml
  42. 111 0
      src/main/res/layout/unified_search_item.xml
  43. 2 0
      src/main/res/values/strings.xml

+ 1 - 0
.github/workflows/screenShotTest.yml

@@ -25,6 +25,7 @@ jobs:
                     echo "org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError" > $HOME/.gradle/gradle.properties
                     ./gradlew assembleGplayDebug
             -   name: Delete old comments
+                if: ${{ always() }}
                 run: scripts/deleteOldComments.sh "${{ matrix.color }}-${{ matrix.scheme }}" "Screenshot" ${{github.event.number}} ${{ secrets.GITHUB_TOKEN }}
             -   name: run tests
                 uses: reactivecircus/android-emulator-runner@v2

+ 4 - 0
build.gradle

@@ -250,6 +250,10 @@ android {
         dataBinding true
         viewBinding true
     }
+
+    kotlinOptions {
+        jvmTarget = "1.8"
+    }
 }
 
 dependencies {

+ 1 - 1
scripts/analysis/findbugs-results.txt

@@ -1 +1 @@
-349
+351

+ 1 - 1
scripts/analysis/lint-results.txt

@@ -1,2 +1,2 @@
 DO NOT TOUCH; GENERATED BY DRONE
-      <span class="mdl-layout-title">Lint Report: 1 error and 111 warnings</span>
+      <span class="mdl-layout-title">Lint Report: 1 error and 110 warnings</span>

+ 106 - 0
src/androidTest/java/com/owncloud/android/ui/fragment/UnifiedSearchFakeRepository.kt

@@ -0,0 +1,106 @@
+/*
+ *
+ * Nextcloud Android client application
+ *
+ * @author Tobias Kaminsky
+ * Copyright (C) 2020 Tobias Kaminsky
+ * Copyright (C) 2020 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.owncloud.android.ui.fragment
+
+import com.owncloud.android.lib.common.SearchResult
+import com.owncloud.android.lib.common.SearchResultEntry
+import com.owncloud.android.ui.unifiedsearch.IUnifiedSearchRepository
+import com.owncloud.android.ui.unifiedsearch.ProviderID
+import com.owncloud.android.ui.unifiedsearch.UnifiedSearchResult
+
+class UnifiedSearchFakeRepository : IUnifiedSearchRepository {
+
+    override fun queryAll(
+        query: String,
+        onResult: (UnifiedSearchResult) -> Unit,
+        onError: (Throwable) -> Unit,
+        onFinished: (Boolean) -> Unit
+    ) {
+        val result = UnifiedSearchResult(
+            provider = "files",
+            success = true,
+            result = SearchResult(
+                "files",
+                false,
+                listOf(
+                    SearchResultEntry(
+                        "thumbnailUrl",
+                        "Test",
+                        "in Files",
+                        "http://localhost/nc/index.php/apps/files/?dir=/Files&scrollto=Test",
+                        "icon",
+                        false
+                    ),
+                    SearchResultEntry(
+                        "thumbnailUrl",
+                        "Test1",
+                        "in Folder",
+                        "http://localhost/nc/index.php/apps/files/?dir=/folder&scrollto=test1.txt",
+                        "icon",
+                        false
+                    )
+                )
+            )
+
+        )
+        onResult(result)
+        onFinished(true)
+    }
+
+    override fun queryProvider(
+        query: String,
+        provider: ProviderID,
+        cursor: Int?,
+        onResult: (UnifiedSearchResult) -> Unit,
+        onError: (Throwable) -> Unit,
+        onFinished: (Boolean) -> Unit
+    ) {
+        val result = UnifiedSearchResult(
+            provider = provider,
+            success = true,
+            result = SearchResult(
+                provider,
+                false,
+                listOf(
+                    SearchResultEntry(
+                        "thumbnailUrl",
+                        "Test",
+                        "in Files",
+                        "http://localhost/nc/index.php/apps/files/?dir=/Files&scrollto=Test",
+                        "icon",
+                        false
+                    ),
+                    SearchResultEntry(
+                        "thumbnailUrl",
+                        "Test1",
+                        "in Folder",
+                        "http://localhost/nc/index.php/apps/files/?dir=/folder&scrollto=test1.txt",
+                        "icon",
+                        false
+                    )
+                )
+            )
+
+        )
+    }
+}

+ 98 - 0
src/androidTest/java/com/owncloud/android/ui/fragment/UnifiedSearchFragmentIT.kt

@@ -0,0 +1,98 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Tobias Kaminsky
+ * Copyright (C) 2020 Tobias Kaminsky
+ * Copyright (C) 2020 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package com.owncloud.android.ui.fragment
+
+import androidx.test.espresso.intent.rule.IntentsTestRule
+import androidx.test.internal.runner.junit4.statement.UiThreadStatement
+import com.nextcloud.client.TestActivity
+import com.owncloud.android.AbstractIT
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.SearchResultEntry
+import com.owncloud.android.ui.unifiedsearch.UnifiedSearchSection
+import com.owncloud.android.ui.unifiedsearch.UnifiedSearchViewModel
+import org.junit.Rule
+import org.junit.Test
+import java.io.File
+
+class UnifiedSearchFragmentIT : AbstractIT() {
+    @get:Rule
+    val testActivityRule = IntentsTestRule(TestActivity::class.java, true, false)
+
+    @Test
+    fun showSearchResult() {
+        val activity = testActivityRule.launchActivity(null)
+        val sut = UnifiedSearchFragment.newInstance(null)
+
+        activity.addFragment(sut)
+
+        shortSleep()
+
+        UiThreadStatement.runOnUiThread {
+            sut.onSearchResultChanged(
+                listOf(
+                    UnifiedSearchSection(
+                        providerID = "files",
+                        name = "Files",
+                        entries = listOf(
+                            SearchResultEntry(
+                                "thumbnailUrl",
+                                "Test",
+                                "in Files",
+                                "http://localhost/nc/index.php/apps/files/?dir=/Files&scrollto=Test",
+                                "icon",
+                                false
+                            )
+                        ),
+                        hasMoreResults = false
+                    )
+                )
+            )
+        }
+        shortSleep()
+    }
+
+    @Test
+    fun search() {
+        val activity = testActivityRule.launchActivity(null)
+        val sut = UnifiedSearchFragment.newInstance(null)
+        val testViewModel = UnifiedSearchViewModel(activity.application)
+        val localRepository = UnifiedSearchFakeRepository()
+        testViewModel.setRepository(localRepository)
+
+        val ocFile = OCFile("/folder/test1.txt").apply {
+            storagePath = "/sdcard/1.txt"
+            storageManager.saveFile(this)
+        }
+
+        File(ocFile.storagePath).createNewFile()
+
+        activity.addFragment(sut)
+
+        shortSleep()
+
+        UiThreadStatement.runOnUiThread {
+            sut.setViewModel(testViewModel)
+            sut.vm.setQuery("test")
+            sut.vm.initialQuery()
+        }
+        shortSleep()
+    }
+}

+ 4 - 3
src/main/java/com/nextcloud/client/di/ComponentsModule.java

@@ -78,6 +78,7 @@ import com.owncloud.android.ui.fragment.FileDetailSharingFragment;
 import com.owncloud.android.ui.fragment.GalleryFragment;
 import com.owncloud.android.ui.fragment.LocalFileListFragment;
 import com.owncloud.android.ui.fragment.OCFileListFragment;
+import com.owncloud.android.ui.fragment.UnifiedSearchFragment;
 import com.owncloud.android.ui.fragment.contactsbackup.BackupFragment;
 import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment;
 import com.owncloud.android.ui.preview.PreviewImageActivity;
@@ -175,11 +176,11 @@ abstract class ComponentsModule {
     @ContributesAndroidInjector
     abstract SetStatusDialogFragment setStatusDialogFragment();
 
-    @ContributesAndroidInjector
-    abstract PreviewTextFileFragment previewTextFileFragment();
+    @ContributesAndroidInjector abstract PreviewTextFileFragment previewTextFileFragment();
+    @ContributesAndroidInjector abstract PreviewTextStringFragment previewTextStringFragment();
 
     @ContributesAndroidInjector
-    abstract PreviewTextStringFragment previewTextStringFragment();
+    abstract UnifiedSearchFragment searchFragment();
 
     @ContributesAndroidInjector
     abstract GalleryFragment photoFragment();

+ 6 - 0
src/main/java/com/nextcloud/client/di/ViewModelModule.kt

@@ -23,6 +23,7 @@ import androidx.lifecycle.ViewModel
 import androidx.lifecycle.ViewModelProvider
 import com.nextcloud.client.etm.EtmViewModel
 import com.nextcloud.client.logger.ui.LogsViewModel
+import com.owncloud.android.ui.unifiedsearch.UnifiedSearchViewModel
 import dagger.Binds
 import dagger.Module
 import dagger.multibindings.IntoMap
@@ -39,6 +40,11 @@ abstract class ViewModelModule {
     @ViewModelKey(LogsViewModel::class)
     abstract fun logsViewModel(vm: LogsViewModel): ViewModel
 
+    @Binds
+    @IntoMap
+    @ViewModelKey(UnifiedSearchViewModel::class)
+    abstract fun unifiedSearchViewModel(vm: UnifiedSearchViewModel): ViewModel
+
     @Binds
     abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory
 }

+ 1 - 1
src/main/java/com/nextcloud/client/jobs/OfflineSyncWork.kt

@@ -110,7 +110,7 @@ class OfflineSyncWork constructor(
             for (file in files) {
                 val ocFile = storageManager.getFileByLocalPath(file.path)
                 val synchronizeFileOperation = SynchronizeFileOperation(
-                    ocFile.remotePath,
+                    ocFile?.remotePath,
                     user,
                     true,
                     context,

+ 2 - 1
src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java

@@ -155,7 +155,8 @@ public class FileDataStorageManager {
         return ocFile;
     }
 
-    public OCFile getFileByLocalPath(String path) {
+    public @Nullable
+    OCFile getFileByLocalPath(String path) {
         Cursor cursor = getFileCursorForValue(ProviderTableMeta.FILE_STORAGE_PATH, path);
         OCFile ocFile = null;
 

+ 199 - 167
src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java

@@ -51,6 +51,7 @@ import android.view.ViewTreeObserver;
 import android.view.WindowManager;
 
 import com.google.android.material.appbar.AppBarLayout;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
 import com.google.android.material.snackbar.Snackbar;
 import com.nextcloud.client.account.User;
 import com.nextcloud.client.appinfo.AppInfo;
@@ -100,6 +101,7 @@ import com.owncloud.android.ui.fragment.FileFragment;
 import com.owncloud.android.ui.fragment.GalleryFragment;
 import com.owncloud.android.ui.fragment.OCFileListFragment;
 import com.owncloud.android.ui.fragment.TaskRetainerFragment;
+import com.owncloud.android.ui.fragment.UnifiedSearchFragment;
 import com.owncloud.android.ui.helpers.FileOperationsHelper;
 import com.owncloud.android.ui.helpers.UriUploader;
 import com.owncloud.android.ui.preview.PreviewImageActivity;
@@ -131,10 +133,10 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
-import javax.annotation.Nullable;
 import javax.inject.Inject;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.appcompat.app.AlertDialog;
 import androidx.appcompat.widget.SearchView;
 import androidx.coordinatorlayout.widget.CoordinatorLayout;
@@ -151,9 +153,9 @@ import static com.owncloud.android.datamodel.OCFile.PATH_SEPARATOR;
  * Displays, what files the user has available in his ownCloud. This is the main view.
  */
 public class FileDisplayActivity extends FileActivity
-        implements FileFragment.ContainerActivity,
-        OnEnforceableRefreshListener, SortingOrderDialogFragment.OnSortingOrderListener,
-        SendShareDialog.SendShareDialogDownloader, Injectable {
+    implements FileFragment.ContainerActivity,
+    OnEnforceableRefreshListener, SortingOrderDialogFragment.OnSortingOrderListener,
+    SendShareDialog.SendShareDialogDownloader, Injectable {
 
     public static final String RESTART = "RESTART";
     public static final String ALL_FILES = "ALL_FILES";
@@ -272,7 +274,7 @@ public class FileDisplayActivity extends FileActivity
         // Init Fragment without UI to retain AsyncTask across configuration changes
         FragmentManager fm = getSupportFragmentManager();
         TaskRetainerFragment taskRetainerFragment =
-                (TaskRetainerFragment) fm.findFragmentByTag(TaskRetainerFragment.FTAG_TASK_RETAINER_FRAGMENT);
+            (TaskRetainerFragment) fm.findFragmentByTag(TaskRetainerFragment.FTAG_TASK_RETAINER_FRAGMENT);
         if (taskRetainerFragment == null) {
             taskRetainerFragment = new TaskRetainerFragment();
             fm.beginTransaction()
@@ -356,8 +358,7 @@ public class FileDisplayActivity extends FileActivity
     }
 
     /**
-     * For Android 7+.
-     * Opens a pop up info for the new instant upload and disabled the old instant upload.
+     * For Android 7+. Opens a pop up info for the new instant upload and disabled the old instant upload.
      */
     private void upgradeNotificationForInstantUpload() {
         // check for Android 6+ if legacy instant upload is activated --> disable + show info
@@ -365,23 +366,23 @@ public class FileDisplayActivity extends FileActivity
             preferences.removeLegacyPreferences();
             // show info pop-up
             new AlertDialog.Builder(this, R.style.Theme_ownCloud_Dialog)
-                    .setTitle(R.string.drawer_synced_folders)
-                    .setMessage(R.string.synced_folders_new_info)
-                    .setPositiveButton(R.string.drawer_open, new DialogInterface.OnClickListener() {
-                        public void onClick(DialogInterface dialog, int which) {
-                            // show instant upload
-                            Intent syncedFoldersIntent = new Intent(getApplicationContext(), SyncedFoldersActivity.class);
-                            dialog.dismiss();
-                            startActivity(syncedFoldersIntent);
-                        }
-                    })
-                    .setNegativeButton(R.string.drawer_close, new DialogInterface.OnClickListener() {
-                        public void onClick(DialogInterface dialog, int which) {
-                            dialog.dismiss();
-                        }
-                    })
-                    .setIcon(R.drawable.nav_synced_folders)
-                    .show();
+                .setTitle(R.string.drawer_synced_folders)
+                .setMessage(R.string.synced_folders_new_info)
+                .setPositiveButton(R.string.drawer_open, new DialogInterface.OnClickListener() {
+                    public void onClick(DialogInterface dialog, int which) {
+                        // show instant upload
+                        Intent syncedFoldersIntent = new Intent(getApplicationContext(), SyncedFoldersActivity.class);
+                        dialog.dismiss();
+                        startActivity(syncedFoldersIntent);
+                    }
+                })
+                .setNegativeButton(R.string.drawer_close, new DialogInterface.OnClickListener() {
+                    public void onClick(DialogInterface dialog, int which) {
+                        dialog.dismiss();
+                    }
+                })
+                .setIcon(R.drawable.nav_synced_folders)
+                .show();
         }
     }
 
@@ -405,7 +406,7 @@ public class FileDisplayActivity extends FileActivity
             case PermissionUtil.PERMISSIONS_WRITE_EXTERNAL_STORAGE: {
                 // If request is cancelled, result arrays are empty.
                 if (grantResults.length > 0
-                        && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+                    && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                     // permission was granted
                     EventBus.getDefault().post(new TokenPushEvent());
                     syncAndUpdateFolder(true);
@@ -439,7 +440,7 @@ public class FileDisplayActivity extends FileActivity
             Bundle args = new Bundle();
 
             args.putParcelable(OCFileListFragment.SEARCH_EVENT,
-                    getIntent().getParcelableExtra(OCFileListFragment.SEARCH_EVENT));
+                               getIntent().getParcelableExtra(OCFileListFragment.SEARCH_EVENT));
             args.putBoolean(OCFileListFragment.ARG_ALLOW_CONTEXTUAL_ACTIONS, true);
 
             listOfFiles.setArguments(args);
@@ -577,6 +578,12 @@ public class FileDisplayActivity extends FileActivity
         transaction.addToBackStack(null);
         transaction.replace(R.id.left_fragment_container, fragment, TAG_LIST_OF_FILES);
         transaction.commit();
+
+        if (fragment instanceof UnifiedSearchFragment) {
+            showSortListGroup(false);
+        } else {
+            showSortListGroup(true);
+        }
     }
 
 
@@ -839,7 +846,8 @@ public class FileDisplayActivity extends FileActivity
                     isSearchOpen()) {
                 onBackPressed();
             } else if (getLeftFragment() instanceof FileDetailFragment ||
-                    getLeftFragment() instanceof PreviewMediaFragment) {
+                getLeftFragment() instanceof PreviewMediaFragment ||
+                getLeftFragment() instanceof UnifiedSearchFragment) {
                 onBackPressed();
             } else {
                 openDrawer();
@@ -853,7 +861,7 @@ public class FileDisplayActivity extends FileActivity
         } else {
             retval = super.onOptionsItemSelected(item);
         }
-        
+
         return retval;
     }
 
@@ -864,21 +872,21 @@ public class FileDisplayActivity extends FileActivity
     protected void onActivityResult(int requestCode, int resultCode, Intent data) {
 
         if (requestCode == REQUEST_CODE__SELECT_CONTENT_FROM_APPS &&
-                (resultCode == RESULT_OK ||
-                        resultCode == UploadFilesActivity.RESULT_OK_AND_MOVE)) {
+            (resultCode == RESULT_OK ||
+                resultCode == UploadFilesActivity.RESULT_OK_AND_MOVE)) {
 
             requestUploadOfContentFromApps(data, resultCode);
 
         } else if (requestCode == REQUEST_CODE__SELECT_FILES_FROM_FILE_SYSTEM &&
-                (resultCode == RESULT_OK ||
-                        resultCode == UploadFilesActivity.RESULT_OK_AND_MOVE ||
-                        resultCode == UploadFilesActivity.RESULT_OK_AND_DO_NOTHING ||
-                        resultCode == UploadFilesActivity.RESULT_OK_AND_DELETE)) {
+            (resultCode == RESULT_OK ||
+                resultCode == UploadFilesActivity.RESULT_OK_AND_MOVE ||
+                resultCode == UploadFilesActivity.RESULT_OK_AND_DO_NOTHING ||
+                resultCode == UploadFilesActivity.RESULT_OK_AND_DELETE)) {
 
             requestUploadOfFilesFromFileSystem(data, resultCode);
 
         } else if (requestCode == REQUEST_CODE__UPLOAD_FROM_CAMERA &&
-                (resultCode == RESULT_OK || resultCode == UploadFilesActivity.RESULT_OK_AND_DELETE)) {
+            (resultCode == RESULT_OK || resultCode == UploadFilesActivity.RESULT_OK_AND_DELETE)) {
 
             new CheckAvailableSpaceTask(new CheckAvailableSpaceTask.CheckAvailableSpaceListener() {
                 @Override
@@ -909,27 +917,27 @@ public class FileDisplayActivity extends FileActivity
             exitSelectionMode();
             final Intent fData = data;
             getHandler().postDelayed(
-                    new Runnable() {
-                        @Override
-                        public void run() {
-                            requestMoveOperation(fData);
-                        }
-                    },
-                    DELAY_TO_REQUEST_OPERATIONS_LATER
-            );
+                new Runnable() {
+                    @Override
+                    public void run() {
+                        requestMoveOperation(fData);
+                    }
+                },
+                DELAY_TO_REQUEST_OPERATIONS_LATER
+                                    );
 
         } else if (requestCode == REQUEST_CODE__COPY_FILES && resultCode == RESULT_OK) {
             exitSelectionMode();
             final Intent fData = data;
             getHandler().postDelayed(
-                    new Runnable() {
-                        @Override
-                        public void run() {
-                            requestCopyOperation(fData);
-                        }
-                    },
-                    DELAY_TO_REQUEST_OPERATIONS_LATER
-            );
+                new Runnable() {
+                    @Override
+                    public void run() {
+                        requestCopyOperation(fData);
+                    }
+                },
+                DELAY_TO_REQUEST_OPERATIONS_LATER
+                                    );
         } else {
             super.onActivityResult(requestCode, resultCode, data);
         }
@@ -992,7 +1000,7 @@ public class FileDisplayActivity extends FileActivity
                 false,
                 false,
                 NameCollisionPolicy.ASK_USER
-            );
+                                      );
 
         } else {
             Log_OC.d(TAG, "User clicked on 'Update' with no selection");
@@ -1015,19 +1023,19 @@ public class FileDisplayActivity extends FileActivity
         }
 
         int behaviour = (resultCode == UploadFilesActivity.RESULT_OK_AND_MOVE) ? FileUploader.LOCAL_BEHAVIOUR_MOVE :
-                FileUploader.LOCAL_BEHAVIOUR_COPY;
+            FileUploader.LOCAL_BEHAVIOUR_COPY;
 
         OCFile currentDir = getCurrentDir();
         String remotePath = (currentDir != null) ? currentDir.getRemotePath() : OCFile.ROOT_PATH;
 
         UriUploader uploader = new UriUploader(
-                this,
-                streamsToUpload,
-                remotePath,
-                getUser().orElseThrow(RuntimeException::new),
-                behaviour,
-                false, // Not show waiting dialog while file is being copied from private storage
-                null  // Not needed copy temp task listener
+            this,
+            streamsToUpload,
+            remotePath,
+            getUser().orElseThrow(RuntimeException::new),
+            behaviour,
+            false, // Not show waiting dialog while file is being copied from private storage
+            null  // Not needed copy temp task listener
         );
 
         uploader.uploadUris();
@@ -1075,10 +1083,10 @@ public class FileDisplayActivity extends FileActivity
     @SuppressFBWarnings("ITC_INHERITANCE_TYPE_CHECKING")
     @Override
     public void onBackPressed() {
-        boolean isDrawerOpen = isDrawerOpen();
-        boolean isSearchOpen = isSearchOpen();
+        final boolean isDrawerOpen = isDrawerOpen();
+        final boolean isSearchOpen = isSearchOpen();
 
-        Fragment leftFragment = getLeftFragment();
+        final Fragment leftFragment = getLeftFragment();
 
         if (leftFragment instanceof OCFileListFragment) {
             OCFileListFragment listOfFiles = (OCFileListFragment) leftFragment;
@@ -1118,6 +1126,7 @@ public class FileDisplayActivity extends FileActivity
         } else {
             // pop back
             hideSearchView(getCurrentDir());
+            showSortListGroup(true);
             super.onBackPressed();
         }
     }
@@ -1270,11 +1279,11 @@ public class FileDisplayActivity extends FileActivity
                 String accountName = intent.getStringExtra(FileSyncAdapter.EXTRA_ACCOUNT_NAME);
 
                 String synchFolderRemotePath =
-                        intent.getStringExtra(FileSyncAdapter.EXTRA_FOLDER_PATH);
+                    intent.getStringExtra(FileSyncAdapter.EXTRA_FOLDER_PATH);
                 RemoteOperationResult synchResult = (RemoteOperationResult)
-                        DataHolderUtil.getInstance().retrieve(intent.getStringExtra(FileSyncAdapter.EXTRA_RESULT));
+                    DataHolderUtil.getInstance().retrieve(intent.getStringExtra(FileSyncAdapter.EXTRA_RESULT));
                 boolean sameAccount = getAccount() != null &&
-                        accountName.equals(getAccount().name) && getStorageManager() != null;
+                    accountName.equals(getAccount().name) && getStorageManager() != null;
 
                 if (sameAccount) {
 
@@ -1290,10 +1299,10 @@ public class FileDisplayActivity extends FileActivity
                         if (currentDir == null) {
                             // current folder was removed from the server
                             DisplayUtils.showSnackMessage(
-                                    getActivity(),
-                                    R.string.sync_current_folder_was_removed,
-                                    synchFolderRemotePath
-                            );
+                                getActivity(),
+                                R.string.sync_current_folder_was_removed,
+                                synchFolderRemotePath
+                                                         );
 
                             browseToRoot();
 
@@ -1315,10 +1324,10 @@ public class FileDisplayActivity extends FileActivity
                         }
 
                         mSyncInProgress = !FileSyncAdapter.EVENT_FULL_SYNC_END.equals(event) &&
-                                !RefreshFolderOperation.EVENT_SINGLE_FOLDER_SHARES_SYNCED.equals(event);
+                            !RefreshFolderOperation.EVENT_SINGLE_FOLDER_SHARES_SYNCED.equals(event);
 
                         if (RefreshFolderOperation.EVENT_SINGLE_FOLDER_CONTENTS_SYNCED.equals(event) &&
-                                synchResult != null) {
+                            synchResult != null) {
 
                             if (synchResult.isSuccess()) {
                                 hideInfoBox();
@@ -1363,7 +1372,7 @@ public class FileDisplayActivity extends FileActivity
                 }
 
                 if (synchResult != null && synchResult.getCode().equals(
-                        RemoteOperationResult.ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED)) {
+                    RemoteOperationResult.ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED)) {
                     mLastSslUntrustedServerResult = synchResult;
                 }
             } catch (RuntimeException e) {
@@ -1382,13 +1391,12 @@ public class FileDisplayActivity extends FileActivity
 
     private boolean checkForRemoteOperationError(RemoteOperationResult syncResult) {
         return ResultCode.UNAUTHORIZED.equals(syncResult.getCode()) ||
-                (syncResult.isException() && syncResult.getException()
-                        instanceof AuthenticatorException);
+            (syncResult.isException() && syncResult.getException()
+                instanceof AuthenticatorException);
     }
 
     /**
-     * Show a text message on screen view for notifying user if content is
-     * loading or folder is empty
+     * Show a text message on screen view for notifying user if content is loading or folder is empty
      */
     private void setBackgroundText() {
         final OCFileListFragment ocFileListFragment = getListOfFilesFragment();
@@ -1417,7 +1425,7 @@ public class FileDisplayActivity extends FileActivity
     private class UploadFinishReceiver extends BroadcastReceiver {
         /**
          * Once the file upload has finished -> update view
-         *
+         * <p>
          * {@link BroadcastReceiver} to enable upload feedback in UI
          */
         @Override
@@ -1460,7 +1468,7 @@ public class FileDisplayActivity extends FileActivity
                         getActivity(),
                         R.string.filedetails_renamed_in_upload_msg,
                         newName
-                    );
+                                                 );
                 }
                 if (uploadWasFine || getFile().fileExists()) {
                     ((FileDetailFragment) details).updateFileDetails(false, true);
@@ -1495,9 +1503,8 @@ public class FileDisplayActivity extends FileActivity
 
     /**
      * Class waiting for broadcast events from the {@link FileDownloader} service.
-     *
-     * Updates the UI when a download is started or finished, provided that it is relevant for the
-     * current folder.
+     * <p>
+     * Updates the UI when a download is started or finished, provided that it is relevant for the current folder.
      */
     private class DownloadFinishReceiver extends BroadcastReceiver {
 
@@ -1508,16 +1515,16 @@ public class FileDisplayActivity extends FileActivity
             String downloadBehaviour = intent.getStringExtra(OCFileListFragment.DOWNLOAD_BEHAVIOUR);
             boolean isDescendant = isDescendant(downloadedRemotePath);
 
-                if (sameAccount && isDescendant) {
-                    String linkedToRemotePath = intent.getStringExtra(FileDownloader.EXTRA_LINKED_TO_PATH);
-                    if (linkedToRemotePath == null || isAscendant(linkedToRemotePath)) {
-                        updateListOfFilesFragment(false);
-                    }
-                    refreshSecondFragment(
-                            intent.getAction(),
-                            downloadedRemotePath,
-                            intent.getBooleanExtra(FileDownloader.EXTRA_DOWNLOAD_RESULT, false));
+            if (sameAccount && isDescendant) {
+                String linkedToRemotePath = intent.getStringExtra(FileDownloader.EXTRA_LINKED_TO_PATH);
+                if (linkedToRemotePath == null || isAscendant(linkedToRemotePath)) {
+                    updateListOfFilesFragment(false);
                 }
+                refreshSecondFragment(
+                    intent.getAction(),
+                    downloadedRemotePath,
+                    intent.getBooleanExtra(FileDownloader.EXTRA_DOWNLOAD_RESULT, false));
+            }
 
             if (mWaitingToSend != null) {
                 // update file after downloading
@@ -1541,7 +1548,7 @@ public class FileDisplayActivity extends FileActivity
         private boolean isDescendant(String downloadedRemotePath) {
             OCFile currentDir = getCurrentDir();
             return currentDir != null &&
-                    downloadedRemotePath != null &&
+                downloadedRemotePath != null &&
                 downloadedRemotePath.startsWith(currentDir.getRemotePath());
         }
 
@@ -1571,8 +1578,7 @@ public class FileDisplayActivity extends FileActivity
 
 
     /**
-     * {@inheritDoc}
-     * Updates action bar and second fragment, if in dual pane mode.
+     * {@inheritDoc} Updates action bar and second fragment, if in dual pane mode.
      */
     @Override
     public void onBrowsedDownTo(OCFile directory) {
@@ -1583,8 +1589,7 @@ public class FileDisplayActivity extends FileActivity
     }
 
     /**
-     * Shows the information of the {@link OCFile} received as a
-     * parameter in the second fragment.
+     * Shows the information of the {@link OCFile} received as a parameter in the second fragment.
      *
      * @param file {@link OCFile} whose details will be shown
      */
@@ -1594,10 +1599,9 @@ public class FileDisplayActivity extends FileActivity
     }
 
     /**
-     * Shows the information of the {@link OCFile} received as a
-     * parameter in the second fragment.
+     * Shows the information of the {@link OCFile} received as a parameter in the second fragment.
      *
-     * @param file {@link OCFile} whose details will be shown
+     * @param file      {@link OCFile} whose details will be shown
      * @param activeTab the active tab in the details view
      */
     public void showDetails(OCFile file, int activeTab) {
@@ -1660,7 +1664,7 @@ public class FileDisplayActivity extends FileActivity
                     }
                 }
             } else if (component.equals(new ComponentName(FileDisplayActivity.this,
-                    FileUploader.class))) {
+                                                          FileUploader.class))) {
                 Log_OC.d(TAG, "Upload service connected");
                 mUploaderBinder = (FileUploaderBinder) service;
             } else {
@@ -1670,7 +1674,7 @@ public class FileDisplayActivity extends FileActivity
             // getFileDownloadBinder() - THIS IS A MESS
             OCFileListFragment listOfFiles = getListOfFilesFragment();
             if (listOfFiles != null && (getIntent() == null ||
-                    (getIntent() != null && getIntent().getParcelableExtra(EXTRA_FILE) == null))) {
+                (getIntent() != null && getIntent().getParcelableExtra(EXTRA_FILE) == null))) {
                 listOfFiles.listDirectory(MainApp.isOnlyOnDevice(), false);
             }
             FileFragment secondFragment = getSecondFragment();
@@ -1694,8 +1698,8 @@ public class FileDisplayActivity extends FileActivity
     }
 
     /**
-     * Updates the view associated to the activity after the finish of some operation over files
-     * in the current account.
+     * Updates the view associated to the activity after the finish of some operation over files in the current
+     * account.
      *
      * @param operation Removal operation performed.
      * @param result    Result of the removal.
@@ -1742,8 +1746,7 @@ public class FileDisplayActivity extends FileActivity
     }
 
     /**
-     * Updates the view associated to the activity after the finish of an operation trying to
-     * remove a file.
+     * Updates the view associated to the activity after the finish of an operation trying to remove a file.
      *
      * @param operation Removal operation performed.
      * @param result    Result of the removal.
@@ -1753,7 +1756,7 @@ public class FileDisplayActivity extends FileActivity
 
         if (!operation.isInBackground()) {
             DisplayUtils.showSnackMessage(this, ErrorMessageAdapter.getErrorCauseMessage(result, operation,
-                    getResources()));
+                                                                                         getResources()));
         }
 
         if (result.isSuccess()) {
@@ -1801,8 +1804,9 @@ public class FileDisplayActivity extends FileActivity
             OCFile parent = getStorageManager().getFileById(file.getParentId());
             startSyncFolderOperation(parent, true, true);
 
-            if (getSecondFragment() instanceof FileDetailFragment) {
-                FileDetailFragment fileDetailFragment = (FileDetailFragment) getSecondFragment();
+            FileFragment secondFragment = getSecondFragment();
+            if (secondFragment instanceof FileDetailFragment) {
+                FileDetailFragment fileDetailFragment = (FileDetailFragment) secondFragment;
                 fileDetailFragment.getFileDetailActivitiesFragment().reload();
             }
 
@@ -1820,8 +1824,7 @@ public class FileDisplayActivity extends FileActivity
     }
 
     /**
-     * Updates the view associated to the activity after the finish of an operation trying to move a
-     * file.
+     * Updates the view associated to the activity after the finish of an operation trying to move a file.
      *
      * @param operation Move operation performed.
      * @param result    Result of the move operation.
@@ -1833,8 +1836,8 @@ public class FileDisplayActivity extends FileActivity
         } else {
             try {
                 DisplayUtils.showSnackMessage(
-                        this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources())
-                );
+                    this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources())
+                                             );
 
             } catch (NotFoundException e) {
                 Log_OC.e(TAG, "Error while trying to show fail message ", e);
@@ -1843,8 +1846,7 @@ public class FileDisplayActivity extends FileActivity
     }
 
     /**
-     * Updates the view associated to the activity after the finish of an operation trying to copy a
-     * file.
+     * Updates the view associated to the activity after the finish of an operation trying to copy a file.
      *
      * @param operation Copy operation performed.
      * @param result    Result of the copy operation.
@@ -1855,8 +1857,8 @@ public class FileDisplayActivity extends FileActivity
         } else {
             try {
                 DisplayUtils.showSnackMessage(
-                        this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources())
-                );
+                    this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources())
+                                             );
 
             } catch (NotFoundException e) {
                 Log_OC.e(TAG, "Error while trying to show fail message ", e);
@@ -1865,8 +1867,7 @@ public class FileDisplayActivity extends FileActivity
     }
 
     /**
-     * Updates the view associated to the activity after the finish of an operation trying to rename
-     * a file.
+     * Updates the view associated to the activity after the finish of an operation trying to rename a file.
      *
      * @param operation Renaming operation performed.
      * @param result    Result of the renaming.
@@ -1880,12 +1881,12 @@ public class FileDisplayActivity extends FileActivity
             FileFragment details = getSecondFragment();
             if (details != null) {
                 if (details instanceof FileDetailFragment &&
-                        renamedFile.equals(details.getFile())) {
+                    renamedFile.equals(details.getFile())) {
                     ((FileDetailFragment) details).updateFileDetails(renamedFile, currentUser);
                     showDetails(renamedFile);
 
                 } else if (details instanceof PreviewMediaFragment &&
-                        renamedFile.equals(details.getFile())) {
+                    renamedFile.equals(details.getFile())) {
                     ((PreviewMediaFragment) details).updateFile(renamedFile);
                     if (PreviewMediaFragment.canBePreviewed(renamedFile)) {
                         long position = ((PreviewMediaFragment) details).getPosition();
@@ -1911,8 +1912,8 @@ public class FileDisplayActivity extends FileActivity
 
         } else {
             DisplayUtils.showSnackMessage(
-                    this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources())
-            );
+                this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources())
+                                         );
 
             if (result.isSslRecoverableException()) {
                 mLastSslUntrustedServerResult = result;
@@ -1933,8 +1934,7 @@ public class FileDisplayActivity extends FileActivity
     }
 
     /**
-     * Updates the view associated to the activity after the finish of an operation trying create a
-     * new folder
+     * Updates the view associated to the activity after the finish of an operation trying create a new folder
      *
      * @param operation Creation operation performed.
      * @param result    Result of the creation.
@@ -1952,7 +1952,7 @@ public class FileDisplayActivity extends FileActivity
                     DisplayUtils.showSnackMessage(this, R.string.folder_already_exists);
                 } else {
                     DisplayUtils.showSnackMessage(this, ErrorMessageAdapter.getErrorCauseMessage(result, operation,
-                            getResources()));
+                                                                                                 getResources()));
                 }
             } catch (NotFoundException e) {
                 Log_OC.e(TAG, "Error while trying to show fail message ", e);
@@ -2003,16 +2003,15 @@ public class FileDisplayActivity extends FileActivity
 
     /**
      * Starts an operation to refresh the requested folder.
-     *
+     * <p>
      * The operation is run in a new background thread created on the fly.
-     *
-     * The refresh updates is a "light sync": properties of regular files in folder are updated (including
-     * associated shares), but not their contents. Only the contents of files marked to be kept-in-sync are
-     * synchronized too.
+     * <p>
+     * The refresh updates is a "light sync": properties of regular files in folder are updated (including associated
+     * shares), but not their contents. Only the contents of files marked to be kept-in-sync are synchronized too.
      *
      * @param folder     Folder to refresh.
-     * @param ignoreETag If 'true', the data from the server will be fetched and sync'ed even if the eTag
-     *                   didn't change.
+     * @param ignoreETag If 'true', the data from the server will be fetched and sync'ed even if the eTag didn't
+     *                   change.
      */
     public void startSyncFolderOperation(OCFile folder, boolean ignoreETag) {
         startSyncFolderOperation(folder, ignoreETag, false);
@@ -2020,16 +2019,15 @@ public class FileDisplayActivity extends FileActivity
 
     /**
      * Starts an operation to refresh the requested folder.
-     *
+     * <p>
      * The operation is run in a new background thread created on the fly.
-     *
-     * The refresh updates is a "light sync": properties of regular files in folder are updated (including
-     * associated shares), but not their contents. Only the contents of files marked to be kept-in-sync are
-     * synchronized too.
+     * <p>
+     * The refresh updates is a "light sync": properties of regular files in folder are updated (including associated
+     * shares), but not their contents. Only the contents of files marked to be kept-in-sync are synchronized too.
      *
      * @param folder      Folder to refresh.
-     * @param ignoreETag  If 'true', the data from the server will be fetched and sync'ed even if the eTag
-     *                    didn't change.
+     * @param ignoreETag  If 'true', the data from the server will be fetched and sync'ed even if the eTag didn't
+     *                    change.
      * @param ignoreFocus reloads file list even without focus, e.g. on tablet mode, focus can still be in detail view
      */
     public void startSyncFolderOperation(final OCFile folder, final boolean ignoreETag, boolean ignoreFocus) {
@@ -2038,12 +2036,12 @@ public class FileDisplayActivity extends FileActivity
         // or if the method is called from a dialog that is being dismissed
         if (TextUtils.isEmpty(searchQuery)) {
             getHandler().postDelayed(
-                    new Runnable() {
-                        @Override
-                        public void run() {
-                            if (ignoreFocus || hasWindowFocus()) {
-                                long currentSyncTime = System.currentTimeMillis();
-                                mSyncInProgress = true;
+                new Runnable() {
+                    @Override
+                    public void run() {
+                        if (ignoreFocus || hasWindowFocus()) {
+                            long currentSyncTime = System.currentTimeMillis();
+                            mSyncInProgress = true;
 
                                 // perform folder synchronization
                                 RemoteOperation synchFolderOp = new RefreshFolderOperation(folder,
@@ -2062,20 +2060,20 @@ public class FileDisplayActivity extends FileActivity
                                         null
                                 );
 
-                                OCFileListFragment fragment = getListOfFilesFragment();
+                            OCFileListFragment fragment = getListOfFilesFragment();
 
-                                if (fragment != null) {
-                                    fragment.setLoading(true);
-                                }
+                            if (fragment != null) {
+                                fragment.setLoading(true);
+                            }
 
-                                setBackgroundText();
+                            setBackgroundText();
 
-                            }   // else: NOTHING ; lets' not refresh when the user rotates the device but there is
-                            // another window floating over
-                        }
-                    },
-                    DELAY_TO_REQUEST_REFRESH_OPERATION_LATER
-            );
+                        }   // else: NOTHING ; lets' not refresh when the user rotates the device but there is
+                        // another window floating over
+                    }
+                },
+                DELAY_TO_REQUEST_REFRESH_OPERATION_LATER
+                                    );
         }
     }
 
@@ -2112,11 +2110,10 @@ public class FileDisplayActivity extends FileActivity
     }
 
     /**
-     * Requests the download of the received {@link OCFile} , updates the UI
-     * to monitor the download progress and prepares the activity to send the file
-     * when the download finishes.
+     * Requests the download of the received {@link OCFile} , updates the UI to monitor the download progress and
+     * prepares the activity to send the file when the download finishes.
      *
-     * @param file {@link OCFile} to download and preview.
+     * @param file         {@link OCFile} to download and preview.
      * @param packageName
      * @param activityName
      */
@@ -2257,9 +2254,8 @@ public class FileDisplayActivity extends FileActivity
     }
 
     /**
-     * Requests the download of the received {@link OCFile} , updates the UI
-     * to monitor the download progress and prepares the activity to preview
-     * or open the file when the download finishes.
+     * Requests the download of the received {@link OCFile} , updates the UI to monitor the download progress and
+     * prepares the activity to preview or open the file when the download finishes.
      *
      * @param file {@link OCFile} to download and preview.
      */
@@ -2355,11 +2351,11 @@ public class FileDisplayActivity extends FileActivity
                               (long) bundle.get(PreviewVideoActivity.EXTRA_START_POSITION),
                               (boolean) bundle.get(PreviewVideoActivity.EXTRA_AUTOPLAY), true, true);
         } else if (bundle.containsKey(PreviewImageActivity.EXTRA_VIRTUAL_TYPE)) {
-            startImagePreview((OCFile)bundle.get(EXTRA_FILE),
-                    (VirtualFolderType)bundle.get(PreviewImageActivity.EXTRA_VIRTUAL_TYPE),
-                    true);
+            startImagePreview((OCFile) bundle.get(EXTRA_FILE),
+                              (VirtualFolderType) bundle.get(PreviewImageActivity.EXTRA_VIRTUAL_TYPE),
+                              true);
         } else {
-            startImagePreview((OCFile)bundle.get(EXTRA_FILE),true);
+            startImagePreview((OCFile) bundle.get(EXTRA_FILE), true);
         }
     }
 
@@ -2513,4 +2509,40 @@ public class FileDisplayActivity extends FileActivity
         fetchRemoteFileTask.execute();
 
     }
+
+    public void performUnifiedSearch(String query) {
+        UnifiedSearchFragment unifiedSearchFragment = UnifiedSearchFragment.Companion.newInstance(query);
+        setLeftFragment(unifiedSearchFragment);
+    }
+
+    public void setMainFabVisible(final Boolean visible) {
+        final int visibility = visible ? View.VISIBLE : View.GONE;
+        binding.fabMain.setVisibility(visibility);
+    }
+
+    public void showFile(String message) {
+        dismissLoadingDialog();
+
+        final Fragment leftFragment = getLeftFragment();
+        OCFileListFragment listOfFiles = null;
+        if (leftFragment instanceof OCFileListFragment) {
+            listOfFiles = (OCFileListFragment) leftFragment;
+        } else {
+            listOfFiles = new OCFileListFragment();
+            Bundle args = new Bundle();
+            args.putBoolean(OCFileListFragment.ARG_ALLOW_CONTEXTUAL_ACTIONS, true);
+            listOfFiles.setArguments(args);
+            setLeftFragment(listOfFiles);
+            getSupportFragmentManager().executePendingTransactions();
+        }
+
+        if (TextUtils.isEmpty(message)) {
+            OCFile temp = getFile();
+            setFile(getCurrentDir());
+            listOfFiles.listDirectory(getCurrentDir(), temp, MainApp.isOnlyOnDevice(), false);
+            updateActionBarTitleAndHomeButton(null);
+        } else {
+            DisplayUtils.showSnackMessage(listOfFiles.getView(), message);
+        }
+    }
 }

+ 1 - 1
src/main/java/com/owncloud/android/ui/activity/ToolbarActivity.java

@@ -179,7 +179,7 @@ public abstract class ToolbarActivity extends BaseActivity {
     /**
      * Updates title bar and home buttons (state and icon).
      */
-    protected void updateActionBarTitleAndHomeButtonByString(String title) {
+    public void updateActionBarTitleAndHomeButtonByString(String title) {
         String titleToSet = getString(R.string.app_name);    // default
 
         if (title != null) {

+ 4 - 2
src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java

@@ -269,10 +269,12 @@ public class ActivityListAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
             } else {
                 placeholder = R.drawable.file_movie;
             }
-            Glide.with(context).using(new CustomGlideStreamLoader(currentAccountProvider, clientFactory))
+            Glide.with(context).using(new CustomGlideStreamLoader(currentAccountProvider.getUser(), clientFactory))
                 .load(previewObject.getSource())
                 .placeholder(placeholder)
-                .error(placeholder)
+                .error(android.R.color.holo_red_light)
+                .diskCacheStrategy(DiskCacheStrategy.NONE)
+                .skipMemoryCache(true)
                 .into(imageView);
         } else {
             if (MimeTypeUtil.isFolder(previewObject.getMimeType())) {

+ 2 - 2
src/main/java/com/owncloud/android/ui/adapter/RichDocumentsTemplateAdapter.java

@@ -150,8 +150,8 @@ public class RichDocumentsTemplateAdapter extends RecyclerView.Adapter<RichDocum
                     break;
             }
 
-            Glide.with(context).using(new CustomGlideStreamLoader(currentAccountProvider, clientFactory)).
-                load(template.getThumbnailLink())
+            Glide.with(context).using(new CustomGlideStreamLoader(currentAccountProvider.getUser(), clientFactory))
+                .load(template.getThumbnailLink())
                 .placeholder(placeholder)
                 .error(placeholder)
                 .into(binding.template);

+ 1 - 1
src/main/java/com/owncloud/android/ui/adapter/TemplateAdapter.java

@@ -134,7 +134,7 @@ public class TemplateAdapter extends RecyclerView.Adapter<TemplateAdapter.ViewHo
                                                                 currentAccountProvider.getUser(),
                                                                 context);
 
-            Glide.with(context).using(new CustomGlideStreamLoader(currentAccountProvider, clientFactory))
+            Glide.with(context).using(new CustomGlideStreamLoader(currentAccountProvider.getUser(), clientFactory))
                 .load(template.getPreview())
                 .placeholder(placeholder)
                 .error(placeholder)

+ 42 - 0
src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchFooterViewHolder.kt

@@ -0,0 +1,42 @@
+/*
+ *
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2021 Álvaro Brey Vilas
+ * Copyright (C) 2021 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package com.owncloud.android.ui.adapter
+
+import android.content.Context
+import com.afollestad.sectionedrecyclerview.SectionedViewHolder
+import com.owncloud.android.databinding.UnifiedSearchFooterBinding
+import com.owncloud.android.ui.interfaces.UnifiedSearchListInterface
+import com.owncloud.android.ui.unifiedsearch.UnifiedSearchSection
+
+class UnifiedSearchFooterViewHolder(
+    val binding: UnifiedSearchFooterBinding,
+    val context: Context,
+    private val listInterface: UnifiedSearchListInterface,
+) :
+    SectionedViewHolder(binding.root) {
+
+    fun bind(section: UnifiedSearchSection) {
+        binding.unifiedSearchFooterLayout.setOnClickListener {
+            listInterface.onLoadMoreClicked(section.providerID)
+        }
+    }
+}

+ 37 - 0
src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchHeaderViewHolder.kt

@@ -0,0 +1,37 @@
+/*
+ *
+ * Nextcloud Android client application
+ *
+ * @author Tobias Kaminsky
+ * Copyright (C) 2020 Tobias Kaminsky
+ * Copyright (C) 2020 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package com.owncloud.android.ui.adapter
+
+import android.content.Context
+import com.afollestad.sectionedrecyclerview.SectionedViewHolder
+import com.owncloud.android.databinding.UnifiedSearchHeaderBinding
+import com.owncloud.android.ui.unifiedsearch.UnifiedSearchSection
+import com.owncloud.android.utils.theme.ThemeColorUtils
+
+class UnifiedSearchHeaderViewHolder(val binding: UnifiedSearchHeaderBinding, val context: Context) :
+    SectionedViewHolder(binding.root) {
+
+    fun bind(section: UnifiedSearchSection) {
+        binding.title.text = section.name
+        binding.title.setTextColor(ThemeColorUtils.primaryColor(context))
+    }
+}

+ 135 - 0
src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchItemViewHolder.kt

@@ -0,0 +1,135 @@
+/*
+ *
+ * Nextcloud Android client application
+ *
+ * @author Tobias Kaminsky
+ * Copyright (C) 2020 Tobias Kaminsky
+ * Copyright (C) 2020 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package com.owncloud.android.ui.adapter
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffColorFilter
+import android.graphics.drawable.Drawable
+import android.view.View
+import androidx.core.content.res.ResourcesCompat
+import com.afollestad.sectionedrecyclerview.SectionedViewHolder
+import com.bumptech.glide.Glide
+import com.bumptech.glide.request.RequestListener
+import com.bumptech.glide.request.target.Target
+import com.nextcloud.client.account.User
+import com.nextcloud.client.network.ClientFactory
+import com.owncloud.android.R
+import com.owncloud.android.databinding.UnifiedSearchItemBinding
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.lib.common.SearchResultEntry
+import com.owncloud.android.ui.interfaces.UnifiedSearchListInterface
+import com.owncloud.android.utils.BitmapUtils
+import com.owncloud.android.utils.MimeTypeUtil
+import com.owncloud.android.utils.glide.CustomGlideStreamLoader
+import com.owncloud.android.utils.theme.ThemeColorUtils
+
+@Suppress("LongParameterList")
+class UnifiedSearchItemViewHolder(
+    val binding: UnifiedSearchItemBinding,
+    val user: User,
+    val clientFactory: ClientFactory,
+    private val storageManager: FileDataStorageManager,
+    private val listInterface: UnifiedSearchListInterface,
+    val context: Context
+) :
+    SectionedViewHolder(binding.root) {
+
+    fun bind(entry: SearchResultEntry) {
+        binding.title.text = entry.title
+        binding.subline.text = entry.subline
+
+        storageManager.getFileByDecryptedRemotePath(entry.remotePath())?.let {
+            if (it.isDown) {
+                binding.localFileIndicator.visibility = View.VISIBLE
+            } else {
+                binding.localFileIndicator.visibility = View.GONE
+            }
+        }
+
+        val mimetype = MimeTypeUtil.getBestMimeTypeByFilename(entry.title)
+
+        val placeholder = getPlaceholder(entry, mimetype)
+
+        Glide.with(context).using(CustomGlideStreamLoader(user, clientFactory))
+            .load(entry.thumbnailUrl)
+            .asBitmap()
+            .placeholder(placeholder)
+            .error(placeholder)
+            .animate(android.R.anim.fade_in)
+            .listener(RoundIfNeededListener(entry))
+            .into(binding.thumbnail)
+
+        binding.unifiedSearchItemLayout.setOnClickListener { listInterface.onSearchResultClicked(entry) }
+    }
+
+    private fun getPlaceholder(
+        entry: SearchResultEntry,
+        mimetype: String?
+    ): Drawable {
+        val drawable = with(entry.icon) {
+            when {
+                equals("icon-folder") ->
+                    ResourcesCompat.getDrawable(context.resources, R.drawable.folder, null)
+                startsWith("icon-note") ->
+                    ResourcesCompat.getDrawable(context.resources, R.drawable.ic_edit, null)
+                startsWith("icon-contacts") ->
+                    ResourcesCompat.getDrawable(context.resources, R.drawable.file_vcard, null)
+                startsWith("icon-calendar") ->
+                    ResourcesCompat.getDrawable(context.resources, R.drawable.file_calendar, null)
+                startsWith("icon-deck") ->
+                    ResourcesCompat.getDrawable(context.resources, R.drawable.ic_deck, null)
+                else ->
+                    MimeTypeUtil.getFileTypeIcon(mimetype, entry.title, context)
+            }
+        }
+        val color = ThemeColorUtils.primaryColor(context)
+        drawable!!.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP)
+        return drawable
+    }
+
+    private inner class RoundIfNeededListener(private val entry: SearchResultEntry) :
+        RequestListener<String, Bitmap> {
+        override fun onException(
+            e: Exception?,
+            model: String?,
+            target: Target<Bitmap>?,
+            isFirstResource: Boolean
+        ): Boolean = false
+
+        override fun onResourceReady(
+            resource: Bitmap?,
+            model: String?,
+            target: Target<Bitmap>?,
+            isFromMemoryCache: Boolean,
+            isFirstResource: Boolean
+        ): Boolean {
+            if (entry.rounded) {
+                val drawable = BitmapUtils.bitmapToCircularBitmapDrawable(context.resources, resource)
+                binding.thumbnail.setImageDrawable(drawable)
+                return true
+            }
+            return false
+        }
+    }
+}

+ 158 - 0
src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchListAdapter.kt

@@ -0,0 +1,158 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Tobias Kaminsky
+ * @author Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * Copyright (C) 2018 Tobias Kaminsky
+ * Copyright (C) 2018 Nextcloud
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.owncloud.android.ui.adapter
+
+import android.content.Context
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.ui.interfaces.UnifiedSearchListInterface
+import com.nextcloud.client.account.User
+import com.nextcloud.client.network.ClientFactory
+import com.afollestad.sectionedrecyclerview.SectionedRecyclerViewAdapter
+import com.afollestad.sectionedrecyclerview.SectionedViewHolder
+import android.view.ViewGroup
+import android.view.LayoutInflater
+import android.view.View
+import com.owncloud.android.R
+import com.owncloud.android.databinding.UnifiedSearchEmptyBinding
+import com.owncloud.android.databinding.UnifiedSearchFooterBinding
+import com.owncloud.android.databinding.UnifiedSearchHeaderBinding
+import com.owncloud.android.databinding.UnifiedSearchItemBinding
+import com.owncloud.android.datamodel.ThumbnailsCacheManager.InitDiskCacheTask
+import com.owncloud.android.ui.unifiedsearch.UnifiedSearchSection
+import java.lang.IllegalArgumentException
+
+/**
+ * This Adapter populates a SectionedRecyclerView with search results by unified search
+ */
+class UnifiedSearchListAdapter(
+    private val storageManager: FileDataStorageManager,
+    private val listInterface: UnifiedSearchListInterface,
+    private val user: User,
+    private val clientFactory: ClientFactory,
+    private val context: Context
+) : SectionedRecyclerViewAdapter<SectionedViewHolder>() {
+    companion object {
+        private const val VIEW_TYPE_EMPTY = Int.MAX_VALUE
+    }
+
+    private var sections: List<UnifiedSearchSection> = emptyList()
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionedViewHolder {
+        val layoutInflater = LayoutInflater.from(context)
+        return when (viewType) {
+            VIEW_TYPE_HEADER -> {
+                val binding = UnifiedSearchHeaderBinding.inflate(
+                    layoutInflater, parent, false
+                )
+                UnifiedSearchHeaderViewHolder(binding, context)
+            }
+            VIEW_TYPE_FOOTER -> {
+                val binding = UnifiedSearchFooterBinding.inflate(
+                    layoutInflater, parent, false
+                )
+                UnifiedSearchFooterViewHolder(binding, context, listInterface)
+            }
+            VIEW_TYPE_ITEM -> {
+                val binding = UnifiedSearchItemBinding.inflate(
+                    layoutInflater,
+                    parent,
+                    false
+                )
+                UnifiedSearchItemViewHolder(
+                    binding,
+                    user,
+                    clientFactory,
+                    storageManager,
+                    listInterface,
+                    context
+                )
+            }
+            VIEW_TYPE_EMPTY -> {
+                val binding = UnifiedSearchEmptyBinding.inflate(layoutInflater, parent, false)
+                EmptyViewHolder(binding)
+            }
+            else -> throw IllegalArgumentException("Invalid view type")
+        }
+    }
+
+    internal class EmptyViewHolder(binding: UnifiedSearchEmptyBinding) :
+        SectionedViewHolder(binding.getRoot())
+
+    override fun getSectionCount(): Int {
+        return sections.size
+    }
+
+    override fun getItemCount(section: Int): Int {
+        return sections[section].entries.size
+    }
+
+    override fun onBindHeaderViewHolder(holder: SectionedViewHolder, section: Int, expanded: Boolean) {
+        val headerViewHolder = holder as UnifiedSearchHeaderViewHolder
+        headerViewHolder.bind(sections[section])
+    }
+
+    override fun onBindFooterViewHolder(holder: SectionedViewHolder, section: Int) {
+        if (sections[section].hasMoreResults) {
+            val footerViewHolder = holder as UnifiedSearchFooterViewHolder
+            footerViewHolder.bind(sections[section])
+        }
+    }
+
+    override fun getFooterViewType(section: Int): Int = when {
+        sections[section].hasMoreResults -> VIEW_TYPE_FOOTER
+        else -> VIEW_TYPE_EMPTY
+    }
+
+    override fun onBindViewHolder(
+        holder: SectionedViewHolder,
+        section: Int,
+        relativePosition: Int,
+        absolutePosition: Int
+    ) {
+        // TODO different binding (and also maybe diff UI) for non-file results
+        val itemViewHolder = holder as UnifiedSearchItemViewHolder
+        val entry = sections[section].entries[relativePosition]
+        itemViewHolder.bind(entry)
+    }
+
+    override fun onViewAttachedToWindow(holder: SectionedViewHolder) {
+        if (holder is UnifiedSearchItemViewHolder) {
+            val thumbnailShimmer = holder.binding.thumbnailShimmer
+            if (thumbnailShimmer.visibility == View.VISIBLE) {
+                thumbnailShimmer.setImageResource(R.drawable.background)
+                thumbnailShimmer.resetLoader()
+            }
+        }
+    }
+
+    fun setData(sections: List<UnifiedSearchSection>) {
+        this.sections = sections
+        notifyDataSetChanged()
+    }
+
+    init {
+        // initialise thumbnails cache on background thread
+        InitDiskCacheTask().execute()
+    }
+}

+ 1 - 17
src/main/java/com/owncloud/android/ui/asynctasks/FetchRemoteFileTask.java

@@ -22,10 +22,8 @@
 package com.owncloud.android.ui.asynctasks;
 
 import android.os.AsyncTask;
-import android.text.TextUtils;
 
 import com.nextcloud.client.account.User;
-import com.owncloud.android.MainApp;
 import com.owncloud.android.R;
 import com.owncloud.android.datamodel.FileDataStorageManager;
 import com.owncloud.android.datamodel.OCFile;
@@ -36,8 +34,6 @@ import com.owncloud.android.lib.resources.files.SearchRemoteOperation;
 import com.owncloud.android.lib.resources.files.model.RemoteFile;
 import com.owncloud.android.operations.RefreshFolderOperation;
 import com.owncloud.android.ui.activity.FileDisplayActivity;
-import com.owncloud.android.ui.fragment.OCFileListFragment;
-import com.owncloud.android.utils.DisplayUtils;
 import com.owncloud.android.utils.FileStorageUtils;
 
 import static com.owncloud.android.lib.resources.files.SearchRemoteOperation.SearchType.FILE_ID_SEARCH;
@@ -124,18 +120,6 @@ public class FetchRemoteFileTask extends AsyncTask<Void, Void, String> {
     protected void onPostExecute(String message) {
         super.onPostExecute(message);
 
-        fileDisplayActivity.dismissLoadingDialog();
-
-        OCFileListFragment listOfFiles = fileDisplayActivity.getListOfFilesFragment();
-        if (listOfFiles != null) {
-            if (TextUtils.isEmpty(message)) {
-                OCFile temp = fileDisplayActivity.getFile();
-                fileDisplayActivity.setFile(fileDisplayActivity.getCurrentDir());
-                listOfFiles.listDirectory(fileDisplayActivity.getCurrentDir(), temp, MainApp.isOnlyOnDevice(), false);
-                fileDisplayActivity.updateActionBarTitleAndHomeButton(null);
-            } else {
-                DisplayUtils.showSnackMessage(listOfFiles.getView(), message);
-            }
-        }
+        fileDisplayActivity.showFile(message);
     }
 }

+ 67 - 0
src/main/java/com/owncloud/android/ui/asynctasks/GetRemoteFileTask.kt

@@ -0,0 +1,67 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.owncloud.android.ui.asynctasks
+
+import android.content.Context
+import com.nextcloud.client.account.User
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation
+import com.owncloud.android.lib.resources.files.model.RemoteFile
+import com.owncloud.android.operations.RefreshFolderOperation
+import com.owncloud.android.utils.FileStorageUtils
+
+class GetRemoteFileTask(
+    private val context: Context,
+    private val fileUrl: String,
+    private val client: OwnCloudClient,
+    private val storageManager: FileDataStorageManager,
+    private val user: User
+) : () -> GetRemoteFileTask.Result {
+
+    data class Result(val success: Boolean = false, val file: OCFile = OCFile("/"))
+
+    override fun invoke(): Result {
+        val result = ReadFileRemoteOperation(fileUrl).execute(client)
+        if (result.isSuccess) {
+            val remoteFile = result.getData().get(0) as RemoteFile
+            val temp = FileStorageUtils.fillOCFile(remoteFile)
+            val remoteOcFile = storageManager.saveFileWithParent(temp, context)
+            if (remoteOcFile.isFolder()) {
+                // perform folder synchronization
+                val synchFolderOp: RemoteOperation<Any> = RefreshFolderOperation(
+                    remoteOcFile,
+                    System.currentTimeMillis(),
+                    false,
+                    true,
+                    storageManager,
+                    user,
+                    context
+                )
+                synchFolderOp.execute(client)
+            }
+            return Result(true, remoteOcFile)
+        } else {
+            return Result(false, OCFile(""))
+        }
+    }
+}

+ 35 - 13
src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.java

@@ -57,8 +57,10 @@ import com.nextcloud.client.preferences.AppPreferences;
 import com.nextcloud.client.preferences.AppPreferencesImpl;
 import com.owncloud.android.MainApp;
 import com.owncloud.android.R;
+import com.owncloud.android.databinding.ListFragmentBinding;
 import com.owncloud.android.lib.common.utils.Log_OC;
 import com.owncloud.android.lib.resources.files.SearchRemoteOperation;
+import com.owncloud.android.lib.resources.status.OwnCloudVersion;
 import com.owncloud.android.ui.EmptyRecyclerView;
 import com.owncloud.android.ui.activity.FileDisplayActivity;
 import com.owncloud.android.ui.activity.FolderPickerActivity;
@@ -141,6 +143,8 @@ public class ExtendedListFragment extends Fragment implements
 
     private float mScale = AppPreferencesImpl.DEFAULT_GRID_COLUMN;
 
+    private ListFragmentBinding binding;
+
     @Parcel
     public enum SearchType {
         NO_SEARCH,
@@ -220,9 +224,9 @@ public class ExtendedListFragment extends Fragment implements
             if (getActivity() != null && !(getActivity() instanceof FolderPickerActivity)
                 && !(getActivity() instanceof UploadFilesActivity)) {
                 if (getActivity() instanceof FileDisplayActivity) {
-                    OCFileListFragment fileFragment = ((FileDisplayActivity) getActivity()).getListOfFilesFragment();
-                    if (fileFragment != null) {
-                        fileFragment.setFabVisible(!hasFocus);
+                    Fragment fragment = ((FileDisplayActivity) getActivity()).getLeftFragment();
+                    if (fragment instanceof OCFileListFragment) {
+                        ((OCFileListFragment) fragment).setFabVisible(!hasFocus);
                     }
                 }
                 if (TextUtils.isEmpty(searchView.getQuery())) {
@@ -317,8 +321,18 @@ public class ExtendedListFragment extends Fragment implements
                 } else {
                     handler.post(() -> {
                         if (adapter instanceof OCFileListAdapter) {
-                            EventBus.getDefault().post(new SearchEvent(query,
-                                                                       SearchRemoteOperation.SearchType.FILE_SEARCH));
+                            if (accountManager
+                                .getUser()
+                                .getServer()
+                                .getVersion()
+                                .isNewerOrEqual(OwnCloudVersion.nextcloud_20)
+                            ) {
+                                ((FileDisplayActivity) activity).performUnifiedSearch(query);
+                            } else {
+                                EventBus.getDefault().post(
+                                    new SearchEvent(query, SearchRemoteOperation.SearchType.FILE_SEARCH)
+                                                          );
+                            }
                         } else if (adapter instanceof LocalFileListAdapter) {
                             LocalFileListAdapter localFileListAdapter = (LocalFileListAdapter) adapter;
                             localFileListAdapter.filter(query);
@@ -355,12 +369,14 @@ public class ExtendedListFragment extends Fragment implements
     public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
         Log_OC.d(TAG, "onCreateView");
 
-        View v = inflater.inflate(R.layout.list_fragment, null);
+        binding = ListFragmentBinding.inflate(inflater, container, false);
+        View v = binding.getRoot();
+
         setupEmptyList(v);
 
-        mRecyclerView = v.findViewById(R.id.list_root);
+        mRecyclerView = binding.listRoot;
         mRecyclerView.setHasFooter(true);
-        mRecyclerView.setEmptyView(v.findViewById(R.id.empty_list_view));
+        mRecyclerView.setEmptyView(binding.emptyList.emptyListView);
         mRecyclerView.setHasFixedSize(true);
         mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
 
@@ -380,7 +396,7 @@ public class ExtendedListFragment extends Fragment implements
         });
 
         // Pull-down to refresh layout
-        mRefreshListLayout = v.findViewById(R.id.swipe_containing_list);
+        mRefreshListLayout = binding.swipeContainingList;
         ThemeLayoutUtils.colorSwipeRefreshLayout(getContext(), mRefreshListLayout);
         mRefreshListLayout.setOnRefreshListener(this);
 
@@ -390,6 +406,12 @@ public class ExtendedListFragment extends Fragment implements
         return v;
     }
 
+    @Override
+    public void onDestroyView() {
+        super.onDestroyView();
+        binding = null;
+    }
+
     private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
         @Override
         public boolean onScale(ScaleGestureDetector detector) {
@@ -419,10 +441,10 @@ public class ExtendedListFragment extends Fragment implements
     }
 
     protected void setupEmptyList(View view) {
-        mEmptyListContainer = view.findViewById(R.id.empty_list_view);
-        mEmptyListMessage = view.findViewById(R.id.empty_list_view_text);
-        mEmptyListHeadline = view.findViewById(R.id.empty_list_view_headline);
-        mEmptyListIcon = view.findViewById(R.id.empty_list_icon);
+        mEmptyListContainer = binding.emptyList.emptyListView;
+        mEmptyListMessage = binding.emptyList.emptyListViewText;
+        mEmptyListHeadline = binding.emptyList.emptyListViewHeadline;
+        mEmptyListIcon = binding.emptyList.emptyListIcon;
     }
 
     /**

+ 228 - 0
src/main/java/com/owncloud/android/ui/fragment/UnifiedSearchFragment.kt

@@ -0,0 +1,228 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Tobias Kaminsky
+ * Copyright (C) 2020 Tobias Kaminsky
+ * Copyright (C) 2020 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.fragment
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.widget.SearchView
+import androidx.core.view.MenuItemCompat
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.GridLayoutManager
+import com.nextcloud.client.account.CurrentAccountProvider
+import com.nextcloud.client.core.AsyncRunner
+import com.nextcloud.client.di.Injectable
+import com.nextcloud.client.di.ViewModelFactory
+import com.nextcloud.client.network.ClientFactory
+import com.owncloud.android.R
+import com.owncloud.android.databinding.ListFragmentBinding
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.lib.common.SearchResultEntry
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.ui.activity.FileDisplayActivity
+import com.owncloud.android.ui.adapter.UnifiedSearchListAdapter
+import com.owncloud.android.ui.interfaces.UnifiedSearchListInterface
+import com.owncloud.android.ui.unifiedsearch.ProviderID
+import com.owncloud.android.ui.unifiedsearch.UnifiedSearchSection
+import com.owncloud.android.ui.unifiedsearch.UnifiedSearchViewModel
+import com.owncloud.android.utils.DisplayUtils
+import javax.inject.Inject
+import android.content.Intent
+import androidx.core.view.updatePadding
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.ui.unifiedsearch.IUnifiedSearchViewModel
+
+/**
+ * Starts query to all capable unified search providers and displays them Opens result in our app, redirect to other
+ * apps, if installed, or opens browser
+ */
+class UnifiedSearchFragment : Fragment(), Injectable, UnifiedSearchListInterface, SearchView.OnQueryTextListener {
+    private lateinit var adapter: UnifiedSearchListAdapter
+    private var _binding: ListFragmentBinding? = null
+    private val binding get() = _binding!!
+    lateinit var vm: IUnifiedSearchViewModel
+
+    @Inject
+    lateinit var vmFactory: ViewModelFactory
+
+    @Inject
+    lateinit var storageManager: FileDataStorageManager
+
+    @Inject
+    lateinit var runner: AsyncRunner
+
+    @Inject
+    lateinit var currentAccountProvider: CurrentAccountProvider
+
+    @Inject
+    lateinit var clientFactory: ClientFactory
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        vm = ViewModelProvider(this, vmFactory).get(UnifiedSearchViewModel::class.java)
+        setUpViewModel()
+
+        val query = savedInstanceState?.getString(ARG_QUERY) ?: arguments?.getString(ARG_QUERY)
+        if (!query.isNullOrEmpty()) {
+            vm.setQuery(query)
+            vm.initialQuery()
+        }
+    }
+
+    private fun setUpViewModel() {
+        vm.searchResults.observe(this, this::onSearchResultChanged)
+        vm.isLoading.observe(this) { loading ->
+            binding.swipeContainingList.isRefreshing = loading
+        }
+        vm.error.observe(this) { error ->
+            if (!error.isNullOrEmpty()) {
+                DisplayUtils.showSnackMessage(binding.root, error)
+            }
+        }
+        vm.query.observe(this) { query ->
+            if (activity is FileDisplayActivity) {
+                (activity as FileDisplayActivity)
+                    .updateActionBarTitleAndHomeButtonByString("\"${query}\"")
+            }
+        }
+        vm.browserUri.observe(this) { uri ->
+            val browserIntent = Intent(Intent.ACTION_VIEW, uri)
+            startActivity(browserIntent)
+        }
+        vm.file.observe(this) {
+            showFile(it)
+        }
+    }
+
+    private fun setUpBinding() {
+        binding.swipeContainingList.setOnRefreshListener {
+            vm.initialQuery()
+        }
+    }
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+        _binding = ListFragmentBinding.inflate(inflater, container, false)
+        binding.listRoot.updatePadding(top = resources.getDimension(R.dimen.standard_half_padding).toInt())
+        setUpBinding()
+        setHasOptionsMenu(true)
+        return binding.root
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        if (activity is FileDisplayActivity) {
+            val fileDisplayActivity = activity as FileDisplayActivity
+            fileDisplayActivity.setMainFabVisible(false)
+            fileDisplayActivity.updateActionBarTitleAndHomeButtonByString("\"${vm.query.value!!}\"")
+        }
+
+        val gridLayoutManager = GridLayoutManager(requireContext(), 1)
+        adapter = UnifiedSearchListAdapter(
+            storageManager,
+            this,
+            currentAccountProvider.user,
+            clientFactory,
+            requireContext()
+        )
+        adapter.shouldShowFooters(true)
+        adapter.setLayoutManager(gridLayoutManager)
+        binding.listRoot.layoutManager = gridLayoutManager
+        binding.listRoot.adapter = adapter
+    }
+
+    override fun onDestroyView() {
+        super.onDestroyView()
+        _binding = null
+    }
+
+    private fun showFile(file: OCFile) {
+        activity.let {
+            if (activity is FileDisplayActivity) {
+                val fda = activity as FileDisplayActivity
+                fda.file = file
+                fda.showFile("")
+            }
+        }
+    }
+
+    override fun onSearchResultClicked(searchResultEntry: SearchResultEntry) {
+        vm.openResult(searchResultEntry)
+    }
+
+    override fun onLoadMoreClicked(providerID: ProviderID) {
+        vm.loadMore(providerID)
+    }
+
+    @VisibleForTesting
+    fun onSearchResultChanged(result: List<UnifiedSearchSection>) {
+        Log_OC.d(TAG, "result")
+        binding.emptyList.emptyListView.visibility = View.GONE
+
+        adapter.setData(result)
+    }
+
+    @VisibleForTesting
+    fun setViewModel(testViewModel: IUnifiedSearchViewModel) {
+        vm = testViewModel
+        setUpViewModel()
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+        val item = menu.findItem(R.id.action_search)
+        val sv = MenuItemCompat.getActionView(item) as SearchView
+        sv.setQuery(vm.query.value, false)
+        sv.setOnQueryTextListener(this)
+        sv.isIconified = false
+    }
+
+    companion object {
+        private const val TAG = "UnifiedSearchFragment"
+        const val ARG_QUERY = "ARG_QUERY"
+
+        /**
+         * Public factory method to get fragment.
+         */
+        fun newInstance(query: String?): UnifiedSearchFragment {
+            val fragment = UnifiedSearchFragment()
+            val args = Bundle()
+            args.putString(ARG_QUERY, query)
+            fragment.arguments = args
+            return fragment
+        }
+    }
+
+    override fun onQueryTextSubmit(query: String): Boolean {
+        vm.setQuery(query)
+        vm.initialQuery()
+        return true
+    }
+
+    override fun onQueryTextChange(newText: String?): Boolean {
+        // noop
+        return true
+    }
+}

+ 32 - 0
src/main/java/com/owncloud/android/ui/interfaces/UnifiedSearchListInterface.kt

@@ -0,0 +1,32 @@
+/*
+ *
+ * Nextcloud Android client application
+ *
+ * @author Tobias Kaminsky
+ * Copyright (C) 2020 Tobias Kaminsky
+ * Copyright (C) 2020 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.owncloud.android.ui.interfaces
+
+import com.owncloud.android.lib.common.SearchResultEntry
+import com.owncloud.android.ui.unifiedsearch.ProviderID
+
+interface UnifiedSearchListInterface {
+
+    fun onSearchResultClicked(searchResultEntry: SearchResultEntry)
+    fun onLoadMoreClicked(providerID: ProviderID)
+}

+ 52 - 0
src/main/java/com/owncloud/android/ui/unifiedsearch/GetSearchProvidersTask.kt

@@ -0,0 +1,52 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.owncloud.android.ui.unifiedsearch
+
+import com.nextcloud.android.lib.resources.search.UnifiedSearchProvidersRemoteOperation
+import com.nextcloud.common.NextcloudClient
+import com.owncloud.android.lib.common.SearchProviders
+import com.owncloud.android.lib.common.utils.Log_OC
+
+class GetSearchProvidersTask(
+    private val client: NextcloudClient
+) : () -> GetSearchProvidersTask.Result {
+
+    companion object {
+        const val TAG = "GetSearchProviders"
+    }
+
+    data class Result(val success: Boolean = false, val providers: SearchProviders = SearchProviders())
+
+    override fun invoke(): Result {
+        Log_OC.d(TAG, "Getting search providers")
+        val result = UnifiedSearchProvidersRemoteOperation().execute(client)
+
+        Log_OC.d(TAG, "Task finished: " + result.isSuccess)
+        return when {
+            result.isSuccess && result.resultData != null -> {
+                Result(
+                    success = true,
+                    providers = result.resultData
+                )
+            }
+            else -> Result()
+        }
+    }
+}

+ 46 - 0
src/main/java/com/owncloud/android/ui/unifiedsearch/IUnifiedSearchRepository.kt

@@ -0,0 +1,46 @@
+/*
+ *
+ * Nextcloud Android client application
+ *
+ * @author Tobias Kaminsky
+ * Copyright (C) 2020 Tobias Kaminsky
+ * Copyright (C) 2020 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.owncloud.android.ui.unifiedsearch
+
+import com.owncloud.android.lib.common.SearchResult
+
+data class UnifiedSearchResult(val provider: ProviderID, val success: Boolean, val result: SearchResult)
+
+@Suppress("LongParameterList")
+interface IUnifiedSearchRepository {
+    fun queryAll(
+        query: String,
+        onResult: (UnifiedSearchResult) -> Unit,
+        onError: (Throwable) -> Unit,
+        onFinished: (Boolean) -> Unit
+    )
+
+    fun queryProvider(
+        query: String,
+        provider: ProviderID,
+        cursor: Int?,
+        onResult: (UnifiedSearchResult) -> Unit,
+        onError: (Throwable) -> Unit,
+        onFinished: (Boolean) -> Unit
+    )
+}

+ 20 - 0
src/main/java/com/owncloud/android/ui/unifiedsearch/IUnifiedSearchViewModel.kt

@@ -0,0 +1,20 @@
+package com.owncloud.android.ui.unifiedsearch
+
+import android.net.Uri
+import androidx.lifecycle.LiveData
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.SearchResultEntry
+
+interface IUnifiedSearchViewModel {
+    val browserUri: LiveData<Uri>
+    val error: LiveData<String>
+    val file: LiveData<OCFile>
+    val isLoading: LiveData<Boolean>
+    val query: LiveData<String>
+    val searchResults: LiveData<List<UnifiedSearchSection>>
+
+    fun initialQuery()
+    fun loadMore(provider: ProviderID)
+    fun openResult(result: SearchResultEntry)
+    fun setQuery(query: String)
+}

+ 54 - 0
src/main/java/com/owncloud/android/ui/unifiedsearch/SearchOnProviderTask.kt

@@ -0,0 +1,54 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.owncloud.android.ui.unifiedsearch
+
+import com.nextcloud.android.lib.resources.search.UnifiedSearchRemoteOperation
+import com.nextcloud.common.NextcloudClient
+import com.owncloud.android.lib.common.SearchResult
+import com.owncloud.android.lib.common.utils.Log_OC
+
+class SearchOnProviderTask(
+    private val query: String,
+    private val provider: String,
+    private val client: NextcloudClient,
+    private val cursor: Int? = null,
+    private val limit: Int = 5
+) : () -> SearchOnProviderTask.Result {
+    companion object {
+        private const val TAG = "SearchOnProviderTask"
+    }
+
+    data class Result(val success: Boolean = false, val searchResult: SearchResult = SearchResult())
+
+    override fun invoke(): Result {
+        Log_OC.d(TAG, "Run task")
+        val result = UnifiedSearchRemoteOperation(provider, query, cursor, limit).execute(client)
+
+        Log_OC.d(TAG, "Task finished: " + result.isSuccess)
+        return if (result.isSuccess && result.resultData != null) {
+            Result(
+                success = true,
+                searchResult = result.resultData as SearchResult
+            )
+        } else {
+            Result()
+        }
+    }
+}

+ 12 - 0
src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchModel.kt

@@ -0,0 +1,12 @@
+package com.owncloud.android.ui.unifiedsearch
+
+import com.owncloud.android.lib.common.SearchResultEntry
+
+typealias ProviderID = String
+
+data class UnifiedSearchSection(
+    val providerID: ProviderID,
+    val name: String,
+    val entries: List<SearchResultEntry>,
+    val hasMoreResults: Boolean
+)

+ 117 - 0
src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchRemoteRepository.kt

@@ -0,0 +1,117 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Tobias Kaminsky
+ * Copyright (C) 2020 Tobias Kaminsky
+ * Copyright (C) 2020 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.owncloud.android.ui.unifiedsearch
+
+import com.nextcloud.client.account.CurrentAccountProvider
+import com.nextcloud.client.core.AsyncRunner
+import com.nextcloud.client.network.ClientFactory
+import com.owncloud.android.lib.common.SearchProviders
+import com.owncloud.android.lib.common.utils.Log_OC
+
+class UnifiedSearchRemoteRepository(
+    private val clientFactory: ClientFactory,
+    private val currentAccountProvider: CurrentAccountProvider,
+    private val asyncRunner: AsyncRunner
+) : IUnifiedSearchRepository {
+
+    private var providers: SearchProviders? = null
+
+    override fun queryAll(
+        query: String,
+        onResult: (UnifiedSearchResult) -> Unit,
+        onError: (Throwable) -> Unit,
+        onFinished: (Boolean) -> Unit
+    ) {
+        Log_OC.d(this, "queryAll")
+        fetchProviders(
+            onResult = { result ->
+                val providerIds = result.providers.map { it.id }
+                var openRequests = providerIds.size
+                var anyError = false
+                val client = clientFactory.createNextcloudClient(currentAccountProvider.user)
+                providerIds
+                    .forEach { provider ->
+                        val task = SearchOnProviderTask(query, provider, client)
+                        asyncRunner.postQuickTask(
+                            task = task,
+                            onResult = {
+                                openRequests--
+                                anyError = anyError || !it.success
+                                onResult(UnifiedSearchResult(provider, it.success, it.searchResult))
+                                if (openRequests == 0) {
+                                    onFinished(!anyError)
+                                }
+                            },
+                            onError = {
+                                openRequests--
+                                anyError = true
+                                onError(it)
+                                if (openRequests == 0) {
+                                    onFinished(!anyError)
+                                }
+                            }
+                        )
+                    }
+            },
+            onError = onError
+        )
+    }
+
+    override fun queryProvider(
+        query: String,
+        provider: ProviderID,
+        cursor: Int?,
+        onResult: (UnifiedSearchResult) -> Unit,
+        onError: (Throwable) -> Unit,
+        onFinished: (Boolean) -> Unit
+    ) {
+        Log_OC.d(
+            this,
+            "queryProvider() called with: query = $query, provider = $provider, cursor = $cursor"
+        )
+        val client = clientFactory.createNextcloudClient(currentAccountProvider.user)
+        val task = SearchOnProviderTask(query, provider, client, cursor)
+        asyncRunner.postQuickTask(
+            task,
+            onResult = {
+                onResult(UnifiedSearchResult(provider, it.success, it.searchResult))
+                onFinished(it.success)
+            },
+            onError
+        )
+    }
+
+    fun fetchProviders(onResult: (SearchProviders) -> Unit, onError: (Throwable) -> Unit) {
+        Log_OC.d(this, "fetchProviders")
+        if (this.providers != null) {
+            onResult(this.providers!!)
+        } else {
+            val client = clientFactory.createNextcloudClient(currentAccountProvider.user)
+            val task = GetSearchProvidersTask(client)
+            asyncRunner.postQuickTask(
+                task,
+                onResult = { onResult(it.providers) },
+                onError = onError
+            )
+        }
+    }
+}

+ 241 - 0
src/main/java/com/owncloud/android/ui/unifiedsearch/UnifiedSearchViewModel.kt

@@ -0,0 +1,241 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.owncloud.android.ui.unifiedsearch
+
+import android.app.Application
+import android.content.Context
+import android.content.res.Resources
+import android.net.Uri
+import androidx.annotation.VisibleForTesting
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.MutableLiveData
+import com.nextcloud.client.account.CurrentAccountProvider
+import com.nextcloud.client.core.AsyncRunner
+import com.nextcloud.client.network.ClientFactory
+import com.owncloud.android.R
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.SearchResult
+import com.owncloud.android.lib.common.SearchResultEntry
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.ui.asynctasks.GetRemoteFileTask
+import javax.inject.Inject
+
+@Suppress("LongParameterList")
+class UnifiedSearchViewModel(application: Application) : AndroidViewModel(application), IUnifiedSearchViewModel {
+    companion object {
+        private const val TAG = "UnifiedSearchViewModel"
+        private const val DEFAULT_LIMIT = 5
+        private const val FILES_PROVIDER_ID = "files"
+    }
+
+    private data class UnifiedSearchMetadata(
+        var results: MutableList<SearchResult> = mutableListOf()
+    ) {
+        fun nextCursor(): Int? = results.lastOrNull()?.cursor?.toInt()
+        fun name(): String? = results.lastOrNull()?.name
+        fun isFinished(): Boolean {
+            if (results.isEmpty()) {
+                return false
+            }
+            val lastResult = results.last()
+            return when {
+                !lastResult.isPaginated -> true
+                lastResult.entries.size < DEFAULT_LIMIT -> true
+                else -> false
+            }
+        }
+    }
+
+    lateinit var currentAccountProvider: CurrentAccountProvider
+    lateinit var runner: AsyncRunner
+    lateinit var clientFactory: ClientFactory
+    lateinit var resources: Resources
+
+    private val context: Context
+        get() = getApplication<Application>().applicationContext
+
+    private lateinit var repository: IUnifiedSearchRepository
+    private var loadingStarted: Boolean = false
+    private var results: MutableMap<ProviderID, UnifiedSearchMetadata> = mutableMapOf()
+
+    override val isLoading = MutableLiveData(false)
+    override val searchResults = MutableLiveData<List<UnifiedSearchSection>>(mutableListOf())
+    override val error = MutableLiveData("")
+    override val query = MutableLiveData<String>()
+    override val browserUri = MutableLiveData<Uri>()
+    override val file = MutableLiveData<OCFile>()
+
+    @Inject
+    constructor(
+        application: Application,
+        currentAccountProvider: CurrentAccountProvider,
+        runner: AsyncRunner,
+        clientFactory: ClientFactory,
+        resources: Resources,
+    ) : this(application) {
+        this.currentAccountProvider = currentAccountProvider
+        this.runner = runner
+        this.clientFactory = clientFactory
+        this.resources = resources
+
+        repository = UnifiedSearchRemoteRepository(
+            clientFactory,
+            currentAccountProvider,
+            runner
+        )
+    }
+
+    open fun startLoading(query: String) {
+        if (!loadingStarted) {
+            loadingStarted = true
+            this.query.value = query
+            initialQuery()
+        }
+    }
+
+    /**
+     * Clears data and queries all available providers
+     */
+    override fun initialQuery() {
+        results = mutableMapOf()
+        searchResults.value = mutableListOf()
+        val queryTerm = query.value.orEmpty()
+
+        if (isLoading.value != true && queryTerm.isNotBlank()) {
+            isLoading.value = true
+            repository.queryAll(queryTerm, this::onSearchResult, this::onError, this::onSearchFinished)
+        }
+    }
+
+    override fun loadMore(provider: ProviderID) {
+        val queryTerm = query.value.orEmpty()
+
+        if (isLoading.value != true && queryTerm.isNotBlank()) {
+            results[provider]?.nextCursor()?.let { cursor ->
+                isLoading.value = true
+                repository.queryProvider(
+                    queryTerm,
+                    provider,
+                    cursor,
+                    this::onSearchResult,
+                    this::onError,
+                    this::onSearchFinished
+                )
+            }
+        }
+    }
+
+    override fun openResult(result: SearchResultEntry) {
+        if (result.fileId() != null) {
+            openFile(result.remotePath())
+        } else {
+            val uri = Uri.parse(result.resourceUrl)
+            this.browserUri.value = uri
+        }
+    }
+
+    fun openFile(fileUrl: String) {
+        if (isLoading.value == false) {
+            isLoading.value = true
+            val user = currentAccountProvider.user
+            val task = GetRemoteFileTask(
+                context,
+                fileUrl,
+                clientFactory.create(currentAccountProvider.user),
+                FileDataStorageManager(user.toPlatformAccount(), context.contentResolver),
+                user
+            )
+            runner.postQuickTask(task, onResult = this::onFileRequestResult)
+        }
+    }
+
+    open fun clearError() {
+        error.value = ""
+    }
+
+    fun onError(error: Throwable) {
+        Log_OC.e(TAG, "Error: " + error.stackTrace)
+    }
+
+    @Synchronized
+    fun onSearchResult(result: UnifiedSearchResult) {
+
+        if (result.success) {
+            val providerMeta = results[result.provider] ?: UnifiedSearchMetadata()
+            providerMeta.results.add(result.result)
+
+            results[result.provider] = providerMeta
+            genSearchResultsFromMeta()
+        }
+
+        Log_OC.d(TAG, "onSearchResult: Provider '${result.provider}', success: ${result.success}")
+        if (result.success) {
+            Log_OC.d(TAG, "onSearchResult: Provider '${result.provider}', result count: ${result.result.entries.size}")
+        }
+    }
+
+    private fun genSearchResultsFromMeta() {
+        searchResults.value = results
+            .filter { it.value.results.isNotEmpty() }
+            .map { (key, value) ->
+                UnifiedSearchSection(
+                    providerID = key,
+                    name = value.name()!!,
+                    entries = value.results.flatMap { it.entries },
+                    hasMoreResults = !value.isFinished()
+                )
+            }
+            .sortedWith { o1, o2 ->
+                // TODO sort with sort order from server providers?
+                when {
+                    o1.providerID == FILES_PROVIDER_ID -> -1
+                    o2.providerID == FILES_PROVIDER_ID -> 1
+                    else -> 0
+                }
+            }
+    }
+
+    fun onSearchFinished(success: Boolean) {
+        Log_OC.d(TAG, "onSearchFinished: success: $success")
+        isLoading.value = false
+        if (!success) {
+            error.value = resources.getString(R.string.search_error)
+        }
+    }
+
+    @VisibleForTesting
+    fun setRepository(repository: IUnifiedSearchRepository) {
+        this.repository = repository
+    }
+
+    private fun onFileRequestResult(result: GetRemoteFileTask.Result) {
+        isLoading.value = false
+        if (result.success) {
+            file.value = result.file
+        } else {
+            error.value = "Error showing search result"
+        }
+    }
+
+    override fun setQuery(query: String) {
+        this.query.value = query
+    }
+}

+ 1 - 1
src/main/java/com/owncloud/android/utils/DisplayUtils.java

@@ -574,7 +574,7 @@ public final class DisplayUtils {
                                         int width,
                                         int height) {
         GenericRequestBuilder<Uri, InputStream, SVG, PictureDrawable> requestBuilder = Glide.with(context)
-            .using(new CustomGlideUriLoader(currentAccountProvider, clientFactory), InputStream.class)
+            .using(new CustomGlideUriLoader(currentAccountProvider.getUser(), clientFactory), InputStream.class)
             .from(Uri.class)
             .as(SVG.class)
             .transcode(new SvgDrawableTranscoder(), PictureDrawable.class)

+ 5 - 5
src/main/java/com/owncloud/android/utils/glide/CustomGlideStreamLoader.java

@@ -24,7 +24,7 @@ package com.owncloud.android.utils.glide;
 
 import com.bumptech.glide.load.data.DataFetcher;
 import com.bumptech.glide.load.model.stream.StreamModelLoader;
-import com.nextcloud.client.account.CurrentAccountProvider;
+import com.nextcloud.client.account.User;
 import com.nextcloud.client.network.ClientFactory;
 
 import java.io.InputStream;
@@ -34,16 +34,16 @@ import java.io.InputStream;
  */
 public class CustomGlideStreamLoader implements StreamModelLoader<String> {
 
-    private final CurrentAccountProvider currentAccount;
+    private final User user;
     private final ClientFactory clientFactory;
 
-    public CustomGlideStreamLoader(CurrentAccountProvider currentAccount, ClientFactory clientFactory) {
-        this.currentAccount = currentAccount;
+    public CustomGlideStreamLoader(User user, ClientFactory clientFactory) {
+        this.user = user;
         this.clientFactory = clientFactory;
     }
 
     @Override
     public DataFetcher<InputStream> getResourceFetcher(String url, int width, int height) {
-        return new HttpStreamFetcher(currentAccount, clientFactory, url);
+        return new HttpStreamFetcher(user, clientFactory, url);
     }
 }

+ 5 - 5
src/main/java/com/owncloud/android/utils/glide/CustomGlideUriLoader.java

@@ -24,7 +24,7 @@ import android.net.Uri;
 
 import com.bumptech.glide.load.data.DataFetcher;
 import com.bumptech.glide.load.model.stream.StreamModelLoader;
-import com.nextcloud.client.account.CurrentAccountProvider;
+import com.nextcloud.client.account.User;
 import com.nextcloud.client.network.ClientFactory;
 
 import java.io.InputStream;
@@ -34,16 +34,16 @@ import java.io.InputStream;
  */
 public class CustomGlideUriLoader implements StreamModelLoader<Uri> {
 
-    private final CurrentAccountProvider currentAccount;
+    private final User user;
     private final ClientFactory clientFactory;
 
-    public CustomGlideUriLoader(CurrentAccountProvider currentAccount, ClientFactory clientFactory) {
-        this.currentAccount = currentAccount;
+    public CustomGlideUriLoader(User user, ClientFactory clientFactory) {
+        this.user = user;
         this.clientFactory = clientFactory;
     }
 
     @Override
     public DataFetcher<InputStream> getResourceFetcher(Uri url, int width, int height) {
-        return new HttpStreamFetcher(currentAccount, clientFactory, url.toString());
+        return new HttpStreamFetcher(user, clientFactory, url.toString());
     }
 }

+ 2 - 3
src/main/java/com/owncloud/android/utils/glide/HttpStreamFetcher.kt

@@ -24,7 +24,7 @@ package com.owncloud.android.utils.glide
 
 import com.bumptech.glide.Priority
 import com.bumptech.glide.load.data.DataFetcher
-import com.nextcloud.client.account.CurrentAccountProvider
+import com.nextcloud.client.account.User
 import com.nextcloud.client.network.ClientFactory
 import com.owncloud.android.lib.common.operations.RemoteOperation
 import com.owncloud.android.lib.common.utils.Log_OC
@@ -39,13 +39,12 @@ import java.io.InputStream
  */
 @Suppress("TooGenericExceptionCaught")
 class HttpStreamFetcher internal constructor(
-    private val currentAccount: CurrentAccountProvider,
+    private val user: User,
     private val clientFactory: ClientFactory,
     private val url: String
 ) : DataFetcher<InputStream?> {
     @Throws(Exception::class)
     override fun loadData(priority: Priority): InputStream? {
-        val user = currentAccount.user
         val client = clientFactory.create(user)
         if (client != null) {
             var get: GetMethod? = null

+ 18 - 0
src/main/res/drawable/ic_deck.xml

@@ -0,0 +1,18 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="16dp"
+    android:height="16dp"
+    android:viewportWidth="16"
+    android:viewportHeight="16">
+  <path
+      android:pathData="M2,7L14,7A1,1 0,0 1,15 8L15,14A1,1 0,0 1,14 15L2,15A1,1 0,0 1,1 14L1,8A1,1 0,0 1,2 7z"
+      android:fillColor="#fff"/>
+  <path
+      android:pathData="M2.5,5L13.5,5A0.5,0.5 0,0 1,14 5.5L14,5.5A0.5,0.5 0,0 1,13.5 6L2.5,6A0.5,0.5 0,0 1,2 5.5L2,5.5A0.5,0.5 0,0 1,2.5 5z"
+      android:fillColor="#fff"/>
+  <path
+      android:pathData="M3.5,3L12.5,3A0.5,0.5 0,0 1,13 3.5L13,3.5A0.5,0.5 0,0 1,12.5 4L3.5,4A0.5,0.5 0,0 1,3 3.5L3,3.5A0.5,0.5 0,0 1,3.5 3z"
+      android:fillColor="#fff"/>
+  <path
+      android:pathData="M4.5,1L11.5,1A0.5,0.5 0,0 1,12 1.5L12,1.5A0.5,0.5 0,0 1,11.5 2L4.5,2A0.5,0.5 0,0 1,4 1.5L4,1.5A0.5,0.5 0,0 1,4.5 1z"
+      android:fillColor="#fff"/>
+</vector>

+ 3 - 1
src/main/res/layout/list_fragment.xml

@@ -36,6 +36,8 @@
             android:layout_height="match_parent" />
     </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
 
-    <include layout="@layout/empty_list" />
+    <include
+        android:id="@+id/empty_list"
+        layout="@layout/empty_list" />
 
 </RelativeLayout>

+ 0 - 1
src/main/res/layout/list_item.xml

@@ -23,7 +23,6 @@
     android:layout_height="@dimen/standard_list_item_size"
     android:baselineAligned="false"
     android:descendantFocusability="blocksDescendants"
-    android:foreground="?android:attr/selectableItemBackground"
     android:orientation="horizontal">
 
     <androidx.constraintlayout.widget.ConstraintLayout

+ 4 - 0
src/main/res/layout/unified_search_empty.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="@dimen/zero"
+    android:layout_height="@dimen/zero" />

+ 46 - 0
src/main/res/layout/unified_search_footer.xml

@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+  Nextcloud Android client application
+
+  Copyright (C) 2020 Tobias Kaminsky
+  Copyright (C) 2020 Nextcloud
+
+  This program is free software; you can redistribute it and/or
+  modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+  License as published by the Free Software Foundation; either
+  version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
+
+  You should have received a copy of the GNU Affero General Public
+  License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+-->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/unified_search_footer_layout"
+    android:layout_width="match_parent"
+    android:layout_height="@dimen/min_list_item_size"
+    android:baselineAligned="false"
+    android:orientation="horizontal">
+
+
+    <TextView
+        android:layout_width="0dp"
+        android:layout_height="@dimen/standard_list_item_size"
+        android:layout_marginStart="@dimen/standard_list_item_size"
+        android:layout_weight="1"
+        android:gravity="center_vertical"
+        android:orientation="vertical"
+        android:paddingStart="@dimen/standard_quarter_padding"
+        android:paddingEnd="0dp"
+        android:text="@string/load_more_results"
+        android:textColor="@color/secondary_text_color"
+        tools:text="Load more results">
+
+    </TextView>
+</LinearLayout>
+

+ 41 - 0
src/main/res/layout/unified_search_header.xml

@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+  Nextcloud Android client application
+
+  Copyright (C) 2018 Tobias Kaminsky
+  Copyright (C) 2018 Andy Scherzinger
+  Copyright (C) 2018 Nextcloud
+
+  This program is free software; you can redistribute it and/or
+  modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+  License as published by the Free Software Foundation; either
+  version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
+
+  You should have received a copy of the GNU Affero General Public
+  License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+-->
+
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/ListItemLayout"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="horizontal">
+
+    <TextView
+        android:id="@+id/title"
+        style="@style/TextAppearance.AppCompat.Body2"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:ellipsize="middle"
+        android:paddingHorizontal="@dimen/standard_padding"
+        android:paddingVertical="@dimen/standard_half_padding"
+        android:textColor="@color/color_accent"
+        tools:text="Files" />
+</RelativeLayout>

+ 111 - 0
src/main/res/layout/unified_search_item.xml

@@ -0,0 +1,111 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+  Nextcloud Android client application
+
+  Copyright (C) 2020 Tobias Kaminsky
+  Copyright (C) 2020 Nextcloud
+
+  This program is free software; you can redistribute it and/or
+  modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+  License as published by the Free Software Foundation; either
+  version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
+
+  You should have received a copy of the GNU Affero General Public
+  License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+-->
+<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/unified_search_item_layout"
+    android:layout_width="match_parent"
+    android:layout_height="@dimen/standard_list_item_size"
+    android:baselineAligned="false"
+    android:paddingStart="0dp"
+    android:paddingEnd="@dimen/standard_padding"
+    android:orientation="horizontal">
+
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:layout_width="@dimen/standard_list_item_size"
+        android:layout_height="@dimen/standard_list_item_size"
+        android:layout_marginStart="@dimen/zero"
+        android:layout_marginEnd="@dimen/standard_quarter_padding">
+
+        <FrameLayout
+            android:id="@+id/thumbnail_layout"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent">
+
+            <ImageView
+                android:id="@+id/thumbnail"
+                android:layout_width="@dimen/file_icon_size"
+                android:layout_height="@dimen/file_icon_size"
+                android:contentDescription="@null"
+                android:src="@drawable/folder" />
+
+            <com.elyeproj.loaderviewlibrary.LoaderImageView
+                android:id="@+id/thumbnail_shimmer"
+                android:layout_width="@dimen/file_icon_size"
+                android:layout_height="@dimen/file_icon_size"
+                android:visibility="gone"
+                app:corners="8" />
+        </FrameLayout>
+
+        <ImageView
+            android:id="@+id/localFileIndicator"
+            android:layout_width="@dimen/list_item_local_file_indicator_layout_width"
+            android:layout_height="@dimen/list_item_local_file_indicator_layout_height"
+            android:contentDescription="@string/downloader_download_succeeded_ticker"
+            android:scaleType="fitCenter"
+            android:src="@drawable/ic_synced"
+            android:visibility="gone"
+            app:layout_constraintBottom_toBottomOf="@+id/thumbnail_layout"
+            app:layout_constraintEnd_toEndOf="@+id/thumbnail_layout"
+            app:layout_constraintStart_toEndOf="@+id/thumbnail_layout"
+            app:layout_constraintTop_toBottomOf="@+id/thumbnail_layout" />
+
+    </androidx.constraintlayout.widget.ConstraintLayout>
+
+    <LinearLayout
+        android:layout_width="0dp"
+        android:layout_height="match_parent"
+        android:layout_weight="1"
+        android:gravity="top"
+        android:orientation="vertical"
+        android:paddingTop="@dimen/standard_padding">
+
+        <TextView
+            android:id="@+id/title"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center_vertical"
+            android:ellipsize="middle"
+            android:singleLine="true"
+            android:text=""
+            android:textColor="@color/text_color"
+            android:textSize="@dimen/two_line_primary_text_size"
+            tools:text="Test 123" />
+
+        <TextView
+            android:id="@+id/subline"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:ellipsize="middle"
+            android:singleLine="true"
+            android:text=""
+            android:textColor="@color/list_item_lastmod_and_filesize_text"
+            android:textSize="@dimen/two_line_secondary_text_size"
+            tools:text="in TestFolder" />
+
+    </LinearLayout>
+</LinearLayout>
+

+ 2 - 0
src/main/res/values/strings.xml

@@ -990,4 +990,6 @@
     <string name="more">More</string>
     <string name="write_email">Send email</string>
     <string name="no_actions">No actions for this user</string>
+    <string name="search_error">Error getting search results</string>
+    <string name="load_more_results">Load more results</string>
 </resources>