Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit 29909877 authored by Andy Scherzinger's avatar Andy Scherzinger Committed by GitHub
Browse files

Merge pull request #2713 from nextcloud/general-bugfixes

General Bug-Fixes
parents a5ef21a6 49bc395c
Loading
Loading
Loading
Loading
+0 −50
Original line number Diff line number Diff line
/*
 * Nextcloud Notes - Android Client
 *
 * SPDX-FileCopyrightText: 2020-2024 Nextcloud GmbH and Nextcloud contributors
 * SPDX-License-Identifier: GPL-3.0-or-later
 */
package it.niedermann.owncloud.notes.branding;

import android.util.TypedValue;
import android.view.Menu;
import android.view.MenuInflater;

import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;

public abstract class BrandedFragment extends Fragment implements Branded {

    @ColorInt
    protected int colorAccent;
    @ColorInt
    protected int colorPrimary;

    @Override
    public void onStart() {
        super.onStart();

        final var context = requireContext();
        final var typedValue = new TypedValue();
        context.getTheme().resolveAttribute(com.google.android.material.R.attr.colorAccent, typedValue, true);
        colorAccent = typedValue.data;
        context.getTheme().resolveAttribute(com.google.android.material.R.attr.colorPrimary, typedValue, true);
        colorPrimary = typedValue.data;

        @ColorInt final int color = BrandingUtil.readBrandMainColor(context);
        applyBrand(color);
    }

    @Override
    public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
        super.onCreateOptionsMenu(menu, inflater);
        final var utils = BrandingUtil.of(colorAccent, requireContext());

        for (int i = 0; i < menu.size(); i++) {
            if (menu.getItem(i).getIcon() != null) {
                utils.platform.colorToolbarMenuIcon(requireContext(), menu.getItem(i));
            }
        }
    }
}
+117 −0
Original line number Diff line number Diff line
/*
 * Nextcloud Notes - Android Client
 *
 * SPDX-FileCopyrightText: 2020-2024 Nextcloud GmbH and Nextcloud contributors
 * SPDX-License-Identifier: GPL-3.0-or-later
 */
package it.niedermann.owncloud.notes.branding

import android.os.Bundle
import android.util.TypedValue
import android.view.Menu
import android.view.MenuInflater
import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.forEach
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.nextcloud.android.common.ui.util.extensions.adjustUIForAPILevel35
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

/**
 * An abstract base [Fragment] implementation that provides common branding support for UI
 * components.
 *
 * This class reads and applies brand-specific colors (`colorPrimary`, `colorAccent`, etc.) when the
 * fragment starts, and adjusts UI elements such as toolbar menu icons accordingly.
 *
 * Subclasses can extend this to inherit branding behavior while implementing their specific logic.
 *
 * @see BrandingUtil for brand color resolution and application.
 * @see Branded for the interface definition related to branding behavior.
 */
abstract class BrandedFragment : Fragment(), Branded {
    @JvmField
    @ColorInt
    protected var colorAccent: Int = 0

    @JvmField
    @ColorInt
    protected var colorPrimary: Int = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        if (activity is AppCompatActivity) {
            val appCompatActivity = activity as AppCompatActivity
            appCompatActivity.adjustUIForAPILevel35()
        }
        super.onCreate(savedInstanceState)
    }

    override fun onStart() {
        super.onStart()

        val context = requireContext()
        val typedValue = TypedValue()

        context.theme.resolveAttribute(
            com.google.android.material.R.attr.colorAccent,
            typedValue,
            true
        )
        colorAccent = typedValue.data

        context.theme.resolveAttribute(
            com.google.android.material.R.attr.colorPrimary,
            typedValue,
            true
        )
        colorPrimary = typedValue.data

        @ColorInt
        val color = BrandingUtil.readBrandMainColor(context)
        applyBrand(color)
    }

    @Suppress("DEPRECATION")
    @Deprecated("Deprecated in Java")
    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
        super.onCreateOptionsMenu(menu, inflater)
        val utils = BrandingUtil.of(colorAccent, requireContext())

        menu.forEach { menu ->
            menu.icon?.let { icon ->
                utils.platform.colorToolbarMenuIcon(requireContext(), menu)
            }
        }
    }

    /**
     * Launches the given [block] of code in the [Dispatchers.IO] context using the [lifecycleScope].
     *
     * This is useful for running long-running or blocking operations (e.g., file or network I/O)
     * that should not block the main thread. The coroutine will be automatically canceled when
     * the lifecycle is destroyed.
     *
     * @param block The code block to be executed on the IO dispatcher.
     */
    fun lifecycleScopeIOJob(block: () -> Unit) {
        lifecycleScope.launch(Dispatchers.IO) {
            block()
        }
    }

    /**
     * Executes the given [block] on the main (UI) thread.
     *
     * This is typically used to perform UI-related tasks such as updating views from a background
     * thread. Requires [activity] to be non-null; otherwise, the block will not be executed.
     *
     * @param block The code block to be executed on the main thread.
     */
    fun onMainThread(block: () -> Unit) {
        activity?.runOnUiThread {
            block()
        }
    }
}
+134 −98
Original line number Diff line number Diff line
@@ -27,6 +27,7 @@ import com.google.android.material.snackbar.Snackbar
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import com.nextcloud.android.sso.helper.SingleAccountHelper
import com.nextcloud.android.sso.model.SingleSignOnAccount
import com.owncloud.android.lib.common.utils.Log_OC
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
@@ -44,12 +45,13 @@ import it.niedermann.owncloud.notes.shared.model.ApiVersion
import it.niedermann.owncloud.notes.shared.model.ISyncCallback
import it.niedermann.owncloud.notes.shared.util.ExtendedFabUtil
import it.niedermann.owncloud.notes.shared.util.rx.DisposableSet
import okio.IOException
import java.util.concurrent.TimeUnit

class NoteDirectEditFragment : BaseNoteFragment(), Branded {
    private var _binding: FragmentNoteDirectEditBinding? = null
    private val binding: FragmentNoteDirectEditBinding
        get() = _binding!!
    private val binding: FragmentNoteDirectEditBinding?
        get() = _binding

    private val disposables: DisposableSet = DisposableSet()
    private var switchToEditPending = false
@@ -79,20 +81,22 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded {
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?,
    ): View {
    ): View? {
        Log.d(TAG, "onCreateView() called")
        _binding = FragmentNoteDirectEditBinding.inflate(inflater, container, false)
        setupFab()
        prepareWebView()
        return binding.root
        return binding?.root
    }

    @SuppressLint("ClickableViewAccessibility") // touch listener only for UI purposes, no need to handle click
    private fun setupFab() {
        binding.plainEditingFab.isExtended = false
        ExtendedFabUtil.toggleExtendedOnLongClick(binding.plainEditingFab)
        binding?.run {
            plainEditingFab.isExtended = false
            ExtendedFabUtil.toggleExtendedOnLongClick(plainEditingFab)

            // manually detect scroll as we can't get it from the webview (maybe with custom JS?)
        binding.noteWebview.setOnTouchListener { _, event ->
            noteWebview.setOnTouchListener { _, event ->
                when (event.action) {
                    MotionEvent.ACTION_DOWN -> {
                        scrollStart = event.y.toInt()
@@ -100,7 +104,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded {
                    MotionEvent.ACTION_UP -> {
                        val scrollEnd = event.y.toInt()
                        ExtendedFabUtil.toggleVisibilityOnScroll(
                        binding.plainEditingFab,
                            plainEditingFab,
                            scrollStart,
                            scrollEnd,
                        )
@@ -108,12 +112,13 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded {
                }
                return@setOnTouchListener false
            }
        binding.plainEditingFab.setOnClickListener { switchToPlainEdit() }
            plainEditingFab.setOnClickListener { switchToPlainEdit() }
        }
    }

    private fun switchToPlainEdit() {
        switchToEditPending = true
        binding.noteWebview.evaluateJavascript(JS_CLOSE) { result ->
        binding?.noteWebview?.evaluateJavascript(JS_CLOSE) { result ->
            val resultWithoutQuotes = result.replace("\"", "")
            if (resultWithoutQuotes != JS_RESULT_OK) {
                Log.w(TAG, "Closing via JS failed: $resultWithoutQuotes")
@@ -126,7 +131,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded {
    override fun onDestroyView() {
        super.onDestroyView()
        disposables.dispose()
        binding.noteWebview.destroy()
        binding?.noteWebview?.destroy()
        _binding = null
    }

@@ -135,7 +140,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded {
        val timeoutDisposable = Single.just(Unit)
            .delay(LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS)
            .map {
                if (!binding.noteWebview.isVisible) {
                if (binding?.noteWebview?.isVisible == false) {
                    Log.w(TAG, "Editor not loaded after $LOAD_TIMEOUT_SECONDS seconds")
                    handleLoadError()
                }
@@ -158,11 +163,19 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded {
        Log.d(TAG, "createAndLoadNote() called")
        val noteCreateDisposable = Single
            .fromCallable {
                notesApi.createNote(newNote).execute().body()!!
                try {
                    val response = notesApi.createNote(newNote).execute()
                    response.body()
                } catch (e: IOException) {
                    Log_OC.w(TAG, "Cant able to create a note: $e")
                    null
                }
            }
            .flatMap { createdNote ->
                createdNote?.let {
                    repo.updateRemoteId(newNote.id, it.remoteId)
                    Single.fromCallable { repo.getNoteById(newNote.id) }
                }
            .map { createdNote ->
                repo.updateRemoteId(newNote.id, createdNote.remoteId)
                repo.getNoteById(newNote.id)
            }
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
@@ -178,27 +191,33 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded {

    private fun loadNoteInWebView(note: Note) {
        Log.d(TAG, "loadNoteInWebView() called")
        val directEditingRepository =
            DirectEditingRepository.getInstance(requireContext().applicationContext)
        val urlDisposable = directEditingRepository.getDirectEditingUrl(account, note)

        context?.let { context ->
            val repository = DirectEditingRepository.getInstance(context.applicationContext)
            val urlDisposable = repository.getDirectEditingUrl(account, note)
                .observeOn(AndroidSchedulers.mainThread()).subscribe({ url ->
                    url?.let {
                        if (BuildConfig.DEBUG) {
                            Log.d(TAG, "loadNoteInWebView: url = $url")
                        }
                binding.noteWebview.loadUrl(url)
                        binding?.noteWebview?.loadUrl(url)
                    }
                }, { throwable ->
                    handleLoadError()
                    Log.e(TAG, "loadNoteInWebView:", throwable)
                })
            disposables.add(urlDisposable)
        }
    }

    private fun handleLoadError() {
        binding?.run {
            val snackbar = BrandedSnackbar.make(
            binding.plainEditingFab,
                plainEditingFab,
                getString(R.string.direct_editing_error),
                Snackbar.LENGTH_INDEFINITE,
            )

            if (note != null) {
                snackbar.setAction(R.string.switch_to_plain_editing) {
                    changeToEditMode()
@@ -208,50 +227,53 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded {
                    close()
                }
            }

            snackbar.show()
        }
    }

    override fun shouldShowToolbar(): Boolean = false

    @SuppressLint("SetJavaScriptEnabled")
    private fun prepareWebView() {
        val webSettings = binding.noteWebview.settings
        binding?.noteWebview?.settings?.run {
            // enable zoom
        webSettings.setSupportZoom(true)
        webSettings.builtInZoomControls = true
        webSettings.displayZoomControls = false
            setSupportZoom(true)
            builtInZoomControls = true
            displayZoomControls = false

            // Non-responsive webs are zoomed out when loaded
        webSettings.useWideViewPort = true
        webSettings.loadWithOverviewMode = true
            useWideViewPort = true
            loadWithOverviewMode = true

            // user agent
            val userAgent =
                getString(R.string.user_agent, getString(R.string.app_name), BuildConfig.VERSION_NAME)
        webSettings.userAgentString = userAgent
            userAgentString = userAgent

            // no private data storing
        webSettings.savePassword = false
        webSettings.saveFormData = false
            savePassword = false
            saveFormData = false

            // disable local file access
        webSettings.allowFileAccess = false
            allowFileAccess = false

            // enable javascript
        webSettings.javaScriptEnabled = true
        webSettings.domStorageEnabled = true
            javaScriptEnabled = true
            domStorageEnabled = true
        }

        if (BuildConfig.DEBUG) {
            // caching disabled in debug mode
            binding.noteWebview.settings.cacheMode = WebSettings.LOAD_NO_CACHE
            binding?.noteWebview?.settings?.cacheMode = WebSettings.LOAD_NO_CACHE
        }

        binding.noteWebview.addJavascriptInterface(
        binding?.noteWebview?.addJavascriptInterface(
            DirectEditingMobileInterface(this),
            JS_INTERFACE_NAME,
        )

        binding.noteWebview.webViewClient = object : WebViewClient() {
        binding?.noteWebview?.webViewClient = object : WebViewClient() {
            override fun onReceivedError(
                view: WebView?,
                request: WebResourceRequest?,
@@ -299,8 +321,11 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded {

    override fun applyBrand(color: Int) {
        val util = BrandingUtil.of(color, requireContext())
        util.material.themeExtendedFAB(binding.plainEditingFab)
        util.platform.colorCircularProgressBar(binding.progress, ColorRole.PRIMARY)

        binding?.run {
            util.material.themeExtendedFAB(plainEditingFab)
            util.platform.colorCircularProgressBar(progress, ColorRole.PRIMARY)
        }
    }

    private class DirectEditingMobileInterface(val noteDirectEditFragment: NoteDirectEditFragment) {
@@ -331,6 +356,11 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded {
    }

    private fun changeToEditMode() {
        if (note == null || note.remoteId == null) {
            Log.d(TAG, "note is null, cant edit")
            return
        }

        toggleLoadingUI(true)
        val updateDisposable = Single.just(note.remoteId)
            .map { remoteId ->
@@ -360,9 +390,11 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded {

    private fun toggleLoadingUI(loading: Boolean) {
        activity?.runOnUiThread {
            binding.progress.isVisible = loading
            binding.noteWebview.isVisible = !loading
            binding.plainEditingFab.isVisible = !loading
            binding?.run {
                progress.isVisible = loading
                noteWebview.isVisible = !loading
                plainEditingFab.isVisible = !loading
            }
        }
    }

@@ -387,21 +419,25 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded {

        @JvmStatic
        fun newInstance(accountId: Long, noteId: Long): BaseNoteFragment {
            val fragment = NoteDirectEditFragment()
            val args = Bundle()
            args.putLong(PARAM_NOTE_ID, noteId)
            args.putLong(PARAM_ACCOUNT_ID, accountId)
            fragment.arguments = args
            return fragment
            val bundle = Bundle().apply {
                putLong(PARAM_NOTE_ID, noteId)
                putLong(PARAM_ACCOUNT_ID, accountId)
            }

            return NoteDirectEditFragment().apply {
                arguments = bundle
            }
        }

        @JvmStatic
        fun newInstanceWithNewNote(newNote: Note?): BaseNoteFragment {
            val fragment = NoteDirectEditFragment()
            val args = Bundle()
            args.putSerializable(PARAM_NEWNOTE, newNote)
            fragment.arguments = args
            return fragment
            val bundle = Bundle().apply {
                putSerializable(PARAM_NEWNOTE, newNote)
            }

            return NoteDirectEditFragment().apply {
                arguments = bundle
            }
        }
    }
}
+25 −10
Original line number Diff line number Diff line
@@ -40,6 +40,8 @@ import it.niedermann.owncloud.notes.databinding.FragmentNoteEditBinding;
import it.niedermann.owncloud.notes.persistence.entity.Note;
import it.niedermann.owncloud.notes.shared.model.ISyncCallback;
import it.niedermann.owncloud.notes.shared.util.DisplayUtils;
import kotlin.Unit;
import kotlin.jvm.functions.Function0;

public class NoteEditFragment extends SearchableBaseNoteFragment {

@@ -180,15 +182,24 @@ public class NoteEditFragment extends SearchableBaseNoteFragment {
    @Override
    protected void onNoteLoaded(Note note) {
        super.onNoteLoaded(note);
        if (binding == null) {
            return;
        }

        if (TextUtils.isEmpty(note.getContent())) {
            openSoftKeyboard();
        }

        binding.editContent.setMarkdownString(note.getContent());
        binding.editContent.setEnabled(true);

        lifecycleScopeIOJob(() -> {
            // load potential big note on IO Dispatchers
            final String content = note.getContent();
            final var sp = PreferenceManager.getDefaultSharedPreferences(requireContext().getApplicationContext());

            onMainThread(() -> {
                binding.editContent.setMarkdownString(content);
                binding.editContent.setEnabled(true);
                binding.editContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, getFontSizeFromPreferences(requireContext(), sp));

                if (sp.getBoolean(getString(R.string.pref_key_font), false)) {
                    binding.editContent.setTypeface(Typeface.MONOSPACE);
                }
@@ -196,6 +207,10 @@ public class NoteEditFragment extends SearchableBaseNoteFragment {
                if (lastSelection > 0 && binding.editContent.length() >= lastSelection) {
                    binding.editContent.setSelection(lastSelection);
                }
                return Unit.INSTANCE;
            });
            return Unit.INSTANCE;
        });
    }

    private void openSoftKeyboard() {
+38 −13
Original line number Diff line number Diff line
@@ -34,12 +34,16 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException;
import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException;
import com.nextcloud.android.sso.helper.SingleAccountHelper;
import com.owncloud.android.lib.common.utils.Log_OC;

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.model.ISyncCallback;
import it.niedermann.owncloud.notes.shared.util.SSOUtil;
import kotlin.Unit;
import kotlin.jvm.functions.Function0;

public class NotePreviewFragment extends SearchableBaseNoteFragment implements OnRefreshListener {

@@ -135,12 +139,27 @@ public class NotePreviewFragment extends SearchableBaseNoteFragment implements O
        super.onNoteLoaded(note);
        noteLoaded = true;
        registerInternalNoteLinkHandler();
        changedText = note.getContent();
        binding.singleNoteContent.setMarkdownString(note.getContent(), setScrollY);
        binding.singleNoteContent.getMarkdownString().observe(requireActivity(), (newContent) -> {

        lifecycleScopeIOJob(() -> {
            final String content = note.getContent();
            changedText = content;

            onMainThread(() -> {
                binding.singleNoteContent.setMarkdownString(content, setScrollY);

                final var activity = getActivity();
                if (activity == null) {
                    return Unit.INSTANCE;
                }

                binding.singleNoteContent.getMarkdownString().observe(activity, (newContent) -> {
                    changedText = newContent.toString();
                    saveNote(null);
                });
                return Unit.INSTANCE;
            });
            return Unit.INSTANCE;
        });
    }

    protected void registerInternalNoteLinkHandler() {
@@ -176,21 +195,27 @@ public class NotePreviewFragment extends SearchableBaseNoteFragment implements O
    public void onRefresh() {
        if (noteLoaded && repo.isSyncPossible() && SSOUtil.isConfigured(getContext())) {
            binding.swiperefreshlayout.setRefreshing(true);
            executor.submit(() -> {
            lifecycleScopeIOJob(() -> {
                try {
                    final var account = repo.getAccountByName(SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext()).name);
                    repo.addCallbackPull(account, () -> executor.submit(() -> {

                    repo.addCallbackPull(account, () -> {
                        note = repo.getNoteById(note.getId());
                        changedText = note.getContent();
                        requireActivity().runOnUiThread(() -> {
                            binding.singleNoteContent.setMarkdownString(note.getContent());
                        final String content = note.getContent();
                        changedText = content;

                        onMainThread(() -> {
                            binding.singleNoteContent.setMarkdownString(content);
                            binding.swiperefreshlayout.setRefreshing(false);
                            return Unit.INSTANCE;
                        });
                    }));
                    });

                    repo.scheduleSync(account, false);
                } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) {
                    e.printStackTrace();
                } catch (Exception e) {
                    Log_OC.e(TAG, "onRefresh exception: " + e);
                }
                return Unit.INSTANCE;
            });
        } else {
            binding.swiperefreshlayout.setRefreshing(false);
Loading