Add initial implementation of calls tab behind a feature flag.
This commit is contained in:
parent
d1373d2767
commit
88de0f21e7
33 changed files with 1464 additions and 63 deletions
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,5 +2,6 @@ package org.thoughtcrime.securesms.stories.tabs
|
|||
|
||||
enum class ConversationListTab {
|
||||
CHATS,
|
||||
CALLS,
|
||||
STORIES
|
||||
}
|
||||
|
|
|
@ -46,4 +46,8 @@ class ConversationListTabRepository {
|
|||
refresh()
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getNumberOfUnseenCalls(): Observable<Long> {
|
||||
return Observable.just(99)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
) {
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
129
app/src/main/res/layout/call_log_adapter_item.xml
Normal file
129
app/src/main/res/layout/call_log_adapter_item.xml
Normal 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 · 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>
|
61
app/src/main/res/layout/call_log_fragment.xml
Normal file
61
app/src/main/res/layout/call_log_fragment.xml
Normal 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>
|
|
@ -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>
|
6
app/src/main/res/layout/new_call_activity.xml
Normal file
6
app/src/main/res/layout/new_call_activity.xml
Normal 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>
|
7
app/src/main/res/menu/calls_tab_menu.xml
Normal file
7
app/src/main/res/menu/calls_tab_menu.xml
Normal 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>
|
|
@ -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>
|
|
@ -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 · %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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Reference in a new issue