Add initial implementation of calls tab behind a feature flag.

This commit is contained in:
Alex Hart 2023-03-15 12:52:18 -03:00 committed by Greyson Parrelli
parent d1373d2767
commit 88de0f21e7
33 changed files with 1464 additions and 63 deletions

View file

@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.calls.log
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.fragments.requireListener
class CallLogActionMode(
private val fragment: CallLogFragment,
private val onResetSelectionState: () -> Unit
) : ActionMode.Callback {
private var actionMode: ActionMode? = null
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
mode?.title = getTitle(1)
return true
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return false
}
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
onResetSelectionState()
endIfActive()
}
fun setCount(count: Int) {
actionMode?.title = getTitle(count)
}
fun start() {
actionMode = (fragment.requireActivity() as AppCompatActivity).startSupportActionMode(this)
fragment.requireListener<CallLogFragment.Callback>().onMultiSelectStarted()
}
fun end() {
fragment.requireListener<CallLogFragment.Callback>().onMultiSelectFinished()
actionMode?.finish()
actionMode = null
}
private fun getTitle(callLogsSelected: Int): String {
return fragment.requireContext().resources.getQuantityString(R.plurals.ConversationListFragment_s_selected, callLogsSelected, callLogsSelected)
}
private fun endIfActive() {
if (actionMode != null) {
end()
}
}
}

View file

@ -0,0 +1,229 @@
package org.thoughtcrime.securesms.calls.log
import android.content.res.ColorStateList
import android.view.View
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.widget.TextViewCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.databinding.CallLogAdapterItemBinding
import org.thoughtcrime.securesms.databinding.ConversationListItemClearFilterBinding
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.setRelativeDrawables
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
/**
* RecyclerView Adapter for the Call Log screen
*/
class CallLogAdapter(
callbacks: Callbacks
) : PagingMappingAdapter<CallLogRow.Id>() {
init {
registerFactory(
CallModel::class.java,
BindingFactory(
creator = {
CallModelViewHolder(
it,
callbacks::onCallClicked,
callbacks::onCallLongClicked
)
},
inflater = CallLogAdapterItemBinding::inflate
)
)
registerFactory(
ClearFilterModel::class.java,
BindingFactory(
creator = { ClearFilterViewHolder(it, callbacks::onClearFilterClicked) },
inflater = ConversationListItemClearFilterBinding::inflate
)
)
}
fun submitCallRows(rows: List<CallLogRow>, selectionState: CallLogSelectionState) {
submitList(
rows.filterNotNull().map {
when (it) {
is CallLogRow.Call -> CallModel(it, selectionState, itemCount)
is CallLogRow.ClearFilter -> ClearFilterModel()
}
}
)
}
private class CallModel(
val call: CallLogRow.Call,
val selectionState: CallLogSelectionState,
val itemCount: Int
) : MappingModel<CallModel> {
companion object {
const val PAYLOAD_SELECTION_STATE = "PAYLOAD_SELECTION_STATE"
}
override fun areItemsTheSame(newItem: CallModel): Boolean = call.id == newItem.call.id
override fun areContentsTheSame(newItem: CallModel): Boolean {
return call == newItem.call &&
isSelectionStateTheSame(newItem) &&
isItemCountTheSame(newItem)
}
override fun getChangePayload(newItem: CallModel): Any? {
return if (call == newItem.call && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) {
PAYLOAD_SELECTION_STATE
} else {
null
}
}
private fun isSelectionStateTheSame(newItem: CallModel): Boolean {
return selectionState.contains(call.id) == newItem.selectionState.contains(newItem.call.id) &&
selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount)
}
private fun isItemCountTheSame(newItem: CallModel): Boolean {
return itemCount == newItem.itemCount
}
}
private class ClearFilterModel : MappingModel<ClearFilterModel> {
override fun areItemsTheSame(newItem: ClearFilterModel): Boolean = true
override fun areContentsTheSame(newItem: ClearFilterModel): Boolean = true
}
private class CallModelViewHolder(
binding: CallLogAdapterItemBinding,
private val onCallClicked: (CallLogRow.Call) -> Unit,
private val onCallLongClicked: (View, CallLogRow.Call) -> Boolean
) : BindingViewHolder<CallModel, CallLogAdapterItemBinding>(binding) {
override fun bind(model: CallModel) {
itemView.setOnClickListener {
onCallClicked(model.call)
}
itemView.setOnLongClickListener {
onCallLongClicked(itemView, model.call)
}
itemView.isSelected = model.selectionState.contains(model.call.id)
binding.callSelected.isChecked = model.selectionState.contains(model.call.id)
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
if (payload.contains(CallModel.PAYLOAD_SELECTION_STATE)) {
return
}
val event = model.call.call.event
val direction = model.call.call.direction
val type = model.call.call.type
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), model.call.peer, true)
binding.callRecipientBadge.setBadgeFromRecipient(model.call.peer)
binding.callRecipientName.text = model.call.peer.getDisplayName(context)
presentCallInfo(event, direction, model.call.date)
presentCallType(type)
}
private fun presentCallInfo(event: CallTable.Event, direction: CallTable.Direction, date: Long) {
binding.callInfo.text = context.getString(
R.string.CallLogAdapter__s_dot_s,
context.getString(getCallStateStringRes(event, direction)),
DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), date)
)
binding.callInfo.setRelativeDrawables(
start = getCallStateDrawableRes(event, direction)
)
val color = ContextCompat.getColor(
context,
if (event == CallTable.Event.MISSED) {
R.color.signal_colorError
} else {
R.color.signal_colorOnSurface
}
)
TextViewCompat.setCompoundDrawableTintList(
binding.callInfo,
ColorStateList.valueOf(color)
)
binding.callInfo.setTextColor(color)
}
private fun presentCallType(callType: CallTable.Type) {
binding.callType.setImageResource(
when (callType) {
CallTable.Type.AUDIO_CALL -> R.drawable.symbol_phone_24
CallTable.Type.VIDEO_CALL -> R.drawable.ic_video_call_24
}
)
binding.callType.visible = true
}
@DrawableRes
private fun getCallStateDrawableRes(callEvent: CallTable.Event, callDirection: CallTable.Direction): Int {
if (callEvent == CallTable.Event.MISSED) {
return R.drawable.ic_update_audio_call_missed_16
}
return if (callDirection == CallTable.Direction.INCOMING) {
R.drawable.ic_update_audio_call_incoming_16
} else {
R.drawable.ic_update_audio_call_outgoing_16
}
}
@StringRes
private fun getCallStateStringRes(callEvent: CallTable.Event, callDirection: CallTable.Direction): Int {
if (callEvent == CallTable.Event.MISSED) {
return R.string.CallLogAdapter__missed
}
return if (callDirection == CallTable.Direction.INCOMING) {
R.string.CallLogAdapter__incoming
} else {
R.string.CallLogAdapter__outgoing
}
}
}
private class ClearFilterViewHolder(
binding: ConversationListItemClearFilterBinding,
onClearFilterClicked: () -> Unit
) : BindingViewHolder<ClearFilterModel, ConversationListItemClearFilterBinding>(binding) {
init {
binding.clearFilter.setOnClickListener { onClearFilterClicked() }
}
override fun bind(model: ClearFilterModel) = Unit
}
interface Callbacks {
/**
* Invoked when a call row is clicked
*/
fun onCallClicked(callLogRow: CallLogRow.Call)
/**
* Invoked when a call row is long-clicked
*/
fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean
/**
* Invoked when the clear filter button is pressed
*/
fun onClearFilterClicked()
}
}

View file

@ -0,0 +1,101 @@
package org.thoughtcrime.securesms.calls.log
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.util.CommunicationActions
/**
* Context menu for row items on the Call Log screen.
*/
class CallLogContextMenu(
private val fragment: Fragment,
private val callbacks: Callbacks
) {
fun show(anchor: View, call: CallLogRow.Call) {
SignalContextMenu.Builder(anchor, anchor.parent as ViewGroup)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.show(
listOfNotNull(
getVideoCallActionItem(call),
getAudioCallActionItem(call),
getGoToChatActionItem(call),
getInfoActionItem(call),
getSelectActionItem(call),
getDeleteActionItem(call)
)
)
}
private fun getVideoCallActionItem(call: CallLogRow.Call): ActionItem {
// TODO [alex] -- Need group calling disposition to make this correct
return ActionItem(
iconRes = R.drawable.ic_video_call_24,
title = fragment.getString(R.string.CallContextMenu__video_call)
) {
CommunicationActions.startVideoCall(fragment, call.peer)
}
}
private fun getAudioCallActionItem(call: CallLogRow.Call): ActionItem? {
if (call.peer.isGroup) {
return null
}
return ActionItem(
iconRes = R.drawable.symbol_phone_24,
title = fragment.getString(R.string.CallContextMenu__audio_call)
) {
CommunicationActions.startVoiceCall(fragment, call.peer)
}
}
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_open_24,
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
) {
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
}
}
private fun getInfoActionItem(call: CallLogRow.Call): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_info_24,
title = fragment.getString(R.string.CallContextMenu__info)
) {
// TODO
}
}
private fun getSelectActionItem(call: CallLogRow.Call): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_check_circle_24,
title = fragment.getString(R.string.CallContextMenu__select)
) {
callbacks.startSelection(call)
}
}
private fun getDeleteActionItem(call: CallLogRow.Call): ActionItem? {
if (call.call.event == CallTable.Event.ONGOING) {
return null
}
return ActionItem(
iconRes = R.drawable.symbol_trash_24,
title = fragment.getString(R.string.CallContextMenu__delete)
) {
// TODO [alex] what does this actually delete
}
}
interface Callbacks {
fun startSelection(call: CallLogRow.Call)
}
}

View file

@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.calls.log
/**
* Allows user to only display certain classes of calls.
*/
enum class CallLogFilter {
/**
* All call logs will be displayed
*/
ALL,
/**
* Only missed calls will be displayed
*/
MISSED
}

View file

@ -0,0 +1,213 @@
package org.thoughtcrime.securesms.calls.log
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import com.google.android.material.appbar.AppBarLayout
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.Observables
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.Material3SearchToolbar
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
import org.thoughtcrime.securesms.conversationlist.ConversationFilterBehavior
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView.OnCloseClicked
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView.OnFilterStateChanged
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
import org.thoughtcrime.securesms.main.SearchBinder
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import java.util.Objects
/**
* Call Log tab.
*/
@SuppressLint("DiscouragedApi")
class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Callbacks, CallLogContextMenu.Callbacks {
private val viewModel: CallLogViewModel by viewModels()
private val binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind)
private val disposables = LifecycleDisposable()
private val callLogContextMenu = CallLogContextMenu(this, this)
private val callLogActionMode = CallLogActionMode(
fragment = this,
onResetSelectionState = {
viewModel.clearSelected()
}
)
private val menuProvider = object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.calls_tab_menu, menu)
}
override fun onPrepareMenu(menu: Menu) {
val isFiltered = viewModel.filterSnapshot == CallLogFilter.MISSED
menu.findItem(R.id.action_clear_missed_call_filter).isVisible = isFiltered
menu.findItem(R.id.action_filter_missed_calls).isVisible = !isFiltered
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_settings -> startActivity(AppSettingsActivity.home(requireContext()))
R.id.action_notification_profile -> NotificationProfileSelectionFragment.show(parentFragmentManager)
R.id.action_filter_missed_calls -> filterMissedCalls()
R.id.action_clear_missed_call_filter -> onClearFilterClicked()
}
return true
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(menuProvider, viewLifecycleOwner)
val adapter = CallLogAdapter(this)
disposables.bindTo(viewLifecycleOwner)
disposables += viewModel.controller
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
adapter.setPagingController(it)
}
disposables += Observables.combineLatest(viewModel.data, viewModel.selected)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (data, selected) ->
adapter.submitCallRows(data, selected)
}
disposables += viewModel.selected
.observeOn(AndroidSchedulers.mainThread())
.distinctUntilChanged()
.subscribe {
if (!it.isNotEmpty(adapter.itemCount)) {
callLogActionMode.end()
} else {
callLogActionMode.setCount(it.count(adapter.itemCount))
}
}
binding.recycler.adapter = adapter
initializePullToFilter()
}
override fun onResume() {
super.onResume()
initializeSearchAction()
}
private fun initializeSearchAction() {
val searchBinder = requireListener<SearchBinder>()
searchBinder.getSearchAction().setOnClickListener {
searchBinder.onSearchOpened()
searchBinder.getSearchToolbar().get().setSearchInputHint(R.string.SearchToolbar_search)
searchBinder.getSearchToolbar().get().listener = object : Material3SearchToolbar.Listener {
override fun onSearchTextChange(text: String) {
viewModel.setSearchQuery(text.trim())
}
override fun onSearchClosed() {
viewModel.setSearchQuery("")
searchBinder.onSearchClosed()
}
}
}
}
private fun initializePullToFilter() {
val collapsingToolbarLayout = binding.collapsingToolbar
val openHeight = DimensionUnit.DP.toPixels(FilterLerp.FILTER_OPEN_HEIGHT).toInt()
binding.pullView.onFilterStateChanged = OnFilterStateChanged { state: FilterPullState?, source: ConversationFilterSource ->
when (state) {
FilterPullState.CLOSING -> viewModel.setFilter(CallLogFilter.ALL)
FilterPullState.OPENING -> {
ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight)
viewModel.setFilter(CallLogFilter.MISSED)
}
FilterPullState.OPEN_APEX -> if (source === ConversationFilterSource.DRAG) {
// TODO[alex] -- hint here? SignalStore.uiHints().incrementNeverDisplayPullToFilterTip()
}
FilterPullState.CLOSE_APEX -> ViewUtil.setMinimumHeight(collapsingToolbarLayout, 0)
else -> Unit
}
}
binding.pullView.onCloseClicked = OnCloseClicked {
onClearFilterClicked()
}
val conversationFilterBehavior = Objects.requireNonNull<ConversationFilterBehavior?>((binding.recyclerCoordinatorAppBar.layoutParams as CoordinatorLayout.LayoutParams).behavior as ConversationFilterBehavior?)
conversationFilterBehavior.callback = object : ConversationFilterBehavior.Callback {
override fun onStopNestedScroll() {
binding.pullView.onUserDragFinished()
}
override fun canStartNestedScroll(): Boolean {
return !isSearchOpen() || binding.pullView.isCloseable()
}
}
binding.recyclerCoordinatorAppBar.addOnOffsetChangedListener { layout: AppBarLayout, verticalOffset: Int ->
val progress = 1 - verticalOffset.toFloat() / -layout.height
binding.pullView.onUserDrag(progress)
}
}
override fun onCallClicked(callLogRow: CallLogRow.Call) {
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
}
}
override fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean {
callLogContextMenu.show(itemView, callLogRow)
return true
}
override fun onClearFilterClicked() {
binding.pullView.toggle()
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
}
override fun startSelection(call: CallLogRow.Call) {
callLogActionMode.start()
viewModel.toggleSelected(call.id)
}
private fun filterMissedCalls() {
binding.pullView.toggle()
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
}
private fun isSearchOpen(): Boolean {
return isSearchVisible() || viewModel.hasSearchQuery
}
private fun isSearchVisible(): Boolean {
return requireListener<SearchBinder>().getSearchToolbar().resolved() &&
requireListener<SearchBinder>().getSearchToolbar().get().getVisibility() == View.VISIBLE
}
interface Callback {
fun onMultiSelectStarted()
fun onMultiSelectFinished()
}
}

View file

@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.calls.log
import org.signal.paging.PagedDataSource
class CallLogPagedDataSource(
private val query: String?,
private val filter: CallLogFilter,
private val repository: CallRepository
) : PagedDataSource<CallLogRow.Id, CallLogRow> {
private val hasFilter = filter == CallLogFilter.MISSED
override fun size(): Int {
return repository.getCallsCount(query, filter) + (if (hasFilter) 1 else 0)
}
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
val calls: MutableList<CallLogRow> = repository.getCalls(query, filter, start, length).toMutableList()
if (calls.size < length && hasFilter) {
calls.add(CallLogRow.ClearFilter)
}
return calls
}
override fun getKey(data: CallLogRow): CallLogRow.Id = data.id
override fun load(key: CallLogRow.Id?): CallLogRow = error("Not supported")
interface CallRepository {
fun getCallsCount(query: String?, filter: CallLogFilter): Int
fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
}
}

View file

@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.calls.log
import org.thoughtcrime.securesms.database.SignalDatabase
class CallLogRepository : CallLogPagedDataSource.CallRepository {
override fun getCallsCount(query: String?, filter: CallLogFilter): Int {
return SignalDatabase.calls.getCallsCount(query, filter)
}
override fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow> {
return SignalDatabase.calls.getCalls(start, length, query, filter)
}
}

View file

@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.calls.log
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.recipients.Recipient
/**
* A row to be displayed in the call log
*/
sealed class CallLogRow {
abstract val id: Id
/**
* An incoming, outgoing, or missed call.
*/
data class Call(
val call: CallTable.Call,
val peer: Recipient,
val date: Long,
override val id: Id = Id.Call(call.callId)
) : CallLogRow()
/**
* A row which can be used to clear the current filter.
*/
object ClearFilter : CallLogRow() {
override val id: Id = Id.ClearFilter
}
sealed class Id {
data class Call(val callId: Long) : Id()
object ClearFilter : Id()
}
}

View file

@ -0,0 +1,72 @@
package org.thoughtcrime.securesms.calls.log
/**
* Selection state object for call logs.
*/
sealed class CallLogSelectionState {
abstract fun contains(callId: CallLogRow.Id): Boolean
abstract fun isNotEmpty(totalCount: Int): Boolean
abstract fun count(totalCount: Int): Int
protected abstract fun select(callId: CallLogRow.Id): CallLogSelectionState
protected abstract fun deselect(callId: CallLogRow.Id): CallLogSelectionState
fun toggle(callId: CallLogRow.Id): CallLogSelectionState {
return if (contains(callId)) {
deselect(callId)
} else {
select(callId)
}
}
/**
* Includes contains an opt-in list of call logs.
*/
data class Includes(private val includes: Set<CallLogRow.Id>) : CallLogSelectionState() {
override fun contains(callId: CallLogRow.Id): Boolean {
return includes.contains(callId)
}
override fun isNotEmpty(totalCount: Int): Boolean {
return includes.isNotEmpty()
}
override fun count(totalCount: Int): Int {
return includes.size
}
override fun select(callId: CallLogRow.Id): CallLogSelectionState {
return Includes(includes + callId)
}
override fun deselect(callId: CallLogRow.Id): CallLogSelectionState {
return Includes(includes - callId)
}
}
/**
* Excludes contains an opt-out list of call logs.
*/
data class Excludes(private val excluded: Set<CallLogRow.Id>) : CallLogSelectionState() {
override fun contains(callId: CallLogRow.Id): Boolean = !excluded.contains(callId)
override fun isNotEmpty(totalCount: Int): Boolean = excluded.size < totalCount
override fun count(totalCount: Int): Int {
return totalCount - excluded.size
}
override fun select(callId: CallLogRow.Id): CallLogSelectionState {
return Excludes(excluded - callId)
}
override fun deselect(callId: CallLogRow.Id): CallLogSelectionState {
return Excludes(excluded + callId)
}
}
companion object {
fun empty(): CallLogSelectionState = Includes(emptySet())
fun selectAll(): CallLogSelectionState = Excludes(emptySet())
}
}

View file

@ -0,0 +1,75 @@
package org.thoughtcrime.securesms.calls.log
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.core.Observable
import org.signal.paging.ObservablePagedData
import org.signal.paging.PagedData
import org.signal.paging.PagingConfig
import org.signal.paging.PagingController
import org.thoughtcrime.securesms.util.rx.RxStore
/**
* ViewModel for call log management.
*/
class CallLogViewModel(
private val callLogRepository: CallLogRepository = CallLogRepository()
) : ViewModel() {
private val callLogStore = RxStore(CallLogState())
private val pagedData: Observable<ObservablePagedData<CallLogRow.Id, CallLogRow>> = callLogStore
.stateFlowable
.toObservable()
.map { (query, filter) ->
PagedData.createForObservable(
CallLogPagedDataSource(query, filter, callLogRepository),
pagingConfig
)
}
val controller: Observable<PagingController<CallLogRow.Id>> = pagedData.map { it.controller }
val data: Observable<MutableList<CallLogRow>> = pagedData.switchMap { it.data }
val selected: Observable<CallLogSelectionState> = callLogStore
.stateFlowable
.toObservable()
.map { it.selectionState }
val selectionStateSnapshot: CallLogSelectionState
get() = callLogStore.state.selectionState
val filterSnapshot: CallLogFilter
get() = callLogStore.state.filter
val hasSearchQuery: Boolean
get() = !callLogStore.state.query.isNullOrBlank()
private val pagingConfig = PagingConfig.Builder()
.setBufferPages(1)
.setPageSize(20)
.setStartIndex(0)
.build()
fun toggleSelected(callId: CallLogRow.Id) {
callLogStore.update {
val selectionState = it.selectionState.toggle(callId)
it.copy(selectionState = selectionState)
}
}
fun clearSelected() {
callLogStore.update {
it.copy(selectionState = CallLogSelectionState.empty())
}
}
fun setSearchQuery(query: String) {
callLogStore.update { it.copy(query = query) }
}
fun setFilter(filter: CallLogFilter) {
callLogStore.update { it.copy(filter = filter) }
}
private data class CallLogState(
val query: String? = null,
val filter: CallLogFilter = CallLogFilter.ALL,
val selectionState: CallLogSelectionState = CallLogSelectionState.empty()
)
}

View file

@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.calls.new
import android.annotation.SuppressLint
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
class NewCallActivity : FragmentWrapperActivity() {
@SuppressLint("DiscouragedApi")
override fun getFragment(): Fragment = NewCallFragment()
}

View file

@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.calls.new
import android.annotation.SuppressLint
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
@SuppressLint("DiscouragedApi")
class NewCallFragment : DSLSettingsFragment()

View file

@ -275,11 +275,11 @@ class ConversationListFilterPullView @JvmOverloads constructor(
}
}
interface OnFilterStateChanged {
fun interface OnFilterStateChanged {
fun newState(state: FilterPullState, source: ConversationFilterSource)
}
interface OnCloseClicked {
fun interface OnCloseClicked {
fun onCloseClicked()
}
}

View file

@ -14,7 +14,10 @@ import org.signal.core.util.requireObject
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.calls.log.CallLogRow
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent
@ -136,6 +139,86 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
return calls
}
private fun getCallsCursor(isCount: Boolean, offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): Cursor {
val filterClause = when (filter) {
CallLogFilter.ALL -> SqlUtil.buildQuery("")
CallLogFilter.MISSED -> SqlUtil.buildQuery("$EVENT == ${Event.serialize(Event.MISSED)}")
}
val queryClause = if (!searchTerm.isNullOrEmpty()) {
val glob = SqlUtil.buildCaseInsensitiveGlobPattern(searchTerm)
val selection =
"""
${RecipientTable.TABLE_NAME}.${RecipientTable.BLOCKED} = ? AND ${RecipientTable.TABLE_NAME}.${RecipientTable.HIDDEN} = ? AND
(
sort_name GLOB ? OR
${RecipientTable.TABLE_NAME}.${RecipientTable.USERNAME} GLOB ? OR
${RecipientTable.TABLE_NAME}.${RecipientTable.PHONE} GLOB ? OR
${RecipientTable.TABLE_NAME}.${RecipientTable.EMAIL} GLOB ?
)
""".trimIndent()
SqlUtil.buildQuery(selection, 0, 0, glob, glob, glob, glob)
} else {
SqlUtil.buildQuery("")
}
val whereClause = filterClause and queryClause
val where = if (whereClause.where.isNotEmpty()) {
"WHERE ${whereClause.where}"
} else {
""
}
val offsetLimit = if (limit > 0) {
"LIMIT $offset,$limit"
} else {
""
}
//language=sql
val statement = """
SELECT
${if (isCount) "COUNT(*)," else "$TABLE_NAME.*, ${MessageTable.DATE_RECEIVED},"}
LOWER(
COALESCE(
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_JOINED_NAME}, ''),
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_GIVEN_NAME}, ''),
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_JOINED_NAME}, ''),
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_GIVEN_NAME}, ''),
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.USERNAME}, '')
)
) AS sort_name
FROM $TABLE_NAME
INNER JOIN ${RecipientTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = $TABLE_NAME.$PEER
INNER JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $TABLE_NAME.$MESSAGE_ID
$where
ORDER BY ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} DESC
$offsetLimit
""".trimIndent()
return readableDatabase.query(statement, whereClause.whereArgs)
}
fun getCallsCount(searchTerm: String?, filter: CallLogFilter): Int {
return getCallsCursor(true, 0, 0, searchTerm, filter).use {
it.moveToFirst()
it.getInt(0)
}
}
fun getCalls(offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): List<CallLogRow.Call> {
return getCallsCursor(false, offset, limit, searchTerm, filter).readToList {
val call = Call.deserialize(it)
val recipient = Recipient.resolved(call.peer)
val date = it.requireLong(MessageTable.DATE_RECEIVED)
CallLogRow.Call(
call = call,
peer = recipient,
date = date
)
}
}
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
writableDatabase
.update(TABLE_NAME)

View file

@ -131,7 +131,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
const val ID = "_id"
const val SERVICE_ID = "uuid"
const val PNI_COLUMN = "pni"
private const val USERNAME = "username"
const val USERNAME = "username"
const val PHONE = "phone"
const val EMAIL = "email"
const val GROUP_ID = "group_id"
@ -167,9 +167,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
const val FORCE_SMS_SELECTION = "force_sms_selection"
private const val CAPABILITIES = "capabilities"
const val STORAGE_SERVICE_ID = "storage_service_key"
private const val PROFILE_GIVEN_NAME = "signal_profile_name"
const val PROFILE_GIVEN_NAME = "signal_profile_name"
private const val PROFILE_FAMILY_NAME = "profile_family_name"
private const val PROFILE_JOINED_NAME = "profile_joined_name"
const val PROFILE_JOINED_NAME = "profile_joined_name"
private const val MENTION_SETTING = "mention_setting"
private const val STORAGE_PROTO = "storage_proto"
private const val LAST_SESSION_RESET = "last_session_reset"
@ -183,12 +183,12 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
private const val CUSTOM_CHAT_COLORS_ID = "custom_chat_colors_id"
private const val BADGES = "badges"
const val SEARCH_PROFILE_NAME = "search_signal_profile"
private const val SORT_NAME = "sort_name"
const val SORT_NAME = "sort_name"
private const val IDENTITY_STATUS = "identity_status"
private const val IDENTITY_KEY = "identity_key"
private const val NEEDS_PNI_SIGNATURE = "needs_pni_signature"
private const val UNREGISTERED_TIMESTAMP = "unregistered_timestamp"
private const val HIDDEN = "hidden"
const val HIDDEN = "hidden"
const val REPORTING_TOKEN = "reporting_token"
@JvmField

View file

@ -24,6 +24,7 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.components.Material3SearchToolbar
import org.thoughtcrime.securesms.components.TooltipPopup
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
@ -48,7 +49,7 @@ import org.thoughtcrime.securesms.util.views.Stub
import org.thoughtcrime.securesms.util.visible
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_fragment), ConversationListFragment.Callback, Material3OnScrollHelperBinder {
class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_fragment), ConversationListFragment.Callback, Material3OnScrollHelperBinder, CallLogFragment.Callback {
companion object {
private val TAG = Log.tag(MainActivityListHostFragment::class.java)
@ -98,6 +99,7 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
R.id.conversationListFragment -> goToStateFromConversationList(state, controller)
R.id.conversationListArchiveFragment -> Unit
R.id.storiesLandingFragment -> goToStateFromStories(state, controller)
R.id.callLogFragment -> goToStateFromCalling(state, controller)
}
}
}
@ -105,7 +107,7 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
private fun goToStateFromConversationList(state: ConversationListTabsState, navController: NavController) {
if (state.tab == ConversationListTab.CHATS) {
return
} else {
} else if (state.tab == ConversationListTab.STORIES) {
val cameraFab = requireView().findViewById<View>(R.id.camera_fab)
val newConvoFab = requireView().findViewById<View>(R.id.fab)
@ -127,14 +129,35 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
null,
extras
)
} else {
navController.navigate(
R.id.action_conversationListFragment_to_callLogFragment,
null,
null,
null
)
}
}
private fun goToStateFromCalling(state: ConversationListTabsState, navController: NavController) {
when (state.tab) {
ConversationListTab.CALLS -> return
ConversationListTab.CHATS -> navController.popBackStack()
ConversationListTab.STORIES -> {
navController.popBackStack()
goToStateFromConversationList(state, navController)
}
}
}
private fun goToStateFromStories(state: ConversationListTabsState, navController: NavController) {
if (state.tab == ConversationListTab.STORIES) {
return
} else {
navController.popBackStack()
when (state.tab) {
ConversationListTab.STORIES -> return
ConversationListTab.CHATS -> navController.popBackStack()
ConversationListTab.CALLS -> {
navController.popBackStack()
goToStateFromConversationList(state, navController)
}
}
}
@ -182,6 +205,10 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
}
}
private fun presentToolbarForCallLogFragment() {
presentToolbarForConversationListFragment()
}
private fun presentToolbarForMultiselect() {
_toolbar.visible = false
if (_basicToolbar.resolved()) {
@ -332,6 +359,10 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
conversationListTabsViewModel.isShowingArchived(false)
presentToolbarForStoriesLandingFragment()
}
R.id.callLogFragment -> {
conversationListTabsViewModel.isShowingArchived(false)
presentToolbarForCallLogFragment()
}
}
}

View file

@ -2,5 +2,6 @@ package org.thoughtcrime.securesms.stories.tabs
enum class ConversationListTab {
CHATS,
CALLS,
STORIES
}

View file

@ -46,4 +46,8 @@ class ConversationListTabRepository {
refresh()
}.subscribeOn(Schedulers.io())
}
fun getNumberOfUnseenCalls(): Observable<Long> {
return Observable.just(99)
}
}

View file

@ -6,7 +6,6 @@ import android.animation.ValueAnimator
import android.os.Bundle
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.view.animation.PathInterpolatorCompat
import androidx.fragment.app.Fragment
@ -16,6 +15,9 @@ import com.airbnb.lottie.LottieProperty
import com.airbnb.lottie.model.KeyPath
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.ConversationListTabsBinding
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.visible
import java.text.NumberFormat
@ -25,33 +27,24 @@ import java.text.NumberFormat
class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) {
private val viewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
private lateinit var chatsUnreadIndicator: TextView
private lateinit var storiesUnreadIndicator: TextView
private lateinit var chatsIcon: LottieAnimationView
private lateinit var storiesIcon: LottieAnimationView
private lateinit var chatsPill: ImageView
private lateinit var storiesPill: ImageView
private val binding by ViewBinderDelegate(ConversationListTabsBinding::bind)
private var shouldBeImmediate = true
private var pillAnimator: Animator? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
chatsUnreadIndicator = view.findViewById(R.id.chats_unread_indicator)
storiesUnreadIndicator = view.findViewById(R.id.stories_unread_indicator)
chatsIcon = view.findViewById(R.id.chats_tab_icon)
storiesIcon = view.findViewById(R.id.stories_tab_icon)
chatsPill = view.findViewById(R.id.chats_pill)
storiesPill = view.findViewById(R.id.stories_pill)
val iconTint = ContextCompat.getColor(requireContext(), R.color.signal_colorOnSecondaryContainer)
chatsIcon.addValueCallback(
binding.chatsTabIcon.addValueCallback(
KeyPath("**"),
LottieProperty.COLOR
) { iconTint }
storiesIcon.addValueCallback(
binding.callsTabIcon.addValueCallback(
KeyPath("**"),
LottieProperty.COLOR
) { iconTint }
binding.storiesTabIcon.addValueCallback(
KeyPath("**"),
LottieProperty.COLOR
) { iconTint }
@ -60,10 +53,16 @@ class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) {
viewModel.onChatsSelected()
}
view.findViewById<View>(R.id.calls_tab_touch_point).setOnClickListener {
viewModel.onCallsSelected()
}
view.findViewById<View>(R.id.stories_tab_touch_point).setOnClickListener {
viewModel.onStoriesSelected()
}
binding.callsTabGroup.visible = FeatureFlags.callsTab()
viewModel.state.observe(viewLifecycleOwner) {
update(it, shouldBeImmediate)
shouldBeImmediate = false
@ -71,31 +70,39 @@ class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) {
}
private fun update(state: ConversationListTabsState, immediate: Boolean) {
chatsIcon.isSelected = state.tab == ConversationListTab.CHATS
chatsPill.isSelected = state.tab == ConversationListTab.CHATS
binding.chatsTabIcon.isSelected = state.tab == ConversationListTab.CHATS
binding.chatsPill.isSelected = state.tab == ConversationListTab.CHATS
storiesIcon.isSelected = state.tab == ConversationListTab.STORIES
storiesPill.isSelected = state.tab == ConversationListTab.STORIES
binding.callsTabIcon.isSelected = state.tab == ConversationListTab.CALLS
binding.callsPill.isSelected = state.tab == ConversationListTab.CALLS
binding.storiesTabIcon.isSelected = state.tab == ConversationListTab.STORIES
binding.storiesPill.isSelected = state.tab == ConversationListTab.STORIES
val hasStateChange = state.tab != state.prevTab
if (immediate) {
chatsIcon.pauseAnimation()
storiesIcon.pauseAnimation()
binding.chatsTabIcon.pauseAnimation()
binding.callsTabIcon.pauseAnimation()
binding.storiesTabIcon.pauseAnimation()
chatsIcon.progress = if (state.tab == ConversationListTab.CHATS) 1f else 0f
storiesIcon.progress = if (state.tab == ConversationListTab.STORIES) 1f else 0f
binding.chatsTabIcon.progress = if (state.tab == ConversationListTab.CHATS) 1f else 0f
binding.callsTabIcon.progress = if (state.tab == ConversationListTab.CALLS) 1f else 0f
binding.storiesTabIcon.progress = if (state.tab == ConversationListTab.STORIES) 1f else 0f
runPillAnimation(0, chatsPill, storiesPill)
runPillAnimation(0, binding.chatsPill, binding.callsPill, binding.storiesPill)
} else if (hasStateChange) {
runLottieAnimations(chatsIcon, storiesIcon)
runPillAnimation(150, chatsPill, storiesPill)
runLottieAnimations(binding.chatsTabIcon, binding.callsTabIcon, binding.storiesTabIcon)
runPillAnimation(150, binding.chatsPill, binding.callsPill, binding.storiesPill)
}
chatsUnreadIndicator.visible = state.unreadMessagesCount > 0
chatsUnreadIndicator.text = formatCount(state.unreadMessagesCount)
binding.chatsUnreadIndicator.alpha = if (state.unreadMessagesCount > 0) 1f else 0f
binding.chatsUnreadIndicator.text = formatCount(state.unreadMessagesCount)
storiesUnreadIndicator.visible = state.unreadStoriesCount > 0
storiesUnreadIndicator.text = formatCount(state.unreadStoriesCount)
binding.callsUnreadIndicator.alpha = if (state.unreadCallsCount > 0) 1f else 0f
binding.callsUnreadIndicator.text = formatCount(state.unreadCallsCount)
binding.storiesUnreadIndicator.alpha = if (state.unreadStoriesCount > 0) 1f else 0f
binding.storiesUnreadIndicator.text = formatCount(state.unreadStoriesCount)
requireView().visible = state.visibilityState.isVisible()
}

View file

@ -4,6 +4,7 @@ data class ConversationListTabsState(
val tab: ConversationListTab = ConversationListTab.CHATS,
val prevTab: ConversationListTab = ConversationListTab.STORIES,
val unreadMessagesCount: Long = 0L,
val unreadCallsCount: Long = 0L,
val unreadStoriesCount: Long = 0L,
val visibilityState: VisibilityState = VisibilityState()
) {

View file

@ -27,6 +27,10 @@ class ConversationListTabsViewModel(repository: ConversationListTabRepository) :
store.update { it.copy(unreadMessagesCount = unreadChats) }
}
disposables += repository.getNumberOfUnseenCalls().subscribe { unseenCalls ->
store.update { it.copy(unreadCallsCount = unseenCalls) }
}
disposables += repository.getNumberOfUnseenStories().subscribe { unseenStories ->
store.update { it.copy(unreadStoriesCount = unseenStories) }
}
@ -41,6 +45,11 @@ class ConversationListTabsViewModel(repository: ConversationListTabRepository) :
store.update { it.copy(tab = ConversationListTab.CHATS, prevTab = it.tab) }
}
fun onCallsSelected() {
internalTabClickEvents.onNext(ConversationListTab.CALLS)
store.update { it.copy(tab = ConversationListTab.CALLS, prevTab = it.tab) }
}
fun onStoriesSelected() {
internalTabClickEvents.onNext(ConversationListTab.STORIES)
store.update { it.copy(tab = ConversationListTab.STORIES, prevTab = it.tab) }

View file

@ -106,6 +106,7 @@ public final class FeatureFlags {
private static final String PAYPAL_RECURRING_DONATIONS = "android.recurringPayPalDonations.3";
private static final String TEXT_FORMATTING = "android.textFormatting";
private static final String ANY_ADDRESS_PORTS_KILL_SWITCH = "android.calling.fieldTrial.anyAddressPortsKillSwitch";
private static final String CALLS_TAB = "android.calls.tab";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@ -162,7 +163,8 @@ public final class FeatureFlags {
PAYPAL_ONE_TIME_DONATIONS,
PAYPAL_RECURRING_DONATIONS,
TEXT_FORMATTING,
ANY_ADDRESS_PORTS_KILL_SWITCH
ANY_ADDRESS_PORTS_KILL_SWITCH,
CALLS_TAB
);
@VisibleForTesting
@ -584,6 +586,13 @@ public final class FeatureFlags {
return getBoolean(ANY_ADDRESS_PORTS_KILL_SWITCH, false);
}
/**
* Whether or not the calls tab is enabled
*/
public static boolean callsTab() {
return getBoolean(CALLS_TAB, false);
}
/** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);

View file

@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.util
import android.view.View
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
@ -28,3 +30,17 @@ inline fun View.doOnEachLayout(crossinline action: (view: View) -> Unit): View.O
addOnLayoutChangeListener(listener)
return listener
}
fun TextView.setRelativeDrawables(
@DrawableRes start: Int = 0,
@DrawableRes top: Int = 0,
@DrawableRes bottom: Int = 0,
@DrawableRes end: Int = 0
) {
setCompoundDrawablesRelativeWithIntrinsicBounds(
start,
top,
end,
bottom
)
}

View file

@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginVertical="2dp"
android:background="@drawable/selectable_list_item_background"
android:minHeight="60dp">
<androidx.appcompat.widget.AppCompatCheckBox
android:id="@+id/call_selected"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_marginStart="16dp"
android:background="@drawable/contact_selection_checkbox"
android:button="@null"
android:clickable="false"
android:focusable="false"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:checked="true"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/call_recipient_avatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/call_selected"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginStart="12dp"
tools:src="@tools:sample/avatars" />
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/call_recipient_badge"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="20dp"
android:layout_marginTop="22dp"
android:contentDescription="@string/ImageView__badge"
app:badge_size="medium"
app:layout_constraintStart_toStartOf="@id/call_recipient_avatar"
app:layout_constraintTop_toTopOf="@id/call_recipient_avatar" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/call_recipient_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:gravity="start|center_vertical"
android:maxLines="2"
android:textAlignment="viewStart"
android:textAppearance="@style/Signal.Text.BodyLarge"
app:layout_constraintBottom_toTopOf="@+id/call_info"
app:layout_constraintEnd_toStartOf="@id/call_status_barrier"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@+id/call_recipient_avatar"
app:layout_constraintTop_toTopOf="@+id/call_recipient_avatar"
tools:text="Miles Morales" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/call_info"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:drawablePadding="6dp"
android:ellipsize="end"
android:gravity="start|center_vertical"
android:maxLines="1"
android:textAlignment="viewStart"
android:textAppearance="@style/Signal.Text.BodyMedium"
android:textColor="@color/signal_text_secondary"
app:layout_constraintBottom_toBottomOf="@+id/call_recipient_avatar"
app:layout_constraintEnd_toStartOf="@id/call_status_barrier"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@+id/call_recipient_avatar"
app:layout_constraintTop_toBottomOf="@+id/call_recipient_name"
tools:drawableStartCompat="@drawable/ic_update_audio_call_incoming_16"
tools:text="Incoming &#183; 40m" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/call_status_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="start"
app:constraint_referenced_ids="call_type,call_join_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/call_join_button"
style="@style/Signal.Widget.Button.Small.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textColor="@color/signal_colorOnPrimary"
android:visibility="gone"
app:backgroundTint="@color/signal_colorPrimary"
app:icon="@drawable/ic_video_call_24"
app:iconPadding="4dp"
app:iconSize="20dp"
app:iconTint="@color/signal_colorOnPrimary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:strokeWidth="0dp"
tools:text="Join"
tools:visibility="gone" />
<ImageView
android:id="@+id/call_type"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_video_call_24"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/signal_colorOnSurface"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/recycler_coordinator_app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:elevation="0dp"
app:expanded="false"
app:layout_behavior="org.thoughtcrime.securesms.conversationlist.ConversationFilterBehavior">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/collapsing_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlwaysCollapsed|exitUntilCollapsed">
<org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView
android:id="@+id/pull_view"
android:layout_width="match_parent"
android:layout_height="130dp"
android:background="@color/signal_colorBackground"
app:layout_scrollInterpolator="@android:anim/linear_interpolator" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/signal_colorBackground"
android:clipToPadding="false"
android:paddingBottom="80dp"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:listitem="@layout/conversation_list_item_view" />
<org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:contentDescription="@string/CallLogFragment__start_a_new_call"
android:focusable="true"
android:theme="@style/Widget.Material3.FloatingActionButton.Secondary"
android:transitionName="new_convo_fab"
app:backgroundTint="@color/signal_colorPrimaryContainer"
app:shapeAppearanceOverlay="@style/Signal.ShapeOverlay.Rounded.Fab"
app:srcCompat="@drawable/symbol_phone_24"
app:tint="@color/signal_colorOnSurface" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -1,13 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/signal_colorSurface2"
android:minHeight="80dp"
android:paddingHorizontal="@dimen/dsl_settings_gutter">
android:minHeight="80dp">
<View
android:id="@+id/chats_tab_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/calls_tab_container"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/calls_tab_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/stories_tab_container"
app:layout_constraintStart_toEndOf="@id/chats_tab_container"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/stories_tab_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/calls_tab_container"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/chats_tab_touch_point"
@ -15,8 +40,18 @@
android:layout_height="0dp"
android:background="?selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/chats_tab_icon"
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
app:layout_constraintEnd_toEndOf="@id/chats_tab_container"
app:layout_constraintStart_toStartOf="@id/chats_tab_container"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/calls_tab_touch_point"
android:layout_width="80dp"
android:layout_height="0dp"
android:background="?selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/calls_tab_container"
app:layout_constraintStart_toStartOf="@id/calls_tab_container"
app:layout_constraintTop_toTopOf="parent" />
<View
@ -25,8 +60,8 @@
android:layout_height="0dp"
android:background="?selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/stories_tab_icon"
app:layout_constraintStart_toStartOf="@id/stories_tab_icon"
app:layout_constraintEnd_toEndOf="@id/stories_tab_container"
app:layout_constraintStart_toStartOf="@id/stories_tab_container"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
@ -40,6 +75,17 @@
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
app:layout_constraintTop_toTopOf="@id/chats_tab_icon" />
<ImageView
android:id="@+id/calls_pill"
android:layout_width="64dp"
android:layout_height="32dp"
android:importantForAccessibility="no"
android:src="@drawable/conversation_tab_icon_background"
app:layout_constraintBottom_toBottomOf="@id/calls_tab_icon"
app:layout_constraintEnd_toEndOf="@id/calls_tab_icon"
app:layout_constraintStart_toStartOf="@id/calls_tab_icon"
app:layout_constraintTop_toTopOf="@id/calls_tab_icon" />
<ImageView
android:id="@+id/stories_pill"
android:layout_width="64dp"
@ -59,8 +105,8 @@
android:importantForAccessibility="no"
android:scaleType="centerInside"
app:layout_constraintBottom_toTopOf="@id/chats_tab_label"
app:layout_constraintEnd_toStartOf="@id/tabs_center_guide"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="@id/chats_tab_touch_point"
app:layout_constraintStart_toStartOf="@id/chats_tab_touch_point"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
app:lottie_rawRes="@raw/chats_32" />
@ -78,12 +124,31 @@
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
app:layout_constraintTop_toBottomOf="@id/chats_tab_icon" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/tabs_center_guide"
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/calls_tab_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:importantForAccessibility="no"
android:scaleType="centerInside"
app:layout_constraintBottom_toTopOf="@id/calls_tab_label"
app:layout_constraintEnd_toEndOf="@id/calls_tab_touch_point"
app:layout_constraintStart_toStartOf="@id/calls_tab_touch_point"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
app:lottie_rawRes="@raw/chats_32" />
<TextView
android:id="@+id/calls_tab_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.5" />
android:layout_marginTop="4dp"
android:text="@string/ConversationListTabs__calls"
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/signal_colorOnBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/calls_tab_icon"
app:layout_constraintStart_toStartOf="@id/calls_tab_icon"
app:layout_constraintTop_toBottomOf="@id/calls_tab_icon" />
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/stories_tab_icon"
@ -92,8 +157,8 @@
android:importantForAccessibility="no"
android:scaleType="centerInside"
app:layout_constraintBottom_toTopOf="@id/stories_tab_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/tabs_center_guide"
app:layout_constraintEnd_toEndOf="@id/stories_tab_touch_point"
app:layout_constraintStart_toStartOf="@id/stories_tab_touch_point"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
app:lottie_rawRes="@raw/stories_32" />
@ -129,6 +194,24 @@
app:layout_constraintTop_toTopOf="parent"
tools:text="3" />
<TextView
android:id="@+id/calls_unread_indicator"
android:layout_width="wrap_content"
android:layout_height="16sp"
android:layout_marginStart="28.5dp"
android:layout_marginTop="6dp"
android:background="@drawable/tab_unread_circle"
android:gravity="center_horizontal"
android:minWidth="16sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textAppearance="@style/Signal.Text.Caption"
android:textColor="@color/white"
android:textSize="11sp"
app:layout_constraintStart_toStartOf="@id/calls_tab_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="3" />
<TextView
android:id="@+id/stories_unread_indicator"
android:layout_width="wrap_content"
@ -147,4 +230,16 @@
app:layout_constraintTop_toTopOf="parent"
tools:text="99+" />
<androidx.constraintlayout.widget.Group
android:id="@+id/calls_tab_group"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:constraint_referenced_ids="calls_pill,calls_tab_container,calls_tab_icon,calls_tab_label,calls_tab_touch_point,calls_unread_indicator" />
<androidx.constraintlayout.widget.Group
android:id="@+id/stories_tab_group"
app:constraint_referenced_ids="stories_pill,stories_tab_container,stories_tab_icon,stories_tab_label,stories_tab_touch_point,stories_unread_indicator"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/action_filter_missed_calls" android:title="@string/CallLogFragment__filter_missed_calls" />
<item android:id="@+id/action_clear_missed_call_filter" android:title="@string/CallLogFragment__clear_filter" />
<item android:id="@+id/action_settings" android:title="@string/CallLogFragment__settings" />
<item android:id="@+id/action_notification_profile" android:title="@string/CallLogFragment__notification_profile" />
</menu>

View file

@ -18,6 +18,9 @@
<action
android:id="@+id/action_conversationListFragment_to_storiesLandingFragment"
app:destination="@id/storiesLandingFragment" />
<action
android:id="@+id/action_conversationListFragment_to_callLogFragment"
app:destination="@id/callLogFragment" />
</fragment>
<fragment
@ -30,4 +33,9 @@
android:name="org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment"
android:label="stories_landing_fragment" />
<fragment
android:id="@+id/callLogFragment"
android:name="org.thoughtcrime.securesms.calls.log.CallLogFragment"
android:label="call_log_fragment" />
</navigation>

View file

@ -4822,6 +4822,8 @@
<!-- Label for Chats tab in home app screen -->
<string name="ConversationListTabs__chats">Chats</string>
<!-- Label for Calls tab in home app screen -->
<string name="ConversationListTabs__calls">Calls</string>
<!-- Label for Stories tab in home app screen -->
<string name="ConversationListTabs__stories">Stories</string>
<!-- String for counts above 99 in conversation list tabs -->
@ -5686,5 +5688,45 @@
<!-- Shown in a time duration picker for selecting duration in hours and minutes, helper text indicating minimum allowable duration -->
<string name="TimeDurationPickerDialog_minimum_duration_warning">Minimum time before screen lock applies is 1 minute.</string>
<!-- Call Log -->
<!-- Displayed below the user's name in row items on the call log. First placeholder is the call status, second is when it occurred -->
<string name="CallLogAdapter__s_dot_s">%s &#183; %s</string>
<!-- Displayed for incoming calls -->
<string name="CallLogAdapter__incoming">Incoming</string>
<!-- Displayed for outgoing calls -->
<string name="CallLogAdapter__outgoing">Outgoing</string>
<!-- Displayed for missed calls -->
<string name="CallLogAdapter__missed">Missed</string>
<!-- Call Log context menu -->
<!-- Displayed as a context menu item to start a video call -->
<string name="CallContextMenu__video_call">Video call</string>
<!-- Displayed as a context menu item to join an ongoing group call -->
<string name="CallContextMenu__join_call">Join call</string>
<!-- Displayed as a context menu item to return to active call -->
<string name="CallContextMenu__return_to_call">Return to call</string>
<!-- Displayed as a context menu item to start an audio call -->
<string name="CallContextMenu__audio_call">Audio call</string>
<!-- Displayed as a context menu item to go to chat -->
<string name="CallContextMenu__go_to_chat">Go to chat</string>
<!-- Displayed as a context menu item to see call info -->
<string name="CallContextMenu__info">Info</string>
<!-- Displayed as a context menu item to select multiple calls -->
<string name="CallContextMenu__select">Select</string>
<!-- Displayed as a context menu item to delete this call -->
<string name="CallContextMenu__delete">Delete</string>
<!-- Call Log action bar menu -->
<!-- Action bar menu item to only display missed calls -->
<string name="CallLogFragment__filter_missed_calls">Filter missed calls</string>
<!-- Action bar menu item to clear missed call filter -->
<string name="CallLogFragment__clear_filter">Clear filter</string>
<!-- Action bar menu item to open settings -->
<string name="CallLogFragment__settings">Settings</string>
<!-- Action bar menu item to open notification profile settings -->
<string name="CallLogFragment__notification_profile">Notification Profile</string>
<!-- Call log new call content description -->
<string name="CallLogFragment__start_a_new_call">Start a new call</string>
<!-- EOF -->
</resources>

View file

@ -125,6 +125,10 @@ class SelectBuilderPart3(
return SelectBuilderPart4b(db, columns, tableName, where, whereArgs, limit)
}
fun limit(limit: Int, offset: Int): SelectBuilderPart4b {
return SelectBuilderPart4b(db, columns, tableName, where, whereArgs, "$offset,$limit")
}
fun run(): Cursor {
return db.query(
SupportSQLiteQueryBuilder
@ -152,6 +156,10 @@ class SelectBuilderPart4a(
return SelectBuilderPart5(db, columns, tableName, where, whereArgs, orderBy, limit)
}
fun limit(limit: Int, offset: Int): SelectBuilderPart5 {
return SelectBuilderPart5(db, columns, tableName, where, whereArgs, orderBy, "$offset,$limit")
}
fun run(): Cursor {
return db.query(
SupportSQLiteQueryBuilder

View file

@ -394,5 +394,15 @@ object SqlUtil {
return Query(query, args.toTypedArray())
}
class Query(val where: String, val whereArgs: Array<String>)
class Query(val where: String, val whereArgs: Array<String>) {
infix fun and(other: Query): Query {
return if (where.isNotEmpty() && other.where.isNotEmpty()) {
Query("($where) AND (${other.where})", whereArgs + other.whereArgs)
} else if (where.isNotEmpty()) {
this
} else {
other
}
}
}
}

View file

@ -282,6 +282,16 @@ public final class SqlUtilTest {
assertArrayEquals(new String[] { "5", "6" }, output.get(1).getWhereArgs());
}
@Test
public void aggregateQueries() {
SqlUtil.Query q1 = SqlUtil.buildQuery("a = ?", 1);
SqlUtil.Query q2 = SqlUtil.buildQuery("b = ?", 2);
SqlUtil.Query q3 = q1.and(q2);
assertEquals("(a = ?) AND (b = ?)", q3.getWhere());
assertArrayEquals(new String[]{"1", "2"}, q3.getWhereArgs());
}
private static byte[] hexToBytes(String hex) {
try {
return Hex.fromStringCondensed(hex);