Add call tab bottom bar.

This commit is contained in:
Alex Hart 2023-03-17 12:54:31 -03:00 committed by Greyson Parrelli
parent 545f1fa5a4
commit 8c0d979abd
14 changed files with 494 additions and 63 deletions

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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> {

View file

@ -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())
}
}

View file

@ -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()
}
}

View file

@ -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 {

View file

@ -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,

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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>

View file

@ -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 -->

View file

@ -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")
}
}