Ensure owned call links are revoked on delete.

This commit is contained in:
Alex Hart 2023-06-13 11:06:05 -03:00 committed by Cody Henthorne
parent 03a212eee4
commit 290b0fe46f
12 changed files with 334 additions and 126 deletions

View file

@ -75,12 +75,10 @@ class CallLogAdapter(
fun submitCallRows( fun submitCallRows(
rows: List<CallLogRow?>, rows: List<CallLogRow?>,
selectionState: CallLogSelectionState, selectionState: CallLogSelectionState,
stagedDeletion: CallLogStagedDeletion?,
onCommit: () -> Unit onCommit: () -> Unit
): Int { ): Int {
val filteredRows = rows val filteredRows = rows
.filterNotNull() .filterNotNull()
.filterNot { stagedDeletion?.isStagedForDeletion(it.id) == true }
.map { .map {
when (it) { when (it) {
is CallLogRow.Call -> CallModel(it, selectionState, itemCount) is CallLogRow.Call -> CallModel(it, selectionState, itemCount)

View file

@ -0,0 +1,14 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.log
sealed interface CallLogDeletionResult {
object Success : CallLogDeletionResult
object Empty : CallLogDeletionResult
data class FailedToRevoke(val failedRevocations: Int) : CallLogDeletionResult
data class UnknownFailure(val reason: Throwable) : CallLogDeletionResult
}

View file

@ -7,7 +7,9 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
@ -27,10 +29,13 @@ import io.reactivex.rxjava3.kotlin.Flowables
import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.DimensionUnit import org.signal.core.util.DimensionUnit
import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.addTo
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.calls.new.NewCallActivity import org.thoughtcrime.securesms.calls.new.NewCallActivity
import org.thoughtcrime.securesms.components.Material3SearchToolbar import org.thoughtcrime.securesms.components.Material3SearchToolbar
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.menu.ActionItem import org.thoughtcrime.securesms.components.menu.ActionItem
@ -65,6 +70,10 @@ import java.util.concurrent.TimeUnit
@SuppressLint("DiscouragedApi") @SuppressLint("DiscouragedApi")
class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Callbacks, CallLogContextMenu.Callbacks { class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Callbacks, CallLogContextMenu.Callbacks {
companion object {
private val TAG = Log.tag(CallLogFragment::class.java)
}
private val viewModel: CallLogViewModel by viewModels() private val viewModel: CallLogViewModel by viewModels()
private val binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind) private val binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind)
private val disposables = LifecycleDisposable() private val disposables = LifecycleDisposable()
@ -114,24 +123,23 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
) )
disposables += scrollToPositionDelegate disposables += scrollToPositionDelegate
disposables += Flowables.combineLatest(viewModel.data, viewModel.selectedAndStagedDeletion) disposables += Flowables.combineLatest(viewModel.data, viewModel.selected)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { (data, selected) -> .subscribe { (data, selected) ->
val filteredCount = adapter.submitCallRows( val filteredCount = adapter.submitCallRows(
data, data,
selected.first, selected,
selected.second,
scrollToPositionDelegate::notifyListCommitted scrollToPositionDelegate::notifyListCommitted
) )
binding.emptyState.visible = filteredCount == 0 binding.emptyState.visible = filteredCount == 0
} }
disposables += Flowables.combineLatest(viewModel.selectedAndStagedDeletion, viewModel.totalCount) disposables += Flowables.combineLatest(viewModel.selected, viewModel.totalCount)
.distinctUntilChanged() .distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { (selected, totalCount) -> .subscribe { (selected, totalCount) ->
if (selected.first.isNotEmpty(totalCount)) { if (selected.isNotEmpty(totalCount)) {
callLogActionMode.setCount(selected.first.count(totalCount)) callLogActionMode.setCount(selected.count(totalCount))
} else { } else {
callLogActionMode.end() callLogActionMode.end()
} }
@ -223,19 +231,8 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, count, count)) .setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, count, count))
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ -> .setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->
viewModel.stageSelectionDeletion() performDeletion(count, viewModel.stageSelectionDeletion())
callLogActionMode.end() callLogActionMode.end()
Snackbar
.make(
binding.root,
resources.getQuantityString(R.plurals.CallLogFragment__d_calls_deleted, count, count),
Snackbar.LENGTH_SHORT
)
.addCallback(SnackbarDeletionCallback())
.setAction(R.string.CallLogFragment__undo) {
viewModel.cancelStagedDeletion()
}
.show()
} }
.setNegativeButton(android.R.string.cancel) { _, _ -> } .setNegativeButton(android.R.string.cancel) { _, _ -> }
.show() .show()
@ -272,6 +269,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
scrollToPositionDelegate.resetScrollPosition() scrollToPositionDelegate.resetScrollPosition()
} }
} }
FilterPullState.OPENING -> { FilterPullState.OPENING -> {
ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight) ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight)
viewModel.setFilter(CallLogFilter.MISSED) viewModel.setFilter(CallLogFilter.MISSED)
@ -366,22 +364,10 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1)) .setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ -> .setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->
viewModel.stageCallDeletion(call) performDeletion(1, viewModel.stageCallDeletion(call))
Snackbar
.make(
binding.root,
resources.getQuantityString(R.plurals.CallLogFragment__d_calls_deleted, 1, 1),
Snackbar.LENGTH_SHORT
)
.addCallback(SnackbarDeletionCallback())
.setAction(R.string.CallLogFragment__undo) {
viewModel.cancelStagedDeletion()
} }
.show() .show()
} }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
private fun filterMissedCalls() { private fun filterMissedCalls() {
binding.pullView.toggle() binding.pullView.toggle()
@ -394,18 +380,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
.setMessage(R.string.CallLogFragment__this_will_permanently_delete_all_call_history) .setMessage(R.string.CallLogFragment__this_will_permanently_delete_all_call_history)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
callLogActionMode.end() callLogActionMode.end()
viewModel.stageDeleteAll() performDeletion(-1, viewModel.stageDeleteAll())
Snackbar
.make(
binding.root,
R.string.CallLogFragment__cleared_call_history,
Snackbar.LENGTH_SHORT
)
.addCallback(SnackbarDeletionCallback())
.setAction(R.string.CallLogFragment__undo) {
viewModel.cancelStagedDeletion()
}
.show()
} }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
@ -426,7 +401,59 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
private fun isSearchVisible(): Boolean { private fun isSearchVisible(): Boolean {
return requireListener<SearchBinder>().getSearchToolbar().resolved() && return requireListener<SearchBinder>().getSearchToolbar().resolved() &&
requireListener<SearchBinder>().getSearchToolbar().get().getVisibility() == View.VISIBLE requireListener<SearchBinder>().getSearchToolbar().get().visibility == View.VISIBLE
}
private fun performDeletion(count: Int, callLogStagedDeletion: CallLogStagedDeletion) {
var progressDialog: ProgressCardDialogFragment? = null
var errorDialog: AlertDialog? = null
fun cleanUp() {
progressDialog?.dismissAllowingStateLoss()
progressDialog = null
errorDialog?.dismiss()
errorDialog = null
}
val snackbarMessage = if (count == -1) {
getString(R.string.CallLogFragment__cleared_call_history)
} else {
resources.getQuantityString(R.plurals.CallLogFragment__d_calls_deleted, count, count)
}
viewModel.delete(callLogStagedDeletion)
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe {
progressDialog = ProgressCardDialogFragment.create(getString(R.string.CallLogFragment__deleting))
progressDialog?.show(parentFragmentManager, null)
}
.doOnDispose { cleanUp() }
.subscribeBy {
cleanUp()
when (it) {
CallLogDeletionResult.Empty -> Unit
is CallLogDeletionResult.FailedToRevoke -> {
errorDialog = MaterialAlertDialogBuilder(requireContext())
.setMessage(resources.getQuantityString(R.plurals.CallLogFragment__cant_delete_call_link, it.failedRevocations))
.setPositiveButton(R.string.ok, null)
.show()
}
CallLogDeletionResult.Success -> {
Snackbar
.make(
binding.root,
snackbarMessage,
Snackbar.LENGTH_SHORT
)
.show()
}
is CallLogDeletionResult.UnknownFailure -> {
Log.w(TAG, "Deletion failed.", it.reason)
Toast.makeText(requireContext(), R.string.CallLogFragment__deletion_failed, Toast.LENGTH_SHORT).show()
}
}
}
.addTo(disposables)
} }
private inner class BottomActionBarControllerCallback : SignalBottomActionBarController.Callback { private inner class BottomActionBarControllerCallback : SignalBottomActionBarController.Callback {
@ -454,12 +481,6 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
} }
} }
private inner class SnackbarDeletionCallback : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
viewModel.commitStagedDeletion()
}
}
interface Callback { interface Callback {
fun onMultiSelectStarted() fun onMultiSelectStarted()
fun onMultiSelectFinished() fun onMultiSelectFinished()

View file

@ -2,14 +2,20 @@ package org.thoughtcrime.securesms.calls.log
import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
import org.thoughtcrime.securesms.database.DatabaseObserver import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.CallLinkPeekJob import org.thoughtcrime.securesms.jobs.CallLinkPeekJob
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
class CallLogRepository : CallLogPagedDataSource.CallRepository { class CallLogRepository(
private val updateCallLinkRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
) : CallLogPagedDataSource.CallRepository {
override fun getCallsCount(query: String?, filter: CallLogFilter): Int { override fun getCallsCount(query: String?, filter: CallLogFilter): Int {
return SignalDatabase.calls.getCallsCount(query, filter) return SignalDatabase.calls.getCallsCount(query, filter)
} }
@ -61,7 +67,7 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
selectedCallRowIds: Set<Long> selectedCallRowIds: Set<Long>
): Completable { ): Completable {
return Completable.fromAction { return Completable.fromAction {
SignalDatabase.calls.deleteCallEvents(selectedCallRowIds) SignalDatabase.calls.deleteNonAdHocCallEvents(selectedCallRowIds)
}.observeOn(Schedulers.io()) }.observeOn(Schedulers.io())
} }
@ -70,7 +76,63 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
missedOnly: Boolean missedOnly: Boolean
): Completable { ): Completable {
return Completable.fromAction { return Completable.fromAction {
SignalDatabase.calls.deleteAllCallEventsExcept(selectedCallRowIds, missedOnly) SignalDatabase.calls.deleteAllNonAdHocCallEventsExcept(selectedCallRowIds, missedOnly)
}.observeOn(Schedulers.io())
}
/**
* Deletes the selected call links. We DELETE those links we don't have admin keys for,
* and revoke the ones we *do* have admin keys for. We then perform a cleanup step on
* terminate to clean up call events.
*/
fun deleteSelectedCallLinks(
selectedCallRowIds: Set<Long>,
selectedRoomIds: Set<CallLinkRoomId>
): Single<Int> {
return Single.fromCallable {
val allCallLinkIds = SignalDatabase.calls.getCallLinkRoomIdsFromCallRowIds(selectedCallRowIds) + selectedRoomIds
SignalDatabase.callLinks.deleteNonAdminCallLinks(allCallLinkIds)
SignalDatabase.callLinks.getAdminCallLinks(allCallLinkIds)
}.flatMap { callLinksToRevoke ->
Single.merge(
callLinksToRevoke.map {
updateCallLinkRepository.revokeCallLink(it.credentials!!)
}
).reduce(0) { acc, current ->
acc + (if (current is UpdateCallLinkResult.Success) 0 else 1)
}
}.doOnTerminate {
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
}.doOnDispose {
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
}.observeOn(Schedulers.io())
}
/**
* Deletes all but the selected call links. We DELETE those links we don't have admin keys for,
* and revoke the ones we *do* have admin keys for. We then perform a cleanup step on
* terminate to clean up call events.
*/
fun deleteAllCallLinksExcept(
selectedCallRowIds: Set<Long>,
selectedRoomIds: Set<CallLinkRoomId>
): Single<Int> {
return Single.fromCallable {
val allCallLinkIds = SignalDatabase.calls.getCallLinkRoomIdsFromCallRowIds(selectedCallRowIds) + selectedRoomIds
SignalDatabase.callLinks.deleteAllNonAdminCallLinksExcept(allCallLinkIds)
SignalDatabase.callLinks.getAllAdminCallLinksExcept(allCallLinkIds)
}.flatMap { callLinksToRevoke ->
Single.merge(
callLinksToRevoke.map {
updateCallLinkRepository.revokeCallLink(it.credentials!!)
}
).reduce(0) { acc, current ->
acc + (if (current is UpdateCallLinkResult.Success) 0 else 1)
}
}.doOnTerminate {
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
}.doOnDispose {
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
}.observeOn(Schedulers.io()) }.observeOn(Schedulers.io())
} }

View file

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.calls.log package org.thoughtcrime.securesms.calls.log
import androidx.annotation.MainThread import androidx.annotation.MainThread
import io.reactivex.rxjava3.core.Single
/** /**
* Encapsulates a single deletion action * Encapsulates a single deletion action
@ -13,19 +14,13 @@ class CallLogStagedDeletion(
private var isCommitted = false private var isCommitted = false
fun isStagedForDeletion(id: CallLogRow.Id): Boolean { /**
return stateSnapshot.contains(id) * Returns a Single<Int> which contains the number of failed call-link revocations.
} */
@MainThread @MainThread
fun cancel() { fun commit(): Single<Int> {
isCommitted = true
}
@MainThread
fun commit() {
if (isCommitted) { if (isCommitted) {
return return Single.just(0)
} }
isCommitted = true isCommitted = true
@ -35,10 +30,19 @@ class CallLogStagedDeletion(
.flatten() .flatten()
.toSet() .toSet()
if (stateSnapshot.isExclusionary()) { val callLinkIds = stateSnapshot.selected()
repository.deleteAllCallLogsExcept(callRowIds, filter == CallLogFilter.MISSED).subscribe() .filterIsInstance<CallLogRow.Id.CallLink>()
.map { it.roomId }
.toSet()
return if (stateSnapshot.isExclusionary()) {
repository.deleteAllCallLogsExcept(callRowIds, filter == CallLogFilter.MISSED).andThen(
repository.deleteAllCallLinksExcept(callRowIds, callLinkIds)
)
} else { } else {
repository.deleteSelectedCallLogs(callRowIds).subscribe() repository.deleteSelectedCallLogs(callRowIds).andThen(
repository.deleteSelectedCallLinks(callRowIds, callLinkIds)
)
} }
} }
} }

View file

@ -1,9 +1,11 @@
package org.thoughtcrime.securesms.calls.log package org.thoughtcrime.securesms.calls.log
import android.annotation.SuppressLint
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.plusAssign
@ -36,9 +38,7 @@ class CallLogViewModel(
val controller = ProxyPagingController<CallLogRow.Id>() val controller = ProxyPagingController<CallLogRow.Id>()
val data: Flowable<MutableList<CallLogRow?>> = pagedData.switchMap { it.data.toFlowable(BackpressureStrategy.LATEST) } val data: Flowable<MutableList<CallLogRow?>> = pagedData.switchMap { it.data.toFlowable(BackpressureStrategy.LATEST) }
val selectedAndStagedDeletion: Flowable<Pair<CallLogSelectionState, CallLogStagedDeletion?>> = callLogStore val selected: Flowable<CallLogSelectionState> = callLogStore.stateFlowable.map { it.selectionState }
.stateFlowable
.map { it.selectionState to it.stagedDeletion }
private val _isEmpty: BehaviorProcessor<Boolean> = BehaviorProcessor.createDefault(false) private val _isEmpty: BehaviorProcessor<Boolean> = BehaviorProcessor.createDefault(false)
val isEmpty: Boolean get() = _isEmpty.value ?: false val isEmpty: Boolean get() = _isEmpty.value ?: false
@ -98,7 +98,6 @@ class CallLogViewModel(
} }
override fun onCleared() { override fun onCleared() {
commitStagedDeletion()
disposables.dispose() disposables.dispose()
} }
@ -121,63 +120,52 @@ class CallLogViewModel(
} }
@MainThread @MainThread
fun stageCallDeletion(call: CallLogRow) { fun stageCallDeletion(call: CallLogRow): CallLogStagedDeletion {
callLogStore.state.stagedDeletion?.commit() return CallLogStagedDeletion(
callLogStore.update { callLogStore.state.filter,
it.copy(
stagedDeletion = CallLogStagedDeletion(
it.filter,
CallLogSelectionState.empty().toggle(call.id), CallLogSelectionState.empty().toggle(call.id),
callLogRepository callLogRepository
) )
)
}
} }
@MainThread @MainThread
fun stageSelectionDeletion() { fun stageSelectionDeletion(): CallLogStagedDeletion {
callLogStore.state.stagedDeletion?.commit() return CallLogStagedDeletion(
callLogStore.update { callLogStore.state.filter,
it.copy( callLogStore.state.selectionState,
stagedDeletion = CallLogStagedDeletion(
it.filter,
it.selectionState,
callLogRepository callLogRepository
) )
)
}
} }
fun stageDeleteAll() { fun stageDeleteAll(): CallLogStagedDeletion {
callLogStore.state.stagedDeletion?.cancel()
callLogStore.update { callLogStore.update {
it.copy( it.copy(
selectionState = CallLogSelectionState.empty(), selectionState = CallLogSelectionState.empty()
stagedDeletion = CallLogStagedDeletion( )
it.filter, }
return CallLogStagedDeletion(
callLogStore.state.filter,
CallLogSelectionState.selectAll(), CallLogSelectionState.selectAll(),
callLogRepository callLogRepository
) )
)
}
} }
fun commitStagedDeletion() { @SuppressLint("CheckResult")
callLogStore.state.stagedDeletion?.commit() fun delete(stagedDeletion: CallLogStagedDeletion): Maybe<CallLogDeletionResult> {
callLogStore.update { return stagedDeletion.commit()
it.copy( .doOnSubscribe {
stagedDeletion = null clearSelected()
) }
.map { failedRevocations ->
if (failedRevocations == 0) {
CallLogDeletionResult.Success
} else {
CallLogDeletionResult.FailedToRevoke(failedRevocations)
} }
} }
.onErrorReturn { CallLogDeletionResult.UnknownFailure(it) }
fun cancelStagedDeletion() { .toMaybe()
callLogStore.state.stagedDeletion?.cancel()
callLogStore.update {
it.copy(
stagedDeletion = null
)
}
} }
fun clearSelected() { fun clearSelected() {
@ -197,7 +185,6 @@ class CallLogViewModel(
private data class CallLogState( private data class CallLogState(
val query: String? = null, val query: String? = null,
val filter: CallLogFilter = CallLogFilter.ALL, val filter: CallLogFilter = CallLogFilter.ALL,
val selectionState: CallLogSelectionState = CallLogSelectionState.empty(), val selectionState: CallLogSelectionState = CallLogSelectionState.empty()
val stagedDeletion: CallLogStagedDeletion? = null
) )
} }

View file

@ -14,6 +14,14 @@ import org.thoughtcrime.securesms.R
*/ */
class ProgressCardDialogFragment : DialogFragment(R.layout.progress_card_dialog) { class ProgressCardDialogFragment : DialogFragment(R.layout.progress_card_dialog) {
companion object {
fun create(title: String): ProgressCardDialogFragment {
return ProgressCardDialogFragment().apply {
arguments = ProgressCardDialogFragmentArgs.Builder(title).build().toBundle()
}
}
}
private val args: ProgressCardDialogFragmentArgs by navArgs() private val args: ProgressCardDialogFragmentArgs by navArgs()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

View file

@ -6,6 +6,7 @@ import android.database.Cursor
import androidx.core.content.contentValuesOf import androidx.core.content.contentValuesOf
import org.signal.core.util.Serializer import org.signal.core.util.Serializer
import org.signal.core.util.SqlUtil import org.signal.core.util.SqlUtil
import org.signal.core.util.delete
import org.signal.core.util.insertInto import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.core.util.readToList import org.signal.core.util.readToList
@ -32,7 +33,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.util.Base64
import java.time.Instant import java.time.Instant
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
@ -221,6 +221,64 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
} }
} }
fun deleteNonAdminCallLinks(roomIds: Set<CallLinkRoomId>) {
val queries = SqlUtil.buildCollectionQuery(ROOM_ID, roomIds)
queries.forEach {
writableDatabase.delete(TABLE_NAME)
.where("${it.where} AND $ADMIN_KEY IS NULL", it.whereArgs)
.run()
}
}
fun getAdminCallLinks(roomIds: Set<CallLinkRoomId>): Set<CallLink> {
val queries = SqlUtil.buildCollectionQuery(ROOM_ID, roomIds)
return queries.map {
writableDatabase
.select()
.from(TABLE_NAME)
.where("${it.where} AND $ADMIN_KEY IS NOT NULL", it.whereArgs)
.run()
.readToList { CallLinkDeserializer.deserialize(it) }
}.flatten().toSet()
}
fun deleteAllNonAdminCallLinksExcept(roomIds: Set<CallLinkRoomId>) {
if (roomIds.isEmpty()) {
writableDatabase.delete(TABLE_NAME)
.where("$ADMIN_KEY IS NULL")
.run()
} else {
SqlUtil.buildCollectionQuery(ROOM_ID, roomIds, collectionOperator = SqlUtil.CollectionOperator.NOT_IN).forEach {
writableDatabase.delete(TABLE_NAME)
.where("${it.where} AND $ADMIN_KEY IS NULL", it.whereArgs)
.run()
}
}
}
fun getAllAdminCallLinksExcept(roomIds: Set<CallLinkRoomId>): Set<CallLink> {
return if (roomIds.isEmpty()) {
writableDatabase
.select()
.from(TABLE_NAME)
.where("$ADMIN_KEY IS NOT NULL")
.run()
.readToList { CallLinkDeserializer.deserialize(it) }
.toSet()
} else {
SqlUtil.buildCollectionQuery(ROOM_ID, roomIds, collectionOperator = SqlUtil.CollectionOperator.NOT_IN).map {
writableDatabase
.select()
.from(TABLE_NAME)
.where("${it.where} AND $ADMIN_KEY IS NOT NULL", it.whereArgs)
.run()
.readToList { CallLinkDeserializer.deserialize(it) }
}.flatten().toSet()
}
}
private fun queryCallLinks(query: String?, offset: Int, limit: Int, asCount: Boolean): Cursor { private fun queryCallLinks(query: String?, offset: Int, limit: Int, asCount: Boolean): Cursor {
//language=sql //language=sql
val noCallEvent = """ val noCallEvent = """
@ -289,7 +347,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
override fun deserialize(data: Cursor): CallLink { override fun deserialize(data: Cursor): CallLink {
return CallLink( return CallLink(
recipientId = data.requireLong(RECIPIENT_ID).let { if (it > 0) RecipientId.from(it) else RecipientId.UNKNOWN }, recipientId = data.requireLong(RECIPIENT_ID).let { if (it > 0) RecipientId.from(it) else RecipientId.UNKNOWN },
roomId = CallLinkRoomId.fromBytes(Base64.decode(data.requireNonNullString(ROOM_ID))), roomId = CallLinkRoomId.DatabaseSerializer.deserialize(data.requireNonNullString(ROOM_ID)),
credentials = CallLinkCredentials( credentials = CallLinkCredentials(
linkKeyBytes = data.requireNonNullBlob(ROOT_KEY), linkKeyBytes = data.requireNonNullBlob(ROOT_KEY),
adminPassBytes = data.requireBlob(ADMIN_KEY) adminPassBytes = data.requireBlob(ADMIN_KEY)

View file

@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.CallSyncEventJob import org.thoughtcrime.securesms.jobs.CallSyncEventJob
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.whispersystems.signalservice.api.push.ServiceId import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent
import java.util.UUID import java.util.UUID
@ -207,6 +208,48 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
.run() .run()
} }
fun getCallLinkRoomIdsFromCallRowIds(callRowIds: Set<Long>): Set<CallLinkRoomId> {
return SqlUtil.buildCollectionQuery("$TABLE_NAME.$ID", callRowIds).map { query ->
//language=sql
val statement = """
SELECT ${CallLinkTable.ROOM_ID} FROM $TABLE_NAME
INNER JOIN ${CallLinkTable.TABLE_NAME} ON ${CallLinkTable.TABLE_NAME}.${CallLinkTable.RECIPIENT_ID} = $PEER
WHERE $TYPE = ${Type.serialize(Type.AD_HOC_CALL)} AND ${query.where}
""".toSingleLine()
readableDatabase.query(statement, query.whereArgs).readToList {
CallLinkRoomId.DatabaseSerializer.deserialize(it.requireNonNullString(CallLinkTable.ROOM_ID))
}
}.flatten().toSet()
}
/**
* If a call link has been revoked, or if we do not have a CallLink table entry for an AD_HOC_CALL type
* event, we mark it deleted.
*/
fun updateAdHocCallEventDeletionTimestamps() {
//language=sql
val statement = """
UPDATE $TABLE_NAME
SET $DELETION_TIMESTAMP = ${System.currentTimeMillis()}, $EVENT = ${Event.serialize(Event.DELETE)}
WHERE $TYPE = ${Type.serialize(Type.AD_HOC_CALL)}
AND (
(NOT EXISTS (SELECT 1 FROM ${CallLinkTable.TABLE_NAME} WHERE ${CallLinkTable.RECIPIENT_ID} = $PEER))
OR
(SELECT ${CallLinkTable.REVOKED} FROM ${CallLinkTable.TABLE_NAME} WHERE ${CallLinkTable.RECIPIENT_ID} = $PEER)
)
RETURNING *
""".toSingleLine()
val toSync = writableDatabase.query(statement).readToList {
Call.deserialize(it)
}.toSet()
CallSyncEventJob.enqueueDeleteSyncEvents(toSync)
ApplicationDependencies.getDeletedCallEventManager().scheduleIfNecessary()
ApplicationDependencies.getDatabaseObserver().notifyCallUpdateObservers()
}
/** /**
* If a non-ad-hoc call has been deleted from the message database, then we need to * If a non-ad-hoc call has been deleted from the message database, then we need to
* set its deletion_timestamp to now. * set its deletion_timestamp to now.
@ -706,13 +749,13 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
.run() .run()
} }
fun deleteCallEvents(callRowIds: Set<Long>) { fun deleteNonAdHocCallEvents(callRowIds: Set<Long>) {
val messageIds = getMessageIds(callRowIds) val messageIds = getMessageIds(callRowIds)
SignalDatabase.messages.deleteCallUpdates(messageIds) SignalDatabase.messages.deleteCallUpdates(messageIds)
updateCallEventDeletionTimestamps() updateCallEventDeletionTimestamps()
} }
fun deleteAllCallEventsExcept(callRowIds: Set<Long>, missedOnly: Boolean) { fun deleteAllNonAdHocCallEventsExcept(callRowIds: Set<Long>, missedOnly: Boolean) {
val callFilter = if (missedOnly) { val callFilter = if (missedOnly) {
"$EVENT = ${Event.serialize(Event.MISSED)} AND $DELETION_TIMESTAMP = 0" "$EVENT = ${Event.serialize(Event.MISSED)} AND $DELETION_TIMESTAMP = 0"
} else { } else {

View file

@ -32,6 +32,10 @@ class CallLinkRoomId private constructor(private val roomId: ByteArray) : Parcel
return roomId.contentHashCode() return roomId.contentHashCode()
} }
override fun toString(): String {
return DatabaseSerializer.serialize(this)
}
object DatabaseSerializer : Serializer<CallLinkRoomId, String> { object DatabaseSerializer : Serializer<CallLinkRoomId, String> {
override fun serialize(data: CallLinkRoomId): String { override fun serialize(data: CallLinkRoomId): String {
return Base64.encodeBytes(data.roomId) return Base64.encodeBytes(data.roomId)

View file

@ -85,9 +85,9 @@
android:focusable="true" android:focusable="true"
android:theme="@style/Widget.Material3.FloatingActionButton.Secondary" android:theme="@style/Widget.Material3.FloatingActionButton.Secondary"
android:transitionName="camera_fab" android:transitionName="camera_fab"
app:shapeAppearanceOverlay="@style/Signal.ShapeOverlay.Rounded.Fab"
app:backgroundTint="@color/signal_colorSurfaceVariant" app:backgroundTint="@color/signal_colorSurfaceVariant"
app:elevation="0dp" app:elevation="0dp"
app:shapeAppearanceOverlay="@style/Signal.ShapeOverlay.Rounded.Fab"
app:srcCompat="@drawable/ic_camera_outline_24" app:srcCompat="@drawable/ic_camera_outline_24"
app:tint="@color/signal_colorOnSurface" /> app:tint="@color/signal_colorOnSurface" />

View file

@ -5975,6 +5975,15 @@
<string name="CallContextMenu__delete">Delete</string> <string name="CallContextMenu__delete">Delete</string>
<!-- Call Log Fragment --> <!-- Call Log Fragment -->
<!-- Displayed when deleting call history items -->
<string name="CallLogFragment__deleting">Deleting…</string>
<!-- Displayed in a toast when a deletion fails for an unknown reason -->
<string name="CallLogFragment__deletion_failed">Deletion failed.</string>
<!-- Displayed as message in error dialog when can't delete links -->
<plurals name="CallLogFragment__cant_delete_call_link">
<item quantity="one">Can\'t delete link. Check your connection and try again.</item>
<item quantity="other">Not all call links could be deleted. Check your connection and try again.</item>
</plurals>
<!-- Snackbar text after clearing the call history --> <!-- Snackbar text after clearing the call history -->
<string name="CallLogFragment__cleared_call_history">Cleared call history</string> <string name="CallLogFragment__cleared_call_history">Cleared call history</string>
<!-- Dialog title to clear all call events --> <!-- Dialog title to clear all call events -->