Ensure owned call links are revoked on delete.
This commit is contained in:
parent
03a212eee4
commit
290b0fe46f
12 changed files with 334 additions and 126 deletions
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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,20 +364,8 @@ 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()
|
|
||||||
}
|
}
|
||||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
|
||||||
.show()
|
.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)
|
.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()
|
||||||
|
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
CallLogSelectionState.empty().toggle(call.id),
|
||||||
stagedDeletion = CallLogStagedDeletion(
|
callLogRepository
|
||||||
it.filter,
|
)
|
||||||
CallLogSelectionState.empty().toggle(call.id),
|
|
||||||
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(
|
callLogRepository
|
||||||
it.filter,
|
)
|
||||||
it.selectionState,
|
|
||||||
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,
|
|
||||||
CallLogSelectionState.selectAll(),
|
|
||||||
callLogRepository
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return CallLogStagedDeletion(
|
||||||
|
callLogStore.state.filter,
|
||||||
|
CallLogSelectionState.selectAll(),
|
||||||
|
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
|
||||||
fun cancelStagedDeletion() {
|
} else {
|
||||||
callLogStore.state.stagedDeletion?.cancel()
|
CallLogDeletionResult.FailedToRevoke(failedRevocations)
|
||||||
callLogStore.update {
|
}
|
||||||
it.copy(
|
}
|
||||||
stagedDeletion = null
|
.onErrorReturn { CallLogDeletionResult.UnknownFailure(it) }
|
||||||
)
|
.toMaybe()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
Loading…
Add table
Reference in a new issue