Newer
Older
* Copyright (C) 2004-2023 Savoir-faire Linux Inc.
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package 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.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
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.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.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
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>(),
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 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))
}
}
}
}
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 ->
layoutToAnimate.updatePadding(
top = insets.systemWindowInsetTop,
bottom = insets.systemWindowInsetBottom
)
// 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 ->
(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)
else binding.fabLatest.hide()
val animator = binding.histList.itemAnimator as DefaultItemAnimator?
animator?.supportsChangeAnimations = false
binding.histList.adapter = mAdapter
}
}
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) {
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()
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
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 {
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)
private fun startFileSend(op: Completable): Disposable {
return op.observeOn(DeviceUtils.uiScheduler)
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
if (currentPhoto == null || !currentPhoto.exists() || currentPhoto.length() == 0L) {
resultData?.data?.let { uri ->
file = AndroidFileUtils.getCacheFile(requireContext(), uri)
file = Single.just(currentPhoto)
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)
.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) {
val granted = grantResults[i] == PackageManager.PERMISSION_GRANTED
when (permissions[i]) {
Manifest.permission.CAMERA -> {
presenter.cameraPermissionChanged(granted)
if (granted) {
if (requestCode == REQUEST_CODE_CAPTURE_VIDEO)
else if (requestCode == REQUEST_CODE_TAKE_PICTURE)
takePicture()
}
return
}
Manifest.permission.RECORD_AUDIO -> {
if (granted && requestCode == REQUEST_CODE_CAPTURE_AUDIO)
sendAudioMessage()
}
}
}
override fun addElement(element: Interaction) {
if (mAdapter!!.add(element) && element.type != Interaction.InteractionType.INVALID)
scrollToEnd()
loading = false
}
override fun updateElement(element: Interaction) {
}
override fun removeElement(element: Interaction) {
}
override fun setComposingStatus(composingStatus: 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
}
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()
}
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
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)
}
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
}