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) }