ChatFragment.kt (11880B)
1 /* 2 This file is part of GNUnet. 3 Copyright (C) 2021--2025 GNUnet e.V. 4 5 GNUnet is free software: you can redistribute it and/or modify it 6 under the terms of the GNU Affero General Public License as published 7 by the Free Software Foundation, either version 3 of the License, 8 or (at your option) any later version. 9 10 GNUnet is distributed in the hope that it will be useful, but 11 WITHOUT ANY WARRANTY; without even the implied warranty of 12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 Affero General Public License for more details. 14 15 You should have received a copy of the GNU Affero General Public License 16 along with this program. If not, see <http://www.gnu.org/licenses/>. 17 18 SPDX-License-Identifier: AGPL3.0-or-later 19 */ 20 /* 21 * @author t3sserakt 22 * @file GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ui/chat/ChatFragment.kt 23 */ 24 25 package org.gnunet.gnunetmessenger.ui.chat 26 27 import android.os.Bundle 28 import android.util.Log 29 import android.view.LayoutInflater 30 import android.view.Menu 31 import android.view.MenuInflater 32 import android.view.MenuItem 33 import android.view.View 34 import android.view.ViewGroup 35 import android.widget.Button 36 import android.widget.EditText 37 import android.widget.Toast 38 import androidx.appcompat.app.AppCompatActivity 39 import androidx.core.view.MenuHost 40 import androidx.core.view.MenuProvider 41 import androidx.fragment.app.Fragment 42 import androidx.lifecycle.Observer 43 import androidx.lifecycle.lifecycleScope 44 import androidx.navigation.fragment.findNavController 45 import androidx.navigation.fragment.navArgs 46 import androidx.recyclerview.widget.LinearLayoutManager 47 import androidx.recyclerview.widget.RecyclerView 48 import kotlinx.coroutines.launch 49 import org.gnunet.gnunetmessenger.MainActivity 50 import org.gnunet.gnunetmessenger.R 51 import org.gnunet.gnunetmessenger.model.ChatContact 52 import org.gnunet.gnunetmessenger.model.ChatContext 53 import org.gnunet.gnunetmessenger.viewmodel.ChatMenuViewModel 54 import org.gnunet.gnunetmessenger.model.ChatMessage 55 import org.gnunet.gnunetmessenger.model.ChatMessageType 56 import org.gnunet.gnunetmessenger.model.MessageKind 57 import org.gnunet.gnunetmessenger.service.GnunetChat 58 import org.gnunet.gnunetmessenger.ui.adapters.ChatMessageAdapter 59 import org.gnunet.gnunetmessenger.viewmodel.ChatViewModel 60 61 class ChatFragment : Fragment(R.layout.fragment_chat) { 62 63 private lateinit var recyclerView: RecyclerView 64 private lateinit var adapter: ChatMessageAdapter 65 private val args: ChatFragmentArgs by navArgs() 66 private lateinit var chatViewModel: ChatViewModel 67 private lateinit var chatContext: ChatContext 68 private lateinit var chatMenuViewModel: ChatMenuViewModel 69 private lateinit var mainActivity: MainActivity 70 private lateinit var gnunetChat: GnunetChat 71 72 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 73 super.onViewCreated(view, savedInstanceState) 74 75 val menuHost = requireActivity() as MenuHost 76 77 78 menuHost.addMenuProvider(object : MenuProvider { 79 override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { 80 81 82 menuInflater.inflate(R.menu.chat_menu, menu) 83 84 val chatGroup = gnunetChat.getGroupFromContext(chatContext) 85 86 if (null != chatGroup) { 87 88 gnunetChat.iterateGroupContacts(chatGroup) { _, contact -> 89 val itemId = View.generateViewId() 90 chatMenuViewModel.contactMenuIds[itemId] = contact 91 menu.add(Menu.NONE, itemId, 0, "👤 ${contact.name}") 92 0 93 } 94 } 95 } 96 97 override fun onPrepareMenu(menu: Menu) { 98 val contact = gnunetChat.getContextContact(chatContext) 99 val isBlocked = gnunetChat.isContactBlocked(contact) 100 val isGroup = gnunetChat.isGroup(chatContext) 101 val isPlatform = gnunetChat.isPlatform(chatContext) 102 val blockItem = menu.findItem(R.id.menu_block_contact) 103 104 blockItem.isVisible = !isGroup || !isPlatform 105 blockItem.title = if (isBlocked) "Unblock Contact" else "Block Contact" 106 107 menu.findItem(R.id.menu_invite_members)?.isVisible = isGroup || isPlatform 108 } 109 110 override fun onMenuItemSelected(menuItem: MenuItem): Boolean { 111 val mainActivity = requireActivity() as MainActivity 112 val gnunetChat = mainActivity.getGnunetChatInstance() 113 val handle = mainActivity.getChatHandle() 114 115 val contact = chatMenuViewModel.contactMenuIds[menuItem.itemId] 116 if (contact != null) { 117 val action = ChatFragmentDirections.actionChatFragmentToContactFragment(contact) 118 findNavController().navigate(action) 119 return true 120 } 121 122 return when (menuItem.itemId) { 123 R.id.menu_share_identity -> { 124 val action = ChatFragmentDirections.actionChatFragmentToLobbyDisplayFragment(lobbyId = gnunetChat.getProfileKey(handle), 125 // lifetime not used here 126 lifetime = "0") 127 findNavController().navigate(action) 128 true 129 } 130 R.id.menu_block_contact -> { 131 val contact = gnunetChat.getContextContact(chatContext) 132 val isBlocked = gnunetChat.isContactBlocked(contact) 133 gnunetChat.setContactBlocked(contact, !isBlocked) 134 contact.blocked = !isBlocked 135 requireActivity().invalidateOptionsMenu() 136 true 137 } 138 R.id.menu_leave_chat -> { 139 val contact = gnunetChat.getContextContact(chatContext) 140 gnunetChat.deleteContact(contact) 141 true 142 } 143 R.id.menu_invite_members -> { 144 val chatGroup = gnunetChat.getGroupFromContext(chatContext) 145 if (null != chatGroup) { 146 val action = ChatFragmentDirections.actionChatFragmentToMemberListFragment(chatGroup) 147 findNavController().navigate(action) 148 } 149 true 150 } 151 else -> false 152 } 153 } 154 }, viewLifecycleOwner) 155 val title = try { 156 when { 157 gnunetChat.isGroup(chatContext) || gnunetChat.isPlatform(chatContext) -> { 158 gnunetChat.getGroupFromContext(chatContext)?.name 159 ?: getString(R.string.placeholder_label_chat) 160 } 161 else -> { 162 gnunetChat.getContextContact(chatContext).name 163 } 164 } 165 } catch (t: Throwable) { 166 // The server died (or the binder hasn't reattached yet). Bounce 167 // the user back to the chat list instead of letting the 168 // IllegalStateException kill the whole app on the main thread. 169 Log.w("ChatFragment", "Server unreachable while opening chat; popping back stack", t) 170 Toast.makeText( 171 requireContext(), 172 getString(R.string.toast_server_unreachable), 173 Toast.LENGTH_SHORT 174 ).show() 175 runCatching { findNavController().popBackStack() } 176 return 177 } 178 (requireActivity() as AppCompatActivity).supportActionBar?.title = title 179 } 180 181 override fun onCreateView( 182 inflater: LayoutInflater, container: ViewGroup?, 183 savedInstanceState: Bundle? 184 ): View? { 185 val view = inflater.inflate(R.layout.fragment_chat, container, false) 186 mainActivity = activity as MainActivity 187 gnunetChat = mainActivity.getGnunetChatInstance() 188 189 chatContext = args.chatContext 190 chatViewModel = mainActivity.getChatViewModel(chatContext)!! 191 chatMenuViewModel = mainActivity.getChatMenuViewModel(chatContext)!! 192 recyclerView = view.findViewById(R.id.chatRecyclerView) 193 recyclerView.layoutManager = LinearLayoutManager(requireContext()) 194 195 adapter = ChatMessageAdapter() 196 recyclerView.adapter = adapter 197 198 adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { 199 override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { 200 recyclerView.scrollToPosition(adapter.itemCount - 1) 201 } 202 }) 203 204 chatViewModel.messages.observe(viewLifecycleOwner, Observer { messages -> 205 Log.d( 206 "ChatFragment", 207 "observer: count=${messages.size} " + 208 "types=${messages.map { it.type }} " + 209 "texts=${messages.map { it.text.take(8) }}" 210 ) 211 adapter.submitList(messages) 212 recyclerView.scrollToPosition(messages.size - 1) 213 }) 214 215 // If the ViewModel has no messages (e.g., after account switch cleared 216 // state), reload history from libgnunetchat via native iteration. 217 if (chatViewModel.messages.value.isNullOrEmpty()) { 218 viewLifecycleOwner.lifecycleScope.launch { 219 try { 220 val profileKey = gnunetChat.getProfileKey(mainActivity.getChatHandle()) 221 val history = gnunetChat.iterateContextMessages(chatContext) 222 Log.d( 223 "ChatFragment", 224 "iterate: profileKey='${profileKey.take(16)}...' " + 225 "(len=${profileKey.length}) historySize=${history.size}" 226 ) 227 for (msg in history) { 228 val sKey = msg.sender?.key ?: "" 229 val isOwn = sKey.isNotBlank() && sKey == profileKey 230 Log.d( 231 "ChatFragment", 232 "iterate.msg: senderKey='${sKey.take(16)}...' " + 233 "(len=${sKey.length}) isOwn=$isOwn text='${msg.text}'" 234 ) 235 val typed = msg.copy(type = if (isOwn) ChatMessageType.OWN else ChatMessageType.OTHER) 236 chatViewModel.addMessage(typed) 237 } 238 Log.d("ChatFragment", "Loaded ${history.size} messages from native") 239 } catch (t: Throwable) { 240 Log.w("ChatFragment", "iterateContextMessages failed", t) 241 } 242 } 243 } 244 245 view.findViewById<Button>(R.id.sendButton).setOnClickListener { 246 val input = view.findViewById<EditText>(R.id.inputMessage) 247 val text = input.text.toString() 248 if (text.isNotBlank()) { 249 val newMessage = ChatMessage( 250 chatContext = chatContext, 251 text = text, 252 timestamp = System.currentTimeMillis(), 253 sender = ChatContact(chatContext, mainActivity.currentAccount?.name ?: ""), 254 kind = MessageKind.TEXT, 255 type = ChatMessageType.OWN 256 ) 257 chatViewModel.addMessage(newMessage) 258 Log.d( 259 "ChatFragment", 260 "send: nativeCtxPtr=${chatContext.nativeContextPointer} " + 261 "userPtr=${chatContext.userPointer} isGroup=${chatContext.isGroup} " + 262 "textLen=${text.length}" 263 ) 264 gnunetChat.sendText(chatContext, text) 265 input.text.clear() 266 } 267 } 268 269 return view 270 } 271 }