branch: elpa/jabber
commit a01e2c71b823132bd5e7da731e50e354ba918b68
Author: Talos Apollo <[email protected]>
Commit: Talos Apollo <[email protected]>

    chat: Offer saving unsupported images
---
 lisp/jabber-chat.el        | 109 +++++++++++++++++++++++++++++++++------------
 lisp/jabber-image.el       |  65 ++++++++++++++++++---------
 tests/jabber-test-chat.el  |  68 ++++++++++++++++++++++++++--
 tests/jabber-test-image.el |  11 +++++
 4 files changed, 202 insertions(+), 51 deletions(-)

diff --git a/lisp/jabber-chat.el b/lisp/jabber-chat.el
index d0df6c5742..280ff09971 100644
--- a/lisp/jabber-chat.el
+++ b/lisp/jabber-chat.el
@@ -1655,44 +1655,68 @@ not a valid aesgcm:// URL.  The fragment must be 88 hex 
characters
             :iv iv
             :key key))))
 
+(defun jabber-chat--aesgcm-image-result-from-body
+    (encrypted key iv allowed-types)
+  "Decrypt ENCRYPTED and return an image result.
+KEY, IV, and ALLOWED-TYPES follow
+`jabber-chat--aesgcm-image-from-body'."
+  (cond ((null encrypted) (list :error 'response))
+        ((not (jabber-image--size-ok-p encrypted)) (list :error 'size))
+        (t
+         (condition-case err
+             (jabber-image--result-from-data
+              (jabber-omemo-aesgcm-decrypt key iv encrypted)
+              allowed-types)
+           (error
+            (list :error 'decrypt
+                  :message (error-message-string err)))))))
+
 (defun jabber-chat--aesgcm-image-from-body (encrypted key iv allowed-types)
   "Decrypt ENCRYPTED with KEY and IV and build an inline image.
 Return nil when ENCRYPTED is missing or exceeds
 `jabber-image-max-bytes', decryption fails, or the plaintext
 fails the ALLOWED-TYPES check per `jabber-image-from-data'."
-  (and encrypted
-       (jabber-image--size-ok-p encrypted)
-       (and-let* ((plaintext (condition-case err
-                                 (jabber-omemo-aesgcm-decrypt key iv encrypted)
-                               (error
-                                (message "aesgcm: decryption failed: %s"
-                                         (error-message-string err))
-                                nil))))
-         (jabber-image-from-data plaintext allowed-types))))
-
-(defun jabber-chat--fetch-aesgcm-image (url allowed-types callback &rest 
cbargs)
-  "Fetch and decrypt an aesgcm:// image URL.
-Downloads via HTTPS, decrypts with AES-256-GCM, and calls
-CALLBACK with the created image (or nil) followed by CBARGS.
-ALLOWED-TYPES and `jabber-image-max-bytes' are enforced per
-`jabber-image-from-data'."
+  (let ((result (jabber-chat--aesgcm-image-result-from-body
+                 encrypted key iv allowed-types)))
+    (when (eq (plist-get result :error) 'decrypt)
+      (message "aesgcm: decryption failed: %s" (plist-get result :message)))
+    (plist-get result :image)))
+
+(defun jabber-chat--fetch-aesgcm-image-result
+    (url allowed-types callback &rest cbargs)
+  "Fetch URL and call CALLBACK with a decrypted image result and CBARGS.
+ALLOWED-TYPES restricts the decoded image types; nil permits any."
   (let ((parsed (jabber-chat--parse-aesgcm-url url)))
     (if (null parsed)
-        (apply callback nil cbargs)
+        (apply callback (list :error 'url) cbargs)
       (url-queue-retrieve
        (plist-get parsed :https-url)
        (lambda (status key iv types cb args)
          (let ((url-buffer (current-buffer))
-               (image (unless (plist-get status :error)
-                        (jabber-chat--aesgcm-image-from-body
-                         (jabber-image--response-body) key iv types))))
+               (result
+                (if (plist-get status :error)
+                    (list :error 'fetch)
+                  (jabber-chat--aesgcm-image-result-from-body
+                   (jabber-image--response-body) key iv types))))
            (kill-buffer url-buffer)
-           (apply cb image args)))
+           (apply cb result args)))
        (list (plist-get parsed :key) (plist-get parsed :iv)
              allowed-types callback cbargs)
        'silent
        'inhibit-cookies))))
 
+(defun jabber-chat--fetch-aesgcm-image (url allowed-types callback &rest 
cbargs)
+  "Fetch and decrypt an aesgcm:// image URL.
+Downloads via HTTPS, decrypts with AES-256-GCM, and calls
+CALLBACK with the created image (or nil) followed by CBARGS.
+ALLOWED-TYPES and `jabber-image-max-bytes' are enforced per
+`jabber-image-from-data'."
+  (apply #'jabber-chat--fetch-aesgcm-image-result
+         url allowed-types
+         (lambda (result cb args)
+           (apply cb (plist-get result :image) args))
+         callback cbargs))
+
 (defconst jabber-chat--image-extension-types
   '(("png"  . png)
     ("jpg"  . jpeg)
@@ -1814,7 +1838,7 @@ not `jabber-image-max-bytes'."
           ((jabber-chat--restore-cached-image url beg end))
           (t
            (let ((inhibit-read-only t))
-             (jabber-chat--start-image-fetch url beg end nil))
+             (jabber-chat--start-image-fetch url beg end nil t))
            (message "Loading image...")))))
 
 (defun jabber-chat-url-action-at-point (&optional arg)
@@ -2061,6 +2085,33 @@ manual loading with RET still may."
                  (put-text-property beg end 'jabber-chat-image-fetching
                                     'failed))))))))
 
+(defun jabber-chat--offer-image-save (url data)
+  "Offer to save fetched DATA from URL without another download."
+  (when (y-or-n-p "Cannot display image; save it instead? ")
+    (let* ((parsed (and (string-prefix-p "aesgcm://" url)
+                        (jabber-chat--parse-aesgcm-url url)))
+           (save-url (if parsed (plist-get parsed :https-url) url))
+           (dest (jabber-chat--download-destination save-url)))
+      (jabber-chat--record-download-directory dest)
+      (with-temp-file dest
+        (set-buffer-multibyte nil)
+        (insert data))
+      (message "Downloaded %s" dest))))
+
+(defun jabber-chat--handle-image-result
+    (result url beg end buffer manual)
+  "Handle fetched image RESULT for URL between BEG and END in BUFFER.
+When MANUAL is non-nil, offer retained bytes after a decode failure."
+  (when (and (buffer-live-p buffer)
+             (with-current-buffer buffer
+               (jabber-chat--url-markers-valid-p beg end url buffer)))
+    (jabber-chat--replace-url-with-image
+     (plist-get result :image) url beg end buffer)
+    (when (and manual
+               (eq (plist-get result :error) 'decode)
+               (plist-get result :data))
+      (jabber-chat--offer-image-save url (plist-get result :data)))))
+
 (defvar-local jabber-chat--image-scan-timer nil
   "Idle timer for scanning image URLs in this buffer.")
 
@@ -2115,21 +2166,23 @@ Return the possibly shifted bounds as a cons cell."
            (insert "\n"))
          (cons (1+ beg) (1+ end)))))
 
-(defun jabber-chat--start-image-fetch (url beg end allowed-types)
+(defun jabber-chat--start-image-fetch
+    (url beg end allowed-types &optional manual)
   "Fetch image URL and display it over BEG..END in the current buffer.
 ALLOWED-TYPES restricts decoding per `jabber-image-from-data';
-nil permits any supported type."
+nil permits any supported type.  MANUAL is non-nil for a fetch
+explicitly requested by the user."
   (jabber-chat--mark-image-fetching beg end url)
   (let ((beg (copy-marker beg))
         (end (copy-marker end))
         (buf (current-buffer)))
     (if (string-prefix-p "aesgcm://" url)
-        (jabber-chat--fetch-aesgcm-image
+        (jabber-chat--fetch-aesgcm-image-result
          url allowed-types
-         #'jabber-chat--replace-url-with-image url beg end buf)
-      (jabber-image-fetch
+         #'jabber-chat--handle-image-result url beg end buf manual)
+      (jabber-image--fetch-result
        url allowed-types
-       #'jabber-chat--replace-url-with-image url beg end buf))))
+       #'jabber-chat--handle-image-result url beg end buf manual))))
 
 (defun jabber-chat--scan-image-url (url beg end auto)
   "Process one image URL between BEG and END found by the scan.
diff --git a/lisp/jabber-image.el b/lisp/jabber-image.el
index 66b3e90384..857da41474 100644
--- a/lisp/jabber-image.el
+++ b/lisp/jabber-image.el
@@ -103,20 +103,33 @@ ALLOWED-TYPES is a list of image type symbols; nil 
permits any."
   (or (null allowed-types)
       (memq (image-type-from-data data) allowed-types)))
 
+(defun jabber-image--result-from-data (data allowed-types)
+  "Return an image decoding result for DATA and ALLOWED-TYPES.
+The result contains either :image, or an :error symbol.  Decode
+failures also retain DATA under :data so an explicit user action
+can offer to save the payload without fetching it again."
+  (cond ((null data) (list :error 'response))
+        ((not (jabber-image--size-ok-p data)) (list :error 'size))
+        ((not (jabber-image--type-ok-p data allowed-types))
+         (list :error 'type))
+        (t
+         (condition-case err
+             (list :image (jabber-image-create data))
+           (error
+            (list :error 'decode
+                  :message (error-message-string err)
+                  :data data))))))
+
 (defun jabber-image-from-data (data &optional allowed-types)
   "Create a dynamically-sized image from raw DATA string.
 Return nil when DATA exceeds `jabber-image-max-bytes', its
 detected type is not in ALLOWED-TYPES (nil permits any), or it
 cannot be decoded."
-  (and data
-       (jabber-image--size-ok-p data)
-       (jabber-image--type-ok-p data allowed-types)
-       (condition-case err
-           (jabber-image-create data)
-         (error
-          (message "jabber-image: failed to decode image: %s"
-                   (error-message-string err))
-          nil))))
+  (let ((result (jabber-image--result-from-data data allowed-types)))
+    (when (eq (plist-get result :error) 'decode)
+      (message "jabber-image: failed to decode image: %s"
+               (plist-get result :message)))
+    (plist-get result :image)))
 
 (defun jabber-image--response-body ()
   "Return the HTTP response body of the current buffer, or nil.
@@ -127,26 +140,38 @@ separator is found."
   (when (re-search-forward "\r?\n\r?\n" nil t)
     (buffer-substring-no-properties (point) (point-max))))
 
-(defun jabber-image-fetch (url allowed-types callback &rest cbargs)
-  "Fetch image at URL asynchronously.
-When complete, call CALLBACK with the image object (or nil on
-error) followed by CBARGS.  The image must satisfy
-`jabber-image-max-bytes' and ALLOWED-TYPES per
-`jabber-image-from-data'; it is sized per
-`jabber-image-max-width' and `jabber-image-max-height'."
+(defun jabber-image--fetch-result (url allowed-types callback &rest cbargs)
+  "Fetch URL and call CALLBACK with an image result and CBARGS.
+ALLOWED-TYPES restricts decoded image types; nil permits any.
+The result follows `jabber-image--result-from-data'.  Transport
+failures use the error symbol `fetch'."
   (url-queue-retrieve
    url
    (lambda (status types cb args)
      (let ((url-buffer (current-buffer))
-           (image (unless (plist-get status :error)
-                    (jabber-image-from-data
-                     (jabber-image--response-body) types))))
+           (result (if (plist-get status :error)
+                       (list :error 'fetch)
+                     (jabber-image--result-from-data
+                      (jabber-image--response-body) types))))
        (kill-buffer url-buffer)
-       (apply cb image args)))
+       (apply cb result args)))
    (list allowed-types callback cbargs)
    'silent
    'inhibit-cookies))
 
+(defun jabber-image-fetch (url allowed-types callback &rest cbargs)
+  "Fetch image at URL asynchronously.
+When complete, call CALLBACK with the image object (or nil on
+error) followed by CBARGS.  The image must satisfy
+`jabber-image-max-bytes' and ALLOWED-TYPES per
+`jabber-image-from-data'; it is sized per
+`jabber-image-max-width' and `jabber-image-max-height'."
+  (apply #'jabber-image--fetch-result
+         url allowed-types
+         (lambda (result cb args)
+           (apply cb (plist-get result :image) args))
+         callback cbargs))
+
 (provide 'jabber-image)
 
 ;;; jabber-image.el ends here
diff --git a/tests/jabber-test-chat.el b/tests/jabber-test-chat.el
index 8671eb9feb..2b3cfc7111 100644
--- a/tests/jabber-test-chat.el
+++ b/tests/jabber-test-chat.el
@@ -1106,13 +1106,24 @@ JC is a fake connection from 
`jabber-test-chat--make-fake-jc'."
 (ert-deftest jabber-test-chat-aesgcm-image-threads-allowed-types ()
   (cl-letf (((symbol-function 'jabber-omemo-aesgcm-decrypt)
              (lambda (&rest _) "plaintext"))
-            ((symbol-function 'jabber-image-from-data)
-             (lambda (data types) (list data types))))
+            ((symbol-function 'jabber-image--result-from-data)
+             (lambda (data types) (list :image (list data types)))))
     (let ((jabber-image-max-bytes nil))
       (should (equal (jabber-chat--aesgcm-image-from-body
                       "ct" "key" "iv" '(png))
                      '("plaintext" (png)))))))
 
+(ert-deftest jabber-test-chat-aesgcm-decode-failure-retains-plaintext ()
+  "An unsupported decrypted payload remains available for manual saving."
+  (cl-letf (((symbol-function 'jabber-omemo-aesgcm-decrypt)
+             (lambda (&rest _) "decrypted-heic"))
+            ((symbol-function 'jabber-image--result-from-data)
+             (lambda (data _types) (list :error 'decode :data data))))
+    (let ((result (jabber-chat--aesgcm-image-result-from-body
+                   "ciphertext" "key" "iv" nil)))
+      (should (eq (plist-get result :error) 'decode))
+      (should (equal (plist-get result :data) "decrypted-heic")))))
+
 (ert-deftest jabber-test-chat-aesgcm-image-nil-body-returns-nil ()
   (should-not (jabber-chat--aesgcm-image-from-body nil "key" "iv" nil)))
 
@@ -1289,7 +1300,7 @@ and `url' to the URL; `display-graphic-p' is stubbed to 
t."
   "Manual load passes nil ALLOWED-TYPES to the fetch."
   (jabber-test-chat--with-manual-load-buffer
    (jabber-chat--load-image-at-point)
-   (should (equal fetches (list (list url 1 (point-max) nil))))))
+   (should (equal fetches (list (list url 1 (point-max) nil t))))))
 
 (ert-deftest jabber-test-chat-manual-load-blocked-while-in-flight ()
   (jabber-test-chat--with-manual-load-buffer
@@ -1365,6 +1376,57 @@ and `url' to the URL; `display-graphic-p' is stubbed to 
t."
     (goto-char (point-min))
     (should-error (jabber-chat-url-action-at-point) :type 'user-error)))
 
+(ert-deftest jabber-test-chat-manual-decode-failure-offers-decrypted-save ()
+  "Manual aesgcm preview failure offers its decrypted bytes for saving."
+  (with-temp-buffer
+    (let* ((url (concat "aesgcm://example.org/photo.jpg#"
+                        (make-string 88 ?a)))
+           (saved nil))
+      (insert (propertize url 'jabber-chat-image-url url))
+      (cl-letf (((symbol-function 'jabber-chat--offer-image-save)
+                 (lambda (save-url data)
+                   (setq saved (list save-url data)))))
+        (jabber-chat--handle-image-result
+         (list :error 'decode :data "decrypted-heic")
+         url (copy-marker 1) (copy-marker (point-max))
+         (current-buffer) t)
+        (should (equal saved (list url "decrypted-heic")))
+        (should (eq (get-text-property 1 'jabber-chat-image-fetching)
+                    'failed))))))
+
+(ert-deftest jabber-test-chat-aesgcm-save-fallback-writes-decrypted-bytes ()
+  "The aesgcm save fallback writes retained plaintext, not ciphertext."
+  (let* ((url (concat "aesgcm://example.org/photo.jpg#"
+                      (make-string 88 ?a)))
+         (plaintext (unibyte-string 0 1 2 255))
+         (dest (make-temp-file "jabber-save-fallback-")))
+    (unwind-protect
+        (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) t))
+                  ((symbol-function 'jabber-chat--download-destination)
+                   (lambda (_) dest)))
+          (jabber-chat--offer-image-save url plaintext)
+          (with-temp-buffer
+            (set-buffer-multibyte nil)
+            (insert-file-contents-literally dest)
+            (should (equal (buffer-string) plaintext))))
+      (delete-file dest))))
+
+(ert-deftest jabber-test-chat-automatic-decode-failure-does-not-offer-save ()
+  "Background image fetching must never open an interactive save prompt."
+  (with-temp-buffer
+    (let ((url "https://example.org/photo.jpg";)
+          (offered nil))
+      (insert (propertize url 'jabber-chat-image-url url))
+      (cl-letf (((symbol-function 'jabber-chat--offer-image-save)
+                 (lambda (&rest _) (setq offered t))))
+        (jabber-chat--handle-image-result
+         (list :error 'decode :data "unsupported-image")
+         url (copy-marker 1) (copy-marker (point-max))
+         (current-buffer) nil)
+        (should-not offered)
+        (should (eq (get-text-property 1 'jabber-chat-image-fetching)
+                    'failed))))))
+
 (provide 'jabber-test-chat)
 
 ;;; jabber-test-chat.el ends here
diff --git a/tests/jabber-test-image.el b/tests/jabber-test-image.el
index 3adfbd4a23..d74a1c8053 100644
--- a/tests/jabber-test-image.el
+++ b/tests/jabber-test-image.el
@@ -88,6 +88,17 @@ Binds `calls' to the list of DATA arguments received."
     (let ((jabber-image-max-bytes nil))
       (should-not (jabber-image-from-data jabber-test-image--png-bytes)))))
 
+(ert-deftest jabber-test-image-result-retains-data-on-decode-error ()
+  "A decode failure keeps bytes available for a manual save fallback."
+  (cl-letf (((symbol-function 'jabber-image-create)
+             (lambda (&rest _) (error "Invalid image type ‘heic’"))))
+    (let* ((jabber-image-max-bytes nil)
+           (data "unsupported-image-bytes")
+           (result (jabber-image--result-from-data data nil)))
+      (should (eq (plist-get result :error) 'decode))
+      (should (equal (plist-get result :data) data))
+      (should-not (plist-get result :image)))))
+
 ;;; Response body extraction
 
 (ert-deftest jabber-test-image-response-body-extracts-bytes ()

Reply via email to