diff --git a/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java b/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java index 6f06582..7d65dc0 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java +++ b/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java @@ -7,6 +7,9 @@ import org.to.telegramfinalproject.Models.ContactEntry; import org.to.telegramfinalproject.Models.SearchRequestModel; import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.BlockingQueue; @@ -23,19 +26,11 @@ public class ActionHandler { - - private void handleRealTime(JSONObject json) throws IOException { IncomingMessageListener listener = new IncomingMessageListener(this.in); listener.handleRealTimeEvent (json); } -// public ActionHandler(PrintWriter out, BufferedReader in, Scanner scanner) { -// this.out = out; -// this.in = in; -// this.scanner = scanner; -// ActionHandler.instance = this; -// -// } + public ActionHandler(PrintWriter out, BufferedReader in, DataOutputStream outBin, Scanner scanner) { this.out = out; this.in = in; @@ -477,6 +472,12 @@ public class ActionHandler { case "register": Session.currentUser = response.getJSONObject("data"); + //for downloaded medias + UUID accountId = UUID.fromString(Session.currentUser.getString("internal_uuid")); + Session.downloadsIndex = new DownloadsIndex(accountId); + + + JSONArray chatListJson = Session.currentUser.getJSONArray("chat_list"); JSONArray Archived = Session.currentUser.getJSONArray("archived_chat_list"); JSONArray Active = Session.currentUser.getJSONArray("active_chat_list"); @@ -3648,6 +3649,18 @@ public class ActionHandler { replyLabel = "↪️ Reply to " + repliedSender + ": \"" + repliedContent + "\""; } + JSONArray atts = msg.optJSONArray("attachments"); + if (atts != null && atts.length() > 0) { + System.out.println(" 📎 " + atts.length() + " attachment(s)"); + for (int a = 0; a < atts.length(); a++) { + JSONObject att = atts.getJSONObject(a); + String fn = att.optString("file_name", "(unnamed)"); + long sz = att.optLong("file_size", 0); + System.out.printf(" - #%d %s (%s)\n", a + 1, fn, humanSize(sz)); + } + } + + JSONArray reactions = msg.optJSONArray("reactions"); if (reactions != null && !reactions.isEmpty()) { System.out.print(" 💬 Reactions: "); @@ -3697,6 +3710,10 @@ public class ActionHandler { boolean isSender = senderId.toString().equals(Session.currentUser.getString("internal_uuid")); boolean isChannel = chat.getType().equals("channel"); boolean isOwnerOrAdmin = chat.isOwner() || chat.isAdmin(); + //for media + JSONArray atts = selected.optJSONArray("attachments"); + boolean hasAttachments = (atts != null && atts.length() > 0); + System.out.println("\n🎯 Selected message by " + selected.getString("sender_name")); @@ -3722,6 +3739,9 @@ public class ActionHandler { System.out.println("5. Delete"); } } + if (hasAttachments) { + System.out.println("D. Download attachment"); + } System.out.println("0. Back to message list"); System.out.print("➤ Select an action: "); @@ -3754,6 +3774,15 @@ public class ActionHandler { System.out.println("❌ You are not allowed to delete this message."); } case "0" -> {} + + case "D", "d" -> { + if (hasAttachments) { + downloadAttachmentFlow(chat, selected); + } else { + System.out.println("🚫 No attachments to download."); + } + } + default -> System.out.println("❌ Invalid option."); } @@ -3763,6 +3792,126 @@ public class ActionHandler { } } + private void downloadAttachmentFlow(ChatEntry chat, JSONObject msg) { + JSONArray atts = msg.optJSONArray("attachments"); + if (atts == null || atts.length() == 0) { + System.out.println("🚫 No attachments."); + return; + } + + int idx = 0; + if (atts.length() > 1) { + System.out.print("Which attachment [1.." + atts.length() + "]? "); + try { + String ans = scanner.nextLine().trim(); + if (!ans.isEmpty()) { + int n = Integer.parseInt(ans); + if (n >= 1 && n <= atts.length()) idx = n - 1; + } + } catch (Exception ignored) { idx = 0; } + } + + JSONObject att = atts.getJSONObject(idx); + String mediaKeyStr = att.optString("media_key", ""); + if (mediaKeyStr.isBlank()) { + System.out.println("❌ Attachment missing media_key."); + return; + } + + UUID mediaKey = UUID.fromString(mediaKeyStr); + String rawName = att.optString("file_name", mediaKey.toString()); + String fileName = sanitizeFileName(rawName); + long declaredSize = att.optLong("file_size", 0L); + + // ~/Downloads/TeleSock// + String folderName = (chat.getDisplayId() != null && !chat.getDisplayId().isBlank()) + ? chat.getDisplayId() : chat.getId().toString(); + Path saveDir = Paths.get(System.getProperty("user.home"), "Downloads", "TeleSock", folderName); + + try { Files.createDirectories(saveDir); } catch (IOException e) { + System.out.println("❌ Cannot create folder: " + saveDir + " -> " + e.getMessage()); + return; + } + + // اگر قبلاً دانلود شده (و فایل واقعاً وجود دارد) + try { + Path existing = Session.downloadsIndex.find(mediaKey); + if (existing != null) { + System.out.println("✅ Already downloaded: " + existing); + return; + } + } catch (IllegalStateException notInit) { + System.out.println("⚠️ DownloadsIndex not initialized. Call DownloadsIndex.init() after login."); + // ادامه می‌دهیم؛ فقط کش نمی‌شود. + } + + // جلوگیری از overwrite با انتخاب نام یکتا + Path target = uniquePath(saveDir, fileName); + + + // دانلود روی همان سوکت: Listener را موقتاً متوقف کن + TelegramClient.mediaBusy.set(true); + try { + Path saved = TelegramClient.getDownloader().download(mediaKey, saveDir, target.getFileName().toString()); + + long sizeToRecord = declaredSize > 0 ? declaredSize : Files.size(saved); + try { + Session.downloadsIndex.put(mediaKey, saved, sizeToRecord); + } catch (IllegalStateException notInit) { + // اگر init نشده بود، تنها کش نمی‌کنیم + } + + System.out.println("✅ Saved to: " + saved + " (" + humanSize(sizeToRecord) + ")"); + } catch (Exception ex) { + System.out.println("❌ Download failed: " + ex.getMessage()); + } finally { + TelegramClient.mediaBusy.set(false); + } + } + + // نام یکتا اگر فایل موجود است: name.png -> name (1).png + private static Path uniquePath(Path dir, String fileName) { + Path p = dir.resolve(fileName); + if (!Files.exists(p)) return p; + + String name = fileName; + String ext = ""; + int dot = fileName.lastIndexOf('.'); + if (dot > 0 && dot < fileName.length()-1) { + name = fileName.substring(0, dot); + ext = fileName.substring(dot); // includes dot + } + int i = 1; + while (true) { + Path cand = dir.resolve(String.format("%s (%d)%s", name, i, ext)); + if (!Files.exists(cand)) return cand; + i++; + } + } + + private static String sanitizeFileName(String s) { + // حذف مسیر و کاراکترهای غیرمجاز (برای ویندوز/یونیکس) + s = s.replace("\\", "/"); + if (s.contains("/")) s = s.substring(s.lastIndexOf('/') + 1); + // کاراکترهای نامعتبر ویندوز: \ / : * ? " < > | + s = s.replaceAll("[\\\\/:*?\"<>|]", "_"); + // جلوگیری از parent traversal + if (s.equals(".") || s.equals("..") || s.isBlank()) s = "file"; + return s; + } + + private static String humanSize(long b) { + if (b <= 0) return "0 B"; + String[] u = {"B","KB","MB","GB","TB"}; + int i = (int) Math.floor(Math.log(b) / Math.log(1024)); + if (i < 0) i = 0; + if (i >= u.length) i = u.length - 1; + double v = b / Math.pow(1024, i); + return String.format("%.1f %s", v, u[i]); + } + + + private void editMessage(UUID messageId) { System.out.print("📝 Enter new content: "); String newContent = scanner.nextLine().trim(); @@ -4130,6 +4279,39 @@ public class ActionHandler { } + + private static String safeName(String s) { + if (s == null) return "unknown"; + s = s.replace("\\", "/"); + if (s.contains("/")) s = s.substring(s.lastIndexOf('/') + 1); + s = s.replaceAll("[\\\\/:*?\"<>|]", "_").trim(); + if (s.isEmpty() || s.equals(".") || s.equals("..")) s = "unknown"; + return s; + } + + private static String accountFolderName() { + // اولویت: username → user_id → profile_name → internal_uuid + JSONObject me = Session.currentUser; + String acc = me.optString("username", + me.optString("user_id", + me.optString("profile_name", + me.optString("internal_uuid", "me")))); + return safeName(acc); + } + + private static String chatFolderName(ChatEntry chat) { + // پرایوت: اسم طرف مقابل؛ گروه/کانال: اسم چت + String name = chat.getName(); + if (name == null || name.isBlank()) { + // fallback به displayId یا id + name = chat.getDisplayId() != null && !chat.getDisplayId().isBlank() + ? chat.getDisplayId() + : String.valueOf(chat.getId()); + } + return safeName(name); + } + + } diff --git a/src/main/java/org/to/telegramfinalproject/Client/DownloadIndexRegistry.java b/src/main/java/org/to/telegramfinalproject/Client/DownloadIndexRegistry.java new file mode 100644 index 0000000..2c29b72 --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Client/DownloadIndexRegistry.java @@ -0,0 +1,30 @@ +// DownloadIndexRegistry.java +package org.to.telegramfinalproject.Client; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class DownloadIndexRegistry { + private static final ConcurrentHashMap INSTANCES = new ConcurrentHashMap<>(); + private static volatile boolean HOOK_REGISTERED = false; + + private DownloadIndexRegistry() {} + + public static DownloadsIndex forAccount(UUID accountId) { + registerHookOnce(); + return INSTANCES.computeIfAbsent(accountId, DownloadsIndex::new); + } + + public static void closeAccount(UUID accountId) { + DownloadsIndex idx = INSTANCES.remove(accountId); + if (idx != null) idx.saveQuietly(); + } + + private static synchronized void registerHookOnce() { + if (HOOK_REGISTERED) return; + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + for (DownloadsIndex idx : INSTANCES.values()) idx.saveQuietly(); + })); + HOOK_REGISTERED = true; + } +} diff --git a/src/main/java/org/to/telegramfinalproject/Client/DownloadsIndex.java b/src/main/java/org/to/telegramfinalproject/Client/DownloadsIndex.java new file mode 100644 index 0000000..12e1944 --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Client/DownloadsIndex.java @@ -0,0 +1,122 @@ +package org.to.telegramfinalproject.Client; + +import org.json.JSONObject; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class DownloadsIndex { + private final UUID accountId; + private final Path indexFile; + private final Map map = new ConcurrentHashMap<>(); + + public DownloadsIndex(UUID accountId) { + this.accountId = accountId; + this.indexFile = resolveIndexPath(accountId.toString()); + load(); + } + + + /** مسیر فایل ایندکس مخصوص هر اکانت */ + private static Path resolveIndexPath(String accountId) { + String os = System.getProperty("os.name", "").toLowerCase(); + String home = System.getProperty("user.home"); + + Path dir; + if (os.contains("win")) { + String appData = System.getenv("APPDATA"); + dir = (appData != null) + ? Paths.get(appData, "TeleSock") + : Paths.get(home, "AppData", "Roaming", "TeleSock"); + } else { + dir = Paths.get(home, ".telesock"); + } + try { Files.createDirectories(dir); } catch (IOException ignored) {} + return dir.resolve("downloads-index-" + accountId + ".json"); + } + + /** لود از دیسک (اگر فایل وجود داشته باشد) */ + private synchronized void load() { + map.clear(); + try { + if (!Files.exists(indexFile)) return; + String json = Files.readString(indexFile, StandardCharsets.UTF_8); + if (json == null || json.isBlank()) return; + + JSONObject root = new JSONObject(json); + JSONObject items = root.optJSONObject("items"); + if (items == null) return; + + for (String key : items.keySet()) { + JSONObject e = items.getJSONObject(key); + map.put(UUID.fromString(key), new Entry( + e.getString("path"), + e.optLong("size", 0L), + e.optLong("ts", System.currentTimeMillis()) + )); + } + } catch (Exception e) { + System.err.println("⚠️ DownloadsIndex load failed: " + e.getMessage()); + } + } + + /** ذخیره اتمیک روی دیسک */ + private synchronized void save() throws IOException { + JSONObject items = new JSONObject(); + for (Map.Entry it : map.entrySet()) { + JSONObject e = new JSONObject(); + e.put("path", it.getValue().path); + e.put("size", it.getValue().size); + e.put("ts", it.getValue().ts); + items.put(it.getKey().toString(), e); + } + byte[] data = new JSONObject().put("items", items).toString(2).getBytes(StandardCharsets.UTF_8); + + Path tmp = indexFile.resolveSibling(indexFile.getFileName() + ".tmp"); + Files.write(tmp, data); + try { + Files.move(tmp, indexFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(tmp, indexFile, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** ذخیره‌ی بی‌سر‌وصدا */ + public void saveQuietly() { + try { save(); } catch (Exception ignored) {} + } + + /** اگر قبلاً دانلود شده و فایلش هست، مسیر را برمی‌گرداند؛ وگرنه رکورد کهنه پاک می‌شود. */ + public Path find(UUID mediaKey) { + Entry e = map.get(mediaKey); + if (e == null) return null; + Path p = Paths.get(e.path); + if (Files.exists(p)) return p; + map.remove(mediaKey); + saveQuietly(); + return null; + } + + /** بعد از دانلود موفق */ + public void put(UUID mediaKey, Path path, long size) { + map.put(mediaKey, new Entry(path.toString(), size, System.currentTimeMillis())); + saveQuietly(); + } + + /** اختیاری */ + public void remove(UUID mediaKey) { + map.remove(mediaKey); + saveQuietly(); + } + + private static final class Entry { + final String path; final long size; final long ts; + Entry(String path, long size, long ts) { + this.path = path; this.size = size; this.ts = ts; + } + } +} diff --git a/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java b/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java index 2ddbb4a..841c227 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java +++ b/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java @@ -11,6 +11,7 @@ import java.util.concurrent.BlockingQueue; public class IncomingMessageListener implements Runnable { private final BufferedReader in; + private volatile boolean running = true; public IncomingMessageListener(BufferedReader in) { this.in = in; @@ -21,15 +22,41 @@ public class IncomingMessageListener implements Runnable { try { System.out.println("👂 Real-Time Listener started."); - String line; - while ((line = in.readLine()) != null) { + while (running) { + // ✅ اگر دانلود/آپلود مدیا در حال انجامه، اصلاً نخون + if (TelegramClient.mediaBusy.get()) { + try { Thread.sleep(15); } catch (InterruptedException ignored) {} + continue; + } + // ✅ فقط وقتی دادهٔ متنی آماده است بخوان (بدون بلاک شدن روی readLine) + if (!in.ready()) { + try { Thread.sleep(10); } catch (InterruptedException ignored) {} + continue; + } + + String line = in.readLine(); + if (line == null) { + // socket بسته شده + break; + } + + // خط‌های خالی/سفید رو رد کن + if (line.isBlank()) continue; + + // تلاش برای پارس JSON + final JSONObject response; + try { + response = new JSONObject(line); + } catch (Exception badJson) { + // اگر به هر دلیلی خط JSON نبود (مثلاً نویز)، امن رد کن + System.out.println("⚠️ [Listener] Non-JSON line ignored: " + line); + continue; + } - JSONObject response = new JSONObject(line); System.out.println("📥 Received raw line: " + line); - - //for media + // --- Media ACK routing by message_id --- String mid = response.optString("message_id", ""); if (!mid.isEmpty()) { BlockingQueue q = TelegramClient.pendingResponses.get(mid); @@ -39,9 +66,9 @@ public class IncomingMessageListener implements Runnable { } } - //if it has reqID answer + // --- General request_id response routing --- if (response.has("request_id")) { - String requestId = response.getString("request_id"); + String requestId = response.optString("request_id", ""); System.out.println("📬 Response with request_id: " + requestId); System.out.println("📬 Full response: " + response.toString(2)); @@ -52,15 +79,12 @@ public class IncomingMessageListener implements Runnable { System.out.println("⚠️ No pending queue for request_id = " + requestId + ". Putting in responseQueue..."); TelegramClient.responseQueue.put(response); } - continue; } - - - //if it has action check it + // --- Real-time actions --- if (response.has("action")) { - String action = response.getString("action"); + String action = response.optString("action", ""); System.out.println("🎯 [Listener] Action received: " + response.toString(2)); System.out.println("🎯 Received action: " + action); @@ -69,11 +93,12 @@ public class IncomingMessageListener implements Runnable { } else { TelegramClient.responseQueue.put(response); } - } else if (response.has("status") && response.has("message")) { - TelegramClient.responseQueue.put(response); // general answer + // General success/error + TelegramClient.responseQueue.put(response); } else { - TelegramClient.responseQueue.put(response); // fallback + // Fallback + TelegramClient.responseQueue.put(response); } } @@ -90,26 +115,30 @@ public class IncomingMessageListener implements Runnable { "update_group_or_channel", "chat_deleted", "blocked_by_user", "unblocked_by_user", "message_seen", "removed_from_group", "removed_from_channel", - "became_admin", "removed_admin", "ownership_transferred","admin_permissions_updated","created_private_chat" , "message_reacted" , "message_unreacted" -> true; + "became_admin", "removed_admin", "ownership_transferred", + "admin_permissions_updated", "created_private_chat", + "message_reacted", "message_unreacted" -> true; default -> false; }; } void handleRealTimeEvent(JSONObject response) throws IOException { - String action = response.getString("action"); - JSONObject msg = response.has("data") ? response.getJSONObject("data") : new JSONObject(); - + String action = response.optString("action", ""); + JSONObject msg = response.has("data") ? response.optJSONObject("data") : new JSONObject(); switch (action) { case "added_to_group", "added_to_channel", - "removed_from_group", "removed_from_channel", "chat_deleted","created_private_chat" -> { + "removed_from_group", "removed_from_channel", + "chat_deleted", "created_private_chat" -> { System.out.println("🔄 Chat list changed. Updating..."); Session.forceRefreshChatList = true; System.out.println("🧪 Calling requestChatList() after being added"); - String chatId = msg.getString("chat_id"); - String chatType = msg.getString("chat_type"); - ActionHandler.requestChatInfo(chatId, chatType); + String chatId = msg.optString("chat_id", ""); + String chatType = msg.optString("chat_type", ""); + if (!chatId.isBlank() && !chatType.isBlank()) { + ActionHandler.requestChatInfo(chatId, chatType); + } if (action.equals("removed_from_group") || action.equals("removed_from_channel") || action.equals("chat_deleted")) { System.out.println("🚫 You were removed from the chat or chat was deleted. Exiting..."); @@ -119,33 +148,22 @@ public class IncomingMessageListener implements Runnable { case "chat_updated" -> { System.out.println("\n🔄 Chat info updated."); - if (msg.has("last_message_time")) { - updateLastMessageTime(msg); + updateLastMessageTime(msg); } else { new Thread(() -> { - try { - handleAdminRoleChanged(msg); - } catch (IOException e) { - e.printStackTrace(); - } + try { handleAdminRoleChanged(msg); } catch (IOException e) { e.printStackTrace(); } }).start(); } } - - case "became_admin", "removed_admin", "ownership_transferred","admin_permissions_updated" -> { + case "became_admin", "removed_admin", "ownership_transferred", "admin_permissions_updated" -> { System.out.println("🧩 Detected admin/owner role change. Calling handler..."); new Thread(() -> { - try { - handleAdminRoleChanged(msg); //new thread - } catch (IOException e) { - e.printStackTrace(); - } + try { handleAdminRoleChanged(msg); } catch (IOException e) { e.printStackTrace(); } }).start(); } - default -> displayRealTimeMessage(action, msg); } @@ -157,25 +175,19 @@ public class IncomingMessageListener implements Runnable { UUID chatUUID = UUID.fromString(msg.getString("chat_id")); String newTime = msg.optString("last_message_time", null); - Session.chatList.stream() - .filter(chat -> chat.getId().equals(chatUUID)) - .findFirst() + Session.chatList.stream().filter(chat -> chat.getId().equals(chatUUID)).findFirst() .ifPresent(chat -> { chat.setLastMessageTime(newTime); System.out.println("✅ Updated last message time for chat: " + chat.getDisplayId()); }); - Session.activeChats.stream() - .filter(chat -> chat.getId().equals(chatUUID)) - .findFirst() + Session.activeChats.stream().filter(chat -> chat.getId().equals(chatUUID)).findFirst() .ifPresent(chat -> { chat.setLastMessageTime(newTime); System.out.println("✅ Updated last message time for chat: " + chat.getDisplayId()); }); - Session.archivedChats.stream() - .filter(chat -> chat.getId().equals(chatUUID)) - .findFirst() + Session.archivedChats.stream().filter(chat -> chat.getId().equals(chatUUID)).findFirst() .ifPresent(chat -> { chat.setLastMessageTime(newTime); System.out.println("✅ Updated last message time for chat: " + chat.getDisplayId()); @@ -185,24 +197,21 @@ public class IncomingMessageListener implements Runnable { if (c1.getLastMessageTime() == null && c2.getLastMessageTime() == null) return 0; if (c1.getLastMessageTime() == null) return 1; if (c2.getLastMessageTime() == null) return -1; - return c2.getLastMessageTime().compareTo(c1.getLastMessageTime()); // descending + return c2.getLastMessageTime().compareTo(c1.getLastMessageTime()); }); Session.activeChats.sort((c1, c2) -> { if (c1.getLastMessageTime() == null && c2.getLastMessageTime() == null) return 0; if (c1.getLastMessageTime() == null) return 1; if (c2.getLastMessageTime() == null) return -1; - return c2.getLastMessageTime().compareTo(c1.getLastMessageTime()); // descending + return c2.getLastMessageTime().compareTo(c1.getLastMessageTime()); }); Session.archivedChats.sort((c1, c2) -> { if (c1.getLastMessageTime() == null && c2.getLastMessageTime() == null) return 0; if (c1.getLastMessageTime() == null) return 1; if (c2.getLastMessageTime() == null) return -1; - return c2.getLastMessageTime().compareTo(c1.getLastMessageTime()); // descending + return c2.getLastMessageTime().compareTo(c1.getLastMessageTime()); }); - - - if (Session.inChatListMenu) { ActionHandler.displayChatList(); System.out.print("Select a chat by number: "); @@ -213,12 +222,12 @@ public class IncomingMessageListener implements Runnable { } } - private void handleAdminRoleChanged(JSONObject data) throws IOException { - String chatType = data.getString("chat_type"); - String chatId = data.optString("group_id", data.optString("channel_id", data.optString("chat_id", null))); + String chatType = data.optString("chat_type", ""); + String chatId = data.optString("group_id", + data.optString("channel_id", data.optString("chat_id", ""))); - if (chatId == null) { + if (chatId.isBlank()) { System.out.println("⚠️ No valid ID found in real-time data: " + data.toString(2)); return; } @@ -226,11 +235,10 @@ public class IncomingMessageListener implements Runnable { System.out.println("\n🔄 Your admin status changed. Updating chat info..."); try { - // 1. get chat info - JSONObject chatInfoReq = new JSONObject(); - chatInfoReq.put("action", "get_chat_info"); - chatInfoReq.put("receiver_id", chatId); - chatInfoReq.put("receiver_type", chatType); + JSONObject chatInfoReq = new JSONObject() + .put("action", "get_chat_info") + .put("receiver_id", chatId) + .put("receiver_type", chatType); System.out.println("📤 Sending get_chat_info: " + chatInfoReq); JSONObject chatInfoResp = ActionHandler.sendWithResponse(chatInfoReq); JSONObject chatData = chatInfoResp.getJSONObject("data"); @@ -256,21 +264,17 @@ public class IncomingMessageListener implements Runnable { Session.currentChatEntry = chat; }); - // 2. get permission JSONObject permissionReq = new JSONObject(); if (chatType.equalsIgnoreCase("group")) { - permissionReq.put("action", "get_group_permissions"); - permissionReq.put("group_id", chatId); + permissionReq.put("action", "get_group_permissions").put("group_id", chatId); } else { - permissionReq.put("action", "get_channel_permissions"); - permissionReq.put("channel_id", chatId); + permissionReq.put("action", "get_channel_permissions").put("channel_id", chatId); } JSONObject permissionResp = ActionHandler.sendWithResponse(permissionReq); JSONObject perm = permissionResp.getJSONObject("data"); entry.ifPresent(chat -> chat.setPermissions(perm)); - // 3. set currentChatId Session.currentChatId = chatUUID.toString(); System.out.println("🧪 Checking refresh conditions..."); @@ -293,20 +297,11 @@ public class IncomingMessageListener implements Runnable { } } - - - - - - - - - private void displayRealTimeMessage(String action, JSONObject msg) { switch (action) { case "new_message" -> { String senderName = msg.optString("sender_name","Unknown"); - String content = msg.optString("content","(empty)"); + String content = msg.optString("content",""); String sendAt = msg.optString("send_at","-"); String chatId = msg.optString("receiver_id", msg.optString("chat_id","")); String kind = msg.optString("kind","plain"); @@ -325,47 +320,47 @@ public class IncomingMessageListener implements Runnable { Session.currentChatId != null && Session.currentChatId.equals(chatId); if (isInCurrentChat) { + if (content.isBlank()) content = "(no content)"; // برای مدیا بدون کپشن System.out.println(senderName + ": " + prefix + content + " (" + sendAt + ")"); } else { - System.out.println("💬 Message from " + senderName + ": " + prefix + content); + String preview = content.isBlank() ? "[media]" : content; + System.out.println("💬 Message from " + senderName + ": " + prefix + preview); Session.forceRefreshChatList = true; } } - case "message_edited" -> { System.out.println("\n✏️ Message Edited:"); - System.out.println("ID: " + msg.getString("message_id")); - System.out.println("New Content: " + msg.getString("new_content")); - System.out.println("Edit Time: " + msg.getString("edited_at")); + System.out.println("ID: " + msg.optString("message_id","")); + System.out.println("New Content: " + msg.optString("new_content","")); + System.out.println("Edit Time: " + msg.optString("edited_at","")); } case "message_deleted_global" -> { System.out.println("\n🗑️ Message Deleted:"); - System.out.println("Message ID: " + msg.getString("message_id")); + System.out.println("Message ID: " + msg.optString("message_id","")); } case "message_reacted", "message_unreacted" -> { - String mid = msg.getString("message_id"); - String emoji = msg.getString("emoji"); - JSONObject counts = msg.optJSONObject("counts"); + String mid = msg.optString("message_id",""); + String emoji = msg.optString("emoji",""); int n = msg.optInt("count_for_emoji", 0); System.out.println("\n⭐ Reaction update on " + mid + " : " + emoji + " → " + n); } case "user_status_changed" -> { System.out.println("\n🔄 User Status Changed:"); - System.out.println("User: " + msg.getString("user_id")); - System.out.println("Status: " + msg.getString("status")); + System.out.println("User: " + msg.optString("user_id","")); + System.out.println("Status: " + msg.optString("status","")); } case "blocked_by_user" -> { - System.out.println("\n⛔ You were blocked by user: " + msg.getString("blocker_id")); + System.out.println("\n⛔ You were blocked by user: " + msg.optString("blocker_id","")); } case "unblocked_by_user" -> { - System.out.println("\n✅ You were unblocked by user: " + msg.getString("unblocker_id")); + System.out.println("\n✅ You were unblocked by user: " + msg.optString("unblocker_id","")); } case "message_seen" -> { System.out.println("\n👁️ Your message was seen:"); - System.out.println("Message ID: " + msg.getString("message_id")); - System.out.println("Seen at: " + msg.getString("seen_at")); + System.out.println("Message ID: " + msg.optString("message_id","")); + System.out.println("Seen at: " + msg.optString("seen_at","")); } default -> { System.out.println("\n❓ Unknown real-time action: " + action); diff --git a/src/main/java/org/to/telegramfinalproject/Client/Session.java b/src/main/java/org/to/telegramfinalproject/Client/Session.java index a13892c..f4f9475 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/Session.java +++ b/src/main/java/org/to/telegramfinalproject/Client/Session.java @@ -28,7 +28,7 @@ public class Session { public static ChatEntry currentChatEntry = null; public static List contactEntries = new ArrayList<>(); public static boolean inContactListMenu = false; - + public static DownloadsIndex downloadsIndex = null; public static String getUserUUID() { if (currentUser.has("uuid")) return currentUser.getString("uuid"); @@ -110,4 +110,7 @@ public class Session { .filter(ChatEntry::isArchived) .toList(); } + + public void setDownloadIndex(DownloadsIndex idx){ this.downloadsIndex = idx; } + } \ No newline at end of file diff --git a/src/main/java/org/to/telegramfinalproject/Client/SocketMediaDownloader.java b/src/main/java/org/to/telegramfinalproject/Client/SocketMediaDownloader.java new file mode 100644 index 0000000..0a128ac --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Client/SocketMediaDownloader.java @@ -0,0 +1,78 @@ +package org.to.telegramfinalproject.Client; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.PrintWriter; + +public final class SocketMediaDownloader { + private static final int MAGIC_DL = 0x4D444D32; + private final PrintWriter outText; // NEW + private final DataInputStream inBin; + private final DataOutputStream outBin; + + public SocketMediaDownloader(PrintWriter outText, DataInputStream inBin, DataOutputStream outBin) { + this.outText = outText; + this.inBin = inBin; + this.outBin = outBin; + } + + + public java.nio.file.Path download(java.util.UUID mediaKey, java.nio.file.Path saveDir, String fileNameHint) throws Exception { + // 1) سوییچ مود با PrintWriter + outText.print("MEDIA_DL\n"); + outText.flush(); + + // 2) هدر باینری درخواست + org.json.JSONObject req = new org.json.JSONObject() + .put("op","download") + .put("media_key", mediaKey.toString()) + .put("offset", 0); + byte[] hb = req.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + + outBin.writeInt(MAGIC_DL); + outBin.writeInt(hb.length); + outBin.write(hb); + outBin.flush(); + + // 3) پاسخ + int magic = inBin.readInt(); + if (magic != MAGIC_DL) throw new java.io.IOException("bad magic"); + + int hlen = inBin.readInt(); + byte[] hbytes = inBin.readNBytes(hlen); + org.json.JSONObject hdr = new org.json.JSONObject(new String(hbytes, java.nio.charset.StandardCharsets.UTF_8)); + if (!"success".equalsIgnoreCase(hdr.optString("status"))) { + throw new java.io.IOException("download error: " + hdr.optString("message")); + } + + long contentLen = inBin.readLong(); + String serverName = hdr.optString("file_name", fileNameHint != null ? fileNameHint : mediaKey.toString()); + + java.nio.file.Files.createDirectories(saveDir); + java.nio.file.Path dest = uniquePath(saveDir, serverName); + + try (java.io.OutputStream os = java.nio.file.Files.newOutputStream(dest)) { + byte[] buf = new byte[8192]; + long remain = contentLen; + while (remain > 0) { + int toRead = (int) Math.min(buf.length, remain); + int n = inBin.read(buf, 0, toRead); + if (n == -1) throw new java.io.EOFException("unexpected EOF"); + os.write(buf, 0, n); + remain -= n; + } + } + return dest; + } + + private static java.nio.file.Path uniquePath(java.nio.file.Path dir, String name) throws java.io.IOException { + java.nio.file.Path p = dir.resolve(name); + if (!java.nio.file.Files.exists(p)) return p; + String base = name, ext = ""; + int dot = name.lastIndexOf('.'); + if (dot >= 0) { base = name.substring(0, dot); ext = name.substring(dot); } + int i = 1; + while (java.nio.file.Files.exists(dir.resolve(base + " (" + i + ")" + ext))) i++; + return dir.resolve(base + " (" + i + ")" + ext); + } +} diff --git a/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java b/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java index 464b36e..84a12ea 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java +++ b/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java @@ -4,12 +4,14 @@ import org.json.JSONObject; import java.io.*; import java.net.Socket; +import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Scanner; import java.util.UUID; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; public class TelegramClient { private static final String SERVER_HOST = "localhost"; @@ -22,7 +24,10 @@ public class TelegramClient { public static BlockingQueue responseQueue = new LinkedBlockingQueue<>(); public static UUID loggedInUserId = null; public static final Map> pendingResponses = new ConcurrentHashMap<>(); - + private DataInputStream inBin; // NEW + private static SocketMediaDownloader downloader; // NEW + public static final AtomicBoolean mediaBusy = new AtomicBoolean(false); // + private DownloadsIndex downloadIndex; private static TelegramClient instance; @@ -31,6 +36,10 @@ public class TelegramClient { instance = this; } + public static SocketMediaDownloader getDownloader() { + return downloader; + } + public static TelegramClient getInstance() { return instance; } @@ -39,9 +48,14 @@ public class TelegramClient { public void start() { try { socket = new Socket(SERVER_HOST, SERVER_PORT); - in = new BufferedReader(new InputStreamReader(socket.getInputStream())); - out = new PrintWriter(socket.getOutputStream(), true); - outBin = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream())); // برای MEDIA + InputStream rawIn = socket.getInputStream(); + OutputStream rawOut = socket.getOutputStream(); + in = new BufferedReader(new InputStreamReader(rawIn, StandardCharsets.UTF_8)); + out = new PrintWriter(new OutputStreamWriter(rawOut, StandardCharsets.UTF_8), true); + + inBin = new DataInputStream(rawIn); + outBin = new DataOutputStream(rawOut); + downloader = new SocketMediaDownloader(out, inBin, outBin); System.out.println("✅ Connected to Telegram Server"); handler = new ActionHandler(out, in, outBin, scanner); @@ -78,7 +92,7 @@ public class TelegramClient { UUID internalId = UUID.fromString(Session.currentUser.getString("internal_uuid")); loggedInUserId = internalId; - + this.downloadIndex = DownloadIndexRegistry.forAccount(internalId); handler.userMenu(internalId); } else { System.out.println("❌ Login failed."); @@ -120,3 +134,4 @@ public class TelegramClient { } } + diff --git a/src/main/java/org/to/telegramfinalproject/Database/ChannelDatabase.java b/src/main/java/org/to/telegramfinalproject/Database/ChannelDatabase.java index 6804ad4..4429721 100644 --- a/src/main/java/org/to/telegramfinalproject/Database/ChannelDatabase.java +++ b/src/main/java/org/to/telegramfinalproject/Database/ChannelDatabase.java @@ -195,7 +195,7 @@ public class ChannelDatabase { """; try (Connection conn = ConnectionDb.connect()) { - // مرحله اول: ساخت کانال + PreparedStatement stmt = conn.prepareStatement(sql); stmt.setString(1, channel.getChannel_id()); stmt.setString(2, channel.getChannel_name()); @@ -208,9 +208,8 @@ public class ChannelDatabase { if (!rs.next()) return false; UUID internalUUID = (UUID) rs.getObject("internal_uuid"); - channel.setInternal_uuid(internalUUID); // اختیاری برای پیگیری بعدی + channel.setInternal_uuid(internalUUID); - // مرحله دوم: افزودن کاربر به لیست سابسکرایبرها PreparedStatement subStmt = conn.prepareStatement(subscriberSql); subStmt.setObject(1, internalUUID); subStmt.setObject(2, creatorId); diff --git a/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java b/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java index 90ff0fc..4de0c20 100644 --- a/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java +++ b/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java @@ -1,14 +1,12 @@ package org.to.telegramfinalproject.Database; import org.to.telegramfinalproject.Models.FileAttachment; +import org.to.telegramfinalproject.Models.MediaRow; import org.to.telegramfinalproject.Models.Message; import java.sql.*; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; public class MessageDatabase { @@ -1197,4 +1195,171 @@ public class MessageDatabase { } } + +// +// public static MediaRow findMediaByKey(UUID mediaKey) throws SQLException { +// final String sql = """ +// SELECT a.message_id, a.storage_path, a.file_name, a.mime_type, a.file_size, +// m.receiver_type, m.receiver_id, m.sender_id +// FROM message_attachments a +// JOIN messages m ON m.message_id = a.message_id +// WHERE a.media_key = ? +// """; +// try (Connection c = ConnectionDb.connect(); +// PreparedStatement ps = c.prepareStatement(sql)) { +// ps.setObject(1, mediaKey); +// try (ResultSet rs = ps.executeQuery()) { +// if (!rs.next()) return null; +// MediaRow mr = new MediaRow(); +// mr.messageId = (UUID) rs.getObject(1); +// mr.storagePath = rs.getString(2); +// mr.fileName = rs.getString(3); +// mr.mimeType = rs.getString(4); +// mr.fileSize = rs.getLong(5); +// mr.receiverType= rs.getString(6); +// mr.receiverId = (UUID) rs.getObject(7); +// mr.senderId = (UUID) rs.getObject(8); +// return mr; +// } +// } +// } + +// public static boolean canAccess(UUID requester, MediaRow mr) { +// if ("private".equals(mr.receiverType)) { +// return requester.equals(mr.senderId) || requester.equals(mr.receiverId); +// } else if ("group".equals(mr.receiverType)) { +// return GroupDatabase.isMember(mr.receiverId, requester); +// } else if ("channel".equals(mr.receiverType)) { +// return ChannelDatabase.isUserInChannel(mr.receiverId, requester); +// } +// return false; +// } + + public static Map> findAttachmentsForMessages(List ids) throws SQLException { + Map> map = new java.util.HashMap<>(); + if (ids == null || ids.isEmpty()) return map; + + // ساخت IN به‌صورت امن + String placeholders = ids.stream().map(x -> "?").collect(java.util.stream.Collectors.joining(",")); + String sql = """ + SELECT attachment_id, message_id, media_key, file_name, file_size, mime_type, file_type, + width, height, duration_seconds, thumbnail_url, file_url, storage_path + FROM message_attachments + WHERE message_id IN (""" + placeholders + ") ORDER BY uploaded_at ASC"; + + try (Connection c = ConnectionDb.connect(); + PreparedStatement ps = c.prepareStatement(sql)) { + int i = 1; + for (UUID id : ids) ps.setObject(i++, id); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + MediaRow a = new MediaRow(); + a.attachmentId = (UUID) rs.getObject("attachment_id"); + a.messageId = (UUID) rs.getObject("message_id"); + a.mediaKey = (UUID) rs.getObject("media_key"); + a.fileName = rs.getString("file_name"); + long sz = rs.getLong("file_size"); + a.fileSize = rs.wasNull() ? null : sz; + a.mimeType = rs.getString("mime_type"); + a.fileType = rs.getString("file_type"); + int w = rs.getInt("width"); + a.width = rs.wasNull() ? null : w; + int h = rs.getInt("height"); + a.height = rs.wasNull() ? null : h; + int d = rs.getInt("duration_seconds"); + a.durationSeconds = rs.wasNull() ? null : d; + a.thumbnailUrl = rs.getString("thumbnail_url"); + a.fileUrl = rs.getString("file_url"); + a.storagePath = rs.getString("storage_path"); + + map.computeIfAbsent(a.messageId, k -> new java.util.ArrayList<>()).add(a); + } + } + } + return map; + } + + + + + + public static MediaRow findMediaByKey(UUID mediaKey) throws SQLException { + String sql = """ + SELECT + ma.attachment_id, + ma.message_id, + ma.media_key, + ma.file_name, + ma.file_size, + ma.mime_type, + ma.file_type, + ma.width, + ma.height, + ma.duration_seconds, + ma.thumbnail_url, + ma.file_url, + ma.storage_path, + m.receiver_type, + m.receiver_id, + m.sender_id + FROM message_attachments ma + JOIN messages m ON m.message_id = ma.message_id + WHERE ma.media_key = ? + LIMIT 1 + """; + + try (Connection c = ConnectionDb.connect(); + PreparedStatement ps = c.prepareStatement(sql)) { + ps.setObject(1, mediaKey); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + + MediaRow a = new MediaRow(); + a.attachmentId = (UUID) rs.getObject("attachment_id"); + a.messageId = (UUID) rs.getObject("message_id"); + a.mediaKey = (UUID) rs.getObject("media_key"); + a.fileName = rs.getString("file_name"); + + long sz = rs.getLong("file_size"); + a.fileSize = rs.wasNull() ? null : sz; // MediaRow.fileSize = Long + + a.mimeType = rs.getString("mime_type"); + a.fileType = rs.getString("file_type"); + int w = rs.getInt("width"); a.width = rs.wasNull() ? null : w; + int h = rs.getInt("height"); a.height = rs.wasNull() ? null : h; + int d = rs.getInt("duration_seconds"); a.durationSeconds = rs.wasNull() ? null : d; + a.thumbnailUrl = rs.getString("thumbnail_url"); + a.fileUrl = rs.getString("file_url"); + a.storagePath = rs.getString("storage_path"); + a.receiverType = rs.getString("receiver_type"); + a.receiverId = (UUID) rs.getObject("receiver_id"); + a.senderId = (UUID) rs.getObject("sender_id"); + return a; + } + } + } + + + public static boolean canAccess(UUID requester, MediaRow mr) { + if (requester == null || mr == null || mr.receiverType == null) return false; + + // اختیاری: فرستنده همیشه مجاز + if (requester.equals(mr.senderId)) return true; + + switch (mr.receiverType.toLowerCase(Locale.ROOT)) { + case "private": + // receiver_id در پیام‌های private = UUID چت خصوصی + return PrivateChatDatabase.isParticipant(mr.receiverId, requester); + + case "group": + return GroupDatabase.isMember(mr.receiverId, requester); + + case "channel": + return ChannelDatabase.isUserInChannel(mr.receiverId, requester); + + default: + return false; + } + } + } diff --git a/src/main/java/org/to/telegramfinalproject/Database/MessageReactionDatabase.java b/src/main/java/org/to/telegramfinalproject/Database/MessageReactionDatabase.java index d027a5a..65ade2d 100644 --- a/src/main/java/org/to/telegramfinalproject/Database/MessageReactionDatabase.java +++ b/src/main/java/org/to/telegramfinalproject/Database/MessageReactionDatabase.java @@ -1,6 +1,7 @@ package org.to.telegramfinalproject.Database; import org.json.JSONObject; +import org.to.telegramfinalproject.Models.MediaRow; import java.sql.Connection; import java.sql.PreparedStatement; @@ -8,6 +9,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.UUID; public class MessageReactionDatabase { @@ -73,4 +75,8 @@ public class MessageReactionDatabase { return counts; // مثال: {"❤️":2,"👍":1} } + + + + } diff --git a/src/main/java/org/to/telegramfinalproject/Database/PrivateChatDatabase.java b/src/main/java/org/to/telegramfinalproject/Database/PrivateChatDatabase.java index 6b2ab1f..cb56fc2 100644 --- a/src/main/java/org/to/telegramfinalproject/Database/PrivateChatDatabase.java +++ b/src/main/java/org/to/telegramfinalproject/Database/PrivateChatDatabase.java @@ -315,4 +315,27 @@ public class PrivateChatDatabase { return null; } + public static boolean isParticipant(java.util.UUID chatId, java.util.UUID userId) { + String sql = """ + SELECT 1 + FROM private_chat + WHERE chat_id = ? + AND (user1_id = ? OR user2_id = ?) + LIMIT 1 + """; + try (var c = ConnectionDb.connect(); + var ps = c.prepareStatement(sql)) { + ps.setObject(1, chatId); + ps.setObject(2, userId); + ps.setObject(3, userId); + try (var rs = ps.executeQuery()) { + return rs.next(); + } + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + } diff --git a/src/main/java/org/to/telegramfinalproject/Models/MediaRow.java b/src/main/java/org/to/telegramfinalproject/Models/MediaRow.java new file mode 100644 index 0000000..0d89c82 --- /dev/null +++ b/src/main/java/org/to/telegramfinalproject/Models/MediaRow.java @@ -0,0 +1,22 @@ +package org.to.telegramfinalproject.Models; + +import java.util.UUID; + +public class MediaRow { + public UUID messageId; + public String storagePath; + public String fileName; + public String mimeType; + public Long fileSize; + public String receiverType; + public UUID receiverId; + public UUID senderId; + public java.util.UUID attachmentId; + public java.util.UUID mediaKey; + public String fileType; // IMAGE/AUDIO/... + public Integer width; + public Integer height; + public Integer durationSeconds; //for audio only + public String thumbnailUrl; + public String fileUrl; //display link +} diff --git a/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java b/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java index 76136db..8dce818 100644 --- a/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java +++ b/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java @@ -32,12 +32,31 @@ public class ClientHandler implements Runnable { UUID userId = null; try ( + +// InputStream rawIn = socket.getInputStream(); +// OutputStream rawOut = socket.getOutputStream(); +// +// BufferedReader in = new BufferedReader(new InputStreamReader(rawIn, java.nio.charset.StandardCharsets.UTF_8)); +// PrintWriter out = new PrintWriter(new OutputStreamWriter(rawOut, java.nio.charset.StandardCharsets.UTF_8), true); + +// DataInputStream dis = new DataInputStream(rawIn); +// DataOutputStream dos = new DataOutputStream(new BufferedOutputStream(rawOut)); + // BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); // PrintWriter out = new PrintWriter(socket.getOutputStream(), true) - BufferedInputStream bis = new BufferedInputStream(socket.getInputStream()); - DataInputStream dis = new DataInputStream(bis); //for binary headers +// BufferedInputStream bis = new BufferedInputStream(socket.getInputStream()); + // DataInputStream dis = new DataInputStream(bis); //for binary headers - PrintWriter out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), java.nio.charset.StandardCharsets.UTF_8), true); + //PrintWriter out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), java.nio.charset.StandardCharsets.UTF_8), true); + InputStream rawIn = socket.getInputStream(); + OutputStream rawOut = socket.getOutputStream(); + + BufferedInputStream bis = new BufferedInputStream(rawIn); + BufferedOutputStream bos = new BufferedOutputStream(rawOut); + + DataInputStream dis = new DataInputStream(bis); + DataOutputStream dos = new DataOutputStream(bos); + PrintWriter out = new PrintWriter(new OutputStreamWriter(bos, java.nio.charset.StandardCharsets.UTF_8), true); ) { @@ -45,11 +64,23 @@ public class ClientHandler implements Runnable { String inputLine; while ((inputLine = readUtf8Line(bis)) != null) { + + String line = inputLine.trim(); + if ("MEDIA".equalsIgnoreCase(inputLine.trim())) { handleMediaFrame(dis, out); continue; } + if ("MEDIA_DL".equalsIgnoreCase(line)) { + if (this.currentUser.getInternal_uuid() == null) { + sendDlErr(dos, "not authorized"); + continue; + } + handleMediaDownload(dis, dos, this.currentUser.getInternal_uuid()); + continue; + } + JSONObject requestJson = new JSONObject(inputLine); String action = requestJson.getString("action"); ResponseModel response = null; @@ -2144,6 +2175,13 @@ public class ClientHandler implements Runnable { List messages = MessageDatabase.getMessagesForChat(chatId, chatType, currentUser.getInternal_uuid(), offset, limit); + java.util.List mids = new java.util.ArrayList<>(); + for (Message m : messages) mids.add(m.getMessage_id()); + + // ⬅️ همهٔ اتچمنت‌ها را یک‌جا بگیر: message_id -> list(attachments) + java.util.Map> attMap = + MessageDatabase.findAttachmentsForMessages(mids); + JSONArray result = new JSONArray(); for (Message m : messages) { JSONObject obj = new JSONObject(); @@ -2211,6 +2249,26 @@ public class ClientHandler implements Runnable { obj.put("reactions", new JSONArray(reactions)); + JSONArray atts = new JSONArray(); + java.util.List list = attMap.getOrDefault(m.getMessage_id(), java.util.Collections.emptyList()); + for (MediaRow a : list) { + JSONObject aj = new JSONObject() + .put("media_key", a.mediaKey != null ? a.mediaKey.toString() : JSONObject.NULL) + .put("file_name", a.fileName != null ? a.fileName : JSONObject.NULL) + .put("file_size", a.fileSize != null ? a.fileSize : JSONObject.NULL) + .put("mime_type", a.mimeType != null ? a.mimeType : JSONObject.NULL) + .put("file_type", a.fileType != null ? a.fileType : JSONObject.NULL) + .put("width", a.width != null ? a.width : JSONObject.NULL) + .put("height", a.height != null ? a.height : JSONObject.NULL) + .put("duration_seconds", a.durationSeconds != null ? a.durationSeconds : JSONObject.NULL) + .put("thumbnail_url", a.thumbnailUrl != null ? a.thumbnailUrl : JSONObject.NULL) + // اختیاری/دیباگ + .put("file_url", a.fileUrl != null ? a.fileUrl : JSONObject.NULL); + atts.put(aj); + } + obj.put("attachments", atts); + + result.put(obj); } @@ -2570,7 +2628,9 @@ public class ClientHandler implements Runnable { userDatabase.updateLastSeen(userId); SessionManager.removeUser(userId); } - } finally { + } catch (SQLException e) { + throw new RuntimeException(e); + } finally { try { if (currentUser != null) { //RealTime @@ -2863,6 +2923,75 @@ public class ClientHandler implements Runnable { } } + private static final int MAGIC_DL = 0x4D444D32; // "MDM2" + + private void handleMediaDownload(DataInputStream inBin, DataOutputStream outBin, UUID requesterId) { + try { + int magic = inBin.readInt(); + if (magic != MAGIC_DL) { sendDlErr(outBin, "bad magic"); return; } + + int hlen = inBin.readInt(); + if (hlen <= 0 || hlen > 64 * 1024) { sendDlErr(outBin, "bad header length"); return; } + + byte[] hb = inBin.readNBytes(hlen); + if (hb.length != hlen) { sendDlErr(outBin, "header truncated"); return; } + + JSONObject hdr = new JSONObject(new String(hb, java.nio.charset.StandardCharsets.UTF_8)); + if (!"download".equalsIgnoreCase(hdr.optString("op"))) { sendDlErr(outBin, "bad op"); return; } + + UUID mediaKey = UUID.fromString(hdr.getString("media_key")); + long offset = Math.max(0L, hdr.optLong("offset", 0L)); + + MediaRow mr = MessageDatabase.findMediaByKey(mediaKey); + if (mr == null) { sendDlErr(outBin, "not found"); return; } + if (!MessageDatabase.canAccess(requesterId, mr)) { sendDlErr(outBin, "not authorized"); return; } + + java.nio.file.Path path = java.nio.file.Paths.get(mr.storagePath).normalize(); + long size = java.nio.file.Files.size(path); + if (offset > size) offset = 0L; + + JSONObject ok = new JSONObject() + .put("status","success") + .put("media_key", mediaKey.toString()) + .put("file_name", mr.fileName) + .put("mime_type", mr.mimeType) + .put("file_size", size); + + byte[] okb = ok.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + + outBin.writeInt(MAGIC_DL); + outBin.writeInt(okb.length); + outBin.write(okb); + outBin.writeLong(size - offset); + + try (java.io.InputStream fis = new java.io.BufferedInputStream(java.nio.file.Files.newInputStream(path))) { + if (offset > 0) fis.skipNBytes(offset); + byte[] buf = new byte[8192]; + long remain = size - offset; + while (remain > 0) { + int n = fis.read(buf, 0, (int) Math.min(buf.length, remain)); + if (n == -1) break; + outBin.write(buf, 0, n); + remain -= n; + } + } + outBin.flush(); + + } catch (Exception e) { + e.printStackTrace(); + try { sendDlErr(outBin, "exception"); } catch (Exception ignored) {} + } + } + + private void sendDlErr(DataOutputStream outBin, String msg) throws java.io.IOException { + JSONObject j = new JSONObject().put("status","error").put("message", msg); + byte[] b = j.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + outBin.writeInt(MAGIC_DL); + outBin.writeInt(b.length); + outBin.write(b); + outBin.writeLong(0L); + outBin.flush(); + } private static void skip(DataInputStream dis, long n) throws IOException { if (n <= 0) return;