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/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 b982baca9..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,10 +34,13 @@ import com.nextcloud.android.sso.helper.SingleAccountHelper; import com.owncloud.android.lib.common.utils.Log_OC; +import java.util.regex.Matcher; + 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; @@ -46,6 +49,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 +135,33 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) { } } + /** + * 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) { + if (content == null || localAccount == null || note == null) { + return content; + } + + final String username = localAccount.getUserName(); + final String category = note.getCategory(); + + final Matcher matcher = AttachmentUrlUtil.ATTACHMENT_PATTERN.matcher(content); + final StringBuffer result = new StringBuffer(); + while (matcher.find()) { + 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); + return result.toString(); + } + @Override protected void onNoteLoaded(Note note) { super.onNoteLoaded(note); @@ -141,12 +173,22 @@ 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; + 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) { @@ -154,8 +196,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; }); @@ -203,10 +261,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); } } } 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 }) 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