Close chat #169
@@ -22,9 +22,15 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
|
||||
private static final int TYPE_SENT = 1;
|
||||
private static final int TYPE_RECEIVED = 2;
|
||||
|
||||
public interface OnAttachmentClickListener {
|
||||
void onAttachmentClick(Message message);
|
||||
}
|
||||
|
||||
private final List<Message> messages;
|
||||
private Long currentUserId;
|
||||
private String token;
|
||||
private String baseUrl;
|
||||
private OnAttachmentClickListener attachmentClickListener;
|
||||
|
||||
public MessageAdapter(List<Message> messages, Long currentUserId) {
|
||||
this.messages = messages;
|
||||
@@ -40,6 +46,14 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public void setBaseUrl(String baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public void setOnAttachmentClickListener(OnAttachmentClickListener listener) {
|
||||
this.attachmentClickListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
Message m = messages.get(position);
|
||||
@@ -64,8 +78,8 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
||||
Message m = messages.get(position);
|
||||
if (holder instanceof SentHolder) ((SentHolder) holder).bind(m, token);
|
||||
if (holder instanceof ReceivedHolder) ((ReceivedHolder) holder).bind(m, token);
|
||||
if (holder instanceof SentHolder) ((SentHolder) holder).bind(m, token, baseUrl, attachmentClickListener);
|
||||
if (holder instanceof ReceivedHolder) ((ReceivedHolder) holder).bind(m, token, baseUrl, attachmentClickListener);
|
||||
}
|
||||
|
||||
@Override public int getItemCount() { return messages.size(); }
|
||||
@@ -76,9 +90,15 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
|
||||
super(binding.getRoot());
|
||||
this.binding = binding;
|
||||
}
|
||||
void bind(Message m, String token) {
|
||||
void bind(Message m, String token, String baseUrl, OnAttachmentClickListener listener) {
|
||||
binding.tvMessageContent.setText(m.getContent());
|
||||
displayAttachment(m, binding.ivAttachment, binding.tvAttachmentName, token);
|
||||
displayAttachment(m, binding.ivAttachment, binding.tvAttachmentName, token, baseUrl);
|
||||
|
||||
View.OnClickListener click = v -> {
|
||||
if (listener != null) listener.onAttachmentClick(m);
|
||||
};
|
||||
binding.ivAttachment.setOnClickListener(click);
|
||||
binding.tvAttachmentName.setOnClickListener(click);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,22 +108,44 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
|
||||
super(binding.getRoot());
|
||||
this.binding = binding;
|
||||
}
|
||||
void bind(Message m, String token) {
|
||||
void bind(Message m, String token, String baseUrl, OnAttachmentClickListener listener) {
|
||||
binding.tvMessageContent.setText(m.getContent());
|
||||
displayAttachment(m, binding.ivAttachment, binding.tvAttachmentName, token);
|
||||
displayAttachment(m, binding.ivAttachment, binding.tvAttachmentName, token, baseUrl);
|
||||
|
||||
View.OnClickListener click = v -> {
|
||||
if (listener != null) listener.onAttachmentClick(m);
|
||||
};
|
||||
binding.ivAttachment.setOnClickListener(click);
|
||||
binding.tvAttachmentName.setOnClickListener(click);
|
||||
}
|
||||
}
|
||||
|
||||
// helper function to display the attachment to the chat bubble
|
||||
private static void displayAttachment(Message m, ImageView iv, TextView tvName, String token) {
|
||||
if (m.getAttachmentUrl() != null) {
|
||||
if (m.getAttachmentType() != null && m.getAttachmentType().startsWith("image/")) {
|
||||
private static void displayAttachment(Message m, ImageView iv, TextView tvName, String token, String baseUrl) {
|
||||
// Check if there's an attachment by looking at name or mime type
|
||||
if (m.getAttachmentName() != null || m.getAttachmentMimeType() != null) {
|
||||
// Construct the download URL using the message ID
|
||||
String url;
|
||||
if (baseUrl != null) {
|
||||
String cleanBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
url = cleanBase + "/api/v1/chat/messages/" + m.getId() + "/attachment";
|
||||
} else {
|
||||
url = m.getAttachmentUrl(); // Fallback
|
||||
}
|
||||
|
||||
if (url == null) {
|
||||
iv.setVisibility(View.GONE);
|
||||
tvName.setVisibility(View.GONE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (m.getAttachmentMimeType() != null && m.getAttachmentMimeType().startsWith("image/")) {
|
||||
iv.setVisibility(View.VISIBLE);
|
||||
tvName.setVisibility(View.GONE);
|
||||
|
||||
Object loadTarget = m.getAttachmentUrl();
|
||||
if (token != null && m.getAttachmentUrl().startsWith("http")) {
|
||||
loadTarget = new GlideUrl(m.getAttachmentUrl(), new LazyHeaders.Builder()
|
||||
Object loadTarget = url;
|
||||
if (token != null) {
|
||||
loadTarget = new GlideUrl(url, new LazyHeaders.Builder()
|
||||
.addHeader("Authorization", "Bearer " + token)
|
||||
.build());
|
||||
}
|
||||
|
||||
@@ -3,11 +3,18 @@ package com.example.petstoremobile.api;
|
||||
import com.example.petstoremobile.dtos.MessageDTO;
|
||||
import com.example.petstoremobile.dtos.SendMessageRequest;
|
||||
import java.util.List;
|
||||
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.ResponseBody;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.http.Body;
|
||||
import retrofit2.http.GET;
|
||||
import retrofit2.http.Multipart;
|
||||
import retrofit2.http.POST;
|
||||
import retrofit2.http.Part;
|
||||
import retrofit2.http.Path;
|
||||
import retrofit2.http.Streaming;
|
||||
|
||||
//api calls to get and send messages
|
||||
public interface MessageApi {
|
||||
@@ -17,4 +24,16 @@ public interface MessageApi {
|
||||
|
||||
@POST("api/v1/chat/conversations/{id}/messages")
|
||||
Call<MessageDTO> sendMessage(@Path("id") Long conversationId, @Body SendMessageRequest request);
|
||||
|
||||
@Multipart
|
||||
@POST("api/v1/chat/conversations/{id}/attachments")
|
||||
Call<MessageDTO> sendMessageWithAttachment(
|
||||
@Path("id") Long conversationId,
|
||||
@Part("content") RequestBody content,
|
||||
@Part MultipartBody.Part attachment
|
||||
);
|
||||
|
||||
@GET("api/v1/chat/messages/{id}/attachment")
|
||||
@Streaming
|
||||
Call<ResponseBody> downloadAttachment(@Path("id") Long messageId);
|
||||
}
|
||||
@@ -28,8 +28,11 @@ public class MessageDTO {
|
||||
@SerializedName("attachmentName")
|
||||
private String attachmentName;
|
||||
|
||||
@SerializedName("attachmentType")
|
||||
private String attachmentType;
|
||||
@SerializedName("attachmentMimeType")
|
||||
private String attachmentMimeType;
|
||||
|
||||
@SerializedName("attachmentSizeBytes")
|
||||
private Long attachmentSizeBytes;
|
||||
|
||||
public MessageDTO() {}
|
||||
|
||||
@@ -57,6 +60,9 @@ public class MessageDTO {
|
||||
public String getAttachmentName() { return attachmentName; }
|
||||
public void setAttachmentName(String attachmentName) { this.attachmentName = attachmentName; }
|
||||
|
||||
public String getAttachmentType() { return attachmentType; }
|
||||
public void setAttachmentType(String attachmentType) { this.attachmentType = attachmentType; }
|
||||
public String getAttachmentMimeType() { return attachmentMimeType; }
|
||||
public void setAttachmentMimeType(String attachmentMimeType) { this.attachmentMimeType = attachmentMimeType; }
|
||||
|
||||
public Long getAttachmentSizeBytes() { return attachmentSizeBytes; }
|
||||
public void setAttachmentSizeBytes(Long attachmentSizeBytes) { this.attachmentSizeBytes = attachmentSizeBytes; }
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
package com.example.petstoremobile.fragments;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Dialog;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.provider.MediaStore;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Log;
|
||||
import android.view.*;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.Toast;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
@@ -18,6 +25,9 @@ import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.model.GlideUrl;
|
||||
import com.bumptech.glide.load.model.LazyHeaders;
|
||||
import com.example.petstoremobile.R;
|
||||
import com.example.petstoremobile.adapters.ChatAdapter;
|
||||
import com.example.petstoremobile.adapters.MessageAdapter;
|
||||
import com.example.petstoremobile.api.auth.TokenManager;
|
||||
@@ -27,16 +37,25 @@ import com.example.petstoremobile.dtos.MessageDTO;
|
||||
import com.example.petstoremobile.models.Chat;
|
||||
import com.example.petstoremobile.models.Message;
|
||||
import com.example.petstoremobile.services.ChatNotificationService;
|
||||
import com.example.petstoremobile.utils.FileUtils;
|
||||
import com.example.petstoremobile.utils.Resource;
|
||||
import com.example.petstoremobile.viewmodels.ChatListViewModel;
|
||||
import com.example.petstoremobile.websocket.StompChatManager;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.*;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
@AndroidEntryPoint
|
||||
public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickListener, StompChatManager.MessageListener,
|
||||
@@ -112,6 +131,16 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
binding.rvChatList.setAdapter(chatAdapter);
|
||||
|
||||
messageAdapter = new MessageAdapter(messageList, null);
|
||||
messageAdapter.setBaseUrl(baseUrl);
|
||||
|
||||
messageAdapter.setOnAttachmentClickListener(message -> {
|
||||
if (message.getAttachmentMimeType() != null && message.getAttachmentMimeType().startsWith("image/")) {
|
||||
showFullScreenImage(message);
|
||||
} else {
|
||||
downloadFile(message);
|
||||
}
|
||||
});
|
||||
|
||||
LinearLayoutManager lm = new LinearLayoutManager(getContext());
|
||||
lm.setStackFromEnd(true);
|
||||
binding.rvMessages.setLayoutManager(lm);
|
||||
@@ -119,6 +148,85 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
setConversationActive(false);
|
||||
}
|
||||
|
||||
private void showFullScreenImage(Message message) {
|
||||
if (baseUrl == null || message.getId() == null) return;
|
||||
|
||||
Dialog dialog = new Dialog(requireContext(), android.R.style.Theme_Black_NoTitleBar_Fullscreen);
|
||||
dialog.setContentView(R.layout.dialog_full_screen_image);
|
||||
|
||||
ImageView imageView = dialog.findViewById(R.id.ivFullScreen);
|
||||
ImageButton closeButton = dialog.findViewById(R.id.btnClose);
|
||||
|
||||
String cleanBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
String downloadUrl = cleanBase + "/api/v1/chat/messages/" + message.getId() + "/attachment";
|
||||
|
||||
GlideUrl glideUrl = new GlideUrl(downloadUrl, new LazyHeaders.Builder()
|
||||
.addHeader("Authorization", "Bearer " + tokenManager.getToken())
|
||||
.build());
|
||||
|
||||
Glide.with(this)
|
||||
.load(glideUrl)
|
||||
.into(imageView);
|
||||
|
||||
closeButton.setOnClickListener(v -> dialog.dismiss());
|
||||
imageView.setOnClickListener(v -> dialog.dismiss());
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
private void downloadFile(Message message) {
|
||||
if (message.getId() == null) return;
|
||||
|
||||
Toast.makeText(requireContext(), "Downloading " + message.getAttachmentName() + "...", Toast.LENGTH_SHORT).show();
|
||||
|
||||
viewModel.downloadAttachment(message.getId()).observe(getViewLifecycleOwner(), resource -> {
|
||||
if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) {
|
||||
saveFileToDownloads(resource.data, message.getAttachmentName(), message.getAttachmentMimeType());
|
||||
} else if (resource != null && resource.status == Resource.Status.ERROR) {
|
||||
Toast.makeText(requireContext(), "Download failed: " + resource.message, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void saveFileToDownloads(ResponseBody body, String fileName, String mimeType) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.Downloads.DISPLAY_NAME, fileName);
|
||||
values.put(MediaStore.Downloads.MIME_TYPE, mimeType);
|
||||
values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
|
||||
|
||||
Uri uri = requireContext().getContentResolver().insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
|
||||
if (uri != null) {
|
||||
try (OutputStream outputStream = requireContext().getContentResolver().openOutputStream(uri);
|
||||
InputStream inputStream = body.byteStream()) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
Toast.makeText(requireContext(), "File saved to Downloads", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
File downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
|
||||
File file = new File(downloadsDir, fileName);
|
||||
try (OutputStream outputStream = new FileOutputStream(file);
|
||||
InputStream inputStream = body.byteStream()) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
Toast.makeText(requireContext(), "File saved to Downloads: " + file.getAbsolutePath(), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
body.close();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error saving file", e);
|
||||
Toast.makeText(requireContext(), "Error saving file", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private void observeViewModel() {
|
||||
viewModel.getChatList().observe(getViewLifecycleOwner(), list -> {
|
||||
chatList.clear();
|
||||
@@ -214,7 +322,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
pendingAttachmentUri = uri;
|
||||
binding.layoutAttachmentPreview.setVisibility(View.VISIBLE);
|
||||
String mimeType = requireContext().getContentResolver().getType(uri);
|
||||
binding.tvPreviewName.setText(getFileName(uri));
|
||||
binding.tvPreviewName.setText(FileUtils.getFileName(requireContext(), uri));
|
||||
if (mimeType != null && mimeType.startsWith("image/")) {
|
||||
binding.ivPreview.setVisibility(View.VISIBLE);
|
||||
Glide.with(this).load(uri).into(binding.ivPreview);
|
||||
@@ -228,27 +336,35 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
binding.layoutAttachmentPreview.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private String getFileName(Uri uri) {
|
||||
String result = null;
|
||||
if (uri.getScheme().equals("content")) {
|
||||
try (Cursor cursor = requireContext().getContentResolver().query(uri, null, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (index != -1) result = cursor.getString(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result == null) {
|
||||
result = uri.getPath();
|
||||
int cut = result.lastIndexOf('/');
|
||||
if (cut != -1) result = result.substring(cut + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void sendWithAttachment(Uri uri) {
|
||||
Toast.makeText(requireContext(), "File attachments are not supported", Toast.LENGTH_SHORT).show();
|
||||
if (activeConversationId == null) return;
|
||||
|
||||
File file = FileUtils.getFileFromUri(requireContext(), uri);
|
||||
if (file == null) {
|
||||
Toast.makeText(requireContext(), "Failed to prepare file", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
String text = binding.etMessage.getText().toString().trim();
|
||||
RequestBody contentPart = RequestBody.create(MediaType.parse("text/plain"), text);
|
||||
|
||||
String mimeType = requireContext().getContentResolver().getType(uri);
|
||||
if (mimeType == null) mimeType = "application/octet-stream";
|
||||
|
||||
RequestBody filePartBody = RequestBody.create(MediaType.parse(mimeType), file);
|
||||
MultipartBody.Part filePart = MultipartBody.Part.createFormData("file", file.getName(), filePartBody);
|
||||
|
||||
binding.etMessage.setText("");
|
||||
removeAttachment();
|
||||
|
||||
viewModel.sendMessageWithAttachment(activeConversationId, contentPart, filePart).observe(getViewLifecycleOwner(), resource -> {
|
||||
if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) {
|
||||
viewModel.addMessageLocally(resource.data);
|
||||
viewModel.loadConversations();
|
||||
} else if (resource != null && resource.status == Resource.Status.ERROR) {
|
||||
Toast.makeText(requireContext(), "Failed to send attachment: " + resource.message, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -9,7 +9,8 @@ public class Message {
|
||||
private Boolean isRead;
|
||||
private String attachmentUrl;
|
||||
private String attachmentName;
|
||||
private String attachmentType;
|
||||
private String attachmentMimeType;
|
||||
private Long attachmentSizeBytes;
|
||||
|
||||
public Message() {}
|
||||
|
||||
@@ -43,6 +44,9 @@ public class Message {
|
||||
public String getAttachmentName() { return attachmentName; }
|
||||
public void setAttachmentName(String attachmentName) { this.attachmentName = attachmentName; }
|
||||
|
||||
public String getAttachmentType() { return attachmentType; }
|
||||
public void setAttachmentType(String attachmentType) { this.attachmentType = attachmentType; }
|
||||
public String getAttachmentMimeType() { return attachmentMimeType; }
|
||||
public void setAttachmentMimeType(String attachmentMimeType) { this.attachmentMimeType = attachmentMimeType; }
|
||||
|
||||
public Long getAttachmentSizeBytes() { return attachmentSizeBytes; }
|
||||
public void setAttachmentSizeBytes(Long attachmentSizeBytes) { this.attachmentSizeBytes = attachmentSizeBytes; }
|
||||
}
|
||||
@@ -17,6 +17,10 @@ import java.util.List;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
/**
|
||||
* Repository for handling chat-related data operations.
|
||||
*/
|
||||
@@ -55,6 +59,20 @@ public class ChatRepository extends BaseRepository {
|
||||
return executeCall(messageApi.sendMessage(conversationId, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message with an attachment.
|
||||
*/
|
||||
public LiveData<Resource<MessageDTO>> sendMessageWithAttachment(Long conversationId, RequestBody content, MultipartBody.Part attachment) {
|
||||
return executeCall(messageApi.sendMessageWithAttachment(conversationId, content, attachment));
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an attachment for a specific message.
|
||||
*/
|
||||
public LiveData<Resource<ResponseBody>> downloadAttachment(Long messageId) {
|
||||
return executeCall(messageApi.downloadAttachment(messageId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a paginated list of customers.
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.example.petstoremobile.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.OpenableColumns;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
@@ -9,8 +11,11 @@ import java.io.InputStream;
|
||||
public class FileUtils {
|
||||
public static File getFileFromUri(Context context, Uri uri) {
|
||||
try {
|
||||
String fileName = getFileName(context, uri);
|
||||
if (fileName == null) fileName = "upload_" + System.currentTimeMillis();
|
||||
|
||||
InputStream inputStream = context.getContentResolver().openInputStream(uri);
|
||||
File tempFile = new File(context.getCacheDir(), "upload_image_" + System.currentTimeMillis() + ".jpg");
|
||||
File tempFile = new File(context.getCacheDir(), fileName);
|
||||
FileOutputStream outputStream = new FileOutputStream(tempFile);
|
||||
byte[] buffer = new byte[1024];
|
||||
int length;
|
||||
@@ -24,4 +29,22 @@ public class FileUtils {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static String getFileName(Context context, Uri uri) {
|
||||
String result = null;
|
||||
if (uri.getScheme().equals("content")) {
|
||||
try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (index != -1) result = cursor.getString(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result == null) {
|
||||
result = uri.getPath();
|
||||
int cut = result.lastIndexOf('/');
|
||||
if (cut != -1) result = result.substring(cut + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ import java.util.Map;
|
||||
import javax.inject.Inject;
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel;
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
@HiltViewModel
|
||||
public class ChatListViewModel extends ViewModel {
|
||||
@@ -88,6 +91,14 @@ public class ChatListViewModel extends ViewModel {
|
||||
return chatRepository.sendMessage(conversationId, new SendMessageRequest(text));
|
||||
}
|
||||
|
||||
public LiveData<Resource<MessageDTO>> sendMessageWithAttachment(Long conversationId, RequestBody content, MultipartBody.Part attachment) {
|
||||
return chatRepository.sendMessageWithAttachment(conversationId, content, attachment);
|
||||
}
|
||||
|
||||
public LiveData<Resource<ResponseBody>> downloadAttachment(Long messageId) {
|
||||
return chatRepository.downloadAttachment(messageId);
|
||||
}
|
||||
|
||||
public void addMessageLocally(MessageDTO dto) {
|
||||
List<Message> current = new ArrayList<>(messageList.getValue());
|
||||
current.add(dtoToModel(dto));
|
||||
@@ -122,7 +133,8 @@ public class ChatListViewModel extends ViewModel {
|
||||
m.setIsRead(dto.getIsRead());
|
||||
m.setAttachmentUrl(dto.getAttachmentUrl());
|
||||
m.setAttachmentName(dto.getAttachmentName());
|
||||
m.setAttachmentType(dto.getAttachmentType());
|
||||
m.setAttachmentMimeType(dto.getAttachmentMimeType());
|
||||
m.setAttachmentSizeBytes(dto.getAttachmentSizeBytes());
|
||||
return m;
|
||||
}
|
||||
|
||||
|
||||
24
android/app/src/main/res/layout/dialog_full_screen_image.xml
Normal file
24
android/app/src/main/res/layout/dialog_full_screen_image.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/black">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ivFullScreen"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="fitCenter" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnClose"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_margin="16dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@android:drawable/ic_menu_close_clear_cancel"
|
||||
android:contentDescription="Close"
|
||||
android:tint="@android:color/white" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -5,17 +5,24 @@ import com.petshop.backend.dto.chat.ConversationResponse;
|
||||
import com.petshop.backend.dto.chat.MessageRequest;
|
||||
import com.petshop.backend.dto.chat.MessageResponse;
|
||||
import com.petshop.backend.dto.chat.UpdateConversationRequest;
|
||||
import com.petshop.backend.entity.Message;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.repository.MessageRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.service.ChatAttachmentStorageService;
|
||||
import com.petshop.backend.service.ChatRealtimeService;
|
||||
import com.petshop.backend.service.ChatService;
|
||||
import com.petshop.backend.util.AuthenticationHelper;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -26,11 +33,17 @@ public class ChatController {
|
||||
private final ChatService chatService;
|
||||
private final ChatRealtimeService chatRealtimeService;
|
||||
private final UserRepository userRepository;
|
||||
private final ChatAttachmentStorageService attachmentStorageService;
|
||||
private final MessageRepository messageRepository;
|
||||
|
||||
public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService, UserRepository userRepository) {
|
||||
public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService,
|
||||
UserRepository userRepository, ChatAttachmentStorageService attachmentStorageService,
|
||||
MessageRepository messageRepository) {
|
||||
this.chatService = chatService;
|
||||
this.chatRealtimeService = chatRealtimeService;
|
||||
this.userRepository = userRepository;
|
||||
this.attachmentStorageService = attachmentStorageService;
|
||||
this.messageRepository = messageRepository;
|
||||
}
|
||||
|
||||
private User getCurrentUser() {
|
||||
@@ -78,6 +91,44 @@ public class ChatController {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(message);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/conversations/{id}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
|
||||
public ResponseEntity<MessageResponse> sendMessageWithAttachment(
|
||||
@PathVariable Long id,
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
User user = getCurrentUser();
|
||||
MessageResponse message = chatService.sendMessageWithAttachment(id, user.getId(), user.getRole(), file);
|
||||
chatRealtimeService.publishMessage(id, message);
|
||||
chatRealtimeService.publishConversationUpdate(id);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(message);
|
||||
}
|
||||
|
||||
@GetMapping("/messages/{messageId}/attachment")
|
||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
|
||||
public ResponseEntity<Resource> getMessageAttachment(@PathVariable Long messageId) {
|
||||
User user = getCurrentUser();
|
||||
Message message = messageRepository.findById(messageId)
|
||||
.orElseThrow(() -> new RuntimeException("Message not found"));
|
||||
|
||||
if (!chatService.hasConversationAccess(message.getConversationId(), user.getId(), user.getRole())) {
|
||||
throw new AccessDeniedException("Access denied to this message attachment");
|
||||
}
|
||||
|
||||
if (message.getAttachmentUrl() == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
try {
|
||||
Resource resource = attachmentStorageService.loadAttachmentResource(message.getAttachmentUrl());
|
||||
return ResponseEntity.ok()
|
||||
.contentType(attachmentStorageService.resolveMediaType(message.getAttachmentUrl()))
|
||||
.header("Content-Disposition", "attachment; filename=\"" + message.getAttachmentName() + "\"")
|
||||
.body(resource);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/conversations/{id}/messages")
|
||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
|
||||
public ResponseEntity<List<MessageResponse>> getMessages(@PathVariable Long id) {
|
||||
|
||||
@@ -36,7 +36,12 @@ public class MessageResponse {
|
||||
response.setContent(message.getContent());
|
||||
response.setTimestamp(message.getTimestamp());
|
||||
response.setIsRead(message.getIsRead());
|
||||
response.setAttachmentUrl(message.getAttachmentUrl());
|
||||
|
||||
|
||||
if (message.getAttachmentUrl() != null) {
|
||||
response.setAttachmentUrl("/api/v1/chat/messages/" + message.getId() + "/attachment");
|
||||
}
|
||||
|
||||
response.setAttachmentName(message.getAttachmentName());
|
||||
response.setAttachmentMimeType(message.getAttachmentMimeType());
|
||||
response.setAttachmentSizeBytes(message.getAttachmentSizeBytes());
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import org.springframework.core.io.PathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class ChatAttachmentStorageService {
|
||||
|
||||
private static final String STORED_PREFIX = "/uploads/chat/";
|
||||
private final Path chatDirectory = Paths.get("uploads", "chat").toAbsolutePath().normalize();
|
||||
|
||||
public String storeAttachment(MultipartFile file) throws IOException {
|
||||
Files.createDirectories(chatDirectory);
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String extension = "";
|
||||
if (originalFilename != null && originalFilename.contains(".")) {
|
||||
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
|
||||
}
|
||||
|
||||
String filename = UUID.randomUUID() + extension;
|
||||
Path filePath = chatDirectory.resolve(filename).normalize();
|
||||
|
||||
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
return STORED_PREFIX + filename;
|
||||
}
|
||||
|
||||
public Resource loadAttachmentResource(String attachmentUrl) {
|
||||
Path filePath = resolveStoredPath(attachmentUrl);
|
||||
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
|
||||
throw new IllegalArgumentException("Attachment file was not found");
|
||||
}
|
||||
return new PathResource(filePath);
|
||||
}
|
||||
|
||||
public MediaType resolveMediaType(String attachmentUrl) {
|
||||
try {
|
||||
return MediaTypeFactory.getMediaType(loadAttachmentResource(attachmentUrl)).orElse(MediaType.APPLICATION_OCTET_STREAM);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveStoredPath(String attachmentUrl) {
|
||||
if (attachmentUrl == null || attachmentUrl.isBlank() || !attachmentUrl.startsWith(STORED_PREFIX)) {
|
||||
throw new IllegalArgumentException("Invalid attachment URL");
|
||||
}
|
||||
|
||||
String filename = attachmentUrl.substring(STORED_PREFIX.length());
|
||||
if (filename.isBlank() || filename.contains("/") || filename.contains("\\") || filename.contains("..")) {
|
||||
throw new IllegalArgumentException("Invalid attachment filename");
|
||||
}
|
||||
|
||||
Path resolved = chatDirectory.resolve(filename).normalize();
|
||||
if (!resolved.startsWith(chatDirectory)) {
|
||||
throw new IllegalArgumentException("Invalid attachment path");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,9 @@ import com.petshop.backend.repository.UserRepository;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -26,13 +28,16 @@ public class ChatService {
|
||||
private final ConversationRepository conversationRepository;
|
||||
private final MessageRepository messageRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ChatAttachmentStorageService attachmentStorageService;
|
||||
|
||||
public ChatService(ConversationRepository conversationRepository,
|
||||
MessageRepository messageRepository,
|
||||
UserRepository userRepository) {
|
||||
UserRepository userRepository,
|
||||
ChatAttachmentStorageService attachmentStorageService) {
|
||||
this.conversationRepository = conversationRepository;
|
||||
this.messageRepository = messageRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.attachmentStorageService = attachmentStorageService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -145,6 +150,51 @@ public class ChatService {
|
||||
return MessageResponse.fromEntity(message);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MessageResponse sendMessageWithAttachment(Long conversationId, Long userId, User.Role role, MultipartFile file) {
|
||||
Conversation conversation = conversationRepository.findById(conversationId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
|
||||
|
||||
if (conversation.getStatus() == Conversation.ConversationStatus.CLOSED) {
|
||||
throw new AccessDeniedException("Conversation is closed");
|
||||
}
|
||||
|
||||
if (!hasConversationAccess(conversation, userId, role)) {
|
||||
if (role == User.Role.CUSTOMER) {
|
||||
throw new AccessDeniedException("You can only send messages to your own conversations");
|
||||
}
|
||||
if (role == User.Role.STAFF) {
|
||||
throw new AccessDeniedException("You can only reply to conversations assigned to you or unassigned conversations");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
String attachmentUrl = attachmentStorageService.storeAttachment(file);
|
||||
Message message = new Message();
|
||||
message.setConversationId(conversationId);
|
||||
message.setSenderId(userId);
|
||||
message.setAttachmentUrl(attachmentUrl);
|
||||
message.setAttachmentName(file.getOriginalFilename());
|
||||
message.setAttachmentMimeType(file.getContentType());
|
||||
message.setAttachmentSizeBytes(file.getSize());
|
||||
message.setIsRead(false);
|
||||
message = messageRepository.save(message);
|
||||
|
||||
if (role == User.Role.STAFF && conversation.getStaffId() == null) {
|
||||
conversation.setStaffId(userId);
|
||||
}
|
||||
|
||||
if (role == User.Role.STAFF) {
|
||||
conversation.setMode(Conversation.ConversationMode.HUMAN);
|
||||
conversationRepository.save(conversation);
|
||||
}
|
||||
|
||||
return MessageResponse.fromEntity(message);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to store attachment", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ConversationResponse requestHumanTakeover(Long conversationId, Long userId, User.Role role) {
|
||||
Conversation conversation = conversationRepository.findById(conversationId)
|
||||
|
||||
Reference in New Issue
Block a user