diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogActionMode.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogActionMode.kt new file mode 100644 index 0000000000..cc9c52595d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogActionMode.kt @@ -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().onMultiSelectStarted() + } + + fun end() { + fragment.requireListener().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() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt new file mode 100644 index 0000000000..327a7b9ad3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt @@ -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() { + + 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, 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 { + 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 { + 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(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(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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt new file mode 100644 index 0000000000..a86706877c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFilter.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFilter.kt new file mode 100644 index 0000000000..98036ca32c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFilter.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt new file mode 100644 index 0000000000..ca3165250f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -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.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((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().getSearchToolbar().resolved() && + requireListener().getSearchToolbar().get().getVisibility() == View.VISIBLE + } + + interface Callback { + fun onMultiSelectStarted() + fun onMultiSelectFinished() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt new file mode 100644 index 0000000000..20d5be5521 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt @@ -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 { + + 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 { + val calls: MutableList = 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt new file mode 100644 index 0000000000..97fe4369f6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt @@ -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 { + return SignalDatabase.calls.getCalls(start, length, query, filter) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt new file mode 100644 index 0000000000..b279cb7b45 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogSelectionState.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogSelectionState.kt new file mode 100644 index 0000000000..6dbcad8a73 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogSelectionState.kt @@ -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) : 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) : 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()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt new file mode 100644 index 0000000000..b5d8fe232a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt @@ -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> = callLogStore + .stateFlowable + .toObservable() + .map { (query, filter) -> + PagedData.createForObservable( + CallLogPagedDataSource(query, filter, callLogRepository), + pagingConfig + ) + } + + val controller: Observable> = pagedData.map { it.controller } + val data: Observable> = pagedData.switchMap { it.data } + val selected: Observable = 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() + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt new file mode 100644 index 0000000000..6d5832c008 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt @@ -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() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallFragment.kt new file mode 100644 index 0000000000..efa838163c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallFragment.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ConversationListFilterPullView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ConversationListFilterPullView.kt index 286aab95a3..7f434ada7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ConversationListFilterPullView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ConversationListFilterPullView.kt @@ -275,11 +275,11 @@ class ConversationListFilterPullView @JvmOverloads constructor( } } - interface OnFilterStateChanged { + fun interface OnFilterStateChanged { fun newState(state: FilterPullState, source: ConversationFilterSource) } - interface OnCloseClicked { + fun interface OnCloseClicked { fun onCloseClicked() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt index eb5262f87f..06cafb0dca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt @@ -14,7 +14,10 @@ import org.signal.core.util.requireObject import org.signal.core.util.select import org.signal.core.util.update import org.signal.core.util.withinTransaction +import org.thoughtcrime.securesms.calls.log.CallLogFilter +import org.thoughtcrime.securesms.calls.log.CallLogRow import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent @@ -136,6 +139,86 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl return calls } + private fun getCallsCursor(isCount: Boolean, offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): Cursor { + val filterClause = when (filter) { + CallLogFilter.ALL -> SqlUtil.buildQuery("") + CallLogFilter.MISSED -> SqlUtil.buildQuery("$EVENT == ${Event.serialize(Event.MISSED)}") + } + + val queryClause = if (!searchTerm.isNullOrEmpty()) { + val glob = SqlUtil.buildCaseInsensitiveGlobPattern(searchTerm) + val selection = + """ + ${RecipientTable.TABLE_NAME}.${RecipientTable.BLOCKED} = ? AND ${RecipientTable.TABLE_NAME}.${RecipientTable.HIDDEN} = ? AND + ( + sort_name GLOB ? OR + ${RecipientTable.TABLE_NAME}.${RecipientTable.USERNAME} GLOB ? OR + ${RecipientTable.TABLE_NAME}.${RecipientTable.PHONE} GLOB ? OR + ${RecipientTable.TABLE_NAME}.${RecipientTable.EMAIL} GLOB ? + ) + """.trimIndent() + SqlUtil.buildQuery(selection, 0, 0, glob, glob, glob, glob) + } else { + SqlUtil.buildQuery("") + } + + val whereClause = filterClause and queryClause + val where = if (whereClause.where.isNotEmpty()) { + "WHERE ${whereClause.where}" + } else { + "" + } + + val offsetLimit = if (limit > 0) { + "LIMIT $offset,$limit" + } else { + "" + } + + //language=sql + val statement = """ + SELECT + ${if (isCount) "COUNT(*)," else "$TABLE_NAME.*, ${MessageTable.DATE_RECEIVED},"} + LOWER( + COALESCE( + NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_JOINED_NAME}, ''), + NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_GIVEN_NAME}, ''), + NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_JOINED_NAME}, ''), + NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_GIVEN_NAME}, ''), + NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.USERNAME}, '') + ) + ) AS sort_name + FROM $TABLE_NAME + INNER JOIN ${RecipientTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = $TABLE_NAME.$PEER + INNER JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $TABLE_NAME.$MESSAGE_ID + $where + ORDER BY ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} DESC + $offsetLimit + """.trimIndent() + + return readableDatabase.query(statement, whereClause.whereArgs) + } + + fun getCallsCount(searchTerm: String?, filter: CallLogFilter): Int { + return getCallsCursor(true, 0, 0, searchTerm, filter).use { + it.moveToFirst() + it.getInt(0) + } + } + + fun getCalls(offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): List { + return getCallsCursor(false, offset, limit, searchTerm, filter).readToList { + val call = Call.deserialize(it) + val recipient = Recipient.resolved(call.peer) + val date = it.requireLong(MessageTable.DATE_RECEIVED) + CallLogRow.Call( + call = call, + peer = recipient, + date = date + ) + } + } + override fun remapRecipient(fromId: RecipientId, toId: RecipientId) { writableDatabase .update(TABLE_NAME) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index 65e0e1285f..5d812e6783 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -131,7 +131,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da const val ID = "_id" const val SERVICE_ID = "uuid" const val PNI_COLUMN = "pni" - private const val USERNAME = "username" + const val USERNAME = "username" const val PHONE = "phone" const val EMAIL = "email" const val GROUP_ID = "group_id" @@ -167,9 +167,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da const val FORCE_SMS_SELECTION = "force_sms_selection" private const val CAPABILITIES = "capabilities" const val STORAGE_SERVICE_ID = "storage_service_key" - private const val PROFILE_GIVEN_NAME = "signal_profile_name" + const val PROFILE_GIVEN_NAME = "signal_profile_name" private const val PROFILE_FAMILY_NAME = "profile_family_name" - private const val PROFILE_JOINED_NAME = "profile_joined_name" + const val PROFILE_JOINED_NAME = "profile_joined_name" private const val MENTION_SETTING = "mention_setting" private const val STORAGE_PROTO = "storage_proto" private const val LAST_SESSION_RESET = "last_session_reset" @@ -183,12 +183,12 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da private const val CUSTOM_CHAT_COLORS_ID = "custom_chat_colors_id" private const val BADGES = "badges" const val SEARCH_PROFILE_NAME = "search_signal_profile" - private const val SORT_NAME = "sort_name" + const val SORT_NAME = "sort_name" private const val IDENTITY_STATUS = "identity_status" private const val IDENTITY_KEY = "identity_key" private const val NEEDS_PNI_SIGNATURE = "needs_pni_signature" private const val UNREGISTERED_TIMESTAMP = "unregistered_timestamp" - private const val HIDDEN = "hidden" + const val HIDDEN = "hidden" const val REPORTING_TOKEN = "reporting_token" @JvmField diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt index e9c0d19228..7f0f324c10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt @@ -24,6 +24,7 @@ import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.MainActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.badges.BadgeImageView +import org.thoughtcrime.securesms.calls.log.CallLogFragment import org.thoughtcrime.securesms.components.Material3SearchToolbar import org.thoughtcrime.securesms.components.TooltipPopup import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity @@ -48,7 +49,7 @@ import org.thoughtcrime.securesms.util.views.Stub import org.thoughtcrime.securesms.util.visible import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState -class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_fragment), ConversationListFragment.Callback, Material3OnScrollHelperBinder { +class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_fragment), ConversationListFragment.Callback, Material3OnScrollHelperBinder, CallLogFragment.Callback { companion object { private val TAG = Log.tag(MainActivityListHostFragment::class.java) @@ -98,6 +99,7 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f R.id.conversationListFragment -> goToStateFromConversationList(state, controller) R.id.conversationListArchiveFragment -> Unit R.id.storiesLandingFragment -> goToStateFromStories(state, controller) + R.id.callLogFragment -> goToStateFromCalling(state, controller) } } } @@ -105,7 +107,7 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f private fun goToStateFromConversationList(state: ConversationListTabsState, navController: NavController) { if (state.tab == ConversationListTab.CHATS) { return - } else { + } else if (state.tab == ConversationListTab.STORIES) { val cameraFab = requireView().findViewById(R.id.camera_fab) val newConvoFab = requireView().findViewById(R.id.fab) @@ -127,14 +129,35 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f null, extras ) + } else { + navController.navigate( + R.id.action_conversationListFragment_to_callLogFragment, + null, + null, + null + ) + } + } + + private fun goToStateFromCalling(state: ConversationListTabsState, navController: NavController) { + when (state.tab) { + ConversationListTab.CALLS -> return + ConversationListTab.CHATS -> navController.popBackStack() + ConversationListTab.STORIES -> { + navController.popBackStack() + goToStateFromConversationList(state, navController) + } } } private fun goToStateFromStories(state: ConversationListTabsState, navController: NavController) { - if (state.tab == ConversationListTab.STORIES) { - return - } else { - navController.popBackStack() + when (state.tab) { + ConversationListTab.STORIES -> return + ConversationListTab.CHATS -> navController.popBackStack() + ConversationListTab.CALLS -> { + navController.popBackStack() + goToStateFromConversationList(state, navController) + } } } @@ -182,6 +205,10 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f } } + private fun presentToolbarForCallLogFragment() { + presentToolbarForConversationListFragment() + } + private fun presentToolbarForMultiselect() { _toolbar.visible = false if (_basicToolbar.resolved()) { @@ -332,6 +359,10 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f conversationListTabsViewModel.isShowingArchived(false) presentToolbarForStoriesLandingFragment() } + R.id.callLogFragment -> { + conversationListTabsViewModel.isShowingArchived(false) + presentToolbarForCallLogFragment() + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTab.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTab.kt index 06f7d7ab2e..41e5cf5df9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTab.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTab.kt @@ -2,5 +2,6 @@ package org.thoughtcrime.securesms.stories.tabs enum class ConversationListTab { CHATS, + CALLS, STORIES } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabRepository.kt index 0ade8786af..3e00821787 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabRepository.kt @@ -46,4 +46,8 @@ class ConversationListTabRepository { refresh() }.subscribeOn(Schedulers.io()) } + + fun getNumberOfUnseenCalls(): Observable { + return Observable.just(99) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt index 6eb586dcea..90d54dd217 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt @@ -6,7 +6,6 @@ import android.animation.ValueAnimator import android.os.Bundle import android.view.View import android.widget.ImageView -import android.widget.TextView import androidx.core.content.ContextCompat import androidx.core.view.animation.PathInterpolatorCompat import androidx.fragment.app.Fragment @@ -16,6 +15,9 @@ import com.airbnb.lottie.LottieProperty import com.airbnb.lottie.model.KeyPath import org.signal.core.util.DimensionUnit import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.databinding.ConversationListTabsBinding +import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.visible import java.text.NumberFormat @@ -25,33 +27,24 @@ import java.text.NumberFormat class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) { private val viewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() }) - - private lateinit var chatsUnreadIndicator: TextView - private lateinit var storiesUnreadIndicator: TextView - private lateinit var chatsIcon: LottieAnimationView - private lateinit var storiesIcon: LottieAnimationView - private lateinit var chatsPill: ImageView - private lateinit var storiesPill: ImageView - + private val binding by ViewBinderDelegate(ConversationListTabsBinding::bind) private var shouldBeImmediate = true private var pillAnimator: Animator? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - chatsUnreadIndicator = view.findViewById(R.id.chats_unread_indicator) - storiesUnreadIndicator = view.findViewById(R.id.stories_unread_indicator) - chatsIcon = view.findViewById(R.id.chats_tab_icon) - storiesIcon = view.findViewById(R.id.stories_tab_icon) - chatsPill = view.findViewById(R.id.chats_pill) - storiesPill = view.findViewById(R.id.stories_pill) - val iconTint = ContextCompat.getColor(requireContext(), R.color.signal_colorOnSecondaryContainer) - chatsIcon.addValueCallback( + binding.chatsTabIcon.addValueCallback( KeyPath("**"), LottieProperty.COLOR ) { iconTint } - storiesIcon.addValueCallback( + binding.callsTabIcon.addValueCallback( + KeyPath("**"), + LottieProperty.COLOR + ) { iconTint } + + binding.storiesTabIcon.addValueCallback( KeyPath("**"), LottieProperty.COLOR ) { iconTint } @@ -60,10 +53,16 @@ class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) { viewModel.onChatsSelected() } + view.findViewById(R.id.calls_tab_touch_point).setOnClickListener { + viewModel.onCallsSelected() + } + view.findViewById(R.id.stories_tab_touch_point).setOnClickListener { viewModel.onStoriesSelected() } + binding.callsTabGroup.visible = FeatureFlags.callsTab() + viewModel.state.observe(viewLifecycleOwner) { update(it, shouldBeImmediate) shouldBeImmediate = false @@ -71,31 +70,39 @@ class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) { } private fun update(state: ConversationListTabsState, immediate: Boolean) { - chatsIcon.isSelected = state.tab == ConversationListTab.CHATS - chatsPill.isSelected = state.tab == ConversationListTab.CHATS + binding.chatsTabIcon.isSelected = state.tab == ConversationListTab.CHATS + binding.chatsPill.isSelected = state.tab == ConversationListTab.CHATS - storiesIcon.isSelected = state.tab == ConversationListTab.STORIES - storiesPill.isSelected = state.tab == ConversationListTab.STORIES + binding.callsTabIcon.isSelected = state.tab == ConversationListTab.CALLS + binding.callsPill.isSelected = state.tab == ConversationListTab.CALLS + + binding.storiesTabIcon.isSelected = state.tab == ConversationListTab.STORIES + binding.storiesPill.isSelected = state.tab == ConversationListTab.STORIES val hasStateChange = state.tab != state.prevTab if (immediate) { - chatsIcon.pauseAnimation() - storiesIcon.pauseAnimation() + binding.chatsTabIcon.pauseAnimation() + binding.callsTabIcon.pauseAnimation() + binding.storiesTabIcon.pauseAnimation() - chatsIcon.progress = if (state.tab == ConversationListTab.CHATS) 1f else 0f - storiesIcon.progress = if (state.tab == ConversationListTab.STORIES) 1f else 0f + binding.chatsTabIcon.progress = if (state.tab == ConversationListTab.CHATS) 1f else 0f + binding.callsTabIcon.progress = if (state.tab == ConversationListTab.CALLS) 1f else 0f + binding.storiesTabIcon.progress = if (state.tab == ConversationListTab.STORIES) 1f else 0f - runPillAnimation(0, chatsPill, storiesPill) + runPillAnimation(0, binding.chatsPill, binding.callsPill, binding.storiesPill) } else if (hasStateChange) { - runLottieAnimations(chatsIcon, storiesIcon) - runPillAnimation(150, chatsPill, storiesPill) + runLottieAnimations(binding.chatsTabIcon, binding.callsTabIcon, binding.storiesTabIcon) + runPillAnimation(150, binding.chatsPill, binding.callsPill, binding.storiesPill) } - chatsUnreadIndicator.visible = state.unreadMessagesCount > 0 - chatsUnreadIndicator.text = formatCount(state.unreadMessagesCount) + binding.chatsUnreadIndicator.alpha = if (state.unreadMessagesCount > 0) 1f else 0f + binding.chatsUnreadIndicator.text = formatCount(state.unreadMessagesCount) - storiesUnreadIndicator.visible = state.unreadStoriesCount > 0 - storiesUnreadIndicator.text = formatCount(state.unreadStoriesCount) + binding.callsUnreadIndicator.alpha = if (state.unreadCallsCount > 0) 1f else 0f + binding.callsUnreadIndicator.text = formatCount(state.unreadCallsCount) + + binding.storiesUnreadIndicator.alpha = if (state.unreadStoriesCount > 0) 1f else 0f + binding.storiesUnreadIndicator.text = formatCount(state.unreadStoriesCount) requireView().visible = state.visibilityState.isVisible() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsState.kt index daf944eff9..059cbca1a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsState.kt @@ -4,6 +4,7 @@ data class ConversationListTabsState( val tab: ConversationListTab = ConversationListTab.CHATS, val prevTab: ConversationListTab = ConversationListTab.STORIES, val unreadMessagesCount: Long = 0L, + val unreadCallsCount: Long = 0L, val unreadStoriesCount: Long = 0L, val visibilityState: VisibilityState = VisibilityState() ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsViewModel.kt index 9d27929163..45fbaa01eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsViewModel.kt @@ -27,6 +27,10 @@ class ConversationListTabsViewModel(repository: ConversationListTabRepository) : store.update { it.copy(unreadMessagesCount = unreadChats) } } + disposables += repository.getNumberOfUnseenCalls().subscribe { unseenCalls -> + store.update { it.copy(unreadCallsCount = unseenCalls) } + } + disposables += repository.getNumberOfUnseenStories().subscribe { unseenStories -> store.update { it.copy(unreadStoriesCount = unseenStories) } } @@ -41,6 +45,11 @@ class ConversationListTabsViewModel(repository: ConversationListTabRepository) : store.update { it.copy(tab = ConversationListTab.CHATS, prevTab = it.tab) } } + fun onCallsSelected() { + internalTabClickEvents.onNext(ConversationListTab.CALLS) + store.update { it.copy(tab = ConversationListTab.CALLS, prevTab = it.tab) } + } + fun onStoriesSelected() { internalTabClickEvents.onNext(ConversationListTab.STORIES) store.update { it.copy(tab = ConversationListTab.STORIES, prevTab = it.tab) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 0f8e650b83..715c78c046 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -106,6 +106,7 @@ public final class FeatureFlags { private static final String PAYPAL_RECURRING_DONATIONS = "android.recurringPayPalDonations.3"; private static final String TEXT_FORMATTING = "android.textFormatting"; private static final String ANY_ADDRESS_PORTS_KILL_SWITCH = "android.calling.fieldTrial.anyAddressPortsKillSwitch"; + private static final String CALLS_TAB = "android.calls.tab"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -162,7 +163,8 @@ public final class FeatureFlags { PAYPAL_ONE_TIME_DONATIONS, PAYPAL_RECURRING_DONATIONS, TEXT_FORMATTING, - ANY_ADDRESS_PORTS_KILL_SWITCH + ANY_ADDRESS_PORTS_KILL_SWITCH, + CALLS_TAB ); @VisibleForTesting @@ -584,6 +586,13 @@ public final class FeatureFlags { return getBoolean(ANY_ADDRESS_PORTS_KILL_SWITCH, false); } + /** + * Whether or not the calls tab is enabled + */ + public static boolean callsTab() { + return getBoolean(CALLS_TAB, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt index 60a93741a3..d8bb92cea2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.util import android.view.View +import android.widget.TextView +import androidx.annotation.DrawableRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet @@ -28,3 +30,17 @@ inline fun View.doOnEachLayout(crossinline action: (view: View) -> Unit): View.O addOnLayoutChangeListener(listener) return listener } + +fun TextView.setRelativeDrawables( + @DrawableRes start: Int = 0, + @DrawableRes top: Int = 0, + @DrawableRes bottom: Int = 0, + @DrawableRes end: Int = 0 +) { + setCompoundDrawablesRelativeWithIntrinsicBounds( + start, + top, + end, + bottom + ) +} diff --git a/app/src/main/res/layout/call_log_adapter_item.xml b/app/src/main/res/layout/call_log_adapter_item.xml new file mode 100644 index 0000000000..6fb9e2aabe --- /dev/null +++ b/app/src/main/res/layout/call_log_adapter_item.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/call_log_fragment.xml b/app/src/main/res/layout/call_log_fragment.xml new file mode 100644 index 0000000000..639c2a83ef --- /dev/null +++ b/app/src/main/res/layout/call_log_fragment.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_list_tabs.xml b/app/src/main/res/layout/conversation_list_tabs.xml index ccbc6a31e4..ef197d349c 100644 --- a/app/src/main/res/layout/conversation_list_tabs.xml +++ b/app/src/main/res/layout/conversation_list_tabs.xml @@ -1,13 +1,38 @@ + android:minHeight="80dp"> + + + + + + + + + + @@ -78,12 +124,31 @@ app:layout_constraintStart_toStartOf="@id/chats_tab_icon" app:layout_constraintTop_toBottomOf="@id/chats_tab_icon" /> - + + + android:layout_marginTop="4dp" + android:text="@string/ConversationListTabs__calls" + android:textAppearance="@style/TextAppearance.Signal.Body2" + android:textColor="@color/signal_colorOnBackground" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="@id/calls_tab_icon" + app:layout_constraintStart_toStartOf="@id/calls_tab_icon" + app:layout_constraintTop_toBottomOf="@id/calls_tab_icon" /> @@ -129,6 +194,24 @@ app:layout_constraintTop_toTopOf="parent" tools:text="3" /> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/new_call_activity.xml b/app/src/main/res/layout/new_call_activity.xml new file mode 100644 index 0000000000..77d9ef65f8 --- /dev/null +++ b/app/src/main/res/layout/new_call_activity.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/calls_tab_menu.xml b/app/src/main/res/menu/calls_tab_menu.xml new file mode 100644 index 0000000000..d0679d8975 --- /dev/null +++ b/app/src/main/res/menu/calls_tab_menu.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main_activity_list.xml b/app/src/main/res/navigation/main_activity_list.xml index e72c0df523..3e5ceabe1a 100644 --- a/app/src/main/res/navigation/main_activity_list.xml +++ b/app/src/main/res/navigation/main_activity_list.xml @@ -18,6 +18,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5063395bb9..ac4eb6a4da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4822,6 +4822,8 @@ Chats + + Calls Stories @@ -5686,5 +5688,45 @@ Minimum time before screen lock applies is 1 minute. + + + %s · %s + + Incoming + + Outgoing + + Missed + + + + Video call + + Join call + + Return to call + + Audio call + + Go to chat + + Info + + Select + + Delete + + + + Filter missed calls + + Clear filter + + Settings + + Notification Profile + + Start a new call + diff --git a/core-util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt b/core-util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt index 2d3d48a4ae..8c057838ab 100644 --- a/core-util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt @@ -125,6 +125,10 @@ class SelectBuilderPart3( return SelectBuilderPart4b(db, columns, tableName, where, whereArgs, limit) } + fun limit(limit: Int, offset: Int): SelectBuilderPart4b { + return SelectBuilderPart4b(db, columns, tableName, where, whereArgs, "$offset,$limit") + } + fun run(): Cursor { return db.query( SupportSQLiteQueryBuilder @@ -152,6 +156,10 @@ class SelectBuilderPart4a( return SelectBuilderPart5(db, columns, tableName, where, whereArgs, orderBy, limit) } + fun limit(limit: Int, offset: Int): SelectBuilderPart5 { + return SelectBuilderPart5(db, columns, tableName, where, whereArgs, orderBy, "$offset,$limit") + } + fun run(): Cursor { return db.query( SupportSQLiteQueryBuilder diff --git a/core-util/src/main/java/org/signal/core/util/SqlUtil.kt b/core-util/src/main/java/org/signal/core/util/SqlUtil.kt index 9149bf4768..18af0d3163 100644 --- a/core-util/src/main/java/org/signal/core/util/SqlUtil.kt +++ b/core-util/src/main/java/org/signal/core/util/SqlUtil.kt @@ -394,5 +394,15 @@ object SqlUtil { return Query(query, args.toTypedArray()) } - class Query(val where: String, val whereArgs: Array) + class Query(val where: String, val whereArgs: Array) { + 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 + } + } + } } diff --git a/core-util/src/test/java/org/signal/core/util/SqlUtilTest.java b/core-util/src/test/java/org/signal/core/util/SqlUtilTest.java index a7c9e1f993..368a3e39d4 100644 --- a/core-util/src/test/java/org/signal/core/util/SqlUtilTest.java +++ b/core-util/src/test/java/org/signal/core/util/SqlUtilTest.java @@ -282,6 +282,16 @@ public final class SqlUtilTest { assertArrayEquals(new String[] { "5", "6" }, output.get(1).getWhereArgs()); } + @Test + public void aggregateQueries() { + SqlUtil.Query q1 = SqlUtil.buildQuery("a = ?", 1); + SqlUtil.Query q2 = SqlUtil.buildQuery("b = ?", 2); + SqlUtil.Query q3 = q1.and(q2); + + assertEquals("(a = ?) AND (b = ?)", q3.getWhere()); + assertArrayEquals(new String[]{"1", "2"}, q3.getWhereArgs()); + } + private static byte[] hexToBytes(String hex) { try { return Hex.fromStringCondensed(hex);