Browse Source

Merge pull request #1946 from nextcloud/feature/noid/media-overview

media overview
Tim Krueger 3 years ago
parent
commit
df62c87b57
33 changed files with 1916 additions and 405 deletions
  1. 4 0
      app/build.gradle
  2. 4 0
      app/src/main/AndroidManifest.xml
  3. 20 30
      app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt
  4. 180 0
      app/src/main/java/com/nextcloud/talk/activities/SharedItemsActivity.kt
  5. 105 0
      app/src/main/java/com/nextcloud/talk/adapters/SharedItemsGridAdapter.kt
  6. 117 0
      app/src/main/java/com/nextcloud/talk/adapters/SharedItemsListAdapter.kt
  7. 73 348
      app/src/main/java/com/nextcloud/talk/adapters/messages/MagicPreviewMessageViewHolder.java
  8. 14 0
      app/src/main/java/com/nextcloud/talk/api/NcApi.java
  9. 27 7
      app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt
  10. 19 3
      app/src/main/java/com/nextcloud/talk/controllers/ConversationInfoController.kt
  11. 3 0
      app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.java
  12. 76 0
      app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOCS.java
  13. 75 0
      app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOverall.java
  14. 78 0
      app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOverviewOCS.java
  15. 75 0
      app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOverviewOverall.java
  16. 16 0
      app/src/main/java/com/nextcloud/talk/repositories/SharedItem.kt
  17. 18 0
      app/src/main/java/com/nextcloud/talk/repositories/SharedItemType.kt
  18. 78 0
      app/src/main/java/com/nextcloud/talk/repositories/SharedItemsRepository.kt
  19. 9 0
      app/src/main/java/com/nextcloud/talk/repositories/SharedMediaItems.kt
  20. 8 0
      app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java
  21. 393 0
      app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt
  22. 192 0
      app/src/main/java/com/nextcloud/talk/viewmodels/SharedItemsViewModel.kt
  23. 25 0
      app/src/main/res/drawable/ic_folder_multiple_image.xml
  24. 66 0
      app/src/main/res/layout/activity_shared_items.xml
  25. 23 1
      app/src/main/res/layout/controller_conversation_info.xml
  26. 1 1
      app/src/main/res/layout/rv_item_browser_file.xml
  27. 61 0
      app/src/main/res/layout/shared_item_grid.xml
  28. 125 0
      app/src/main/res/layout/shared_item_list.xml
  29. 6 1
      app/src/main/res/menu/menu_conversation.xml
  30. 2 0
      app/src/main/res/values/dimens.xml
  31. 16 13
      app/src/main/res/values/strings.xml
  32. 6 0
      app/src/main/res/values/styles.xml
  33. 1 1
      scripts/analysis/lint-results.txt

+ 4 - 0
app/build.gradle

@@ -332,6 +332,10 @@ dependencies {
 
     gplayImplementation 'com.google.android.gms:play-services-base:18.0.1'
     gplayImplementation "com.google.firebase:firebase-messaging:23.0.0"
+
+    // TODO: Define variable for version
+    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
+    // implementation 'androidx.activity:activity-ktx:1.4.0'
 }
 
 task installGitHooks(type: Copy, group: "development") {

+ 4 - 0
app/src/main/AndroidManifest.xml

@@ -167,6 +167,10 @@
             android:theme="@style/TakePhotoTheme"
             android:windowSoftInputMode="stateHidden" />
 
+        <activity
+            android:name=".activities.SharedItemsActivity"
+            android:theme="@style/AppTheme"/>
+
         <receiver android:name=".receivers.PackageReplacedReceiver">
             <intent-filter>
                 <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />

+ 20 - 30
app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt

@@ -118,42 +118,32 @@ class MainActivity : BaseActivity(), ActionBarProvider {
                 if (userUtils.anyUserExists()) {
                     setDefaultRootController()
                 } else {
-                    if (!TextUtils.isEmpty(resources.getString(R.string.weblogin_url))) {
-                        router!!.pushController(
-                            RouterTransaction.with(
-                                WebViewLoginController(resources.getString(R.string.weblogin_url), false)
-                            )
-                                .pushChangeHandler(HorizontalChangeHandler())
-                                .popChangeHandler(HorizontalChangeHandler())
-                        )
-                    } else {
-                        router!!.setRoot(
-                            RouterTransaction.with(ServerSelectionController())
-                                .pushChangeHandler(HorizontalChangeHandler())
-                                .popChangeHandler(HorizontalChangeHandler())
-                        )
-                    }
+                    launchLoginScreen()
                 }
             } else {
-                if (!TextUtils.isEmpty(resources.getString(R.string.weblogin_url))) {
-                    router!!.pushController(
-                        RouterTransaction.with(
-                            WebViewLoginController(resources.getString(R.string.weblogin_url), false)
-                        )
-                            .pushChangeHandler(HorizontalChangeHandler())
-                            .popChangeHandler(HorizontalChangeHandler())
-                    )
-                } else {
-                    router!!.setRoot(
-                        RouterTransaction.with(ServerSelectionController())
-                            .pushChangeHandler(HorizontalChangeHandler())
-                            .popChangeHandler(HorizontalChangeHandler())
-                    )
-                }
+                launchLoginScreen()
             }
         }
     }
 
+    private fun launchLoginScreen() {
+        if (!TextUtils.isEmpty(resources.getString(R.string.weblogin_url))) {
+            router!!.pushController(
+                RouterTransaction.with(
+                    WebViewLoginController(resources.getString(R.string.weblogin_url), false)
+                )
+                    .pushChangeHandler(HorizontalChangeHandler())
+                    .popChangeHandler(HorizontalChangeHandler())
+            )
+        } else {
+            router!!.setRoot(
+                RouterTransaction.with(ServerSelectionController())
+                    .pushChangeHandler(HorizontalChangeHandler())
+                    .popChangeHandler(HorizontalChangeHandler())
+            )
+        }
+    }
+
     override fun onStart() {
         Log.d(TAG, "onStart: Activity: " + System.identityHashCode(this).toString())
 

+ 180 - 0
app/src/main/java/com/nextcloud/talk/activities/SharedItemsActivity.kt

@@ -0,0 +1,180 @@
+package com.nextcloud.talk.activities
+
+import android.os.Bundle
+import android.util.Log
+import android.view.MenuItem
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.res.ResourcesCompat
+import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.tabs.TabLayout
+import com.nextcloud.talk.R
+import com.nextcloud.talk.adapters.SharedItemsGridAdapter
+import com.nextcloud.talk.adapters.SharedItemsListAdapter
+import com.nextcloud.talk.databinding.ActivitySharedItemsBinding
+import com.nextcloud.talk.models.database.UserEntity
+import com.nextcloud.talk.repositories.SharedItemType
+import com.nextcloud.talk.utils.DisplayUtils
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY
+import com.nextcloud.talk.viewmodels.SharedItemsViewModel
+
+class SharedItemsActivity : AppCompatActivity() {
+
+    private lateinit var binding: ActivitySharedItemsBinding
+    private lateinit var viewModel: SharedItemsViewModel
+    private lateinit var currentTab: SharedItemType
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        currentTab = SharedItemType.MEDIA
+
+        val roomToken = intent.getStringExtra(KEY_ROOM_TOKEN)!!
+        val conversationName = intent.getStringExtra(KEY_CONVERSATION_NAME)
+        val userEntity = intent.getParcelableExtra<UserEntity>(KEY_USER_ENTITY)!!
+
+        binding = ActivitySharedItemsBinding.inflate(layoutInflater)
+        setSupportActionBar(binding.sharedItemsToolbar)
+        setContentView(binding.root)
+
+        DisplayUtils.applyColorToStatusBar(
+            this,
+            ResourcesCompat.getColor(
+                resources, R.color.appbar, null
+            )
+        )
+        DisplayUtils.applyColorToNavigationBar(
+            this.window,
+            ResourcesCompat.getColor(resources, R.color.bg_default, null)
+        )
+
+        supportActionBar?.title = conversationName
+        supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+        viewModel = ViewModelProvider(
+            this,
+            SharedItemsViewModel.Factory(userEntity, roomToken, currentTab)
+        ).get(SharedItemsViewModel::class.java)
+
+        viewModel.sharedItemType.observe(this) {
+            initTabs(it)
+        }
+
+        viewModel.sharedItems.observe(this) {
+            Log.d(TAG, "Items received: $it")
+
+            if (currentTab == SharedItemType.MEDIA) {
+                val adapter = SharedItemsGridAdapter()
+                adapter.items = it.items
+                adapter.authHeader = it.authHeader
+                binding.imageRecycler.adapter = adapter
+
+                val layoutManager = GridLayoutManager(this, SPAN_COUNT)
+                binding.imageRecycler.layoutManager = layoutManager
+            } else {
+                val adapter = SharedItemsListAdapter()
+                adapter.items = it.items
+                adapter.authHeader = it.authHeader
+                binding.imageRecycler.adapter = adapter
+
+                val layoutManager = LinearLayoutManager(this)
+                layoutManager.orientation = LinearLayoutManager.VERTICAL
+                binding.imageRecycler.layoutManager = layoutManager
+            }
+        }
+
+        binding.imageRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
+                super.onScrollStateChanged(recyclerView, newState)
+                if (!recyclerView.canScrollVertically(1) && newState == RecyclerView.SCROLL_STATE_IDLE) {
+                    viewModel.loadNextItems()
+                }
+            }
+        })
+    }
+
+    fun updateItems(type: SharedItemType) {
+        currentTab = type
+        viewModel.loadItems(type)
+    }
+
+    private fun initTabs(sharedItemTypes: Set<SharedItemType>) {
+
+        if (sharedItemTypes.contains(SharedItemType.MEDIA)) {
+            val tabMedia: TabLayout.Tab = binding.sharedItemsTabs.newTab()
+            tabMedia.tag = SharedItemType.MEDIA
+            tabMedia.setText(R.string.shared_items_media)
+            binding.sharedItemsTabs.addTab(tabMedia)
+        }
+
+        if (sharedItemTypes.contains(SharedItemType.FILE)) {
+            val tabFile: TabLayout.Tab = binding.sharedItemsTabs.newTab()
+            tabFile.tag = SharedItemType.FILE
+            tabFile.setText(R.string.shared_items_file)
+            binding.sharedItemsTabs.addTab(tabFile)
+        }
+
+        if (sharedItemTypes.contains(SharedItemType.AUDIO)) {
+            val tabAudio: TabLayout.Tab = binding.sharedItemsTabs.newTab()
+            tabAudio.tag = SharedItemType.AUDIO
+            tabAudio.setText(R.string.shared_items_audio)
+            binding.sharedItemsTabs.addTab(tabAudio)
+        }
+
+        if (sharedItemTypes.contains(SharedItemType.VOICE)) {
+            val tabVoice: TabLayout.Tab = binding.sharedItemsTabs.newTab()
+            tabVoice.tag = SharedItemType.VOICE
+            tabVoice.setText(R.string.shared_items_voice)
+            binding.sharedItemsTabs.addTab(tabVoice)
+        }
+
+        // if(sharedItemTypes.contains(SharedItemType.LOCATION)) {
+        // val tabLocation: TabLayout.Tab = binding.sharedItemsTabs.newTab()
+        // tabLocation.tag = SharedItemType.LOCATION
+        // tabLocation.text = "location"
+        // binding.sharedItemsTabs.addTab(tabLocation)
+        // }
+
+        // if(sharedItemTypes.contains(SharedItemType.DECKCARD)) {
+        // val tabDeckCard: TabLayout.Tab = binding.sharedItemsTabs.newTab()
+        // tabDeckCard.tag = SharedItemType.DECKCARD
+        // tabDeckCard.text = "deckcard"
+        // binding.sharedItemsTabs.addTab(tabDeckCard)
+        // }
+
+        // if(sharedItemTypes.contains(SharedItemType.OTHER)) {
+        // val tabOther: TabLayout.Tab = binding.sharedItemsTabs.newTab()
+        // tabOther.tag = SharedItemType.OTHER
+        // tabOther.setText(R.string.shared_items_other)
+        // binding.sharedItemsTabs.addTab(tabOther)
+        // }
+
+        binding.sharedItemsTabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
+            override fun onTabSelected(tab: TabLayout.Tab) {
+                updateItems(tab.tag as SharedItemType)
+            }
+
+            override fun onTabUnselected(tab: TabLayout.Tab) = Unit
+
+            override fun onTabReselected(tab: TabLayout.Tab) = Unit
+        })
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        return if (item.itemId == android.R.id.home) {
+            onBackPressed()
+            true
+        } else {
+            super.onOptionsItemSelected(item)
+        }
+    }
+
+    companion object {
+        private val TAG = SharedItemsActivity::class.simpleName
+        const val SPAN_COUNT: Int = 4
+    }
+}

+ 105 - 0
app/src/main/java/com/nextcloud/talk/adapters/SharedItemsGridAdapter.kt

@@ -0,0 +1,105 @@
+package com.nextcloud.talk.adapters
+
+import android.net.Uri
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import com.facebook.drawee.backends.pipeline.Fresco
+import com.facebook.drawee.controller.BaseControllerListener
+import com.facebook.drawee.controller.ControllerListener
+import com.facebook.drawee.interfaces.DraweeController
+import com.facebook.drawee.view.SimpleDraweeView
+import com.facebook.imagepipeline.common.RotationOptions
+import com.facebook.imagepipeline.image.ImageInfo
+import com.facebook.imagepipeline.request.ImageRequestBuilder
+import com.nextcloud.talk.databinding.SharedItemGridBinding
+import com.nextcloud.talk.repositories.SharedItem
+import com.nextcloud.talk.utils.DrawableUtils
+import com.nextcloud.talk.utils.FileViewerUtils
+
+class SharedItemsGridAdapter : RecyclerView.Adapter<SharedItemsGridAdapter.ViewHolder>() {
+
+    companion object {
+        private val TAG = SharedItemsGridAdapter::class.simpleName
+    }
+
+    class ViewHolder(val binding: SharedItemGridBinding, itemView: View) : RecyclerView.ViewHolder(itemView)
+
+    var authHeader: Map<String, String> = emptyMap()
+    var items: List<SharedItem> = emptyList()
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+        val binding = SharedItemGridBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+        return ViewHolder(binding, binding.root)
+    }
+
+    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+
+        val currentItem = items[position]
+
+        if (currentItem.previewAvailable) {
+            val imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(currentItem.previewLink))
+                .setProgressiveRenderingEnabled(true)
+                .setRotationOptions(RotationOptions.autoRotate())
+                .disableDiskCache()
+                .setHeaders(authHeader)
+                .build()
+
+            val listener: ControllerListener<ImageInfo?> = object : BaseControllerListener<ImageInfo?>() {
+                override fun onFailure(id: String, e: Throwable) {
+                    Log.w(TAG, "Failed to load image. A static mimetype image will be used", e)
+                    setStaticMimetypeImage(currentItem, holder)
+                }
+            }
+
+            val draweeController: DraweeController = Fresco.newDraweeControllerBuilder()
+                .setOldController(holder.binding.image.controller)
+                .setAutoPlayAnimations(true)
+                .setImageRequest(imageRequest)
+                .setControllerListener(listener)
+                .build()
+            holder.binding.image.controller = draweeController
+        } else {
+            setStaticMimetypeImage(currentItem, holder)
+        }
+
+        val fileViewerUtils = FileViewerUtils(holder.binding.image.context, currentItem.userEntity)
+
+        holder.binding.image.setOnClickListener {
+            fileViewerUtils.openFile(
+                FileViewerUtils.FileInfo(currentItem.id, currentItem.name, currentItem.fileSize),
+                currentItem.path,
+                currentItem.link,
+                currentItem.mimeType,
+                FileViewerUtils.ProgressUi(
+                    holder.binding.progressBar,
+                    null,
+                    it as SimpleDraweeView
+                )
+            )
+        }
+
+        fileViewerUtils.resumeToUpdateViewsByProgress(
+            currentItem.name,
+            currentItem.id,
+            currentItem.mimeType,
+            FileViewerUtils.ProgressUi(holder.binding.progressBar, null, holder.binding.image)
+        )
+    }
+
+    private fun setStaticMimetypeImage(
+        currentItem: SharedItem,
+        holder: ViewHolder
+    ) {
+        val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(currentItem.mimeType)
+        val drawable = ContextCompat.getDrawable(holder.binding.image.context, drawableResourceId)
+        holder.binding.image.hierarchy.setPlaceholderImage(drawable)
+    }
+
+    override fun getItemCount(): Int {
+        return items.size
+    }
+}

+ 117 - 0
app/src/main/java/com/nextcloud/talk/adapters/SharedItemsListAdapter.kt

@@ -0,0 +1,117 @@
+package com.nextcloud.talk.adapters
+
+import android.net.Uri
+import android.text.format.Formatter
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import com.facebook.drawee.backends.pipeline.Fresco
+import com.facebook.drawee.controller.BaseControllerListener
+import com.facebook.drawee.controller.ControllerListener
+import com.facebook.drawee.interfaces.DraweeController
+import com.facebook.imagepipeline.common.RotationOptions
+import com.facebook.imagepipeline.image.ImageInfo
+import com.facebook.imagepipeline.request.ImageRequestBuilder
+import com.nextcloud.talk.databinding.SharedItemListBinding
+import com.nextcloud.talk.repositories.SharedItem
+import com.nextcloud.talk.utils.DateUtils
+import com.nextcloud.talk.utils.DrawableUtils
+import com.nextcloud.talk.utils.FileViewerUtils
+import com.nextcloud.talk.utils.FileViewerUtils.ProgressUi
+
+class SharedItemsListAdapter : RecyclerView.Adapter<SharedItemsListAdapter.ViewHolder>() {
+
+    companion object {
+        private val TAG = SharedItemsListAdapter::class.simpleName
+        private const val ONE_SECOND_IN_MILLIS = 1000
+    }
+
+    class ViewHolder(val binding: SharedItemListBinding, itemView: View) : RecyclerView.ViewHolder(itemView)
+
+    var authHeader: Map<String, String> = emptyMap()
+    var items: List<SharedItem> = emptyList()
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+        val binding = SharedItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+        return ViewHolder(binding, binding.root)
+    }
+
+    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+
+        val currentItem = items[position]
+
+        holder.binding.fileName.text = currentItem.name
+        holder.binding.fileSize.text = Formatter.formatShortFileSize(
+            holder.binding.fileSize.context,
+            currentItem.fileSize.toLong()
+        )
+        holder.binding.fileDate.text = DateUtils.getLocalDateTimeStringFromTimestamp(
+            currentItem.date * ONE_SECOND_IN_MILLIS
+        )
+
+        if (currentItem.previewAvailable) {
+            val imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(currentItem.previewLink))
+                .setProgressiveRenderingEnabled(true)
+                .setRotationOptions(RotationOptions.autoRotate())
+                .disableDiskCache()
+                .setHeaders(authHeader)
+                .build()
+
+            val listener: ControllerListener<ImageInfo?> = object : BaseControllerListener<ImageInfo?>() {
+                override fun onFailure(id: String, e: Throwable) {
+                    Log.w(TAG, "Failed to load image. A static mimetype image will be used", e)
+                    setStaticMimetypeImage(currentItem, holder)
+                }
+            }
+
+            val draweeController: DraweeController = Fresco.newDraweeControllerBuilder()
+                .setOldController(holder.binding.fileImage.controller)
+                .setAutoPlayAnimations(true)
+                .setImageRequest(imageRequest)
+                .setControllerListener(listener)
+                .build()
+            holder.binding.fileImage.controller = draweeController
+        } else {
+            setStaticMimetypeImage(currentItem, holder)
+        }
+
+        val fileViewerUtils = FileViewerUtils(holder.binding.fileImage.context, currentItem.userEntity)
+
+        holder.binding.fileItem.setOnClickListener {
+            fileViewerUtils.openFile(
+                FileViewerUtils.FileInfo(currentItem.id, currentItem.name, currentItem.fileSize),
+                currentItem.path,
+                currentItem.link,
+                currentItem.mimeType,
+                ProgressUi(
+                    holder.binding.progressBar,
+                    null,
+                    holder.binding.fileImage
+                )
+            )
+        }
+
+        fileViewerUtils.resumeToUpdateViewsByProgress(
+            currentItem.name,
+            currentItem.id,
+            currentItem.mimeType,
+            ProgressUi(holder.binding.progressBar, null, holder.binding.fileImage)
+        )
+    }
+
+    private fun setStaticMimetypeImage(
+        currentItem: SharedItem,
+        holder: ViewHolder
+    ) {
+        val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(currentItem.mimeType)
+        val drawable = ContextCompat.getDrawable(holder.binding.fileImage.context, drawableResourceId)
+        holder.binding.fileImage.hierarchy.setPlaceholderImage(drawable)
+    }
+
+    override fun getItemCount(): Int {
+        return items.size
+    }
+}

+ 73 - 348
app/src/main/java/com/nextcloud/talk/adapters/messages/MagicPreviewMessageViewHolder.java

@@ -27,13 +27,11 @@
 package com.nextcloud.talk.adapters.messages;
 
 import android.annotation.SuppressLint;
-import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
 import android.graphics.drawable.Drawable;
 import android.graphics.drawable.LayerDrawable;
 import android.net.Uri;
-import android.os.Build;
 import android.os.Handler;
 import android.util.Base64;
 import android.util.Log;
@@ -43,44 +41,31 @@ import android.widget.PopupMenu;
 import android.widget.ProgressBar;
 
 import com.facebook.drawee.view.SimpleDraweeView;
-import com.google.common.util.concurrent.ListenableFuture;
 import com.nextcloud.talk.R;
-import com.nextcloud.talk.activities.FullScreenImageActivity;
-import com.nextcloud.talk.activities.FullScreenMediaActivity;
-import com.nextcloud.talk.activities.FullScreenTextViewerActivity;
 import com.nextcloud.talk.application.NextcloudTalkApplication;
 import com.nextcloud.talk.components.filebrowser.models.BrowserFile;
 import com.nextcloud.talk.components.filebrowser.models.DavResponse;
 import com.nextcloud.talk.components.filebrowser.webdav.ReadFilesystemOperation;
 import com.nextcloud.talk.databinding.ReactionsInsideMessageBinding;
-import com.nextcloud.talk.jobs.DownloadFileToCacheWorker;
-import com.nextcloud.talk.models.database.CapabilitiesUtil;
 import com.nextcloud.talk.models.database.UserEntity;
 import com.nextcloud.talk.models.json.chat.ChatMessage;
 import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet;
-import com.nextcloud.talk.utils.AccountUtils;
 import com.nextcloud.talk.utils.DisplayUtils;
 import com.nextcloud.talk.utils.DrawableUtils;
-import com.nextcloud.talk.utils.bundle.BundleKeys;
+import com.nextcloud.talk.utils.FileViewerUtils;
 import com.stfalcon.chatkit.messages.MessageHolders;
 
 import java.io.ByteArrayInputStream;
-import java.io.File;
 import java.io.IOException;
 import java.util.List;
+import java.util.Objects;
 import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
 
 import javax.inject.Inject;
 
 import androidx.appcompat.view.ContextThemeWrapper;
 import androidx.core.content.ContextCompat;
-import androidx.core.content.FileProvider;
 import androidx.emoji.widget.EmojiTextView;
-import androidx.work.Data;
-import androidx.work.OneTimeWorkRequest;
-import androidx.work.WorkInfo;
-import androidx.work.WorkManager;
 import autodagger.AutoInjector;
 import io.reactivex.Single;
 import io.reactivex.SingleObserver;
@@ -114,6 +99,8 @@ public abstract class MagicPreviewMessageViewHolder extends MessageHolders.Incom
 
     ReactionsInsideMessageBinding reactionsBinding;
 
+    FileViewerUtils fileViewerUtils;
+
     View clickView;
 
     ReactionsInterface reactionsInterface;
@@ -138,7 +125,7 @@ public abstract class MagicPreviewMessageViewHolder extends MessageHolders.Incom
             } else {
                 userAvatar.setVisibility(View.VISIBLE);
                 userAvatar.setOnClickListener(v -> {
-                    if (payload instanceof ProfileBottomSheet){
+                    if (payload instanceof ProfileBottomSheet) {
                         ((ProfileBottomSheet) payload).showFor(message.actorId, v.getContext());
                     }
                 });
@@ -163,6 +150,8 @@ public abstract class MagicPreviewMessageViewHolder extends MessageHolders.Incom
 
         if (message.getMessageType() == ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE) {
 
+            fileViewerUtils = new FileViewerUtils(context, message.activeUser);
+
             String fileName = message.getSelectedIndividualHashMap().get(KEY_NAME);
             getMessageText().setText(fileName);
             if (message.getSelectedIndividualHashMap().containsKey(KEY_CONTACT_NAME)) {
@@ -179,8 +168,8 @@ public abstract class MagicPreviewMessageViewHolder extends MessageHolders.Incom
             if (message.getSelectedIndividualHashMap().containsKey(KEY_CONTACT_PHOTO)) {
                 image = getPreviewContactPhoto();
                 Drawable drawable = getDrawableFromContactDetails(
-                        context,
-                        message.getSelectedIndividualHashMap().get(KEY_CONTACT_PHOTO));
+                    context,
+                    message.getSelectedIndividualHashMap().get(KEY_CONTACT_PHOTO));
                 image.getHierarchy().setPlaceholderImage(drawable);
             } else if (message.getSelectedIndividualHashMap().containsKey(KEY_MIMETYPE)) {
                 String mimetype = message.getSelectedIndividualHashMap().get(KEY_MIMETYPE);
@@ -191,52 +180,28 @@ public abstract class MagicPreviewMessageViewHolder extends MessageHolders.Incom
                 fetchFileInformation("/" + message.getSelectedIndividualHashMap().get(KEY_PATH), message.activeUser);
             }
 
-            if(message.activeUser != null && message.activeUser.getUsername() != null && message.activeUser.getBaseUrl() != null){
-                String accountString =
-                    message.activeUser.getUsername() + "@" +
-                        message.activeUser.getBaseUrl()
-                            .replace("https://", "")
-                            .replace("http://", "");
-
-                clickView.setOnClickListener(v -> {
-                    String mimetype = message.getSelectedIndividualHashMap().get(KEY_MIMETYPE);
-                    if (isSupportedForInternalViewer(mimetype) || canBeHandledByExternalApp(mimetype, fileName)) {
-                        openOrDownloadFile(message);
-                    } else {
-                        openFileInFilesApp(message, accountString);
-                    }
-                });
+            if (message.activeUser != null && message.activeUser.getUsername() != null && message.activeUser.getBaseUrl() != null) {
+                clickView.setOnClickListener(v ->
+                    fileViewerUtils.openFile(
+                        message,
+                        new FileViewerUtils.ProgressUi(progressBar, getMessageText(), image)
+                    )
+                );
 
                 clickView.setOnLongClickListener(l -> {
-                    onMessageViewLongClick(message, accountString);
+                    onMessageViewLongClick(message);
                     return true;
                 });
             } else {
                 Log.e(TAG, "failed to set click listener because activeUser, username or baseUrl were null");
             }
 
+            fileViewerUtils.resumeToUpdateViewsByProgress(
+                Objects.requireNonNull(message.getSelectedIndividualHashMap().get(MagicPreviewMessageViewHolder.KEY_NAME)),
+                Objects.requireNonNull(message.getSelectedIndividualHashMap().get(MagicPreviewMessageViewHolder.KEY_ID)),
+                Objects.requireNonNull(message.getSelectedIndividualHashMap().get(MagicPreviewMessageViewHolder.KEY_MIMETYPE)),
+                new FileViewerUtils.ProgressUi(progressBar, getMessageText(), image));
 
-            // check if download worker is already running
-            String fileId = message.getSelectedIndividualHashMap().get(KEY_ID);
-            ListenableFuture<List<WorkInfo>> workers = WorkManager.getInstance(context).getWorkInfosByTag(fileId);
-
-            try {
-                for (WorkInfo workInfo : workers.get()) {
-                    if (workInfo.getState() == WorkInfo.State.RUNNING ||
-                            workInfo.getState() == WorkInfo.State.ENQUEUED) {
-                        progressBar.setVisibility(View.VISIBLE);
-
-                        String mimetype = message.getSelectedIndividualHashMap().get(KEY_MIMETYPE);
-
-                        WorkManager
-                                .getInstance(context)
-                                .getWorkInfoByIdLiveData(workInfo.getId())
-                                .observeForever(info -> updateViewsByProgress(fileName, mimetype, info));
-                    }
-                }
-            } catch (ExecutionException | InterruptedException e) {
-                Log.e(TAG, "Error when checking if worker already exists", e);
-            }
         } else if (message.getMessageType() == ChatMessage.MessageType.SINGLE_LINK_GIPHY_MESSAGE) {
             getMessageText().setText("GIPHY");
             DisplayUtils.setClickableString("GIPHY", "https://giphy.com", getMessageText());
@@ -273,9 +238,9 @@ public abstract class MagicPreviewMessageViewHolder extends MessageHolders.Incom
         Drawable drawable = null;
         if (!base64.equals("")) {
             ByteArrayInputStream inputStream = new ByteArrayInputStream(
-                    Base64.decode(base64.getBytes(), Base64.DEFAULT));
+                Base64.decode(base64.getBytes(), Base64.DEFAULT));
             drawable = Drawable.createFromResourceStream(context.getResources(),
-                                                    null, inputStream, null, null);
+                                                         null, inputStream, null, null);
             try {
                 inputStream.close();
             } catch (IOException e) {
@@ -287,151 +252,8 @@ public abstract class MagicPreviewMessageViewHolder extends MessageHolders.Incom
         return drawable;
     }
 
-    public abstract EmojiTextView getMessageText();
-
-    public abstract ProgressBar getProgressBar();
-
-    public abstract SimpleDraweeView getImage();
-
-    public abstract View getPreviewContainer();
-
-    public abstract View getPreviewContactContainer();
-
-    public abstract SimpleDraweeView getPreviewContactPhoto();
-
-    public abstract EmojiTextView getPreviewContactName();
-
-    public abstract ProgressBar getPreviewContactProgressBar();
-
-    public abstract ReactionsInsideMessageBinding getReactionsBinding();
-
-    private void openOrDownloadFile(ChatMessage message) {
-        String filename = message.getSelectedIndividualHashMap().get(KEY_NAME);
-        String mimetype = message.getSelectedIndividualHashMap().get(KEY_MIMETYPE);
-        File file = new File(context.getCacheDir(), filename);
-        if (file.exists()) {
-            openFile(filename, mimetype);
-        } else {
-            downloadFileToCache(message);
-        }
-    }
-
-    public boolean isSupportedForInternalViewer(String mimetype){
-        switch (mimetype) {
-            case "image/png":
-            case "image/jpeg":
-            case "image/gif":
-            case "audio/mpeg":
-            case "audio/wav":
-            case "audio/ogg":
-            case "video/mp4":
-            case "video/quicktime":
-            case "video/ogg":
-            case "text/markdown":
-            case "text/plain":
-                return true;
-            default:
-                return false;
-        }
-    }
-
-    private void openFile(String filename, String mimetype) {
-        switch (mimetype) {
-            case "audio/mpeg":
-            case "audio/wav":
-            case "audio/ogg":
-            case "video/mp4":
-            case "video/quicktime":
-            case "video/ogg":
-                openMediaView(filename, mimetype);
-                break;
-            case "image/png":
-            case "image/jpeg":
-            case "image/gif":
-                openImageView(filename, mimetype);
-                break;
-            case "text/markdown":
-            case "text/plain":
-                openTextView(filename, mimetype);
-                break;
-            default:
-                openFileByExternalApp(filename, mimetype);
-        }
-    }
-
-    private void openFileByExternalApp(String fileName, String mimetype) {
-        String path = context.getCacheDir().getAbsolutePath() + "/" + fileName;
-        File file = new File(path);
-        Intent intent;
-        if (Build.VERSION.SDK_INT < 24) {
-            intent = new Intent(Intent.ACTION_VIEW);
-            intent.setDataAndType(Uri.fromFile(file), mimetype);
-            intent.setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
-        } else {
-            intent = new Intent();
-            intent.setAction(Intent.ACTION_VIEW);
-            Uri pdfURI = FileProvider.getUriForFile(context, context.getPackageName(), file);
-            intent.setDataAndType(pdfURI, mimetype);
-            intent.setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
-            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
-        }
-
-        try {
-            if (intent.resolveActivity(context.getPackageManager()) != null) {
-                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-                context.startActivity(intent);
-            } else {
-                Log.e(TAG, "No Application found to open the file. This should have been handled beforehand!");
-            }
-        } catch (Exception e) {
-            Log.e(TAG, "Error while opening file", e);
-        }
-    }
-
-    private boolean canBeHandledByExternalApp(String mimetype, String fileName) {
-        String path = context.getCacheDir().getAbsolutePath() + "/" + fileName;
-        File file = new File(path);
-        Intent intent = new Intent(Intent.ACTION_VIEW);
-        intent.setDataAndType(Uri.fromFile(file), mimetype);
-        return intent.resolveActivity(context.getPackageManager()) != null;
-    }
-
-    private void openImageView(String filename, String mimetype) {
-        Intent fullScreenImageIntent = new Intent(context, FullScreenImageActivity.class);
-        fullScreenImageIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-        fullScreenImageIntent.putExtra("FILE_NAME", filename);
-        fullScreenImageIntent.putExtra("IS_GIF", isGif(mimetype));
-        context.startActivity(fullScreenImageIntent);
-    }
-
-    private void openFileInFilesApp(ChatMessage message, String accountString) {
-        if (AccountUtils.INSTANCE.canWeOpenFilesApp(context, accountString)) {
-            Intent filesAppIntent = new Intent(Intent.ACTION_VIEW, null);
-            final ComponentName componentName = new ComponentName(
-                    context.getString(R.string.nc_import_accounts_from),
-                    "com.owncloud.android.ui.activity.FileDisplayActivity"
-            );
-            filesAppIntent.setComponent(componentName);
-            filesAppIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-            filesAppIntent.setPackage(context.getString(R.string.nc_import_accounts_from));
-            filesAppIntent.putExtra(BundleKeys.INSTANCE.getKEY_ACCOUNT(), accountString);
-            filesAppIntent.putExtra(
-                    BundleKeys.INSTANCE.getKEY_FILE_ID(),
-                    message.getSelectedIndividualHashMap().get(KEY_ID)
-                                   );
-            context.startActivity(filesAppIntent);
-        } else {
-            Intent browserIntent = new Intent(
-                    Intent.ACTION_VIEW,
-                    Uri.parse(message.getSelectedIndividualHashMap().get("link"))
-            );
-            browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-            context.startActivity(browserIntent);
-        }
-    }
-
-    private void onMessageViewLongClick(ChatMessage message, String accountString) {
-        if (isSupportedForInternalViewer(message.getSelectedIndividualHashMap().get(KEY_MIMETYPE))) {
+    private void onMessageViewLongClick(ChatMessage message) {
+        if (fileViewerUtils.isSupportedForInternalViewer(message.getSelectedIndividualHashMap().get(KEY_MIMETYPE))) {
             previewMessageInterface.onPreviewMessageLongClick(message);
             return;
         }
@@ -452,132 +274,17 @@ public abstract class MagicPreviewMessageViewHolder extends MessageHolders.Incom
         popupMenu.inflate(R.menu.chat_preview_message_menu);
 
         popupMenu.setOnMenuItemClickListener(item -> {
-            openFileInFilesApp(message, accountString);
+            if (item.getItemId()== R.id.openInFiles){
+                String keyID = message.getSelectedIndividualHashMap().get(KEY_ID);
+                String link = message.getSelectedIndividualHashMap().get("link");
+                fileViewerUtils.openFileInFilesApp(link, keyID);
+            }
             return true;
         });
 
         popupMenu.show();
     }
 
-    @SuppressLint("LongLogTag")
-    private void downloadFileToCache(ChatMessage message) {
-
-        String baseUrl = message.activeUser.getBaseUrl();
-        String userId = message.activeUser.getUserId();
-        String attachmentFolder = CapabilitiesUtil.getAttachmentFolder(message.activeUser);
-
-        String fileName = message.getSelectedIndividualHashMap().get(KEY_NAME);
-        String mimetype = message.getSelectedIndividualHashMap().get(KEY_MIMETYPE);
-
-        String size = message.getSelectedIndividualHashMap().get("size");
-
-        if (size == null) {
-            size = "-1";
-        }
-        Integer fileSize = Integer.valueOf(size);
-
-        String fileId = message.getSelectedIndividualHashMap().get(KEY_ID);
-        String path = message.getSelectedIndividualHashMap().get(KEY_PATH);
-
-        // check if download worker is already running
-        ListenableFuture<List<WorkInfo>> workers = WorkManager.getInstance(context).getWorkInfosByTag(fileId);
-
-        try {
-            for (WorkInfo workInfo : workers.get()) {
-                if (workInfo.getState() == WorkInfo.State.RUNNING || workInfo.getState() == WorkInfo.State.ENQUEUED) {
-                    Log.d(TAG, "Download worker for " + fileId + " is already running or " +
-                            "scheduled");
-                    return;
-                }
-            }
-        } catch (ExecutionException | InterruptedException e) {
-            Log.e(TAG, "Error when checking if worker already exsists", e);
-        }
-
-        Data data;
-        OneTimeWorkRequest downloadWorker;
-
-        data = new Data.Builder()
-                .putString(DownloadFileToCacheWorker.KEY_BASE_URL, baseUrl)
-                .putString(DownloadFileToCacheWorker.KEY_USER_ID, userId)
-                .putString(DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, attachmentFolder)
-                .putString(DownloadFileToCacheWorker.KEY_FILE_NAME, fileName)
-                .putString(DownloadFileToCacheWorker.KEY_FILE_PATH, path)
-                .putInt(DownloadFileToCacheWorker.KEY_FILE_SIZE, fileSize)
-                .build();
-
-        downloadWorker = new OneTimeWorkRequest.Builder(DownloadFileToCacheWorker.class)
-                .setInputData(data)
-                .addTag(fileId)
-                .build();
-
-        WorkManager.getInstance().enqueue(downloadWorker);
-
-        progressBar.setVisibility(View.VISIBLE);
-
-        WorkManager.getInstance(context).getWorkInfoByIdLiveData(downloadWorker.getId()).observeForever(workInfo -> {
-            updateViewsByProgress(fileName, mimetype, workInfo);
-        });
-    }
-
-    private void updateViewsByProgress(String fileName, String mimetype, WorkInfo workInfo) {
-        switch (workInfo.getState()) {
-            case RUNNING:
-                int progress = workInfo.getProgress().getInt(DownloadFileToCacheWorker.PROGRESS, -1);
-                if (progress > -1) {
-                    getMessageText().setText(String.format(context.getResources().getString(R.string.filename_progress), fileName, progress));
-                }
-                break;
-
-            case SUCCEEDED:
-                if (image.isShown()) {
-                    openFile(fileName, mimetype);
-                } else {
-                    Log.d(TAG, "file " + fileName +
-                            " was downloaded but it's not opened because view is not shown on screen");
-                }
-                getMessageText().setText(fileName);
-                progressBar.setVisibility(View.GONE);
-                break;
-
-            case FAILED:
-                getMessageText().setText(fileName);
-                progressBar.setVisibility(View.GONE);
-                break;
-            default:
-                // do nothing
-                break;
-        }
-    }
-
-    private void openMediaView(String filename, String mimetype) {
-        Intent fullScreenMediaIntent = new Intent(context, FullScreenMediaActivity.class);
-        fullScreenMediaIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-        fullScreenMediaIntent.putExtra("FILE_NAME", filename);
-        fullScreenMediaIntent.putExtra("AUDIO_ONLY", isAudioOnly(mimetype));
-        context.startActivity(fullScreenMediaIntent);
-    }
-
-    private void openTextView(String filename, String mimetype) {
-        Intent fullScreenTextViewerIntent = new Intent(context, FullScreenTextViewerActivity.class);
-        fullScreenTextViewerIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-        fullScreenTextViewerIntent.putExtra("FILE_NAME", filename);
-        fullScreenTextViewerIntent.putExtra("IS_MARKDOWN", isMarkdown(mimetype));
-        context.startActivity(fullScreenTextViewerIntent);
-    }
-
-    private boolean isGif(String mimetype) {
-        return ("image/gif").equals(mimetype);
-    }
-
-    private boolean isMarkdown(String mimetype) {
-        return ("text/markdown").equals(mimetype);
-    }
-
-    private boolean isAudioOnly(String mimetype) {
-        return mimetype.startsWith("audio");
-    }
-
     private void fetchFileInformation(String url, UserEntity activeUser) {
         Single.fromCallable(new Callable<ReadFilesystemOperation>() {
             @Override
@@ -585,34 +292,34 @@ public abstract class MagicPreviewMessageViewHolder extends MessageHolders.Incom
                 return new ReadFilesystemOperation(okHttpClient, activeUser, url, 0);
             }
         }).observeOn(Schedulers.io())
-                .subscribe(new SingleObserver<ReadFilesystemOperation>() {
-                    @Override
-                    public void onSubscribe(@NonNull Disposable d) {
-                        // unused atm
-                    }
+            .subscribe(new SingleObserver<ReadFilesystemOperation>() {
+                @Override
+                public void onSubscribe(@NonNull Disposable d) {
+                    // unused atm
+                }
 
-                    @Override
-                    public void onSuccess(@NonNull ReadFilesystemOperation readFilesystemOperation) {
-                        DavResponse davResponse = readFilesystemOperation.readRemotePath();
-                        if (davResponse.data != null) {
-                            List<BrowserFile> browserFileList = (List<BrowserFile>) davResponse.data;
-                            if (!browserFileList.isEmpty()) {
-                                new Handler(context.getMainLooper()).post(() -> {
-                                    int resourceId = DrawableUtils
-                                            .INSTANCE
-                                            .getDrawableResourceIdForMimeType(browserFileList.get(0).mimeType);
-                                    Drawable drawable = ContextCompat.getDrawable(context, resourceId);
-                                    image.getHierarchy().setPlaceholderImage(drawable);
-                                });
-                            }
+                @Override
+                public void onSuccess(@NonNull ReadFilesystemOperation readFilesystemOperation) {
+                    DavResponse davResponse = readFilesystemOperation.readRemotePath();
+                    if (davResponse.data != null) {
+                        List<BrowserFile> browserFileList = (List<BrowserFile>) davResponse.data;
+                        if (!browserFileList.isEmpty()) {
+                            new Handler(context.getMainLooper()).post(() -> {
+                                int resourceId = DrawableUtils
+                                    .INSTANCE
+                                    .getDrawableResourceIdForMimeType(browserFileList.get(0).mimeType);
+                                Drawable drawable = ContextCompat.getDrawable(context, resourceId);
+                                image.getHierarchy().setPlaceholderImage(drawable);
+                            });
                         }
                     }
+                }
 
-                    @Override
-                    public void onError(@NonNull Throwable e) {
-                        Log.e(TAG, "Error reading file information", e);
-                    }
-                });
+                @Override
+                public void onError(@NonNull Throwable e) {
+                    Log.e(TAG, "Error reading file information", e);
+                }
+            });
     }
 
     public void assignReactionInterface(ReactionsInterface reactionsInterface) {
@@ -622,4 +329,22 @@ public abstract class MagicPreviewMessageViewHolder extends MessageHolders.Incom
     public void assignPreviewMessageInterface(PreviewMessageInterface previewMessageInterface) {
         this.previewMessageInterface = previewMessageInterface;
     }
+
+    public abstract EmojiTextView getMessageText();
+
+    public abstract ProgressBar getProgressBar();
+
+    public abstract SimpleDraweeView getImage();
+
+    public abstract View getPreviewContainer();
+
+    public abstract View getPreviewContactContainer();
+
+    public abstract SimpleDraweeView getPreviewContactPhoto();
+
+    public abstract EmojiTextView getPreviewContactName();
+
+    public abstract ProgressBar getPreviewContactProgressBar();
+
+    public abstract ReactionsInsideMessageBinding getReactionsBinding();
 }

+ 14 - 0
app/src/main/java/com/nextcloud/talk/api/NcApi.java

@@ -27,6 +27,8 @@ package com.nextcloud.talk.api;
 import com.nextcloud.talk.models.json.capabilities.CapabilitiesOverall;
 import com.nextcloud.talk.models.json.chat.ChatOverall;
 import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage;
+import com.nextcloud.talk.models.json.chat.ChatShareOverall;
+import com.nextcloud.talk.models.json.chat.ChatShareOverviewOverall;
 import com.nextcloud.talk.models.json.conversations.RoomOverall;
 import com.nextcloud.talk.models.json.conversations.RoomsOverall;
 import com.nextcloud.talk.models.json.generic.GenericOverall;
@@ -338,6 +340,18 @@ public interface NcApi {
                                                @Field("actorDisplayName") String actorDisplayName,
                                                @Field("replyTo") Integer replyTo);
 
+    @GET
+    Observable<Response<ChatShareOverall>> getSharedItems(@Header("Authorization") String authorization, @Url String url,
+                                                          @Query("objectType") String objectType,
+                                                          @Nullable @Query("lastKnownMessageId") Integer lastKnownMessageId,
+                                                          @Nullable @Query("limit") Integer limit);
+
+    @GET
+    Observable<Response<ChatShareOverviewOverall>> getSharedItemsOverview(@Header("Authorization") String authorization,
+                                                                          @Url String url,
+                                                                          @Nullable @Query("limit") Integer limit);
+
+
     @GET
     Observable<MentionOverall> getMentionAutocompleteSuggestions(@Header("Authorization") String authorization,
                                                                  @Url String url, @Query("search") String query,

+ 27 - 7
app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt

@@ -46,6 +46,7 @@ import android.os.Build
 import android.os.Build.VERSION_CODES.O
 import android.os.Bundle
 import android.os.Handler
+import android.os.Parcelable
 import android.os.SystemClock
 import android.os.VibrationEffect
 import android.os.Vibrator
@@ -99,6 +100,7 @@ import com.nextcloud.talk.BuildConfig
 import com.nextcloud.talk.R
 import com.nextcloud.talk.activities.CallActivity
 import com.nextcloud.talk.activities.MainActivity
+import com.nextcloud.talk.activities.SharedItemsActivity
 import com.nextcloud.talk.activities.TakePhotoActivity
 import com.nextcloud.talk.adapters.messages.IncomingLocationMessageViewHolder
 import com.nextcloud.talk.adapters.messages.IncomingPreviewMessageViewHolder
@@ -156,6 +158,7 @@ import com.nextcloud.talk.utils.NotificationUtils
 import com.nextcloud.talk.utils.UriUtils
 import com.nextcloud.talk.utils.bundle.BundleKeys
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACTIVE_CONVERSATION
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ID
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY
@@ -188,9 +191,7 @@ import java.io.File
 import java.io.IOException
 import java.net.HttpURLConnection
 import java.text.SimpleDateFormat
-import java.util.ArrayList
 import java.util.Date
-import java.util.HashMap
 import java.util.Objects
 import java.util.concurrent.ExecutionException
 import javax.inject.Inject
@@ -253,6 +254,7 @@ class ChatController(args: Bundle) :
     var conversationInfoMenuItem: MenuItem? = null
     var conversationVoiceCallMenuItem: MenuItem? = null
     var conversationVideoMenuItem: MenuItem? = null
+    var conversationSharedItemsItem: MenuItem? = null
 
     var magicWebSocketInstance: MagicWebSocketInstance? = null
 
@@ -1464,7 +1466,7 @@ class ChatController(args: Bundle) :
         val bundle = Bundle()
         bundle.putParcelable(BundleKeys.KEY_BROWSER_TYPE, Parcels.wrap<BrowserController.BrowserType>(browserType))
         bundle.putParcelable(BundleKeys.KEY_USER_ENTITY, Parcels.wrap<UserEntity>(conversationUser))
-        bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken)
+        bundle.putString(KEY_ROOM_TOKEN, roomToken)
         router.pushController(
             RouterTransaction.with(BrowserForSharingController(bundle))
                 .pushChangeHandler(VerticalChangeHandler())
@@ -1476,7 +1478,7 @@ class ChatController(args: Bundle) :
         Log.d(TAG, "showShareLocationScreen")
 
         val bundle = Bundle()
-        bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken)
+        bundle.putString(KEY_ROOM_TOKEN, roomToken)
         router.pushController(
             RouterTransaction.with(LocationPickerController(bundle))
                 .pushChangeHandler(HorizontalChangeHandler())
@@ -1487,7 +1489,7 @@ class ChatController(args: Bundle) :
     private fun showConversationInfoScreen() {
         val bundle = Bundle()
         bundle.putParcelable(BundleKeys.KEY_USER_ENTITY, conversationUser)
-        bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken)
+        bundle.putString(KEY_ROOM_TOKEN, roomToken)
         bundle.putBoolean(BundleKeys.KEY_ROOM_ONE_TO_ONE, inOneToOneCall())
         router.pushController(
             RouterTransaction.with(ConversationInfoController(bundle))
@@ -1679,7 +1681,7 @@ class ChatController(args: Bundle) :
                 "  sessionId: " + currentConversation?.sessionId
         )
 
-        if (! validSessionId()) {
+        if (!validSessionId()) {
             var apiVersion = 1
             // FIXME Fix API checking with guests?
             if (conversationUser != null) {
@@ -2300,6 +2302,12 @@ class ChatController(args: Bundle) :
             conversationVoiceCallMenuItem = menu.findItem(R.id.conversation_voice_call)
             conversationVideoMenuItem = menu.findItem(R.id.conversation_video_call)
 
+            if (CapabilitiesUtil.hasSpreedFeatureCapability(conversationUser, "rich-object-list-media")) {
+                conversationSharedItemsItem = menu.findItem(R.id.shared_items)
+            } else {
+                menu.removeItem(R.id.shared_items)
+            }
+
             loadAvatarForStatusBar()
         }
     }
@@ -2337,10 +2345,22 @@ class ChatController(args: Bundle) :
                 showConversationInfoScreen()
                 return true
             }
+            R.id.shared_items -> {
+                showSharedItems()
+                return true
+            }
             else -> return super.onOptionsItemSelected(item)
         }
     }
 
+    private fun showSharedItems() {
+        val intent = Intent(activity, SharedItemsActivity::class.java)
+        intent.putExtra(KEY_CONVERSATION_NAME, currentConversation?.displayName)
+        intent.putExtra(KEY_ROOM_TOKEN, roomToken)
+        intent.putExtra(KEY_USER_ENTITY, conversationUser as Parcelable)
+        activity!!.startActivity(intent)
+    }
+
     private fun handleSystemMessages(chatMessageList: List<ChatMessage>): List<ChatMessage> {
         val chatMessageMap = chatMessageList.map { it.id to it }.toMap().toMutableMap()
         val chatMessageIterator = chatMessageMap.iterator()
@@ -2402,7 +2422,7 @@ class ChatController(args: Bundle) :
             bundle.putParcelable(KEY_USER_ENTITY, conversationUser)
             bundle.putString(BundleKeys.KEY_CONVERSATION_PASSWORD, roomPassword)
             bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, conversationUser?.baseUrl)
-            bundle.putString(BundleKeys.KEY_CONVERSATION_NAME, it.displayName)
+            bundle.putString(KEY_CONVERSATION_NAME, it.displayName)
 
             if (isVoiceOnlyCall) {
                 bundle.putBoolean(BundleKeys.KEY_CALL_VOICE_ONLY, true)

+ 19 - 3
app/src/main/java/com/nextcloud/talk/controllers/ConversationInfoController.kt

@@ -27,9 +27,11 @@
 package com.nextcloud.talk.controllers
 
 import android.annotation.SuppressLint
+import android.content.Intent
 import android.graphics.drawable.Drawable
 import android.graphics.drawable.LayerDrawable
 import android.os.Bundle
+import android.os.Parcelable
 import android.text.TextUtils
 import android.util.Log
 import android.view.MenuItem
@@ -49,6 +51,7 @@ import com.bluelinelabs.conductor.RouterTransaction
 import com.bluelinelabs.conductor.changehandler.HorizontalChangeHandler
 import com.facebook.drawee.backends.pipeline.Fresco
 import com.nextcloud.talk.R
+import com.nextcloud.talk.activities.SharedItemsActivity
 import com.nextcloud.talk.adapters.items.ParticipantItem
 import com.nextcloud.talk.api.NcApi
 import com.nextcloud.talk.application.NextcloudTalkApplication
@@ -88,11 +91,8 @@ import io.reactivex.schedulers.Schedulers
 import org.greenrobot.eventbus.EventBus
 import org.greenrobot.eventbus.Subscribe
 import org.greenrobot.eventbus.ThreadMode
-import java.util.ArrayList
 import java.util.Calendar
 import java.util.Collections
-import java.util.Comparator
-import java.util.HashMap
 import java.util.Locale
 import javax.inject.Inject
 
@@ -176,9 +176,25 @@ class ConversationInfoController(args: Bundle) :
         binding.clearConversationHistory.setOnClickListener { showClearHistoryDialog(null) }
         binding.addParticipantsAction.setOnClickListener { addParticipants() }
 
+        if (CapabilitiesUtil.hasSpreedFeatureCapability(conversationUser, "rich-object-list-media")) {
+            binding.showSharedItemsAction.setOnClickListener { showSharedItems() }
+        } else {
+            binding.categorySharedItems.visibility = View.GONE
+        }
+
         fetchRoomInfo()
     }
 
+    private fun showSharedItems() {
+        val intent = Intent(activity, SharedItemsActivity::class.java)
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+        intent.putExtra(BundleKeys.KEY_CONVERSATION_NAME, conversation?.displayName)
+        intent.putExtra(BundleKeys.KEY_ROOM_TOKEN, conversationToken)
+        intent.putExtra(BundleKeys.KEY_USER_ENTITY, conversationUser as Parcelable)
+        activity!!.startActivity(intent)
+    }
+
     override fun onViewBound(view: View) {
         super.onViewBound(view)
 

+ 3 - 0
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.java

@@ -156,6 +156,9 @@ public class ChatMessage implements MessageContentType, MessageContentType.Image
                 if (MessageDigest.isEqual(
                     Objects.requireNonNull(individualHashMap.get("type")).getBytes(Charsets.UTF_8),
                     ("file").getBytes(Charsets.UTF_8))) {
+
+                    // TODO: this selectedIndividualHashMap stuff needs to be analyzed and most likely be refactored!
+                    //  it just feels wrong to fill this here inside getImageUrl()
                     selectedIndividualHashMap = individualHashMap;
                     if (!isVoiceMessage()) {
                         if (getActiveUser() != null && getActiveUser().getBaseUrl() != null) {

+ 76 - 0
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOCS.java

@@ -0,0 +1,76 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Mario Danic
+ * Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.talk.models.json.chat;
+
+import com.bluelinelabs.logansquare.annotation.JsonField;
+import com.bluelinelabs.logansquare.annotation.JsonObject;
+import com.nextcloud.talk.models.json.generic.GenericOCS;
+
+import org.parceler.Parcel;
+
+import java.util.HashMap;
+import java.util.Objects;
+
+@Parcel
+@JsonObject
+public class ChatShareOCS {
+    @JsonField(name = "data")
+    public HashMap<String, ChatMessage> data;
+
+    public HashMap<String, ChatMessage> getData() {
+        return this.data;
+    }
+
+    public void setData(HashMap<String, ChatMessage> data) {
+        this.data = data;
+    }
+
+    public boolean equals(final Object o) {
+        if (o == this) {
+            return true;
+        }
+        if (!(o instanceof ChatShareOCS)) {
+            return false;
+        }
+        final ChatShareOCS other = (ChatShareOCS) o;
+        if (!other.canEqual(this)) {
+            return false;
+        }
+        final Object this$data = this.getData();
+        final Object other$data = other.getData();
+
+        return Objects.equals(this$data, other$data);
+    }
+
+    protected boolean canEqual(final Object other) {
+        return other instanceof ChatShareOCS;
+    }
+
+    public int hashCode() {
+        final int PRIME = 59;
+        int result = 1;
+        final Object $data = this.getData();
+        return result * PRIME + ($data == null ? 43 : $data.hashCode());
+    }
+
+    public String toString() {
+        return "ChatShareOCS(data=" + this.getData() + ")";
+    }
+}

+ 75 - 0
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOverall.java

@@ -0,0 +1,75 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Mario Danic
+ * Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.models.json.chat;
+
+import com.bluelinelabs.logansquare.annotation.JsonField;
+import com.bluelinelabs.logansquare.annotation.JsonObject;
+
+import org.parceler.Parcel;
+
+import java.util.Objects;
+
+@Parcel
+@JsonObject
+public class ChatShareOverall {
+    @JsonField(name = "ocs")
+    public ChatShareOCS ocs;
+
+    public ChatShareOCS getOcs() {
+        return this.ocs;
+    }
+
+    public void setOcs(ChatShareOCS ocs) {
+        this.ocs = ocs;
+    }
+
+    public boolean equals(final Object o) {
+        if (o == this) {
+            return true;
+        }
+        if (!(o instanceof ChatShareOverall)) {
+            return false;
+        }
+        final ChatShareOverall other = (ChatShareOverall) o;
+        if (!other.canEqual(this)) {
+            return false;
+        }
+        final Object this$ocs = this.getOcs();
+        final Object other$ocs = other.getOcs();
+
+        return Objects.equals(this$ocs, other$ocs);
+    }
+
+    protected boolean canEqual(final Object other) {
+        return other instanceof ChatShareOverall;
+    }
+
+    public int hashCode() {
+        final int PRIME = 59;
+        int result = 1;
+        final Object $ocs = this.getOcs();
+        return result * PRIME + ($ocs == null ? 43 : $ocs.hashCode());
+    }
+
+    public String toString() {
+        return "ChatShareOverall(ocs=" + this.getOcs() + ")";
+    }
+}

+ 78 - 0
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOverviewOCS.java

@@ -0,0 +1,78 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Mario Danic
+ * Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.talk.models.json.chat;
+
+import com.bluelinelabs.logansquare.annotation.JsonField;
+import com.bluelinelabs.logansquare.annotation.JsonObject;
+
+import org.parceler.Parcel;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Objects;
+
+import androidx.annotation.NonNull;
+
+@Parcel
+@JsonObject
+public class ChatShareOverviewOCS {
+    @JsonField(name = "data")
+    public HashMap<String, List<Object>> data;
+
+    public HashMap<String, List<Object>> getData() {
+        return this.data;
+    }
+
+    public void setData(HashMap<String, List<Object>> data) {
+        this.data = data;
+    }
+
+    public boolean equals(final Object o) {
+        if (o == this) {
+            return true;
+        }
+        if (!(o instanceof ChatShareOverviewOCS)) {
+            return false;
+        }
+        final ChatShareOverviewOCS other = (ChatShareOverviewOCS) o;
+        if (!other.canEqual(this)) {
+            return false;
+        }
+        final Object this$data = this.getData();
+        final Object other$data = other.getData();
+
+        return Objects.equals(this$data, other$data);
+    }
+
+    protected boolean canEqual(final Object other) {
+        return other instanceof ChatShareOverviewOCS;
+    }
+
+    public int hashCode() {
+        final int PRIME = 59;
+        int result = 1;
+        final Object $data = this.getData();
+        return result * PRIME + ($data == null ? 43 : $data.hashCode());
+    }
+
+    public String toString() {
+        return "ChatShareOverviewOCS(data=" + this.getData() + ")";
+    }
+}

+ 75 - 0
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOverviewOverall.java

@@ -0,0 +1,75 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Mario Danic
+ * Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.models.json.chat;
+
+import com.bluelinelabs.logansquare.annotation.JsonField;
+import com.bluelinelabs.logansquare.annotation.JsonObject;
+
+import org.parceler.Parcel;
+
+import java.util.Objects;
+
+@Parcel
+@JsonObject
+public class ChatShareOverviewOverall {
+    @JsonField(name = "ocs")
+    public ChatShareOverviewOCS ocs;
+
+    public ChatShareOverviewOCS getOcs() {
+        return this.ocs;
+    }
+
+    public void setOcs(ChatShareOverviewOCS ocs) {
+        this.ocs = ocs;
+    }
+
+    public boolean equals(final Object o) {
+        if (o == this) {
+            return true;
+        }
+        if (!(o instanceof ChatShareOverviewOverall)) {
+            return false;
+        }
+        final ChatShareOverviewOverall other = (ChatShareOverviewOverall) o;
+        if (!other.canEqual(this)) {
+            return false;
+        }
+        final Object this$ocs = this.getOcs();
+        final Object other$ocs = other.getOcs();
+
+        return Objects.equals(this$ocs, other$ocs);
+    }
+
+    protected boolean canEqual(final Object other) {
+        return other instanceof ChatShareOverviewOverall;
+    }
+
+    public int hashCode() {
+        final int PRIME = 59;
+        int result = 1;
+        final Object $ocs = this.getOcs();
+        return result * PRIME + ($ocs == null ? 43 : $ocs.hashCode());
+    }
+
+    public String toString() {
+        return "ChatShareOverviewOverall(ocs=" + this.getOcs() + ")";
+    }
+}

+ 16 - 0
app/src/main/java/com/nextcloud/talk/repositories/SharedItem.kt

@@ -0,0 +1,16 @@
+package com.nextcloud.talk.repositories
+
+import com.nextcloud.talk.models.database.UserEntity
+
+data class SharedItem(
+    val id: String,
+    val name: String,
+    val fileSize: Int,
+    val date: Long,
+    val path: String,
+    val link: String,
+    val mimeType: String,
+    val previewAvailable: Boolean,
+    val previewLink: String,
+    val userEntity: UserEntity,
+)

+ 18 - 0
app/src/main/java/com/nextcloud/talk/repositories/SharedItemType.kt

@@ -0,0 +1,18 @@
+package com.nextcloud.talk.repositories
+
+import java.util.Locale
+
+enum class SharedItemType {
+
+    AUDIO,
+    FILE,
+    MEDIA,
+    VOICE,
+    LOCATION,
+    DECKCARD,
+    OTHER;
+
+    companion object {
+        fun typeFor(name: String) = valueOf(name.uppercase(Locale.ROOT))
+    }
+}

+ 78 - 0
app/src/main/java/com/nextcloud/talk/repositories/SharedItemsRepository.kt

@@ -0,0 +1,78 @@
+package com.nextcloud.talk.repositories
+
+import autodagger.AutoInjector
+import com.nextcloud.talk.R
+import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.models.database.UserEntity
+import com.nextcloud.talk.models.json.chat.ChatShareOverall
+import com.nextcloud.talk.models.json.chat.ChatShareOverviewOverall
+import com.nextcloud.talk.utils.ApiUtils
+import io.reactivex.Observable
+import retrofit2.Response
+import java.util.Locale
+import javax.inject.Inject
+
+@AutoInjector(NextcloudTalkApplication::class)
+class SharedItemsRepository {
+
+    var parameters: Parameters? = null
+
+    @Inject
+    lateinit var ncApi: NcApi
+
+    init {
+        sharedApplication!!.componentApplication.inject(this)
+    }
+
+    fun media(type: SharedItemType): Observable<Response<ChatShareOverall>>? {
+        return media(type, null)
+    }
+
+    fun media(type: SharedItemType, lastKnownMessageId: Int?): Observable<Response<ChatShareOverall>>? {
+        val credentials = ApiUtils.getCredentials(parameters!!.userName, parameters!!.userToken)
+
+        return ncApi.getSharedItems(
+            credentials,
+            ApiUtils.getUrlForChatSharedItems(1, parameters!!.baseUrl, parameters!!.roomToken),
+            type.toString().lowercase(Locale.ROOT),
+            lastKnownMessageId,
+            BATCH_SIZE
+        )
+    }
+
+    fun availableTypes(): Observable<Response<ChatShareOverviewOverall>>? {
+        val credentials = ApiUtils.getCredentials(parameters!!.userName, parameters!!.userToken)
+
+        return ncApi.getSharedItemsOverview(
+            credentials,
+            ApiUtils.getUrlForChatSharedItemsOverview(1, parameters!!.baseUrl, parameters!!.roomToken),
+            1
+        )
+    }
+
+    fun authHeader(): Map<String, String> {
+        return mapOf(Pair("Authorization", ApiUtils.getCredentials(parameters!!.userName, parameters!!.userToken)))
+    }
+
+    fun previewLink(fileId: String?): String {
+        return ApiUtils.getUrlForFilePreviewWithFileId(
+            parameters!!.baseUrl,
+            fileId,
+            sharedApplication!!.resources.getDimensionPixelSize(R.dimen.maximum_file_preview_size)
+        )
+    }
+
+    data class Parameters(
+        val userName: String,
+        val userToken: String,
+        val baseUrl: String,
+        val userEntity: UserEntity,
+        val roomToken: String
+    )
+
+    companion object {
+        const val BATCH_SIZE: Int = 28
+    }
+}

+ 9 - 0
app/src/main/java/com/nextcloud/talk/repositories/SharedMediaItems.kt

@@ -0,0 +1,9 @@
+package com.nextcloud.talk.repositories
+
+class SharedMediaItems(
+    val type: SharedItemType,
+    val items: MutableList<SharedItem>,
+    var lastSeenId: Int?,
+    var moreItemsExisting: Boolean,
+    val authHeader: Map<String, String>
+)

+ 8 - 0
app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java

@@ -260,6 +260,14 @@ public class ApiUtils {
     public static String getUrlForChatMessage(int version, String baseUrl, String token, String messageId) {
         return getUrlForChat(version, baseUrl, token) + "/" + messageId;
     }
+    
+    public static String getUrlForChatSharedItems(int version, String baseUrl, String token) {
+        return getUrlForChat(version, baseUrl, token) + "/share";
+    }
+
+    public static String getUrlForChatSharedItemsOverview(int version, String baseUrl, String token) {
+        return getUrlForChatSharedItems(version, baseUrl, token) + "/overview";
+    }
 
     public static String getUrlForSignaling(int version, String baseUrl) {
         return getUrlForApi(version, baseUrl) + "/signaling";

+ 393 - 0
app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt

@@ -0,0 +1,393 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Marcel Hibbe
+ * Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.utils
+
+import android.annotation.SuppressLint
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.util.Log
+import android.view.View
+import android.widget.ProgressBar
+import androidx.core.content.FileProvider
+import androidx.emoji.widget.EmojiTextView
+import androidx.work.Data
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import com.facebook.drawee.view.SimpleDraweeView
+import com.nextcloud.talk.R
+import com.nextcloud.talk.activities.FullScreenImageActivity
+import com.nextcloud.talk.activities.FullScreenMediaActivity
+import com.nextcloud.talk.activities.FullScreenTextViewerActivity
+import com.nextcloud.talk.adapters.messages.MagicPreviewMessageViewHolder
+import com.nextcloud.talk.jobs.DownloadFileToCacheWorker
+import com.nextcloud.talk.models.database.CapabilitiesUtil
+import com.nextcloud.talk.models.database.UserEntity
+import com.nextcloud.talk.models.json.chat.ChatMessage
+import com.nextcloud.talk.utils.AccountUtils.canWeOpenFilesApp
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACCOUNT
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_ID
+import java.io.File
+import java.util.concurrent.ExecutionException
+
+class FileViewerUtils(private val context: Context, private val userEntity: UserEntity) {
+
+    fun openFile(
+        message: ChatMessage,
+        progressUi: ProgressUi
+    ) {
+        val fileName = message.getSelectedIndividualHashMap()[MagicPreviewMessageViewHolder.KEY_NAME]!!
+        val mimetype = message.getSelectedIndividualHashMap()[MagicPreviewMessageViewHolder.KEY_MIMETYPE]!!
+        val link = message.getSelectedIndividualHashMap()["link"]!!
+
+        val fileId = message.getSelectedIndividualHashMap()[MagicPreviewMessageViewHolder.KEY_ID]!!
+        val path = message.getSelectedIndividualHashMap()[MagicPreviewMessageViewHolder.KEY_PATH]!!
+
+        var size = message.getSelectedIndividualHashMap()["size"]
+        if (size == null) {
+            size = "-1"
+        }
+        val fileSize = Integer.valueOf(size)
+
+        openFile(
+            FileInfo(fileId, fileName, fileSize),
+            path,
+            link,
+            mimetype,
+            progressUi
+        )
+    }
+
+    fun openFile(
+        fileInfo: FileInfo,
+        path: String,
+        link: String,
+        mimetype: String,
+        progressUi: ProgressUi
+    ) {
+        if (isSupportedForInternalViewer(mimetype) || canBeHandledByExternalApp(mimetype, fileInfo.fileName)) {
+            openOrDownloadFile(
+                fileInfo,
+                path,
+                mimetype,
+                progressUi
+            )
+        } else {
+            openFileInFilesApp(link, fileInfo.fileId)
+        }
+    }
+
+    private fun canBeHandledByExternalApp(mimetype: String, fileName: String): Boolean {
+        val path: String = context.cacheDir.absolutePath + "/" + fileName
+        val file = File(path)
+        val intent = Intent(Intent.ACTION_VIEW)
+        intent.setDataAndType(Uri.fromFile(file), mimetype)
+        return intent.resolveActivity(context.packageManager) != null
+    }
+
+    private fun openOrDownloadFile(
+        fileInfo: FileInfo,
+        path: String,
+        mimetype: String,
+        progressUi: ProgressUi
+    ) {
+        val file = File(context.cacheDir, fileInfo.fileName)
+        if (file.exists()) {
+            openFileByMimetype(fileInfo.fileName!!, mimetype!!)
+        } else {
+            downloadFileToCache(
+                fileInfo,
+                path,
+                mimetype,
+                progressUi
+            )
+        }
+    }
+
+    private fun openFileByMimetype(filename: String, mimetype: String) {
+        when (mimetype) {
+            "audio/mpeg",
+            "audio/wav",
+            "audio/ogg",
+            "video/mp4",
+            "video/quicktime",
+            "video/ogg"
+            -> openMediaView(filename, mimetype)
+            "image/png",
+            "image/jpeg",
+            "image/gif"
+            -> openImageView(filename, mimetype)
+            "text/markdown",
+            "text/plain"
+            -> openTextView(filename, mimetype)
+            else
+            -> openFileByExternalApp(filename, mimetype)
+        }
+    }
+
+    @Suppress("Detekt.TooGenericExceptionCaught")
+    private fun openFileByExternalApp(fileName: String, mimetype: String) {
+        val path = context.cacheDir.absolutePath + "/" + fileName
+        val file = File(path)
+        val intent: Intent
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+            intent = Intent(Intent.ACTION_VIEW)
+            intent.setDataAndType(Uri.fromFile(file), mimetype)
+            intent.flags = Intent.FLAG_ACTIVITY_NO_HISTORY
+        } else {
+            intent = Intent()
+            intent.action = Intent.ACTION_VIEW
+            val pdfURI = FileProvider.getUriForFile(context, context.packageName, file)
+            intent.setDataAndType(pdfURI, mimetype)
+            intent.flags = Intent.FLAG_ACTIVITY_NO_HISTORY
+            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+        }
+        try {
+            if (intent.resolveActivity(context.packageManager) != null) {
+                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+                context.startActivity(intent)
+            } else {
+                Log.e(TAG, "No Application found to open the file. This should have been handled beforehand!")
+            }
+        } catch (e: Exception) {
+            Log.e(TAG, "Error while opening file", e)
+        }
+    }
+
+    fun openFileInFilesApp(link: String, keyID: String) {
+        val accountString = userEntity.username + "@" +
+            userEntity.baseUrl
+                .replace("https://", "")
+                .replace("http://", "")
+
+        if (canWeOpenFilesApp(context, accountString)) {
+            val filesAppIntent = Intent(Intent.ACTION_VIEW, null)
+            val componentName = ComponentName(
+                context.getString(R.string.nc_import_accounts_from),
+                "com.owncloud.android.ui.activity.FileDisplayActivity"
+            )
+            filesAppIntent.component = componentName
+            filesAppIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+            filesAppIntent.setPackage(context.getString(R.string.nc_import_accounts_from))
+            filesAppIntent.putExtra(KEY_ACCOUNT, accountString)
+            filesAppIntent.putExtra(KEY_FILE_ID, keyID)
+            context.startActivity(filesAppIntent)
+        } else {
+            val browserIntent = Intent(
+                Intent.ACTION_VIEW,
+                Uri.parse(link)
+            )
+            browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+            context.startActivity(browserIntent)
+        }
+    }
+
+    private fun openImageView(filename: String, mimetype: String) {
+        val fullScreenImageIntent = Intent(context, FullScreenImageActivity::class.java)
+        fullScreenImageIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        fullScreenImageIntent.putExtra("FILE_NAME", filename)
+        fullScreenImageIntent.putExtra("IS_GIF", isGif(mimetype))
+        context.startActivity(fullScreenImageIntent)
+    }
+
+    private fun openMediaView(filename: String, mimetype: String) {
+        val fullScreenMediaIntent = Intent(context, FullScreenMediaActivity::class.java)
+        fullScreenMediaIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        fullScreenMediaIntent.putExtra("FILE_NAME", filename)
+        fullScreenMediaIntent.putExtra("AUDIO_ONLY", isAudioOnly(mimetype))
+        context.startActivity(fullScreenMediaIntent)
+    }
+
+    private fun openTextView(filename: String, mimetype: String) {
+        val fullScreenTextViewerIntent = Intent(context, FullScreenTextViewerActivity::class.java)
+        fullScreenTextViewerIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        fullScreenTextViewerIntent.putExtra("FILE_NAME", filename)
+        fullScreenTextViewerIntent.putExtra("IS_MARKDOWN", isMarkdown(mimetype))
+        context.startActivity(fullScreenTextViewerIntent)
+    }
+
+    fun isSupportedForInternalViewer(mimetype: String?): Boolean {
+        return when (mimetype) {
+            "image/png", "image/jpeg",
+            "image/gif", "audio/mpeg",
+            "audio/wav", "audio/ogg",
+            "video/mp4", "video/quicktime",
+            "video/ogg", "text/markdown",
+            "text/plain" -> true
+            else -> false
+        }
+    }
+
+    private fun isGif(mimetype: String): Boolean {
+        return "image/gif" == mimetype
+    }
+
+    private fun isMarkdown(mimetype: String): Boolean {
+        return "text/markdown" == mimetype
+    }
+
+    private fun isAudioOnly(mimetype: String): Boolean {
+        return mimetype.startsWith("audio")
+    }
+
+    @SuppressLint("LongLogTag")
+    private fun downloadFileToCache(
+        fileInfo: FileInfo,
+        path: String,
+        mimetype: String,
+        progressUi: ProgressUi
+    ) {
+        // check if download worker is already running
+        val workers = WorkManager.getInstance(context).getWorkInfosByTag(fileInfo.fileId!!)
+        try {
+            for (workInfo in workers.get()) {
+                if (workInfo.state == WorkInfo.State.RUNNING || workInfo.state == WorkInfo.State.ENQUEUED) {
+                    Log.d(TAG, "Download worker for $fileInfo.fileId is already running or scheduled")
+                    return
+                }
+            }
+        } catch (e: ExecutionException) {
+            Log.e(TAG, "Error when checking if worker already exists", e)
+        } catch (e: InterruptedException) {
+            Log.e(TAG, "Error when checking if worker already exists", e)
+        }
+        val downloadWorker: OneTimeWorkRequest
+        val data: Data = Data.Builder()
+            .putString(DownloadFileToCacheWorker.KEY_BASE_URL, userEntity.baseUrl)
+            .putString(DownloadFileToCacheWorker.KEY_USER_ID, userEntity.userId)
+            .putString(
+                DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER,
+                CapabilitiesUtil.getAttachmentFolder(userEntity)
+            )
+            .putString(DownloadFileToCacheWorker.KEY_FILE_NAME, fileInfo.fileName)
+            .putString(DownloadFileToCacheWorker.KEY_FILE_PATH, path)
+            .putInt(DownloadFileToCacheWorker.KEY_FILE_SIZE, fileInfo.fileSize)
+            .build()
+        downloadWorker = OneTimeWorkRequest.Builder(DownloadFileToCacheWorker::class.java)
+            .setInputData(data)
+            .addTag(fileInfo.fileId)
+            .build()
+        WorkManager.getInstance().enqueue(downloadWorker)
+        progressUi.progressBar?.visibility = View.VISIBLE
+        WorkManager.getInstance(context).getWorkInfoByIdLiveData(downloadWorker.id)
+            .observeForever { workInfo: WorkInfo? ->
+                updateViewsByProgress(
+                    fileInfo.fileName,
+                    mimetype,
+                    workInfo!!,
+                    progressUi
+                )
+            }
+    }
+
+    private fun updateViewsByProgress(
+        fileName: String,
+        mimetype: String,
+        workInfo: WorkInfo,
+        progressUi: ProgressUi
+    ) {
+        when (workInfo.state) {
+            WorkInfo.State.RUNNING -> {
+                val progress = workInfo.progress.getInt(DownloadFileToCacheWorker.PROGRESS, -1)
+                if (progress > -1) {
+                    progressUi.messageText?.text = String.format(
+                        context.resources.getString(R.string.filename_progress),
+                        fileName,
+                        progress
+                    )
+                }
+            }
+            WorkInfo.State.SUCCEEDED -> {
+                if (progressUi.previewImage.isShown) {
+                    openFileByMimetype(fileName, mimetype)
+                } else {
+                    Log.d(
+                        TAG,
+                        "file " + fileName +
+                            " was downloaded but it's not opened because view is not shown on screen"
+                    )
+                }
+                progressUi.messageText?.text = fileName
+                progressUi.progressBar?.visibility = View.GONE
+            }
+            WorkInfo.State.FAILED -> {
+                progressUi.messageText?.text = fileName
+                progressUi.progressBar?.visibility = View.GONE
+            }
+            else -> {
+            }
+        }
+    }
+
+    fun resumeToUpdateViewsByProgress(
+        fileName: String,
+        fileId: String,
+        mimeType: String,
+        progressUi: ProgressUi
+    ) {
+        val workers = WorkManager.getInstance(context).getWorkInfosByTag(fileId)
+
+        try {
+            for (workInfo in workers.get()) {
+                if (workInfo.state == WorkInfo.State.RUNNING ||
+                    workInfo.state == WorkInfo.State.ENQUEUED
+                ) {
+                    progressUi.progressBar?.visibility = View.VISIBLE
+                    WorkManager
+                        .getInstance(context)
+                        .getWorkInfoByIdLiveData(workInfo.id)
+                        .observeForever { info: WorkInfo? ->
+                            updateViewsByProgress(
+                                fileName,
+                                mimeType,
+                                info!!,
+                                progressUi
+                            )
+                        }
+                }
+            }
+        } catch (e: ExecutionException) {
+            Log.e(TAG, "Error when checking if worker already exists", e)
+        } catch (e: InterruptedException) {
+            Log.e(TAG, "Error when checking if worker already exists", e)
+        }
+    }
+
+    data class ProgressUi(
+        val progressBar: ProgressBar?,
+        val messageText: EmojiTextView?,
+        val previewImage: SimpleDraweeView
+    )
+
+    data class FileInfo(
+        val fileId: String,
+        val fileName: String,
+        val fileSize: Int
+    )
+
+    companion object {
+        private val TAG = FileViewerUtils::class.simpleName
+        const val KEY_ID = "id"
+    }
+}

+ 192 - 0
app/src/main/java/com/nextcloud/talk/viewmodels/SharedItemsViewModel.kt

@@ -0,0 +1,192 @@
+package com.nextcloud.talk.viewmodels
+
+import android.util.Log
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import com.nextcloud.talk.models.database.UserEntity
+import com.nextcloud.talk.models.json.chat.ChatShareOverall
+import com.nextcloud.talk.models.json.chat.ChatShareOverviewOverall
+import com.nextcloud.talk.repositories.SharedItem
+import com.nextcloud.talk.repositories.SharedItemType
+import com.nextcloud.talk.repositories.SharedItemsRepository
+import com.nextcloud.talk.repositories.SharedMediaItems
+import io.reactivex.Observer
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.disposables.Disposable
+import io.reactivex.schedulers.Schedulers
+import retrofit2.Response
+
+class SharedItemsViewModel(private val repository: SharedItemsRepository, private val initialType: SharedItemType) :
+    ViewModel() {
+
+    private val _sharedItemType: MutableLiveData<Set<SharedItemType>> by lazy {
+        MutableLiveData<Set<SharedItemType>>().also {
+            availableTypes()
+        }
+    }
+
+    private val _sharedItems: MutableLiveData<SharedMediaItems> by lazy {
+        MutableLiveData<SharedMediaItems>().also {
+            loadItems(initialType)
+        }
+    }
+
+    val sharedItemType: LiveData<Set<SharedItemType>>
+        get() = _sharedItemType
+
+    val sharedItems: LiveData<SharedMediaItems>
+        get() = _sharedItems
+
+    fun loadNextItems() {
+        val currentSharedItems = sharedItems.value!!
+
+        if (currentSharedItems.moreItemsExisting) {
+            repository.media(currentSharedItems.type, currentSharedItems.lastSeenId)?.subscribeOn(Schedulers.io())
+                ?.observeOn(AndroidSchedulers.mainThread())
+                ?.subscribe(observer(currentSharedItems.type, false))
+        }
+    }
+
+    fun loadItems(type: SharedItemType) {
+        repository.media(type)?.subscribeOn(Schedulers.io())
+            ?.observeOn(AndroidSchedulers.mainThread())
+            ?.subscribe(observer(type, true))
+    }
+
+    private fun observer(type: SharedItemType, initModel: Boolean): Observer<Response<ChatShareOverall>> {
+        return object : Observer<Response<ChatShareOverall>> {
+
+            var chatLastGiven: Int? = null
+            val items = mutableMapOf<String, SharedItem>()
+
+            override fun onSubscribe(d: Disposable) = Unit
+
+            override fun onNext(response: Response<ChatShareOverall>) {
+
+                if (response.headers()["x-chat-last-given"] != null) {
+                    chatLastGiven = response.headers()["x-chat-last-given"]!!.toInt()
+                }
+
+                val mediaItems = response.body()!!.ocs!!.data
+                if (mediaItems != null) {
+                    for (it in mediaItems) {
+                        if (it.value.messageParameters.containsKey("file")) {
+                            val fileParameters = it.value.messageParameters["file"]!!
+
+                            val previewAvailable =
+                                "yes".equals(fileParameters["preview-available"]!!, ignoreCase = true)
+
+                            items[it.value.id] = SharedItem(
+                                fileParameters["id"]!!,
+                                fileParameters["name"]!!,
+                                fileParameters["size"]!!.toInt(),
+                                it.value.timestamp,
+                                fileParameters["path"]!!,
+                                fileParameters["link"]!!,
+                                fileParameters["mimetype"]!!,
+                                previewAvailable,
+                                repository.previewLink(fileParameters["id"]),
+                                repository.parameters!!.userEntity
+                            )
+                        } else {
+                            Log.w(TAG, "location and deckcard are not yet supported")
+                        }
+                    }
+                }
+            }
+
+            override fun onError(e: Throwable) {
+                Log.d(TAG, "An error occurred: $e")
+            }
+
+            override fun onComplete() {
+
+                val sortedMutableItems = items.toSortedMap().values.toList().reversed().toMutableList()
+                val moreItemsExisting = items.count() == BATCH_SIZE
+
+                if (initModel) {
+                    this@SharedItemsViewModel._sharedItems.value =
+                        SharedMediaItems(
+                            type,
+                            sortedMutableItems,
+                            chatLastGiven,
+                            moreItemsExisting,
+                            repository.authHeader()
+                        )
+                } else {
+                    val oldItems = this@SharedItemsViewModel._sharedItems.value!!.items
+                    this@SharedItemsViewModel._sharedItems.value =
+                        SharedMediaItems(
+                            type,
+                            (oldItems.toMutableList() + sortedMutableItems) as MutableList<SharedItem>,
+                            chatLastGiven,
+                            moreItemsExisting,
+                            repository.authHeader()
+                        )
+                }
+            }
+        }
+    }
+
+    private fun availableTypes() {
+        repository.availableTypes()?.subscribeOn(Schedulers.io())
+            ?.observeOn(AndroidSchedulers.mainThread())
+            ?.subscribe(object : Observer<Response<ChatShareOverviewOverall>> {
+
+                val types = mutableSetOf<SharedItemType>()
+
+                override fun onSubscribe(d: Disposable) = Unit
+
+                override fun onNext(response: Response<ChatShareOverviewOverall>) {
+                    val typeMap = response.body()!!.ocs!!.data
+                    for (it in typeMap) {
+                        if (it.value.size > 0) {
+                            try {
+                                types += SharedItemType.typeFor(it.key)
+                            } catch (e: IllegalArgumentException) {
+                                Log.w(TAG, "Server responds an unknown shared item type: ${it.key}")
+                            }
+                        }
+                    }
+                }
+
+                override fun onError(e: Throwable) {
+                    Log.d(TAG, "An error occurred: $e")
+                }
+
+                override fun onComplete() {
+                    this@SharedItemsViewModel._sharedItemType.value = types
+                }
+            })
+    }
+
+    class Factory(val userEntity: UserEntity, val roomToken: String, private val initialType: SharedItemType) :
+        ViewModelProvider
+        .Factory {
+
+        override fun <T : ViewModel?> create(modelClass: Class<T>): T {
+            if (modelClass.isAssignableFrom(SharedItemsViewModel::class.java)) {
+
+                val repository = SharedItemsRepository()
+                repository.parameters = SharedItemsRepository.Parameters(
+                    userEntity.userId,
+                    userEntity.token,
+                    userEntity.baseUrl,
+                    userEntity,
+                    roomToken
+                )
+
+                return SharedItemsViewModel(repository, initialType) as T
+            }
+
+            throw IllegalArgumentException("Unknown ViewModel class")
+        }
+    }
+
+    companion object {
+        private val TAG = SharedItemsViewModel::class.simpleName
+        const val BATCH_SIZE: Int = 28
+    }
+}

+ 25 - 0
app/src/main/res/drawable/ic_folder_multiple_image.xml

@@ -0,0 +1,25 @@
+<!--
+    @author Google LLC
+    Copyright (C) 2022 Google LLC
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="#000"
+        android:pathData="M7,15L11.5,9L15,13.5L17.5,10.5L21,15M22,4H14L12,2H6A2,2 0 0,0 4,4V16A2,2 0 0,0 6,18H22A2,2 0 0,0 24,16V6A2,2 0 0,0 22,4M2,6H0V11H0V20A2,2 0 0,0 2,22H20V20H2V6Z" />
+</vector>

+ 66 - 0
app/src/main/res/layout/activity_shared_items.xml

@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Talk application
+  ~
+  ~ @author Tim Krüger
+  ~ @author Andy Scherzinger
+  ~ Copyright (C) 2022 Tim Krüger <t@timkrueger.me>
+  ~ Copyright (C) 2022 Andy Scherzinger <info@andy-scherzinger.de>
+  ~
+  ~ 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 <http://www.gnu.org/licenses/>.
+  -->
+<androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@color/bg_default"
+    tools:context=".activities.SharedItemsActivity">
+
+    <com.google.android.material.appbar.MaterialToolbar
+        android:id="@+id/shared_items_toolbar"
+        android:layout_width="match_parent"
+        android:layout_height="?attr/actionBarSize"
+        android:background="@color/appbar"
+        android:theme="?attr/actionBarPopupTheme"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_scrollFlags="enterAlwaysCollapsed|noScroll"
+        app:navigationIconTint="@color/fontAppbar"
+        app:popupTheme="@style/appActionBarPopupMenu"
+        app:titleTextColor="@color/fontAppbar"
+        tools:title="@string/nc_app_product_name" />
+
+    <com.google.android.material.tabs.TabLayout
+        android:id="@+id/shared_items_tabs"
+        android:layout_width="match_parent"
+        android:layout_height="@dimen/min_size_clickable_area"
+        android:background="@color/appbar"
+        app:layout_constraintTop_toBottomOf="@id/shared_items_toolbar"
+        app:tabGravity="fill"
+        app:tabMode="fixed"
+        app:tabMaxWidth="0dp"
+        app:tabTextAppearance="@style/TextAppearanceTab" />
+
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/image_recycler"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/shared_items_tabs"
+        android:layout_marginTop="@dimen/double_margin_between_elements"
+        tools:listitem="@layout/shared_item_grid" />
+
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 23 - 1
app/src/main/res/layout/controller_conversation_info.xml

@@ -129,7 +129,7 @@
                 android:id="@+id/participants_list_category"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
-                android:layout_below="@+id/settings"
+                android:layout_below="@+id/category_shared_items"
                 android:visibility="gone"
                 apc:cardBackgroundColor="@color/bg_default"
                 apc:cardElevation="0dp"
@@ -213,6 +213,28 @@
                     tools:visibility="visible" />
 
             </LinearLayout>
+
+
+            <com.yarolegovich.mp.MaterialPreferenceCategory
+                android:id="@+id/category_shared_items"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_below="@id/settings"
+                android:animateLayoutChanges="true"
+                apc:cardBackgroundColor="@color/bg_default"
+                apc:cardElevation="0dp"
+                apc:mpc_title="@string/nc_shared_items">
+
+                <com.yarolegovich.mp.MaterialStandardPreference
+                    android:id="@+id/show_shared_items_action"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    apc:mp_icon="@drawable/ic_folder_multiple_image"
+                    apc:mp_icon_tint="@color/grey_600"
+                    apc:mp_title="@string/nc_shared_items_description" />
+
+            </com.yarolegovich.mp.MaterialPreferenceCategory>
+
         </RelativeLayout>
     </ScrollView>
 </RelativeLayout>

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

@@ -73,7 +73,7 @@
         android:singleLine="true"
         android:textAlignment="viewStart"
         android:textColor="@color/textColorMaxContrast"
-        android:textSize="12sp"
+        android:textSize="14sp"
         tools:text="3 minutes ago" />
 
     <TextView

+ 61 - 0
app/src/main/res/layout/shared_item_grid.xml

@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Talk application
+  ~
+  ~ @author Tim Krüger
+  ~ @author Marcel Hibbe
+  ~ Copyright (C) 2022 Tim Krüger <t@timkrueger.me>
+  ~ Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
+  ~
+  ~ 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 <http://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:fresco="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content">
+
+    <FrameLayout
+        android:id="@+id/preview_container"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_marginStart="2dp"
+        android:layout_marginEnd="2dp"
+        android:adjustViewBounds="true"
+        app:layout_alignSelf="flex_start"
+        app:layout_constraintDimensionRatio="1:1"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_flexGrow="1"
+        app:layout_wrapBefore="true">
+
+        <com.facebook.drawee.view.SimpleDraweeView
+            android:id="@+id/image"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:padding="4dp"
+            app:placeholderImageScaleType="fitCenter"
+            fresco:actualImageScaleType="centerCrop"
+            fresco:roundedCornerRadius="4dp" />
+
+        <ProgressBar
+            android:id="@+id/progress_bar"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center"
+            android:visibility="gone" />
+
+    </FrameLayout>
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 125 - 0
app/src/main/res/layout/shared_item_list.xml

@@ -0,0 +1,125 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Talk application
+  ~
+  ~ @author Tim Krüger
+  ~ Copyright (C) 2022 Tim Krüger <t@timkrueger.me>
+  ~
+  ~ 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 <http://www.gnu.org/licenses/>.
+  -->
+<RelativeLayout 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/file_item"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_marginStart="@dimen/standard_margin"
+    android:layout_marginEnd="@dimen/standard_margin"
+    android:layout_marginBottom="@dimen/standard_half_margin"
+    android:orientation="vertical">
+
+    <FrameLayout
+        android:id="@+id/preview_container"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="2dp"
+        android:layout_marginEnd="@dimen/standard_margin"
+        android:adjustViewBounds="true"
+        app:layout_alignSelf="flex_start"
+        app:layout_flexGrow="1"
+        app:layout_wrapBefore="true">
+
+        <com.facebook.drawee.view.SimpleDraweeView
+            xmlns:fresco="http://schemas.android.com/apk/res-auto"
+            android:id="@+id/file_image"
+            android:layout_width="@dimen/mediatab_file_icon_size"
+            android:layout_height="@dimen/mediatab_file_icon_size"
+            android:padding="4dp"
+            app:layout_constraintTop_toTopOf="parent"
+            app:placeholderImageScaleType="fitCenter"
+            fresco:actualImageScaleType="centerCrop"
+            fresco:roundedCornerRadius="4dp"
+            tools:src="@drawable/ic_call_black_24dp"/>
+
+        <ProgressBar
+            android:id="@+id/progress_bar"
+            android:layout_width="20dp"
+            android:layout_height="20dp"
+            android:layout_gravity="center"
+            android:visibility="gone" />
+    </FrameLayout>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical"
+        android:layout_centerVertical="true"
+        android:layout_toEndOf="@id/preview_container">
+
+        <androidx.emoji.widget.EmojiTextView
+            android:id="@+id/file_name"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:ellipsize="end"
+            android:lines="1"
+            android:textAlignment="viewStart"
+            android:textAppearance="@style/ListItem"
+            android:textSize="@dimen/two_line_primary_text_size"
+            tools:text="Filename.md" />
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal">
+
+            <TextView
+                android:id="@+id/file_size"
+                android:layout_width="wrap_content"
+                android:layout_height="match_parent"
+                android:textColor="@color/textColorMaxContrast"
+                android:textSize="14sp"
+                tools:text="11 KB" />
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginStart="@dimen/standard_quarter_margin"
+                android:textColor="@color/textColorMaxContrast"
+                android:textSize="14sp"
+                android:text="|"
+                tools:ignore="HardcodedText" />
+
+            <TextView
+                android:id="@+id/file_date"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginStart="@dimen/standard_quarter_margin"
+                android:textColor="@color/textColorMaxContrast"
+                android:textSize="14sp"
+                tools:text="04-05-2022 21:16"/>
+
+        </LinearLayout>
+    </LinearLayout>
+</RelativeLayout>
+
+
+
+
+
+
+
+
+
+
+
+

+ 6 - 1
app/src/main/res/menu/menu_conversation.xml

@@ -37,8 +37,13 @@
 
     <item
         android:id="@+id/conversation_info"
-        android:icon="@drawable/ic_info_white_24dp"
         android:orderInCategory="1"
         android:title="@string/nc_conversation_menu_conversation_info"
         app:showAsAction="never" />
+
+    <item
+        android:id="@+id/shared_items"
+        android:orderInCategory="1"
+        android:title="Shared Items"
+        app:showAsAction="never" />
 </menu>

+ 2 - 0
app/src/main/res/values/dimens.xml

@@ -70,4 +70,6 @@
     <dimen name="activity_row_layout_height">48dp</dimen>
     <dimen name="reaction_bottom_sheet_layout_size">40dp</dimen>
     <dimen name="standard_eighth_margin">2dp</dimen>
+
+    <dimen name="mediatab_file_icon_size">50dp</dimen>
 </resources>

+ 16 - 13
app/src/main/res/values/strings.xml

@@ -89,7 +89,6 @@
     <string name="nc_settings_server_eol">The server version is too old and not supported by this version of the Android app</string>
     <string name="nc_settings_server_almost_eol">The server version is very old and will not be supported in the next release!</string>
     <string name="nc_settings_warning">Warning</string>
-    <string name="nc_add">Add</string>
     <string name="nc_settings_wrong_account">Only current account can be reauthorized</string>
     <string name="nc_settings_no_talk_installed">Talk app is not installed on the server you tried to authenticate against</string>
     <string name="nc_settings_account_updated">Your already existing account was updated, instead of adding a new one</string>
@@ -118,7 +117,6 @@
     <string name="nc_settings_screen_lock_desc">Lock %1$s with Android screen lock or supported biometric method</string>
     <string name="nc_settings_screen_lock_key" translatable="false">screen_lock</string>
     <string name="nc_settings_screen_lock_timeout_title">Screen lock inactivity timeout</string>
-    <string name="nc_none">None</string>
     <string name="nc_settings_screen_lock_timeout_key" translatable="false">screen_lock_timeout</string>
     <string name="nc_settings_screen_security_title">Screen security</string>
     <string name="nc_settings_screen_security_desc">Prevents screenshots in the recent list and inside the app</string>
@@ -205,8 +203,6 @@
     <string name="nc_call_incoming">INCOMING</string>
     <string name="nc_call_ringing">RINGING</string>
     <string name="nc_connecting_call">Connecting…</string>
-    <string name="nc_calling">Calling…</string>
-    <string name="nc_incoming_call">Incoming call from</string>
     <string name="nc_nick_guest">Guest</string>
     <string name="nc_public_call">New public conversation</string>
     <string name="nc_public_call_explanation">Public conversations let you invite people from outside through a specially crafted link.</string>
@@ -338,7 +334,6 @@
     <!-- Empty states -->
     <string name="nc_conversations_empty">Join a conversation or start a new one</string>
     <string name="nc_conversations_empty_details">Say hi to your friends and colleagues!</string>
-    <string name="nc_hello">Hello</string>
 
     <!-- Other -->
     <string name="nc_limit_hit">%s characters limit has been hit</string>
@@ -379,14 +374,6 @@
     <string name="nc_lobby_start_soon">The meeting will start soon</string>
     <string name="nc_manual">Not set</string>
 
-    <!-- Errors -->
-    <string name="nc_no_connection_error">No connection</string>
-    <string name="nc_bad_response_error">Bad response</string>
-    <string name="nc_timeout_error">Timeout</string>
-    <string name="nc_empty_response_error">Empty response</string>
-    <string name="nc_not_defined_error">Unknown error</string>
-    <string name="nc_unauthorized_error">Unauthorized</string>
-
     <string name="nc_allow_guests">Allow guests</string>
     <string name="nc_last_moderator_title">Could not leave conversation</string>
     <string name="nc_last_moderator">You need to promote a new moderator before you can leave %1$s.</string>
@@ -430,6 +417,10 @@
     <string name="nc_share_contact">Share contact</string>
     <string name="nc_share_contact_permission">Permission to read contacts is required</string>
 
+    <!-- shared items -->
+    <string name="nc_shared_items">Shared items</string>
+    <string name="nc_shared_items_description">Images, files, voice messages…</string>
+
     <!-- voice messages -->
     <string name="nc_voice_message_filename">Talk recording from %1$s (%2$s)</string>
     <string name="nc_voice_message_hold_to_record_info">Hold to record, release to send.</string>
@@ -498,6 +489,7 @@
     <string name="nc_dialog_invalid_password">Invalid password</string>
     <string name="nc_dialog_reauth_or_delete">Do you want to reauthorize or delete this account?</string>
 
+    <!-- Take photo -->
     <string name="take_photo">Take a photo</string>
     <string name="take_photo_switch_camera">Switch camera</string>
     <string name="take_photo_retake_photo">Re-take photo</string>
@@ -507,12 +499,23 @@
     <string name="take_photo_send">Send</string>
     <string name="take_photo_error_deleting_picture">Error taking picture</string>
     <string name="take_photo_permission">Taking a photo is not possible without permissions</string>
+
+    <!-- Audio selection -->
     <string name="audio_output_bluetooth">Bluetooth</string>
     <string name="audio_output_speaker">Speaker</string>
     <string name="audio_output_phone">Phone</string>
     <string name="audio_output_dialog_headline">Audio output</string>
     <string name="audio_output_wired_headset">Wired headset</string>
 
+    <!-- Shared items -->
+    <string name="shared_items_media">Media</string>
+    <string name="shared_items_file">File</string>
+    <string name="shared_items_audio">Audio</string>
+    <string name="shared_items_voice">Voice</string>
+    <string name="shared_items_other">Other</string>
+
+    <string name="title_attachments">Attachments</string>
+
     <string name="reactions_tab_all">All</string>
 
 </resources>

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

@@ -257,4 +257,10 @@
         <item name="android:textStyle">bold</item>
     </style>
 
+    <style name="TextAppearanceTab" parent="TextAppearance.Design.Tab">
+        <item name="textAllCaps">false</item>
+        <item name="android:textAllCaps">false</item>
+    </style>
+
+
 </resources>

+ 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 156 warnings</span>
+      <span class="mdl-layout-title">Lint Report: 1 error and 150 warnings</span>