Add call tab bottom bar.
This commit is contained in:
parent
545f1fa5a4
commit
8c0d979abd
14 changed files with 494 additions and 63 deletions
|
@ -1,18 +1,17 @@
|
|||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
|
||||
class CallLogActionMode(
|
||||
private val fragment: CallLogFragment,
|
||||
private val onResetSelectionState: () -> Unit
|
||||
private val callback: Callback
|
||||
) : ActionMode.Callback {
|
||||
|
||||
private var actionMode: ActionMode? = null
|
||||
private var count: Int = 0
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
|
||||
mode?.title = getTitle(1)
|
||||
|
@ -28,27 +27,36 @@ class CallLogActionMode(
|
|||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode?) {
|
||||
onResetSelectionState()
|
||||
callback.onResetSelectionState()
|
||||
endIfActive()
|
||||
}
|
||||
|
||||
fun isInActionMode(): Boolean {
|
||||
return actionMode != null
|
||||
}
|
||||
|
||||
fun getCount(): Int {
|
||||
return if (actionMode != null) count else 0
|
||||
}
|
||||
|
||||
fun setCount(count: Int) {
|
||||
this.count = count
|
||||
actionMode?.title = getTitle(count)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
actionMode = (fragment.requireActivity() as AppCompatActivity).startSupportActionMode(this)
|
||||
fragment.requireListener<CallLogFragment.Callback>().onMultiSelectStarted()
|
||||
actionMode = callback.startActionMode(this)
|
||||
}
|
||||
|
||||
fun end() {
|
||||
fragment.requireListener<CallLogFragment.Callback>().onMultiSelectFinished()
|
||||
callback.onActionModeWillEnd()
|
||||
actionMode?.finish()
|
||||
count = 0
|
||||
actionMode = null
|
||||
}
|
||||
|
||||
private fun getTitle(callLogsSelected: Int): String {
|
||||
return fragment.requireContext().resources.getQuantityString(R.plurals.ConversationListFragment_s_selected, callLogsSelected, callLogsSelected)
|
||||
return callback.getResources().getQuantityString(R.plurals.ConversationListFragment_s_selected, callLogsSelected, callLogsSelected)
|
||||
}
|
||||
|
||||
private fun endIfActive() {
|
||||
|
@ -56,4 +64,11 @@ class CallLogActionMode(
|
|||
end()
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun startActionMode(callback: ActionMode.Callback): ActionMode?
|
||||
fun onActionModeWillEnd()
|
||||
fun getResources(): Resources
|
||||
fun onResetSelectionState()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.database.CallTable
|
|||
import org.thoughtcrime.securesms.databinding.CallLogAdapterItemBinding
|
||||
import org.thoughtcrime.securesms.databinding.ConversationListItemClearFilterBinding
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
|
||||
|
@ -35,7 +36,9 @@ class CallLogAdapter(
|
|||
CallModelViewHolder(
|
||||
it,
|
||||
callbacks::onCallClicked,
|
||||
callbacks::onCallLongClicked
|
||||
callbacks::onCallLongClicked,
|
||||
callbacks::onStartAudioCallClicked,
|
||||
callbacks::onStartVideoCallClicked
|
||||
)
|
||||
},
|
||||
inflater = CallLogAdapterItemBinding::inflate
|
||||
|
@ -50,7 +53,7 @@ class CallLogAdapter(
|
|||
)
|
||||
}
|
||||
|
||||
fun submitCallRows(rows: List<CallLogRow>, selectionState: CallLogSelectionState) {
|
||||
fun submitCallRows(rows: List<CallLogRow?>, selectionState: CallLogSelectionState) {
|
||||
submitList(
|
||||
rows.filterNotNull().map {
|
||||
when (it) {
|
||||
|
@ -103,7 +106,9 @@ class CallLogAdapter(
|
|||
private class CallModelViewHolder(
|
||||
binding: CallLogAdapterItemBinding,
|
||||
private val onCallClicked: (CallLogRow.Call) -> Unit,
|
||||
private val onCallLongClicked: (View, CallLogRow.Call) -> Boolean
|
||||
private val onCallLongClicked: (View, CallLogRow.Call) -> Boolean,
|
||||
private val onStartAudioCallClicked: (Recipient) -> Unit,
|
||||
private val onStartVideoCallClicked: (Recipient) -> Unit
|
||||
) : BindingViewHolder<CallModel, CallLogAdapterItemBinding>(binding) {
|
||||
override fun bind(model: CallModel) {
|
||||
itemView.setOnClickListener {
|
||||
|
@ -130,7 +135,7 @@ class CallLogAdapter(
|
|||
binding.callRecipientBadge.setBadgeFromRecipient(model.call.peer)
|
||||
binding.callRecipientName.text = model.call.peer.getDisplayName(context)
|
||||
presentCallInfo(event, direction, model.call.date)
|
||||
presentCallType(type)
|
||||
presentCallType(type, model.call.peer)
|
||||
}
|
||||
|
||||
private fun presentCallInfo(event: CallTable.Event, direction: CallTable.Direction, date: Long) {
|
||||
|
@ -161,13 +166,18 @@ class CallLogAdapter(
|
|||
binding.callInfo.setTextColor(color)
|
||||
}
|
||||
|
||||
private fun presentCallType(callType: CallTable.Type) {
|
||||
binding.callType.setImageResource(
|
||||
when (callType) {
|
||||
CallTable.Type.AUDIO_CALL -> R.drawable.symbol_phone_24
|
||||
CallTable.Type.VIDEO_CALL -> R.drawable.symbol_video_24
|
||||
private fun presentCallType(callType: CallTable.Type, peer: Recipient) {
|
||||
when (callType) {
|
||||
CallTable.Type.AUDIO_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_phone_24)
|
||||
binding.callType.setOnClickListener { onStartAudioCallClicked(peer) }
|
||||
}
|
||||
)
|
||||
CallTable.Type.VIDEO_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(peer) }
|
||||
}
|
||||
}
|
||||
|
||||
binding.callType.visible = true
|
||||
}
|
||||
|
||||
|
@ -225,5 +235,15 @@ class CallLogAdapter(
|
|||
* Invoked when the clear filter button is pressed
|
||||
*/
|
||||
fun onClearFilterClicked()
|
||||
|
||||
/**
|
||||
* Invoked when user presses the audio icon
|
||||
*/
|
||||
fun onStartAudioCallClicked(peer: Recipient)
|
||||
|
||||
/**
|
||||
* Invoked when user presses the video icon
|
||||
*/
|
||||
fun onStartVideoCallClicked(peer: Recipient)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -93,11 +93,12 @@ class CallLogContextMenu(
|
|||
iconRes = R.drawable.symbol_trash_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__delete)
|
||||
) {
|
||||
// TODO [alex] Delete message by message id
|
||||
callbacks.deleteCall(call)
|
||||
}
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
fun startSelection(call: CallLogRow.Call)
|
||||
fun deleteCall(call: CallLogRow.Call)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,28 +1,36 @@
|
|||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.Resources
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.Observables
|
||||
import io.reactivex.rxjava3.kotlin.Flowables
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
import org.thoughtcrime.securesms.components.Material3SearchToolbar
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.conversation.SignalBottomActionBarController
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationFilterBehavior
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView.OnCloseClicked
|
||||
|
@ -32,8 +40,10 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
|
|||
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.main.SearchBinder
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
|
@ -53,12 +63,9 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
|||
private val binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind)
|
||||
private val disposables = LifecycleDisposable()
|
||||
private val callLogContextMenu = CallLogContextMenu(this, this)
|
||||
private val callLogActionMode = CallLogActionMode(
|
||||
fragment = this,
|
||||
onResetSelectionState = {
|
||||
viewModel.clearSelected()
|
||||
}
|
||||
)
|
||||
private val callLogActionMode = CallLogActionMode(CallLogActionModeCallback())
|
||||
|
||||
private lateinit var signalBottomActionBarController: SignalBottomActionBarController
|
||||
|
||||
private val tabsViewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
|
||||
|
@ -90,30 +97,27 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
|||
|
||||
val adapter = CallLogAdapter(this)
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
disposables += viewModel.controller
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
adapter.setPagingController(it)
|
||||
}
|
||||
adapter.setPagingController(viewModel.controller)
|
||||
|
||||
disposables += Observables.combineLatest(viewModel.data, viewModel.selected)
|
||||
disposables += Flowables.combineLatest(viewModel.data, viewModel.selected)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { (data, selected) ->
|
||||
adapter.submitCallRows(data, selected)
|
||||
}
|
||||
|
||||
disposables += viewModel.selected
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
disposables += Flowables.combineLatest(viewModel.selected, viewModel.totalCount)
|
||||
.distinctUntilChanged()
|
||||
.subscribe {
|
||||
if (!it.isNotEmpty(adapter.itemCount)) {
|
||||
callLogActionMode.end()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { (selected, totalCount) ->
|
||||
if (selected.isNotEmpty(totalCount)) {
|
||||
callLogActionMode.setCount(selected.count(totalCount))
|
||||
} else {
|
||||
callLogActionMode.setCount(it.count(adapter.itemCount))
|
||||
callLogActionMode.end()
|
||||
}
|
||||
}
|
||||
|
||||
binding.recycler.adapter = adapter
|
||||
|
||||
requireListener<Material3OnScrollHelperBinder>().bindScrollHelper(binding.recycler)
|
||||
binding.fab.setOnClickListener {
|
||||
startActivity(NewCallActivity.createIntent(requireContext()))
|
||||
|
@ -121,6 +125,22 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
|||
|
||||
binding.pullView.setPillText(R.string.CallLogFragment__filtered_by_missed)
|
||||
|
||||
binding.bottomActionBar.setItems(
|
||||
listOf(
|
||||
ActionItem(
|
||||
iconRes = R.drawable.symbol_check_circle_24,
|
||||
title = getString(R.string.CallLogFragment__select_all)
|
||||
) {
|
||||
viewModel.selectAll()
|
||||
},
|
||||
ActionItem(
|
||||
iconRes = R.drawable.symbol_trash_24,
|
||||
title = getString(R.string.CallLogFragment__delete),
|
||||
action = this::handleDeleteSelectedRows
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
initializePullToFilter()
|
||||
initializeTapToScrollToTop()
|
||||
|
||||
|
@ -134,6 +154,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
|||
}
|
||||
}
|
||||
)
|
||||
|
||||
signalBottomActionBarController = SignalBottomActionBarController(
|
||||
binding.bottomActionBar,
|
||||
binding.recycler,
|
||||
BottomActionBarControllerCallback()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
@ -154,6 +180,25 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
|||
})
|
||||
}
|
||||
|
||||
private fun handleDeleteSelectedRows() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, callLogActionMode.getCount(), callLogActionMode.getCount()))
|
||||
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->
|
||||
disposables += viewModel.deleteSelection()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy(onSuccess = {
|
||||
callLogActionMode.end()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
resources.getQuantityString(R.plurals.CallLogFragment__d_calls_deleted, it, it),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
})
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun initializeSearchAction() {
|
||||
val searchBinder = requireListener<SearchBinder>()
|
||||
searchBinder.getSearchAction().setOnClickListener {
|
||||
|
@ -205,7 +250,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
|||
}
|
||||
|
||||
override fun canStartNestedScroll(): Boolean {
|
||||
return !isSearchOpen() || binding.pullView.isCloseable()
|
||||
return !callLogActionMode.isInActionMode() || !isSearchOpen() || binding.pullView.isCloseable()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -218,6 +263,9 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
|||
override fun onCallClicked(callLogRow: CallLogRow.Call) {
|
||||
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
|
||||
viewModel.toggleSelected(callLogRow.id)
|
||||
} else {
|
||||
val intent = ConversationSettingsActivity.forCall(requireContext(), callLogRow.peer, longArrayOf(callLogRow.call.messageId))
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -231,11 +279,37 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
|||
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
|
||||
}
|
||||
|
||||
override fun onStartAudioCallClicked(peer: Recipient) {
|
||||
CommunicationActions.startVoiceCall(this, peer)
|
||||
}
|
||||
|
||||
override fun onStartVideoCallClicked(peer: Recipient) {
|
||||
CommunicationActions.startVideoCall(this, peer)
|
||||
}
|
||||
|
||||
override fun startSelection(call: CallLogRow.Call) {
|
||||
callLogActionMode.start()
|
||||
viewModel.toggleSelected(call.id)
|
||||
}
|
||||
|
||||
override fun deleteCall(call: CallLogRow.Call) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
|
||||
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->
|
||||
disposables += viewModel.deleteCall(call)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy(onSuccess = {
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
resources.getQuantityString(R.plurals.CallLogFragment__d_calls_deleted, it, it),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
})
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun filterMissedCalls() {
|
||||
binding.pullView.toggle()
|
||||
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
|
||||
|
@ -259,6 +333,29 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
|||
requireListener<SearchBinder>().getSearchToolbar().get().getVisibility() == View.VISIBLE
|
||||
}
|
||||
|
||||
private inner class BottomActionBarControllerCallback : SignalBottomActionBarController.Callback {
|
||||
override fun onBottomActionBarVisibilityChanged(visibility: Int) = Unit
|
||||
}
|
||||
|
||||
private inner class CallLogActionModeCallback : CallLogActionMode.Callback {
|
||||
override fun startActionMode(callback: ActionMode.Callback): ActionMode? {
|
||||
val actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(callback)
|
||||
requireListener<Callback>().onMultiSelectStarted()
|
||||
signalBottomActionBarController.setVisibility(true)
|
||||
return actionMode
|
||||
}
|
||||
|
||||
override fun onActionModeWillEnd() {
|
||||
requireListener<Callback>().onMultiSelectFinished()
|
||||
signalBottomActionBarController.setVisibility(false)
|
||||
}
|
||||
|
||||
override fun getResources(): Resources = resources
|
||||
override fun onResetSelectionState() {
|
||||
viewModel.clearSelected()
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onMultiSelectStarted()
|
||||
fun onMultiSelectFinished()
|
||||
|
|
|
@ -10,8 +10,11 @@ class CallLogPagedDataSource(
|
|||
|
||||
private val hasFilter = filter == CallLogFilter.MISSED
|
||||
|
||||
var callsCount = 0
|
||||
|
||||
override fun size(): Int {
|
||||
return repository.getCallsCount(query, filter) + (if (hasFilter) 1 else 0)
|
||||
callsCount = repository.getCallsCount(query, filter)
|
||||
return callsCount + (if (hasFilter) 1 else 0)
|
||||
}
|
||||
|
||||
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
|
||||
class CallLogRepository : CallLogPagedDataSource.CallRepository {
|
||||
override fun getCallsCount(query: String?, filter: CallLogFilter): Int {
|
||||
|
@ -10,4 +15,44 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
|
|||
override fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow> {
|
||||
return SignalDatabase.calls.getCalls(start, length, query, filter)
|
||||
}
|
||||
|
||||
fun listenForChanges(): Observable<Unit> {
|
||||
return Observable.create { emitter ->
|
||||
fun refresh() {
|
||||
emitter.onNext(Unit)
|
||||
}
|
||||
|
||||
val databaseObserver = DatabaseObserver.Observer {
|
||||
refresh()
|
||||
}
|
||||
|
||||
val messageObserver = DatabaseObserver.MessageObserver {
|
||||
refresh()
|
||||
}
|
||||
|
||||
ApplicationDependencies.getDatabaseObserver().registerConversationListObserver(databaseObserver)
|
||||
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageObserver)
|
||||
|
||||
emitter.setCancellable {
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(databaseObserver)
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSelectedCallLogs(
|
||||
selectedMessageIds: Set<Long>
|
||||
): Single<Int> {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.messages.deleteCallUpdates(selectedMessageIds)
|
||||
}.observeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun deleteAllCallLogsExcept(
|
||||
selectedMessageIds: Set<Long>
|
||||
): Single<Int> {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.messages.deleteAllCallUpdatesExcept(selectedMessageIds)
|
||||
}.observeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ sealed class CallLogRow {
|
|||
val call: CallTable.Call,
|
||||
val peer: Recipient,
|
||||
val date: Long,
|
||||
override val id: Id = Id.Call(call.callId)
|
||||
override val id: Id = Id.Call(call.messageId)
|
||||
) : CallLogRow()
|
||||
|
||||
/**
|
||||
|
@ -28,7 +28,7 @@ sealed class CallLogRow {
|
|||
}
|
||||
|
||||
sealed class Id {
|
||||
data class Call(val callId: Long) : Id()
|
||||
data class Call(val messageId: Long) : Id()
|
||||
object ClearFilter : Id()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,9 @@ sealed class CallLogSelectionState {
|
|||
|
||||
abstract fun count(totalCount: Int): Int
|
||||
|
||||
abstract fun selected(): Set<CallLogRow.Id>
|
||||
fun isExclusionary(): Boolean = this is Excludes
|
||||
|
||||
protected abstract fun select(callId: CallLogRow.Id): CallLogSelectionState
|
||||
protected abstract fun deselect(callId: CallLogRow.Id): CallLogSelectionState
|
||||
|
||||
|
@ -43,6 +46,10 @@ sealed class CallLogSelectionState {
|
|||
override fun deselect(callId: CallLogRow.Id): CallLogSelectionState {
|
||||
return Includes(includes - callId)
|
||||
}
|
||||
|
||||
override fun selected(): Set<CallLogRow.Id> {
|
||||
return includes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -63,6 +70,8 @@ sealed class CallLogSelectionState {
|
|||
override fun deselect(callId: CallLogRow.Id): CallLogSelectionState {
|
||||
return Excludes(excluded + callId)
|
||||
}
|
||||
|
||||
override fun selected(): Set<CallLogRow.Id> = excluded
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
||||
import org.signal.paging.ObservablePagedData
|
||||
import org.signal.paging.PagedData
|
||||
import org.signal.paging.PagingConfig
|
||||
import org.signal.paging.PagingController
|
||||
import org.signal.paging.ProxyPagingController
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
|
||||
/**
|
||||
|
@ -15,23 +20,24 @@ class CallLogViewModel(
|
|||
private val callLogRepository: CallLogRepository = CallLogRepository()
|
||||
) : ViewModel() {
|
||||
private val callLogStore = RxStore(CallLogState())
|
||||
private val pagedData: Observable<ObservablePagedData<CallLogRow.Id, CallLogRow>> = callLogStore
|
||||
.stateFlowable
|
||||
.toObservable()
|
||||
.map { (query, filter) ->
|
||||
PagedData.createForObservable(
|
||||
CallLogPagedDataSource(query, filter, callLogRepository),
|
||||
pagingConfig
|
||||
)
|
||||
}
|
||||
|
||||
val controller: Observable<PagingController<CallLogRow.Id>> = pagedData.map { it.controller }
|
||||
val data: Observable<MutableList<CallLogRow>> = pagedData.switchMap { it.data }
|
||||
val selected: Observable<CallLogSelectionState> = callLogStore
|
||||
private val disposables = CompositeDisposable()
|
||||
private val pagedData: BehaviorProcessor<ObservablePagedData<CallLogRow.Id, CallLogRow>> = BehaviorProcessor.create()
|
||||
|
||||
private val distinctQueryFilterPairs = callLogStore
|
||||
.stateFlowable
|
||||
.map { (query, filter) -> Pair(query, filter) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
val controller = ProxyPagingController<CallLogRow.Id>()
|
||||
val data: Flowable<MutableList<CallLogRow?>> = pagedData.switchMap { it.data.toFlowable(BackpressureStrategy.LATEST) }
|
||||
val selected: Flowable<CallLogSelectionState> = callLogStore
|
||||
.stateFlowable
|
||||
.toObservable()
|
||||
.map { it.selectionState }
|
||||
|
||||
val totalCount: Flowable<Int> = Flowable.combineLatest(distinctQueryFilterPairs, data) { a, _ -> a }
|
||||
.map { (query, filter) -> callLogRepository.getCallsCount(query, filter) }
|
||||
|
||||
val selectionStateSnapshot: CallLogSelectionState
|
||||
get() = callLogStore.state.selectionState
|
||||
val filterSnapshot: CallLogFilter
|
||||
|
@ -46,6 +52,37 @@ class CallLogViewModel(
|
|||
.setStartIndex(0)
|
||||
.build()
|
||||
|
||||
init {
|
||||
disposables.add(callLogStore)
|
||||
disposables += distinctQueryFilterPairs.subscribe { (query, filter) ->
|
||||
pagedData.onNext(
|
||||
PagedData.createForObservable(
|
||||
CallLogPagedDataSource(query, filter, callLogRepository),
|
||||
pagingConfig
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
disposables += pagedData.map { it.controller }.subscribe {
|
||||
controller.set(it)
|
||||
}
|
||||
|
||||
disposables += callLogRepository.listenForChanges().subscribe {
|
||||
controller.onDataInvalidated()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
callLogStore.update {
|
||||
val selectionState = CallLogSelectionState.selectAll()
|
||||
it.copy(selectionState = selectionState)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSelected(callId: CallLogRow.Id) {
|
||||
callLogStore.update {
|
||||
val selectionState = it.selectionState.toggle(callId)
|
||||
|
@ -53,6 +90,10 @@ class CallLogViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
fun deleteCall(call: CallLogRow.Call): Single<Int> {
|
||||
return callLogRepository.deleteSelectedCallLogs(setOf(call.call.messageId))
|
||||
}
|
||||
|
||||
fun clearSelected() {
|
||||
callLogStore.update {
|
||||
it.copy(selectionState = CallLogSelectionState.empty())
|
||||
|
@ -67,6 +108,20 @@ class CallLogViewModel(
|
|||
callLogStore.update { it.copy(filter = filter) }
|
||||
}
|
||||
|
||||
fun deleteSelection(): Single<Int> {
|
||||
val stateSnapshot = callLogStore.state
|
||||
val messageIds: Set<Long> = stateSnapshot.selectionState.selected()
|
||||
.filterIsInstance<CallLogRow.Id.Call>()
|
||||
.map { it.messageId }
|
||||
.toSet()
|
||||
|
||||
return if (stateSnapshot.selectionState.isExclusionary()) {
|
||||
callLogRepository.deleteAllCallLogsExcept(messageIds)
|
||||
} else {
|
||||
callLogRepository.deleteSelectedCallLogs(messageIds)
|
||||
}
|
||||
}
|
||||
|
||||
private data class CallLogState(
|
||||
val query: String? = null,
|
||||
val filter: CallLogFilter = CallLogFilter.ALL,
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener
|
||||
import java.util.concurrent.ExecutionException
|
||||
|
||||
class SignalBottomActionBarController(
|
||||
private val bottomActionBar: SignalBottomActionBar,
|
||||
private val recyclerView: RecyclerView,
|
||||
private val callback: Callback
|
||||
) {
|
||||
|
||||
private val additionalScrollOffset = 54.dp
|
||||
private val paddingBottom: Int = recyclerView.paddingBottom
|
||||
|
||||
fun setVisibility(isVisible: Boolean) {
|
||||
val isCurrentlyVisible = bottomActionBar.isVisible
|
||||
if (isVisible == isCurrentlyVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
ViewUtil.animateIn(bottomActionBar, bottomActionBar.enterAnimation)
|
||||
callback.onBottomActionBarVisibilityChanged(View.VISIBLE)
|
||||
|
||||
bottomActionBar.viewTreeObserver.addOnPreDrawListener(BecomingVisiblePreDrawListener())
|
||||
} else {
|
||||
ViewUtil
|
||||
.animateOut(bottomActionBar, bottomActionBar.exitAnimation)
|
||||
.addListener(BecomingGoneAnimationListener())
|
||||
}
|
||||
}
|
||||
|
||||
private inner class BecomingVisiblePreDrawListener : ViewTreeObserver.OnPreDrawListener {
|
||||
|
||||
private val bottomPaddingExtra = 18.dp
|
||||
|
||||
override fun onPreDraw(): Boolean {
|
||||
if (bottomActionBar.height == 0 && bottomActionBar.visibility == View.VISIBLE) {
|
||||
return false
|
||||
}
|
||||
|
||||
bottomActionBar.viewTreeObserver.removeOnPreDrawListener(this)
|
||||
|
||||
val bottomPadding = bottomActionBar.height + bottomPaddingExtra
|
||||
ViewUtil.setPaddingBottom(recyclerView, bottomPadding)
|
||||
|
||||
recyclerView.scrollBy(0, -(bottomPadding - additionalScrollOffset))
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private inner class BecomingGoneAnimationListener : Listener<Boolean> {
|
||||
override fun onSuccess(result: Boolean) {
|
||||
val scrollOffset = recyclerView.paddingBottom - additionalScrollOffset
|
||||
callback.onBottomActionBarVisibilityChanged(View.GONE)
|
||||
ViewUtil.setPaddingBottom(recyclerView, paddingBottom)
|
||||
|
||||
recyclerView.doOnPreDraw {
|
||||
recyclerView.scrollBy(0, scrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(e: ExecutionException?) = Unit
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onBottomActionBarVisibilityChanged(visibility: Int)
|
||||
}
|
||||
}
|
|
@ -44,6 +44,7 @@ import org.signal.core.util.forEach
|
|||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToSet
|
||||
import org.signal.core.util.readToSingleInt
|
||||
import org.signal.core.util.readToSingleLong
|
||||
import org.signal.core.util.readToSingleObject
|
||||
|
@ -386,6 +387,20 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
|||
ORDER BY $DATE_RECEIVED DESC LIMIT 1
|
||||
""".toSingleLine()
|
||||
|
||||
private val IS_CALL_TYPE_CLAUSE = """(
|
||||
($TYPE = ${MessageTypes.INCOMING_AUDIO_CALL_TYPE})
|
||||
OR
|
||||
($TYPE = ${MessageTypes.INCOMING_VIDEO_CALL_TYPE})
|
||||
OR
|
||||
($TYPE = ${MessageTypes.OUTGOING_AUDIO_CALL_TYPE})
|
||||
OR
|
||||
($TYPE = ${MessageTypes.OUTGOING_VIDEO_CALL_TYPE})
|
||||
OR
|
||||
($TYPE = ${MessageTypes.MISSED_AUDIO_CALL_TYPE})
|
||||
OR
|
||||
($TYPE = ${MessageTypes.MISSED_VIDEO_CALL_TYPE})
|
||||
)""".toSingleLine()
|
||||
|
||||
@JvmStatic
|
||||
fun mmsReaderFor(cursor: Cursor): MmsReader {
|
||||
return MmsReader(cursor)
|
||||
|
@ -2966,6 +2981,56 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
|||
return messageId
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the call updates specified in the messageIds set.
|
||||
*/
|
||||
fun deleteCallUpdates(messageIds: Set<Long>): Int {
|
||||
return deleteCallUpdatesInternal(messageIds, SqlUtil.CollectionOperator.IN)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all call updates except for those specified in the parameter.
|
||||
*/
|
||||
fun deleteAllCallUpdatesExcept(excludedMessageIds: Set<Long>): Int {
|
||||
return deleteCallUpdatesInternal(excludedMessageIds, SqlUtil.CollectionOperator.NOT_IN)
|
||||
}
|
||||
|
||||
private fun deleteCallUpdatesInternal(messageIds: Set<Long>, collectionOperator: SqlUtil.CollectionOperator): Int {
|
||||
var rowsDeleted = 0
|
||||
val threadIds: Set<Long> = writableDatabase.withinTransaction {
|
||||
SqlUtil.buildCollectionQuery(
|
||||
column = ID,
|
||||
values = messageIds,
|
||||
prefix = "$IS_CALL_TYPE_CLAUSE AND ",
|
||||
collectionOperator = collectionOperator
|
||||
).map { query ->
|
||||
val threadSet = writableDatabase.select(ID)
|
||||
.from(TABLE_NAME)
|
||||
.where(query.where, query.whereArgs)
|
||||
.run()
|
||||
.readToSet { cursor ->
|
||||
cursor.requireLong(ID)
|
||||
}
|
||||
|
||||
val rows = writableDatabase
|
||||
.delete(TABLE_NAME)
|
||||
.where(query.where, query.whereArgs)
|
||||
.run()
|
||||
|
||||
if (rows <= 0) {
|
||||
Log.w(TAG, "Failed to delete some rows during call update deletion.")
|
||||
}
|
||||
|
||||
rowsDeleted += rows
|
||||
threadSet
|
||||
}.flatten().toSet()
|
||||
}
|
||||
|
||||
notifyConversationListeners(threadIds)
|
||||
notifyConversationListListeners()
|
||||
return rowsDeleted
|
||||
}
|
||||
|
||||
fun deleteMessage(messageId: Long): Boolean {
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
return deleteMessage(messageId, threadId)
|
||||
|
|
|
@ -58,4 +58,15 @@
|
|||
app:srcCompat="@drawable/symbol_phone_plus_24"
|
||||
app:tint="@color/signal_colorOnSurface" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.menu.SignalBottomActionBar
|
||||
android:id="@+id/bottom_action_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
@ -5724,7 +5724,7 @@
|
|||
<!-- Displayed as a context menu item to delete this call -->
|
||||
<string name="CallContextMenu__delete">Delete</string>
|
||||
|
||||
<!-- Call Log action bar menu -->
|
||||
<!-- Call Log Fragment -->
|
||||
<!-- Action bar menu item to only display missed calls -->
|
||||
<string name="CallLogFragment__filter_missed_calls">Filter missed calls</string>
|
||||
<!-- Action bar menu item to clear missed call filter -->
|
||||
|
@ -5737,6 +5737,22 @@
|
|||
<string name="CallLogFragment__start_a_new_call">Start a new call</string>
|
||||
<!-- Filter pull text when pulled -->
|
||||
<string name="CallLogFragment__filtered_by_missed">Filtered by missed</string>
|
||||
<!-- Bottom bar option to select all call entries -->
|
||||
<string name="CallLogFragment__select_all">Select all</string>
|
||||
<!-- Bottom bar option to delete all selected call entries -->
|
||||
<string name="CallLogFragment__delete">Delete</string>
|
||||
<!-- Title on multi-delete protection dialog (TODO - final terminology) -->
|
||||
<plurals name="CallLogFragment__delete_d_calls" translatable="false">
|
||||
<item quantity="one">Delete %1$d call?</item>
|
||||
<item quantity="other">Delete %1$d calls?</item>
|
||||
</plurals>
|
||||
<!-- Positive action on multi-delete protection dialog -->
|
||||
<string name="CallLogFragment__delete_for_me">Delete for me</string>
|
||||
<!-- Snackbar label after deleting call logs -->
|
||||
<plurals name="CallLogFragment__d_calls_deleted">
|
||||
<item quantity="one">%1$d call deleted</item>
|
||||
<item quantity="other">%1$d calls deleted</item>
|
||||
</plurals>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
|
|
|
@ -243,13 +243,19 @@ object SqlUtil {
|
|||
*/
|
||||
@JvmOverloads
|
||||
@JvmStatic
|
||||
fun buildCollectionQuery(column: String, values: Collection<Any?>, prefix: String = "", maxSize: Int = MAX_QUERY_ARGS): List<Query> {
|
||||
fun buildCollectionQuery(
|
||||
column: String,
|
||||
values: Collection<Any?>,
|
||||
prefix: String = "",
|
||||
maxSize: Int = MAX_QUERY_ARGS,
|
||||
collectionOperator: CollectionOperator = CollectionOperator.IN
|
||||
): List<Query> {
|
||||
return if (values.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
values
|
||||
.chunked(maxSize)
|
||||
.map { batch -> buildSingleCollectionQuery(column, batch, prefix) }
|
||||
.map { batch -> buildSingleCollectionQuery(column, batch, prefix, collectionOperator) }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -261,7 +267,12 @@ object SqlUtil {
|
|||
*/
|
||||
@JvmOverloads
|
||||
@JvmStatic
|
||||
fun buildSingleCollectionQuery(column: String, values: Collection<Any?>, prefix: String = ""): Query {
|
||||
fun buildSingleCollectionQuery(
|
||||
column: String,
|
||||
values: Collection<Any?>,
|
||||
prefix: String = "",
|
||||
collectionOperator: CollectionOperator = CollectionOperator.IN
|
||||
): Query {
|
||||
require(!values.isEmpty()) { "Must have values!" }
|
||||
|
||||
val query = StringBuilder()
|
||||
|
@ -276,7 +287,7 @@ object SqlUtil {
|
|||
}
|
||||
i++
|
||||
}
|
||||
return Query("$prefix $column IN ($query)".trim(), buildArgs(*args))
|
||||
return Query("$prefix $column ${collectionOperator.sql} ($query)".trim(), buildArgs(*args))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
@ -405,4 +416,9 @@ object SqlUtil {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class CollectionOperator(val sql: String) {
|
||||
IN("IN"),
|
||||
NOT_IN("NOT IN")
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue