From fbd1721eaf6e42e845f0dd673a1375babdbd1fc5 Mon Sep 17 00:00:00 2001 From: ceokot Date: Fri, 30 Sep 2022 13:32:07 +1000 Subject: [PATCH] Menu redesign (#958) * feat: Menu redesign * Add bottomsheet * Handle default peek height * Smooth out setting peek height * Move contacts prep to util * Dialog layout tweaks * Contact grouping tweaks * Add new message dialog * Add public key input delegate * Add create group dialog * Add join community dialog * Handle dialog back navigation * Enter community url tab tweaks * Scan QR code tab refactor * Scan qr code refactor * Direct and community tabs refactor * Add session id copy context menu item * Set dialog background colours * Set full dialog background colour * Minor tweaks * Add closed group contact search * Cleanup * Add content descriptions * Resize community chips * Fix new conversation screen paddings * Fix fade in/out of join community screen * Prevent creating conversation with empty public key * Resize and position create group loader * Fix back nav after creating direct message conversation * Fix inter-screen transitions * Fix new conversation background colours * Fix background colours * Rename contact list header for clarity * Bug fixes * Enable scrolling of Enter Session ID tab of the new message dialog * Minor refactor * Switch to child fragment manager * Fix member search on create group screen Co-authored-by: charles --- app/build.gradle | 1 + .../loki/messenger/HomeActivityTests.kt | 6 +- .../network/loki/messenger/util/Matchers.kt | 18 - app/src/main/AndroidManifest.xml | 13 - .../components/NestedScrollableHost.kt | 112 +++++ .../conversation/start/ContactListAdapter.kt | 95 +++++ .../start/NewConversationDelegate.kt | 10 + .../start/NewConversationFragment.kt | 99 +++++ .../start/NewConversationHomeFragment.kt | 61 +++ .../start/NewConversationHomeViewModel.kt | 35 ++ .../dms/CreatePrivateChatActivity.kt | 222 ---------- .../securesms/dms/EnterPublicKeyFragment.kt | 101 +++++ .../securesms/dms/NewMessageFragment.kt | 107 +++++ .../dms/NewMessageFragmentAdapter.kt | 24 ++ .../groups/CreateClosedGroupActivity.kt | 145 ------- .../securesms/groups/CreateGroupFragment.kt | 110 +++++ .../securesms/groups/CreateGroupViewModel.kt | 46 +++ .../groups/EnterCommunityUrlFragment.kt | 95 +++++ .../securesms/groups/JoinCommunityFragment.kt | 125 ++++++ .../groups/JoinCommunityFragmentAdapter.kt | 23 ++ .../groups/JoinPublicChatActivity.kt | 229 ----------- .../securesms/home/HomeActivity.kt | 40 +- .../home/NewConversationButtonSetView.kt | 386 ------------------ .../securesms/util/ScanQRCodeFragment.kt | 2 + .../util/ScanQRCodeWrapperFragment.kt | 7 +- .../securesms/util/ViewUtilities.kt | 7 + .../res/drawable/ic_baseline_close_24.xml | 10 + .../fragment_enter_chat_url.xml | 88 ---- .../fragment_enter_public_key.xml | 115 ------ .../layout/activity_create_closed_group.xml | 86 ---- .../layout/activity_create_private_chat.xml | 40 -- app/src/main/res/layout/activity_home.xml | 14 +- .../res/layout/contact_section_header.xml | 23 ++ .../main/res/layout/default_group_chip.xml | 3 +- .../main/res/layout/fragment_create_group.xml | 163 ++++++++ .../res/layout/fragment_enter_chat_url.xml | 88 ---- .../layout/fragment_enter_community_url.xml | 103 +++++ .../res/layout/fragment_enter_public_key.xml | 229 ++++++----- .../res/layout/fragment_join_community.xml | 87 ++++ .../res/layout/fragment_new_conversation.xml | 5 + .../layout/fragment_new_conversation_home.xml | 158 +++++++ .../main/res/layout/fragment_new_message.xml | 87 ++++ .../main/res/layout/grid_layout_filler.xml | 5 - app/src/main/res/layout/view_contact.xml | 43 ++ .../main/res/values-notnight-v21/colors.xml | 3 + app/src/main/res/values/colors.xml | 3 + app/src/main/res/values/strings.xml | 15 + app/src/main/res/values/styles.xml | 1 - 48 files changed, 1907 insertions(+), 1581 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/NestedScrollableHost.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeViewModel.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/dms/CreatePrivateChatActivity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragmentAdapter.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/CreateClosedGroupActivity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/EnterCommunityUrlFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragmentAdapter.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/home/NewConversationButtonSetView.kt create mode 100644 app/src/main/res/drawable/ic_baseline_close_24.xml delete mode 100644 app/src/main/res/layout-sw400dp/fragment_enter_chat_url.xml delete mode 100644 app/src/main/res/layout-sw400dp/fragment_enter_public_key.xml delete mode 100644 app/src/main/res/layout/activity_create_closed_group.xml delete mode 100644 app/src/main/res/layout/activity_create_private_chat.xml create mode 100644 app/src/main/res/layout/contact_section_header.xml create mode 100644 app/src/main/res/layout/fragment_create_group.xml delete mode 100644 app/src/main/res/layout/fragment_enter_chat_url.xml create mode 100644 app/src/main/res/layout/fragment_enter_community_url.xml create mode 100644 app/src/main/res/layout/fragment_join_community.xml create mode 100644 app/src/main/res/layout/fragment_new_conversation.xml create mode 100644 app/src/main/res/layout/fragment_new_conversation_home.xml create mode 100644 app/src/main/res/layout/fragment_new_message.xml delete mode 100644 app/src/main/res/layout/grid_layout_filler.xml create mode 100644 app/src/main/res/layout/view_contact.xml diff --git a/app/build.gradle b/app/build.gradle index a85f0478d3..ba4fb2f1a6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.recyclerview:recyclerview:1.1.0' implementation 'com.google.android.material:material:1.2.1' + implementation 'com.google.android:flexbox:2.0.1' implementation 'androidx.legacy:legacy-support-v13:1.0.0' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.preference:preference-ktx:1.1.1' diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index db977544cf..087d486893 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -22,7 +22,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable -import network.loki.messenger.util.NewConversationButtonDrawableMatcher.Companion.newConversationButtonWithDrawable import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.not @@ -39,7 +38,6 @@ import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar import org.thoughtcrime.securesms.home.HomeActivity import org.thoughtcrime.securesms.mms.GlideApp - @RunWith(AndroidJUnit4::class) @LargeTest class HomeActivityTests { @@ -90,8 +88,8 @@ class HomeActivityTests { } private fun goToMyChat() { - onView(newConversationButtonWithDrawable(R.drawable.ic_plus)).perform(ViewActions.click()) - onView(newConversationButtonWithDrawable(R.drawable.ic_message)).perform(ViewActions.click()) + onView(withId(R.id.newConversationButton)).perform(ViewActions.click()) + onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) // new chat onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard()) onView(withId(R.id.copyButton)).perform(ViewActions.click()) diff --git a/app/src/androidTest/java/network/loki/messenger/util/Matchers.kt b/app/src/androidTest/java/network/loki/messenger/util/Matchers.kt index 1c93745467..d1562c9f34 100644 --- a/app/src/androidTest/java/network/loki/messenger/util/Matchers.kt +++ b/app/src/androidTest/java/network/loki/messenger/util/Matchers.kt @@ -5,24 +5,6 @@ import androidx.annotation.DrawableRes import org.hamcrest.Description import org.hamcrest.TypeSafeMatcher import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton -import org.thoughtcrime.securesms.home.NewConversationButtonSetView - -class NewConversationButtonDrawableMatcher(@DrawableRes private val expectedId: Int): TypeSafeMatcher() { - - companion object { - @JvmStatic fun newConversationButtonWithDrawable(@DrawableRes expectedId: Int) = NewConversationButtonDrawableMatcher(expectedId) - } - - override fun describeTo(description: Description?) { - description?.appendText("with drawable on button with resource id: $expectedId") - } - - override fun matchesSafely(item: View): Boolean { - if (item !is NewConversationButtonSetView.Button) return false - - return item.getIconID() == expectedId - } -} class InputBarButtonDrawableMatcher(@DrawableRes private val expectedId: Int): TypeSafeMatcher() { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 384119ef5f..eddee7e844 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -140,23 +140,10 @@ android:name="org.thoughtcrime.securesms.preferences.QRCodeActivity" android:screenOrientation="portrait" android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> - - - diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/NestedScrollableHost.kt b/app/src/main/java/org/thoughtcrime/securesms/components/NestedScrollableHost.kt new file mode 100644 index 0000000000..ef27c307c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/NestedScrollableHost.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.components + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.widget.FrameLayout +import androidx.viewpager2.widget.ViewPager2 +import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL +import kotlin.math.absoluteValue +import kotlin.math.sign + +/** + * Layout to wrap a scrollable component inside a ViewPager2. Provided as a solution to the problem + * where pages of ViewPager2 have nested scrollable elements that scroll in the same direction as + * ViewPager2. The scrollable element needs to be the immediate and only child of this host layout. + * + * This solution has limitations when using multiple levels of nested scrollable elements + * (e.g. a horizontal RecyclerView in a vertical RecyclerView in a horizontal ViewPager2). + */ +class NestedScrollableHost : FrameLayout { + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + private var touchSlop = 0 + private var initialX = 0f + private var initialY = 0f + private val parentViewPager: ViewPager2? + get() { + var v: View? = parent as? View + while (v != null && v !is ViewPager2) { + v = v.parent as? View + } + return v as? ViewPager2 + } + + private val child: View? get() = if (childCount > 0) getChildAt(0) else null + + init { + touchSlop = ViewConfiguration.get(context).scaledTouchSlop + } + + private fun canChildScroll(orientation: Int, delta: Float): Boolean { + val direction = -delta.sign.toInt() + return when (orientation) { + 0 -> child?.canScrollHorizontally(direction) ?: false + 1 -> child?.canScrollVertically(direction) ?: false + else -> throw IllegalArgumentException() + } + } + + override fun onInterceptTouchEvent(e: MotionEvent): Boolean { + handleInterceptTouchEvent(e) + return super.onInterceptTouchEvent(e) + } + + private fun handleInterceptTouchEvent(e: MotionEvent) { + val orientation = parentViewPager?.orientation ?: return + + // Early return if child can't scroll in same direction as parent + if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) { + return + } + + if (e.action == MotionEvent.ACTION_DOWN) { + initialX = e.x + initialY = e.y + parent.requestDisallowInterceptTouchEvent(true) + } else if (e.action == MotionEvent.ACTION_MOVE) { + val dx = e.x - initialX + val dy = e.y - initialY + val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL + + // assuming ViewPager2 touch-slop is 2x touch-slop of child + val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f + val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f + + if (scaledDx > touchSlop || scaledDy > touchSlop) { + if (isVpHorizontal == (scaledDy > scaledDx)) { + // Gesture is perpendicular, allow all parents to intercept + parent.requestDisallowInterceptTouchEvent(false) + } else { + // Gesture is parallel, query child if movement in that direction is possible + if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) { + // Child can scroll, disallow all parents to intercept + parent.requestDisallowInterceptTouchEvent(true) + } else { + // Child cannot scroll, allow all parents to intercept + parent.requestDisallowInterceptTouchEvent(false) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt new file mode 100644 index 0000000000..99e7c90615 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.conversation.start + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import network.loki.messenger.databinding.ContactSectionHeaderBinding +import network.loki.messenger.databinding.ViewContactBinding +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.mms.GlideRequests + +sealed class ContactListItem { + class Header(val name: String) : ContactListItem() + class Contact(val recipient: Recipient, val displayName: String) : ContactListItem() +} + +class ContactListAdapter( + private val context: Context, + private val glide: GlideRequests, + private val listener: (Recipient) -> Unit +) : RecyclerView.Adapter() { + var items = listOf() + set(value) { + field = value + notifyDataSetChanged() + } + + private object ViewType { + const val Contact = 0 + const val Header = 1 + } + + class ContactViewHolder(private val binding: ViewContactBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(contact: ContactListItem.Contact, glide: GlideRequests, listener: (Recipient) -> Unit) { + binding.profilePictureView.root.glide = glide + binding.profilePictureView.root.update(contact.recipient) + binding.nameTextView.text = contact.displayName + binding.root.setOnClickListener { listener(contact.recipient) } + } + + fun unbind() { + binding.profilePictureView.root.recycle() + } + } + + class HeaderViewHolder( + private val binding: ContactSectionHeaderBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: ContactListItem.Header) { + with(binding) { + label.text = item.name + } + } + } + + override fun getItemCount(): Int { + return items.size + } + + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + super.onViewRecycled(holder) + if (holder is ContactViewHolder) { + holder.unbind() + } + } + + override fun getItemViewType(position: Int): Int { + return when (items[position]) { + is ContactListItem.Header -> ViewType.Header + else -> ViewType.Contact + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return if (viewType == ViewType.Contact) { + ContactViewHolder( + ViewContactBinding.inflate(LayoutInflater.from(context), parent, false) + ) + } else { + HeaderViewHolder( + ContactSectionHeaderBinding.inflate(LayoutInflater.from(context), parent, false) + ) + } + } + + override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { + val item = items[position] + if (viewHolder is ContactViewHolder) { + viewHolder.bind(item as ContactListItem.Contact, glide, listener) + } else if (viewHolder is HeaderViewHolder) { + viewHolder.bind(item as ContactListItem.Header) + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt new file mode 100644 index 0000000000..7e51e1de07 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.conversation.start + +interface NewConversationDelegate { + fun onNewMessageSelected() + fun onCreateGroupSelected() + fun onJoinCommunitySelected() + fun onContactSelected(address: String) + fun onDialogBackPressed() + fun onDialogClosePressed() +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt new file mode 100644 index 0000000000..ee255a2721 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt @@ -0,0 +1,99 @@ +package org.thoughtcrime.securesms.conversation.start + +import android.app.Dialog +import android.content.Intent +import android.content.res.Resources +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import org.session.libsession.utilities.Address +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.dms.NewMessageFragment +import org.thoughtcrime.securesms.groups.CreateGroupFragment +import org.thoughtcrime.securesms.groups.JoinCommunityFragment + +@AndroidEntryPoint +class NewConversationFragment : BottomSheetDialogFragment(), NewConversationDelegate { + + private val defaultPeekHeight: Int by lazy { (Resources.getSystem().displayMetrics.heightPixels * 0.94).toInt() } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_new_conversation, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + replaceFragment( + fragment = NewConversationHomeFragment().apply { delegate = this@NewConversationFragment }, + fragmentKey = NewConversationHomeFragment::class.java.simpleName + ) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = BottomSheetDialog(requireContext(), theme) + dialog.setOnShowListener { + val bottomSheetDialog = it as BottomSheetDialog + val parentLayout = + bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + parentLayout?.let { it -> + val behaviour = BottomSheetBehavior.from(it) + val layoutParams = it.layoutParams + layoutParams.height = defaultPeekHeight + it.layoutParams = layoutParams + behaviour.state = BottomSheetBehavior.STATE_EXPANDED + } + } + return dialog + } + + override fun onNewMessageSelected() { + replaceFragment(NewMessageFragment().apply { delegate = this@NewConversationFragment }) + } + + override fun onCreateGroupSelected() { + replaceFragment(CreateGroupFragment().apply { delegate = this@NewConversationFragment }) + } + + override fun onJoinCommunitySelected() { + replaceFragment(JoinCommunityFragment().apply { delegate = this@NewConversationFragment }) + } + + override fun onContactSelected(address: String) { + val intent = Intent(requireContext(), ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(address)) + requireContext().startActivity(intent) + requireActivity().overridePendingTransition(R.anim.slide_from_bottom, R.anim.fade_scale_out) + } + + override fun onDialogBackPressed() { + childFragmentManager.popBackStack() + } + + override fun onDialogClosePressed() { + dismiss() + } + + private fun replaceFragment(fragment: Fragment, fragmentKey: String? = null) { + childFragmentManager.commit { + setCustomAnimations( + R.anim.slide_from_right, + R.anim.fade_scale_out, + 0, + R.anim.slide_to_right + ) + replace(R.id.new_conversation_fragment_container, fragment) + addToBackStack(fragmentKey) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt new file mode 100644 index 0000000000..f8936d581f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.conversation.start + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import network.loki.messenger.databinding.FragmentNewConversationHomeBinding +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.PublicKeyValidation +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.mms.GlideApp +import javax.inject.Inject + +@AndroidEntryPoint +class NewConversationHomeFragment : Fragment() { + + private lateinit var binding: FragmentNewConversationHomeBinding + private val viewModel: NewConversationHomeViewModel by viewModels() + + @Inject + lateinit var textSecurePreferences: TextSecurePreferences + + lateinit var delegate: NewConversationDelegate + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentNewConversationHomeBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } + binding.createPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() } + binding.createClosedGroupButton.setOnClickListener { delegate.onCreateGroupSelected() } + binding.joinCommunityButton.setOnClickListener { delegate.onJoinCommunitySelected() } + val adapter = ContactListAdapter(requireContext(), GlideApp.with(requireContext())) { + delegate.onContactSelected(it.address.serialize()) + } + val unknownSectionTitle = getString(R.string.new_conversation_unknown_contacts_section_title) + val recipients = viewModel.recipients.value?.filter { !it.isGroupRecipient && it.address.serialize() != textSecurePreferences.getLocalNumber()!! } ?: emptyList() + val contactGroups = recipients.map { + val sessionId = it.address.serialize() + val contact = DatabaseComponent.get(requireContext()).sessionContactDatabase().getContactWithSessionID(sessionId) + val displayName = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionId + ContactListItem.Contact(it, displayName) + }.sortedBy { it.displayName } + .groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.first().uppercase() } + .toMutableMap() + contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) } + adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value } + binding.contactsRecyclerView.adapter = adapter + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeViewModel.kt new file mode 100644 index 0000000000..47fc50598c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeViewModel.kt @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.conversation.start + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.database.ThreadDatabase +import javax.inject.Inject + +@HiltViewModel +class NewConversationHomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): ViewModel() { + + private val _recipients = MutableLiveData>() + val recipients: LiveData> = _recipients + + init { + viewModelScope.launch { + threadDb.approvedConversationList.use { openCursor -> + val reader = threadDb.readerFor(openCursor) + val threads = mutableListOf() + while (true) { + threads += reader.next?.recipient ?: break + } + withContext(Dispatchers.Main) { + _recipients.value = threads + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/CreatePrivateChatActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/CreatePrivateChatActivity.kt deleted file mode 100644 index 2aecad34bb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/CreatePrivateChatActivity.kt +++ /dev/null @@ -1,222 +0,0 @@ -package org.thoughtcrime.securesms.dms - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.text.InputType -import android.util.TypedValue -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.widget.Toast -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentPagerAdapter -import network.loki.messenger.R -import network.loki.messenger.databinding.ActivityCreatePrivateChatBinding -import network.loki.messenger.databinding.FragmentEnterPublicKeyBinding -import nl.komponents.kovenant.ui.failUi -import nl.komponents.kovenant.ui.successUi -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.PublicKeyValidation -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate - -class CreatePrivateChatActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { - private lateinit var binding: ActivityCreatePrivateChatBinding - private val adapter = CreatePrivateChatActivityAdapter(this) - private var isKeyboardShowing = false - set(value) { - val hasChanged = (field != value) - field = value - if (hasChanged) { - adapter.isKeyboardShowing = value - } - } - - // region Lifecycle - override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { - super.onCreate(savedInstanceState, isReady) - binding = ActivityCreatePrivateChatBinding.inflate(layoutInflater) - // Set content view - setContentView(binding.root) - // Set title - supportActionBar!!.title = resources.getString(R.string.activity_create_private_chat_title) - // Set up view pager - binding.viewPager.adapter = adapter - binding.tabLayout.setupWithViewPager(binding.viewPager) - binding.rootLayout.viewTreeObserver.addOnGlobalLayoutListener { - val diff = binding.rootLayout.rootView.height - binding.rootLayout.height - val displayMetrics = this@CreatePrivateChatActivity.resources.displayMetrics - val estimatedKeyboardHeight = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200.0f, displayMetrics) - this@CreatePrivateChatActivity.isKeyboardShowing = (diff > estimatedKeyboardHeight) - } - } - // endregion - - // region Updating - private fun showLoader() { - binding.loader.visibility = View.VISIBLE - binding.loader.animate().setDuration(150).alpha(1.0f).start() - } - - private fun hideLoader() { - binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - - override fun onAnimationEnd(animation: Animator?) { - super.onAnimationEnd(animation) - binding.loader.visibility = View.GONE - } - }) - } - // endregion - - // region Interaction - override fun handleQRCodeScanned(hexEncodedPublicKey: String) { - createPrivateChatIfPossible(hexEncodedPublicKey) - } - - fun createPrivateChatIfPossible(onsNameOrPublicKey: String) { - if (PublicKeyValidation.isValid(onsNameOrPublicKey)) { - createPrivateChat(onsNameOrPublicKey) - } else { - // This could be an ONS name - showLoader() - SnodeAPI.getSessionID(onsNameOrPublicKey).successUi { hexEncodedPublicKey -> - hideLoader() - this.createPrivateChat(hexEncodedPublicKey) - }.failUi { exception -> - hideLoader() - var message = resources.getString(R.string.fragment_enter_public_key_error_message) - exception.localizedMessage?.let { - message = it - } - Toast.makeText(this, message, Toast.LENGTH_SHORT).show() - } - } - } - - private fun createPrivateChat(hexEncodedPublicKey: String) { - val recipient = Recipient.from(this, Address.fromSerialized(hexEncodedPublicKey), false) - val intent = Intent(this, ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) - intent.setDataAndType(getIntent().data, getIntent().type) - val existingThread = DatabaseComponent.get(this).threadDatabase().getThreadIdIfExistsFor(recipient) - intent.putExtra(ConversationActivityV2.THREAD_ID, existingThread) - startActivity(intent) - finish() - } - // endregion -} - -// region Adapter -private class CreatePrivateChatActivityAdapter(val activity: CreatePrivateChatActivity) : FragmentPagerAdapter(activity.supportFragmentManager) { - val enterPublicKeyFragment = EnterPublicKeyFragment() - var isKeyboardShowing = false - set(value) { field = value; enterPublicKeyFragment.isKeyboardShowing = isKeyboardShowing } - - override fun getCount(): Int { - return 2 - } - - override fun getItem(index: Int): Fragment { - return when (index) { - 0 -> enterPublicKeyFragment - 1 -> { - val result = ScanQRCodeWrapperFragment() - result.delegate = activity - result.message = activity.resources.getString(R.string.activity_create_private_chat_scan_qr_code_explanation) - result - } - else -> throw IllegalStateException() - } - } - - override fun getPageTitle(index: Int): CharSequence? { - return when (index) { - 0 -> activity.resources.getString(R.string.activity_create_private_chat_enter_session_id_tab_title) - 1 -> activity.resources.getString(R.string.activity_create_private_chat_scan_qr_code_tab_title) - else -> throw IllegalStateException() - } - } -} -// endregion - -// region Enter Public Key Fragment -class EnterPublicKeyFragment : Fragment() { - private lateinit var binding: FragmentEnterPublicKeyBinding - - var isKeyboardShowing = false - set(value) { field = value; handleIsKeyboardShowingChanged() } - - private val hexEncodedPublicKey: String - get() { - return TextSecurePreferences.getLocalNumber(requireContext())!! - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = FragmentEnterPublicKeyBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - with(binding) { - publicKeyEditText.imeOptions = EditorInfo.IME_ACTION_DONE or 16777216 // Always use incognito keyboard - publicKeyEditText.setRawInputType(InputType.TYPE_CLASS_TEXT) - publicKeyEditText.setOnEditorActionListener { v, actionID, _ -> - if (actionID == EditorInfo.IME_ACTION_DONE) { - val imm = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(v.windowToken, 0) - createPrivateChatIfPossible() - true - } else { - false - } - } - publicKeyTextView.text = hexEncodedPublicKey - copyButton.setOnClickListener { copyPublicKey() } - shareButton.setOnClickListener { sharePublicKey() } - createPrivateChatButton.setOnClickListener { createPrivateChatIfPossible() } - } - } - - private fun handleIsKeyboardShowingChanged() { - binding.optionalContentContainer.isVisible = !isKeyboardShowing - } - - private fun copyPublicKey() { - val clipboard = requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Session ID", hexEncodedPublicKey) - clipboard.setPrimaryClip(clip) - Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() - } - - private fun sharePublicKey() { - val intent = Intent() - intent.action = Intent.ACTION_SEND - intent.putExtra(Intent.EXTRA_TEXT, hexEncodedPublicKey) - intent.type = "text/plain" - startActivity(intent) - } - - private fun createPrivateChatIfPossible() { - val hexEncodedPublicKey = binding.publicKeyEditText.text?.trim().toString() - val activity = requireActivity() as CreatePrivateChatActivity - activity.createPrivateChatIfPossible(hexEncodedPublicKey) - } -} -// endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt new file mode 100644 index 0000000000..c3afbf5b77 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt @@ -0,0 +1,101 @@ +package org.thoughtcrime.securesms.dms + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.Toast +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import network.loki.messenger.R +import network.loki.messenger.databinding.FragmentEnterPublicKeyBinding +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.util.QRCodeUtilities +import org.thoughtcrime.securesms.util.hideKeyboard +import org.thoughtcrime.securesms.util.toPx + +class EnterPublicKeyFragment : Fragment() { + private lateinit var binding: FragmentEnterPublicKeyBinding + + var delegate: EnterPublicKeyDelegate? = null + + private val hexEncodedPublicKey: String + get() { + return TextSecurePreferences.getLocalNumber(requireContext())!! + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentEnterPublicKeyBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + with(binding) { + publicKeyEditText.imeOptions = EditorInfo.IME_ACTION_DONE or 16777216 // Always use incognito keyboard + publicKeyEditText.setRawInputType(InputType.TYPE_CLASS_TEXT) + publicKeyEditText.setOnEditorActionListener { v, actionID, _ -> + if (actionID == EditorInfo.IME_ACTION_DONE) { + v.hideKeyboard() + handlePublicKeyEntered() + true + } else { + false + } + } + publicKeyEditText.addTextChangedListener { text -> createPrivateChatButton.isVisible = !text.isNullOrBlank() } + publicKeyEditText.setOnFocusChangeListener { _, hasFocus -> optionalContentContainer.isVisible = !hasFocus } + mainContainer.setOnTouchListener { _, _ -> + binding.optionalContentContainer.isVisible = true + publicKeyEditText.clearFocus() + publicKeyEditText.hideKeyboard() + true + } + val size = toPx(228, resources) + val qrCode = QRCodeUtilities.encode(hexEncodedPublicKey, size, isInverted = false, hasTransparentBackground = false) + qrCodeImageView.setImageBitmap(qrCode) + publicKeyTextView.text = hexEncodedPublicKey + publicKeyTextView.setOnCreateContextMenuListener { contextMenu, view, _ -> + contextMenu.add(0, view.id, 0, R.string.copy).setOnMenuItemClickListener { + copyPublicKey() + true + } + } + copyButton.setOnClickListener { copyPublicKey() } + shareButton.setOnClickListener { sharePublicKey() } + createPrivateChatButton.setOnClickListener { handlePublicKeyEntered(); publicKeyEditText.hideKeyboard() } + } + } + + private fun copyPublicKey() { + val clipboard = requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Session ID", hexEncodedPublicKey) + clipboard.setPrimaryClip(clip) + Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + } + + private fun sharePublicKey() { + val intent = Intent() + intent.action = Intent.ACTION_SEND + intent.putExtra(Intent.EXTRA_TEXT, hexEncodedPublicKey) + intent.type = "text/plain" + startActivity(intent) + } + + private fun handlePublicKeyEntered() { + val hexEncodedPublicKey = binding.publicKeyEditText.text?.trim()?.toString() + if (hexEncodedPublicKey.isNullOrEmpty()) return + delegate?.handlePublicKeyEntered(hexEncodedPublicKey) + } +} + +fun interface EnterPublicKeyDelegate { + fun handlePublicKeyEntered(publicKey: String) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt new file mode 100644 index 0000000000..8b880d2189 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms.dms + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import com.google.android.material.tabs.TabLayoutMediator +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import network.loki.messenger.databinding.FragmentNewMessageBinding +import nl.komponents.kovenant.ui.failUi +import nl.komponents.kovenant.ui.successUi +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.PublicKeyValidation +import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.dependencies.DatabaseComponent + +@AndroidEntryPoint +class NewMessageFragment : Fragment() { + + private lateinit var binding: FragmentNewMessageBinding + + lateinit var delegate: NewConversationDelegate + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentNewMessageBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.backButton.setOnClickListener { delegate.onDialogBackPressed() } + binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } + val onsOrPkDelegate = { onsNameOrPublicKey: String -> createPrivateChatIfPossible(onsNameOrPublicKey)} + val adapter = NewMessageFragmentAdapter( + parentFragment = this, + enterPublicKeyDelegate = onsOrPkDelegate, + scanPublicKeyDelegate = onsOrPkDelegate + ) + binding.viewPager.adapter = adapter + val mediator = TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, pos -> + tab.text = when (pos) { + 0 -> getString(R.string.activity_create_private_chat_enter_session_id_tab_title) + 1 -> getString(R.string.activity_create_private_chat_scan_qr_code_tab_title) + else -> throw IllegalStateException() + } + } + mediator.attach() + } + + private fun createPrivateChatIfPossible(onsNameOrPublicKey: String) { + if (PublicKeyValidation.isValid(onsNameOrPublicKey)) { + createPrivateChat(onsNameOrPublicKey) + } else { + // This could be an ONS name + showLoader() + SnodeAPI.getSessionID(onsNameOrPublicKey).successUi { hexEncodedPublicKey -> + hideLoader() + createPrivateChat(hexEncodedPublicKey) + }.failUi { exception -> + hideLoader() + var message = getString(R.string.fragment_enter_public_key_error_message) + exception.localizedMessage?.let { + message = it + } + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() + } + } + } + + private fun createPrivateChat(hexEncodedPublicKey: String) { + val recipient = Recipient.from(requireContext(), Address.fromSerialized(hexEncodedPublicKey), false) + val intent = Intent(requireContext(), ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) + intent.setDataAndType(requireActivity().intent.data, requireActivity().intent.type) + val existingThread = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient) + intent.putExtra(ConversationActivityV2.THREAD_ID, existingThread) + requireContext().startActivity(intent) + delegate.onDialogClosePressed() + } + + private fun showLoader() { + binding.loader.visibility = View.VISIBLE + binding.loader.animate().setDuration(150).alpha(1.0f).start() + } + + private fun hideLoader() { + binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { + + override fun onAnimationEnd(animation: Animator?) { + super.onAnimationEnd(animation) + binding.loader.visibility = View.GONE + } + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragmentAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragmentAdapter.kt new file mode 100644 index 0000000000..3a07bcb518 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragmentAdapter.kt @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.dms + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment +import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate + +class NewMessageFragmentAdapter( + private val parentFragment: Fragment, + private val enterPublicKeyDelegate: EnterPublicKeyDelegate, + private val scanPublicKeyDelegate: ScanQRCodeWrapperFragmentDelegate +) : FragmentStateAdapter(parentFragment) { + + override fun getItemCount(): Int = 2 + + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> EnterPublicKeyFragment().apply { delegate = enterPublicKeyDelegate } + 1 -> ScanQRCodeWrapperFragment().apply { delegate = scanPublicKeyDelegate } + else -> throw IllegalStateException() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateClosedGroupActivity.kt deleted file mode 100644 index 63894344d6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateClosedGroupActivity.kt +++ /dev/null @@ -1,145 +0,0 @@ -package org.thoughtcrime.securesms.groups - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.Toast -import androidx.loader.app.LoaderManager -import androidx.loader.content.Loader -import androidx.recyclerview.widget.LinearLayoutManager -import network.loki.messenger.R -import network.loki.messenger.databinding.ActivityCreateClosedGroupBinding -import nl.komponents.kovenant.ui.failUi -import nl.komponents.kovenant.ui.successUi -import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.messaging.sending_receiving.groupSizeLimit -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.contacts.SelectContactsAdapter -import org.thoughtcrime.securesms.contacts.SelectContactsLoader -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.mms.GlideApp -import org.thoughtcrime.securesms.util.fadeIn -import org.thoughtcrime.securesms.util.fadeOut - -class CreateClosedGroupActivity : PassphraseRequiredActionBarActivity(), LoaderManager.LoaderCallbacks> { - private lateinit var binding: ActivityCreateClosedGroupBinding - private var isLoading = false - set(newValue) { field = newValue; invalidateOptionsMenu() } - private var members = listOf() - set(value) { field = value; selectContactsAdapter.members = value } - private val publicKey: String - get() { - return TextSecurePreferences.getLocalNumber(this)!! - } - - private val selectContactsAdapter by lazy { - SelectContactsAdapter(this, GlideApp.with(this)) - } - - companion object { - const val closedGroupCreatedResultCode = 100 - } - - // region Lifecycle - override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { - super.onCreate(savedInstanceState, isReady) - binding = ActivityCreateClosedGroupBinding.inflate(layoutInflater) - setContentView(binding.root) - supportActionBar!!.title = resources.getString(R.string.activity_create_closed_group_title) - binding.recyclerView.adapter = this.selectContactsAdapter - binding.recyclerView.layoutManager = LinearLayoutManager(this) - binding.createNewPrivateChatButton.setOnClickListener { createNewPrivateChat() } - LoaderManager.getInstance(this).initLoader(0, null, this) - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_done, menu) - return members.isNotEmpty() && !isLoading - } - // endregion - - // region Updating - override fun onCreateLoader(id: Int, bundle: Bundle?): Loader> { - return SelectContactsLoader(this, setOf()) - } - - override fun onLoadFinished(loader: Loader>, members: List) { - update(members) - } - - override fun onLoaderReset(loader: Loader>) { - update(listOf()) - } - - private fun update(members: List) { - //if there is a Note to self conversation, it loads self in the list, so we need to remove it here - this.members = members.minus(publicKey) - binding.mainContentContainer.visibility = if (members.isEmpty()) View.GONE else View.VISIBLE - binding.emptyStateContainer.visibility = if (members.isEmpty()) View.VISIBLE else View.GONE - invalidateOptionsMenu() - } - // endregion - - // region Interaction - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when(item.itemId) { - R.id.doneButton -> if (!isLoading) { createClosedGroup() } - } - return super.onOptionsItemSelected(item) - } - - private fun createNewPrivateChat() { - setResult(closedGroupCreatedResultCode) - finish() - } - - private fun createClosedGroup() { - val name = binding.nameEditText.text.trim() - if (name.isEmpty()) { - return Toast.makeText(this, R.string.activity_create_closed_group_group_name_missing_error, Toast.LENGTH_LONG).show() - } - if (name.length >= 64) { - return Toast.makeText(this, R.string.activity_create_closed_group_group_name_too_long_error, Toast.LENGTH_LONG).show() - } - val selectedMembers = this.selectContactsAdapter.selectedMembers - if (selectedMembers.count() < 1) { - return Toast.makeText(this, R.string.activity_create_closed_group_not_enough_group_members_error, Toast.LENGTH_LONG).show() - } - if (selectedMembers.count() >= groupSizeLimit) { // Minus one because we're going to include self later - return Toast.makeText(this, R.string.activity_create_closed_group_too_many_group_members_error, Toast.LENGTH_LONG).show() - } - val userPublicKey = TextSecurePreferences.getLocalNumber(this)!! - isLoading = true - binding.loaderContainer.fadeIn() - MessageSender.createClosedGroup(name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID -> - binding.loaderContainer.fadeOut() - isLoading = false - val threadID = DatabaseComponent.get(this).threadDatabase().getOrCreateThreadIdFor(Recipient.from(this, Address.fromSerialized(groupID), false)) - if (!isFinishing) { - openConversationActivity(this, threadID, Recipient.from(this, Address.fromSerialized(groupID), false)) - finish() - } - }.failUi { - binding.loaderContainer.fadeOut() - isLoading = false - Toast.makeText(this, it.message, Toast.LENGTH_LONG).show() - } - } - // endregion -} - -// region Convenience -private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) { - val intent = Intent(context, ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) - intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) - context.startActivity(intent) -} -// endregion \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt new file mode 100644 index 0000000000..c195525ac0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt @@ -0,0 +1,110 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import network.loki.messenger.databinding.FragmentCreateGroupBinding +import nl.komponents.kovenant.ui.failUi +import nl.komponents.kovenant.ui.successUi +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.sending_receiving.groupSizeLimit +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.contacts.SelectContactsAdapter +import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.util.fadeIn +import org.thoughtcrime.securesms.util.fadeOut + +@AndroidEntryPoint +class CreateGroupFragment : Fragment() { + + private lateinit var binding: FragmentCreateGroupBinding + private val viewModel: CreateGroupViewModel by viewModels() + + lateinit var delegate: NewConversationDelegate + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentCreateGroupBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val adapter = SelectContactsAdapter(requireContext(), GlideApp.with(requireContext())) + binding.backButton.setOnClickListener { delegate.onDialogBackPressed() } + binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } + binding.contactSearch.callbacks = object : KeyboardPageSearchView.Callbacks { + override fun onQueryChanged(query: String) { + adapter.members = viewModel.filter(query).map { it.address.serialize() } + } + } + binding.createNewPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() } + binding.recyclerView.adapter = adapter + var isLoading = false + binding.createClosedGroupButton.setOnClickListener { + if (isLoading) return@setOnClickListener + val name = binding.nameEditText.text.trim() + if (name.isEmpty()) { + return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_missing_error, Toast.LENGTH_LONG).show() + } + if (name.length >= 30) { + return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_too_long_error, Toast.LENGTH_LONG).show() + } + val selectedMembers = adapter.selectedMembers + if (selectedMembers.isEmpty()) { + return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_not_enough_group_members_error, Toast.LENGTH_LONG).show() + } + if (selectedMembers.count() >= groupSizeLimit) { // Minus one because we're going to include self later + return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_too_many_group_members_error, Toast.LENGTH_LONG).show() + } + val userPublicKey = TextSecurePreferences.getLocalNumber(requireContext())!! + isLoading = true + binding.loaderContainer.fadeIn() + MessageSender.createClosedGroup(name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID -> + binding.loaderContainer.fadeOut() + isLoading = false + val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getOrCreateThreadIdFor(Recipient.from(requireContext(), Address.fromSerialized(groupID), false)) + openConversationActivity( + requireContext(), + threadID, + Recipient.from(requireContext(), Address.fromSerialized(groupID), false) + ) + delegate.onDialogClosePressed() + }.failUi { + binding.loaderContainer.fadeOut() + isLoading = false + Toast.makeText(context, it.message, Toast.LENGTH_LONG).show() + } + } + binding.mainContentGroup.isVisible = !viewModel.recipients.value.isNullOrEmpty() + binding.emptyStateGroup.isVisible = viewModel.recipients.value.isNullOrEmpty() + viewModel.recipients.observe(viewLifecycleOwner) { recipients -> + adapter.members = recipients.map { it.address.serialize() } + } + } + + private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) { + val intent = Intent(context, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) + intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) + context.startActivity(intent) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt new file mode 100644 index 0000000000..b3dbb49384 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.groups + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.database.ThreadDatabase +import javax.inject.Inject + +@HiltViewModel +class CreateGroupViewModel @Inject constructor( + private val threadDb: ThreadDatabase, + private val textSecurePreferences: TextSecurePreferences +) : ViewModel() { + + private val _recipients = MutableLiveData>() + val recipients: LiveData> = _recipients + + init { + viewModelScope.launch { + threadDb.approvedConversationList.use { openCursor -> + val reader = threadDb.readerFor(openCursor) + val recipients = mutableListOf() + while (true) { + recipients += reader.next?.recipient ?: break + } + withContext(Dispatchers.Main) { + _recipients.value = recipients + .filter { !it.isGroupRecipient && it.hasApprovedMe() && it.address.serialize() != textSecurePreferences.getLocalNumber() } + } + } + } + } + + fun filter(query: String): List { + return _recipients.value?.filter { + it.address.serialize().contains(query, ignoreCase = true) || it.name?.contains(query, ignoreCase = true) == true + } ?: emptyList() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EnterCommunityUrlFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EnterCommunityUrlFragment.kt new file mode 100644 index 0000000000..a23e71c640 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EnterCommunityUrlFragment.kt @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.groups + +import android.graphics.BitmapFactory +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.chip.Chip +import network.loki.messenger.R +import network.loki.messenger.databinding.FragmentEnterCommunityUrlBinding +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.thoughtcrime.securesms.BaseActionBarActivity +import org.thoughtcrime.securesms.util.State +import org.thoughtcrime.securesms.util.hideKeyboard +import java.util.Locale + +class EnterCommunityUrlFragment : Fragment() { + private lateinit var binding: FragmentEnterCommunityUrlBinding + private val viewModel by activityViewModels() + + var delegate: EnterCommunityUrlDelegate? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentEnterCommunityUrlBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.communityUrlEditText.imeOptions = binding.communityUrlEditText.imeOptions or 16777216 // Always use incognito keyboard + binding.communityUrlEditText.addTextChangedListener { text -> binding.joinCommunityButton.isEnabled = !text.isNullOrBlank() } + binding.communityUrlEditText.setOnFocusChangeListener { _, hasFocus -> binding.defaultRoomsContainer.isVisible = !hasFocus } + binding.mainContainer.setOnTouchListener { _, _ -> + binding.defaultRoomsContainer.isVisible = true + binding.communityUrlEditText.clearFocus() + binding.communityUrlEditText.hideKeyboard() + true + } + binding.joinCommunityButton.setOnClickListener { joinCommunityIfPossible() } + viewModel.defaultRooms.observe(viewLifecycleOwner) { state -> + binding.defaultRoomsContainer.isVisible = state is State.Success + binding.defaultRoomsLoaderContainer.isVisible = state is State.Loading + binding.defaultRoomsLoader.isVisible = state is State.Loading + when (state) { + State.Loading -> { + // TODO: Show a binding.loader + } + is State.Error -> { + // TODO: Hide the binding.loader + } + is State.Success -> { + populateDefaultGroups(state.value) + } + } + } + } + + private fun populateDefaultGroups(groups: List) { + binding.defaultRoomsFlexboxLayout.removeAllViews() + groups.iterator().forEach { defaultGroup -> + val chip = layoutInflater.inflate(R.layout.default_group_chip, binding.defaultRoomsFlexboxLayout, false) as Chip + val drawable = defaultGroup.image?.let { bytes -> + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + RoundedBitmapDrawableFactory.create(resources, bitmap).apply { + isCircular = true + } + } + chip.chipIcon = drawable + chip.text = defaultGroup.name + chip.setOnClickListener { + delegate?.handleCommunityUrlEntered(defaultGroup.joinURL) + } + binding.defaultRoomsFlexboxLayout.addView(chip) + } + } + + // region Convenience + private fun joinCommunityIfPossible() { + val inputMethodManager = requireContext().getSystemService(BaseActionBarActivity.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(binding.communityUrlEditText.windowToken, 0) + val communityUrl = binding.communityUrlEditText.text.trim().toString().lowercase(Locale.US) + delegate?.handleCommunityUrlEntered(communityUrl) + } + // endregion +} + +fun interface EnterCommunityUrlDelegate { + fun handleCommunityUrlEntered(url: String) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt new file mode 100644 index 0000000000..d37b17ef9f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt @@ -0,0 +1,125 @@ +package org.thoughtcrime.securesms.groups + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.tabs.TabLayoutMediator +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import network.loki.messenger.databinding.FragmentJoinCommunityBinding +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities + +@AndroidEntryPoint +class JoinCommunityFragment : Fragment() { + + private lateinit var binding: FragmentJoinCommunityBinding + + lateinit var delegate: NewConversationDelegate + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentJoinCommunityBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.backButton.setOnClickListener { delegate.onDialogBackPressed() } + binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } + fun showLoader() { + binding.loader.visibility = View.VISIBLE + binding.loader.animate().setDuration(150).alpha(1.0f).start() + } + + fun hideLoader() { + binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { + + override fun onAnimationEnd(animation: Animator?) { + super.onAnimationEnd(animation) + binding.loader.visibility = View.GONE + } + }) + } + fun joinCommunityIfPossible(url: String) { + val openGroup = try { + OpenGroupUrlParser.parseUrl(url) + } catch (e: OpenGroupUrlParser.Error) { + when (e) { + is OpenGroupUrlParser.Error.MalformedURL -> return Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() + is OpenGroupUrlParser.Error.InvalidPublicKey -> return Toast.makeText(activity, R.string.invalid_public_key, Toast.LENGTH_SHORT).show() + is OpenGroupUrlParser.Error.NoPublicKey -> return Toast.makeText(activity, R.string.invalid_public_key, Toast.LENGTH_SHORT).show() + is OpenGroupUrlParser.Error.NoRoom -> return Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() + } + } + showLoader() + lifecycleScope.launch(Dispatchers.IO) { + try { + val sanitizedServer = openGroup.server.removeSuffix("/") + val openGroupID = "$sanitizedServer.${openGroup.room}" + OpenGroupManager.add(sanitizedServer, openGroup.room, openGroup.serverPublicKey, requireContext()) + val storage = MessagingModuleConfiguration.shared.storage + storage.onOpenGroupAdded(sanitizedServer) + val threadID = GroupManager.getOpenGroupThreadID(openGroupID, requireContext()) + val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) + + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()) + withContext(Dispatchers.Main) { + val recipient = Recipient.from(requireContext(), Address.fromSerialized(groupID), false) + openConversationActivity(requireContext(), threadID, recipient) + delegate.onDialogClosePressed() + } + } catch (e: Exception) { + Log.e("Loki", "Couldn't join open group.", e) + withContext(Dispatchers.Main) { + hideLoader() + Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() + } + return@launch + } + } + } + val urlDelegate = { url: String -> joinCommunityIfPossible(url) } + binding.viewPager.adapter = JoinCommunityFragmentAdapter( + parentFragment = this, + enterCommunityUrlDelegate = urlDelegate, + scanQrCodeDelegate = urlDelegate + ) + val mediator = TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, pos -> + tab.text = when (pos) { + 0 -> getString(R.string.activity_join_public_chat_enter_community_url_tab_title) + 1 -> getString(R.string.activity_join_public_chat_scan_qr_code_tab_title) + else -> throw IllegalStateException() + } + } + mediator.attach() + } + + private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) { + val intent = Intent(context, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) + intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) + context.startActivity(intent) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragmentAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragmentAdapter.kt new file mode 100644 index 0000000000..a49635476b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragmentAdapter.kt @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.groups + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment +import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate + +class JoinCommunityFragmentAdapter( + private val parentFragment: Fragment, + private val enterCommunityUrlDelegate: EnterCommunityUrlDelegate, + private val scanQrCodeDelegate: ScanQRCodeWrapperFragmentDelegate +) : FragmentStateAdapter(parentFragment) { + + override fun getItemCount(): Int = 2 + + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> EnterCommunityUrlFragment().apply { delegate = enterCommunityUrlDelegate } + 1 -> ScanQRCodeWrapperFragment().apply { delegate = scanQrCodeDelegate } + else -> throw IllegalStateException() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt deleted file mode 100644 index ca7ff0af17..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt +++ /dev/null @@ -1,229 +0,0 @@ -package org.thoughtcrime.securesms.groups - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.content.Context -import android.content.Intent -import android.graphics.BitmapFactory -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import android.widget.Toast -import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentPagerAdapter -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import com.google.android.material.chip.Chip -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import network.loki.messenger.R -import network.loki.messenger.databinding.ActivityJoinPublicChatBinding -import network.loki.messenger.databinding.FragmentEnterChatUrlBinding -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.open_groups.OpenGroupApi.DefaultGroup -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.GroupUtil -import org.session.libsession.utilities.OpenGroupUrlParser -import org.session.libsession.utilities.OpenGroupUrlParser.Error -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.BaseActionBarActivity -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate -import org.thoughtcrime.securesms.util.State -import java.util.Locale - -class JoinPublicChatActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { - private lateinit var binding: ActivityJoinPublicChatBinding - private val adapter = JoinPublicChatActivityAdapter(this) - - // region Lifecycle - override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { - super.onCreate(savedInstanceState, isReady) - binding = ActivityJoinPublicChatBinding.inflate(layoutInflater) - // Set content view - setContentView(binding.root) - // Set title - supportActionBar!!.title = resources.getString(R.string.activity_join_public_chat_title) - // Set up view pager - binding.viewPager.adapter = adapter - binding.tabLayout.setupWithViewPager(binding.viewPager) - } - // endregion - - // region Updating - private fun showLoader() { - binding.loader.visibility = View.VISIBLE - binding.loader.animate().setDuration(150).alpha(1.0f).start() - } - - private fun hideLoader() { - binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - - override fun onAnimationEnd(animation: Animator?) { - super.onAnimationEnd(animation) - binding.loader.visibility = View.GONE - } - }) - } - // endregion - - // region Interaction - override fun handleQRCodeScanned(url: String) { - joinPublicChatIfPossible(url) - } - - fun joinPublicChatIfPossible(url: String) { - // Add "http" if not entered explicitly - val openGroup = try { - OpenGroupUrlParser.parseUrl(url) - } catch (e: Error) { - when (e) { - is Error.MalformedURL -> return Toast.makeText(this, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() - is Error.InvalidPublicKey -> return Toast.makeText(this, R.string.invalid_public_key, Toast.LENGTH_SHORT).show() - is Error.NoPublicKey -> return Toast.makeText(this, R.string.invalid_public_key, Toast.LENGTH_SHORT).show() - is Error.NoRoom -> return Toast.makeText(this, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() - } - } - showLoader() - lifecycleScope.launch(Dispatchers.IO) { - try { - val sanitizedServer = openGroup.server.removeSuffix("/") - val openGroupID = "$sanitizedServer.${openGroup.room}" - OpenGroupManager.add(sanitizedServer, openGroup.room, openGroup.serverPublicKey, this@JoinPublicChatActivity) - val storage = MessagingModuleConfiguration.shared.storage - storage.onOpenGroupAdded(sanitizedServer) - val threadID = GroupManager.getOpenGroupThreadID(openGroupID, this@JoinPublicChatActivity) - val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) - - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@JoinPublicChatActivity) - withContext(Dispatchers.Main) { - val recipient = Recipient.from(this@JoinPublicChatActivity, Address.fromSerialized(groupID), false) - openConversationActivity(this@JoinPublicChatActivity, threadID, recipient) - finish() - } - } catch (e: Exception) { - Log.e("Loki", "Couldn't join open group.", e) - withContext(Dispatchers.Main) { - hideLoader() - Toast.makeText(this@JoinPublicChatActivity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() - } - return@launch - } - } - } - // endregion - - // region Convenience - private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) { - val intent = Intent(context, ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) - intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) - context.startActivity(intent) - } - // endregion -} - -// region Adapter -private class JoinPublicChatActivityAdapter(val activity: JoinPublicChatActivity) : FragmentPagerAdapter(activity.supportFragmentManager) { - - override fun getCount(): Int { - return 2 - } - - override fun getItem(index: Int): Fragment { - return when (index) { - 0 -> EnterChatURLFragment() - 1 -> { - val result = ScanQRCodeWrapperFragment() - result.delegate = activity - result.message = activity.resources.getString(R.string.activity_join_public_chat_scan_qr_code_explanation) - result - } - else -> throw IllegalStateException() - } - } - - override fun getPageTitle(index: Int): CharSequence { - return when (index) { - 0 -> activity.resources.getString(R.string.activity_join_public_chat_enter_group_url_tab_title) - 1 -> activity.resources.getString(R.string.activity_join_public_chat_scan_qr_code_tab_title) - else -> throw IllegalStateException() - } - } -} -// endregion - -// region Enter Chat URL Fragment -class EnterChatURLFragment : Fragment() { - private lateinit var binding: FragmentEnterChatUrlBinding - private val viewModel by activityViewModels() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = FragmentEnterChatUrlBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.chatURLEditText.imeOptions = binding.chatURLEditText.imeOptions or 16777216 // Always use incognito keyboard - binding.joinPublicChatButton.setOnClickListener { joinPublicChatIfPossible() } - viewModel.defaultRooms.observe(viewLifecycleOwner) { state -> - binding.defaultRoomsContainer.isVisible = state is State.Success - binding.defaultRoomsLoaderContainer.isVisible = state is State.Loading - binding.defaultRoomsLoader.isVisible = state is State.Loading - when (state) { - State.Loading -> { - // TODO: Show a binding.loader - } - is State.Error -> { - // TODO: Hide the binding.loader - } - is State.Success -> { - populateDefaultGroups(state.value) - } - } - } - } - - private fun populateDefaultGroups(groups: List) { - binding.defaultRoomsGridLayout.removeAllViews() - binding.defaultRoomsGridLayout.useDefaultMargins = false - groups.iterator().forEach { defaultGroup -> - val chip = layoutInflater.inflate(R.layout.default_group_chip, binding.defaultRoomsGridLayout, false) as Chip - val drawable = defaultGroup.image?.let { bytes -> - val bitmap = BitmapFactory.decodeByteArray(bytes,0, bytes.size) - RoundedBitmapDrawableFactory.create(resources,bitmap).apply { - isCircular = true - } - } - chip.chipIcon = drawable - chip.text = defaultGroup.name - chip.setOnClickListener { - (requireActivity() as JoinPublicChatActivity).joinPublicChatIfPossible(defaultGroup.joinURL) - } - binding.defaultRoomsGridLayout.addView(chip) - } - if ((groups.size and 1) != 0) { // This checks that the number of rooms is even - layoutInflater.inflate(R.layout.grid_layout_filler, binding.defaultRoomsGridLayout) - } - } - - // region Convenience - private fun joinPublicChatIfPossible() { - val inputMethodManager = requireContext().getSystemService(BaseActionBarActivity.INPUT_METHOD_SERVICE) as InputMethodManager - inputMethodManager.hideSoftInputFromWindow(binding.chatURLEditText.windowToken, 0) - val chatURL = binding.chatURLEditText.text.trim().toString().toLowerCase(Locale.US) - (requireActivity() as JoinPublicChatActivity).joinPublicChatIfPossible(chatURL) - } - // endregion -} -// endregion \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 3bbe8c8554..429bbd39f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -41,6 +41,7 @@ import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.MuteDialog import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.conversation.start.NewConversationFragment import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.crypto.IdentityKeyUtil @@ -50,9 +51,6 @@ import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.dms.CreatePrivateChatActivity -import org.thoughtcrime.securesms.groups.CreateClosedGroupActivity -import org.thoughtcrime.securesms.groups.JoinPublicChatActivity import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter import org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout @@ -76,10 +74,9 @@ import javax.inject.Inject @AndroidEntryPoint class HomeActivity : PassphraseRequiredActionBarActivity(), - ConversationClickListener, - SeedReminderViewDelegate, - NewConversationButtonSetViewDelegate, - GlobalSearchInputLayout.GlobalSearchInputLayoutListener { + ConversationClickListener, + SeedReminderViewDelegate, + GlobalSearchInputLayout.GlobalSearchInputLayoutListener { private lateinit var binding: ActivityHomeBinding private lateinit var glide: GlideRequests @@ -178,7 +175,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.recyclerView.adapter = homeAdapter binding.globalSearchRecycler.adapter = globalSearchAdapter // Set up empty state view - binding.createNewPrivateChatButton.setOnClickListener { createNewPrivateChat() } + binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() } IP2Country.configureIfNeeded(this@HomeActivity) homeViewModel.getObservable(this).observe(this) { newData -> val manager = binding.recyclerView.layoutManager as LinearLayoutManager @@ -194,8 +191,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), updateEmptyState() } homeViewModel.tryUpdateChannel() - // Set up new conversation button set - binding.newConversationButtonSet.delegate = this + // Set up new conversation button + binding.newConversationButton.setOnClickListener { showNewConversation() } // Observe blocked contacts changed events val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -301,7 +298,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown binding.gradientView.isVisible = !isShown binding.globalSearchRecycler.isVisible = isShown - binding.newConversationButtonSet.isVisible = !isShown + binding.newConversationButton.isVisible = !isShown } private fun setupMessageRequestsBanner() { @@ -354,13 +351,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(false) } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (resultCode == CreateClosedGroupActivity.closedGroupCreatedResultCode) { - createNewPrivateChat() - } - } - override fun onDestroy() { val broadcastReceiver = this.broadcastReceiver if (broadcastReceiver != null) { @@ -623,19 +613,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), .create().show() } - override fun createNewPrivateChat() { - val intent = Intent(this, CreatePrivateChatActivity::class.java) - show(intent) + private fun showNewConversation() { + NewConversationFragment().show(supportFragmentManager, "NewConversationFragment") } - override fun createNewClosedGroup() { - val intent = Intent(this, CreateClosedGroupActivity::class.java) - show(intent, true) - } - - override fun joinOpenGroup() { - val intent = Intent(this, JoinPublicChatActivity::class.java) - show(intent) - } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/NewConversationButtonSetView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/NewConversationButtonSetView.kt deleted file mode 100644 index 3533a1aa8c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/home/NewConversationButtonSetView.kt +++ /dev/null @@ -1,386 +0,0 @@ -package org.thoughtcrime.securesms.home - -import android.animation.FloatEvaluator -import android.animation.PointFEvaluator -import android.animation.ValueAnimator -import android.content.Context -import android.content.res.ColorStateList -import android.graphics.PointF -import android.graphics.Typeface -import android.os.Build -import android.util.AttributeSet -import android.util.TypedValue -import android.view.Gravity -import android.view.HapticFeedbackConstants -import android.view.MotionEvent -import android.view.View -import android.widget.ImageView -import android.widget.RelativeLayout -import android.widget.TextView -import androidx.annotation.ColorRes -import androidx.annotation.DrawableRes -import network.loki.messenger.R -import org.thoughtcrime.securesms.util.GlowViewUtilities -import org.thoughtcrime.securesms.util.NewConversationButtonImageView -import org.thoughtcrime.securesms.util.UiModeUtilities -import org.thoughtcrime.securesms.util.animateSizeChange -import org.thoughtcrime.securesms.util.contains -import org.thoughtcrime.securesms.util.disableClipping -import org.thoughtcrime.securesms.util.distanceTo -import org.thoughtcrime.securesms.util.getColorWithID -import org.thoughtcrime.securesms.util.isAbove -import org.thoughtcrime.securesms.util.isLeftOf -import org.thoughtcrime.securesms.util.isRightOf -import org.thoughtcrime.securesms.util.toPx - -class NewConversationButtonSetView : RelativeLayout { - private var expandedButton: Button? = null - private var previousAction: Int? = null - private var isExpanded = false - var delegate: NewConversationButtonSetViewDelegate? = null - - // region Convenience - private val sessionButtonExpandedPosition: PointF get() { return PointF(width.toFloat() / 2 - sessionButton.expandedSize / 2 - sessionButton.shadowMargin, 0.0f) } - private val sessionTooltipExpandedPosition: PointF get() { - val x = sessionButtonExpandedPosition.x + sessionButton.width / 2 - sessionTooltip.width / 2 - val y = sessionButtonExpandedPosition.y + sessionButton.height - sessionTooltip.height / 2 - return PointF(x, y) - } - private val closedGroupButtonExpandedPosition: PointF get() { return PointF(width.toFloat() - closedGroupButton.expandedSize - 2 * closedGroupButton.shadowMargin, height.toFloat() - bottomMargin - closedGroupButton.expandedSize - 2 * closedGroupButton.shadowMargin) } - private val closedGroupTooltipExpandedPosition: PointF get() { - val x = closedGroupButtonExpandedPosition.x + closedGroupButton.width / 2 - closedGroupTooltip.width / 2 - val y = closedGroupButtonExpandedPosition.y + closedGroupButton.height - closedGroupTooltip.height / 2 - return PointF(x, y) - } - private val openGroupButtonExpandedPosition: PointF get() { return PointF(0.0f, height.toFloat() - bottomMargin - openGroupButton.expandedSize - 2 * openGroupButton.shadowMargin) } - private val openGroupTooltipExpandedPosition: PointF get() { - val x = openGroupButtonExpandedPosition.x + openGroupButton.width / 2 - openGroupTooltip.width / 2 - val y = openGroupButtonExpandedPosition.y + openGroupButton.height - openGroupTooltip.height / 2 - return PointF(x, y) - } - private val buttonRestPosition: PointF get() { return PointF(width.toFloat() / 2 - mainButton.expandedSize / 2 - mainButton.shadowMargin, height.toFloat() - bottomMargin - mainButton.expandedSize - 2 * mainButton.shadowMargin) } - private fun tooltipRestPosition(viewWidth: Int): PointF { - return PointF(width.toFloat() / 2 - viewWidth / 2, height.toFloat() - bottomMargin) - } - // endregion - - // region Settings - private val minDragDistance by lazy { toPx(40, resources).toFloat() } - private val maxDragDistance by lazy { toPx(56, resources).toFloat() } - private val dragMargin by lazy { toPx(16, resources).toFloat() } - private val bottomMargin by lazy { resources.getDimension(R.dimen.new_conversation_button_bottom_offset) } - // endregion - - // region Components - private val mainButton by lazy { Button(context, true, R.drawable.ic_plus) } - private val sessionButton by lazy { Button(context, false, R.drawable.ic_message) } - private val sessionTooltip by lazy { - TextView(context).apply { - setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) - typeface = Typeface.DEFAULT_BOLD - setText(R.string.NewConversationButton_SessionTooltip) - isAllCaps = true - } - } - private val closedGroupButton by lazy { Button(context, false, R.drawable.ic_group) } - private val closedGroupTooltip by lazy { - TextView(context).apply { - setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) - typeface = Typeface.DEFAULT_BOLD - setText(R.string.NewConversationButton_ClosedGroupTooltip) - isAllCaps = true - } - } - private val openGroupButton by lazy { Button(context, false, R.drawable.ic_globe) } - private val openGroupTooltip by lazy { - TextView(context).apply { - setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) - typeface = Typeface.DEFAULT_BOLD - setText(R.string.NewConversationButton_OpenGroupTooltip) - isAllCaps = true - } - } - // endregion - - // region Button - class Button : RelativeLayout { - @DrawableRes private var iconID = 0 - private var isMain = false - - fun getIconID() = iconID - - companion object { - val animationDuration = 250.toLong() - } - - val expandedSize by lazy { resources.getDimension(R.dimen.new_conversation_button_expanded_size) } - val collapsedSize by lazy { resources.getDimension(R.dimen.new_conversation_button_collapsed_size) } - val shadowMargin by lazy { toPx(6, resources).toFloat() } - private val expandedImageViewPosition by lazy { PointF(shadowMargin, shadowMargin) } - private val collapsedImageViewPosition by lazy { PointF(shadowMargin + (expandedSize - collapsedSize) / 2, shadowMargin + (expandedSize - collapsedSize) / 2) } - - private val imageView by lazy { - val result = NewConversationButtonImageView(context) - val size = collapsedSize.toInt() - result.layoutParams = LayoutParams(size, size) - result.setBackgroundResource(R.drawable.new_conversation_button_background) - @ColorRes val backgroundColorID = if (isMain) R.color.accent else R.color.new_conversation_button_collapsed_background - @ColorRes val shadowColorID = if (isMain) { - R.color.new_conversation_button_shadow - } else { - if (UiModeUtilities.isDayUiMode(context)) R.color.transparent_black_30 else R.color.black - } - result.mainColor = resources.getColorWithID(backgroundColorID, context.theme) - result.sessionShadowColor = resources.getColorWithID(shadowColorID, context.theme) - result.scaleType = ImageView.ScaleType.CENTER - result.setImageResource(iconID) - result.imageTintList = if (isMain) { - ColorStateList.valueOf(resources.getColorWithID(android.R.color.white, context.theme)) - } else { - ColorStateList.valueOf(resources.getColorWithID(R.color.text, context.theme)) - } - result - } - - constructor(context: Context) : super(context) { throw IllegalAccessException("Use Button(context:iconID:) instead.") } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { throw IllegalAccessException("Use Button(context:iconID:) instead.") } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { throw IllegalAccessException("Use Button(context:iconID:) instead.") } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { throw IllegalAccessException("Use Button(context:iconID:) instead.") } - - constructor(context: Context, isMain: Boolean, @DrawableRes iconID: Int) : super(context) { - this.iconID = iconID - this.isMain = isMain - disableClipping() - val size = resources.getDimension(R.dimen.new_conversation_button_expanded_size).toInt() + 2 * shadowMargin.toInt() - val layoutParams = LayoutParams(size, size) - this.layoutParams = layoutParams - addView(imageView) - imageView.x = collapsedImageViewPosition.x - imageView.y = collapsedImageViewPosition.y - gravity = Gravity.TOP or Gravity.LEFT // Intentionally not Gravity.START - } - - fun expand() { - GlowViewUtilities.animateColorChange(context, imageView, R.color.new_conversation_button_collapsed_background, R.color.accent) - @ColorRes val startShadowColorID = if (UiModeUtilities.isDayUiMode(context)) R.color.transparent_black_30 else R.color.black - GlowViewUtilities.animateShadowColorChange(context, imageView, startShadowColorID, R.color.new_conversation_button_shadow) - imageView.animateSizeChange(R.dimen.new_conversation_button_collapsed_size, R.dimen.new_conversation_button_expanded_size, animationDuration) - animateImageViewPositionChange(collapsedImageViewPosition, expandedImageViewPosition) - } - - fun collapse() { - GlowViewUtilities.animateColorChange(context, imageView, R.color.accent, R.color.new_conversation_button_collapsed_background) - @ColorRes val endShadowColorID = if (UiModeUtilities.isDayUiMode(context)) R.color.transparent_black_30 else R.color.black - GlowViewUtilities.animateShadowColorChange(context, imageView, R.color.new_conversation_button_shadow, endShadowColorID) - imageView.animateSizeChange(R.dimen.new_conversation_button_expanded_size, R.dimen.new_conversation_button_collapsed_size, animationDuration) - animateImageViewPositionChange(expandedImageViewPosition, collapsedImageViewPosition) - } - - private fun animateImageViewPositionChange(startPosition: PointF, endPosition: PointF) { - val animation = ValueAnimator.ofObject(PointFEvaluator(), startPosition, endPosition) - animation.duration = animationDuration - animation.addUpdateListener { animator -> - val point = animator.animatedValue as PointF - imageView.x = point.x - imageView.y = point.y - } - animation.start() - } - - fun animatePositionChange(startPosition: PointF, endPosition: PointF) { - val animation = ValueAnimator.ofObject(PointFEvaluator(), startPosition, endPosition) - animation.duration = animationDuration - animation.addUpdateListener { animator -> - val point = animator.animatedValue as PointF - x = point.x - y = point.y - } - animation.start() - } - - fun animateAlphaChange(startAlpha: Float, endAlpha: Float) { - val animation = ValueAnimator.ofObject(FloatEvaluator(), startAlpha, endAlpha) - animation.duration = animationDuration - animation.addUpdateListener { animator -> - alpha = animator.animatedValue as Float - } - animation.start() - } - } - // endregion - - // region Lifecycle - constructor(context: Context) : super(context) { setUpViewHierarchy() } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { setUpViewHierarchy() } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { setUpViewHierarchy() } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { setUpViewHierarchy() } - - private fun setUpViewHierarchy() { - disableClipping() - isHapticFeedbackEnabled = true - // Set up session button - addView(sessionButton) - addView(sessionTooltip) - sessionButton.alpha = 0.0f - sessionTooltip.alpha = 0.0f - val sessionButtonLayoutParams = sessionButton.layoutParams as LayoutParams - sessionButtonLayoutParams.addRule(CENTER_IN_PARENT, TRUE) - sessionButtonLayoutParams.addRule(ALIGN_PARENT_BOTTOM, TRUE) - sessionButtonLayoutParams.bottomMargin = bottomMargin.toInt() - // Set up closed group button - addView(closedGroupButton) - addView(closedGroupTooltip) - closedGroupButton.alpha = 0.0f - closedGroupTooltip.alpha = 0.0f - val closedGroupButtonLayoutParams = closedGroupButton.layoutParams as LayoutParams - closedGroupButtonLayoutParams.addRule(CENTER_IN_PARENT, TRUE) - closedGroupButtonLayoutParams.addRule(ALIGN_PARENT_BOTTOM, TRUE) - closedGroupButtonLayoutParams.bottomMargin = bottomMargin.toInt() - // Set up open group button - addView(openGroupButton) - addView(openGroupTooltip) - openGroupButton.alpha = 0.0f - openGroupTooltip.alpha = 0.0f - val openGroupButtonLayoutParams = openGroupButton.layoutParams as LayoutParams - openGroupButtonLayoutParams.addRule(CENTER_IN_PARENT, TRUE) - openGroupButtonLayoutParams.addRule(ALIGN_PARENT_BOTTOM, TRUE) - openGroupButtonLayoutParams.bottomMargin = bottomMargin.toInt() - // Set up main button - addView(mainButton) - val mainButtonLayoutParams = mainButton.layoutParams as LayoutParams - mainButtonLayoutParams.addRule(CENTER_IN_PARENT, TRUE) - mainButtonLayoutParams.addRule(ALIGN_PARENT_BOTTOM, TRUE) - mainButtonLayoutParams.bottomMargin = bottomMargin.toInt() - } - // endregion - - // region Interaction - override fun onTouchEvent(event: MotionEvent): Boolean { - val touch = PointF(event.x, event.y) - val allButtons = listOf( mainButton, sessionButton, closedGroupButton, openGroupButton ) - val buttonsExcludingMainButton = listOf( sessionButton, closedGroupButton, openGroupButton ) - if (allButtons.none { it.contains(touch) }) { return false } - when (event.action) { - MotionEvent.ACTION_DOWN -> { - if (isExpanded) { - if (mainButton.contains(touch)) { collapse() } - } else { - isExpanded = true - expand() - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) - } else { - performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - } - } - MotionEvent.ACTION_MOVE -> { - mainButton.x = touch.x - mainButton.expandedSize / 2 - mainButton.y = touch.y - mainButton.expandedSize / 2 - mainButton.alpha = 1 - (PointF(mainButton.x, mainButton.y).distanceTo(buttonRestPosition) / maxDragDistance) - val buttonToExpand = buttonsExcludingMainButton.firstOrNull { button -> - var hasUserDraggedBeyondButton = false - if (button == openGroupButton && touch.isLeftOf(openGroupButton, dragMargin)) { hasUserDraggedBeyondButton = true } - if (button == sessionButton && touch.isAbove(sessionButton, dragMargin)) { hasUserDraggedBeyondButton = true } - if (button == closedGroupButton && touch.isRightOf(closedGroupButton, dragMargin)) { hasUserDraggedBeyondButton = true } - button.contains(touch) || hasUserDraggedBeyondButton - } - if (buttonToExpand != null) { - if (buttonToExpand == expandedButton) { return true } - expandedButton?.collapse() - buttonToExpand.expand() - this.expandedButton = buttonToExpand - } else { - expandedButton?.collapse() - this.expandedButton = null - } - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - val mainButtonCenter = PointF(width.toFloat() / 2, height.toFloat() - bottomMargin - mainButton.expandedSize / 2) - val distanceFromMainButtonCenter = touch.distanceTo(mainButtonCenter) - fun collapse() { - isExpanded = false - this.collapse() - } - if (distanceFromMainButtonCenter > (minDragDistance + mainButton.collapsedSize / 2)) { - if (sessionButton.contains(touch) || touch.isAbove(sessionButton, dragMargin)) { delegate?.createNewPrivateChat(); collapse() } - else if (closedGroupButton.contains(touch) || touch.isRightOf(closedGroupButton, dragMargin)) { delegate?.createNewClosedGroup(); collapse() } - else if (openGroupButton.contains(touch) || touch.isLeftOf(openGroupButton, dragMargin)) { delegate?.joinOpenGroup(); collapse() } - else { collapse() } - } else { - val currentPosition = PointF(mainButton.x, mainButton.y) - mainButton.animatePositionChange(currentPosition, buttonRestPosition) - val endAlpha = 1.0f - mainButton.animateAlphaChange(mainButton.alpha, endAlpha) - expandedButton?.collapse() - this.expandedButton = null - } - } - } - previousAction = event.action - return true - } - - private fun expand() { - val buttonsExcludingMainButton = listOf( sessionButton, closedGroupButton, openGroupButton ) - val allTooltips = listOf(sessionTooltip, closedGroupTooltip, openGroupTooltip) - - sessionButton.animatePositionChange(buttonRestPosition, sessionButtonExpandedPosition) - sessionTooltip.animatePositionChange(tooltipRestPosition(sessionTooltip.width), sessionTooltipExpandedPosition) - closedGroupButton.animatePositionChange(buttonRestPosition, closedGroupButtonExpandedPosition) - closedGroupTooltip.animatePositionChange(tooltipRestPosition(closedGroupTooltip.width), closedGroupTooltipExpandedPosition) - openGroupButton.animatePositionChange(buttonRestPosition, openGroupButtonExpandedPosition) - openGroupTooltip.animatePositionChange(tooltipRestPosition(openGroupTooltip.width), openGroupTooltipExpandedPosition) - buttonsExcludingMainButton.forEach { it.animateAlphaChange(0.0f, 1.0f) } - allTooltips.forEach { it.animateAlphaChange(0.0f, 1.0f) } - postDelayed({ isExpanded = true }, Button.animationDuration) - } - - private fun collapse() { - val allButtons = listOf( mainButton, sessionButton, closedGroupButton, openGroupButton ) - val allTooltips = listOf(sessionTooltip, closedGroupTooltip, openGroupTooltip) - - allButtons.forEach { - val currentPosition = PointF(it.x, it.y) - it.animatePositionChange(currentPosition, buttonRestPosition) - val endAlpha = if (it == mainButton) 1.0f else 0.0f - it.animateAlphaChange(it.alpha, endAlpha) - } - allTooltips.forEach { - it.animateAlphaChange(1.0f, 0.0f) - it.animatePositionChange(PointF(it.x, it.y), tooltipRestPosition(it.width)) - } - postDelayed({ isExpanded = false }, Button.animationDuration) - } - // endregion - - fun View.animatePositionChange(startPosition: PointF, endPosition: PointF) { - val animation = ValueAnimator.ofObject(PointFEvaluator(), startPosition, endPosition) - animation.duration = Button.animationDuration - animation.addUpdateListener { animator -> - val point = animator.animatedValue as PointF - x = point.x - y = point.y - } - animation.start() - } - - fun View.animateAlphaChange(startAlpha: Float, endAlpha: Float) { - val animation = ValueAnimator.ofObject(FloatEvaluator(), startAlpha, endAlpha) - animation.duration = Button.animationDuration - animation.addUpdateListener { animator -> - alpha = animator.animatedValue as Float - } - animation.start() - } - -} - -// region Delegate -interface NewConversationButtonSetViewDelegate { - - fun joinOpenGroup() - fun createNewPrivateChat() - fun createNewClosedGroup() -} -// endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeFragment.kt index e060a82823..3b551427a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeFragment.kt @@ -6,6 +6,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.LinearLayout +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import network.loki.messenger.databinding.FragmentScanQrCodeBinding import org.thoughtcrime.securesms.qr.ScanListener @@ -30,6 +31,7 @@ class ScanQRCodeFragment : Fragment() { else -> binding.overlayView.orientation = LinearLayout.VERTICAL } binding.messageTextView.text = message + binding.messageTextView.isVisible = message.isNotEmpty() } override fun onResume() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt index 6913e3cb8a..ea1a90d19a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt @@ -11,7 +11,6 @@ import androidx.fragment.app.Fragment import com.tbruyelle.rxpermissions2.RxPermissions import network.loki.messenger.R import org.thoughtcrime.securesms.qr.ScanListener -import org.thoughtcrime.securesms.util.ScanQRCodeFragment class ScanQRCodeWrapperFragment : Fragment(), ScanQRCodePlaceholderFragmentDelegate, ScanListener { @@ -80,14 +79,14 @@ class ScanQRCodeWrapperFragment : Fragment(), ScanQRCodePlaceholderFragmentDeleg } } - override fun onQrDataFound(string: String) { + override fun onQrDataFound(data: String) { activity?.runOnUiThread { - delegate?.handleQRCodeScanned(string) + delegate?.handleQRCodeScanned(data) } } } -interface ScanQRCodeWrapperFragmentDelegate { +fun interface ScanQRCodeWrapperFragmentDelegate { fun handleQRCodeScanned(string: String) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index 6d4d053b6d..a02033b862 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -4,10 +4,12 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.FloatEvaluator import android.animation.ValueAnimator +import android.content.Context import android.graphics.PointF import android.graphics.Rect import androidx.annotation.DimenRes import android.view.View +import android.view.inputmethod.InputMethodManager fun View.contains(point: PointF): Boolean { return hitRect.contains(point.x.toInt(), point.y.toInt()) @@ -52,3 +54,8 @@ fun View.fadeOut(duration: Long = 150) { } }) } + +fun View.hideKeyboard() { + val imm = this.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(this.windowToken, 0) +} diff --git a/app/src/main/res/drawable/ic_baseline_close_24.xml b/app/src/main/res/drawable/ic_baseline_close_24.xml new file mode 100644 index 0000000000..95cc170f36 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_close_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout-sw400dp/fragment_enter_chat_url.xml b/app/src/main/res/layout-sw400dp/fragment_enter_chat_url.xml deleted file mode 100644 index a689ad6258..0000000000 --- a/app/src/main/res/layout-sw400dp/fragment_enter_chat_url.xml +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - -