From 4b09f4a6546b017b4bff9b4e7cfa52b193219e6e Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Wed, 17 May 2023 10:28:50 -0400 Subject: [PATCH] Add basic attachment keyboard support to CFv2. --- .../components/InputAwareConstraintLayout.kt | 97 ++++++++++++ .../InsetAwareConstraintLayout.java | 94 ------------ .../components/InsetAwareConstraintLayout.kt | 142 ++++++++++++++++++ .../conversation/v2/ConversationFragment.kt | 85 ++++++++++- .../v2/keyboard/AttachmentKeyboardFragment.kt | 86 +++++++++++ .../keyboard/AttachmentKeyboardViewModel.kt | 33 ++++ .../keyvalue/MiscellaneousValues.java | 40 +++++ .../securesms/mediasend/MediaRepository.java | 23 +++ .../layout/attachment_keyboard_fragment.xml | 20 +++ .../main/res/layout/conversation_activity.xml | 2 +- .../main/res/layout/system_ui_guidelines.xml | 7 + .../res/layout/v2_conversation_fragment.xml | 39 +++-- .../util/concurrent/LifecycleDisposable.kt | 2 + 13 files changed, 555 insertions(+), 115 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/InputAwareConstraintLayout.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardViewModel.kt create mode 100644 app/src/main/res/layout/attachment_keyboard_fragment.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareConstraintLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareConstraintLayout.kt new file mode 100644 index 0000000000..b3a3863b7c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareConstraintLayout.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components + +import android.content.Context +import android.util.AttributeSet +import android.widget.EditText +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.ViewUtil + +/** + * A flavor of [InsetAwareConstraintLayout] that allows "replacing" the keyboard with our + * own input fragment. + */ +class InputAwareConstraintLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : InsetAwareConstraintLayout(context, attrs, defStyleAttr) { + + private var inputId: Int? = null + private var input: Fragment? = null + + lateinit var fragmentManager: FragmentManager + var listener: Listener? = null + + fun showSoftkey(editText: EditText) { + ViewUtil.focusAndShowKeyboard(editText) + hideInput(resetKeyboardGuideline = false) + } + + fun toggleInput(fragmentCreator: FragmentCreator, imeTarget: EditText, toggled: (Boolean) -> Unit = { }) { + if (fragmentCreator.id == inputId) { + hideInput(resetKeyboardGuideline = true) + toggled(false) + } else { + hideInput(resetKeyboardGuideline = false) + showInput(fragmentCreator, imeTarget) + } + } + + fun hideInput() { + hideInput(resetKeyboardGuideline = true) + } + + private fun showInput(fragmentCreator: FragmentCreator, imeTarget: EditText) { + inputId = fragmentCreator.id + input = fragmentCreator.create() + + fragmentManager + .beginTransaction() + .replace(R.id.input_container, input!!) + .commit() + + overrideKeyboardGuidelineWithPreviousHeight() + ViewUtil.hideKeyboard(context, imeTarget) + + listener?.onInputShown() + } + + private fun hideInput(resetKeyboardGuideline: Boolean) { + val inputHidden = input != null + input?.let { + fragmentManager + .beginTransaction() + .remove(it) + .commit() + } + input = null + inputId = null + + if (resetKeyboardGuideline) { + resetKeyboardGuideline() + } else { + clearKeyboardGuidelineOverride() + } + + if (inputHidden) { + listener?.onInputHidden() + } + } + + interface FragmentCreator { + val id: Int + fun create(): Fragment + } + + interface Listener { + fun onInputShown() + fun onInputHidden() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.java deleted file mode 100644 index 2b782ded04..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.WindowInsets; -import android.widget.EditText; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.constraintlayout.widget.Guideline; -import androidx.core.graphics.Insets; -import androidx.core.view.WindowInsetsCompat; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.ViewUtil; - -public class InsetAwareConstraintLayout extends ConstraintLayout { - - private WindowInsetsTypeProvider windowInsetsTypeProvider = WindowInsetsTypeProvider.ALL; - private Insets insets; - - public InsetAwareConstraintLayout(@NonNull Context context) { - super(context); - } - - public InsetAwareConstraintLayout(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - public InsetAwareConstraintLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public void setWindowInsetsTypeProvider(@NonNull WindowInsetsTypeProvider windowInsetsTypeProvider) { - this.windowInsetsTypeProvider = windowInsetsTypeProvider; - requestLayout(); - } - - @Override - public WindowInsets onApplyWindowInsets(WindowInsets insets) { - WindowInsetsCompat windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(insets); - Insets newInsets = windowInsetsCompat.getInsets(windowInsetsTypeProvider.getInsetsType()); - - applyInsets(newInsets); - return super.onApplyWindowInsets(insets); - } - - public void applyInsets(@NonNull Insets insets) { - Guideline statusBarGuideline = findViewById(R.id.status_bar_guideline); - Guideline navigationBarGuideline = findViewById(R.id.navigation_bar_guideline); - Guideline parentStartGuideline = findViewById(R.id.parent_start_guideline); - Guideline parentEndGuideline = findViewById(R.id.parent_end_guideline); - - if (statusBarGuideline != null) { - statusBarGuideline.setGuidelineBegin(insets.top); - } - - if (navigationBarGuideline != null) { - navigationBarGuideline.setGuidelineEnd(insets.bottom); - } - - if (parentStartGuideline != null) { - if (ViewUtil.isLtr(this)) { - parentStartGuideline.setGuidelineBegin(insets.left); - } else { - parentStartGuideline.setGuidelineBegin(insets.right); - } - } - - if (parentEndGuideline != null) { - if (ViewUtil.isLtr(this)) { - parentEndGuideline.setGuidelineEnd(insets.right); - } else { - parentEndGuideline.setGuidelineEnd(insets.left); - } - } - } - - public void showSoftkey(@NonNull EditText editText) { - ViewUtil.focusAndShowKeyboard(editText); - } - - public interface WindowInsetsTypeProvider { - - WindowInsetsTypeProvider ALL = () -> - WindowInsetsCompat.Type.ime() | - WindowInsetsCompat.Type.systemBars() | - WindowInsetsCompat.Type.displayCutout(); - - @WindowInsetsCompat.Type.InsetsType - int getInsetsType(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt new file mode 100644 index 0000000000..044db7b961 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt @@ -0,0 +1,142 @@ +package org.thoughtcrime.securesms.components + +import android.content.Context +import android.os.Build +import android.util.AttributeSet +import android.util.DisplayMetrics +import android.view.Surface +import android.view.WindowInsets +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.Guideline +import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.ServiceUtil +import org.thoughtcrime.securesms.util.ViewUtil + +/** + * A specialized [ConstraintLayout] that sets guidelines based on the window insets provided + * by the system. + * + * In portrait mode these are how the guidelines will be configured: + * + * - [R.id.status_bar_guideline] is set to the bottom of the status bar + * - [R.id.navigation_bar_guideline] is set to the top of the navigation bar + * - [R.id.parent_start_guideline] is set to the start of the parent + * - [R.id.parent_end_guideline] is set to the end of the parent + * - [R.id.keyboard_guideline] will be set to the top of the keyboard and will + * change as the keyboard is shown or hidden + * + * In landscape, the spirit of the guidelines are maintained but their names may not + * correlated exactly to the inset they are providing. + * + * These guidelines will only be updated if present in your layout, you can use + * `` to quickly include them. + */ +open class InsetAwareConstraintLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + companion object { + private val keyboardType = WindowInsetsCompat.Type.ime() + private val windowTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + } + + private val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) } + private val navigationBarGuideline: Guideline? by lazy { findViewById(R.id.navigation_bar_guideline) } + private val parentStartGuideline: Guideline? by lazy { findViewById(R.id.parent_start_guideline) } + private val parentEndGuideline: Guideline? by lazy { findViewById(R.id.parent_end_guideline) } + private val keyboardGuideline: Guideline? by lazy { findViewById(R.id.keyboard_guideline) } + + private val displayMetrics = DisplayMetrics() + private var overridingKeyboard: Boolean = false + + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + val windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(insets) + + val windowInsets = windowInsetsCompat.getInsets(windowTypes) + val keyboardInset = windowInsetsCompat.getInsets(keyboardType) + + applyInsets(windowInsets, keyboardInset) + + return super.onApplyWindowInsets(insets) + } + + private fun applyInsets(windowInsets: Insets, keyboardInset: Insets) { + val isLtr = ViewUtil.isLtr(this) + + statusBarGuideline?.setGuidelineBegin(windowInsets.top) + navigationBarGuideline?.setGuidelineEnd(windowInsets.bottom) + parentStartGuideline?.setGuidelineBegin(if (isLtr) windowInsets.left else windowInsets.right) + parentEndGuideline?.setGuidelineEnd(if (isLtr) windowInsets.right else windowInsets.left) + + if (keyboardInset.bottom > 0) { + setKeyboardHeight(keyboardInset.bottom) + keyboardGuideline?.setGuidelineEnd(keyboardInset.bottom) + } else if (!overridingKeyboard) { + keyboardGuideline?.setGuidelineEnd(windowInsets.bottom) + } + } + + protected fun overrideKeyboardGuidelineWithPreviousHeight() { + overridingKeyboard = true + keyboardGuideline?.setGuidelineEnd(getKeyboardHeight()) + } + + protected fun clearKeyboardGuidelineOverride() { + overridingKeyboard = false + } + + protected fun resetKeyboardGuideline() { + clearKeyboardGuidelineOverride() + keyboardGuideline?.setGuidelineEnd(navigationBarGuideline.guidelineEnd) + } + + private fun getKeyboardHeight(): Int { + val height = if (isLandscape()) { + SignalStore.misc().keyboardLandscapeHeight + } else { + SignalStore.misc().keyboardPortraitHeight + } + + return if (height <= 0) { + resources.getDimensionPixelSize(R.dimen.default_custom_keyboard_size) + } else { + height + } + } + + private fun setKeyboardHeight(height: Int) { + if (isLandscape()) { + SignalStore.misc().keyboardLandscapeHeight = height + } else { + SignalStore.misc().keyboardPortraitHeight = height + } + } + + private fun isLandscape(): Boolean { + val rotation = getDeviceRotation() + return rotation == Surface.ROTATION_90 + } + + @Suppress("DEPRECATION") + private fun getDeviceRotation(): Int { + if (isInEditMode) { + return Surface.ROTATION_0 + } + + if (Build.VERSION.SDK_INT >= 30) { + context.display?.getRealMetrics(displayMetrics) + } else { + ServiceUtil.getWindowManager(context).defaultDisplay.getRealMetrics(displayMetrics) + } + + return if (displayMetrics.widthPixels > displayMetrics.heightPixels) Surface.ROTATION_90 else Surface.ROTATION_0 + } + + private val Guideline?.guidelineEnd: Int + get() = if (this == null) 0 else (layoutParams as LayoutParams).guideEnd +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index a2dfc267c6..1e78c2443b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -24,6 +24,7 @@ import android.widget.ImageButton import android.widget.TextView import android.widget.TextView.OnEditorActionListener import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.activity.result.ActivityResultLauncher import androidx.annotation.StringRes import androidx.core.app.ActivityCompat @@ -31,6 +32,8 @@ import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.doOnNextLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentResultListener import androidx.fragment.app.viewModels import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -60,8 +63,8 @@ import org.thoughtcrime.securesms.badges.gifts.viewgift.sent.ViewSentGiftBottomS import org.thoughtcrime.securesms.components.AnimatingToggle import org.thoughtcrime.securesms.components.ComposeText import org.thoughtcrime.securesms.components.HidingLinearLayout +import org.thoughtcrime.securesms.components.InputAwareConstraintLayout import org.thoughtcrime.securesms.components.InputPanel -import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout import org.thoughtcrime.securesms.components.ScrollToPositionDelegate import org.thoughtcrime.securesms.components.SendButton import org.thoughtcrime.securesms.components.ViewBinderDelegate @@ -73,6 +76,7 @@ import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState import org.thoughtcrime.securesms.contactshare.Contact import org.thoughtcrime.securesms.contactshare.ContactUtil import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity +import org.thoughtcrime.securesms.conversation.AttachmentKeyboardButton import org.thoughtcrime.securesms.conversation.BadDecryptLearnMoreDialog import org.thoughtcrime.securesms.conversation.ConversationAdapter import org.thoughtcrime.securesms.conversation.ConversationIntents @@ -93,6 +97,7 @@ import org.thoughtcrime.securesms.conversation.ui.edit.EditMessageHistoryDialog import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupCallViewModel import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupViewModel +import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFragment import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord @@ -119,6 +124,8 @@ import org.thoughtcrime.securesms.longmessage.LongMessageFragment import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory.create import org.thoughtcrime.securesms.mediapreview.MediaPreviewV2Activity +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment import org.thoughtcrime.securesms.mms.AttachmentManager import org.thoughtcrime.securesms.mms.GlideApp @@ -215,8 +222,8 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } } - private val container: InsetAwareConstraintLayout - get() = requireView() as InsetAwareConstraintLayout + private val container: InputAwareConstraintLayout + get() = requireView() as InputAwareConstraintLayout private val inputPanel: InputPanel get() = binding.conversationInputPanel.root @@ -256,6 +263,8 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) presentActionBarMenu() observeConversationThread() + + container.fragmentManager = childFragmentManager } override fun onResume() { @@ -358,6 +367,12 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) post { sendButton.triggerSelectedChangedEvent() } } + val attachListener = { _: View -> + container.toggleInput(AttachmentKeyboardFragmentCreator, composeText) + } + binding.conversationInputPanel.attachButton.setOnClickListener(attachListener) + binding.conversationInputPanel.inlineAttachmentButton.setOnClickListener(attachListener) + presentGroupCallJoinButton() binding.scrollToBottom.setOnClickListener { @@ -369,6 +384,17 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } adapter.registerAdapterDataObserver(DataObserver(scrollToPositionDelegate)) + + val keyboardEvents = KeyboardEvents() + container.listener = keyboardEvents + requireActivity() + .onBackPressedDispatcher + .addCallback( + viewLifecycleOwner, + keyboardEvents + ) + + childFragmentManager.setFragmentResultListener(AttachmentKeyboardFragment.RESULT_KEY, viewLifecycleOwner, AttachmentKeyboardFragmentListener()) } private fun calculateCharactersRemaining() { @@ -630,9 +656,11 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } } - private fun sendMessage(metricId: String? = null, scheduledDate: Long = -1) { - val slideDeck: SlideDeck? = if (attachmentManager.isAttachmentPresent) attachmentManager.buildSlideDeck() else null - + private fun sendMessage( + metricId: String? = null, + scheduledDate: Long = -1, + slideDeck: SlideDeck? = if (attachmentManager.isAttachmentPresent) attachmentManager.buildSlideDeck() else null + ) { val send: Completable = viewModel.sendMessage( metricId = metricId, body = composeText.editableText.toString(), @@ -1286,6 +1314,8 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) //endregion Compose + Send Callbacks + //region Attachment + Media Keyboard + private inner class AttachmentManagerListener : AttachmentManager.AttachmentListener { override fun onAttachmentChanged() { // TODO [cody] implement @@ -1295,4 +1325,47 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) // TODO [cody] implement } } + + private object AttachmentKeyboardFragmentCreator : InputAwareConstraintLayout.FragmentCreator { + override val id: Int = 1 + override fun create(): Fragment = AttachmentKeyboardFragment() + } + + private inner class AttachmentKeyboardFragmentListener : FragmentResultListener { + @Suppress("DEPRECATION") + override fun onFragmentResult(requestKey: String, result: Bundle) { + val button: AttachmentKeyboardButton? = result.getSerializable(AttachmentKeyboardFragment.BUTTON_RESULT) as? AttachmentKeyboardButton + val media: Media? = result.getParcelable(AttachmentKeyboardFragment.MEDIA_RESULT) + + if (button != null) { + when (button) { + AttachmentKeyboardButton.GALLERY -> AttachmentManager.selectGallery(this@ConversationFragment, 1, viewModel.recipientSnapshot!!, composeText.textTrimmed, sendButton.selectedSendType, inputPanel.quote.isPresent) + AttachmentKeyboardButton.FILE -> AttachmentManager.selectDocument(this@ConversationFragment, 1) + AttachmentKeyboardButton.CONTACT -> AttachmentManager.selectContactInfo(this@ConversationFragment, 1) + AttachmentKeyboardButton.LOCATION -> AttachmentManager.selectLocation(this@ConversationFragment, 1, viewModel.recipientSnapshot!!.chatColors.asSingleColor()) + AttachmentKeyboardButton.PAYMENT -> AttachmentManager.selectPayment(this@ConversationFragment, viewModel.recipientSnapshot!!) + } + } else if (media != null) { + startActivityForResult(MediaSelectionActivity.editor(requireActivity(), sendButton.selectedSendType, listOf(media), viewModel.recipientSnapshot!!.id, composeText.textTrimmed), 12) + } + + container.hideInput() + } + } + + private inner class KeyboardEvents : OnBackPressedCallback(false), InputAwareConstraintLayout.Listener { + override fun handleOnBackPressed() { + container.hideInput() + } + + override fun onInputShown() { + isEnabled = true + } + + override fun onInputHidden() { + isEnabled = false + } + } + + //endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardFragment.kt new file mode 100644 index 0000000000..ff6da0f845 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardFragment.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.keyboard + +import android.Manifest +import android.os.Bundle +import android.view.View +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.signal.core.util.concurrent.LifecycleDisposable +import org.signal.core.util.concurrent.addTo +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.AttachmentKeyboard +import org.thoughtcrime.securesms.conversation.AttachmentKeyboardButton +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.util.ViewModelFactory + +/** + * Fragment wrapped version of [AttachmentKeyboard] to help encapsulate logic the view + * needs from external sources. + */ +class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_fragment), AttachmentKeyboard.Callback { + + companion object { + const val RESULT_KEY = "AttachmentKeyboardFragmentResult" + const val MEDIA_RESULT = "Media" + const val BUTTON_RESULT = "Button" + } + + private val viewModel: AttachmentKeyboardViewModel by viewModels( + factoryProducer = ViewModelFactory.factoryProducer { AttachmentKeyboardViewModel() } + ) + + private lateinit var conversationViewModel: ConversationViewModel + + private val lifecycleDisposable = LifecycleDisposable() + + @Suppress("ReplaceGetOrSet") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + lifecycleDisposable.bindTo(viewLifecycleOwner) + + val attachmentKeyboardView = view.findViewById(R.id.attachment_keyboard) + + attachmentKeyboardView.setCallback(this) + + viewModel.getRecentMedia() + .subscribeBy { + attachmentKeyboardView.onMediaChanged(it) + } + .addTo(lifecycleDisposable) + + conversationViewModel = ViewModelProvider(requireParentFragment()).get(ConversationViewModel::class.java) + conversationViewModel + .recipient + .subscribeBy { + attachmentKeyboardView.setWallpaperEnabled(it.hasWallpaper()) + } + .addTo(lifecycleDisposable) + } + + override fun onAttachmentMediaClicked(media: Media) { + setFragmentResult(RESULT_KEY, bundleOf(MEDIA_RESULT to media)) + } + + override fun onAttachmentSelectorClicked(button: AttachmentKeyboardButton) { + setFragmentResult(RESULT_KEY, bundleOf(BUTTON_RESULT to button)) + } + + override fun onAttachmentPermissionsRequested() { + Permissions.with(requireParentFragment()) + .request(Manifest.permission.READ_EXTERNAL_STORAGE) + .onAllGranted { viewModel.refreshRecentMedia() } + .withPermanentDenialDialog(getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) + .execute() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardViewModel.kt new file mode 100644 index 0000000000..364a4b7e61 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardViewModel.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.keyboard + +import androidx.lifecycle.ViewModel +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.MediaRepository + +class AttachmentKeyboardViewModel( + private val mediaRepository: MediaRepository = MediaRepository() +) : ViewModel() { + + private val refreshRecentMedia = BehaviorSubject.createDefault(Unit) + + fun getRecentMedia(): Observable> { + return refreshRecentMedia + .flatMapSingle { + mediaRepository + .recentMedia + } + .observeOn(AndroidSchedulers.mainThread()) + } + + fun refreshRecentMedia() { + refreshRecentMedia.onNext(Unit) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java index 3b6f3852e4..13d68c523a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java @@ -1,10 +1,13 @@ package org.thoughtcrime.securesms.keyvalue; +import android.preference.PreferenceManager; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme; import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.impl.ChangeNumberConstraintObserver; import java.util.Collections; @@ -33,6 +36,8 @@ public final class MiscellaneousValues extends SignalStoreValues { private static final String LINKED_DEVICES_REMINDER = "misc.linked_devices_reminder"; private static final String HAS_LINKED_DEVICES = "misc.linked_devices_present"; private static final String USERNAME_QR_CODE_COLOR = "mis.username_qr_color_scheme"; + private static final String KEYBOARD_LANDSCAPE_HEIGHT = "misc.keyboard.landscape_height"; + private static final String KEYBOARD_PORTRAIT_HEIGHT = "misc.keyboard.protrait_height"; MiscellaneousValues(@NonNull KeyValueStore store) { super(store); @@ -277,4 +282,39 @@ public final class MiscellaneousValues extends SignalStoreValues { putString(USERNAME_QR_CODE_COLOR, color.serialize()); } + public int getKeyboardLandscapeHeight() { + int height = getInteger(KEYBOARD_LANDSCAPE_HEIGHT, 0); + if (height == 0) { + //noinspection deprecation + height = PreferenceManager.getDefaultSharedPreferences(ApplicationDependencies.getApplication()) + .getInt("keyboard_height_landscape", 0); + + if (height > 0) { + setKeyboardLandscapeHeight(height); + } + } + return height; + } + + public void setKeyboardLandscapeHeight(int height) { + putLong(KEYBOARD_LANDSCAPE_HEIGHT, height); + } + + public int getKeyboardPortraitHeight() { + int height = (int) getInteger(KEYBOARD_PORTRAIT_HEIGHT, 0); + if (height == 0) { + //noinspection deprecation + height = PreferenceManager.getDefaultSharedPreferences(ApplicationDependencies.getApplication()) + .getInt("keyboard_height_portrait", 0); + + if (height > 0) { + setKeyboardPortraitHeight(height); + } + } + return height; + } + + public void setKeyboardPortraitHeight(int height) { + putInteger(KEYBOARD_PORTRAIT_HEIGHT, height); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java index 455d55368a..d2809085b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java @@ -22,6 +22,7 @@ import com.annimon.stream.Stream; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.util.MediaUtil; import org.signal.core.util.SqlUtil; @@ -40,6 +41,9 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; + /** * Handles the retrieval of media present on the user's device. */ @@ -61,6 +65,25 @@ public class MediaRepository { SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getFolders(context))); } + /** + * Retrieves a list of recent media items (images and videos). + */ + public Single> getRecentMedia() { + return Single.>fromCallable(() -> { + if (!StorageUtil.canReadFromMediaStore()) { + Log.w(TAG, "No storage permissions!", new Throwable()); + return Collections.emptyList(); + } + + return getMediaInBucket(ApplicationDependencies.getApplication(), Media.ALL_MEDIA_BUCKET_ID); + }) + .onErrorReturn(t -> { + Log.w(TAG, "Unable to get recent media", t); + return Collections.emptyList(); + }) + .subscribeOn(Schedulers.io()); + } + /** * Retrieves a list of media items (images and videos) that are present int he specified bucket. */ diff --git a/app/src/main/res/layout/attachment_keyboard_fragment.xml b/app/src/main/res/layout/attachment_keyboard_fragment.xml new file mode 100644 index 0000000000..8499994c8e --- /dev/null +++ b/app/src/main/res/layout/attachment_keyboard_fragment.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/layout/conversation_activity.xml b/app/src/main/res/layout/conversation_activity.xml index 59034c4b81..5905eff7bd 100644 --- a/app/src/main/res/layout/conversation_activity.xml +++ b/app/src/main/res/layout/conversation_activity.xml @@ -30,7 +30,7 @@ android:id="@+id/layout_container" android:layout_width="0dp" android:layout_height="0dp" - app:layout_constraintBottom_toBottomOf="@id/navigation_bar_guideline" + app:layout_constraintBottom_toBottomOf="@id/keyboard_guideline" app:layout_constraintEnd_toEndOf="@id/parent_end_guideline" app:layout_constraintStart_toStartOf="@id/parent_start_guideline" app:layout_constraintTop_toTopOf="parent"> diff --git a/app/src/main/res/layout/system_ui_guidelines.xml b/app/src/main/res/layout/system_ui_guidelines.xml index a391a7c7eb..19102f0a64 100644 --- a/app/src/main/res/layout/system_ui_guidelines.xml +++ b/app/src/main/res/layout/system_ui_guidelines.xml @@ -17,6 +17,13 @@ android:orientation="horizontal" tools:layout_constraintGuide_end="48dp" /> + + - @@ -106,7 +108,7 @@ android:visibility="invisible" app:cstv_scroll_button_src="@drawable/ic_at_20" app:layout_constraintBottom_toTopOf="@id/scroll_to_bottom" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toEndOf="@id/parent_end_guideline" app:layout_goneMarginBottom="20dp" tools:visibility="visible" /> @@ -119,7 +121,7 @@ android:visibility="invisible" app:cstv_scroll_button_src="@drawable/ic_chevron_down_20" app:layout_constraintBottom_toTopOf="@id/conversation_bottom_panel_barrier" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toEndOf="@id/parent_end_guideline" tools:visibility="visible" /> + app:layout_constraintBottom_toTopOf="@+id/conversation_input_panel" + app:layout_constraintEnd_toEndOf="@id/parent_end_guideline" + app:layout_constraintStart_toStartOf="@id/parent_start_guideline" /> + app:layout_constraintEnd_toEndOf="@id/parent_end_guideline" + app:layout_constraintStart_toStartOf="@id/parent_start_guideline" /> - + + + diff --git a/core-util/src/main/java/org/signal/core/util/concurrent/LifecycleDisposable.kt b/core-util/src/main/java/org/signal/core/util/concurrent/LifecycleDisposable.kt index e0050678fa..71e8817c25 100644 --- a/core-util/src/main/java/org/signal/core/util/concurrent/LifecycleDisposable.kt +++ b/core-util/src/main/java/org/signal/core/util/concurrent/LifecycleDisposable.kt @@ -44,3 +44,5 @@ class LifecycleDisposable : DefaultLifecycleObserver { add(disposable) } } + +fun Disposable.addTo(lifecycleDisposable: LifecycleDisposable): Disposable = apply { lifecycleDisposable.add(this) }