diff --git a/app/build.gradle b/app/build.gradle index 4fa05db26..b8bbb4df1 100644 --- a/app/build.gradle +++ b/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") { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index abbf6b73e..9e52ddd26 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -167,6 +167,10 @@ android:theme="@style/TakePhotoTheme" android:windowSoftInputMode="stateHidden" /> + + diff --git a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt index 06be3d52d..c3ca78e90 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ b/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()) diff --git a/app/src/main/java/com/nextcloud/talk/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/SharedItemsActivity.kt new file mode 100644 index 000000000..76fb75600 --- /dev/null +++ b/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(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) { + + 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 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/adapters/SharedItemsGridAdapter.kt b/app/src/main/java/com/nextcloud/talk/adapters/SharedItemsGridAdapter.kt new file mode 100644 index 000000000..20f723a9c --- /dev/null +++ b/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() { + + companion object { + private val TAG = SharedItemsGridAdapter::class.simpleName + } + + class ViewHolder(val binding: SharedItemGridBinding, itemView: View) : RecyclerView.ViewHolder(itemView) + + var authHeader: Map = emptyMap() + var items: List = 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 = object : BaseControllerListener() { + 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 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/adapters/SharedItemsListAdapter.kt b/app/src/main/java/com/nextcloud/talk/adapters/SharedItemsListAdapter.kt new file mode 100644 index 000000000..4caf6382e --- /dev/null +++ b/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() { + + 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 = emptyMap() + var items: List = 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 = object : BaseControllerListener() { + 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 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/MagicPreviewMessageViewHolder.java b/app/src/main/java/com/nextcloud/talk/adapters/messages/MagicPreviewMessageViewHolder.java index 36d05ed3f..6a37316ea 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/MagicPreviewMessageViewHolder.java +++ b/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> 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> 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() { @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() { - @Override - public void onSubscribe(@NonNull Disposable d) { - // unused atm - } + .subscribe(new SingleObserver() { + @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 browserFileList = (List) 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 browserFileList = (List) 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(); } diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApi.java b/app/src/main/java/com/nextcloud/talk/api/NcApi.java index 8f177a566..857eabecc 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/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> getSharedItems(@Header("Authorization") String authorization, @Url String url, + @Query("objectType") String objectType, + @Nullable @Query("lastKnownMessageId") Integer lastKnownMessageId, + @Nullable @Query("limit") Integer limit); + + @GET + Observable> getSharedItemsOverview(@Header("Authorization") String authorization, + @Url String url, + @Nullable @Query("limit") Integer limit); + + @GET Observable getMentionAutocompleteSuggestions(@Header("Authorization") String authorization, @Url String url, @Query("search") String query, diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt index fca1b0f0c..b34dd9094 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt +++ b/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(browserType)) bundle.putParcelable(BundleKeys.KEY_USER_ENTITY, Parcels.wrap(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): List { 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) diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ConversationInfoController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ConversationInfoController.kt index cc35573f6..b60ed878a 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ConversationInfoController.kt +++ b/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) diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.java b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.java index 3057a51ca..e3ac0445b 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.java +++ b/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) { diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOCS.java b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOCS.java new file mode 100644 index 000000000..6f0191037 --- /dev/null +++ b/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 + * + * 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 . + */ +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 data; + + public HashMap getData() { + return this.data; + } + + public void setData(HashMap 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() + ")"; + } +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOverall.java b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOverall.java new file mode 100644 index 000000000..ce97b53e6 --- /dev/null +++ b/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 + * + * 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 . + */ + +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() + ")"; + } +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOverviewOCS.java b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOverviewOCS.java new file mode 100644 index 000000000..c6ae81b62 --- /dev/null +++ b/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 + * + * 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 . + */ +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> data; + + public HashMap> getData() { + return this.data; + } + + public void setData(HashMap> 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() + ")"; + } +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOverviewOverall.java b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatShareOverviewOverall.java new file mode 100644 index 000000000..086a765d7 --- /dev/null +++ b/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 + * + * 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 . + */ + +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() + ")"; + } +} diff --git a/app/src/main/java/com/nextcloud/talk/repositories/SharedItem.kt b/app/src/main/java/com/nextcloud/talk/repositories/SharedItem.kt new file mode 100644 index 000000000..1f2ba3b3a --- /dev/null +++ b/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, +) diff --git a/app/src/main/java/com/nextcloud/talk/repositories/SharedItemType.kt b/app/src/main/java/com/nextcloud/talk/repositories/SharedItemType.kt new file mode 100644 index 000000000..e263ae8cc --- /dev/null +++ b/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)) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/repositories/SharedItemsRepository.kt b/app/src/main/java/com/nextcloud/talk/repositories/SharedItemsRepository.kt new file mode 100644 index 000000000..f34cd5497 --- /dev/null +++ b/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>? { + return media(type, null) + } + + fun media(type: SharedItemType, lastKnownMessageId: Int?): Observable>? { + 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>? { + val credentials = ApiUtils.getCredentials(parameters!!.userName, parameters!!.userToken) + + return ncApi.getSharedItemsOverview( + credentials, + ApiUtils.getUrlForChatSharedItemsOverview(1, parameters!!.baseUrl, parameters!!.roomToken), + 1 + ) + } + + fun authHeader(): Map { + 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 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/repositories/SharedMediaItems.kt b/app/src/main/java/com/nextcloud/talk/repositories/SharedMediaItems.kt new file mode 100644 index 000000000..91f5fcf10 --- /dev/null +++ b/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, + var lastSeenId: Int?, + var moreItemsExisting: Boolean, + val authHeader: Map +) diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java index 9e3ea3c43..2199191f6 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java +++ b/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"; diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt new file mode 100644 index 000000000..b0334a231 --- /dev/null +++ b/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 + * + * 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 . + */ + +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" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/viewmodels/SharedItemsViewModel.kt b/app/src/main/java/com/nextcloud/talk/viewmodels/SharedItemsViewModel.kt new file mode 100644 index 000000000..6a935a438 --- /dev/null +++ b/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> by lazy { + MutableLiveData>().also { + availableTypes() + } + } + + private val _sharedItems: MutableLiveData by lazy { + MutableLiveData().also { + loadItems(initialType) + } + } + + val sharedItemType: LiveData> + get() = _sharedItemType + + val sharedItems: LiveData + 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> { + return object : Observer> { + + var chatLastGiven: Int? = null + val items = mutableMapOf() + + override fun onSubscribe(d: Disposable) = Unit + + override fun onNext(response: Response) { + + 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, + chatLastGiven, + moreItemsExisting, + repository.authHeader() + ) + } + } + } + } + + private fun availableTypes() { + repository.availableTypes()?.subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe(object : Observer> { + + val types = mutableSetOf() + + override fun onSubscribe(d: Disposable) = Unit + + override fun onNext(response: Response) { + 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 create(modelClass: Class): 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 + } +} diff --git a/app/src/main/res/drawable/ic_folder_multiple_image.xml b/app/src/main/res/drawable/ic_folder_multiple_image.xml new file mode 100644 index 000000000..5cbec6e30 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder_multiple_image.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/layout/activity_shared_items.xml b/app/src/main/res/layout/activity_shared_items.xml new file mode 100644 index 000000000..ed76e8e2c --- /dev/null +++ b/app/src/main/res/layout/activity_shared_items.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/controller_conversation_info.xml b/app/src/main/res/layout/controller_conversation_info.xml index 71d6ef568..269449006 100644 --- a/app/src/main/res/layout/controller_conversation_info.xml +++ b/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" /> + + + + + + + + diff --git a/app/src/main/res/layout/rv_item_browser_file.xml b/app/src/main/res/layout/rv_item_browser_file.xml index 769049bee..dfc50383f 100644 --- a/app/src/main/res/layout/rv_item_browser_file.xml +++ b/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" /> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/shared_item_list.xml b/app/src/main/res/layout/shared_item_list.xml new file mode 100644 index 000000000..fe7313e75 --- /dev/null +++ b/app/src/main/res/layout/shared_item_list.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_conversation.xml b/app/src/main/res/menu/menu_conversation.xml index f9d74ac28..b96a91af2 100644 --- a/app/src/main/res/menu/menu_conversation.xml +++ b/app/src/main/res/menu/menu_conversation.xml @@ -37,8 +37,13 @@ + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index fd5f467b9..e4dc99eb7 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -70,4 +70,6 @@ 48dp 40dp 2dp + + 50dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 493855699..ba1df70e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,7 +89,6 @@ The server version is too old and not supported by this version of the Android app The server version is very old and will not be supported in the next release! Warning - Add Only current account can be reauthorized Talk app is not installed on the server you tried to authenticate against Your already existing account was updated, instead of adding a new one @@ -118,7 +117,6 @@ Lock %1$s with Android screen lock or supported biometric method screen_lock Screen lock inactivity timeout - None screen_lock_timeout Screen security Prevents screenshots in the recent list and inside the app @@ -205,8 +203,6 @@ INCOMING RINGING Connecting… - Calling… - Incoming call from Guest New public conversation Public conversations let you invite people from outside through a specially crafted link. @@ -338,7 +334,6 @@ Join a conversation or start a new one Say hi to your friends and colleagues! - Hello %s characters limit has been hit @@ -379,14 +374,6 @@ The meeting will start soon Not set - - No connection - Bad response - Timeout - Empty response - Unknown error - Unauthorized - Allow guests Could not leave conversation You need to promote a new moderator before you can leave %1$s. @@ -430,6 +417,10 @@ Share contact Permission to read contacts is required + + Shared items + Images, files, voice messages… + Talk recording from %1$s (%2$s) Hold to record, release to send. @@ -498,6 +489,7 @@ Invalid password Do you want to reauthorize or delete this account? + Take a photo Switch camera Re-take photo @@ -507,12 +499,23 @@ Send Error taking picture Taking a photo is not possible without permissions + + Bluetooth Speaker Phone Audio output Wired headset + + Media + File + Audio + Voice + Other + + Attachments + All diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index a884c7fa2..547896de6 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -257,4 +257,10 @@ bold + + + diff --git a/scripts/analysis/lint-results.txt b/scripts/analysis/lint-results.txt index bf6bfba1f..5a424f167 100644 --- a/scripts/analysis/lint-results.txt +++ b/scripts/analysis/lint-results.txt @@ -1,2 +1,2 @@ DO NOT TOUCH; GENERATED BY DRONE - Lint Report: 1 error and 156 warnings + Lint Report: 1 error and 150 warnings