From 46f8ceccbcf211c007b376dc8b361247ecfe15b1 Mon Sep 17 00:00:00 2001 From: Radek Davidek Date: Fri, 6 Feb 2026 16:48:54 +0100 Subject: [PATCH] initial commit --- .gitignore | 25 + .vscode/settings.json | 8 + pom.xml | 58 ++ .../kamma/jkeepass/CustomDatabaseFormat.java | 216 ++++ .../java/cz/kamma/jkeepass/KeepassApp.java | 985 ++++++++++++++++++ .../cz/kamma/jkeepass/model/Database.java | 24 + .../java/cz/kamma/jkeepass/model/Entry.java | 52 + .../java/cz/kamma/jkeepass/model/Group.java | 51 + src/main/resources/icon.png | Bin 0 -> 8813 bytes 9 files changed, 1419 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 pom.xml create mode 100644 src/main/java/cz/kamma/jkeepass/CustomDatabaseFormat.java create mode 100644 src/main/java/cz/kamma/jkeepass/KeepassApp.java create mode 100644 src/main/java/cz/kamma/jkeepass/model/Database.java create mode 100644 src/main/java/cz/kamma/jkeepass/model/Entry.java create mode 100644 src/main/java/cz/kamma/jkeepass/model/Group.java create mode 100644 src/main/resources/icon.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb54cae --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# IDE +.idea/ +*.iml +*.iws +*.ipr +.vscode/ +.classpath +.project +.settings/ + +# OS +.DS_Store +Thumbs.db diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..40d80b9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "automatic", + "chat.tools.terminal.autoApprove": { + "/home/kamma/java/apache-maven-3.9.11/bin/mvn": true, + "/home/kamma/java/jdk-21.0.9+10/bin/java": true + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..19d13d5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + com.jkeepass + jkeepass + 1.0-SNAPSHOT + + + 17 + 17 + UTF-8 + + + + + com.google.code.gson + gson + 2.10.1 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.0 + + + package + + shade + + + + + cz.kamma.jkeepass.KeepassApp + + + + + + + + + diff --git a/src/main/java/cz/kamma/jkeepass/CustomDatabaseFormat.java b/src/main/java/cz/kamma/jkeepass/CustomDatabaseFormat.java new file mode 100644 index 0000000..6867fab --- /dev/null +++ b/src/main/java/cz/kamma/jkeepass/CustomDatabaseFormat.java @@ -0,0 +1,216 @@ +package cz.kamma.jkeepass; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import cz.kamma.jkeepass.model.Database; +import cz.kamma.jkeepass.model.Entry; +import cz.kamma.jkeepass.model.Group; + +/** + * Handler for custom JKP format (encrypted JSON database) + */ +public class CustomDatabaseFormat { + + private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; + private static final Gson gson = new GsonBuilder().create(); + + /** + * Internal JSON structure for serialization + */ + static class JkpDatabase { + public int version = 1; + public String salt; // Base64 encoded + public String iv; // Base64 encoded + public String encryptedData; // Base64 encoded + } + + static class JkpGroup { + public String name; + public List groups = new ArrayList<>(); + public List entries = new ArrayList<>(); + } + + static class JkpEntry { + public String title; + public String username; + public String password; + public String url; + public String notes; + } + + /** + * Save database to .jkp file (encrypted JSON) + */ + public static void saveDatabase(File file, Database database, String password) throws Exception { + // Serialize database to JSON + JkpGroup rootGroup = serializeGroup(database.getRootGroup()); + String jsonData = gson.toJson(rootGroup); + + // Generate salt and derive key + byte[] salt = generateSalt(); + SecretKey key = deriveKey(password.toCharArray(), salt); + + // Encrypt data + Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); + javax.crypto.spec.IvParameterSpec iv = new javax.crypto.spec.IvParameterSpec(new byte[16]); + cipher.init(Cipher.ENCRYPT_MODE, key, iv); + byte[] encryptedBytes = cipher.doFinal(jsonData.getBytes(StandardCharsets.UTF_8)); + + // Create JKP structure + JkpDatabase jkpDb = new JkpDatabase(); + jkpDb.salt = Base64.getEncoder().encodeToString(salt); + jkpDb.iv = Base64.getEncoder().encodeToString(iv.getIV()); + jkpDb.encryptedData = Base64.getEncoder().encodeToString(encryptedBytes); + + // Write to file + String jkpJson = gson.toJson(jkpDb); + Files.write(Path.of(file.getAbsolutePath()), jkpJson.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Load JKP file and convert to Database object + * Creates an empty database structure and populates it with JKP data + */ + public static Database convertJkpToDatabase(File file, String password) throws Exception { + // Load the JKP structure + JkpGroup jkpData = loadDatabaseStructure(file, password); + + // Create a new empty database to hold the JKP data + Database database = new Database(); + + // Clear template data and populate with JKP data + Group rootGroup = database.getRootGroup(); + rootGroup.setName(jkpData.name != null ? jkpData.name : "Root"); + + // Clear existing entries and groups + for (Object entryObj : new java.util.ArrayList<>(rootGroup.getEntries())) { + rootGroup.removeEntry((Entry) entryObj); + } + for (Object groupObj : new java.util.ArrayList<>(rootGroup.getGroups())) { + rootGroup.removeGroup((Group) groupObj); + } + + // Populate with JKP data + deserializeGroup(rootGroup, jkpData, database); + + return database; + } + + /** + * Load database from .jkp file + */ + public static JkpGroup loadDatabaseStructure(File file, String password) throws Exception { + // Read file + String jkpJson = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + JkpDatabase jkpDb = gson.fromJson(jkpJson, JkpDatabase.class); + + if (jkpDb == null || jkpDb.encryptedData == null) { + throw new IOException("Invalid JKP file format"); + } + + // Decode salt and IV + byte[] salt = Base64.getDecoder().decode(jkpDb.salt); + byte[] ivBytes = Base64.getDecoder().decode(jkpDb.iv); + byte[] encryptedBytes = Base64.getDecoder().decode(jkpDb.encryptedData); + + // Derive key + SecretKey key = deriveKey(password.toCharArray(), salt); + + // Decrypt data + Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); + javax.crypto.spec.IvParameterSpec ivSpec = new javax.crypto.spec.IvParameterSpec(ivBytes); + cipher.init(Cipher.DECRYPT_MODE, key, ivSpec); + byte[] decryptedBytes = cipher.doFinal(encryptedBytes); + String jsonData = new String(decryptedBytes, StandardCharsets.UTF_8); + + // Deserialize from JSON + return gson.fromJson(jsonData, JkpGroup.class); + } + + /** + * Serialize KeePass Group to JKP Group + */ + private static JkpGroup serializeGroup(Group group) { + JkpGroup jkpGroup = new JkpGroup(); + jkpGroup.name = group.getName(); + + // Serialize subgroups + for (Object subGroupObj : group.getGroups()) { + Group subGroup = (Group) subGroupObj; + jkpGroup.groups.add(serializeGroup(subGroup)); + } + + // Serialize entries + for (Object entryObj : group.getEntries()) { + Entry entry = (Entry) entryObj; + JkpEntry jkpEntry = new JkpEntry(); + jkpEntry.title = entry.getTitle() != null ? entry.getTitle() : ""; + jkpEntry.username = entry.getUsername() != null ? entry.getUsername() : ""; + jkpEntry.password = entry.getPassword() != null ? entry.getPassword() : ""; + jkpEntry.url = entry.getUrl() != null ? entry.getUrl() : ""; + jkpEntry.notes = entry.getNotes() != null ? entry.getNotes() : ""; + jkpGroup.entries.add(jkpEntry); + } + + return jkpGroup; + } + + /** + * Deserialize JKP Group and populate KeePass Database + */ + public static void deserializeGroup(Group targetGroup, JkpGroup source, Database database) { + // Add subgroups + for (JkpGroup srcGroup : source.groups) { + Group newGroup = database.newGroup(srcGroup.name); + targetGroup.addGroup(newGroup); + deserializeGroup(newGroup, srcGroup, database); + } + + // Add entries + for (JkpEntry srcEntry : source.entries) { + Entry newEntry = targetGroup.addEntry(database.newEntry()); + newEntry.setTitle(srcEntry.title != null ? srcEntry.title : ""); + newEntry.setUsername(srcEntry.username != null ? srcEntry.username : ""); + newEntry.setPassword(srcEntry.password != null ? srcEntry.password : ""); + newEntry.setUrl(srcEntry.url != null ? srcEntry.url : ""); + newEntry.setNotes(srcEntry.notes != null ? srcEntry.notes : ""); + } + } + + /** + * Derive encryption key from password + */ + private static SecretKey deriveKey(char[] password, byte[] salt) throws Exception { + String passwordStr = new String(password); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(salt); + byte[] keyBytes = md.digest(passwordStr.getBytes(StandardCharsets.UTF_8)); + // Ensure exactly 32 bytes (256 bits) for AES-256 + return new SecretKeySpec(keyBytes, 0, 32, "AES"); + } + + /** + * Generate random salt + */ + private static byte[] generateSalt() { + SecureRandom random = new SecureRandom(); + byte[] salt = new byte[16]; + random.nextBytes(salt); + return salt; + } +} diff --git a/src/main/java/cz/kamma/jkeepass/KeepassApp.java b/src/main/java/cz/kamma/jkeepass/KeepassApp.java new file mode 100644 index 0000000..9ff74c4 --- /dev/null +++ b/src/main/java/cz/kamma/jkeepass/KeepassApp.java @@ -0,0 +1,985 @@ +package cz.kamma.jkeepass; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Frame; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Image; +import java.awt.Insets; +import java.awt.MenuItem; +import java.awt.Point; +import java.awt.PopupMenu; +import java.awt.SystemTray; +import java.awt.Toolkit; +import java.awt.TrayIcon; +import java.awt.datatransfer.StringSelection; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.io.File; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.prefs.Preferences; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPasswordField; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTable; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.JTree; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import javax.swing.UIManager; +import javax.swing.event.AncestorEvent; +import javax.swing.event.AncestorListener; +import javax.swing.table.DefaultTableModel; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; + +import cz.kamma.jkeepass.model.Database; +import cz.kamma.jkeepass.model.Entry; +import cz.kamma.jkeepass.model.Group; + +public class KeepassApp extends JFrame { + private JTable entryTable; + private DefaultTableModel tableModel; + private JTextArea notesArea; + private JLabel statusLabel; + private JMenu recentMenu; + private JTree groupTree; + private DefaultTreeModel treeModel; + private JSplitPane mainSplitPane; + private JSplitPane rightSplitPane; + private Database database; + private Group currentGroup; + private List currentEntries = new ArrayList<>(); + private final Preferences prefs = Preferences.userNodeForPackage(KeepassApp.class); + private static final String PREF_RECENT_LIST = "recent_database_list"; + private static final String PREF_WINDOW_X = "window_x"; + private static final String PREF_WINDOW_Y = "window_y"; + private static final String PREF_WINDOW_WIDTH = "window_width"; + private static final String PREF_WINDOW_HEIGHT = "window_height"; + private static final String PREF_MAIN_DIVIDER = "main_divider_location"; + private static final String PREF_RIGHT_DIVIDER = "right_divider_location"; + private static final int MAX_RECENT_FILES = 5; + private File currentFile; + private String currentPassword; + + private TrayIcon trayIcon; + + public KeepassApp() { + // Set properties before anything else + System.setProperty("sun.awt.enableExtraMouseButtons", "true"); + // Force GTK 2 or 3 for better X11 integration in Cinnamon + System.setProperty("jdk.gtk.version", "3"); + // Ensure we don't use the old way of drawing that can conflict with XApp + System.setProperty("sun.java2d.xrender", "true"); + + setTitle("JKeepass"); + setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + + // Load and apply saved window position and size + int savedX = prefs.getInt(PREF_WINDOW_X, -1); + int savedY = prefs.getInt(PREF_WINDOW_Y, -1); + int savedWidth = prefs.getInt(PREF_WINDOW_WIDTH, 800); + int savedHeight = prefs.getInt(PREF_WINDOW_HEIGHT, 500); + + if (savedX != -1 && savedY != -1) { + setLocation(savedX, savedY); + setSize(savedWidth, savedHeight); + } else { + setSize(800, 500); + setLocationRelativeTo(null); + } + + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + saveWindowState(); + saveComponentState(); + if (SystemTray.isSupported() && trayIcon != null) { + setVisible(false); + } else { + System.exit(0); + } + } + }); + + addComponentListener(new ComponentAdapter() { + @Override + public void componentMoved(ComponentEvent e) { + saveWindowState(); + } + + @Override + public void componentResized(ComponentEvent e) { + saveWindowState(); + } + }); + + loadAppIcon(); + initMenuBar(); + initComponents(); + + // Small delay to let OS/Window Manager settle before checking tray + Timer trayTimer = new Timer(1000, e -> initSystemTray()); + trayTimer.setRepeats(false); + trayTimer.start(); + } + + private void loadAppIcon() { + try { + java.net.URL iconUrl = getClass().getResource("/icon.png"); + if (iconUrl != null) { + Image img = Toolkit.getDefaultToolkit().getImage(iconUrl); + setIconImage(img); + System.out.println("Icon loaded successfully via Toolkit."); + } else { + System.err.println("Icon resource not found at /icon.png"); + } + } catch (Exception e) { + System.err.println("Could not load app icon: " + e.getMessage()); + } + } + + private void initSystemTray() { + try { + if (!SystemTray.isSupported()) { + return; + } + SystemTray tray = SystemTray.getSystemTray(); + + java.net.URL iconUrl = getClass().getResource("/icon.png"); + if (iconUrl == null) return; + + Image image; + try { + // Read image directly into a BufferedImage for better compatibility with XApp applet + image = javax.imageio.ImageIO.read(iconUrl); + } catch (Exception e) { + image = Toolkit.getDefaultToolkit().getImage(iconUrl); + } + + PopupMenu popup = new PopupMenu(); + MenuItem showItem = new MenuItem("Show JKeepass"); + MenuItem lockItem = new MenuItem("Lock Database"); + MenuItem exitItem = new MenuItem("Exit"); + + showItem.addActionListener(e -> { + setVisible(true); + setExtendedState(JFrame.NORMAL); + toFront(); + }); + lockItem.addActionListener(e -> lockDatabase()); + exitItem.addActionListener(e -> System.exit(0)); + + popup.add(showItem); + popup.add(lockItem); + popup.addSeparator(); + popup.add(exitItem); + + trayIcon = new TrayIcon(image, "JKeepass", popup); + trayIcon.setToolTip("JKeepass - Password Manager"); + trayIcon.setImageAutoSize(true); + + trayIcon.addActionListener(e -> { + setVisible(true); + setExtendedState(JFrame.NORMAL); + toFront(); + }); + + tray.add(trayIcon); + if (statusLabel != null) { + statusLabel.setText(" Ready (SysTray active)"); + } + setupWindowListeners(); + } catch (Exception e) { + // Silently fail if SystemTray is not available + trayIcon = null; + } + } + + private JFrame createRestoreFrame() { + JFrame restoreFrame = new JFrame("JKeepass - Click to Restore"); + restoreFrame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + restoreFrame.setAlwaysOnTop(true); + restoreFrame.setUndecorated(true); + restoreFrame.setOpacity(0.85f); + restoreFrame.setSize(160, 60); + restoreFrame.setLocation(20, 20); + restoreFrame.setType(JFrame.Type.UTILITY); + + JButton restoreBtn = new JButton("Restore JKeepass"); + restoreBtn.setFont(restoreBtn.getFont().deriveFont(12f)); + restoreBtn.addActionListener(e -> { + setVisible(true); + setExtendedState(JFrame.NORMAL); + toFront(); + requestFocus(); + restoreFrame.dispose(); + }); + + restoreFrame.add(restoreBtn); + return restoreFrame; + } + + private void minimizeToCustomTray() { + if (SystemTray.isSupported() && trayIcon != null) { + setVisible(false); + } else { + // Linux Mint doesn't support SystemTray - use floating restore button + lockDatabase(); + JFrame restoreFrame = createRestoreFrame(); + restoreFrame.setVisible(true); + setVisible(false); + } + } + + private void setupWindowListeners() { + for (java.awt.event.WindowListener wl : getWindowListeners()) { + if (!(wl instanceof WindowAdapter) || wl.getClass().getName().contains("KeepassApp")) { + removeWindowListener(wl); + } + } + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + if (trayIcon != null) { + setVisible(false); + } else { + System.exit(0); + } + } + @Override + public void windowIconified(WindowEvent e) { + if (trayIcon != null) { + setVisible(false); + } + } + }); + } + + private void initMenuBar() { + JMenuBar menuBar = new JMenuBar(); + + // File Menu + JMenu fileMenu = new JMenu("File"); + JMenuItem openItem = new JMenuItem("Open Database..."); + recentMenu = new JMenu("Open Recent"); + JMenuItem saveItem = new JMenuItem("Save"); + JMenuItem minimizeItem = new JMenuItem(SystemTray.isSupported() ? "Minimize to Tray" : "Minimize & Lock"); + JMenuItem lockItem = new JMenuItem("Lock Database"); + JMenuItem exitItem = new JMenuItem("Exit"); + + openItem.addActionListener(e -> openDatabase(null)); + saveItem.addActionListener(e -> saveDatabase()); + minimizeItem.addActionListener(e -> minimizeToCustomTray()); + lockItem.addActionListener(e -> lockDatabase()); + exitItem.addActionListener(e -> System.exit(0)); + + fileMenu.add(openItem); + fileMenu.add(recentMenu); + fileMenu.add(saveItem); + fileMenu.addSeparator(); + fileMenu.add(lockItem); + fileMenu.add(minimizeItem); + fileMenu.add(exitItem); + + updateRecentMenu(); + + // Entry Menu + JMenu entryMenu = new JMenu("Entry"); + JMenuItem addItem = new JMenuItem("Add Entry..."); + JMenuItem editItem = new JMenuItem("Edit Entry..."); + JMenuItem delItem = new JMenuItem("Delete Entry"); + + addItem.addActionListener(e -> addEntry()); + editItem.addActionListener(e -> editEntry()); + delItem.addActionListener(e -> deleteEntry()); + + entryMenu.add(addItem); + entryMenu.add(editItem); + entryMenu.add(delItem); + + // Edit Menu + JMenu editMenu = new JMenu("Edit"); + JMenuItem copyUserItem = new JMenuItem("Copy Username"); + JMenuItem copyPassItem = new JMenuItem("Copy Password"); + + copyUserItem.addActionListener(e -> copyToClipboard(true)); + copyPassItem.addActionListener(e -> copyToClipboard(false)); + + editMenu.add(copyUserItem); + editMenu.add(copyPassItem); + + // View Menu + JMenu viewMenu = new JMenu("View"); + JCheckBoxMenuItem onTopItem = new JCheckBoxMenuItem("Always on Top"); + onTopItem.addActionListener(e -> setAlwaysOnTop(onTopItem.isSelected())); + viewMenu.add(onTopItem); + + menuBar.add(fileMenu); + menuBar.add(entryMenu); + menuBar.add(editMenu); + menuBar.add(viewMenu); + setJMenuBar(menuBar); + } + + private void initComponents() { + // Tree setup + DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode("No database loaded"); + treeModel = new DefaultTreeModel(rootNode); + groupTree = new JTree(treeModel); + groupTree.addTreeSelectionListener(e -> { + DefaultMutableTreeNode selectedNode = (DefaultMutableTreeNode) groupTree.getLastSelectedPathComponent(); + if (selectedNode != null && selectedNode.getUserObject() instanceof GroupWrapper) { + displayEntriesInGroup(((GroupWrapper) selectedNode.getUserObject()).getGroup()); + } + }); + groupTree.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + showTreePopupMenu(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + showTreePopupMenu(e); + } + + private void showTreePopupMenu(MouseEvent e) { + if (e.isPopupTrigger()) { + TreePath path = groupTree.getPathForLocation(e.getX(), e.getY()); + if (path != null) { + groupTree.setSelectionPath(path); + DefaultMutableTreeNode selectedNode = (DefaultMutableTreeNode) path.getLastPathComponent(); + if (selectedNode.getUserObject() instanceof GroupWrapper) { + Group group = ((GroupWrapper) selectedNode.getUserObject()).getGroup(); + JPopupMenu popup = new JPopupMenu(); + JMenuItem addGroupItem = new JMenuItem("Add Category..."); + addGroupItem.addActionListener(al -> addCategory(group)); + popup.add(addGroupItem); + + // Add Delete Category option (not for root group) + if (selectedNode.getParent() != null) { + popup.addSeparator(); + JMenuItem deleteGroupItem = new JMenuItem("Delete Category..."); + deleteGroupItem.addActionListener(al -> deleteCategory(group, selectedNode)); + popup.add(deleteGroupItem); + } + + popup.show(e.getComponent(), e.getX(), e.getY()); + } + } + } + } + }); + JScrollPane treeScrollPane = new JScrollPane(groupTree); + + // Notes area setup + notesArea = new JTextArea(5, 20); + notesArea.setEditable(false); + notesArea.setLineWrap(true); + notesArea.setWrapStyleWord(true); + JScrollPane notesScrollPane = new JScrollPane(notesArea); + notesScrollPane.setBorder(BorderFactory.createTitledBorder("Notes")); + + // Table setup + String[] columnNames = {"Title", "Username", "Password", "URL", "Notes"}; + tableModel = new DefaultTableModel(columnNames, 0) { + @Override + public boolean isCellEditable(int row, int column) { + return false; + } + }; + entryTable = new JTable(tableModel); + entryTable.setAutoCreateRowSorter(true); + entryTable.getSelectionModel().addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) { + int selectedRow = entryTable.getSelectedRow(); + if (selectedRow >= 0) { + int modelRow = entryTable.convertRowIndexToModel(selectedRow); + notesArea.setText(currentEntries.get(modelRow).getNotes()); + } else { + notesArea.setText(""); + } + } + }); + entryTable.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + int row = entryTable.rowAtPoint(e.getPoint()); + int col = entryTable.columnAtPoint(e.getPoint()); + if (row >= 0 && col >= 0) { + int modelRow = entryTable.convertRowIndexToModel(row); + Entry entry = currentEntries.get(modelRow); + String content = switch (col) { + case 0 -> entry.getTitle(); + case 1 -> entry.getUsername(); + case 2 -> entry.getPassword(); + case 3 -> entry.getUrl(); + case 4 -> entry.getNotes(); + default -> ""; + }; + copyTextToClipboard(content, entryTable.getColumnName(col)); + } + } + } + + @Override + public void mousePressed(MouseEvent e) { + showPopupMenu(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + showPopupMenu(e); + } + + private void showPopupMenu(MouseEvent e) { + if (e.isPopupTrigger()) { + int row = entryTable.rowAtPoint(e.getPoint()); + if (row >= 0) { + entryTable.setRowSelectionInterval(row, row); + } + + JPopupMenu popup = new JPopupMenu(); + + JMenuItem addPop = new JMenuItem("Add Entry"); + JMenuItem editPop = new JMenuItem("Edit"); + JMenuItem copyPop = new JMenuItem("Copy Username"); + JMenuItem copyPassPop = new JMenuItem("Copy Password"); + JMenuItem delPop = new JMenuItem("Delete"); + + addPop.addActionListener(al -> addEntry()); + editPop.addActionListener(al -> editEntry()); + copyPop.addActionListener(al -> copyToClipboard(true)); + copyPassPop.addActionListener(al -> copyToClipboard(false)); + delPop.addActionListener(al -> deleteEntry()); + + popup.add(addPop); + popup.add(editPop); + popup.addSeparator(); + popup.add(copyPop); + popup.add(copyPassPop); + popup.addSeparator(); + popup.add(delPop); + + popup.show(e.getComponent(), e.getX(), e.getY()); + } + } + }); + JScrollPane tableScrollPane = new JScrollPane(entryTable); + + // Right side: Table and Notes + rightSplitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, tableScrollPane, notesScrollPane); + int rightDivider = prefs.getInt(PREF_RIGHT_DIVIDER, 250); + rightSplitPane.setDividerLocation(rightDivider); + rightSplitPane.addPropertyChangeListener(JSplitPane.DIVIDER_LOCATION_PROPERTY, e -> saveComponentState()); + + // Main Split pane + mainSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, treeScrollPane, rightSplitPane); + int mainDivider = prefs.getInt(PREF_MAIN_DIVIDER, 200); + mainSplitPane.setDividerLocation(mainDivider); + mainSplitPane.addPropertyChangeListener(JSplitPane.DIVIDER_LOCATION_PROPERTY, e -> saveComponentState()); + add(mainSplitPane, BorderLayout.CENTER); + + // Status bar setup + statusLabel = new JLabel(" Ready"); + statusLabel.setBorder(BorderFactory.createEmptyBorder(0, 5, 0, 5)); + + JButton lockBtn = new JButton("Lock"); + lockBtn.setFocusable(false); + lockBtn.setMargin(new Insets(0, 5, 0, 5)); + lockBtn.addActionListener(e -> lockDatabase()); + + JPanel statusBar = new JPanel(new BorderLayout()); + statusBar.setPreferredSize(new Dimension(getWidth(), 30)); + statusBar.setBorder(BorderFactory.createEtchedBorder()); + statusBar.add(statusLabel, BorderLayout.WEST); + statusBar.add(lockBtn, BorderLayout.EAST); + add(statusBar, BorderLayout.PAGE_END); + + // Tray diagnostic + if (!SystemTray.isSupported()) { + statusLabel.setText(" Ready (SysTray not supported - use Lock button)"); + } else if (trayIcon == null) { + statusLabel.setText(" Ready (SysTray icon failed)"); + } else { + statusLabel.setText(" Ready (SysTray active)"); + } + } + + /** + * Save current window position, size, and component layout to preferences + */ + private void saveWindowState() { + Point location = getLocation(); + Dimension size = getSize(); + prefs.putInt(PREF_WINDOW_X, location.x); + prefs.putInt(PREF_WINDOW_Y, location.y); + prefs.putInt(PREF_WINDOW_WIDTH, size.width); + prefs.putInt(PREF_WINDOW_HEIGHT, size.height); + } + + /** + * Save component layout (divider positions) to preferences + */ + private void saveComponentState() { + if (mainSplitPane != null) { + prefs.putInt(PREF_MAIN_DIVIDER, mainSplitPane.getDividerLocation()); + } + if (rightSplitPane != null) { + prefs.putInt(PREF_RIGHT_DIVIDER, rightSplitPane.getDividerLocation()); + } + } + + private void updateRecentMenu() { + recentMenu.removeAll(); + String recentFiles = prefs.get(PREF_RECENT_LIST, ""); + if (recentFiles.isEmpty()) { + JMenuItem noneItem = new JMenuItem("No recent files"); + noneItem.setEnabled(false); + recentMenu.add(noneItem); + } else { + String[] paths = recentFiles.split(";"); + for (String path : paths) { + if (path.isEmpty()) continue; + JMenuItem item = new JMenuItem(path); + item.addActionListener(e -> { + File file = new File(path); + if (file.exists()) { + openDatabase(file); + } else { + JOptionPane.showMessageDialog(this, "File not found: " + path, "Error", JOptionPane.ERROR_MESSAGE); + removeRecentFile(path); + } + }); + recentMenu.add(item); + } + } + } + + private void saveRecentFile(String path) { + String recentFiles = prefs.get(PREF_RECENT_LIST, ""); + List list = new ArrayList<>(List.of(recentFiles.split(";"))); + list.remove(""); // Clean up + list.remove(path); // Avoid duplicates + list.add(0, path); // Add to top + if (list.size() > MAX_RECENT_FILES) { + list = list.subList(0, MAX_RECENT_FILES); + } + prefs.put(PREF_RECENT_LIST, String.join(";", list)); + updateRecentMenu(); + } + + private void removeRecentFile(String path) { + String recentFiles = prefs.get(PREF_RECENT_LIST, ""); + List list = new ArrayList<>(List.of(recentFiles.split(";"))); + list.remove(path); + prefs.put(PREF_RECENT_LIST, String.join(";", list)); + updateRecentMenu(); + } + + private void openDatabase(File selectedFile) { + if (selectedFile == null) { + JFileChooser fileChooser = new JFileChooser(); + fileChooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("JKeepass Database (*.jkp)", "jkp")); + if (fileChooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + selectedFile = fileChooser.getSelectedFile(); + } else { + return; + } + } + + JPasswordField pf = new JPasswordField(); + pf.addAncestorListener(new AncestorListener() { + @Override + public void ancestorAdded(AncestorEvent event) { + SwingUtilities.invokeLater(pf::requestFocusInWindow); + } + @Override public void ancestorRemoved(AncestorEvent event) {} + @Override public void ancestorMoved(AncestorEvent event) {} + }); + int okCxl = JOptionPane.showConfirmDialog(this, pf, "Enter Master Password:", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE); + + if (okCxl == JOptionPane.OK_OPTION) { + String password = new String(pf.getPassword()); + try { + // Load JKP format + this.database = CustomDatabaseFormat.convertJkpToDatabase(selectedFile, password); + this.currentFile = selectedFile; + this.currentPassword = password; + updateTree(this.database.getRootGroup()); + saveRecentFile(selectedFile.getAbsolutePath()); + statusLabel.setText(" Database loaded: " + selectedFile.getName()); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Error loading database: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + ex.printStackTrace(); + } + } + } + + private void saveDatabase() { + if (database == null || currentFile == null || currentPassword == null) { + JOptionPane.showMessageDialog(this, "No database open to save.", "Warning", JOptionPane.WARNING_MESSAGE); + return; + } + try { + // Save as custom JKP format + CustomDatabaseFormat.saveDatabase(currentFile, database, currentPassword); + statusLabel.setText(" Database saved successfully."); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Error saving database: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + ex.printStackTrace(); + } + } + + private void lockDatabase() { + if (database == null) return; + + // Clear sensitive data + database = null; + currentGroup = null; + currentEntries.clear(); + tableModel.setRowCount(0); + notesArea.setText(""); + treeModel.setRoot(new DefaultMutableTreeNode("Database locked")); + + statusLabel.setText(" Database locked."); + + // Re-open password prompt + if (currentFile != null) { + File toOpen = currentFile; + // Clear current file/pass so we don't think it's still open + currentPassword = null; + currentFile = null; + openDatabase(toOpen); + } + } + + private void addEntry() { + if (currentGroup == null) { + JOptionPane.showMessageDialog(this, "Please select a folder first.", "Warning", JOptionPane.WARNING_MESSAGE); + return; + } + EntryDialog dialog = new EntryDialog(this, "Add Entry", null); + if (dialog.showDialog()) { + Entry entry = currentGroup.addEntry(database.newEntry()); + updateEntryFromDialog(entry, dialog); + displayEntriesInGroup(currentGroup); + statusLabel.setText(" Entry added. Remember to Save."); + } + } + + private void editEntry() { + int selectedRow = entryTable.getSelectedRow(); + if (selectedRow >= 0) { + int modelRow = entryTable.convertRowIndexToModel(selectedRow); + Entry entry = currentEntries.get(modelRow); + EntryDialog dialog = new EntryDialog(this, "Edit Entry", entry); + if (dialog.showDialog()) { + updateEntryFromDialog(entry, dialog); + displayEntriesInGroup(currentGroup); + statusLabel.setText(" Entry updated. Remember to Save."); + } + } else { + JOptionPane.showMessageDialog(this, "Please select an entry to edit.", "Warning", JOptionPane.WARNING_MESSAGE); + } + } + + private void deleteEntry() { + int selectedRow = entryTable.getSelectedRow(); + if (selectedRow >= 0) { + int confirm = JOptionPane.showConfirmDialog(this, "Are you sure you want to delete this entry?", "Confirm Delete", JOptionPane.YES_NO_OPTION); + if (confirm == JOptionPane.YES_OPTION) { + int modelRow = entryTable.convertRowIndexToModel(selectedRow); + Entry entry = currentEntries.get(modelRow); + currentGroup.removeEntry(entry); + displayEntriesInGroup(currentGroup); + statusLabel.setText(" Entry deleted. Remember to Save."); + } + } else { + JOptionPane.showMessageDialog(this, "Please select an entry to delete.", "Warning", JOptionPane.WARNING_MESSAGE); + } + } + + private void updateEntryFromDialog(Entry entry, EntryDialog dialog) { + entry.setTitle(dialog.getTitleText()); + entry.setUsername(dialog.getUsernameText()); + entry.setPassword(dialog.getPasswordText()); + entry.setUrl(dialog.getUrlText()); + entry.setNotes(dialog.getNotesText()); + } + + private void addCategory(Group parentGroup) { + String name = JOptionPane.showInputDialog(this, "Enter name for the new category:", "Add Category", JOptionPane.QUESTION_MESSAGE); + if (name != null && !name.trim().isEmpty()) { + parentGroup.addGroup(database.newGroup(name.trim())); + updateTree(database.getRootGroup()); + statusLabel.setText(" Category added. Remember to Save."); + } + } + + private void deleteCategory(Group group, DefaultMutableTreeNode node) { + // Get parent group from parent node + DefaultMutableTreeNode parentNode = (DefaultMutableTreeNode) node.getParent(); + if (parentNode == null || !(parentNode.getUserObject() instanceof GroupWrapper)) { + JOptionPane.showMessageDialog(this, "Cannot delete root category.", "Error", JOptionPane.ERROR_MESSAGE); + return; + } + + // Check if category has subgroups or entries + int subGroupCount = group.getGroups().size(); + int entryCount = group.getEntries().size(); + + String message = "Delete category \"" + group.getName() + "\"?"; + if (subGroupCount > 0) { + message += "\nThis category has " + subGroupCount + " subcategory/categories."; + } + if (entryCount > 0) { + message += "\nThis category has " + entryCount + " entry/entries."; + } + if (subGroupCount > 0 || entryCount > 0) { + message += "\n\nWarning: All contents will be deleted!"; + } + message += "\n\nThis action cannot be undone!"; + + int result = JOptionPane.showConfirmDialog(this, message, "Delete Category", + JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); + + if (result == JOptionPane.YES_OPTION) { + Group parentGroup = ((GroupWrapper) parentNode.getUserObject()).getGroup(); + parentGroup.removeGroup(group); + updateTree(database.getRootGroup()); + statusLabel.setText(" Category deleted. Remember to Save."); + } + } + + private void displayEntriesInGroup(Group group) { + this.currentGroup = group; + tableModel.setRowCount(0); + currentEntries.clear(); + for (Object entryObj : group.getEntries()) { + Entry entry = (Entry) entryObj; + currentEntries.add(entry); + tableModel.addRow(new Object[]{ + entry.getTitle(), + entry.getUsername(), + "********", + entry.getUrl(), + entry.getNotes() + }); + } + } + + /** + * Dialog for adding/editing entries + */ + private static class EntryDialog extends JDialog { + private final JTextField titleField = new JTextField(20); + private final JTextField userField = new JTextField(20); + private final JPasswordField passField = new JPasswordField(20); + private final JTextField urlField = new JTextField(20); + private final JTextArea notesArea = new JTextArea(5, 20); + private boolean confirmed = false; + + public EntryDialog(Frame owner, String title, Entry entry) { + super(owner, title, true); + setLayout(new BorderLayout()); + + titleField.addAncestorListener(new AncestorListener() { + @Override + public void ancestorAdded(AncestorEvent event) { + SwingUtilities.invokeLater(titleField::requestFocusInWindow); + } + @Override public void ancestorRemoved(AncestorEvent event) {} + @Override public void ancestorMoved(AncestorEvent event) {} + }); + + JPanel panel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(5, 5, 5, 5); + gbc.fill = GridBagConstraints.HORIZONTAL; + + gbc.gridx = 0; gbc.gridy = 0; + panel.add(new JLabel(" Title:"), gbc); + gbc.gridx = 1; gbc.gridwidth = 2; + panel.add(titleField, gbc); + + gbc.gridx = 0; gbc.gridy = 1; gbc.gridwidth = 1; + panel.add(new JLabel(" Username:"), gbc); + gbc.gridx = 1; gbc.gridwidth = 2; + panel.add(userField, gbc); + + gbc.gridx = 0; gbc.gridy = 2; gbc.gridwidth = 1; + panel.add(new JLabel(" Password:"), gbc); + gbc.gridx = 1; + panel.add(passField, gbc); + + JPanel passBtnOptionPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + JCheckBox showPassCheck = new JCheckBox("Show"); + showPassCheck.addActionListener(e -> { + if (showPassCheck.isSelected()) { + passField.setEchoChar((char) 0); + } else { + passField.setEchoChar('•'); + } + }); + JButton genBtn = new JButton("Generate"); + genBtn.addActionListener(e -> passField.setText(generateRandomPassword(16))); + passBtnOptionPanel.add(showPassCheck); + passBtnOptionPanel.add(genBtn); + gbc.gridx = 2; + panel.add(passBtnOptionPanel, gbc); + + gbc.gridx = 0; gbc.gridy = 3; gbc.gridwidth = 1; + panel.add(new JLabel(" URL:"), gbc); + gbc.gridx = 1; gbc.gridwidth = 2; + panel.add(urlField, gbc); + + if (entry != null) { + titleField.setText(entry.getTitle()); + userField.setText(entry.getUsername()); + passField.setText(entry.getPassword()); + urlField.setText(entry.getUrl()); + notesArea.setText(entry.getNotes()); + } + + add(panel, BorderLayout.NORTH); + JScrollPane scrollPane = new JScrollPane(notesArea); + scrollPane.setBorder(BorderFactory.createTitledBorder("Notes")); + add(scrollPane, BorderLayout.CENTER); + + JPanel btnPanel = new JPanel(); + JButton okBtn = new JButton("OK"); + JButton cancelBtn = new JButton("Cancel"); + okBtn.addActionListener(e -> { confirmed = true; setVisible(false); }); + cancelBtn.addActionListener(e -> setVisible(false)); + btnPanel.add(okBtn); + btnPanel.add(cancelBtn); + add(btnPanel, BorderLayout.SOUTH); + + pack(); + setLocationRelativeTo(owner); + } + + public boolean showDialog() { + setVisible(true); + return confirmed; + } + + public String getTitleText() { return titleField.getText(); } + public String getUsernameText() { return userField.getText(); } + public String getPasswordText() { return new String(passField.getPassword()); } + public String getUrlText() { return urlField.getText(); } + public String getNotesText() { return notesArea.getText(); } + + private String generateRandomPassword(int length) { + String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+"; + SecureRandom random = new SecureRandom(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < length; i++) { + sb.append(chars.charAt(random.nextInt(chars.length()))); + } + return sb.toString(); + } + } + + private void updateTree(Group rootGroup) { + DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode(new GroupWrapper(rootGroup)); + populateTreeRecursive(rootNode, rootGroup); + treeModel.setRoot(rootNode); + // Automatically select root and display its entries + groupTree.setSelectionRow(0); + displayEntriesInGroup(rootGroup); + } + + private void populateTreeRecursive(DefaultMutableTreeNode node, Group group) { + for (Object subGroupObj : group.getGroups()) { + Group subGroup = (Group) subGroupObj; + DefaultMutableTreeNode childNode = new DefaultMutableTreeNode(new GroupWrapper(subGroup)); + node.add(childNode); + populateTreeRecursive(childNode, subGroup); + } + } + + /** + * Helper class to display group name in JTree while keeping reference to Group object. + */ + private static class GroupWrapper { + private final Group group; + + public GroupWrapper(Group group) { + this.group = group; + } + + public Group getGroup() { + return group; + } + + @Override + public String toString() { + return group.getName(); + } + } + + private void copyToClipboard(boolean isUsername) { + int selectedRow = entryTable.getSelectedRow(); + if (selectedRow >= 0) { + int modelRow = entryTable.convertRowIndexToModel(selectedRow); + Entry entry = currentEntries.get(modelRow); + String text = isUsername ? entry.getUsername() : entry.getPassword(); + String label = isUsername ? "Username" : "Password"; + copyTextToClipboard(text, label); + } else { + JOptionPane.showMessageDialog(this, "Please select an entry first."); + } + } + + private void copyTextToClipboard(String text, String label) { + if (text != null) { + StringSelection selection = new StringSelection(text); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); + statusLabel.setText(" " + label + " copied to clipboard."); + Timer timer = new Timer(3000, e -> statusLabel.setText(" Ready")); + timer.setRepeats(false); + timer.start(); + } + } + + + + public static void main(String[] args) { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception ignored) {} + + SwingUtilities.invokeLater(() -> { + new KeepassApp().setVisible(true); + }); + } +} diff --git a/src/main/java/cz/kamma/jkeepass/model/Database.java b/src/main/java/cz/kamma/jkeepass/model/Database.java new file mode 100644 index 0000000..008feb8 --- /dev/null +++ b/src/main/java/cz/kamma/jkeepass/model/Database.java @@ -0,0 +1,24 @@ +package cz.kamma.jkeepass.model; + +/** + * Custom Database implementation (replaces KeePassJava2 dependency) + */ +public class Database { + private Group rootGroup; + + public Database() { + this.rootGroup = new Group("Root"); + } + + public Group getRootGroup() { + return rootGroup; + } + + public Entry newEntry() { + return new Entry(); + } + + public Group newGroup(String name) { + return new Group(name); + } +} diff --git a/src/main/java/cz/kamma/jkeepass/model/Entry.java b/src/main/java/cz/kamma/jkeepass/model/Entry.java new file mode 100644 index 0000000..c006377 --- /dev/null +++ b/src/main/java/cz/kamma/jkeepass/model/Entry.java @@ -0,0 +1,52 @@ +package cz.kamma.jkeepass.model; + +/** + * Custom Entry implementation (replaces KeePassJava2 dependency) + */ +public class Entry { + private String title = ""; + private String username = ""; + private String password = ""; + private String url = ""; + private String notes = ""; + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title != null ? title : ""; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username != null ? username : ""; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password != null ? password : ""; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url != null ? url : ""; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes != null ? notes : ""; + } +} diff --git a/src/main/java/cz/kamma/jkeepass/model/Group.java b/src/main/java/cz/kamma/jkeepass/model/Group.java new file mode 100644 index 0000000..f496862 --- /dev/null +++ b/src/main/java/cz/kamma/jkeepass/model/Group.java @@ -0,0 +1,51 @@ +package cz.kamma.jkeepass.model; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * Custom Group implementation (replaces KeePassJava2 dependency) + */ +public class Group { + private String name; + private List groups = new ArrayList<>(); + private List entries = new ArrayList<>(); + + public Group(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Collection getGroups() { + return new ArrayList<>(groups); + } + + public Collection getEntries() { + return new ArrayList<>(entries); + } + + public void addGroup(Group group) { + groups.add(group); + } + + public void removeGroup(Group group) { + groups.remove(group); + } + + public void removeEntry(Entry entry) { + entries.remove(entry); + } + + public Entry addEntry(Entry entry) { + entries.add(entry); + return entry; + } +} diff --git a/src/main/resources/icon.png b/src/main/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4f07b9d6b8d448105d3891ff8dfc5c5075a3c27 GIT binary patch literal 8813 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 Ud)s<~pN2pr2RHkMy_8@759mG?TmS$7 literal 0 HcmV?d00001