diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogActionMode.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogActionMode.kt index cc9c52595d..8dc9eb1e89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogActionMode.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogActionMode.kt @@ -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().onMultiSelectStarted() + actionMode = callback.startActionMode(this) } fun end() { - fragment.requireListener().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() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt index a00889b715..5b8d04ecfe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt @@ -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, selectionState: CallLogSelectionState) { + fun submitCallRows(rows: List, 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(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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt index d06cf72ad4..0cf6287069 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt @@ -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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt index 2d42286bb8..2afa5ceaa2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -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().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.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().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().onMultiSelectStarted() + signalBottomActionBarController.setVisibility(true) + return actionMode + } + + override fun onActionModeWillEnd() { + requireListener().onMultiSelectFinished() + signalBottomActionBarController.setVisibility(false) + } + + override fun getResources(): Resources = resources + override fun onResetSelectionState() { + viewModel.clearSelected() + } + } + interface Callback { fun onMultiSelectStarted() fun onMultiSelectFinished() diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt index 20d5be5521..f840d1c2b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt index 97fe4369f6..c440c9bf9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt @@ -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 { return SignalDatabase.calls.getCalls(start, length, query, filter) } + + fun listenForChanges(): Observable { + 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 + ): Single { + return Single.fromCallable { + SignalDatabase.messages.deleteCallUpdates(selectedMessageIds) + }.observeOn(Schedulers.io()) + } + + fun deleteAllCallLogsExcept( + selectedMessageIds: Set + ): Single { + return Single.fromCallable { + SignalDatabase.messages.deleteAllCallUpdatesExcept(selectedMessageIds) + }.observeOn(Schedulers.io()) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt index b279cb7b45..81cabd964c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt @@ -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() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogSelectionState.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogSelectionState.kt index 6dbcad8a73..847e363527 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogSelectionState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogSelectionState.kt @@ -9,6 +9,9 @@ sealed class CallLogSelectionState { abstract fun count(totalCount: Int): Int + abstract fun selected(): Set + 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 { + return includes + } } /** @@ -63,6 +70,8 @@ sealed class CallLogSelectionState { override fun deselect(callId: CallLogRow.Id): CallLogSelectionState { return Excludes(excluded + callId) } + + override fun selected(): Set = excluded } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt index b5d8fe232a..16f398d5e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt @@ -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> = callLogStore - .stateFlowable - .toObservable() - .map { (query, filter) -> - PagedData.createForObservable( - CallLogPagedDataSource(query, filter, callLogRepository), - pagingConfig - ) - } - val controller: Observable> = pagedData.map { it.controller } - val data: Observable> = pagedData.switchMap { it.data } - val selected: Observable = callLogStore + private val disposables = CompositeDisposable() + private val pagedData: BehaviorProcessor> = BehaviorProcessor.create() + + private val distinctQueryFilterPairs = callLogStore + .stateFlowable + .map { (query, filter) -> Pair(query, filter) } + .distinctUntilChanged() + + val controller = ProxyPagingController() + val data: Flowable> = pagedData.switchMap { it.data.toFlowable(BackpressureStrategy.LATEST) } + val selected: Flowable = callLogStore .stateFlowable - .toObservable() .map { it.selectionState } + val totalCount: Flowable = 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 { + 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 { + val stateSnapshot = callLogStore.state + val messageIds: Set = stateSnapshot.selectionState.selected() + .filterIsInstance() + .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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/SignalBottomActionBarController.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/SignalBottomActionBarController.kt new file mode 100644 index 0000000000..b9d2bba3ca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/SignalBottomActionBarController.kt @@ -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 { + 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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 7fee8921a9..8fe47ef77e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -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): Int { + return deleteCallUpdatesInternal(messageIds, SqlUtil.CollectionOperator.IN) + } + + /** + * Deletes all call updates except for those specified in the parameter. + */ + fun deleteAllCallUpdatesExcept(excludedMessageIds: Set): Int { + return deleteCallUpdatesInternal(excludedMessageIds, SqlUtil.CollectionOperator.NOT_IN) + } + + private fun deleteCallUpdatesInternal(messageIds: Set, collectionOperator: SqlUtil.CollectionOperator): Int { + var rowsDeleted = 0 + val threadIds: Set = 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) diff --git a/app/src/main/res/layout/call_log_fragment.xml b/app/src/main/res/layout/call_log_fragment.xml index 02a5d45f36..de9cf8bc08 100644 --- a/app/src/main/res/layout/call_log_fragment.xml +++ b/app/src/main/res/layout/call_log_fragment.xml @@ -58,4 +58,15 @@ app:srcCompat="@drawable/symbol_phone_plus_24" app:tint="@color/signal_colorOnSurface" /> + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ecc4140b7..69bb06db31 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5724,7 +5724,7 @@ Delete - + Filter missed calls @@ -5737,6 +5737,22 @@ Start a new call Filtered by missed + + Select all + + Delete + + + Delete %1$d call? + Delete %1$d calls? + + + Delete for me + + + %1$d call deleted + %1$d calls deleted + diff --git a/core-util/src/main/java/org/signal/core/util/SqlUtil.kt b/core-util/src/main/java/org/signal/core/util/SqlUtil.kt index 18af0d3163..87d1c876b5 100644 --- a/core-util/src/main/java/org/signal/core/util/SqlUtil.kt +++ b/core-util/src/main/java/org/signal/core/util/SqlUtil.kt @@ -243,13 +243,19 @@ object SqlUtil { */ @JvmOverloads @JvmStatic - fun buildCollectionQuery(column: String, values: Collection, prefix: String = "", maxSize: Int = MAX_QUERY_ARGS): List { + fun buildCollectionQuery( + column: String, + values: Collection, + prefix: String = "", + maxSize: Int = MAX_QUERY_ARGS, + collectionOperator: CollectionOperator = CollectionOperator.IN + ): List { 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, prefix: String = ""): Query { + fun buildSingleCollectionQuery( + column: String, + values: Collection, + 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") + } }