Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -141,21 +173,47 @@ 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) {
return Unit.INSTANCE;
}

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;
});
Expand Down Expand Up @@ -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;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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).</p>
*/
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);
}
}
}
Loading