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 b0c8220620..97dfbc5939 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 @@ -75,12 +75,10 @@ class CallLogAdapter( fun submitCallRows( rows: List, selectionState: CallLogSelectionState, - stagedDeletion: CallLogStagedDeletion?, onCommit: () -> Unit ): Int { val filteredRows = rows .filterNotNull() - .filterNot { stagedDeletion?.isStagedForDeletion(it.id) == true } .map { when (it) { is CallLogRow.Call -> CallModel(it, selectionState, itemCount) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogDeletionResult.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogDeletionResult.kt new file mode 100644 index 0000000000..0306fc60be --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogDeletionResult.kt @@ -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 +} 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 305c2220fe..a351a97ab6 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 @@ -7,7 +7,9 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View +import android.widget.Toast import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.coordinatorlayout.widget.CoordinatorLayout @@ -27,10 +29,13 @@ import io.reactivex.rxjava3.kotlin.Flowables import io.reactivex.rxjava3.kotlin.subscribeBy import org.signal.core.util.DimensionUnit 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.calls.links.details.CallLinkDetailsActivity import org.thoughtcrime.securesms.calls.new.NewCallActivity import org.thoughtcrime.securesms.components.Material3SearchToolbar +import org.thoughtcrime.securesms.components.ProgressCardDialogFragment import org.thoughtcrime.securesms.components.ScrollToPositionDelegate import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.components.menu.ActionItem @@ -65,6 +70,10 @@ import java.util.concurrent.TimeUnit @SuppressLint("DiscouragedApi") 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 binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind) private val disposables = LifecycleDisposable() @@ -114,24 +123,23 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal ) disposables += scrollToPositionDelegate - disposables += Flowables.combineLatest(viewModel.data, viewModel.selectedAndStagedDeletion) + disposables += Flowables.combineLatest(viewModel.data, viewModel.selected) .observeOn(AndroidSchedulers.mainThread()) .subscribe { (data, selected) -> val filteredCount = adapter.submitCallRows( data, - selected.first, - selected.second, + selected, scrollToPositionDelegate::notifyListCommitted ) binding.emptyState.visible = filteredCount == 0 } - disposables += Flowables.combineLatest(viewModel.selectedAndStagedDeletion, viewModel.totalCount) + disposables += Flowables.combineLatest(viewModel.selected, viewModel.totalCount) .distinctUntilChanged() .observeOn(AndroidSchedulers.mainThread()) .subscribe { (selected, totalCount) -> - if (selected.first.isNotEmpty(totalCount)) { - callLogActionMode.setCount(selected.first.count(totalCount)) + if (selected.isNotEmpty(totalCount)) { + callLogActionMode.setCount(selected.count(totalCount)) } else { callLogActionMode.end() } @@ -223,19 +231,8 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal MaterialAlertDialogBuilder(requireContext()) .setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, count, count)) .setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ -> - viewModel.stageSelectionDeletion() + performDeletion(count, viewModel.stageSelectionDeletion()) 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) { _, _ -> } .show() @@ -272,6 +269,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal scrollToPositionDelegate.resetScrollPosition() } } + FilterPullState.OPENING -> { ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight) viewModel.setFilter(CallLogFilter.MISSED) @@ -366,20 +364,8 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal MaterialAlertDialogBuilder(requireContext()) .setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1)) .setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ -> - 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() + performDeletion(1, viewModel.stageCallDeletion(call)) } - .setNegativeButton(android.R.string.cancel) { _, _ -> } .show() } @@ -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) .setPositiveButton(android.R.string.ok) { _, _ -> callLogActionMode.end() - 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() + performDeletion(-1, viewModel.stageDeleteAll()) } .setNegativeButton(android.R.string.cancel, null) .show() @@ -426,7 +401,59 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal private fun isSearchVisible(): Boolean { return requireListener().getSearchToolbar().resolved() && - requireListener().getSearchToolbar().get().getVisibility() == View.VISIBLE + requireListener().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 { @@ -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 { fun onMultiSelectStarted() fun onMultiSelectFinished() 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 4d7dd0d8d0..334b69d4d9 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 @@ -2,14 +2,20 @@ package org.thoughtcrime.securesms.calls.log import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers 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.SignalDatabase import org.thoughtcrime.securesms.dependencies.ApplicationDependencies 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 { return SignalDatabase.calls.getCallsCount(query, filter) } @@ -61,7 +67,7 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository { selectedCallRowIds: Set ): Completable { return Completable.fromAction { - SignalDatabase.calls.deleteCallEvents(selectedCallRowIds) + SignalDatabase.calls.deleteNonAdHocCallEvents(selectedCallRowIds) }.observeOn(Schedulers.io()) } @@ -70,7 +76,63 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository { missedOnly: Boolean ): Completable { 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, + selectedRoomIds: Set + ): Single { + 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, + selectedRoomIds: Set + ): Single { + 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()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogStagedDeletion.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogStagedDeletion.kt index 53397f49f2..d6523e67a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogStagedDeletion.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogStagedDeletion.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.calls.log import androidx.annotation.MainThread +import io.reactivex.rxjava3.core.Single /** * Encapsulates a single deletion action @@ -13,19 +14,13 @@ class CallLogStagedDeletion( private var isCommitted = false - fun isStagedForDeletion(id: CallLogRow.Id): Boolean { - return stateSnapshot.contains(id) - } - + /** + * Returns a Single which contains the number of failed call-link revocations. + */ @MainThread - fun cancel() { - isCommitted = true - } - - @MainThread - fun commit() { + fun commit(): Single { if (isCommitted) { - return + return Single.just(0) } isCommitted = true @@ -35,10 +30,19 @@ class CallLogStagedDeletion( .flatten() .toSet() - if (stateSnapshot.isExclusionary()) { - repository.deleteAllCallLogsExcept(callRowIds, filter == CallLogFilter.MISSED).subscribe() + val callLinkIds = stateSnapshot.selected() + .filterIsInstance() + .map { it.roomId } + .toSet() + + return if (stateSnapshot.isExclusionary()) { + repository.deleteAllCallLogsExcept(callRowIds, filter == CallLogFilter.MISSED).andThen( + repository.deleteAllCallLinksExcept(callRowIds, callLinkIds) + ) } else { - repository.deleteSelectedCallLogs(callRowIds).subscribe() + repository.deleteSelectedCallLogs(callRowIds).andThen( + repository.deleteSelectedCallLinks(callRowIds, callLinkIds) + ) } } } 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 36203fbf17..91529fa222 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,9 +1,11 @@ package org.thoughtcrime.securesms.calls.log +import android.annotation.SuppressLint import androidx.annotation.MainThread import androidx.lifecycle.ViewModel import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Maybe import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign @@ -36,9 +38,7 @@ class CallLogViewModel( val controller = ProxyPagingController() val data: Flowable> = pagedData.switchMap { it.data.toFlowable(BackpressureStrategy.LATEST) } - val selectedAndStagedDeletion: Flowable> = callLogStore - .stateFlowable - .map { it.selectionState to it.stagedDeletion } + val selected: Flowable = callLogStore.stateFlowable.map { it.selectionState } private val _isEmpty: BehaviorProcessor = BehaviorProcessor.createDefault(false) val isEmpty: Boolean get() = _isEmpty.value ?: false @@ -98,7 +98,6 @@ class CallLogViewModel( } override fun onCleared() { - commitStagedDeletion() disposables.dispose() } @@ -121,63 +120,52 @@ class CallLogViewModel( } @MainThread - fun stageCallDeletion(call: CallLogRow) { - callLogStore.state.stagedDeletion?.commit() - callLogStore.update { - it.copy( - stagedDeletion = CallLogStagedDeletion( - it.filter, - CallLogSelectionState.empty().toggle(call.id), - callLogRepository - ) - ) - } + fun stageCallDeletion(call: CallLogRow): CallLogStagedDeletion { + return CallLogStagedDeletion( + callLogStore.state.filter, + CallLogSelectionState.empty().toggle(call.id), + callLogRepository + ) } @MainThread - fun stageSelectionDeletion() { - callLogStore.state.stagedDeletion?.commit() - callLogStore.update { - it.copy( - stagedDeletion = CallLogStagedDeletion( - it.filter, - it.selectionState, - callLogRepository - ) - ) - } + fun stageSelectionDeletion(): CallLogStagedDeletion { + return CallLogStagedDeletion( + callLogStore.state.filter, + callLogStore.state.selectionState, + callLogRepository + ) } - fun stageDeleteAll() { - callLogStore.state.stagedDeletion?.cancel() + fun stageDeleteAll(): CallLogStagedDeletion { callLogStore.update { it.copy( - selectionState = CallLogSelectionState.empty(), - stagedDeletion = CallLogStagedDeletion( - it.filter, - CallLogSelectionState.selectAll(), - callLogRepository - ) + selectionState = CallLogSelectionState.empty() ) } + + return CallLogStagedDeletion( + callLogStore.state.filter, + CallLogSelectionState.selectAll(), + callLogRepository + ) } - fun commitStagedDeletion() { - callLogStore.state.stagedDeletion?.commit() - callLogStore.update { - it.copy( - stagedDeletion = null - ) - } - } - - fun cancelStagedDeletion() { - callLogStore.state.stagedDeletion?.cancel() - callLogStore.update { - it.copy( - stagedDeletion = null - ) - } + @SuppressLint("CheckResult") + fun delete(stagedDeletion: CallLogStagedDeletion): Maybe { + return stagedDeletion.commit() + .doOnSubscribe { + clearSelected() + } + .map { failedRevocations -> + if (failedRevocations == 0) { + CallLogDeletionResult.Success + } else { + CallLogDeletionResult.FailedToRevoke(failedRevocations) + } + } + .onErrorReturn { CallLogDeletionResult.UnknownFailure(it) } + .toMaybe() } fun clearSelected() { @@ -197,7 +185,6 @@ class CallLogViewModel( private data class CallLogState( val query: String? = null, val filter: CallLogFilter = CallLogFilter.ALL, - val selectionState: CallLogSelectionState = CallLogSelectionState.empty(), - val stagedDeletion: CallLogStagedDeletion? = null + val selectionState: CallLogSelectionState = CallLogSelectionState.empty() ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProgressCardDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProgressCardDialogFragment.kt index a3e51d0a2c..54c151f6b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProgressCardDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProgressCardDialogFragment.kt @@ -14,6 +14,14 @@ import org.thoughtcrime.securesms.R */ 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() override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt index 707ffde86b..f20a9bac86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt @@ -6,6 +6,7 @@ import android.database.Cursor import androidx.core.content.contentValuesOf import org.signal.core.util.Serializer import org.signal.core.util.SqlUtil +import org.signal.core.util.delete import org.signal.core.util.insertInto import org.signal.core.util.logging.Log 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.CallLinkRoomId import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState -import org.thoughtcrime.securesms.util.Base64 import java.time.Instant import java.time.temporal.ChronoUnit @@ -221,6 +221,64 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database } } + fun deleteNonAdminCallLinks(roomIds: Set) { + 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): Set { + 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) { + 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): Set { + 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 { //language=sql val noCallEvent = """ @@ -289,7 +347,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database override fun deserialize(data: Cursor): CallLink { return CallLink( 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( linkKeyBytes = data.requireNonNullBlob(ROOT_KEY), adminPassBytes = data.requireBlob(ADMIN_KEY) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt index b325753524..51e03148dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobs.CallSyncEventJob import org.thoughtcrime.securesms.recipients.Recipient 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.internal.push.SignalServiceProtos.SyncMessage.CallEvent import java.util.UUID @@ -207,6 +208,48 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl .run() } + fun getCallLinkRoomIdsFromCallRowIds(callRowIds: Set): Set { + 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 * set its deletion_timestamp to now. @@ -706,13 +749,13 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl .run() } - fun deleteCallEvents(callRowIds: Set) { + fun deleteNonAdHocCallEvents(callRowIds: Set) { val messageIds = getMessageIds(callRowIds) SignalDatabase.messages.deleteCallUpdates(messageIds) updateCallEventDeletionTimestamps() } - fun deleteAllCallEventsExcept(callRowIds: Set, missedOnly: Boolean) { + fun deleteAllNonAdHocCallEventsExcept(callRowIds: Set, missedOnly: Boolean) { val callFilter = if (missedOnly) { "$EVENT = ${Event.serialize(Event.MISSED)} AND $DELETION_TIMESTAMP = 0" } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkRoomId.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkRoomId.kt index 98285a7283..f6194730f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkRoomId.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkRoomId.kt @@ -32,6 +32,10 @@ class CallLinkRoomId private constructor(private val roomId: ByteArray) : Parcel return roomId.contentHashCode() } + override fun toString(): String { + return DatabaseSerializer.serialize(this) + } + object DatabaseSerializer : Serializer { override fun serialize(data: CallLinkRoomId): String { return Base64.encodeBytes(data.roomId) diff --git a/app/src/main/res/layout/call_log_fragment.xml b/app/src/main/res/layout/call_log_fragment.xml index c0f44fa46c..7db4467cb3 100644 --- a/app/src/main/res/layout/call_log_fragment.xml +++ b/app/src/main/res/layout/call_log_fragment.xml @@ -85,9 +85,9 @@ android:focusable="true" android:theme="@style/Widget.Material3.FloatingActionButton.Secondary" android:transitionName="camera_fab" - app:shapeAppearanceOverlay="@style/Signal.ShapeOverlay.Rounded.Fab" app:backgroundTint="@color/signal_colorSurfaceVariant" app:elevation="0dp" + app:shapeAppearanceOverlay="@style/Signal.ShapeOverlay.Rounded.Fab" app:srcCompat="@drawable/ic_camera_outline_24" app:tint="@color/signal_colorOnSurface" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0b0773abee..719a16bafe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5975,6 +5975,15 @@ Delete + + Deleting… + + Deletion failed. + + + Can\'t delete link. Check your connection and try again. + Not all call links could be deleted. Check your connection and try again. + Cleared call history