Skip to content
Snippets Groups Projects
ConversationFragment.kt 56.1 KiB
Newer Older
 *  Copyright (C) 2004-2023 Savoir-faire Linux Inc.
Adrien Béraud's avatar
Adrien Béraud committed
 *  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
Adrien Béraud's avatar
Adrien Béraud committed
 *  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
Adrien Béraud's avatar
Adrien Béraud committed
 *  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
Adrien Béraud's avatar
Adrien Béraud committed
 *  along with this program. If not, see <https://www.gnu.org/licenses/>.
 */
package cx.ring.fragments

import android.Manifest
import android.animation.LayoutTransition
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.Activity
import android.app.ActivityOptions
import android.content.*
import android.content.pm.PackageManager
import android.graphics.Typeface
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.IBinder
import android.provider.MediaStore
import android.text.Editable
import android.text.TextUtils
import android.text.TextWatcher
import android.util.Log
import android.view.*
import android.view.inputmethod.EditorInfo
import android.widget.*
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
Adrien Béraud's avatar
Adrien Béraud committed
import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.view.menu.MenuPopupHelper
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView
import androidx.core.view.*
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import cx.ring.R
import cx.ring.adapters.ConversationAdapter
import cx.ring.client.CallActivity
import cx.ring.client.ContactDetailsActivity
import cx.ring.client.ConversationActivity
import cx.ring.client.HomeActivity
import cx.ring.databinding.FragConversationBinding
import cx.ring.mvp.BaseSupportFragment
import cx.ring.service.DRingService
import cx.ring.service.LocationSharingService
import cx.ring.services.NotificationServiceImpl
import cx.ring.services.SharedPreferencesServiceImpl.Companion.getConversationColor
import cx.ring.services.SharedPreferencesServiceImpl.Companion.getConversationPreferences
import cx.ring.services.SharedPreferencesServiceImpl.Companion.getConversationSymbol
import cx.ring.utils.*
import cx.ring.views.AvatarDrawable
import cx.ring.views.AvatarFactory
import dagger.hilt.android.AndroidEntryPoint
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
Adrien Béraud's avatar
Adrien Béraud committed
import io.reactivex.rxjava3.disposables.Disposable
import net.jami.call.CallPresenter
import net.jami.conversation.ConversationPresenter
import net.jami.conversation.ConversationPresenter.IncomingCallAction
import net.jami.conversation.ConversationView
import net.jami.daemon.JamiService
import net.jami.model.*
import net.jami.model.Account.ComposingStatus
import net.jami.services.NotificationService
import net.jami.smartlist.ConversationItemViewModel
import java.io.File
import java.util.*

@AndroidEntryPoint
class ConversationFragment : BaseSupportFragment<ConversationPresenter, ConversationView>(),
Pierre Nicolas's avatar
Pierre Nicolas committed
    ConversationView, SearchView.OnQueryTextListener {
    private var locationServiceConnection: ServiceConnection? = null
    private var binding: FragConversationBinding? = null
    private var mAudioCallBtn: MenuItem? = null
    private var mVideoCallBtn: MenuItem? = null
    private var currentBottomView: View? = null
    private var mAdapter: ConversationAdapter? = null
    private var mSearchAdapter: ConversationAdapter? = null
    private var marginPx = 0
    private var marginPxTotal = 0
    private val animation = ValueAnimator()
    private var mPreferences: SharedPreferences? = null
    private var mCurrentPhoto: File? = null
    private var mCurrentFileAbsolutePath: String? = null
    private val mCompositeDisposable = CompositeDisposable()
    private var mSelectedPosition = 0
    private var replyingTo: Interaction? = null
    private var mIsBubble = false
    private var mConversationAvatar: AvatarDrawable? = null
    private val mParticipantAvatars: MutableMap<String, AvatarDrawable> = HashMap()
    private val mSmallParticipantAvatars: MutableMap<String, AvatarDrawable> = HashMap()
    private var mapWidth = 0
    private var mapHeight = 0
    private var loading = true
    private var animating = 0
    private val pickMultipleMedia =
        registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(8)) { uris ->
            for (uri in uris) {
                startFileSend(AndroidFileUtils.getCacheFile(requireContext(), uri)
                    .observeOn(DeviceUtils.uiScheduler)
                    .flatMapCompletable { file: File -> sendFile(file) })
            }
        }

    fun getConversationAvatar(uri: String): AvatarDrawable? = mParticipantAvatars[uri]

    fun getSmallConversationAvatar(uri: String): AvatarDrawable? {
        synchronized(mSmallParticipantAvatars) { return mSmallParticipantAvatars[uri] }
    }

    override fun refreshView(conversation: List<Interaction>) {
        if (binding != null) binding!!.pbLoading.visibility = View.GONE
        mAdapter?.let { adapter ->
            adapter.updateDataset(conversation)
            loading = false
        }
        requireActivity().invalidateOptionsMenu()
    }

    override fun scrollToEnd() {
        mAdapter?.let { adapter ->
            if (adapter.itemCount > 0)
                binding!!.histList.scrollToPosition(adapter.itemCount - 1)
    override fun scrollToMessage(messageId: String, highlight: Boolean) {
        val histList = binding?.histList ?: return
        mAdapter?.let { adapter ->
            val position = adapter.getMessagePosition(messageId)
            if(position == -1)
                return
            binding!!.histList.scrollToPosition(position)

            if(highlight) {
                histList.doOnNextLayout {
                    histList.layoutManager?.findViewByPosition(position)
                        ?.setBackgroundColor(resources.getColor(R.color.surface))
                }
            }
        }
    }

    private fun updateListPadding() {
        /* val binding = binding ?: return
        val bottomView = currentBottomView ?: return
        val bottomViewHeight = bottomView.height
        if (bottomViewHeight != 0) {
            val padding = bottomViewHeight + marginPxTotal
            val params = binding.mapCard.layoutParams as RelativeLayout.LayoutParams
            params.bottomMargin = padding
            binding.mapCard.layoutParams = params
    override fun displayErrorToast(error: Error) {
        val errorString: String = when (error) {
            Error.NO_INPUT -> getString(R.string.call_error_no_camera_no_microphone)
            Error.INVALID_FILE -> getString(R.string.invalid_file)
            Error.NOT_ABLE_TO_WRITE_FILE -> getString(R.string.not_able_to_write_file)
            Error.NO_SPACE_LEFT -> getString(R.string.no_space_left_on_device)
            else -> getString(R.string.generic_error)
        }
        Toast.makeText(requireContext(), errorString, Toast.LENGTH_LONG).show()
    }

    private val onBackPressedCallback: OnBackPressedCallback =
        object : OnBackPressedCallback(false) {
            override fun handleOnBackPressed() {
                val count = childFragmentManager.backStackEntryCount
                if (count > 0) {
                    childFragmentManager.popBackStack()
                    if (count == 1)
                        isEnabled = false
                }
    override fun onAttach(context: Context) {
        super.onAttach(context)
        activity?.onBackPressedDispatcher?.addCallback(this, onBackPressedCallback)
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        val res = resources
        marginPx = res.getDimensionPixelSize(R.dimen.conversation_message_input_margin)
        mapWidth = res.getDimensionPixelSize(R.dimen.location_sharing_minmap_width)
        mapHeight = res.getDimensionPixelSize(R.dimen.location_sharing_minmap_height)
        marginPxTotal = marginPx

        requireActivity().addMenuProvider(menuProvider, viewLifecycleOwner)

        return FragConversationBinding.inflate(inflater, container, false).let { binding ->
            this@ConversationFragment.binding = binding
            animation.duration = 150
            animation.addUpdateListener { valueAnimator: ValueAnimator -> binding.histList.updatePadding(bottom = valueAnimator.animatedValue as Int) }
            (activity as AppCompatActivity?)!!.setSupportActionBar(binding.toolbar)

            val layoutToAnimate = binding.relativeLayout
            if (Build.VERSION.SDK_INT >= 30) {
                ViewCompat.setWindowInsetsAnimationCallback(
                    layoutToAnimate,
                    object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
                        override fun onPrepare(animation: WindowInsetsAnimationCompat) {
                            animating++
                        }
                        override fun onProgress(insets: WindowInsetsCompat, runningAnimations: List<WindowInsetsAnimationCompat>): WindowInsetsCompat {
                            layoutToAnimate.updatePadding(bottom = insets.systemWindowInsetBottom)
                            return insets
                        }

                        override fun onEnd(animation: WindowInsetsAnimationCompat) {
                            animating--
                        }
                    })
            }
            ViewCompat.setOnApplyWindowInsetsListener(layoutToAnimate) { _, insets: WindowInsetsCompat ->
                if (animating == 0) {
                    layoutToAnimate.updatePadding(
                        top = insets.systemWindowInsetTop,
                        bottom = insets.systemWindowInsetBottom
                    )
                WindowInsetsCompat.CONSUMED

            // Content may be both text and non-text (HTML, images, videos, audio files, etc).
            ViewCompat.setOnReceiveContentListener(
                binding.msgInputTxt,
                SUPPORTED_MIME_TYPES
            ) { _, payload ->
                // Split the incoming content into two groups: content URIs and everything else.
                // This way we can implement custom handling for URIs and delegate the rest.
                val split = payload.partition { item -> item.uri != null }
                val uriContent = split.first
                val remaining = split.second

                // Handles content URIs.
                if (uriContent != null) {
                    val clip = uriContent.clip
                    for (i in 0 until clip.itemCount) {
                        val uri = clip.getItemAt(i).uri
                        startFileSend(AndroidFileUtils.getCacheFile(requireContext(), uri)
                            .flatMapCompletable { sendFile(it) })
                    }
                // Delegates the processing for text and everything else to the platform.
                remaining
            binding.msgInputTxt.setOnEditorActionListener { _, actionId: Int, _ -> actionSendMsgText(actionId) }
            binding.msgInputTxt.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus: Boolean ->
                if (hasFocus) {
                    (childFragmentManager.findFragmentById(R.id.mapLayout) as LocationSharingFragment?)?.hideControls()
                }
            }
            binding.msgInputTxt.addTextChangedListener(object : TextWatcher {
                override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
                override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
                override fun afterTextChanged(s: Editable) {
                    val message = s.toString()
                    val hasMessage = !TextUtils.isEmpty(message)
                    presenter.onComposingChanged(hasMessage)
                    if (hasMessage) {
                        binding.msgSend.visibility = View.VISIBLE
                        binding.emojiSend.visibility = View.GONE
                    } else {
                        binding.msgSend.visibility = View.GONE
                        binding.emojiSend.visibility = View.VISIBLE
                    }
                    mPreferences?.let { preferences ->
                        if (hasMessage)
                            preferences.edit().putString(KEY_PREFERENCE_PENDING_MESSAGE, message).apply()
                        else
                            preferences.edit().remove(KEY_PREFERENCE_PENDING_MESSAGE).apply()
                    }
                }
            })
            binding.replyCloseBtn.setOnClickListener {
                clearReply()
            }
            binding.fabLatest.setOnClickListener {
                scrollToEnd()
            }
            binding.toolbar.setNavigationIcon(androidx.appcompat.R.drawable.abc_ic_ab_back_material)
            binding.toolbar.setNavigationOnClickListener {
                activity?.onBackPressedDispatcher?.onBackPressed()
            binding.ongoingCallPane.setOnClickListener { presenter.clickOnGoingPane() }
            binding.ringingCallPane.setOnClickListener {
                presenter.clickRingingPane(IncomingCallAction.VIEW_ONLY)
            }
            binding.acceptAudioCallButton.setOnClickListener {
                presenter.clickRingingPane(IncomingCallAction.ACCEPT_AUDIO)
            }
            binding.acceptVideoCallButton.setOnClickListener {
                presenter.clickRingingPane(IncomingCallAction.ACCEPT_VIDEO)
            }
            binding.msgSend.setOnClickListener { sendMessageText() }
            binding.emojiSend.setOnClickListener { sendEmoji() }
            binding.btnMenu.setOnClickListener { expandMenu(it) }
            binding.btnTakePicture.setOnClickListener { takePicture() }
            binding.unknownContactButton.setOnClickListener { presenter.onAddContact() }
            binding.btnBlock.setOnClickListener { presenter.onBlockIncomingContactRequest() }
            binding.btnRefuse.setOnClickListener { presenter.onRefuseIncomingContactRequest() }
            binding.btnAccept.setOnClickListener { presenter.onAcceptIncomingContactRequest() }
            binding.root
        }
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding?.let { binding ->
            mPreferences?.let { preferences ->
                val pendingMessage = preferences.getString(KEY_PREFERENCE_PENDING_MESSAGE, null)
                if (!pendingMessage.isNullOrEmpty()) {
                    binding.msgInputTxt.setText(pendingMessage)
                    binding.msgSend.visibility = View.VISIBLE
                    binding.emojiSend.visibility = View.GONE
                }
            }
            binding.msgInputTxt.addOnLayoutChangeListener { _, _, _, _, _, oldLeft, oldTop, oldRight, oldBottom ->
                if (oldBottom == 0 && oldTop == 0) {
                    updateListPadding()
                } else {
                    if (animation.isStarted) animation.cancel()
                    animation.setIntValues(
                        binding.histList.paddingBottom,
                        (currentBottomView?.height ?: 0) + marginPxTotal
                    )
                    animation.start()
                }
            }
            binding.histList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
                // The minimum amount of items to have below current scroll position
                // before loading more.
                val visibleLoadThreshold = 3
                // The amount of items to have below the current scroll position to display
                // the scroll to latest button.
                val visibleLatestThreshold = 8
                override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {}
                override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                    val layoutManager = recyclerView.layoutManager as LinearLayoutManager? ?: return
                    if (!loading && binding.histList.adapter != mSearchAdapter
                        && layoutManager.findFirstVisibleItemPosition() < visibleLoadThreshold
                    ) {
                        loading = true
                        presenter.loadMore()
                    }

                    // Recyclerview is composed of items which are sometimes invisible (to preserve
                    // the model and interaction relationship).
                    // Because of bug #1251, we use findLastCompletelyVisibleItemPosition because
                    // findLastVisibleItemPosition ignores invisible items (don't understand why).
                    val lastVisibleItemPosition =
                        layoutManager.findLastCompletelyVisibleItemPosition()
                    if (layoutManager.itemCount - lastVisibleItemPosition > visibleLatestThreshold)
                        binding.fabLatest.show()
                    else binding.fabLatest.hide()
            val animator = binding.histList.itemAnimator as DefaultItemAnimator?
            animator?.supportsChangeAnimations = false
            binding.histList.adapter = mAdapter
        }
    }

Adrien Béraud's avatar
Adrien Béraud committed
    override fun setConversationColor(@ColorInt color: Int) {
        mAdapter?.setPrimaryColor(getConversationColor(requireContext(), color))
    }

    override fun setConversationSymbol(symbol: CharSequence) {
        binding?.emojiSend?.text = getConversationSymbol(requireContext(), symbol)
    }

    override fun onDestroyView() {
        animation.removeAllUpdateListeners()
        binding?.histList?.adapter = null
        mCompositeDisposable.clear()
        locationServiceConnection?.let {
            try {
                requireContext().unbindService(it)
            } catch (e: Exception) {
                Log.w(TAG, "Error unbinding service: " + e.message)
            }
        }
        mAdapter = null
        super.onDestroyView()
        binding = null
    }

    fun updateAdapterItem() {
        if (mSelectedPosition != -1) {
Adrien Béraud's avatar
Adrien Béraud committed
            mAdapter?.notifyItemChanged(mSelectedPosition)
            mSelectedPosition = -1
        }
    }

    private fun clearReply() {
        if (replyingTo != null) {
            replyingTo = null
            binding?.apply {
                replyGroup.isVisible = false
            }
        }
    }

    fun sendMessageText() {
        val message = binding!!.msgInputTxt.text.toString()
        presenter.sendTextMessage(message, replyingTo)
        clearMsgEdit()
    }

    fun sendEmoji() {
        presenter.sendTextMessage(binding!!.emojiSend.text.toString(), replyingTo)
        clearReply()
    }

    @SuppressLint("RestrictedApi")
    fun expandMenu(v: View) {
        val context = requireContext()
        val popup = PopupMenu(context, v)
        popup.inflate(R.menu.conversation_share_actions)
        popup.setOnMenuItemClickListener { item: MenuItem ->
            when (item.itemId) {
                R.id.conv_send_audio -> sendAudioMessage()
                R.id.conv_send_video -> sendVideoMessage()
                R.id.conv_send_file -> openFilePicker()
                R.id.conv_select_media -> openGallery()
                R.id.conv_share_location -> shareLocation()
                R.id.chat_plugins -> presenter.showPluginListHandlers()
            }
            false
        }
        popup.menu.findItem(R.id.chat_plugins).isVisible = JamiService.getPluginsEnabled() && !JamiService.getChatHandlers().isEmpty()
        val menuHelper = MenuPopupHelper(context, (popup.menu as MenuBuilder), v)
        menuHelper.setForceShowIcon(true)
        menuHelper.show()
    }

    override fun showPluginListHandlers(accountId: String, contactId: String) {
        Log.w(TAG, "show Plugin Chat Handlers List")
        val fragment = PluginHandlersListFragment.newInstance(accountId, contactId)
        childFragmentManager.beginTransaction()
            .add(R.id.pluginListHandlers, fragment, PluginHandlersListFragment.TAG)
            .commit()
        binding?.let { binding ->
            val params = binding.mapCard.layoutParams as RelativeLayout.LayoutParams
            if (params.width != ViewGroup.LayoutParams.MATCH_PARENT) {
                params.width = ViewGroup.LayoutParams.MATCH_PARENT
                params.height = ViewGroup.LayoutParams.MATCH_PARENT
                binding.mapCard.layoutParams = params
            }
            binding.mapCard.visibility = View.VISIBLE
        }
    }

    fun hidePluginListHandlers() {
        if (binding!!.mapCard.visibility != View.GONE) {
            binding!!.mapCard.visibility = View.GONE
            val fragmentManager = childFragmentManager
            val fragment = fragmentManager.findFragmentById(R.id.pluginListHandlers)
            if (fragment != null) {
                fragmentManager.beginTransaction()
                    .remove(fragment)
                    .commit()
            }
        }
        val params = binding!!.mapCard.layoutParams as RelativeLayout.LayoutParams
        if (params.width != mapWidth) {
            params.width = mapWidth
            params.height = mapHeight
            binding!!.mapCard.layoutParams = params
        }
    }

    private fun shareLocation() {
        presenter.shareLocation()
    }

    fun closeLocationSharing(isSharing: Boolean) {
        val params = binding!!.mapCard.layoutParams as RelativeLayout.LayoutParams
        if (params.width != mapWidth) {
            params.width = mapWidth
            params.height = mapHeight
            binding!!.mapCard.layoutParams = params
        }
        if (!isSharing) hideMap()
    }

    fun openLocationSharing() {
        binding!!.conversationLayout.layoutTransition.enableTransitionType(LayoutTransition.CHANGING)
        val params = binding!!.mapCard.layoutParams as RelativeLayout.LayoutParams
        if (params.width != ViewGroup.LayoutParams.MATCH_PARENT) {
            params.width = ViewGroup.LayoutParams.MATCH_PARENT
            params.height = ViewGroup.LayoutParams.MATCH_PARENT
            binding!!.mapCard.layoutParams = params
        }
    }

    override fun startShareLocation(accountId: String, conversationId: String) {
        showMap(accountId, conversationId, true)
    }

    /**
     * Used to update with the past adapter position when a long click was registered
     */
    fun updatePosition(position: Int) {
        mSelectedPosition = position
    }

    override fun showMap(accountId: String, contactId: String, open: Boolean) {
        if (binding!!.mapCard.visibility == View.GONE) {
            Log.w(TAG, "showMap $accountId $contactId")
            val fragmentManager = childFragmentManager
            val fragment = LocationSharingFragment.newInstance(accountId, contactId, open)
            fragmentManager.beginTransaction()
                .add(R.id.mapLayout, fragment, "map")
                .commit()
            binding!!.mapCard.visibility = View.VISIBLE
        }
        if (open) {
            val fragment = childFragmentManager.findFragmentById(R.id.mapLayout)
            if (fragment != null) {
                (fragment as LocationSharingFragment).showControls()
            }
        }
    }

    override fun hideMap() {
        if (binding!!.mapCard.visibility != View.GONE) {
            binding!!.mapCard.visibility = View.GONE
            val fragmentManager = childFragmentManager
            val fragment = fragmentManager.findFragmentById(R.id.mapLayout)
            if (fragment != null) {
                fragmentManager.beginTransaction()
                    .remove(fragment)
                    .commit()
            }
        }
    }

    private fun sendAudioMessage() {
        if (!presenter.deviceRuntimeService.hasAudioPermission()) {
            requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_CODE_CAPTURE_AUDIO)
        } else {
            try {
                val ctx = requireContext()
                val intent = Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION)
                mCurrentPhoto = AndroidFileUtils.createAudioFile(ctx)
                startActivityForResult(intent, REQUEST_CODE_CAPTURE_AUDIO)
            } catch (ex: Exception) {
                Log.e(TAG, "sendAudioMessage: error", ex)
                Toast.makeText(activity, "Can't find audio recorder app", Toast.LENGTH_SHORT).show()
            }
        }
    }

    private fun sendVideoMessage() {
        if (!presenter.deviceRuntimeService.hasVideoPermission()) {
            requestPermissions(arrayOf(Manifest.permission.CAMERA), REQUEST_CODE_CAPTURE_VIDEO)
        } else {
            try {
                val context = requireContext()
                val intent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply {
Adrien Béraud's avatar
Adrien Béraud committed
                    putExtra("android.intent.extras.CAMERA_FACING", 1)
                    putExtra("android.intent.extras.LENS_FACING_FRONT", 1)
                    putExtra("android.intent.extra.USE_FRONT_CAMERA", true)
                    putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 0)
                    putExtra(MediaStore.EXTRA_OUTPUT, ContentUriHandler.getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, AndroidFileUtils.createVideoFile(context).apply {
                        mCurrentPhoto = this
                    }))
                }
                startActivityForResult(intent, REQUEST_CODE_CAPTURE_VIDEO)
            } catch (ex: Exception) {
                Log.e(TAG, "sendVideoMessage: error", ex)
                Toast.makeText(activity, "Can't find video recorder app", Toast.LENGTH_SHORT).show()
            }
        }
    }

    fun takePicture() {
        if (!presenter.deviceRuntimeService.hasVideoPermission()) {
            requestPermissions(arrayOf(Manifest.permission.CAMERA), REQUEST_CODE_TAKE_PICTURE)
            return
        }
        val c = context ?: return
        try {
            val photoFile = AndroidFileUtils.createImageFile(c)
            Log.i(TAG, "takePicture: trying to save to $photoFile")
            val photoURI = ContentUriHandler.getUriForFile(c, ContentUriHandler.AUTHORITY_FILES, photoFile)
            val takePictureIntent =
                Intent(MediaStore.ACTION_IMAGE_CAPTURE).putExtra(MediaStore.EXTRA_OUTPUT, photoURI)
                    .putExtra("android.intent.extras.CAMERA_FACING", 1)
                    .putExtra("android.intent.extras.LENS_FACING_FRONT", 1)
                    .putExtra("android.intent.extra.USE_FRONT_CAMERA", true)
            mCurrentPhoto = photoFile
            startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PICTURE)
        } catch (e: Exception) {
            Toast.makeText(c, "Error taking picture: " + e.localizedMessage, Toast.LENGTH_SHORT)
                .show()
        }
    }

    override fun openFilePicker() {
        val intent = Intent(Intent.ACTION_GET_CONTENT)
        intent.addCategory(Intent.CATEGORY_OPENABLE)
        intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
        intent.type = "*/*"
        startActivityForResult(intent, REQUEST_CODE_FILE_PICKER)
    }

    private fun openGallery() {
        pickMultipleMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo))
    }

    private fun sendFile(file: File): Completable = Completable.fromAction {
        presenter.sendFile(file)
Adrien Béraud's avatar
Adrien Béraud committed
    private fun startFileSend(op: Completable): Disposable {
        setLoading(true)
        return op.observeOn(DeviceUtils.uiScheduler)
            .doFinally { setLoading(false) }
            .subscribe({}) { e ->
                Log.e(TAG, "startFileSend: not able to create cache file", e)
                displayErrorToast(Error.INVALID_FILE)
            }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
        Log.w(TAG, "onActivityResult: $requestCode $resultCode $resultData")
        if (requestCode == REQUEST_CODE_FILE_PICKER) {
            if (resultCode == Activity.RESULT_OK && resultData != null) {
                val clipData = resultData.clipData
                if (clipData != null) { // checking multiple selection or not
                    for (i in 0 until clipData.itemCount) {
                        val uri = clipData.getItemAt(i).uri
                        startFileSend(AndroidFileUtils.getCacheFile(requireContext(), uri)
                            .observeOn(DeviceUtils.uiScheduler)
                            .flatMapCompletable { file: File -> sendFile(file) })
                    }
                } else {
                    resultData.data?.let { uri ->
                        startFileSend(AndroidFileUtils.getCacheFile(requireContext(), uri)
                            .observeOn(DeviceUtils.uiScheduler)
                            .flatMapCompletable { file: File -> sendFile(file) })
                    }
                }
            }
        } else if (requestCode == REQUEST_CODE_TAKE_PICTURE || requestCode == REQUEST_CODE_CAPTURE_AUDIO || requestCode == REQUEST_CODE_CAPTURE_VIDEO) {
            if (resultCode != Activity.RESULT_OK) {
                mCurrentPhoto = null
                return
            }
            val currentPhoto = mCurrentPhoto
            var file: Single<File>? = null
            if (currentPhoto == null || !currentPhoto.exists() || currentPhoto.length() == 0L) {
                resultData?.data?.let { uri ->
                    file = AndroidFileUtils.getCacheFile(requireContext(), uri)
                file = Single.just(currentPhoto)
            }
            mCurrentPhoto = null
            val sendingFile = file
            if (sendingFile != null)
                startFileSend(sendingFile.flatMapCompletable { f -> sendFile(f) })
            else
                Toast.makeText(activity, "Can't find picture", Toast.LENGTH_SHORT).show()
        } else if (requestCode == REQUEST_CODE_SAVE_FILE) {
            val uri = resultData?.data
            if (resultCode == Activity.RESULT_OK && uri != null) {
                writeToFile(uri)
            }
        } else if (requestCode == REQUEST_CODE_EDIT_MESSAGE) {
            val uri = resultData?.data ?: return
            if (resultCode == Activity.RESULT_OK) {
                val path = InteractionPath.fromUri(uri) ?: return
                val message = resultData.getStringExtra(Intent.EXTRA_TEXT) ?: return
                presenter.editMessage(path.conversation.accountId, path.conversation.conversationUri, path.messageId, message)
            }
        }
    }

    private fun writeToFile(data: Uri) {
        val path = mCurrentFileAbsolutePath ?: return
        val cr = context?.contentResolver ?: return
        mCompositeDisposable.add(AndroidFileUtils.copyFileToUri(cr, File(path), data)
            .observeOn(DeviceUtils.uiScheduler)
Adrien Béraud's avatar
Adrien Béraud committed
            .subscribe({ Toast.makeText(context, R.string.file_saved_successfully, Toast.LENGTH_SHORT).show() })
            { Toast.makeText(context, R.string.generic_error, Toast.LENGTH_SHORT).show() })
    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
        for (i in permissions.indices) {
            val granted = grantResults[i] == PackageManager.PERMISSION_GRANTED
            when (permissions[i]) {
                Manifest.permission.CAMERA -> {
                    presenter.cameraPermissionChanged(granted)
                    if (granted) {
                        if (requestCode == REQUEST_CODE_CAPTURE_VIDEO)
                            sendVideoMessage()
                        else if (requestCode == REQUEST_CODE_TAKE_PICTURE)
                            takePicture()
                    }
                    return
                }
                Manifest.permission.RECORD_AUDIO -> {
                    if (granted && requestCode == REQUEST_CODE_CAPTURE_AUDIO)
                        sendAudioMessage()
                else -> {}
            }
        }
    }

    override fun addElement(element: Interaction) {
        if (mAdapter!!.add(element) && element.type != Interaction.InteractionType.INVALID)
            scrollToEnd()
        loading = false
    }

    override fun updateElement(element: Interaction) {
Adrien Béraud's avatar
Adrien Béraud committed
        mAdapter?.update(element)
    }

    override fun removeElement(element: Interaction) {
Adrien Béraud's avatar
Adrien Béraud committed
        mAdapter?.remove(element)
    }

    override fun setComposingStatus(composingStatus: ComposingStatus) {
Adrien Béraud's avatar
Adrien Béraud committed
        mAdapter?.setComposingStatus(composingStatus)
        if (composingStatus == ComposingStatus.Active) scrollToEnd()
    }

    override fun acceptFile(accountId: String, conversationUri: net.jami.model.Uri, transfer: DataTransfer) {
        if (transfer.messageId == null && transfer.fileId == null)
            return
        val cacheDir = requireContext().cacheDir
        val spaceLeft = AndroidFileUtils.getSpaceLeft(cacheDir.toString())
        if (spaceLeft == -1L || transfer.totalSize > spaceLeft) {
            presenter.noSpaceLeft()
            return
        }
        requireActivity().startService(Intent(DRingService.ACTION_FILE_ACCEPT, ConversationPath.toUri(accountId, conversationUri),
            requireContext(), DRingService::class.java)
            .putExtra(DRingService.KEY_MESSAGE_ID, transfer.messageId)
            .putExtra(DRingService.KEY_TRANSFER_ID, transfer.fileId)
        )
    }

    override fun refuseFile(accountId: String, conversationUri: net.jami.model.Uri, transfer: DataTransfer) {
        if (transfer.messageId == null && transfer.fileId == null)
            return
        requireActivity().startService(Intent(DRingService.ACTION_FILE_CANCEL, ConversationPath.toUri(accountId, conversationUri),
            requireContext(), DRingService::class.java)
            .putExtra(DRingService.KEY_MESSAGE_ID, transfer.messageId)
            .putExtra(DRingService.KEY_TRANSFER_ID, transfer.fileId)
        )
    }

    override fun shareFile(path: File, displayName: String) {
        val c = context ?: return
        AndroidFileUtils.shareFile(c, path, displayName)
    }

    override fun openFile(path: File, displayName: String) {
        val c = context ?: return
        AndroidFileUtils.openFile(c, path, displayName)
    private fun actionSendMsgText(actionId: Int): Boolean = when (actionId) {
        EditorInfo.IME_ACTION_SEND -> {
            sendMessageText()
            true
        else -> false
    }

    fun onClick() {
        presenter.clickOnGoingPane()
    }

    override fun onStart() {
        super.onStart()
        presenter.resume(mIsBubble)
    }

    override fun onStop() {
        super.onStop()
        presenter.pause()
    }

    override fun onDestroy() {
        mCompositeDisposable.dispose()
        super.onDestroy()
    }

    private val menuProvider = object : MenuProvider {
        override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
            if (!isVisible)
                return
            menu.clear()
            inflater.inflate(R.menu.conversation_actions, menu)
            mAudioCallBtn = menu.findItem(R.id.conv_action_audiocall)
            mVideoCallBtn = menu.findItem(R.id.conv_action_videocall)
            val searchMenuItem = menu.findItem(R.id.conv_search)
            searchMenuItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
                override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
                    val binding = binding ?: return false
                    presenter.stopSearch()
                    binding.histList.adapter = mAdapter
                    updateListPadding()
                    currentBottomView?.isVisible = true
                    if (animation.isStarted) animation.cancel()
                    animation.setIntValues(
                        binding.histList.paddingBottom,
                        (currentBottomView?.height ?: 0) + marginPxTotal
                    )
                    animation.start()
                    return true
                }

                override fun onMenuItemActionExpand(item: MenuItem): Boolean {
                    val binding = binding ?: return false
                    mSearchAdapter = ConversationAdapter(this@ConversationFragment, presenter, true)
                    mSearchAdapter?.setPrimaryColor(mAdapter!!.getPrimaryColor())
                    presenter.startSearch()
                    currentBottomView?.isVisible = false
                    binding.histList.adapter = mSearchAdapter
                    if (animation.isStarted) animation.cancel()
                    animation.setIntValues(binding.histList.paddingBottom, marginPxTotal)
                    animation.start()
                    return true
                }
            })
            (searchMenuItem.actionView as SearchView).let {
                it.setOnQueryTextListener(this@ConversationFragment)
                it.queryHint = getString(R.string.conversation_search_hint)
        override fun onMenuItemSelected(item: MenuItem): Boolean {
            when (item.itemId) {
                android.R.id.home -> startActivity(Intent(activity, HomeActivity::class.java))
                R.id.conv_action_audiocall -> presenter.goToCall(false)
                R.id.conv_action_videocall -> presenter.goToCall(true)
                R.id.conv_contact_details -> presenter.openContact()
                else -> return false
            }
            return true
        }
    }

    override fun onQueryTextSubmit(query: String): Boolean {
        return true
    }

    override fun onQueryTextChange(query: String): Boolean {
        if (query.isNotBlank())
            presenter.setSearchQuery(query.trim())
        mSearchAdapter?.clearSearchResults()
        return true
    }

    fun openContact() {
        presenter.openContact()
    }

    override fun addSearchResults(results: List<Interaction>) {
        mSearchAdapter?.addSearchResults(results)
    override fun shareText(body: String) {
        startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
            putExtra(Intent.EXTRA_TEXT, body)
            type = "text/plain"
        }, null))
    }

    override fun initPresenter(presenter: ConversationPresenter) {
        val path = ConversationPath.fromBundle(arguments)
        mIsBubble = requireArguments().getBoolean(NotificationServiceImpl.EXTRA_BUBBLE)
        Log.w(TAG, "initPresenter $path")
        if (path == null) return
        val uri = path.conversationUri
        mAdapter = ConversationAdapter(this, presenter)
        presenter.init(uri, path.accountId)

        // Load shared preferences. Usually useful for non-swarm conversations.
            mPreferences = getConversationPreferences(requireContext(), path.accountId, uri)
                .also { sharedPreferences ->
                    sharedPreferences.edit().remove(KEY_PREFERENCE_CONVERSATION_LAST_READ).apply()
            }
        } catch (e: Exception) {
            Log.e(TAG, "Can't load conversation preferences")
        }
        var connection = locationServiceConnection
        if (connection == null) {
            connection = object : ServiceConnection {
                override fun onServiceConnected(name: ComponentName, service: IBinder) {
                    Log.w(TAG, "onServiceConnected")
                    val binder = service as LocationSharingService.LocalBinder
                    val locationService = binder.service
                    //val path = ConversationPath(presenter.path)
                    if (locationService.isSharing(path)) {
                        showMap(path.accountId, uri.uri, false)
                    }
                    /*try {
                        requireContext().unbindService(locationServiceConnection!!)
                    } catch (e: Exception) {
                        Log.w(TAG, "Error unbinding service", e)
                    }*/
                }

                override fun onServiceDisconnected(name: ComponentName) {
                    Log.w(TAG, "onServiceDisconnected")
                    locationServiceConnection = null
                }
            }
            locationServiceConnection = connection
            Log.w(TAG, "bindService")
            requireContext().bindService(Intent(requireContext(), LocationSharingService::class.java), connection, 0)
        }
    }

    override fun updateContact(contact: ContactViewModel) {
        val contactKey = contact.contact.primaryNumber
        val a = mSmallParticipantAvatars[contactKey]
        if (a != null) {
            a.update(contact)
            mParticipantAvatars[contactKey]!!.update(contact)
            mAdapter?.setPhoto()
        } else {
            val builder = AvatarDrawable.Builder()
                .withContact(contact)
                .withCircleCrop(true)
            mParticipantAvatars[contactKey] = builder
                .withPresence(true)
                .build(requireContext())
            mSmallParticipantAvatars[contactKey] = builder
                .withPresence(false)
                .build(requireContext())
    override fun displayContact(conversation: ConversationItemViewModel) {
        val avatar = AvatarFactory.getAvatar(requireContext(), conversation).blockingGet()
        mConversationAvatar = avatar
        mParticipantAvatars[conversation.uri.rawRingId] = AvatarDrawable(avatar)
        setupActionbar(conversation)
    }

    override fun displayOnGoingCallPane(display: Boolean) {
        binding!!.ongoingCallPane.visibility = if (display) View.VISIBLE else View.GONE
    }

    override fun displayRingingCallPane(display: Boolean, withCamera: Boolean) {
        binding!!.ringingCallPane.visibility =
            if (display) View.VISIBLE else View.GONE
        binding!!.acceptVideoCallButton.visibility =
            if (withCamera) View.VISIBLE else View.GONE
    }

    override fun displayNumberSpinner(conversation: Conversation, number: net.jami.model.Uri) {
        binding!!.numberSelector.visibility = View.VISIBLE
        //binding.numberSelector.setAdapter(new NumberAdapter(getActivity(), conversation.getContact(), false));
        binding!!.numberSelector.setSelection(getIndex(binding!!.numberSelector, number))
    }

    override fun hideNumberSpinner() {
        binding!!.numberSelector.visibility = View.GONE
    }