diff --git a/app/src/main/java/io/xpipe/app/prefs/KeePassClient.java b/app/src/main/java/io/xpipe/app/prefs/KeePassClient.java index 6e6699069..b68f3e1a5 100644 --- a/app/src/main/java/io/xpipe/app/prefs/KeePassClient.java +++ b/app/src/main/java/io/xpipe/app/prefs/KeePassClient.java @@ -19,8 +19,9 @@ public class KeePassClient { @SneakyThrows public static String receive(String key) { var client = getOrCreate(); - client.getDatabaseGroups(); - return client.getLogins("abc"); + var response = client.getLoginsMessage("https://redirect.pizza"); + var password = client.getPassword(response); + return password; } public static void reset() { diff --git a/app/src/main/java/io/xpipe/app/prefs/KeePassNativeClient.java b/app/src/main/java/io/xpipe/app/prefs/KeePassNativeClient.java index 41fb30a88..0e8be679a 100644 --- a/app/src/main/java/io/xpipe/app/prefs/KeePassNativeClient.java +++ b/app/src/main/java/io/xpipe/app/prefs/KeePassNativeClient.java @@ -1,12 +1,15 @@ package io.xpipe.app.prefs; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.type.TypeFactory; import io.xpipe.app.issue.ErrorEvent; import io.xpipe.app.util.ThreadHelper; +import io.xpipe.core.util.JacksonMapper; import lombok.Getter; +import lombok.SneakyThrows; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; @@ -15,10 +18,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -43,20 +42,33 @@ public class KeePassNativeClient { private TweetNaClHelper.KeyPair keyPair; private byte[] serverPublicKey; private boolean connected = false; - private boolean associated = false; - private Thread responseHandler; @Getter private KeePassAssociationKey associationKey; - - // Message buffer for handling requests/responses - private final MessageBuffer messageBuffer = new MessageBuffer(); - private final Object responseNotifier = new Object(); - - // Flag to indicate if key exchange is in progress - private volatile boolean keyExchangeInProgress = false; + public KeePassNativeClient(Path proxyExecutable) {this.proxyExecutable = proxyExecutable;} + + /** + * Extracts the action from a JSON response. + * + * @param response The JSON response + * @return The action, or null if not found + */ + private String extractAction(String response) { + try { + Pattern pattern = Pattern.compile("\"action\":\"([^\"]+)\""); + Matcher matcher = pattern.matcher(response); + + if (matcher.find()) { + return matcher.group(1); + } + } catch (Exception e) { + System.err.println("Error extracting action: " + e.getMessage()); + } + return null; + } + public void useExistingAssociationKey(KeePassAssociationKey key) { this.associationKey = key; } @@ -77,12 +89,7 @@ public class KeePassNativeClient { var pb = new ProcessBuilder(List.of(proxyExecutable.toString())); this.process = pb.start(); - - // Start a thread to handle responses - responseHandler = new Thread(this::handleResponses); - responseHandler.setDaemon(true); - responseHandler.start(); - + connected = true; } @@ -190,12 +197,14 @@ public class KeePassNativeClient { // Send the request String responseJson = sendRequest("test-associate", requestJson, TIMEOUT_TEST_ASSOCIATE); - if (responseJson == null) { - throw new IllegalStateException("No response received from associated instance"); - } // Parse and decrypt the response Map responseMap = jsonToMap(responseJson); + + if (responseMap.containsKey("error")) { + throw ErrorEvent.expected(new IllegalStateException(responseMap.get("error").toString())); + } + if (responseMap.containsKey("message") && responseMap.containsKey("nonce")) { String encryptedResponse = (String) responseMap.get("message"); String responseNonce = (String) responseMap.get("nonce"); @@ -205,10 +214,7 @@ public class KeePassNativeClient { Map parsedResponse = jsonToMap(decryptedResponse); boolean success = parsedResponse.containsKey("success") && "true".equals(parsedResponse.get("success").toString()); - - if (success) { - associated = true; - } else { + if (!success) { throw new IllegalStateException("KeePassXC association failed"); } } @@ -222,7 +228,7 @@ public class KeePassNativeClient { * @return The response JSON, or null if failed * @throws IOException If there's an error communicating with KeePassXC */ - public String getLogins(String url) throws IOException { + public String getLoginsMessage(String url) throws IOException { // Generate a nonce String nonce = TweetNaClHelper.encodeBase64(TweetNaClHelper.randomBytes(TweetNaClHelper.NONCE_SIZE)); @@ -240,9 +246,6 @@ public class KeePassNativeClient { // Encrypt the message String encryptedMessage = encrypt(messageData, nonce); - if (encryptedMessage == null) { - return null; - } // Build the request Map request = new HashMap<>(); @@ -252,39 +255,44 @@ public class KeePassNativeClient { request.put("clientID", clientId); String requestJson = mapToJson(request); - System.out.println("Sending get-logins message: " + requestJson); // Send the request String responseJson = sendRequest("get-logins", requestJson, TIMEOUT_GET_LOGINS); - if (responseJson == null) { - return null; + + Map responseMap = jsonToMap(responseJson); + if (responseMap.containsKey("error")) { + throw ErrorEvent.expected(new IllegalStateException(responseMap.get("error").toString())); + } + + if (responseMap.containsKey("message") && responseMap.containsKey("nonce")) { + String encryptedResponse = (String) responseMap.get("message"); + String responseNonce = (String) responseMap.get("nonce"); + return decrypt(encryptedResponse, responseNonce); } - // Parse and decrypt the response - try { - Map responseMap = jsonToMap(responseJson); - if (responseMap.containsKey("message") && responseMap.containsKey("nonce")) { - String encryptedResponse = (String) responseMap.get("message"); - String responseNonce = (String) responseMap.get("nonce"); - - return decrypt(encryptedResponse, responseNonce); - } - } catch (Exception e) { - System.err.println("Error processing get-logins response: " + e.getMessage()); + throw new IllegalStateException("Login query failed for an unknown reason"); + } + + public String getPassword(String message) throws IOException { + var tree = JacksonMapper.getDefault().readTree(message); + var count = tree.required("count").asInt(); + if (count == 0) { + throw ErrorEvent.expected(new IllegalArgumentException("No password was found for specified key")); } - - return null; + + if (count > 1) { + throw ErrorEvent.expected(new IllegalArgumentException("Password key is ambiguous and returned multiple results")); + } + + var object = (ObjectNode) tree.required("entries").get(0); + var password = object.required("password").asText(); + return password; } /** * Disconnects from KeePassXC. */ public void disconnect() { - if (responseHandler != null) { - responseHandler.interrupt(); - responseHandler = null; - } - process.destroy(); process = null; } @@ -299,43 +307,40 @@ public class KeePassNativeClient { * @throws IOException If there's an error communicating with KeePassXC */ private String sendRequest(String action, String message, long timeout) throws IOException { - String requestId = extractRequestId(message); - if (requestId == null) { - // If no requestId in the message, generate one for tracking - requestId = UUID.randomUUID().toString(); - } - - // Create a completable future for this request - CompletableFuture responseFuture = new CompletableFuture<>(); - - // Create a pending request and add it to the message buffer - PendingRequest request = new PendingRequest(requestId, action, responseFuture, timeout); - messageBuffer.addRequest(request); - // Send the message sendNativeMessage(message); - - // Notify the response handler that we've sent a message - synchronized (responseNotifier) { - responseNotifier.notify(); + + long startTime = System.currentTimeMillis(); + while (System.currentTimeMillis() - startTime < timeout) { + var response = receiveNativeMessage(); + if (filterResponse(action, response)) { + continue; + } + + return response; } - - try { - // Wait for the response with the specified timeout - return responseFuture.get(timeout, TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - System.err.println("Request interrupted: " + e.getMessage()); - return null; - } catch (ExecutionException e) { - System.err.println("Error in request execution: " + e.getMessage()); - return null; - } catch (TimeoutException e) { - System.err.println("Request timed out after " + timeout + "ms: " + action); - return null; - } finally { - // Clean up timed-out requests - messageBuffer.cleanupTimedOutRequests(); + throw new IllegalStateException("KeePassXC " + action + " request timed out"); + } + + private boolean filterResponse(String action, String response) { + System.out.println("Received response: " + response); + + // Extract action + String extractedAction = extractAction(response); + + // Special handling for action-specific responses + if ("database-locked".equals(extractedAction) || "database-unlocked".equals(extractedAction)) { + System.out.println("Database state changed: " + extractedAction); + // Update state based on the action + if ("database-locked".equals(extractedAction)) { + return true; + } + } + + if (action.equals(extractedAction)) { + return false; + } else { + return true; } } @@ -346,99 +351,19 @@ public class KeePassNativeClient { * @return The requestId, or null if not found */ private String extractRequestId(String message) { - return MessageBuffer.extractRequestId(message); - } - - /** - * Continuously reads and processes responses from KeePassXC. - */ - private void handleResponses() { try { - while (!Thread.currentThread().isInterrupted()) { - // If key exchange is in progress, skip normal message handling - if (keyExchangeInProgress) { - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - continue; - } - - // Check if there's anything to read - boolean hasData = false; - try { - hasData = process.getInputStream().available() > 0; - } catch (IOException e) { - System.err.println("Error checking input stream: " + e.getMessage()); - continue; - } - - if (hasData) { - try { - String response = receiveNativeMessage(); - if (response != null) { - processResponse(response); - } - } catch (IOException e) { - System.err.println("Error reading response: " + e.getMessage()); - } - } else { - // If nothing to read, wait efficiently - try { - synchronized (responseNotifier) { - responseNotifier.wait(100); // Wait up to 100ms for notification - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } - - // Periodically check for timed-out requests - messageBuffer.cleanupTimedOutRequests(); + Pattern pattern = Pattern.compile("\"requestId\":\"([^\"]+)\""); + Matcher matcher = pattern.matcher(message); + + if (matcher.find()) { + return matcher.group(1); } } catch (Exception e) { - System.err.println("Error in response handler: " + e.getMessage()); + System.err.println("Error extracting requestId: " + e.getMessage()); } + return null; } - - /** - * Process a response from KeePassXC. - * - * @param response The JSON response - */ - private void processResponse(String response) { - System.out.println("Received response: " + response); - - try { - // Extract action - String action = MessageBuffer.extractAction(response); - - // Special handling for action-specific responses - if ("database-locked".equals(action) || "database-unlocked".equals(action)) { - System.out.println("Database state changed: " + action); - // Update state based on the action - if ("database-locked".equals(action)) { - associated = false; - } - - // Notify any waiting requests - messageBuffer.handleResponse(response); - return; - } - - // Standard response handling - use the message buffer to complete the appropriate request - int completedCount = messageBuffer.handleResponse(response); - if (completedCount == 0) { - System.out.println("Warning: Response did not match any pending request: " + response); - } - } catch (Exception e) { - System.err.println("Error processing response: " + e.getMessage()); - } - } - + /** * Sends a message to KeePassXC using the native messaging protocol. * The message is prefixed with a 32-bit length (little-endian). @@ -490,70 +415,6 @@ public class KeePassNativeClient { return new String(messageBytes, StandardCharsets.UTF_8); } - /** - * Gets the database groups from KeePassXC. - * - * @return The JSON string containing the groups structure, or null if failed - * @throws IOException If there's an error communicating with KeePassXC - */ - public String getDatabaseGroups() throws IOException { - if (!connected) { - throw new IllegalStateException("Not connected to KeePassXC"); - } - - // Generate a nonce - String nonce = TweetNaClHelper.encodeBase64(TweetNaClHelper.randomBytes(TweetNaClHelper.NONCE_SIZE)); - - // Create the unencrypted message - Map messageData = new HashMap<>(); - messageData.put("action", "get-database-groups"); - - // Encrypt the message - String encryptedMessage = encrypt(messageData, nonce); - if (encryptedMessage == null) { - System.err.println("Failed to encrypt get-database-groups message"); - return null; - } - - // Build the request - Map request = new HashMap<>(); - request.put("action", "get-database-groups"); - request.put("message", encryptedMessage); - request.put("nonce", nonce); - request.put("clientID", clientId); - - String requestJson = mapToJson(request); - System.out.println("Sending get-database-groups message: " + requestJson); - - // Send the request - String responseJson = sendRequest("get-database-groups", requestJson, TIMEOUT_GET_DATABASE_GROUPS); - if (responseJson == null) { - System.err.println("No response received from get-database-groups"); - return null; - } - - // Parse and decrypt the response - try { - Map responseMap = jsonToMap(responseJson); - if (responseMap.containsKey("message") && responseMap.containsKey("nonce")) { - String encryptedResponse = (String) responseMap.get("message"); - String responseNonce = (String) responseMap.get("nonce"); - - String decryptedResponse = decrypt(encryptedResponse, responseNonce); - if (decryptedResponse != null) { - System.out.println("Received decrypted get-database-groups response: " + decryptedResponse); - return decryptedResponse; - } else { - System.err.println("Failed to decrypt get-database-groups response"); - } - } - } catch (Exception e) { - System.err.println("Error processing get-database-groups response: " + e.getMessage()); - } - - return null; - } - /** * Encrypts a message for sending to KeePassXC. * @@ -562,33 +423,18 @@ public class KeePassNativeClient { * @return The encrypted message, or null if encryption failed */ private String encrypt(Map message, String nonce) { - if (serverPublicKey == null) { - System.err.println("Server public key not available for encryption"); - return null; - } - - try { - String messageJson = mapToJson(message); - byte[] messageBytes = messageJson.getBytes(StandardCharsets.UTF_8); - byte[] nonceBytes = TweetNaClHelper.decodeBase64(nonce); - - byte[] encrypted = TweetNaClHelper.box( - messageBytes, - nonceBytes, - serverPublicKey, - keyPair.getSecretKey() - ); - - if (encrypted == null) { - System.err.println("Encryption failed"); - return null; - } - - return TweetNaClHelper.encodeBase64(encrypted); - } catch (Exception e) { - System.err.println("Error during encryption: " + e.getMessage()); - return null; - } + String messageJson = mapToJson(message); + byte[] messageBytes = messageJson.getBytes(StandardCharsets.UTF_8); + byte[] nonceBytes = TweetNaClHelper.decodeBase64(nonce); + + byte[] encrypted = TweetNaClHelper.box( + messageBytes, + nonceBytes, + serverPublicKey, + keyPair.getSecretKey() + ); + + return TweetNaClHelper.encodeBase64(encrypted); } /** @@ -599,32 +445,21 @@ public class KeePassNativeClient { * @return The decrypted message, or null if decryption failed */ private String decrypt(String encryptedMessage, String nonce) { - if (serverPublicKey == null) { - System.err.println("Server public key not available for decryption"); - return null; - } - - try { - byte[] messageBytes = TweetNaClHelper.decodeBase64(encryptedMessage); - byte[] nonceBytes = TweetNaClHelper.decodeBase64(nonce); - - byte[] decrypted = TweetNaClHelper.boxOpen( - messageBytes, - nonceBytes, - serverPublicKey, - keyPair.getSecretKey() - ); - - if (decrypted == null) { - System.err.println("Decryption failed"); - return null; - } - - return new String(decrypted, StandardCharsets.UTF_8); - } catch (Exception e) { - System.err.println("Error during decryption: " + e.getMessage()); - return null; + byte[] messageBytes = TweetNaClHelper.decodeBase64(encryptedMessage); + byte[] nonceBytes = TweetNaClHelper.decodeBase64(nonce); + + byte[] decrypted = TweetNaClHelper.boxOpen( + messageBytes, + nonceBytes, + serverPublicKey, + keyPair.getSecretKey() + ); + + if (decrypted == null) { + throw new IllegalArgumentException("Message decryption failed"); } + + return new String(decrypted, StandardCharsets.UTF_8); } /** @@ -633,7 +468,7 @@ public class KeePassNativeClient { * @return True if successful, false otherwise * @throws IOException If there's an error communicating with KeePassXC */ - public boolean associate() throws IOException { + public void associate() throws IOException { // Generate a key pair for identification TweetNaClHelper.KeyPair idKeyPair = TweetNaClHelper.generateKeyPair(); @@ -648,10 +483,7 @@ public class KeePassNativeClient { // Encrypt the message String encryptedMessage = encrypt(messageData, nonce); - if (encryptedMessage == null) { - return false; - } - + // Build the request Map request = new HashMap<>(); request.put("action", "associate"); @@ -660,177 +492,54 @@ public class KeePassNativeClient { request.put("clientID", clientId); String requestJson = mapToJson(request); - System.out.println("Sending associate message: " + requestJson); - // Send the request using longer timeout as it requires user interaction String responseJson = sendRequest("associate", requestJson, TIMEOUT_ASSOCIATE); - if (responseJson == null) { - return false; - } - - // Parse and decrypt the response - try { - Map responseMap = jsonToMap(responseJson); - if (responseMap.containsKey("message") && responseMap.containsKey("nonce")) { - String encryptedResponse = (String) responseMap.get("message"); - String responseNonce = (String) responseMap.get("nonce"); - - String decryptedResponse = decrypt(encryptedResponse, responseNonce); - if (decryptedResponse != null) { - Map parsedResponse = jsonToMap(decryptedResponse); - boolean success = parsedResponse.containsKey("success") && - "true".equals(parsedResponse.get("success").toString()); - - if (success && parsedResponse.containsKey("id") && parsedResponse.containsKey("hash")) { - String id = (String) parsedResponse.get("id"); - String hash = (String) parsedResponse.get("hash"); - associationKey = new KeePassAssociationKey(id, TweetNaClHelper.encodeBase64(idKeyPair.getPublicKey()), hash); - associated = true; - - System.out.println("Association successful"); - System.out.println("Database ID: " + id); - System.out.println("Database hash: " + hash); - - return true; - } - } - } - } catch (Exception e) { - System.err.println("Error processing associate response: " + e.getMessage()); + Map responseMap = jsonToMap(responseJson); + + if (responseMap.containsKey("error")) { + throw ErrorEvent.expected(new IllegalStateException(responseMap.get("error").toString())); } - - return false; + + if (responseMap.containsKey("message") && responseMap.containsKey("nonce")) { + String encryptedResponse = (String) responseMap.get("message"); + String responseNonce = (String) responseMap.get("nonce"); + + String decryptedResponse = decrypt(encryptedResponse, responseNonce); + Map parsedResponse = jsonToMap(decryptedResponse); + boolean success = parsedResponse.containsKey("success") && + "true".equals(parsedResponse.get("success").toString()); + + if (success && parsedResponse.containsKey("id") && parsedResponse.containsKey("hash")) { + String id = (String) parsedResponse.get("id"); + String hash = (String) parsedResponse.get("hash"); + + associationKey = new KeePassAssociationKey(id, TweetNaClHelper.encodeBase64(idKeyPair.getPublicKey()), hash); + + return; + } + } + + throw new IllegalStateException("KeePassXC association failed"); } /** * Convert a map to a JSON string. */ + @SneakyThrows private String mapToJson(Map map) { - StringBuilder sb = new StringBuilder(); - sb.append("{"); - - boolean first = true; - for (Map.Entry entry : map.entrySet()) { - if (!first) { - sb.append(","); - } - first = false; - - sb.append("\"").append(entry.getKey()).append("\":"); - - Object value = entry.getValue(); - if (value instanceof String) { - sb.append("\"").append(escapeJsonString((String) value)).append("\""); - } else if (value instanceof Number || value instanceof Boolean) { - sb.append(value); - } else if (value instanceof Map[]) { - sb.append("["); - Map[] maps = (Map[]) value; - for (int i = 0; i < maps.length; i++) { - if (i > 0) { - sb.append(","); - } - sb.append(mapToJson(maps[i])); - } - sb.append("]"); - } else if (value == null) { - sb.append("null"); - } else { - sb.append("\"").append(escapeJsonString(value.toString())).append("\""); - } - } - - sb.append("}"); - return sb.toString(); - } - - /** - * Escape special characters in a JSON string. - */ - private String escapeJsonString(String s) { - if (s == null) { - return ""; - } - - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < s.length(); i++) { - char ch = s.charAt(i); - switch (ch) { - case '"': - sb.append("\\\""); - break; - case '\\': - sb.append("\\\\"); - break; - case '\b': - sb.append("\\b"); - break; - case '\f': - sb.append("\\f"); - break; - case '\n': - sb.append("\\n"); - break; - case '\r': - sb.append("\\r"); - break; - case '\t': - sb.append("\\t"); - break; - default: - sb.append(ch); - } - } - return sb.toString(); + var mapper = JacksonMapper.getDefault(); + return mapper.writeValueAsString(map); } /** * Convert a JSON string to a map. */ + @SneakyThrows private Map jsonToMap(String json) { - Map map = new HashMap<>(); - - try { - // Use regex to extract key-value pairs - Pattern pattern = Pattern.compile("\"([^\"]+)\"\\s*:\\s*(\"[^\"]*\"|\\d+|true|false|null|\\{[^}]*\\}|\\[[^\\]]*\\])"); - Matcher matcher = pattern.matcher(json); - - while (matcher.find()) { - String key = matcher.group(1); - String valueStr = matcher.group(2); - - // Parse the value based on its format - Object value; - if (valueStr.startsWith("\"") && valueStr.endsWith("\"")) { - // String value - value = valueStr.substring(1, valueStr.length() - 1); - } else if ("true".equals(valueStr) || "false".equals(valueStr)) { - // Boolean value - value = Boolean.parseBoolean(valueStr); - } else if ("null".equals(valueStr)) { - // Null value - value = null; - } else { - try { - // Number value - value = Integer.parseInt(valueStr); - } catch (NumberFormatException e1) { - try { - value = Double.parseDouble(valueStr); - } catch (NumberFormatException e2) { - // Just use the string as is - value = valueStr; - } - } - } - - map.put(key, value); - } - } catch (Exception e) { - System.err.println("Error parsing JSON: " + e.getMessage()); - } - + var mapper = JacksonMapper.getDefault(); + var type = TypeFactory.defaultInstance().constructType(new TypeReference<>() {}); + Map map = mapper.readValue(json, type); return map; } } \ No newline at end of file diff --git a/app/src/main/java/io/xpipe/app/prefs/MessageBuffer.java b/app/src/main/java/io/xpipe/app/prefs/MessageBuffer.java deleted file mode 100644 index cbd8043c2..000000000 --- a/app/src/main/java/io/xpipe/app/prefs/MessageBuffer.java +++ /dev/null @@ -1,312 +0,0 @@ -package io.xpipe.app.prefs; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Manages pending requests to KeePassXC. - * This class tracks all pending requests and provides methods to add, complete, and cancel requests. - */ -public class MessageBuffer { - private final Map requestsById; - private final Map> requestsByAction; - private final Object lock = new Object(); - - /** - * Creates a new message buffer. - */ - public MessageBuffer() { - this.requestsById = new ConcurrentHashMap<>(); - this.requestsByAction = new ConcurrentHashMap<>(); - } - - /** - * Adds a new pending request to the buffer. - * - * @param request The request to add - */ - public void addRequest(PendingRequest request) { - synchronized (lock) { - requestsById.put(request.getRequestId(), request); - - requestsByAction.computeIfAbsent(request.getAction(), k -> new ArrayList<>()) - .add(request); - } - } - - /** - * Gets a request by its ID. - * - * @param requestId The request ID - * @return The request, or null if not found - */ - public PendingRequest getRequestById(String requestId) { - synchronized (lock) { - return requestsById.get(requestId); - } - } - - /** - * Gets all pending requests for a specific action. - * - * @param action The action - * @return A list of pending requests, or an empty list if none found - */ - public List getRequestsByAction(String action) { - synchronized (lock) { - List requests = requestsByAction.get(action); - if (requests == null) { - return new ArrayList<>(); - } - return new ArrayList<>(requests); // Return a copy to avoid concurrent modification - } - } - - /** - * Completes a request with the given response. - * - * @param requestId The request ID - * @param response The response from KeePassXC - * @return True if the request was completed, false if not found or already completed - */ - public boolean completeRequest(String requestId, String response) { - synchronized (lock) { - PendingRequest request = requestsById.get(requestId); - if (request == null) { - return false; - } - - boolean completed = request.complete(response); - if (completed) { - removeRequest(request); - } - return completed; - } - } - - /** - * Completes all pending requests for a specific action. - * This is useful for action-specific responses that don't include a request ID. - * - * @param action The action - * @param response The response from KeePassXC - * @return The number of requests that were completed - */ - public int completeRequestsByAction(String action, String response) { - synchronized (lock) { - List requests = requestsByAction.get(action); - if (requests == null || requests.isEmpty()) { - return 0; - } - - int count = 0; - List completedRequests = new ArrayList<>(); - - for (PendingRequest request : requests) { - if (request.complete(response)) { - completedRequests.add(request); - count++; - } - } - - // Remove completed requests - for (PendingRequest request : completedRequests) { - removeRequest(request); - } - - return count; - } - } - - /** - * Times out a request. - * - * @param requestId The request ID - * @return True if the request was timed out, false if not found or already completed - */ - public boolean timeoutRequest(String requestId) { - synchronized (lock) { - PendingRequest request = requestsById.get(requestId); - if (request == null) { - return false; - } - - boolean timedOut = request.timeout(); - if (timedOut) { - removeRequest(request); - } - return timedOut; - } - } - - /** - * Cancels a request. - * - * @param requestId The request ID - * @param reason The reason for cancellation - * @return True if the request was cancelled, false if not found or already completed - */ - public boolean cancelRequest(String requestId, String reason) { - synchronized (lock) { - PendingRequest request = requestsById.get(requestId); - if (request == null) { - return false; - } - - boolean cancelled = request.cancel(reason); - if (cancelled) { - removeRequest(request); - } - return cancelled; - } - } - - /** - * Removes a request from the buffer. - * - * @param request The request to remove - */ - private void removeRequest(PendingRequest request) { - requestsById.remove(request.getRequestId()); - - List actionRequests = requestsByAction.get(request.getAction()); - if (actionRequests != null) { - actionRequests.remove(request); - if (actionRequests.isEmpty()) { - requestsByAction.remove(request.getAction()); - } - } - } - - /** - * Cleans up timed-out requests. - * - * @return The number of requests that were timed out - */ - public int cleanupTimedOutRequests() { - synchronized (lock) { - List timedOutRequests = new ArrayList<>(); - - for (PendingRequest request : requestsById.values()) { - if (request.isTimedOut()) { - request.timeout(); - timedOutRequests.add(request); - } - } - - // Remove timed-out requests - for (PendingRequest request : timedOutRequests) { - removeRequest(request); - } - - return timedOutRequests.size(); - } - } - - /** - * Gets the number of pending requests. - * - * @return The number of pending requests - */ - public int getPendingRequestCount() { - synchronized (lock) { - return requestsById.size(); - } - } - - /** - * Extracts the request ID from a JSON response. - * - * @param response The JSON response - * @return The request ID, or null if not found - */ - public static String extractRequestId(String response) { - try { - Pattern pattern = Pattern.compile("\"requestId\":\"([^\"]+)\""); - Matcher matcher = pattern.matcher(response); - - if (matcher.find()) { - return matcher.group(1); - } - } catch (Exception e) { - System.err.println("Error extracting requestId: " + e.getMessage()); - } - return null; - } - - /** - * Extracts the action from a JSON response. - * - * @param response The JSON response - * @return The action, or null if not found - */ - public static String extractAction(String response) { - try { - Pattern pattern = Pattern.compile("\"action\":\"([^\"]+)\""); - Matcher matcher = pattern.matcher(response); - - if (matcher.find()) { - return matcher.group(1); - } - } catch (Exception e) { - System.err.println("Error extracting action: " + e.getMessage()); - } - return null; - } - - /** - * Extracts the nonce from a JSON response. - * - * @param response The JSON response - * @return The nonce, or null if not found - */ - public static String extractNonce(String response) { - try { - Pattern pattern = Pattern.compile("\"nonce\":\"([^\"]+)\""); - Matcher matcher = pattern.matcher(response); - - if (matcher.find()) { - return matcher.group(1); - } - } catch (Exception e) { - System.err.println("Error extracting nonce: " + e.getMessage()); - } - return null; - } - - /** - * Handles an incoming response from KeePassXC. - * This method attempts to match the response to a pending request and complete it. - * - * @param response The JSON response from KeePassXC - * @return The number of requests that were completed - */ - public int handleResponse(String response) { - if (response == null || response.isEmpty()) { - return 0; - } - - synchronized (lock) { - String requestId = extractRequestId(response); - String action = extractAction(response); - - if (requestId != null) { - // Try to complete by request ID first - if (completeRequest(requestId, response)) { - return 1; - } - } - - if (action != null) { - // Then try to complete by action - return completeRequestsByAction(action, response); - } - - return 0; - } - } -} \ No newline at end of file diff --git a/app/src/main/java/io/xpipe/app/prefs/PendingRequest.java b/app/src/main/java/io/xpipe/app/prefs/PendingRequest.java deleted file mode 100644 index a14ef3beb..000000000 --- a/app/src/main/java/io/xpipe/app/prefs/PendingRequest.java +++ /dev/null @@ -1,152 +0,0 @@ -package io.xpipe.app.prefs; - -import java.util.concurrent.CompletableFuture; - -/** - * Represents a pending request to KeePassXC. - * This class tracks the request details and provides methods to complete or cancel the request. - */ -public class PendingRequest { - private final String requestId; - private final String action; - private final CompletableFuture future; - private final long timestamp; - private final long timeout; - private boolean completed; - - /** - * Creates a new pending request. - * - * @param requestId The unique ID of the request - * @param action The action being performed (e.g., "associate", "get-logins") - * @param future The CompletableFuture that will be completed when the response is received - * @param timeout The timeout in milliseconds - */ - public PendingRequest(String requestId, String action, CompletableFuture future, long timeout) { - this.requestId = requestId; - this.action = action; - this.future = future; - this.timeout = timeout; - this.timestamp = System.currentTimeMillis(); - this.completed = false; - } - - /** - * Gets the request ID. - * - * @return The request ID - */ - public String getRequestId() { - return requestId; - } - - /** - * Gets the action. - * - * @return The action - */ - public String getAction() { - return action; - } - - /** - * Gets the completable future. - * - * @return The future - */ - public CompletableFuture getFuture() { - return future; - } - - /** - * Gets the timestamp when the request was created. - * - * @return The timestamp in milliseconds - */ - public long getTimestamp() { - return timestamp; - } - - /** - * Gets the timeout duration. - * - * @return The timeout in milliseconds - */ - public long getTimeout() { - return timeout; - } - - /** - * Checks if the request is completed. - * - * @return True if the request is completed, false otherwise - */ - public boolean isCompleted() { - return completed; - } - - /** - * Completes the request with the given response. - * - * @param response The response from KeePassXC - * @return True if the request was completed, false if it was already completed - */ - public boolean complete(String response) { - if (completed) { - return false; - } - - completed = true; - future.complete(response); - return true; - } - - /** - * Completes the request exceptionally with a timeout. - * - * @return True if the request was completed, false if it was already completed - */ - public boolean timeout() { - if (completed) { - return false; - } - - completed = true; - future.completeExceptionally(new TimeoutException("Request timed out after " + timeout + "ms")); - return true; - } - - /** - * Cancels the request. - * - * @param reason The reason for cancellation - * @return True if the request was cancelled, false if it was already completed - */ - public boolean cancel(String reason) { - if (completed) { - return false; - } - - completed = true; - future.completeExceptionally(new RuntimeException("Request cancelled: " + reason)); - return true; - } - - /** - * Checks if the request has timed out. - * - * @return True if the request has timed out, false otherwise - */ - public boolean isTimedOut() { - return !completed && System.currentTimeMillis() - timestamp > timeout; - } - - /** - * Exception class for request timeouts. - */ - public static class TimeoutException extends Exception { - public TimeoutException(String message) { - super(message); - } - } -} \ No newline at end of file