From feb954264f78ae9f0a769a8adf10d1ece8c56ebf Mon Sep 17 00:00:00 2001 From: amalg Date: Thu, 22 Jan 2026 15:51:52 -0800 Subject: [PATCH 1/3] feat: Enable inline image viewing in notes Set the Nextcloud server URL as the markdown image prefix so that relative image paths (/.attachments/, /f/fileId, etc.) are converted to full URLs that can be loaded with SSO authentication. Fixes #1877 Co-Authored-By: Claude Opus 4.5 --- .../it/niedermann/owncloud/notes/edit/BaseNoteFragment.java | 2 +- .../niedermann/owncloud/notes/edit/NotePreviewFragment.java | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java index 946f8a24c..555295138 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java @@ -69,7 +69,7 @@ public abstract class BaseNoteFragment extends BrandedFragment implements Catego private static final String SAVEDKEY_NOTE = "note"; private static final String SAVEDKEY_ORIGINAL_NOTE = "original_note"; - private Account localAccount; + protected Account localAccount; protected Note note; // TODO do we really need this? The reference to note is currently the same diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java index b982baca9..2133904f0 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java @@ -141,6 +141,11 @@ protected void onNoteLoaded(Note note) { noteLoaded = true; registerInternalNoteLinkHandler(); + // Set the image URL prefix for loading images from the Nextcloud server + if (localAccount != null && !localAccount.getUrl().isEmpty()) { + binding.singleNoteContent.setMarkdownImageUrlPrefix(localAccount.getUrl()); + } + lifecycleScopeIOJob(() -> { final String content = note.getContent(); changedText = content; From a62149389f343664e4ea2e7354e953e0f0557002 Mon Sep 17 00:00:00 2001 From: Amal Graafstra Date: Sat, 24 Jan 2026 11:47:08 -0800 Subject: [PATCH 2/3] fix: Resolve inline image loading for notes with attachments - Transform attachment paths to WebDAV URLs for proper image loading - Include note category in path for notes in subfolders - Apply transformation on refresh to maintain image display - Prevent saving transformed URLs back to server - Hide edit FAB in preview mode, use toolbar menu icon instead Co-Authored-By: Claude Opus 4.5 --- .../notes/edit/NotePreviewFragment.java | 109 +++++++++++++++++- .../edit/SearchableBaseNoteFragment.java | 8 +- 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java index 2133904f0..cbca244e4 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java @@ -34,6 +34,11 @@ import com.nextcloud.android.sso.helper.SingleAccountHelper; import com.owncloud.android.lib.common.utils.Log_OC; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.branding.BrandingUtil; import it.niedermann.owncloud.notes.databinding.FragmentNotePreviewBinding; @@ -46,6 +51,8 @@ public class NotePreviewFragment extends SearchableBaseNoteFragment implements O private static final String TAG = NotePreviewFragment.class.getSimpleName(); private String changedText; + private String originalContent; // Store original to prevent saving transformed content + private boolean initialLoadComplete = false; // Flag to skip saving during initial load protected FragmentNotePreviewBinding binding; @@ -130,6 +137,70 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) { } } + /** + * Transforms attachment paths in markdown to use WebDAV URLs. + * Converts: ![alt](.attachments.XXX/file.jpg) + * To: ![alt](/remote.php/dav/files/{username}/Notes/{category}/.attachments.XXX/file.jpg) + * + * Uses WebDAV which works with SSO-Glide authentication. + */ + private String transformAttachmentUrls(String content, Note note) { + Log.i(TAG, "=== transformAttachmentUrls called ==="); + Log.i(TAG, "content length: " + (content != null ? content.length() : "null")); + + if (content == null || localAccount == null || note == null) { + Log.w(TAG, "Skipping transform - content, localAccount, or note is null"); + return content; + } + + String username = localAccount.getUserName(); + // TODO: fetch actual notes path from server settings if customized + String notesPath = "Notes"; + + // Get the note's category (subfolder path) + String category = note.getCategory(); + String fullPath; + if (category != null && !category.isEmpty()) { + fullPath = notesPath + "/" + category; + } else { + fullPath = notesPath; + } + + Log.i(TAG, "Username: " + username + ", NotesPath: " + notesPath + ", Category: " + category + ", FullPath: " + fullPath); + Log.i(TAG, "Content preview: " + content.substring(0, Math.min(content.length(), 300))); + + // Pattern to match markdown images with .attachments paths + // Matches: ![any alt text](.attachments.XXX/filename) + Pattern pattern = Pattern.compile("(!\\[[^\\]]*\\]\\()(\\.attachments\\.[^)]+)(\\))"); + Matcher matcher = pattern.matcher(content); + StringBuffer result = new StringBuffer(); + + int matchCount = 0; + while (matcher.find()) { + matchCount++; + String prefix = matcher.group(1); // ![alt]( + String path = matcher.group(2); // .attachments.XXX/filename + String suffix = matcher.group(3); // ) + + Log.i(TAG, "Found match #" + matchCount + ": " + matcher.group(0)); + Log.i(TAG, " path: " + path); + + // Build the WebDAV URL including category subfolder + String webdavUrl = "/remote.php/dav/files/" + username + "/" + fullPath + "/" + path; + Log.i(TAG, " WebDAV URL: " + webdavUrl); + + matcher.appendReplacement(result, Matcher.quoteReplacement(prefix + webdavUrl + suffix)); + } + matcher.appendTail(result); + + Log.i(TAG, "Total matches found: " + matchCount); + if (matchCount > 0) { + Log.i(TAG, "Transformed content preview: " + result.toString().substring(0, Math.min(result.length(), 500))); + } + + return result.toString(); + } + @Override protected void onNoteLoaded(Note note) { super.onNoteLoaded(note); @@ -147,11 +218,16 @@ protected void onNoteLoaded(Note note) { } lifecycleScopeIOJob(() -> { - final String content = note.getContent(); - changedText = content; + originalContent = note.getContent(); // Store original for comparison + changedText = originalContent; // Keep original for saving + initialLoadComplete = false; // Reset flag before setting content + + // Transform attachment URLs for display only + final String displayContent = transformAttachmentUrls(originalContent, note); + Log.d(TAG, "Original content has attachments: " + (originalContent != null && originalContent.contains(".attachments."))); onMainThread(() -> { - binding.singleNoteContent.setMarkdownString(content, setScrollY); + binding.singleNoteContent.setMarkdownString(displayContent, setScrollY); final var activity = getActivity(); if (activity == null) { @@ -159,8 +235,24 @@ protected void onNoteLoaded(Note note) { } binding.singleNoteContent.getMarkdownString().observe(activity, (newContent) -> { - changedText = newContent.toString(); - saveNote(null); + // Skip saving during initial load or if content matches original + if (!initialLoadComplete) { + initialLoadComplete = true; + Log.d(TAG, "Skipping save during initial load"); + return; + } + + String newContentStr = newContent.toString(); + // Only save if the content is actually different from original + // and doesn't contain our transformed API URLs + if (!newContentStr.equals(originalContent) && + !newContentStr.contains("/index.php/apps/notes/api/v1/notes/") && + !newContentStr.contains("/attachment?path=")) { + changedText = newContentStr; + saveNote(null); + } else { + Log.d(TAG, "Skipping save - content unchanged or contains transformed URLs"); + } }); return Unit.INSTANCE; }); @@ -208,10 +300,15 @@ public void onRefresh() { repo.addCallbackPull(account, () -> { note = repo.getNoteById(note.getId()); final String content = note.getContent(); + originalContent = content; // Store original changedText = content; + // Transform attachment URLs for display + final String displayContent = transformAttachmentUrls(content, note); + onMainThread(() -> { - binding.singleNoteContent.setMarkdownString(content); + initialLoadComplete = false; // Reset flag before setting content + binding.singleNoteContent.setMarkdownString(displayContent); binding.swiperefreshlayout.setRefreshing(false); return Unit.INSTANCE; }); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java index 503fbc9d2..ec7519e8b 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java @@ -104,12 +104,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat getDirectEditingButton().setVisibility(View.GONE); ExtendedFloatingActionButton edit = getNormalEditButton(); if(edit!=null) { - edit.setVisibility(View.VISIBLE); - edit.setOnClickListener(v -> { - if (listener != null) { - listener.changeMode(NoteFragmentListener.Mode.EDIT, true); - } - }); + // Hide the FAB - use the toolbar menu icon instead for a cleaner UI + edit.setVisibility(View.GONE); } } } From 712703e3cb5157e8eca113fc6e0c4342e9c778d7 Mon Sep 17 00:00:00 2001 From: Amal Graafstra Date: Fri, 15 May 2026 23:39:56 -0700 Subject: [PATCH 3/3] feat: Render attachment images inline in note edit mode Edit mode previously showed only the raw markdown source for image attachments. It now replaces each ![alt](.attachments...) reference with the rendered image via a ReplacementSpan, loaded through SSO-Glide. The markdown characters remain in the note content, so saving is unaffected. - Add AttachmentUrlUtil with the shared attachment-path to WebDAV URL transform, used by both preview and edit modes - Add AttachmentImagePreviewSpan, a ReplacementSpan that draws the image in place of the markdown - Add AttachmentPreviewController to scan content, attach spans, load images downsampled, and re-scan on a debounce as the note is edited - Refactor NotePreviewFragment to use the shared AttachmentUrlUtil --- .../owncloud/notes/edit/NoteEditFragment.java | 21 ++ .../notes/edit/NotePreviewFragment.java | 65 +---- .../preview/AttachmentImagePreviewSpan.java | 130 ++++++++++ .../preview/AttachmentPreviewController.java | 222 ++++++++++++++++++ .../notes/shared/util/AttachmentUrlUtil.java | 61 +++++ .../shared/util/AttachmentUrlUtilTest.kt | 95 ++++++++ 6 files changed, 542 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/edit/preview/AttachmentImagePreviewSpan.java create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/edit/preview/AttachmentPreviewController.java create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/shared/util/AttachmentUrlUtil.java create mode 100644 app/src/test/java/it/niedermann/owncloud/notes/shared/util/AttachmentUrlUtilTest.kt diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java index 8d8b8a6b1..2ef766de0 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java @@ -39,6 +39,7 @@ import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.branding.BrandingUtil; import it.niedermann.owncloud.notes.databinding.FragmentNoteEditBinding; +import it.niedermann.owncloud.notes.edit.preview.AttachmentPreviewController; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.shared.model.ISyncCallback; import it.niedermann.owncloud.notes.shared.util.DisplayUtils; @@ -55,6 +56,9 @@ public class NoteEditFragment extends SearchableBaseNoteFragment { private FragmentNoteEditBinding binding; + @Nullable + private AttachmentPreviewController attachmentPreviewController; + private Handler handler; private boolean saveActive; private boolean unsavedEdit; @@ -209,6 +213,14 @@ protected void onNoteLoaded(Note note) { if (lastSelection > 0 && binding.editContent.length() >= lastSelection) { binding.editContent.setSelection(lastSelection); } + + if (attachmentPreviewController != null) { + attachmentPreviewController.detach(); + } + if (localAccount != null) { + attachmentPreviewController = new AttachmentPreviewController(binding.editContent, localAccount); + attachmentPreviewController.attach(note); + } return Unit.INSTANCE; }); return Unit.INSTANCE; @@ -243,6 +255,15 @@ public void onPause() { } } + @Override + public void onDestroyView() { + if (attachmentPreviewController != null) { + attachmentPreviewController.detach(); + attachmentPreviewController = null; + } + super.onDestroyView(); + } + private void cancelTimers() { handler.removeCallbacks(runAutoSave); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java index cbca244e4..ee3b04336 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java @@ -34,15 +34,13 @@ import com.nextcloud.android.sso.helper.SingleAccountHelper; import com.owncloud.android.lib.common.utils.Log_OC; -import java.io.UnsupportedEncodingException; -import java.net.URLEncoder; import java.util.regex.Matcher; -import java.util.regex.Pattern; import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.branding.BrandingUtil; import it.niedermann.owncloud.notes.databinding.FragmentNotePreviewBinding; import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.util.AttachmentUrlUtil; import it.niedermann.owncloud.notes.shared.util.SSOUtil; import kotlin.Unit; @@ -138,66 +136,29 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) { } /** - * Transforms attachment paths in markdown to use WebDAV URLs. - * Converts: ![alt](.attachments.XXX/file.jpg) - * To: ![alt](/remote.php/dav/files/{username}/Notes/{category}/.attachments.XXX/file.jpg) - * - * Uses WebDAV which works with SSO-Glide authentication. + * Transforms attachment paths in markdown to authenticated WebDAV URLs so + * images render via SSO-Glide. The note content itself is not modified; the + * returned string is used for display only. */ private String transformAttachmentUrls(String content, Note note) { - Log.i(TAG, "=== transformAttachmentUrls called ==="); - Log.i(TAG, "content length: " + (content != null ? content.length() : "null")); - if (content == null || localAccount == null || note == null) { - Log.w(TAG, "Skipping transform - content, localAccount, or note is null"); return content; } - String username = localAccount.getUserName(); - // TODO: fetch actual notes path from server settings if customized - String notesPath = "Notes"; - - // Get the note's category (subfolder path) - String category = note.getCategory(); - String fullPath; - if (category != null && !category.isEmpty()) { - fullPath = notesPath + "/" + category; - } else { - fullPath = notesPath; - } - - Log.i(TAG, "Username: " + username + ", NotesPath: " + notesPath + ", Category: " + category + ", FullPath: " + fullPath); - Log.i(TAG, "Content preview: " + content.substring(0, Math.min(content.length(), 300))); - - // Pattern to match markdown images with .attachments paths - // Matches: ![any alt text](.attachments.XXX/filename) - Pattern pattern = Pattern.compile("(!\\[[^\\]]*\\]\\()(\\.attachments\\.[^)]+)(\\))"); - Matcher matcher = pattern.matcher(content); - StringBuffer result = new StringBuffer(); + final String username = localAccount.getUserName(); + final String category = note.getCategory(); - int matchCount = 0; + final Matcher matcher = AttachmentUrlUtil.ATTACHMENT_PATTERN.matcher(content); + final StringBuffer result = new StringBuffer(); while (matcher.find()) { - matchCount++; - String prefix = matcher.group(1); // ![alt]( - String path = matcher.group(2); // .attachments.XXX/filename - String suffix = matcher.group(3); // ) - - Log.i(TAG, "Found match #" + matchCount + ": " + matcher.group(0)); - Log.i(TAG, " path: " + path); - - // Build the WebDAV URL including category subfolder - String webdavUrl = "/remote.php/dav/files/" + username + "/" + fullPath + "/" + path; - Log.i(TAG, " WebDAV URL: " + webdavUrl); - + final String prefix = matcher.group(1); + final String path = matcher.group(2); + final String suffix = matcher.group(3); + final String webdavUrl = AttachmentUrlUtil.transformAttachmentPath( + path, username, "Notes", category); matcher.appendReplacement(result, Matcher.quoteReplacement(prefix + webdavUrl + suffix)); } matcher.appendTail(result); - - Log.i(TAG, "Total matches found: " + matchCount); - if (matchCount > 0) { - Log.i(TAG, "Transformed content preview: " + result.toString().substring(0, Math.min(result.length(), 500))); - } - return result.toString(); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/preview/AttachmentImagePreviewSpan.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/preview/AttachmentImagePreviewSpan.java new file mode 100644 index 000000000..65044936f --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/preview/AttachmentImagePreviewSpan.java @@ -0,0 +1,130 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.edit.preview; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.text.style.ReplacementSpan; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Visually replaces a Nextcloud attachment image's markdown + * ({@code ![alt](.attachments.XXX/file.ext)}) with the rendered image inside an + * editable EditText. + * + *

This is a {@link ReplacementSpan}: it occupies a self-sized box and draws + * the image into it. The markdown characters remain in the {@code Editable} and + * are merely hidden behind the box, so the saved note content is unaffected.

+ * + *

The image loads asynchronously. Until it arrives a faint placeholder box is + * drawn; once loaded, {@link #setLoadedDrawable} fires a callback so the host can + * re-measure (the box may change size).

+ */ +public class AttachmentImagePreviewSpan extends ReplacementSpan { + + private final int maxWidthPx; + private final int maxHeightPx; + private final int placeholderSizePx; + private final Paint placeholderPaint; + + @Nullable + private Drawable loadedDrawable; + @Nullable + private Runnable onLoadedCallback; + + public AttachmentImagePreviewSpan(int maxWidthPx, int maxHeightPx, int placeholderSizePx) { + this.maxWidthPx = Math.max(1, maxWidthPx); + this.maxHeightPx = Math.max(1, maxHeightPx); + this.placeholderSizePx = Math.max(1, placeholderSizePx); + this.placeholderPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + this.placeholderPaint.setStyle(Paint.Style.STROKE); + this.placeholderPaint.setStrokeWidth(2f); + this.placeholderPaint.setColor(Color.argb(80, 128, 128, 128)); + } + + /** + * Supplies the image once it has loaded and notifies the host so it can + * re-measure the (possibly resized) box. + */ + public void setLoadedDrawable(@NonNull Drawable drawable) { + this.loadedDrawable = drawable; + if (onLoadedCallback != null) { + onLoadedCallback.run(); + } + } + + /** + * Sets (or, with null, clears) the callback invoked when the image loads. + * The controller clears this on detach so a late load cannot touch a dead + * view. + */ + public void setOnLoadedCallback(@Nullable Runnable callback) { + this.onLoadedCallback = callback; + } + + private float scaleFactor(int dw, int dh) { + final float scale = Math.min((float) maxWidthPx / dw, (float) maxHeightPx / dh); + return scale > 1f ? 1f : scale; + } + + private int boxWidth() { + if (loadedDrawable == null) { + return placeholderSizePx; + } + final int dw = loadedDrawable.getIntrinsicWidth(); + final int dh = loadedDrawable.getIntrinsicHeight(); + if (dw <= 0 || dh <= 0) { + return placeholderSizePx; + } + return Math.max(1, Math.round(dw * scaleFactor(dw, dh))); + } + + private int boxHeight() { + if (loadedDrawable == null) { + return placeholderSizePx; + } + final int dw = loadedDrawable.getIntrinsicWidth(); + final int dh = loadedDrawable.getIntrinsicHeight(); + if (dw <= 0 || dh <= 0) { + return placeholderSizePx; + } + return Math.max(1, Math.round(dh * scaleFactor(dw, dh))); + } + + @Override + public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, + @Nullable Paint.FontMetricsInt fm) { + final int h = boxHeight(); + if (fm != null) { + // Occupy the full box height above the baseline. + fm.ascent = -h; + fm.top = -h; + fm.descent = 0; + fm.bottom = 0; + } + return boxWidth(); + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, + float x, int top, int y, int bottom, @NonNull Paint paint) { + final int w = boxWidth(); + final int h = boxHeight(); + final int left = Math.round(x); + if (loadedDrawable != null) { + loadedDrawable.setBounds(left, top, left + w, top + h); + loadedDrawable.draw(canvas); + } else { + final float pad = 2f; + canvas.drawRect(left + pad, top + pad, left + w - pad, top + h - pad, placeholderPaint); + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/preview/AttachmentPreviewController.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/preview/AttachmentPreviewController.java new file mode 100644 index 000000000..f5d2ac841 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/preview/AttachmentPreviewController.java @@ -0,0 +1,222 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.edit.preview; + +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.Spanned; +import android.text.TextWatcher; +import android.util.Log; +import android.util.TypedValue; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; + +import it.niedermann.nextcloud.sso.glide.SingleSignOnUrl; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.util.AttachmentUrlUtil; + +/** + * Scans an editable note's markdown for Nextcloud attachment images and replaces + * each image's markdown with the rendered image, via {@link AttachmentImagePreviewSpan}. + * + *

Does not modify the editor's text content. Re-scans on a debounce when the + * user edits, so spans stay reconciled with the current markdown.

+ */ +public class AttachmentPreviewController { + + private static final String TAG = AttachmentPreviewController.class.getSimpleName(); + private static final int MAX_HEIGHT_DP = 200; + private static final int PLACEHOLDER_DP = 100; + private static final int HORIZONTAL_CHROME_DP = 48; + private static final long DEBOUNCE_MS = 500; + private static final String NOTES_ROOT = "Notes"; + + private final EditText editor; + private final Account account; + private final int maxWidthPx; + private final int maxHeightPx; + private final int placeholderSizePx; + private final int targetWidthPx; + private final Handler debounceHandler = new Handler(Looper.getMainLooper()); + + private final List> activeTargets = new ArrayList<>(); + private final List liveSpans = new ArrayList<>(); + + @Nullable + private String category; + private boolean watcherAttached = false; + + private final Runnable rescanRunnable = this::rescan; + + private final TextWatcher textWatcher = new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // no-op + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + // no-op + } + + @Override + public void afterTextChanged(Editable s) { + debounceHandler.removeCallbacks(rescanRunnable); + debounceHandler.postDelayed(rescanRunnable, DEBOUNCE_MS); + } + }; + + public AttachmentPreviewController(@NonNull EditText editor, @NonNull Account account) { + this.editor = editor; + this.account = account; + final var dm = editor.getResources().getDisplayMetrics(); + this.maxHeightPx = (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, MAX_HEIGHT_DP, dm); + this.placeholderSizePx = (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, PLACEHOLDER_DP, dm); + final int horizontalChrome = (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, HORIZONTAL_CHROME_DP, dm); + this.maxWidthPx = Math.max(1, dm.widthPixels - horizontalChrome); + this.targetWidthPx = dm.widthPixels; + } + + /** + * Performs first-time setup: stores the note category, registers the + * debounced re-scan watcher, and runs the initial scan. + */ + public void attach(@NonNull Note note) { + this.category = note.getCategory(); + if (!watcherAttached) { + editor.addTextChangedListener(textWatcher); + watcherAttached = true; + } + rescan(); + } + + /** + * Tears down: cancels pending loads and re-scans, removes the watcher, and + * detaches span callbacks so an in-flight load cannot touch a dead view. + */ + public void detach() { + debounceHandler.removeCallbacks(rescanRunnable); + if (watcherAttached) { + editor.removeTextChangedListener(textWatcher); + watcherAttached = false; + } + clearSpansAndLoads(); + } + + private void clearSpansAndLoads() { + for (CustomTarget target : activeTargets) { + Glide.with(editor).clear(target); + } + activeTargets.clear(); + + for (AttachmentImagePreviewSpan span : liveSpans) { + span.setOnLoadedCallback(null); + } + liveSpans.clear(); + + final Editable text = editor.getText(); + if (text != null) { + final AttachmentImagePreviewSpan[] existing = + text.getSpans(0, text.length(), AttachmentImagePreviewSpan.class); + for (AttachmentImagePreviewSpan span : existing) { + text.removeSpan(span); + } + } + } + + /** + * Re-applies the span so a layout that already happened with the placeholder + * size is redone now that the image (and its real box size) is available. + * Re-setting a span fires the layout's SpanWatcher, which reflows the range. + */ + private void reflowSpan(@NonNull AttachmentImagePreviewSpan span) { + final Editable text = editor.getText(); + if (text == null) { + return; + } + final int start = text.getSpanStart(span); + final int end = text.getSpanEnd(span); + if (start >= 0 && end > start) { + text.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + editor.invalidate(); + } + + private void rescan() { + final Editable text = editor.getText(); + if (text == null) { + return; + } + + clearSpansAndLoads(); + + final String username = account.getUserName(); + final Matcher matcher = AttachmentUrlUtil.ATTACHMENT_PATTERN.matcher(text); + while (matcher.find()) { + final int matchStart = matcher.start(); + final int matchEnd = matcher.end(); + final String attachmentPath = matcher.group(2); + if (attachmentPath == null) { + continue; + } + final String webdavRelative = AttachmentUrlUtil.transformAttachmentPath( + attachmentPath, username, NOTES_ROOT, category); + + final AttachmentImagePreviewSpan span = new AttachmentImagePreviewSpan( + maxWidthPx, maxHeightPx, placeholderSizePx); + span.setOnLoadedCallback(() -> reflowSpan(span)); + text.setSpan(span, matchStart, matchEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + liveSpans.add(span); + + // Decode the image downsampled to roughly the thumbnail size. Without + // a size constraint Glide decodes at full resolution, which can + // produce a bitmap too large for the hardware canvas to draw. + final CustomTarget target = + new CustomTarget(targetWidthPx, maxHeightPx) { + @Override + public void onResourceReady(@NonNull Drawable resource, + @Nullable Transition transition) { + span.setLoadedDrawable(resource); + } + + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + Log.w(TAG, "Failed to load attachment image: " + webdavRelative); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + // no-op; the span keeps its last drawable until rescan replaces it + } + }; + activeTargets.add(target); + + Glide.with(editor) + .load(new SingleSignOnUrl(account.getAccountName(), + account.getUrl() + webdavRelative)) + .downsample(DownsampleStrategy.AT_MOST) + .into(target); + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/AttachmentUrlUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/AttachmentUrlUtil.java new file mode 100644 index 000000000..5dc652db7 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/AttachmentUrlUtil.java @@ -0,0 +1,61 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.shared.util; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.regex.Pattern; + +/** + * Builds authenticated WebDAV URLs for Nextcloud Notes attachment images. + * Markdown stores attachment images as {@code ![alt](.attachments.XXX/file.ext)}; + * the relative {@code .attachments.XXX/...} path is not directly loadable, so it + * is rewritten to a server WebDAV path that SSO-Glide can authenticate against. + */ +public final class AttachmentUrlUtil { + + /** + * Matches a markdown image whose destination is a Nextcloud attachment path. + * Group 1: the {@code ![alt](} prefix. Group 2: the {@code .attachments.XXX/file} + * path. Group 3: the closing {@code )}. + */ + public static final Pattern ATTACHMENT_PATTERN = + Pattern.compile("(!\\[[^\\]]*\\]\\()(\\.attachments\\.[^)]+)(\\))"); + + private AttachmentUrlUtil() { + } + + /** + * Rewrites a {@code .attachments.XXX/file} path into a server-relative WebDAV + * URL. Inputs that are not attachment paths are returned unchanged. No URL + * encoding is applied, matching the behaviour of the existing preview-mode + * transform. + * + * @param attachmentPath the raw markdown destination, e.g. {@code .attachments.1/a.jpg} + * @param username the Nextcloud account username + * @param notesRoot the Notes app root folder, typically {@code "Notes"} + * @param category the note's category (sub-folder), or null/empty for root + * @return a server-relative WebDAV URL beginning with {@code /remote.php/dav/...} + */ + @NonNull + public static String transformAttachmentPath(@NonNull String attachmentPath, + @NonNull String username, + @NonNull String notesRoot, + @Nullable String category) { + if (!attachmentPath.startsWith(".attachments.")) { + return attachmentPath; + } + final String fullPath; + if (category != null && !category.isEmpty()) { + fullPath = notesRoot + "/" + category; + } else { + fullPath = notesRoot; + } + return "/remote.php/dav/files/" + username + "/" + fullPath + "/" + attachmentPath; + } +} diff --git a/app/src/test/java/it/niedermann/owncloud/notes/shared/util/AttachmentUrlUtilTest.kt b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/AttachmentUrlUtilTest.kt new file mode 100644 index 000000000..fa82c7f67 --- /dev/null +++ b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/AttachmentUrlUtilTest.kt @@ -0,0 +1,95 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.shared.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AttachmentUrlUtilTest { + + @Test + fun basicPathBuildsWebdavUrl() { + val result = AttachmentUrlUtil.transformAttachmentPath( + ".attachments.398123/charlie.jpg", "amal", "Notes", null + ) + assertEquals( + "/remote.php/dav/files/amal/Notes/.attachments.398123/charlie.jpg", + result + ) + } + + @Test + fun categoryIsInsertedAsSubfolder() { + val result = AttachmentUrlUtil.transformAttachmentPath( + ".attachments.1/cat.png", "amal", "Notes", "Animals" + ) + assertEquals( + "/remote.php/dav/files/amal/Notes/Animals/.attachments.1/cat.png", + result + ) + } + + @Test + fun emptyCategoryOmitsSubfolder() { + val result = AttachmentUrlUtil.transformAttachmentPath( + ".attachments.1/cat.png", "amal", "Notes", "" + ) + assertEquals( + "/remote.php/dav/files/amal/Notes/.attachments.1/cat.png", + result + ) + } + + @Test + fun nestedCategoryPreservedVerbatim() { + val result = AttachmentUrlUtil.transformAttachmentPath( + ".attachments.1/cat.png", "amal", "Notes", "Animals/Cats" + ) + assertEquals( + "/remote.php/dav/files/amal/Notes/Animals/Cats/.attachments.1/cat.png", + result + ) + } + + @Test + fun spacesInFilenamePreservedVerbatim() { + val result = AttachmentUrlUtil.transformAttachmentPath( + ".attachments.1/my photo.jpg", "amal", "Notes", null + ) + assertEquals( + "/remote.php/dav/files/amal/Notes/.attachments.1/my photo.jpg", + result + ) + } + + @Test + fun nonAttachmentPathReturnedUnchanged() { + val result = AttachmentUrlUtil.transformAttachmentPath( + "https://example.com/img.png", "amal", "Notes", null + ) + assertEquals("https://example.com/img.png", result) + } + + @Test + fun patternMatchesAttachmentImageMarkdown() { + val matcher = AttachmentUrlUtil.ATTACHMENT_PATTERN.matcher( + "text ![charlie.jpg](.attachments.398123/charlie.jpg) more" + ) + assertTrue(matcher.find()) + assertEquals(".attachments.398123/charlie.jpg", matcher.group(2)) + } + + @Test + fun patternIgnoresNonAttachmentImages() { + val matcher = AttachmentUrlUtil.ATTACHMENT_PATTERN.matcher( + "![alt](https://example.com/x.png)" + ) + assertFalse(matcher.find()) + } +}