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)
|
fun newState(state: FilterPullState, source: ConversationFilterSource)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OnCloseClicked {
|
fun interface OnCloseClicked {
|
||||||
fun onCloseClicked()
|
fun onCloseClicked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,10 @@ import org.signal.core.util.requireObject
|
||||||
import org.signal.core.util.select
|
import org.signal.core.util.select
|
||||||
import org.signal.core.util.update
|
import org.signal.core.util.update
|
||||||
import org.signal.core.util.withinTransaction
|
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.dependencies.ApplicationDependencies
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent
|
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent
|
||||||
|
|
||||||
|
@ -136,6 +139,86 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
||||||
return calls
|
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) {
|
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
|
||||||
writableDatabase
|
writableDatabase
|
||||||
.update(TABLE_NAME)
|
.update(TABLE_NAME)
|
||||||
|
|
|
@ -131,7 +131,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||||
const val ID = "_id"
|
const val ID = "_id"
|
||||||
const val SERVICE_ID = "uuid"
|
const val SERVICE_ID = "uuid"
|
||||||
const val PNI_COLUMN = "pni"
|
const val PNI_COLUMN = "pni"
|
||||||
private const val USERNAME = "username"
|
const val USERNAME = "username"
|
||||||
const val PHONE = "phone"
|
const val PHONE = "phone"
|
||||||
const val EMAIL = "email"
|
const val EMAIL = "email"
|
||||||
const val GROUP_ID = "group_id"
|
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"
|
const val FORCE_SMS_SELECTION = "force_sms_selection"
|
||||||
private const val CAPABILITIES = "capabilities"
|
private const val CAPABILITIES = "capabilities"
|
||||||
const val STORAGE_SERVICE_ID = "storage_service_key"
|
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_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 MENTION_SETTING = "mention_setting"
|
||||||
private const val STORAGE_PROTO = "storage_proto"
|
private const val STORAGE_PROTO = "storage_proto"
|
||||||
private const val LAST_SESSION_RESET = "last_session_reset"
|
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 CUSTOM_CHAT_COLORS_ID = "custom_chat_colors_id"
|
||||||
private const val BADGES = "badges"
|
private const val BADGES = "badges"
|
||||||
const val SEARCH_PROFILE_NAME = "search_signal_profile"
|
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_STATUS = "identity_status"
|
||||||
private const val IDENTITY_KEY = "identity_key"
|
private const val IDENTITY_KEY = "identity_key"
|
||||||
private const val NEEDS_PNI_SIGNATURE = "needs_pni_signature"
|
private const val NEEDS_PNI_SIGNATURE = "needs_pni_signature"
|
||||||
private const val UNREGISTERED_TIMESTAMP = "unregistered_timestamp"
|
private const val UNREGISTERED_TIMESTAMP = "unregistered_timestamp"
|
||||||
private const val HIDDEN = "hidden"
|
const val HIDDEN = "hidden"
|
||||||
const val REPORTING_TOKEN = "reporting_token"
|
const val REPORTING_TOKEN = "reporting_token"
|
||||||
|
|
||||||
@JvmField
|
@JvmField
|
||||||
|
|
|
@ -24,6 +24,7 @@ import org.signal.core.util.logging.Log
|
||||||
import org.thoughtcrime.securesms.MainActivity
|
import org.thoughtcrime.securesms.MainActivity
|
||||||
import org.thoughtcrime.securesms.R
|
import org.thoughtcrime.securesms.R
|
||||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||||
|
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||||
import org.thoughtcrime.securesms.components.Material3SearchToolbar
|
import org.thoughtcrime.securesms.components.Material3SearchToolbar
|
||||||
import org.thoughtcrime.securesms.components.TooltipPopup
|
import org.thoughtcrime.securesms.components.TooltipPopup
|
||||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
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.thoughtcrime.securesms.util.visible
|
||||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
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 {
|
companion object {
|
||||||
private val TAG = Log.tag(MainActivityListHostFragment::class.java)
|
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.conversationListFragment -> goToStateFromConversationList(state, controller)
|
||||||
R.id.conversationListArchiveFragment -> Unit
|
R.id.conversationListArchiveFragment -> Unit
|
||||||
R.id.storiesLandingFragment -> goToStateFromStories(state, controller)
|
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) {
|
private fun goToStateFromConversationList(state: ConversationListTabsState, navController: NavController) {
|
||||||
if (state.tab == ConversationListTab.CHATS) {
|
if (state.tab == ConversationListTab.CHATS) {
|
||||||
return
|
return
|
||||||
} else {
|
} else if (state.tab == ConversationListTab.STORIES) {
|
||||||
val cameraFab = requireView().findViewById<View>(R.id.camera_fab)
|
val cameraFab = requireView().findViewById<View>(R.id.camera_fab)
|
||||||
val newConvoFab = requireView().findViewById<View>(R.id.fab)
|
val newConvoFab = requireView().findViewById<View>(R.id.fab)
|
||||||
|
|
||||||
|
@ -127,14 +129,35 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||||
null,
|
null,
|
||||||
extras
|
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) {
|
private fun goToStateFromStories(state: ConversationListTabsState, navController: NavController) {
|
||||||
if (state.tab == ConversationListTab.STORIES) {
|
when (state.tab) {
|
||||||
return
|
ConversationListTab.STORIES -> return
|
||||||
} else {
|
ConversationListTab.CHATS -> navController.popBackStack()
|
||||||
|
ConversationListTab.CALLS -> {
|
||||||
navController.popBackStack()
|
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() {
|
private fun presentToolbarForMultiselect() {
|
||||||
_toolbar.visible = false
|
_toolbar.visible = false
|
||||||
if (_basicToolbar.resolved()) {
|
if (_basicToolbar.resolved()) {
|
||||||
|
@ -332,6 +359,10 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||||
conversationListTabsViewModel.isShowingArchived(false)
|
conversationListTabsViewModel.isShowingArchived(false)
|
||||||
presentToolbarForStoriesLandingFragment()
|
presentToolbarForStoriesLandingFragment()
|
||||||
}
|
}
|
||||||
|
R.id.callLogFragment -> {
|
||||||
|
conversationListTabsViewModel.isShowingArchived(false)
|
||||||
|
presentToolbarForCallLogFragment()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,5 +2,6 @@ package org.thoughtcrime.securesms.stories.tabs
|
||||||
|
|
||||||
enum class ConversationListTab {
|
enum class ConversationListTab {
|
||||||
CHATS,
|
CHATS,
|
||||||
|
CALLS,
|
||||||
STORIES
|
STORIES
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,4 +46,8 @@ class ConversationListTabRepository {
|
||||||
refresh()
|
refresh()
|
||||||
}.subscribeOn(Schedulers.io())
|
}.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.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.animation.PathInterpolatorCompat
|
import androidx.core.view.animation.PathInterpolatorCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
@ -16,6 +15,9 @@ import com.airbnb.lottie.LottieProperty
|
||||||
import com.airbnb.lottie.model.KeyPath
|
import com.airbnb.lottie.model.KeyPath
|
||||||
import org.signal.core.util.DimensionUnit
|
import org.signal.core.util.DimensionUnit
|
||||||
import org.thoughtcrime.securesms.R
|
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 org.thoughtcrime.securesms.util.visible
|
||||||
import java.text.NumberFormat
|
import java.text.NumberFormat
|
||||||
|
|
||||||
|
@ -25,33 +27,24 @@ import java.text.NumberFormat
|
||||||
class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) {
|
class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) {
|
||||||
|
|
||||||
private val viewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
|
private val viewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||||
|
private val binding by ViewBinderDelegate(ConversationListTabsBinding::bind)
|
||||||
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 var shouldBeImmediate = true
|
private var shouldBeImmediate = true
|
||||||
private var pillAnimator: Animator? = null
|
private var pillAnimator: Animator? = null
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
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)
|
val iconTint = ContextCompat.getColor(requireContext(), R.color.signal_colorOnSecondaryContainer)
|
||||||
|
|
||||||
chatsIcon.addValueCallback(
|
binding.chatsTabIcon.addValueCallback(
|
||||||
KeyPath("**"),
|
KeyPath("**"),
|
||||||
LottieProperty.COLOR
|
LottieProperty.COLOR
|
||||||
) { iconTint }
|
) { iconTint }
|
||||||
|
|
||||||
storiesIcon.addValueCallback(
|
binding.callsTabIcon.addValueCallback(
|
||||||
|
KeyPath("**"),
|
||||||
|
LottieProperty.COLOR
|
||||||
|
) { iconTint }
|
||||||
|
|
||||||
|
binding.storiesTabIcon.addValueCallback(
|
||||||
KeyPath("**"),
|
KeyPath("**"),
|
||||||
LottieProperty.COLOR
|
LottieProperty.COLOR
|
||||||
) { iconTint }
|
) { iconTint }
|
||||||
|
@ -60,10 +53,16 @@ class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) {
|
||||||
viewModel.onChatsSelected()
|
viewModel.onChatsSelected()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
view.findViewById<View>(R.id.calls_tab_touch_point).setOnClickListener {
|
||||||
|
viewModel.onCallsSelected()
|
||||||
|
}
|
||||||
|
|
||||||
view.findViewById<View>(R.id.stories_tab_touch_point).setOnClickListener {
|
view.findViewById<View>(R.id.stories_tab_touch_point).setOnClickListener {
|
||||||
viewModel.onStoriesSelected()
|
viewModel.onStoriesSelected()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.callsTabGroup.visible = FeatureFlags.callsTab()
|
||||||
|
|
||||||
viewModel.state.observe(viewLifecycleOwner) {
|
viewModel.state.observe(viewLifecycleOwner) {
|
||||||
update(it, shouldBeImmediate)
|
update(it, shouldBeImmediate)
|
||||||
shouldBeImmediate = false
|
shouldBeImmediate = false
|
||||||
|
@ -71,31 +70,39 @@ class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun update(state: ConversationListTabsState, immediate: Boolean) {
|
private fun update(state: ConversationListTabsState, immediate: Boolean) {
|
||||||
chatsIcon.isSelected = state.tab == ConversationListTab.CHATS
|
binding.chatsTabIcon.isSelected = state.tab == ConversationListTab.CHATS
|
||||||
chatsPill.isSelected = state.tab == ConversationListTab.CHATS
|
binding.chatsPill.isSelected = state.tab == ConversationListTab.CHATS
|
||||||
|
|
||||||
storiesIcon.isSelected = state.tab == ConversationListTab.STORIES
|
binding.callsTabIcon.isSelected = state.tab == ConversationListTab.CALLS
|
||||||
storiesPill.isSelected = state.tab == ConversationListTab.STORIES
|
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
|
val hasStateChange = state.tab != state.prevTab
|
||||||
if (immediate) {
|
if (immediate) {
|
||||||
chatsIcon.pauseAnimation()
|
binding.chatsTabIcon.pauseAnimation()
|
||||||
storiesIcon.pauseAnimation()
|
binding.callsTabIcon.pauseAnimation()
|
||||||
|
binding.storiesTabIcon.pauseAnimation()
|
||||||
|
|
||||||
chatsIcon.progress = if (state.tab == ConversationListTab.CHATS) 1f else 0f
|
binding.chatsTabIcon.progress = if (state.tab == ConversationListTab.CHATS) 1f else 0f
|
||||||
storiesIcon.progress = if (state.tab == ConversationListTab.STORIES) 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) {
|
} else if (hasStateChange) {
|
||||||
runLottieAnimations(chatsIcon, storiesIcon)
|
runLottieAnimations(binding.chatsTabIcon, binding.callsTabIcon, binding.storiesTabIcon)
|
||||||
runPillAnimation(150, chatsPill, storiesPill)
|
runPillAnimation(150, binding.chatsPill, binding.callsPill, binding.storiesPill)
|
||||||
}
|
}
|
||||||
|
|
||||||
chatsUnreadIndicator.visible = state.unreadMessagesCount > 0
|
binding.chatsUnreadIndicator.alpha = if (state.unreadMessagesCount > 0) 1f else 0f
|
||||||
chatsUnreadIndicator.text = formatCount(state.unreadMessagesCount)
|
binding.chatsUnreadIndicator.text = formatCount(state.unreadMessagesCount)
|
||||||
|
|
||||||
storiesUnreadIndicator.visible = state.unreadStoriesCount > 0
|
binding.callsUnreadIndicator.alpha = if (state.unreadCallsCount > 0) 1f else 0f
|
||||||
storiesUnreadIndicator.text = formatCount(state.unreadStoriesCount)
|
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()
|
requireView().visible = state.visibilityState.isVisible()
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ data class ConversationListTabsState(
|
||||||
val tab: ConversationListTab = ConversationListTab.CHATS,
|
val tab: ConversationListTab = ConversationListTab.CHATS,
|
||||||
val prevTab: ConversationListTab = ConversationListTab.STORIES,
|
val prevTab: ConversationListTab = ConversationListTab.STORIES,
|
||||||
val unreadMessagesCount: Long = 0L,
|
val unreadMessagesCount: Long = 0L,
|
||||||
|
val unreadCallsCount: Long = 0L,
|
||||||
val unreadStoriesCount: Long = 0L,
|
val unreadStoriesCount: Long = 0L,
|
||||||
val visibilityState: VisibilityState = VisibilityState()
|
val visibilityState: VisibilityState = VisibilityState()
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -27,6 +27,10 @@ class ConversationListTabsViewModel(repository: ConversationListTabRepository) :
|
||||||
store.update { it.copy(unreadMessagesCount = unreadChats) }
|
store.update { it.copy(unreadMessagesCount = unreadChats) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disposables += repository.getNumberOfUnseenCalls().subscribe { unseenCalls ->
|
||||||
|
store.update { it.copy(unreadCallsCount = unseenCalls) }
|
||||||
|
}
|
||||||
|
|
||||||
disposables += repository.getNumberOfUnseenStories().subscribe { unseenStories ->
|
disposables += repository.getNumberOfUnseenStories().subscribe { unseenStories ->
|
||||||
store.update { it.copy(unreadStoriesCount = 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) }
|
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() {
|
fun onStoriesSelected() {
|
||||||
internalTabClickEvents.onNext(ConversationListTab.STORIES)
|
internalTabClickEvents.onNext(ConversationListTab.STORIES)
|
||||||
store.update { it.copy(tab = ConversationListTab.STORIES, prevTab = it.tab) }
|
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 PAYPAL_RECURRING_DONATIONS = "android.recurringPayPalDonations.3";
|
||||||
private static final String TEXT_FORMATTING = "android.textFormatting";
|
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 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
|
* 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_ONE_TIME_DONATIONS,
|
||||||
PAYPAL_RECURRING_DONATIONS,
|
PAYPAL_RECURRING_DONATIONS,
|
||||||
TEXT_FORMATTING,
|
TEXT_FORMATTING,
|
||||||
ANY_ADDRESS_PORTS_KILL_SWITCH
|
ANY_ADDRESS_PORTS_KILL_SWITCH,
|
||||||
|
CALLS_TAB
|
||||||
);
|
);
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
|
@ -584,6 +586,13 @@ public final class FeatureFlags {
|
||||||
return getBoolean(ANY_ADDRESS_PORTS_KILL_SWITCH, false);
|
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. */
|
/** Only for rendering debug info. */
|
||||||
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
||||||
return new TreeMap<>(REMOTE_VALUES);
|
return new TreeMap<>(REMOTE_VALUES);
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package org.thoughtcrime.securesms.util
|
package org.thoughtcrime.securesms.util
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.constraintlayout.widget.ConstraintSet
|
import androidx.constraintlayout.widget.ConstraintSet
|
||||||
|
|
||||||
|
@ -28,3 +30,17 @@ inline fun View.doOnEachLayout(crossinline action: (view: View) -> Unit): View.O
|
||||||
addOnLayoutChangeListener(listener)
|
addOnLayoutChangeListener(listener)
|
||||||
return 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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
tools:viewBindingIgnore="true"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="@color/signal_colorSurface2"
|
android:background="@color/signal_colorSurface2"
|
||||||
android:minHeight="80dp"
|
android:minHeight="80dp">
|
||||||
android:paddingHorizontal="@dimen/dsl_settings_gutter">
|
|
||||||
|
<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
|
<View
|
||||||
android:id="@+id/chats_tab_touch_point"
|
android:id="@+id/chats_tab_touch_point"
|
||||||
|
@ -15,8 +40,18 @@
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:background="?selectableItemBackgroundBorderless"
|
android:background="?selectableItemBackgroundBorderless"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="@id/chats_tab_icon"
|
app:layout_constraintEnd_toEndOf="@id/chats_tab_container"
|
||||||
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
|
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" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
@ -25,8 +60,8 @@
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:background="?selectableItemBackgroundBorderless"
|
android:background="?selectableItemBackgroundBorderless"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="@id/stories_tab_icon"
|
app:layout_constraintEnd_toEndOf="@id/stories_tab_container"
|
||||||
app:layout_constraintStart_toStartOf="@id/stories_tab_icon"
|
app:layout_constraintStart_toStartOf="@id/stories_tab_container"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
|
@ -40,6 +75,17 @@
|
||||||
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
|
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
|
||||||
app:layout_constraintTop_toTopOf="@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
|
<ImageView
|
||||||
android:id="@+id/stories_pill"
|
android:id="@+id/stories_pill"
|
||||||
android:layout_width="64dp"
|
android:layout_width="64dp"
|
||||||
|
@ -59,8 +105,8 @@
|
||||||
android:importantForAccessibility="no"
|
android:importantForAccessibility="no"
|
||||||
android:scaleType="centerInside"
|
android:scaleType="centerInside"
|
||||||
app:layout_constraintBottom_toTopOf="@id/chats_tab_label"
|
app:layout_constraintBottom_toTopOf="@id/chats_tab_label"
|
||||||
app:layout_constraintEnd_toStartOf="@id/tabs_center_guide"
|
app:layout_constraintEnd_toEndOf="@id/chats_tab_touch_point"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="@id/chats_tab_touch_point"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintVertical_chainStyle="packed"
|
app:layout_constraintVertical_chainStyle="packed"
|
||||||
app:lottie_rawRes="@raw/chats_32" />
|
app:lottie_rawRes="@raw/chats_32" />
|
||||||
|
@ -78,12 +124,31 @@
|
||||||
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
|
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
|
||||||
app:layout_constraintTop_toBottomOf="@id/chats_tab_icon" />
|
app:layout_constraintTop_toBottomOf="@id/chats_tab_icon" />
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.Guideline
|
<com.airbnb.lottie.LottieAnimationView
|
||||||
android:id="@+id/tabs_center_guide"
|
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_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:layout_marginTop="4dp"
|
||||||
app:layout_constraintGuide_percent="0.5" />
|
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
|
<com.airbnb.lottie.LottieAnimationView
|
||||||
android:id="@+id/stories_tab_icon"
|
android:id="@+id/stories_tab_icon"
|
||||||
|
@ -92,8 +157,8 @@
|
||||||
android:importantForAccessibility="no"
|
android:importantForAccessibility="no"
|
||||||
android:scaleType="centerInside"
|
android:scaleType="centerInside"
|
||||||
app:layout_constraintBottom_toTopOf="@id/stories_tab_label"
|
app:layout_constraintBottom_toTopOf="@id/stories_tab_label"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="@id/stories_tab_touch_point"
|
||||||
app:layout_constraintStart_toEndOf="@id/tabs_center_guide"
|
app:layout_constraintStart_toStartOf="@id/stories_tab_touch_point"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintVertical_chainStyle="packed"
|
app:layout_constraintVertical_chainStyle="packed"
|
||||||
app:lottie_rawRes="@raw/stories_32" />
|
app:lottie_rawRes="@raw/stories_32" />
|
||||||
|
@ -129,6 +194,24 @@
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:text="3" />
|
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
|
<TextView
|
||||||
android:id="@+id/stories_unread_indicator"
|
android:id="@+id/stories_unread_indicator"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
@ -147,4 +230,16 @@
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:text="99+" />
|
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>
|
</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
|
<action
|
||||||
android:id="@+id/action_conversationListFragment_to_storiesLandingFragment"
|
android:id="@+id/action_conversationListFragment_to_storiesLandingFragment"
|
||||||
app:destination="@id/storiesLandingFragment" />
|
app:destination="@id/storiesLandingFragment" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_conversationListFragment_to_callLogFragment"
|
||||||
|
app:destination="@id/callLogFragment" />
|
||||||
</fragment>
|
</fragment>
|
||||||
|
|
||||||
<fragment
|
<fragment
|
||||||
|
@ -30,4 +33,9 @@
|
||||||
android:name="org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment"
|
android:name="org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment"
|
||||||
android:label="stories_landing_fragment" />
|
android:label="stories_landing_fragment" />
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/callLogFragment"
|
||||||
|
android:name="org.thoughtcrime.securesms.calls.log.CallLogFragment"
|
||||||
|
android:label="call_log_fragment" />
|
||||||
|
|
||||||
</navigation>
|
</navigation>
|
|
@ -4822,6 +4822,8 @@
|
||||||
|
|
||||||
<!-- Label for Chats tab in home app screen -->
|
<!-- Label for Chats tab in home app screen -->
|
||||||
<string name="ConversationListTabs__chats">Chats</string>
|
<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 -->
|
<!-- Label for Stories tab in home app screen -->
|
||||||
<string name="ConversationListTabs__stories">Stories</string>
|
<string name="ConversationListTabs__stories">Stories</string>
|
||||||
<!-- String for counts above 99 in conversation list tabs -->
|
<!-- 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 -->
|
<!-- 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>
|
<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 -->
|
<!-- EOF -->
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -125,6 +125,10 @@ class SelectBuilderPart3(
|
||||||
return SelectBuilderPart4b(db, columns, tableName, where, whereArgs, limit)
|
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 {
|
fun run(): Cursor {
|
||||||
return db.query(
|
return db.query(
|
||||||
SupportSQLiteQueryBuilder
|
SupportSQLiteQueryBuilder
|
||||||
|
@ -152,6 +156,10 @@ class SelectBuilderPart4a(
|
||||||
return SelectBuilderPart5(db, columns, tableName, where, whereArgs, orderBy, limit)
|
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 {
|
fun run(): Cursor {
|
||||||
return db.query(
|
return db.query(
|
||||||
SupportSQLiteQueryBuilder
|
SupportSQLiteQueryBuilder
|
||||||
|
|
|
@ -394,5 +394,15 @@ object SqlUtil {
|
||||||
return Query(query, args.toTypedArray())
|
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());
|
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) {
|
private static byte[] hexToBytes(String hex) {
|
||||||
try {
|
try {
|
||||||
return Hex.fromStringCondensed(hex);
|
return Hex.fromStringCondensed(hex);
|
||||||
|
|
Loading…
Add table
Reference in a new issue