diff --git a/app/build.gradle b/app/build.gradle index 22768651a5..18a2a74ae6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -144,6 +144,7 @@ dependencies { // UI libs implementation 'com.github.AppIntro:AppIntro:6.2.0' + // Tool libraries //noinspection AnnotationProcessorOnCompilePath implementation 'commons-io:commons-io:2.7' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1218e983a7..37a1d289f5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -62,7 +62,7 @@ android:name=".activity.MainActivity" android:exported="true" android:label="@string/app_name" - android:launchMode="singleInstance" + android:launchMode="standard" android:taskAffinity=".activity.MainActivity" android:windowSoftInputMode="stateUnchanged|adjustResize"> @@ -167,7 +167,9 @@ = 0) { + if (lineNumber != null) { intent.putExtra(Document.EXTRA_FILE_LINE_NUMBER, lineNumber); } if (doPreview != null) { - intent.putExtra(DocumentActivity.EXTRA_DO_PREVIEW, doPreview); + intent.putExtra(Document.EXTRA_DO_PREVIEW, doPreview); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && as.isMultiWindowEnabled()) { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK); - } else { - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); } nextLaunchTransparentBg = (activity instanceof MainActivity); @@ -187,7 +191,7 @@ private void handleLaunchingIntent(final Intent intent) { final Document doc = new Document(file); Integer startLine = null; if (intent.hasExtra(Document.EXTRA_FILE_LINE_NUMBER)) { - startLine = intent.getIntExtra(Document.EXTRA_FILE_LINE_NUMBER, -1); + startLine = intent.getIntExtra(Document.EXTRA_FILE_LINE_NUMBER, Document.EXTRA_FILE_LINE_NUMBER_LAST); } else if (intentData != null) { final String line = intentData.getQueryParameter("line"); if (line != null) { @@ -200,7 +204,7 @@ private void handleLaunchingIntent(final Intent intent) { if (startLine != null) { // If a line is requested, open in edit mode so the line is shown startInPreview = false; - } else if (intent.getBooleanExtra(EXTRA_DO_PREVIEW, false) || file.getName().startsWith("index.")) { + } else if (intent.getBooleanExtra(Document.EXTRA_DO_PREVIEW, false) || file.getName().startsWith("index.")) { startInPreview = true; } @@ -266,11 +270,11 @@ public void setDocumentTitle(final String title) { } public void showTextEditor(final Document document, final Integer lineNumber, final Boolean startPreview) { - final GsFragmentBase currentFragment = getCurrentVisibleFragment(); + final GsFragmentBase currentFragment = getCurrentVisibleFragment(); final boolean sameDocumentRequested = ( currentFragment instanceof DocumentEditAndViewFragment && - document.getPath().equals(((DocumentEditAndViewFragment) currentFragment).getDocument().getPath())); + document.path.equals(((DocumentEditAndViewFragment) currentFragment).getDocument().path)); if (!sameDocumentRequested) { showFragment(DocumentEditAndViewFragment.newInstance(document, lineNumber, startPreview)); @@ -286,21 +290,15 @@ protected void onResume() { @Override @SuppressWarnings("StatementWithEmptyBody") public void onBackPressed() { - FragmentManager fragMgr = getSupportFragmentManager(); - GsFragmentBase top = getCurrentVisibleFragment(); - if (top != null) { - if (!top.onBackPressed()) { - if (fragMgr.getBackStackEntryCount() == 1) { - // Back action was not handled by fragment, handle in activity - } else if (fragMgr.getBackStackEntryCount() > 0) { - // Back action was to go one fragment back - fragMgr.popBackStack(); - return; - } - } else { - // Was handled by child fragment - return; - } + final int entryCount = _fragManager.getBackStackEntryCount(); + final GsFragmentBase top = getCurrentVisibleFragment(); + + // We pop the stack to go back to the previous fragment + // if the top fragment does not handle the back press + // Doesn't actually get called as we have 1 fragment in the stack + if (top != null && !top.onBackPressed() && entryCount > 1) { + _fragManager.popBackStack(); + return; } // Handle in this activity @@ -313,10 +311,10 @@ public void onBackPressed() { @Override public boolean onKeyDown(int keyCode, KeyEvent event) { - return super.onReceiveKeyPress(getCurrentVisibleFragment(), keyCode, event) ? true : super.onKeyDown(keyCode, event); + return super.onReceiveKeyPress(getCurrentVisibleFragment(), keyCode, event) || super.onKeyDown(keyCode, event); } - public GsFragmentBase showFragment(GsFragmentBase fragment) { + public GsFragmentBase showFragment(GsFragmentBase fragment) { if (fragment != getCurrentVisibleFragment()) { _fragManager.beginTransaction() .replace(R.id.document__placeholder_fragment, fragment, fragment.getFragmentTag()) @@ -327,11 +325,11 @@ public GsFragmentBase showFragment(GsFragmentBase fragment) { return fragment; } - public synchronized GsFragmentBase getExistingFragment(final String fragmentTag) { - return (GsFragmentBase) getSupportFragmentManager().findFragmentByTag(fragmentTag); + public synchronized GsFragmentBase getExistingFragment(final String fragmentTag) { + return (GsFragmentBase) getSupportFragmentManager().findFragmentByTag(fragmentTag); } - private GsFragmentBase getCurrentVisibleFragment() { - return (GsFragmentBase) getSupportFragmentManager().findFragmentById(R.id.document__placeholder_fragment); + private GsFragmentBase getCurrentVisibleFragment() { + return (GsFragmentBase) getSupportFragmentManager().findFragmentById(R.id.document__placeholder_fragment); } } diff --git a/app/src/main/java/net/gsantner/notepad2/activity/DocumentEditAndViewFragment.java b/app/src/main/java/net/gsantner/notepad2/activity/DocumentEditAndViewFragment.java index 21914c76fc..1ad05b7ff9 100644 --- a/app/src/main/java/net/gsantner/notepad2/activity/DocumentEditAndViewFragment.java +++ b/app/src/main/java/net/gsantner/notepad2/activity/DocumentEditAndViewFragment.java @@ -149,6 +149,7 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { _webView.setWebChromeClient(new GsWebViewChromeClient(_webView, activity, view.findViewById(R.id.document__fragment_fullscreen_overlay))); _webView.setWebViewClient(_webViewClient); _webView.addJavascriptInterface(this, "Android"); + _webView.setBackgroundColor(Color.TRANSPARENT); WebSettings webSettings = _webView.getSettings(); webSettings.setBuiltInZoomControls(true); webSettings.setDisplayZoomControls(false); @@ -170,17 +171,15 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { // Upon construction, the document format has been determined from extension etc // Here we replace it with the last saved format. - _document.setFormat(_appSettings.getDocumentFormat(_document.getPath(), _document.getFormat())); - applyTextFormat(_document.getFormat()); - _format.getActions().setDocument(_document); + applyTextFormat(_appSettings.getDocumentFormat(_document.path, _document.getFormat())); if (activity instanceof DocumentActivity) { - ((DocumentActivity) activity).setDocumentTitle(_document.getTitle()); + ((DocumentActivity) activity).setDocumentTitle(_document.title); } // Preview mode set before loadDocument to prevent flicker final Bundle args = getArguments(); - final boolean startInPreview = _appSettings.getDocumentPreviewState(_document.getPath()); + final boolean startInPreview = _appSettings.getDocumentPreviewState(_document.path); if (args != null && savedInstanceState == null) { // Use the launch flag on first launch setViewModeVisibility(args.getBoolean(START_PREVIEW, startInPreview), false); } else { @@ -199,19 +198,18 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { // Configure the editor. Doing so after load helps prevent some errors // --------------------------------------------------------- _hlEditor.setLineSpacing(0, 1); - _hlEditor.setTextSize(TypedValue.COMPLEX_UNIT_SP, _appSettings.getDocumentFontSize(_document.getPath())); + _hlEditor.setTextSize(TypedValue.COMPLEX_UNIT_SP, _appSettings.getDocumentFontSize(_document.path)); _hlEditor.setTypeface(GsFontPreferenceCompat.typeface(getContext(), _appSettings.getFontFamily(), Typeface.NORMAL)); _hlEditor.setBackgroundColor(_appSettings.getEditorBackgroundColor()); _hlEditor.setTextColor(_appSettings.getEditorForegroundColor()); _hlEditor.setGravity(_appSettings.isEditorStartEditingInCenter() ? Gravity.CENTER : Gravity.NO_GRAVITY); - _hlEditor.setHighlightingEnabled(_appSettings.getDocumentHighlightState(_document.getPath(), _hlEditor.getText())); - _hlEditor.setLineNumbersEnabled(_appSettings.getDocumentLineNumbersEnabled(_document.getPath())); - _hlEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.getPath())); + _hlEditor.setHighlightingEnabled(_appSettings.getDocumentHighlightState(_document.path, _hlEditor.getText())); + _hlEditor.setLineNumbersEnabled(_appSettings.getDocumentLineNumbersEnabled(_document.path)); + _hlEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.path)); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Do not need to send contents to accessibility _hlEditor.setImportantForAccessibility(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS); } - _webView.setBackgroundColor(Color.TRANSPARENT); // Various settings updateMenuToggleStates(0); @@ -247,22 +245,23 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { } @Override - public void onFragmentFirstTimeVisible() { - _primaryScrollView.invalidate(); - int startPos = _appSettings.getLastEditPosition(_document.getPath(), _hlEditor.length()); - - // First start - overwrite start position if needed - if (_savedInstanceState == null) { - final Bundle args = getArguments(); - if (args != null && args.containsKey(Document.EXTRA_FILE_LINE_NUMBER)) { - final int lno = args.getInt(Document.EXTRA_FILE_LINE_NUMBER); - if (lno >= 0) { - startPos = TextViewUtils.getIndexFromLineOffset(_hlEditor.getText(), lno, 0); - } else if (lno == Document.EXTRA_FILE_LINE_NUMBER_LAST) { - startPos = _hlEditor.length(); - } + protected void onFragmentFirstTimeVisible() { + final Bundle args = getArguments(); + + int startPos = _appSettings.getLastEditPosition(_document.path, _hlEditor.length()); + if (args != null && args.containsKey(Document.EXTRA_FILE_LINE_NUMBER)) { + final int lno = args.getInt(Document.EXTRA_FILE_LINE_NUMBER); + if (lno >= 0) { + startPos = TextViewUtils.getIndexFromLineOffset(_hlEditor.getText(), lno, 0); + } else if (lno == Document.EXTRA_FILE_LINE_NUMBER_LAST) { + startPos = _hlEditor.length(); } } + + _primaryScrollView.invalidate(); + // Can affect layout so run before setting scroll position + _hlEditor.recomputeHighlighting(); + TextViewUtils.setSelectionAndShow(_hlEditor, startPos); } @@ -277,9 +276,9 @@ public void onResume() { public void onPause() { saveDocument(false); _webView.onPause(); - _appSettings.addRecentFile(_document.getFile()); - _appSettings.setDocumentPreviewState(_document.getPath(), _isPreviewVisible); - _appSettings.setLastEditPosition(_document.getPath(), _hlEditor.getSelectionStart()); + _appSettings.addRecentFile(_document.file); + _appSettings.setDocumentPreviewState(_document.path, _isPreviewVisible); + _appSettings.setLastEditPosition(_document.path, TextViewUtils.getSelection(_hlEditor)[0]); super.onPause(); } @@ -424,8 +423,7 @@ public boolean loadDocument() { _editTextUndoRedoHelper.setTextView(_hlEditor); } - _hlEditor.setSelection(sel[0], sel[1]); - TextViewUtils.showSelection(_hlEditor); + TextViewUtils.setSelectionAndShow(_hlEditor, sel); } checkTextChangeState(); @@ -469,6 +467,7 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { setViewModeVisibility(!_isPreviewVisible); return true; } + case R.string.action_format_wikitext: case R.string.action_format_keyvalue: case R.string.action_format_plaintext: @@ -476,7 +475,7 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { if (itemId != _document.getFormat()) { _document.setFormat(itemId); applyTextFormat(itemId); - _appSettings.setDocumentFormat(_document.getPath(), _document.getFormat()); + _appSettings.setDocumentFormat(_document.path, _document.getFormat()); } return true; } @@ -485,26 +484,29 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { _format.getActions().onSearch(); return true; } + case R.id.action_line_numbers: { final boolean newState = !_hlEditor.getLineNumbersEnabled(); - _appSettings.setDocumentLineNumbersEnabled(_document.getPath(), newState); + _appSettings.setDocumentLineNumbersEnabled(_document.path, newState); _hlEditor.setLineNumbersEnabled(newState); updateMenuToggleStates(0); return true; } + case R.id.action_info: { if (saveDocument(false)) { // In order to have the correct info displayed - FileInfoDialog.show(_document.getFile(), getParentFragmentManager()); + FileInfoDialog.show(_document.file, getParentFragmentManager()); } return true; } case R.id.action_set_font_size: { - MarkorDialogFactory.showFontSizeDialog(activity, _appSettings.getDocumentFontSize(_document.getPath()), (newSize) -> { + MarkorDialogFactory.showFontSizeDialog(activity, _appSettings.getDocumentFontSize(_document.path), (newSize) -> { _hlEditor.setTextSize(TypedValue.COMPLEX_UNIT_SP, (float) newSize); - _appSettings.setDocumentFontSize(_document.getPath(), newSize); + _appSettings.setDocumentFontSize(_document.path, newSize); }); return true; } + default: { return super.onOptionsItemSelected(item); } @@ -520,6 +522,7 @@ public void checkTextChangeState() { } } + @Override public void applyTextFormat(final int textFormatId) { final Activity activity = getActivity(); if (activity == null) { @@ -530,10 +533,11 @@ public void applyTextFormat(final int textFormatId) { _hlEditor.setHighlighter(_format.getHighlighter()); _hlEditor.setDynamicHighlightingEnabled(_appSettings.isDynamicHighlightingEnabled()); _hlEditor.setAutoFormatters(_format.getAutoFormatInputFilter(), _format.getAutoFormatTextWatcher()); - _hlEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.getPath())); + _hlEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.path)); _format.getActions() + .setDocument(_document) // elyahw line added by merge request .setUiReferences(activity, _hlEditor, _webView); -// .recreateActionButtons(_textActionsBar, _isPreviewVisible ? ActionButtonBase.ActionItem.DisplayMode.VIEW : ActionButtonBase.ActionItem.DisplayMode.EDIT); + updateMenuToggleStates(_format.getFormatId()); } @@ -567,7 +571,7 @@ public void errorClipText() { } public boolean isSdStatusBad() { - if (_cu.isUnderStorageAccessFolder(getContext(), _document.getFile(), false) && + if (_cu.isUnderStorageAccessFolder(getContext(), _document.file, false) && _cu.getStorageAccessFrameworkTreeUri(getContext()) == null) { _cu.showMountSdDialog(getActivity()); return true; @@ -580,7 +584,7 @@ public boolean isStateBad() { return (_document == null || _hlEditor == null || _appSettings == null || - !_cu.canWriteFile(getContext(), _document.getFile(), false, true)); + !_cu.canWriteFile(getContext(), _document.file, false, true)); } // Save the file @@ -596,7 +600,7 @@ public boolean saveDocument(final boolean forceSaveEmpty) { if (!_document.isContentSame(text)) { // Touch parent folder on edit (elyahw) ---------- - File ff = _document.getFile(); + File ff = _document.file; String ppath = ""; ppath = ff.getAbsolutePath(); //System.out.println("Touching parent folder\n"); diff --git a/app/src/main/java/net/gsantner/notepad2/activity/MainActivity.java b/app/src/main/java/net/gsantner/notepad2/activity/MainActivity.java index 52d20b893c..b88d9224e5 100644 --- a/app/src/main/java/net/gsantner/notepad2/activity/MainActivity.java +++ b/app/src/main/java/net/gsantner/notepad2/activity/MainActivity.java @@ -14,7 +14,6 @@ import android.graphics.Color; import android.os.Build; import android.os.Bundle; -import android.os.Handler; import android.text.TextUtils; import android.util.Log; import android.view.KeyEvent; @@ -31,7 +30,6 @@ import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.navigation.NavigationBarView; import net.gsantner.notepad2.BuildConfig; import net.gsantner.notepad2.R; @@ -52,7 +50,7 @@ //import other.writeily.widget.WrMarkorWidgetProvider; -public class MainActivity extends MarkorBaseActivity implements GsFileBrowserFragment.FilesystemFragmentOptionsListener, NavigationBarView.OnItemSelectedListener { +public class MainActivity extends MarkorBaseActivity implements GsFileBrowserFragment.FilesystemFragmentOptionsListener { public static boolean IS_DEBUG_ENABLED = false; @@ -63,7 +61,6 @@ public class MainActivity extends MarkorBaseActivity implements GsFileBrowserFra // private MoreFragment _more; private FloatingActionButton _fab; - private boolean _doubleBackToExitPressedOnce; private MarkorContextUtils _cu; private File _quickSwitchPrevFolder = null; @@ -101,7 +98,7 @@ public void onPageSelected(int position) { // Setup viewpager _viewPager.setAdapter(new SectionsPagerAdapter(getSupportFragmentManager())); _viewPager.setOffscreenPageLimit(4); -// _bottomNav.setOnItemSelectedListener(this); + reduceViewpagerSwipeSensitivity(); // noinspection PointlessBooleanExpression - Send Test intent @@ -292,38 +289,20 @@ private void newItemCallback(final File file) { @Override public void onBackPressed() { - // Exit confirmed with 2xBack - if (_doubleBackToExitPressedOnce) { - super.onBackPressed(); - _appSettings.setFileBrowserLastBrowsedFolder(_appSettings.getNotebookDirectory()); - return; - } - // Check if fragment handled back press final GsFragmentBase frag = getPosFragment(getCurrentPos()); - if (frag != null && frag.onBackPressed()) { - return; + if (frag == null || !frag.onBackPressed()) { + super.onBackPressed(); } - - // Confirm exit with back / snack bar - _doubleBackToExitPressedOnce = true; - _cu.showSnackBar(this, R.string.press_back_again_to_exit, false, R.string.exit, view -> finish()); - new Handler().postDelayed(() -> _doubleBackToExitPressedOnce = false, 2000); - } - - @Override - public boolean onNavigationItemSelected(@NonNull MenuItem item) { - _viewPager.setCurrentItem(tabIdToPos(item.getItemId())); - return true; } public String getFileBrowserTitle() { - final File file = _appSettings.getFileBrowserLastBrowsedFolder(); - String title = getString(R.string.app_name); - if (!_appSettings.getNotebookDirectory().getAbsolutePath().equals(file.getAbsolutePath())) { - title = "> " + file.getName(); + final File file = _notebook.getCurrentFolder(); + if (file != null && !_appSettings.getNotebookDirectory().equals(file)) { + return "> " + file.getName(); + } else { + return getString(R.string.app_name); } - return title; } public int tabIdToPos(final int id) { @@ -373,7 +352,7 @@ public void onViewPagerPageSelected(final int pos) { if (pos == tabIdToPos(R.id.nav_notebook)) { _fab.show(); - _cu.showSoftKeyboard(this, false); + _cu.showSoftKeyboard(this, false, _notebook.getView()); } else { _fab.hide(); restoreDefaultToolbar(); diff --git a/app/src/main/java/net/gsantner/notepad2/activity/openeditor/OpenFromShortcutOrWidgetActivity.java b/app/src/main/java/net/gsantner/notepad2/activity/openeditor/OpenFromShortcutOrWidgetActivity.java index 7a08af696c..01ed042854 100644 --- a/app/src/main/java/net/gsantner/notepad2/activity/openeditor/OpenFromShortcutOrWidgetActivity.java +++ b/app/src/main/java/net/gsantner/notepad2/activity/openeditor/OpenFromShortcutOrWidgetActivity.java @@ -9,9 +9,9 @@ import net.gsantner.notepad2.util.MarkorContextUtils; import net.gsantner.opoc.frontend.filebrowser.GsFileBrowserListAdapter; - import java.io.File; + /** * This Activity exists solely to launch DocumentActivity with the correct intent * it is necessary as widget and shortcut intents do not respect MultipleTask etc @@ -31,10 +31,7 @@ protected void onNewIntent(final Intent intent) { } private void launchActivityAndFinish(Intent intent) { - final File file = MarkorContextUtils.getIntentFile(intent, null); - if (file != null) { - DocumentActivity.launch(this, file, null, null); - } + DocumentActivity.launch(this, intent); finish(); } } diff --git a/app/src/main/java/net/gsantner/notepad2/format/ActionButtonBase.java b/app/src/main/java/net/gsantner/notepad2/format/ActionButtonBase.java index 3065fb6034..af0d79b30d 100644 --- a/app/src/main/java/net/gsantner/notepad2/format/ActionButtonBase.java +++ b/app/src/main/java/net/gsantner/notepad2/format/ActionButtonBase.java @@ -12,10 +12,11 @@ import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; -import android.content.DialogInterface; import android.content.SharedPreferences; import android.os.Handler; import android.text.Editable; +import android.text.Selection; +import android.text.Spannable; import android.text.TextUtils; import android.view.HapticFeedbackConstants; import android.view.KeyEvent; @@ -53,7 +54,6 @@ import net.gsantner.opoc.util.GsCollectionUtils; //import net.gsantner.opoc.util.GsContextUtils; import net.gsantner.opoc.util.GsFileUtils; -import net.gsantner.opoc.wrapper.GsCallback; import java.io.File; import java.util.ArrayList; @@ -90,12 +90,7 @@ public abstract class ActionButtonBase { private static final Pattern UNTRIMMED_TEXT = Pattern.compile("(\\s*)(.*?)(\\s*)", Pattern.DOTALL); -// public ActionButtonBase(@NonNull final Context context, final Document document) { -// _document = document; -// _appSettings = ApplicationObject.settings(); -// _buttonHorizontalMargin = GsContextUtils.instance.convertDpToPx(context, _appSettings.getEditorActionButtonItemPadding()); -// _indent = _appSettings.getDocumentIndentSize(_document != null ? _document.getPath() : null); -// } + // Override to implement custom onClick public boolean onActionClick(final @StringRes int action) { @@ -401,36 +396,48 @@ public static void runRegexReplaceAction(final Editable editable, final ReplaceP private static void runRegexReplaceAction(final Editable editable, final List patterns) { - TextViewUtils.withKeepSelection(editable, (selStart, selEnd) -> { + final int[] sel = TextViewUtils.getSelection(editable); + if (sel[0] < 0) { + return; + } + final int[][] offsets = TextViewUtils.getLineOffsetFromIndex(editable, sel); - final TextViewUtils.ChunkedEditable text = TextViewUtils.ChunkedEditable.wrap(editable); - // Start of line on which sel begins - final int selStartStart = TextViewUtils.getLineStart(text, selStart); + final TextViewUtils.ChunkedEditable text = TextViewUtils.ChunkedEditable.wrap(editable); + // Start of line on which sel begins + final int selStartStart = TextViewUtils.getLineStart(text, sel[0]); - // Number of lines we will be modifying - final int lineCount = GsTextUtils.countChars(text, selStart, selEnd, '\n')[0] + 1; - int lineStart = selStartStart; + // Number of lines we will be modifying + final int lineCount = GsTextUtils.countChars(text, sel[0], sel[1], '\n')[0] + 1; + int lineStart = selStartStart; - for (int i = 0; i < lineCount; i++) { + for (int i = 0; i < lineCount; i++) { - int lineEnd = TextViewUtils.getLineEnd(text, lineStart); - final String line = TextViewUtils.toString(text, lineStart, lineEnd); + int lineEnd = TextViewUtils.getLineEnd(text, lineStart); + final String line = TextViewUtils.toString(text, lineStart, lineEnd); - for (final ReplacePattern pattern : patterns) { - if (pattern.matcher.reset(line).find()) { - if (!pattern.isSameReplace()) { - text.replace(lineStart, lineEnd, pattern.replace()); - } - break; + for (final ReplacePattern pattern : patterns) { + if (pattern.matcher.reset(line).find()) { + if (!pattern.isSameReplace()) { + text.replace(lineStart, lineEnd, pattern.replace()); } + break; } - - lineStart = TextViewUtils.getLineEnd(text, lineStart) + 1; } - text.applyChanges(); - }); + lineStart = TextViewUtils.getLineEnd(text, lineStart) + 1; + } + + text.applyChanges(); + TextViewUtils.setSelectionFromOffsets(editable, offsets); + } + + public static void surroundBlock(final Editable text, final String delim) { + final int[] sel = TextViewUtils.getLineSelection(text); + if (text != null && sel[0] >= 0) { + final CharSequence line = text.subSequence(sel[0], sel[1]); + text.replace(sel[0], sel[1], delim + "\n" + line + "\n" + delim); + } } protected void runSurroundAction(final String delim) { @@ -447,14 +454,14 @@ protected void runSurroundAction(final String delim) { */ protected void runSurroundAction(final String open, final String close, final boolean trim) { final Editable text = _hlEditor.getText(); - if (text == null) { + final int[] sel = TextViewUtils.getSelection(text); + if (sel[0] < 0) { return; } // Detect if delims within or around selection // If so, remove it // ------------------------------------------------------------------------- - final int[] sel = TextViewUtils.getSelection(_hlEditor); final int ss = sel[0], se = sel[1]; final int ol = open.length(), cl = close.length(), sl = se - ss; // Left as a CharSequence to help maintain spans @@ -580,15 +587,13 @@ protected final boolean runCommonAction(final @StringRes int action) { runRenumberOrderedListIfRequired(); return true; } -// case R.string.abid_common_insert_snippet: { -// MarkorDialogFactory.showInsertSnippetDialog(_activity, (snip) -> { -// _hlEditor.insertOrReplaceTextOnCursor(TextViewUtils.interpolateSnippet(snip, _document.getTitle(), TextViewUtils.getSelectedText(_hlEditor))); -// _lastSnip = snip; -// }); -// return true; -// } + case R.string.abid_common_open_link_browser: { final int sel = TextViewUtils.getSelection(_hlEditor)[0]; + if (sel < 0) { + return true; + } + final String line = TextViewUtils.getSelectedLines(_hlEditor, sel); final int cursor = sel - TextViewUtils.getLineStart(_hlEditor.getText(), sel); @@ -599,13 +604,12 @@ protected final boolean runCommonAction(final @StringRes int action) { if (WEB_URL.matcher(resource).matches()) { url = resource; } else { - final File f = GsFileUtils.makeAbsolute(resource, _document.getFile().getParentFile()); + final File f = GsFileUtils.makeAbsolute(resource, _document.file.getParentFile()); if (f.canRead()) { DocumentActivity.launch(getActivity(), f, null, null); return true; } } - } // Then try to pull a tag @@ -616,6 +620,7 @@ protected final boolean runCommonAction(final @StringRes int action) { } _cu.openWebpageInExternalBrowser(getContext(), url); } + return true; } case R.string.abid_common_special_key: { @@ -624,15 +629,20 @@ protected final boolean runCommonAction(final @StringRes int action) { } case R.string.abid_common_new_line_below: { // Go to end of line, works with wrapped lines too - _hlEditor.setSelection(TextViewUtils.getLineEnd(text, TextViewUtils.getSelection(_hlEditor)[1])); - _hlEditor.simulateKeyPress(KeyEvent.KEYCODE_ENTER); + final int sel = TextViewUtils.getSelection(_hlEditor)[1]; + if (sel > 0) { + _hlEditor.setSelection(TextViewUtils.getLineEnd(text, sel)); + _hlEditor.simulateKeyPress(KeyEvent.KEYCODE_ENTER); + } return true; } case R.string.abid_common_delete_lines: { - final int[] sel = TextViewUtils.getLineSelection(_hlEditor); - final boolean lastLine = sel[1] == text.length(); - final boolean firstLine = sel[0] == 0; - text.delete(sel[0] - (lastLine && !firstLine ? 1 : 0), sel[1] + (lastLine ? 0 : 1)); + final int[] sel = TextViewUtils.getLineSelection(text); + if (GsTextUtils.isValidSelection(text, sel)) { + final boolean lastLine = sel[1] == text.length(); + final boolean firstLine = sel[0] == 0; + text.delete(sel[0] - (lastLine && !firstLine ? 1 : 0), sel[1] + (lastLine ? 0 : 1)); + } return true; } case R.string.abid_common_duplicate_lines: { @@ -653,7 +663,7 @@ protected final boolean runCommonAction(final @StringRes int action) { // return true; // } case R.string.abid_common_view_file_in_other_app: { - _cu.viewFileInOtherApp(getContext(), _document.getFile(), GsFileUtils.getMimeType(_document.getFile())); + _cu.viewFileInOtherApp(getContext(), _document.file, GsFileUtils.getMimeType(_document.file)); return true; } // case R.string.abid_common_rotate_screen: { @@ -673,7 +683,7 @@ protected final boolean runCommonLongPressAction(@StringRes int action) { case R.string.abid_common_indent: { MarkorDialogFactory.showIndentSizeDialog(_activity, _indent, (size) -> { _indent = Integer.parseInt(size); - _appSettings.setDocumentIndentSize(_document.getPath(), _indent); + _appSettings.setDocumentIndentSize(_document.path, _indent); }); return true; } @@ -702,7 +712,7 @@ protected final boolean runCommonLongPressAction(@StringRes int action) { } case R.string.abid_common_insert_snippet: { if (!TextUtils.isEmpty(_lastSnip)) { - _hlEditor.insertOrReplaceTextOnCursor(TextViewUtils.interpolateSnippet(_lastSnip, _document.getTitle(), TextViewUtils.getSelectedText(_hlEditor))); + _hlEditor.insertOrReplaceTextOnCursor(TextViewUtils.interpolateSnippet(_lastSnip, _document.title, TextViewUtils.getSelectedText(_hlEditor))); } return true; } @@ -742,32 +752,30 @@ public ActionItem setRepeatable(boolean repeatable) { public static void moveLineSelectionBy1(final HighlightingEditor hlEditor, final boolean isUp) { final Editable text = hlEditor.getText(); + final int[] sel = TextViewUtils.getSelection(text); + if (text == null || sel[0] < 0) { + return; + } - final int[] sel = TextViewUtils.getSelection(hlEditor); - final int linesStart = TextViewUtils.getLineStart(text, sel[0]); - final int linesEnd = TextViewUtils.getLineEnd(text, sel[1]); + final int[] lineSel = TextViewUtils.getLineSelection(text, sel); - if ((isUp && linesStart > 0) || (!isUp && linesEnd < text.length())) { - final CharSequence lines = text.subSequence(linesStart, linesEnd); + if ((isUp && lineSel[0] > 0) || (!isUp && lineSel[1] < text.length())) { + final CharSequence lines = text.subSequence(lineSel[0], lineSel[1]); - final int altStart = isUp ? TextViewUtils.getLineStart(text, linesStart - 1) : linesEnd + 1; - final int altEnd = TextViewUtils.getLineEnd(text, altStart); - final CharSequence altLine = text.subSequence(altStart, altEnd); + final int[] altSel = TextViewUtils.getLineSelection(text, isUp ? lineSel[0] - 1 : lineSel[1] + 1); + final CharSequence altLine = text.subSequence(altSel[0], altSel[1]); - final int[] selStart = TextViewUtils.getLineOffsetFromIndex(text, sel[0]); - final int[] selEnd = TextViewUtils.getLineOffsetFromIndex(text, sel[1]); + final int[][] offsets = TextViewUtils.getLineOffsetFromIndex(text, sel); hlEditor.withAutoFormatDisabled(() -> { final String newPair = String.format("%s\n%s", isUp ? lines : altLine, isUp ? altLine : lines); - text.replace(Math.min(linesStart, altStart), Math.max(altEnd, linesEnd), newPair); + text.replace(Math.min(lineSel[0], altSel[0]), Math.max(lineSel[1], altSel[1]), newPair); }); - selStart[0] += isUp ? -1 : 1; - selEnd[0] += isUp ? -1 : 1; + offsets[0][0] += isUp ? -1 : 1; + offsets[1][0] += isUp ? -1 : 1; - hlEditor.setSelection( - TextViewUtils.getIndexFromLineOffset(text, selStart), - TextViewUtils.getIndexFromLineOffset(text, selEnd)); + TextViewUtils.setSelectionFromOffsets(text, offsets); } } @@ -776,38 +784,28 @@ public static void duplicateLineSelection(final HighlightingEditor hlEditor) { // cursor is preserved regarding column position (helpful for editing the // newly created line at the selected position right away). final Editable text = hlEditor.getText(); + final int[] sel = TextViewUtils.getSelection(text); + if (sel[0] >= 0) { + final int linesStart = TextViewUtils.getLineStart(text, sel[0]); + final int linesEnd = TextViewUtils.getLineEnd(text, sel[1]); - final int[] sel = TextViewUtils.getSelection(hlEditor); - final int linesStart = TextViewUtils.getLineStart(text, sel[0]); - final int linesEnd = TextViewUtils.getLineEnd(text, sel[1]); - - final CharSequence lines = text.subSequence(linesStart, linesEnd); - - final int[] selStart = TextViewUtils.getLineOffsetFromIndex(text, sel[0]); - final int[] selEnd = TextViewUtils.getLineOffsetFromIndex(text, sel[1]); - - hlEditor.withAutoFormatDisabled(() -> { - // Prepending the newline instead of appending it is required for making - // this logic work even if it's about the last line in the given file. - final String lines_final = String.format("\n%s", lines); - text.insert(linesEnd, lines_final); - }); + final CharSequence lines = text.subSequence(linesStart, linesEnd); - final int sel_offset = selEnd[0] - selStart[0] + 1; - selStart[0] += sel_offset; - selEnd[0] += sel_offset; + final int[][] offsets = TextViewUtils.getLineOffsetFromIndex(text, sel); - hlEditor.setSelection( - TextViewUtils.getIndexFromLineOffset(text, selStart), - TextViewUtils.getIndexFromLineOffset(text, selEnd)); - } + hlEditor.withAutoFormatDisabled(() -> { + // Prepending the newline instead of appending it is required for making + // this logic work even if it's about the last line in the given file. + final String lines_final = String.format("\n%s", lines); + text.insert(linesEnd, lines_final); + }); - public void withKeepSelection(final GsCallback.a2 action) { - _hlEditor.withAutoFormatDisabled(() -> TextViewUtils.withKeepSelection(_hlEditor.getText(), action)); - } + final int lineCount = offsets[1][0] - offsets[0][0] + 1; + offsets[0][0] += lineCount; + offsets[1][0] += lineCount; - public void withKeepSelection(final GsCallback.a0 action) { - withKeepSelection((start, end) -> action.callback()); + TextViewUtils.setSelectionFromOffsets(text, offsets); + } } // Derived classes should override this to implement format-specific renumber logic @@ -830,12 +828,6 @@ private String rstr(@StringRes int resKey) { } public void runSpecialKeyAction() { - // Needed to prevent selection from being overwritten on refocus - final int[] sel = TextViewUtils.getSelection(_hlEditor); - _hlEditor.clearFocus(); - _hlEditor.requestFocus(); - _hlEditor.setSelection(sel[0], sel[1]); - MarkorDialogFactory.showSpecialKeyDialog(getActivity(), _specialKeyDialogState, (callbackPayload) -> { if (!_hlEditor.hasSelection() && _hlEditor.length() > 0) { _hlEditor.requestFocus(); @@ -875,11 +867,17 @@ public void runSpecialKeyAction() { } else if (callbackPayload.equals(rstr(R.string.char_punctation_mark_arrows))) { _hlEditor.insertOrReplaceTextOnCursor("»«"); } else if (callbackPayload.equals(rstr(R.string.select_current_line))) { - _hlEditor.setSelectionExpandWholeLines(); + selectWholeLines(_hlEditor.getText()); } }); } + public static void selectWholeLines(final @Nullable Spannable text) { + final int[] sel = TextViewUtils.getLineSelection(text); + if (sel[0] >= 0) { + Selection.setSelection(text, sel[0], sel[1]); + } + } public void runJumpBottomTopAction(ActionItem.DisplayMode displayMode) { if (displayMode == ActionItem.DisplayMode.EDIT) { diff --git a/app/src/main/java/net/gsantner/notepad2/format/TextConverterBase.java b/app/src/main/java/net/gsantner/notepad2/format/TextConverterBase.java index d788884ee4..cf35c4fdb7 100644 --- a/app/src/main/java/net/gsantner/notepad2/format/TextConverterBase.java +++ b/app/src/main/java/net/gsantner/notepad2/format/TextConverterBase.java @@ -107,12 +107,12 @@ public String convertMarkupShowInWebView( final boolean lineNum) { String html; try { - html = convertMarkup(content, context, lightMode, lineNum, document.getFile()); + html = convertMarkup(content, context, lightMode, lineNum, document.file); } catch (Exception e) { html = "Please report at project issue tracker: " + e; } - String parent = document.getFile().getParent(); + String parent = document.file.getParent(); if (parent == null) { parent = _appSettings.getNotebookDirectory().getAbsolutePath(); } diff --git a/app/src/main/java/net/gsantner/notepad2/frontend/MarkorDialogFactory.java b/app/src/main/java/net/gsantner/notepad2/frontend/MarkorDialogFactory.java index 9140960575..0984d8b931 100644 --- a/app/src/main/java/net/gsantner/notepad2/frontend/MarkorDialogFactory.java +++ b/app/src/main/java/net/gsantner/notepad2/frontend/MarkorDialogFactory.java @@ -812,4 +812,4 @@ public static void baseConf(Activity activity, DialogOptions dopt) { dopt.highlightColor = ContextCompat.getColor(activity, R.color.accent); dopt.dialogStyle = R.style.Theme_AppCompat_DayNight_Dialog_Rounded; } -} +} \ No newline at end of file diff --git a/app/src/main/java/net/gsantner/notepad2/frontend/NewFileDialog.java b/app/src/main/java/net/gsantner/notepad2/frontend/NewFileDialog.java index 894cd58ab7..07acb59ede 100644 --- a/app/src/main/java/net/gsantner/notepad2/frontend/NewFileDialog.java +++ b/app/src/main/java/net/gsantner/notepad2/frontend/NewFileDialog.java @@ -95,19 +95,12 @@ public static NewFileDialog newInstance( public Dialog onCreateDialog(Bundle savedInstanceState) { final File file = (File) getArguments().getSerializable(EXTRA_DIR); final boolean allowCreateDir = getArguments().getBoolean(EXTRA_ALLOW_CREATE_DIR); - - LayoutInflater inflater = LayoutInflater.from(getActivity()); - AlertDialog.Builder dialogBuilder = makeDialog(file, allowCreateDir, inflater); - AlertDialog dialog = dialogBuilder.show(); - Window w; - if ((w = dialog.getWindow()) != null) { - w.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT); - } - return dialog; + final LayoutInflater inflater = LayoutInflater.from(getActivity()); + return makeDialog(file, allowCreateDir, inflater); } @SuppressLint("SetTextI18n") - private AlertDialog.Builder makeDialog(final File basedir, final boolean allowCreateDir, LayoutInflater inflater) { + private AlertDialog makeDialog(final File basedir, final boolean allowCreateDir, LayoutInflater inflater) { final Activity activity = getActivity(); final AppSettings appSettings = ApplicationObject.settings(); final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(inflater.getContext(), R.style.Theme_AppCompat_DayNight_Dialog_Rounded); @@ -264,8 +257,8 @@ else if (!title.isEmpty() && !format.contains("{{title}}")) document.saveContent(activity, content.first, cu, true); // We only make these changes if the file did not already exist - appSettings.setDocumentFormat(document.getPath(), fmt.format); - appSettings.setLastEditPosition(document.getPath(), content.second); + appSettings.setDocumentFormat(document.path, fmt.format); + appSettings.setLastEditPosition(document.path, content.second); appSettings.setNewFileDialogLastUsedExtension(".txt"); @@ -312,9 +305,22 @@ else if (!title.isEmpty() && !format.contains("{{title}}")) dialogBuilder.setNeutralButton("", null); } - titleEdit.requestFocus(); - - return dialogBuilder; + // elyahw merge request.. + // titleEdit.requestFocus(); + ///////////////// + // Initial creation - loop through and set type + final int lastUsedType = appSettings.getNewFileDialogLastUsedType(); + final List indices = GsCollectionUtils.indices(formats, f -> f.format == lastUsedType); + + final AlertDialog dialog = dialogBuilder.show(); + final Window win = dialog.getWindow(); + if (win != null) { + win.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT); + win.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN | WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); + } + titleEdit.post(titleEdit::requestFocus); + ///////////////// elyahw + return dialog; } private void callback(final File file) { @@ -331,11 +337,11 @@ public void setCallback(final GsCallback.a1 callback) { private Pair getTemplateContent(final String template, final String name) { String text = TextViewUtils.interpolateSnippet(template, name, ""); - final int startingIndex = template.indexOf(HighlightingEditor.PLACE_CURSOR_HERE_TOKEN); - text = text.replace(HighlightingEditor.PLACE_CURSOR_HERE_TOKEN, ""); + final int startingIndex = text.indexOf(HighlightingEditor.PLACE_CURSOR_HERE_TOKEN); + text = text.replaceAll(HighlightingEditor.PLACE_CURSOR_HERE_TOKEN, ""); // Has no utility in a new file - text = text.replace(HighlightingEditor.INSERT_SELECTION_HERE_TOKEN, ""); + text = text.replaceAll(HighlightingEditor.INSERT_SELECTION_HERE_TOKEN, ""); return Pair.create(text, startingIndex); } diff --git a/app/src/main/java/net/gsantner/notepad2/frontend/filebrowser/MarkorFileBrowserFactory.java b/app/src/main/java/net/gsantner/notepad2/frontend/filebrowser/MarkorFileBrowserFactory.java index 0f9045b3b5..41c6674e6a 100644 --- a/app/src/main/java/net/gsantner/notepad2/frontend/filebrowser/MarkorFileBrowserFactory.java +++ b/app/src/main/java/net/gsantner/notepad2/frontend/filebrowser/MarkorFileBrowserFactory.java @@ -53,7 +53,6 @@ public static GsFileBrowserOptions.Options prepareFsViewerOpts( opts.newDirButtonText = R.string.create_folder; opts.upButtonEnable = true; opts.homeButtonEnable = true; - opts.mustStartWithRootFolder = false; opts.contentDescriptionFolder = R.string.folder; opts.contentDescriptionSelected = R.string.selected; opts.contentDescriptionFile = R.string.file; @@ -68,6 +67,7 @@ public static GsFileBrowserOptions.Options prepareFsViewerOpts( opts.folderColor = R.color.folder; opts.fileImage = R.drawable.ic_file_white_24dp; opts.folderImage = R.drawable.ic_folder_white_24dp; + opts.descriptionFormat = appSettings.getString(R.string.pref_key__file_description_format, ""); opts.titleText = R.string.select; @@ -109,6 +109,7 @@ public static GsFileBrowserDialog showFileDialog( ) { final GsFileBrowserOptions.Options opts = prepareFsViewerOpts(context, false, listener); opts.fileOverallFilter = fileOverallFilter; + opts.descModtimeInsteadOfParent = true; return showDialog(fm, opts); } @@ -119,6 +120,7 @@ public static GsFileBrowserDialog showFolderDialog( ) { final GsFileBrowserOptions.Options opts = prepareFsViewerOpts(context, true, listener); opts.okButtonText = R.string.select_this_folder; + opts.descModtimeInsteadOfParent = true; return showDialog(fm, opts); } } diff --git a/app/src/main/java/net/gsantner/notepad2/frontend/textview/HighlightingEditor.java b/app/src/main/java/net/gsantner/notepad2/frontend/textview/HighlightingEditor.java index 642a624610..7075d5cc8d 100644 --- a/app/src/main/java/net/gsantner/notepad2/frontend/textview/HighlightingEditor.java +++ b/app/src/main/java/net/gsantner/notepad2/frontend/textview/HighlightingEditor.java @@ -34,6 +34,12 @@ import net.gsantner.opoc.wrapper.GsCallback; import net.gsantner.opoc.wrapper.GsTextWatcherAdapter; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + @SuppressWarnings("UnusedReturnValue") public class HighlightingEditor extends AppCompatEditText { @@ -58,7 +64,8 @@ public class HighlightingEditor extends AppCompatEditText { private boolean _autoFormatEnabled; private boolean _saveInstanceState = true; private final LineNumbersDrawer _lineNumbersDrawer = new LineNumbersDrawer(this); - + private final ExecutorService executor = new ThreadPoolExecutor(0, 3, 60, TimeUnit.SECONDS, new SynchronousQueue<>()); + private final AtomicBoolean _textUnchangedWhileHighlighting = new AtomicBoolean(true); public HighlightingEditor(Context context, AttributeSet attrs) { super(context, attrs); @@ -83,6 +90,7 @@ public HighlightingEditor(Context context, AttributeSet attrs) { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { if (_hlEnabled && _hl != null) { + _textUnchangedWhileHighlighting.set(false); _hl.fixup(start, before, count); } } @@ -97,8 +105,8 @@ public void afterTextChanged(final Editable s) { // Listen to and update highlighting final ViewTreeObserver observer = getViewTreeObserver(); - observer.addOnScrollChangedListener(() -> updateHighlighting(false)); - observer.addOnGlobalLayoutListener(() -> updateHighlighting(false)); + observer.addOnScrollChangedListener(this::updateHighlighting); + observer.addOnGlobalLayoutListener(this::updateHighlighting); // Fix for Android 12 perf issues - https://github.com/gsantner/notepad2/discussions/1794 setEmojiCompatEnabled(false); @@ -122,33 +130,73 @@ protected void onDraw(Canvas canvas) { // Highlighting // --------------------------------------------------------------------------------------------- - private boolean isScrollSignificant() { - return (_oldHlRect.top - _hlRect.top) > _hlShiftThreshold || - (_hlRect.bottom - _oldHlRect.bottom) > _hlShiftThreshold; + // Batch edit spans (or anything else, really) + // This triggers a reflow which will bring focus back to the cursor. + // Therefore it cannot be used for updating the highlighting as one scrolls + private void batch(final Runnable runnable) { + try { + beginBatchEdit(); + runnable.run(); + } finally { + endBatchEdit(); + } } - private void updateHighlighting(final boolean recompute) { - if (_hlEnabled && _hl != null && getLayout() != null) { + private boolean isScrollSignificant() { + return Math.abs(_oldHlRect.top - _hlRect.top) > _hlShiftThreshold || + Math.abs(_hlRect.bottom - _oldHlRect.bottom) > _hlShiftThreshold; + } + + // The order of tests here is important + // - we want to run getLocalVisibleRect even if recompute is true + // - we want to run isScrollSignificant after getLocalVisibleRect + // - We don't care about the presence of spans or scroll significance if recompute is true + private boolean runHighlight(final boolean recompute) { + return _hlEnabled && _hl != null && getLayout() != null && + (getLocalVisibleRect(_hlRect) || recompute) && + (recompute || _hl.hasSpans()) && + (recompute || isScrollSignificant()); + } + + private void updateHighlighting() { + if (runHighlight(false)) { + // Do not batch as we do not want to reflow + _hl.clearDynamic().applyDynamic(hlRegion()); + _oldHlRect.set(_hlRect); + } + } - final boolean visible = getLocalVisibleRect(_hlRect); + public void recomputeHighlighting() { + if (runHighlight(true)) { + batch(() -> _hl.clearAll().recompute().applyStatic().applyDynamic(hlRegion())); + } + } - // Don't highlight unless shifted sufficiently or a recompute is required - if (recompute || (visible && _hl.hasSpans() && isScrollSignificant())) { - _oldHlRect.set(_hlRect); + /** + * Computing the highlighting spans for a lot of text can be slow so we do it async + * 1. We set a flag to check that the text did not change when we were computing + * 2. We trigger the computation to a buffer + * 3. If the text did not change during computation, we apply the highlighting + */ + private void recomputeHighlightingAsync() { + if (runHighlight(true)) { + executor.execute(this::_recomputeHighlightingWorker); + } + } - final int[] newHlRegion = hlRegion(_hlRect); // Compute this _before_ clear - _hl.clearDynamic(); - if (recompute) { - _hl.clearStatic().recompute().applyStatic(); - } - _hl.applyDynamic(newHlRegion); + private synchronized void _recomputeHighlightingWorker() { + _textUnchangedWhileHighlighting.set(true); + _hl.compute(); + post(() -> { + if (_textUnchangedWhileHighlighting.get()) { + batch(() -> _hl.clearAll().setComputed().applyStatic().applyDynamic(hlRegion())); } - } + }); } public void setDynamicHighlightingEnabled(final boolean enable) { _isDynamicHighlightingEnabled = enable; - updateHighlighting(true); + recomputeHighlighting(); } public boolean isDynamicHighlightingEnabled() { @@ -164,8 +212,8 @@ public void setHighlighter(final SyntaxHighlighterBase newHighlighter) { if (_hl != null) { initHighlighter(); - _hlDebounced = TextViewUtils.makeDebounced(getHandler(), _hl.getHighlightingDelay(), () -> updateHighlighting(true)); - _hlDebounced.run(); + _hlDebounced = TextViewUtils.makeDebounced(getHandler(), _hl.getHighlightingDelay(), this::recomputeHighlightingAsync); + recomputeHighlighting(); } else { _hlDebounced = null; } @@ -222,11 +270,11 @@ public void setLineNumbersEnabled(final boolean enable) { } // Region to highlight - private int[] hlRegion(final Rect rect) { + private int[] hlRegion() { if (_isDynamicHighlightingEnabled) { - final int hlSize = Math.round(HIGHLIGHT_REGION_SIZE * rect.height()) + _hlShiftThreshold; - final int startY = rect.centerY() - hlSize; - final int endY = rect.centerY() + hlSize; + final int hlSize = Math.round(HIGHLIGHT_REGION_SIZE * _hlRect.height()) + _hlShiftThreshold; + final int startY = _hlRect.centerY() - hlSize; + final int endY = _hlRect.centerY() + hlSize; return new int[]{rowStart(startY), rowEnd(endY)}; } else { return new int[]{0, length()}; @@ -267,7 +315,7 @@ public Parcelable onSaveInstanceState() { protected void onVisibilityChanged(@NonNull View changedView, int visibility) { super.onVisibilityChanged(changedView, visibility); if (changedView == this && visibility == View.VISIBLE) { - updateHighlighting(true); + recomputeHighlighting(); } } @@ -447,18 +495,6 @@ public int moveCursorToBeginOfLine(int offset) { return getSelectionStart(); } - // Set selection to fill whole lines - // Returns original selectionStart - public int setSelectionExpandWholeLines() { - final int[] sel = TextViewUtils.getSelection(this); - final CharSequence text = getText(); - setSelection( - TextViewUtils.getLineStart(text, sel[0]), - TextViewUtils.getLineEnd(text, sel[1]) - ); - return sel[0]; - } - public boolean indexesValid(int... indexes) { return GsTextUtils.inRange(0, length(), indexes); } diff --git a/app/src/main/java/net/gsantner/notepad2/frontend/textview/SyntaxHighlighterBase.java b/app/src/main/java/net/gsantner/notepad2/frontend/textview/SyntaxHighlighterBase.java index a53d820901..370f286a91 100644 --- a/app/src/main/java/net/gsantner/notepad2/frontend/textview/SyntaxHighlighterBase.java +++ b/app/src/main/java/net/gsantner/notepad2/frontend/textview/SyntaxHighlighterBase.java @@ -5,6 +5,7 @@ * https://www.apache.org/licenses/LICENSE-2.0 * #########################################################*/ + package net.gsantner.notepad2.frontend.textview; import android.graphics.Canvas; @@ -32,6 +33,9 @@ import net.gsantner.notepad2.format.general.ColorUnderlineSpan; import net.gsantner.notepad2.format.plaintext.PlaintextSyntaxHighlighter; import net.gsantner.notepad2.model.AppSettings; + +import net.gsantner.opoc.format.GsTextUtils; + import net.gsantner.opoc.util.GsContextUtils; import net.gsantner.opoc.wrapper.GsCallback; @@ -44,6 +48,49 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +/** + * This is the base class for syntax highlighting, it contains routines for + * managing highlighting spans in the HighlightingEditor. + *

+ * Android's EditText uses a SpannableStringBuilder class to manage it's text, and + * DynamicLayout to to manage the text layout. Both are not very efficient with a large + * number of spans. + *

+ * The approach taken here is to: + * 1. Compute all spans (highlighting) for the text + * 2. Apply only those spans which are currently in the viewport + * 3. Update (remove and reapply) spans when the viewport moves (i.e we scroll) + *

+ * Spans are further divided into two categories: dynamic and static. + * - Dynamic spans are updated as one scrolls, as described above + * - Static spans are applied once and never updated. These are typically used for + * spans which affect the text layout. + * - For example, a span which makes text bigger. + * - Updating these dynamically would make the text jump around as one scrolls + *

+ * Fixup: + * - As the user types we shift all spans to accomodate the changed text. + * - This is done so that dynamically applied spans are applied to the correct region. + * - Fixup is batched and performed only if necessary. + *

+ * Span generation: + * - Derived classes should override generateSpans() to generate all spans + * - New spans are added by calling addSpanGroup() + * - The HighlightingEditor will trigger the generation of spans when the text changes. + * - This is debounced so that changes are batched + * - Span generation is done on a background thread + *

+ * Other performance tips: + * - Performance is heavily dependent on the number of spans applied to the text. + * - Combine related spans into a single span if possible + * - HighlightSpan is a helper class which can be used to create a span with multiple attributes + * - For example, a span which makes text bold and italic + * - Absolutely minimize the number of spans implementing `UpdateLayout` + * - These spans trigger a text layout update when changed in any way + * - Instead consider using a span implementing `StaticSpan` + * - If StaticSpans are present, the text is reflowed after applying them + * - This happens once, and not for each span, which is much more efficient + */ public abstract class SyntaxHighlighterBase { protected final static int LONG_HIGHLIGHTING_DELAY = 2400; @@ -102,7 +149,7 @@ public SyntaxHighlighterBase configure(@Nullable final Paint paint) { // --------------------------------------------------------------------------------------------- /** - * A class representing any span + * A class holding any span */ public static class SpanGroup implements Comparable { int start, end; @@ -113,7 +160,7 @@ public static class SpanGroup implements Comparable { span = o; start = s; end = e; - isStatic = o instanceof UpdateLayout; + isStatic = (span instanceof UpdateLayout || span instanceof StaticSpan); } @Override @@ -126,11 +173,15 @@ private static class ForceUpdateLayout implements UpdateLayout { // Empty class - just implements UpdateLayout } + public interface StaticSpan extends UpdateAppearance { + } + private final ForceUpdateLayout _layoutUpdater; - private final List _groups; + private final List _groups, _groupBuffer; private final NavigableSet _appliedDynamic; private boolean _staticApplied = false; + private int _fixupAfter = -1, _fixupDelta = 0; protected Spannable _spannable; protected final AppSettings _appSettings; @@ -138,8 +189,8 @@ private static class ForceUpdateLayout implements UpdateLayout { public SyntaxHighlighterBase(final AppSettings as) { _appSettings = as; _groups = new ArrayList<>(); + _groupBuffer = new ArrayList<>(); _appliedDynamic = new TreeSet<>(); - _layoutUpdater = new ForceUpdateLayout(); } @@ -154,7 +205,7 @@ public SyntaxHighlighterBase clearAll() { * * @return this */ - public synchronized SyntaxHighlighterBase clearDynamic() { + public SyntaxHighlighterBase clearDynamic() { if (_spannable == null) { return this; } @@ -173,18 +224,24 @@ public synchronized SyntaxHighlighterBase clearDynamic() { * * @return this */ - public synchronized SyntaxHighlighterBase clearStatic() { + public SyntaxHighlighterBase clearStatic() { if (_spannable == null) { return this; } + boolean hasStatic = false; for (int i = _groups.size() - 1; i >= 0; i--) { final SpanGroup group = _groups.get(i); if (group.isStatic) { + hasStatic = true; _spannable.removeSpan(group.span); } } + if (hasStatic) { + reflow(); + } + _staticApplied = false; return this; @@ -197,7 +254,7 @@ public synchronized SyntaxHighlighterBase clearStatic() { * @param spannable Spannable to work on * @return this */ - public synchronized SyntaxHighlighterBase setSpannable(@Nullable final Spannable spannable) { + public SyntaxHighlighterBase setSpannable(@Nullable final Spannable spannable) { if (spannable != _spannable) { _groups.clear(); _appliedDynamic.clear(); @@ -213,7 +270,7 @@ public Spannable getSpannable() { } public boolean hasSpans() { - return _spannable != null && _groups.size() > 0; + return _spannable != null && !_groups.isEmpty(); } /** @@ -223,30 +280,56 @@ public SyntaxHighlighterBase fixup(final int start, final int before, final int return fixup(start + before, count - before); } - // Adjust all spans after a change in the text - /** - * Adjust all currently computed spans. Use to adjust spans after text edited. + * Adjust all currently computed spans so that the spans are still valid after text changes + * We internally buffer / batch these fixes for increased performance * * @param after Apply to spans with region starting after 'after' - * @param delta Apply to + * @param delta How much to shift each span * @return this */ - public synchronized SyntaxHighlighterBase fixup(final int after, final int delta) { - for (int i = _groups.size() - 1; i >= 0; i--) { - final SpanGroup group = _groups.get(i); - // Very simple fixup. If the group is entirely after 'after', adjust it's region - if (group.start <= after) { - // We iterate backwards. As groups are sorted, if start is before after, can break out - break; - } else { - group.start += delta; - group.end += delta; + public SyntaxHighlighterBase fixup(final int after, final int delta) { + if (_fixupAfter == -1) { + _fixupAfter = after; + _fixupDelta = delta; + } else if (isFixupOverlap(after, delta)) { + _fixupAfter = Math.min(_fixupAfter, after); + _fixupDelta += delta; + } else { + applyFixup(); + } + return this; + } + + // Test if fixup region overlaps with the current fixup + private boolean isFixupOverlap(final int after, final int delta) { + return (after >= _fixupAfter && after <= _fixupAfter + Math.abs(_fixupDelta)) || + (_fixupAfter >= after && _fixupAfter <= after + Math.abs(delta)); + } + + private SyntaxHighlighterBase applyFixup() { + if (_fixupAfter >= 0 && _fixupDelta != 0) { + for (int i = _groups.size() - 1; i >= 0; i--) { + final SpanGroup group = _groups.get(i); + // Very simple fixup. If the group is entirely after 'after', adjust it's region + if (group.start <= _fixupAfter) { + // We iterate backwards. As groups are sorted, if start is before after, can break out + break; + } else { + group.start += _fixupDelta; + group.end += _fixupDelta; + } } + clearFixup(); } return this; } + private void clearFixup() { + _fixupAfter = -1; + _fixupDelta = 0; + } + public SyntaxHighlighterBase applyAll() { return applyDynamic().applyStatic(); } @@ -260,51 +343,52 @@ public SyntaxHighlighterBase applyDynamic() { * * @return this */ - public synchronized SyntaxHighlighterBase applyDynamic(final int[] range) { - if (_spannable == null) { - return this; - } - - final int length = _spannable.length(); - if (!TextViewUtils.checkRange(length, range)) { - return this; - } - - for (int i = 0; i < _groups.size(); i++) { - final SpanGroup group = _groups.get(i); - - if (group.isStatic) { - continue; - } + public SyntaxHighlighterBase applyDynamic(final int[] range) { + if (GsTextUtils.isValidSelection(_spannable, range) && range.length >= 2) { + applyFixup(); + final int length = _spannable.length(); + for (int i = 0; i < _groups.size(); i++) { + final SpanGroup group = _groups.get(i); + + if (group.isStatic) { + continue; + } - if (group.start >= range[1]) { - // As we are sorted on start, we can break out after the first group.start > end - break; - } + if (group.start >= range[1]) { + // As we are sorted on start, we can break out after the first group.start > end + break; + } - final boolean valid = group.start >= 0 && group.end > range[0] && group.end <= length; - if (valid && !_appliedDynamic.contains(i)) { - _spannable.setSpan(group.span, group.start, group.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - _appliedDynamic.add(i); + final boolean valid = group.start >= 0 && group.end > range[0] && group.end <= length; + if (valid && !_appliedDynamic.contains(i)) { + _spannable.setSpan(group.span, group.start, group.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + _appliedDynamic.add(i); + } } } - return this; } - public synchronized SyntaxHighlighterBase applyStatic() { - if (_spannable == null || _staticApplied) { - return this; - } - for (int i = 0; i < _groups.size(); i++) { - final SpanGroup group = _groups.get(i); - if (group.isStatic) { - _spannable.setSpan(group.span, group.start, group.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + public SyntaxHighlighterBase applyStatic() { + if (_spannable != null && !_staticApplied) { + applyFixup(); + + boolean hasStatic = false; + for (final SpanGroup group : _groups) { + if (group.isStatic) { + hasStatic = true; + _spannable.setSpan(group.span, group.start, group.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } } - } - _staticApplied = true; + if (hasStatic) { + reflow(); + } + + _staticApplied = true; + } return this; } @@ -314,24 +398,42 @@ public final SyntaxHighlighterBase reflow() { } // Reflow selected region's lines - public final synchronized SyntaxHighlighterBase reflow(final int[] range) { - if (TextViewUtils.checkRange(_spannable, range)) { + public final SyntaxHighlighterBase reflow(final int[] range) { + if (GsTextUtils.isValidSelection(_spannable, range) && range.length >= 2) { _spannable.setSpan(_layoutUpdater, range[0], range[1], Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); _spannable.removeSpan(_layoutUpdater); } return this; } + public final SyntaxHighlighterBase recompute() { + return compute().setComputed(); + } + /** - * Recompute all spans. References to existing spans will be lost. + * Make computed spans current. References to existing spans will be lost. * Caller is responsible for calling 'clear()' before this, if necessary * * @return this */ - public synchronized final SyntaxHighlighterBase recompute() { + public final SyntaxHighlighterBase setComputed() { _groups.clear(); _appliedDynamic.clear(); _staticApplied = false; + _groups.addAll(_groupBuffer); + _groupBuffer.clear(); + clearFixup(); + return this; + } + + /** + * Compute all highlighting spans to a buffer. + * The buffer is not made current until one calls 'setComputed' + * + * @return this + */ + public final SyntaxHighlighterBase compute() { + _groupBuffer.clear(); if (TextUtils.isEmpty(_spannable)) { return this; @@ -340,7 +442,7 @@ public synchronized final SyntaxHighlighterBase recompute() { // Highlighting cannot generate exceptions! try { generateSpans(); - Collections.sort(_groups); // Dramatically improves performance + Collections.sort(_groupBuffer); // Dramatically improves performance } catch (Exception ex) { Log.w(getClass().getName(), ex); } catch (Error er) { @@ -356,7 +458,7 @@ public synchronized final SyntaxHighlighterBase recompute() { protected final void addSpanGroup(final Object span, final int start, final int end) { if (end > start && span != null) { - _groups.add(new SpanGroup(span, start, end)); + _groupBuffer.add(new SpanGroup(span, start, end)); } } @@ -549,4 +651,4 @@ public HighlightSpan callback(Matcher m) { .setTextScale(textScale); } } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/gsantner/notepad2/frontend/textview/TextViewUtils.java b/app/src/main/java/net/gsantner/notepad2/frontend/textview/TextViewUtils.java index 9b35b5776c..7cbc573be2 100644 --- a/app/src/main/java/net/gsantner/notepad2/frontend/textview/TextViewUtils.java +++ b/app/src/main/java/net/gsantner/notepad2/frontend/textview/TextViewUtils.java @@ -18,6 +18,7 @@ import android.text.InputFilter; import android.text.Layout; import android.text.Selection; +import android.text.Spannable; import android.text.TextUtils; import android.util.DisplayMetrics; import android.view.View; @@ -29,7 +30,6 @@ import net.gsantner.opoc.format.GsTextUtils; import net.gsantner.opoc.util.GsContextUtils; -import net.gsantner.opoc.wrapper.GsCallback; import java.lang.reflect.Array; import java.util.ArrayList; @@ -48,14 +48,10 @@ private TextViewUtils() { throw new AssertionError(); } - public static int getLineStart(CharSequence s, int start) { - return getLineStart(s, start, 0); - } - - public static int getLineStart(CharSequence s, int start, int minRange) { - int i = start; - if (GsTextUtils.isValidIndex(s, start - 1, minRange)) { - for (; i > minRange; i--) { + public static int getLineStart(final CharSequence s, final int sel) { + int i = sel; + if (GsTextUtils.isValidSelection(s, i)) { + for (; i > 0; i--) { if (s.charAt(i - 1) == '\n') { break; } @@ -65,14 +61,10 @@ public static int getLineStart(CharSequence s, int start, int minRange) { return i; } - public static int getLineEnd(CharSequence s, int start) { - return getLineEnd(s, start, s.length()); - } - - public static int getLineEnd(CharSequence s, int start, int maxRange) { - int i = start; - if (GsTextUtils.isValidIndex(s, start, maxRange - 1)) { - for (; i < maxRange; i++) { + public static int getLineEnd(final CharSequence s, final int sel) { + int i = sel; + if (GsTextUtils.isValidSelection(s, i)) { + for (; i < s.length(); i++) { if (s.charAt(i) == '\n') { break; } @@ -122,33 +114,20 @@ public static int[] getSelection(final TextView text) { // CharSequence must be an instance of _Spanned_ public static int[] getSelection(final CharSequence text) { + if (text == null) { + return new int[]{-1, -1}; + } - final int selectionStart = Selection.getSelectionStart(text); - final int selectionEnd = Selection.getSelectionEnd(text); + final int start = Selection.getSelectionStart(text); + final int end = Selection.getSelectionEnd(text); - if (selectionEnd >= selectionStart) { - return new int[]{selectionStart, selectionEnd}; + if (end >= start) { + return new int[]{start, end}; } else { - return new int[]{selectionEnd, selectionStart}; + return new int[]{end, start}; } } - public static void withKeepSelection(final Editable text, final GsCallback.a2 action) { - final int[] sel = TextViewUtils.getSelection(text); - final int[] selStart = TextViewUtils.getLineOffsetFromIndex(text, sel[0]); - final int[] selEnd = TextViewUtils.getLineOffsetFromIndex(text, sel[1]); - - action.callback(sel[0], sel[1]); - - Selection.setSelection(text, - TextViewUtils.getIndexFromLineOffset(text, selStart), - TextViewUtils.getIndexFromLineOffset(text, selEnd)); - } - - public static void withKeepSelection(final Editable text, final GsCallback.a0 action) { - withKeepSelection(text, (start, end) -> action.callback()); - } - public static String getSelectedText(final CharSequence text) { final int[] sel = getSelection(text); return (sel[0] >= 0 && sel[1] >= 0) ? text.subSequence(sel[0], sel[1]).toString() : ""; @@ -159,7 +138,7 @@ public static String getSelectedText(final TextView text) { } public static int[] getLineSelection(final CharSequence text, final int[] sel) { - return sel != null && sel.length >= 2 ? new int[]{getLineStart(text, sel[0]), getLineEnd(text, sel[1])} : null; + return sel != null && sel.length >= 2 ? new int[]{getLineStart(text, sel[0]), getLineEnd(text, sel[1])} : new int[]{-1, -1}; } public static int[] getLineSelection(final CharSequence text, final int sel) { @@ -174,7 +153,6 @@ public static int[] getLineSelection(final CharSequence seq) { return getLineSelection(seq, getSelection(seq)); } - /** * Get lines of text in which sel[0] -> sel[1] is contained **/ @@ -190,16 +168,13 @@ public static String getSelectedLines(final CharSequence seq) { * Get lines of text in which sel[0] -> sel[1] is contained **/ public static String getSelectedLines(final CharSequence seq, final int... sel) { - if (sel == null || sel.length == 0) { + if (sel != null && sel.length > 0 && GsTextUtils.isValidSelection(seq, sel)) { + return seq.subSequence(getLineStart(seq, sel[0]), getLineEnd(seq, sel[1])).toString(); + } else { return ""; } - - final int start = Math.min(Math.max(sel[0], 0), seq.length()); - final int end = Math.min(Math.max(start, sel[sel.length - 1]), seq.length()); - return seq.subSequence(getLineStart(seq, start), getLineEnd(seq, end)).toString(); } - /** * Convert a char index to a line index + offset from end of line * @@ -207,14 +182,40 @@ public static String getSelectedLines(final CharSequence seq, final int... sel) * @param p position in text * @return int[2] where index 0 is line and index 1 is position from end of line */ - public static int[] getLineOffsetFromIndex(final CharSequence s, int p) { - p = Math.min(Math.max(p, 0), s.length()); - final int line = GsTextUtils.countChars(s, 0, p, '\n')[0]; - final int offset = getLineEnd(s, p) - p; + public static int[][] getLineOffsetFromIndex(final CharSequence text, final int ... sel) { + final int[][] offsets = new int[sel.length][2]; + + for (int i = 0; i < sel.length; i++) { + offsets[i] = new int[] {-1, -1}; + final int p = sel[i]; + if (p >= 0 && p <= text.length()) { + offsets[i][0] = GsTextUtils.countChars(text, 0, p, '\n')[0]; + offsets[i][1] = getLineEnd(text, p) - p; + } + } - return new int[]{line, offset}; + return offsets; } + public static void setSelectionFromOffsets(final TextView text, final int[][] offsets) { + setSelectionFromOffsets((Spannable) text.getText(), offsets); + } + + public static void setSelectionFromOffsets(final Spannable text, final int[][] offsets) { + if (offsets != null && offsets.length >= 2 && + offsets[0] != null && offsets[0].length == 2 && + offsets[1] != null && offsets[1].length == 2 && + text != null + ) { + final int start = getIndexFromLineOffset(text, offsets[0]); + final int end = getIndexFromLineOffset(text, offsets[1]); + if (GsTextUtils.isValidSelection(text, start, end)) { + Selection.setSelection(text, start, end); + } + } + } + + public static int getIndexFromLineOffset(final CharSequence s, final int[] le) { return getIndexFromLineOffset(s, le[0], le[1]); } @@ -228,6 +229,10 @@ public static int getIndexFromLineOffset(final CharSequence s, final int[] le) { * @return index in s */ public static int getIndexFromLineOffset(final CharSequence s, final int l, final int e) { + if (l < 0 || e < 0) { + return -1; + } + int i = 0, count = 0; if (s != null) { if (l > 0) { @@ -309,15 +314,13 @@ public static void showSelection(final TextView text, final int start, final int final int _start = Math.min(start, end); final int _end = Math.max(start, end); - if (start < 0 || end > text.length()) { + if (_start < 0 || _end > text.length()) { return; } final int lineStart = TextViewUtils.getLineStart(text.getText(), _start); final Rect viewSize = new Rect(); - if (!text.getLocalVisibleRect(viewSize)) { - return; - } + text.getLocalVisibleRect(viewSize); // Region in Y // ------------------------------------------------------------ @@ -347,8 +350,7 @@ public static void showSelection(final TextView text, final int start, final int region.left = startLeft - halfWidth; region.right = startLeft + halfWidth; - // Call in post to try to make sure we run after any pending actions - text.post(() -> text.requestRectangleOnScreen(region)); + text.requestRectangleOnScreen(region); } public static void setSelectionAndShow(final EditText edit, final int... sel) { @@ -360,14 +362,12 @@ public static void setSelectionAndShow(final EditText edit, final int... sel) { final int end = sel.length > 1 ? sel[1] : start; if (GsTextUtils.inRange(0, edit.length(), start, end)) { - edit.post(() -> { - if (!edit.hasFocus() && edit.getVisibility() != View.GONE) { - edit.requestFocus(); - } + if (!edit.hasFocus() && edit.getVisibility() != View.GONE) { + edit.requestFocus(); + } - edit.setSelection(start, end); - edit.postDelayed(() -> showSelection(edit, start, end), 250); - }); + edit.setSelection(start, end); + showSelection(edit, start, end); } } @@ -733,15 +733,6 @@ public static String toString(final CharSequence source, int start, int end) { return new String(buf); } - // Check if a range is valid - public static boolean checkRange(final CharSequence seq, final int... indices) { - return checkRange(seq.length(), indices); - } - - public static boolean checkRange(final int length, final int... indices) { - return indices != null && indices.length >= 2 && GsTextUtils.inRange(0, length, indices) && indices[1] > indices[0]; - } - public static boolean isViewVisible(final View view) { if (view == null || !view.isShown()) { return false; diff --git a/app/src/main/java/net/gsantner/notepad2/model/AppSettings.java b/app/src/main/java/net/gsantner/notepad2/model/AppSettings.java index c5e951e5d2..0fc5132fed 100644 --- a/app/src/main/java/net/gsantner/notepad2/model/AppSettings.java +++ b/app/src/main/java/net/gsantner/notepad2/model/AppSettings.java @@ -87,7 +87,7 @@ public boolean isPreferViewMode() { } public void setNotebookDirectory(final File file) { - setString(R.string.pref_key__notebook_directory, file.getAbsolutePath()); + setString(R.string.pref_key__notebook_directory, Document.getPath(file)); } public File getNotebookDirectory() { @@ -106,7 +106,7 @@ public File getQuickNoteFile() { } public void setQuickNoteFile(final File file) { - setString(R.string.pref_key__quicknote_filepath, file.getAbsolutePath()); + setString(R.string.pref_key__quicknote_filepath, Document.getPath(file)); } public File getDefaultQuickNoteFile() { @@ -118,7 +118,7 @@ public File getTodoFile() { } public void setTodoFile(final File file) { - setString(R.string.pref_key__todo_filepath, file.getAbsolutePath()); + setString(R.string.pref_key__todo_filepath, Document.getPath(file)); } public File getDefaultTodoFile() { @@ -251,15 +251,16 @@ public void addRecentFile(final File file) { if (!listFileInRecents(file)) { return; } + final String path = Document.getPath(file); if (!file.equals(getTodoFile()) && !file.equals(getQuickNoteFile())) { ArrayList recent = getRecentDocuments(); - recent.add(0, file.getAbsolutePath()); - recent.remove(getTodoFile().getAbsolutePath()); - recent.remove(getQuickNoteFile().getAbsolutePath()); + recent.add(0, path); + recent.remove(Document.getPath(getTodoFile())); + recent.remove(Document.getPath(getQuickNoteFile())); recent.remove(""); recent.remove(null); - setInt(file.getAbsolutePath(), getInt(file.getAbsolutePath(), 0, _prefCache) + 1, _prefCache); + setInt(path, getInt(path, 0, _prefCache) + 1, _prefCache); setRecentDocuments(recent); } ShortcutUtils.setShortcuts(_context); @@ -268,8 +269,8 @@ public void addRecentFile(final File file) { public void setFavouriteFiles(final Collection files) { final Set set = new LinkedHashSet<>(); for (final File f : files) { - if (f != null && (f.exists() || GsFileBrowserListAdapter.isVirtualStorage(f))) { - set.add(f.getAbsolutePath()); + if (f != null && (f.exists() || GsFileBrowserListAdapter.isVirtualFolder(f))) { + set.add(Document.getPath(f)); } } setStringList(R.string.pref_key__favourite_files, GsCollectionUtils.map(set, p -> p)); @@ -333,8 +334,8 @@ public void setLastViewPosition(File file, int scrollX, int scrollY) { return; } if (!file.equals(getTodoFile()) && !file.equals(getQuickNoteFile())) { - setInt(PREF_PREFIX_VIEW_SCROLL_X + file.getAbsolutePath(), scrollX, _prefCache); - setInt(PREF_PREFIX_VIEW_SCROLL_Y + file.getAbsolutePath(), scrollY, _prefCache); + setInt(PREF_PREFIX_VIEW_SCROLL_X + Document.getPath(file), scrollX, _prefCache); + setInt(PREF_PREFIX_VIEW_SCROLL_Y + Document.getPath(file), scrollY, _prefCache); } } @@ -446,14 +447,14 @@ public int getLastViewPositionX(File file) { if (file == null || !file.exists()) { return -1; } - return getInt(PREF_PREFIX_VIEW_SCROLL_X + file.getAbsolutePath(), -3, _prefCache); + return getInt(PREF_PREFIX_VIEW_SCROLL_X + Document.getPath(file), -3, _prefCache); } public int getLastViewPositionY(File file) { if (file == null || !file.exists()) { return -1; } - return getInt(PREF_PREFIX_VIEW_SCROLL_Y + file.getAbsolutePath(), -3, _prefCache); + return getInt(PREF_PREFIX_VIEW_SCROLL_Y + Document.getPath(file), -3, _prefCache); } private List getPopularDocumentsSorted() { @@ -496,7 +497,7 @@ public static Set getFileSet(final List paths) { final Set set = new LinkedHashSet<>(); for (final String fp : paths) { final File f = new File(fp); - if (f.exists() || GsFileBrowserListAdapter.isVirtualStorage(f)) { + if (f.exists() || GsFileBrowserListAdapter.isVirtualFolder(f)) { set.add(f); } } @@ -665,16 +666,16 @@ public File getFolderToLoadByMenuId() { } public boolean listFileInRecents(File file) { - return getBool(file.getAbsolutePath() + "_list_in_recents", true); + return getBool(Document.getPath(file) + "_list_in_recents", true); } public void setListFileInRecents(File file, boolean value) { - setBool(file.getAbsolutePath() + "_list_in_recents", value); + setBool(Document.getPath(file) + "_list_in_recents", value); if (!value) { ArrayList recent = getRecentDocuments(); - if (recent.contains(file.getAbsolutePath())) { - recent.remove(file.getAbsolutePath()); + if (recent.contains(Document.getPath(file))) { + recent.remove(Document.getPath(file)); setRecentDocuments(recent); } } @@ -689,11 +690,11 @@ public ArrayList getFilesTaggedWith(String tag) { }*/ public int getRating(File file) { - return getInt(file.getAbsolutePath() + "_rating", 0); + return getInt(Document.getPath(file) + "_rating", 0); } public void setRating(File file, int value) { - setInt(file.getAbsolutePath() + "_rating", value); + setInt(Document.getPath(file) + "_rating", value); } public boolean isEditorLineBreakingEnabled() { @@ -754,7 +755,7 @@ public void setNewFileDialogLastUsedType(final int format) { } public void setFileBrowserLastBrowsedFolder(File f) { - setString(R.string.pref_key__file_browser_last_browsed_folder, f.getAbsolutePath()); + setString(R.string.pref_key__file_browser_last_browsed_folder, Document.getPath(f)); } public File getFileBrowserLastBrowsedFolder() { @@ -854,6 +855,13 @@ public Set getTitleFormats() { public void saveTitleFormat(final String format, final int maxCount) { } + public void setFormatShareAsLink(final boolean asLink) { + setBool(R.string.pref_key__format_share_as_link, asLink); + } + + public boolean getFormatShareAsLink() { + return getBool(R.string.pref_key__format_share_as_link, true); + } private static String mapToJsonString(final Map map) { return new JSONObject(map).toString(); diff --git a/app/src/main/java/net/gsantner/notepad2/model/Document.java b/app/src/main/java/net/gsantner/notepad2/model/Document.java index 15e3c745c8..4ba0042809 100644 --- a/app/src/main/java/net/gsantner/notepad2/model/Document.java +++ b/app/src/main/java/net/gsantner/notepad2/model/Document.java @@ -51,12 +51,15 @@ public class Document implements Serializable { public static final String EXTRA_DOCUMENT = "EXTRA_DOCUMENT"; // Document public static final String EXTRA_FILE = "EXTRA_FILE"; // java.io.File public static final String EXTRA_FILE_LINE_NUMBER = "EXTRA_FILE_LINE_NUMBER"; // int + public static final String EXTRA_DO_PREVIEW = "EXTRA_DO_PREVIEW"; public static final int EXTRA_FILE_LINE_NUMBER_LAST = -919385553; // Flag for last line - private final File _file; - private final String _fileExtension; - private String _title = ""; - private String _path = ""; + // Exposed properties + public final File file; + public final String extension; + public final String title; + public final String path; + private long _modTime = -1; // The file's mod time when it was last touched by this document private long _touchTime = -1; // The last time this document touched the file private GsFileUtils.FileInfo _fileInfo; @@ -68,15 +71,15 @@ public class Document implements Serializable { private long _lastHash = 0; private int _lastLength = -1; - public Document(@NonNull final File file) { - _file = file; - _path = _file.getAbsolutePath(); - _title = GsFileUtils.getFilenameWithoutExtension(_file); - _fileExtension = GsFileUtils.getFilenameExtension(_file); + public Document(@NonNull final File f) { + path = getPath(f); + file = new File(path); + title = GsFileUtils.getFilenameWithoutExtension(file); + extension = GsFileUtils.getFilenameExtension(file); // Set initial format for (final FormatRegistry.Format format : FormatRegistry.FORMATS) { - if (format.converter == null || format.converter.isFileOutOfThisFormat(_file)) { + if (format.converter == null || format.converter.isFileOutOfThisFormat(file)) { setFormat(format.format); break; } @@ -84,7 +87,13 @@ public Document(@NonNull final File file) { } public static String getPath(final File file) { - return file != null ? file.getAbsolutePath() : ""; + try { + return file.getCanonicalPath(); + } catch (IOException e) { + return file.getAbsolutePath(); + } catch (NullPointerException e) { + return ""; + } } // Get a default file @@ -94,15 +103,6 @@ public static Document getDefault(final Context context) { return new Document(random); } - public String getPath() { - return _path; - } - - @NonNull - public File getFile() { - return _file; - } - private void initModTimePref() { // We do not do this in constructor as we want to init after deserialization too if (_modTimePref == null) { @@ -112,13 +112,13 @@ private void initModTimePref() { private long getGlobalTouchTime() { initModTimePref(); - return _modTimePref.getLong(_file.getAbsolutePath(), 0); + return _modTimePref.getLong(file.getAbsolutePath(), 0); } private void setGlobalTouchTime() { initModTimePref(); _touchTime = System.currentTimeMillis(); - _modTimePref.edit().putLong(_file.getAbsolutePath(), _touchTime).apply(); + _modTimePref.edit().putLong(file.getAbsolutePath(), _touchTime).apply(); } public void resetChangeTracking() { @@ -135,37 +135,29 @@ public boolean hasFileChangedSinceLastLoad() { public long fileModTime() { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return Files.readAttributes(_file.toPath(), BasicFileAttributes.class).lastModifiedTime().toMillis(); + return Files.readAttributes(file.toPath(), BasicFileAttributes.class).lastModifiedTime().toMillis(); } } catch (IOException ignored) { } - return _file.lastModified(); + return file.lastModified(); } public long fileBytes() { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return Files.readAttributes(_file.toPath(), BasicFileAttributes.class).size(); + return Files.readAttributes(file.toPath(), BasicFileAttributes.class).size(); } } catch (Exception ignored) { } - return _file.length(); - } - - public String getTitle() { - return _title; - } - - public String getName() { - return _file.getName(); + return file.length(); } @Override public boolean equals(Object obj) { if (obj instanceof Document) { Document other = ((Document) obj); - return equalsc(_file, other._file) - && equalsc(getTitle(), other.getTitle()) + return equalsc(file, other.file) + && equalsc(title, other.title) && (getFormat() == other.getFormat()); } return super.equals(obj); @@ -176,7 +168,7 @@ private static boolean equalsc(Object o1, Object o2) { } public String getFileExtension() { - return _fileExtension; + return extension; } @StringRes @@ -188,18 +180,6 @@ public void setFormat(int format) { _format = format; } -// public static boolean isEncrypted(File file) { -// return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && file.getName().endsWith(JavaPasswordbasedCryption.DEFAULT_ENCRYPTION_EXTENSION); -// } - -// public boolean isBinaryFileNoTextLoading() { -// return _file != null && FormatRegistry.CONVERTER_EMBEDBINARY.isFileOutOfThisFormat(_file); -// } - -// public boolean isEncrypted() { -// return isEncrypted(_file); -// } - private void setContentHash(final CharSequence s) { _lastLength = s != null ? s.length() : 0; _lastHash = s != null ? GsFileUtils.crc32(s) : 0; @@ -214,34 +194,11 @@ String loadContent(final Context context) { String content; final char[] pw; -// if (isBinaryFileNoTextLoading()) { -// content = ""; -// } -// if (isEncrypted() && (pw = getPasswordWithWarning(context)) != null) { -// try { -// final byte[] encryptedContext = GsFileUtils.readCloseStreamWithSize(new FileInputStream(_file), (int) _file.length()); -// if (encryptedContext.length > JavaPasswordbasedCryption.Version.NAME_LENGTH) { -// content = JavaPasswordbasedCryption.getDecryptedText(encryptedContext, pw); -// } else { -// content = new String(encryptedContext, StandardCharsets.UTF_8); -// } -// } catch (FileNotFoundException e) { -// Log.e(Document.class.getName(), "loadDocument: File " + _file + " not found."); -// content = ""; -// } catch (JavaPasswordbasedCryption.EncryptionFailedException | -// IllegalArgumentException e) { -// Toast.makeText(context, R.string.could_not_decrypt_file_content_wrong_password_or_is_the_file_maybe_not_encrypted, Toast.LENGTH_LONG).show(); -// Log.e(Document.class.getName(), "loadDocument: decrypt failed for File " + _file + ". " + e.getMessage(), e); -// content = ""; -// } -// } -// else -// { // We try to load 2x. If both times fail, we return null - Pair result = GsFileUtils.readTextFileFast(_file); + Pair result = GsFileUtils.readTextFileFast(file); if (result.second.ioError) { - Log.i(Document.class.getName(), "loadDocument: File " + _file + " read error, trying again."); - result = GsFileUtils.readTextFileFast(_file); + Log.i(Document.class.getName(), "loadDocument: File " + file + " read error, trying again."); + result = GsFileUtils.readTextFileFast(file); } content = result.first; _fileInfo = result.second; @@ -250,7 +207,7 @@ String loadContent(final Context context) { if (MainActivity.IS_DEBUG_ENABLED) { AppSettings.appendDebugLog( "\n\n\n--------------\nLoaded document, filepattern " - + getName().replaceAll(".*\\.", "-") + + title.replaceAll(".*\\.", "-") + ", chars: " + content.length() + " bytes:" + content.getBytes().length + "(" + GsFileUtils.getReadableFileSize(content.getBytes().length, true) + "). Language >" + Locale.getDefault() @@ -261,7 +218,7 @@ String loadContent(final Context context) { // Force next load on failure setContentHash(null); resetChangeTracking(); - Log.i(Document.class.getName(), "loadDocument: File " + _file + " read error, could not load file."); + Log.i(Document.class.getName(), "loadDocument: File " + file + " read error, could not load file."); return null; } else { // Also set hash and time on load - should prevent unnecessary saves @@ -272,20 +229,8 @@ String loadContent(final Context context) { } } -// @RequiresApi(api = Build.VERSION_CODES.M) -// private static char[] getPasswordWithWarning(final Context context) { -// final char[] pw = ApplicationObject.settings().getDefaultPassword(); -// if (pw == null || pw.length == 0) { -// final String warningText = context.getString(R.string.no_password_set_cannot_encrypt_decrypt); -// Toast.makeText(context, warningText, Toast.LENGTH_LONG).show(); -// Log.w(Document.class.getName(), warningText); -// return null; -// } -// return pw; -// } - public boolean testCreateParent() { - return testCreateParent(_file); + return testCreateParent(file); } public static boolean testCreateParent(final File file) { @@ -332,9 +277,9 @@ public synchronized boolean saveContent(final Activity context, final CharSequen // } cu = cu != null ? cu : new MarkorContextUtils(context); - final boolean isContentResolverProxyFile = cu.isContentResolverProxyFile(_file); - if (cu.isUnderStorageAccessFolder(context, _file, false) || isContentResolverProxyFile) { - cu.writeFile(context, _file, false, (fileOpened, fos) -> { + final boolean isContentResolverProxyFile = cu.isContentResolverProxyFile(file); + if (cu.isUnderStorageAccessFolder(context, file, false) || isContentResolverProxyFile) { + cu.writeFile(context, file, false, (fileOpened, fos) -> { try { if (_fileInfo != null && _fileInfo.hasBom) { fos.write(0xEF); @@ -345,7 +290,7 @@ public synchronized boolean saveContent(final Activity context, final CharSequen // Also overwrite content resolver proxy file in addition to writing back to the origin if (isContentResolverProxyFile) { - GsFileUtils.writeFile(_file, contentAsBytes, _fileInfo); + GsFileUtils.writeFile(file, contentAsBytes, _fileInfo); } } catch (Exception e) { @@ -355,30 +300,24 @@ public synchronized boolean saveContent(final Activity context, final CharSequen success = true; } else { // Try write 2x - success = GsFileUtils.writeFile(_file, contentAsBytes, _fileInfo); + success = GsFileUtils.writeFile(file, contentAsBytes, _fileInfo); if (!success || fileBytes() < contentAsBytes.length) { - success = GsFileUtils.writeFile(_file, contentAsBytes, _fileInfo); + success = GsFileUtils.writeFile(file, contentAsBytes, _fileInfo); } } final long size = fileBytes(); if (fileBytes() < contentAsBytes.length) { success = false; - Log.i(Document.class.getName(), "File write failed; size = " + size + "; length = " + contentAsBytes.length + "; file=" + _file); + Log.i(Document.class.getName(), "File write failed; size = " + size + "; length = " + contentAsBytes.length + "; file=" + file); } -// } catch (JavaPasswordbasedCryption.EncryptionFailedException e) { -// Log.e(Document.class.getName(), "writeContent: encrypt failed for File " + getPath() + ". " + e.getMessage(), e); -// Toast.makeText(context, R.string.could_not_encrypt_file_content_the_file_was_not_saved, Toast.LENGTH_LONG).show(); -// success = false; -// } - if (success) { setContentHash(content); _modTime = fileModTime(); setGlobalTouchTime(); } else { - Log.i(Document.class.getName(), "File write failed, size = " + fileBytes() + "; file=" + _file); + Log.i(Document.class.getName(), "File write failed, size = " + fileBytes() + "; file=" + file); } return success; diff --git a/app/src/main/java/net/gsantner/notepad2/util/MarkorContextUtils.java b/app/src/main/java/net/gsantner/notepad2/util/MarkorContextUtils.java index d0dce2838b..b970893c17 100644 --- a/app/src/main/java/net/gsantner/notepad2/util/MarkorContextUtils.java +++ b/app/src/main/java/net/gsantner/notepad2/util/MarkorContextUtils.java @@ -80,7 +80,7 @@ public T createLauncherDesktopShortcut(final Context @RequiresApi(api = Build.VERSION_CODES.KITKAT) @SuppressWarnings("deprecation") public PrintJob printOrCreatePdfFromWebview(final WebView webview, Document document, boolean... landscape) { - String jobName = String.format("%s (%s)", document.getTitle(), webview.getContext().getString(R.string.app_name_real)); + String jobName = String.format("%s (%s)", document.title, webview.getContext().getString(R.string.app_name_real)); return super.print(webview, jobName, landscape); } diff --git a/app/src/main/java/net/gsantner/opoc/format/GsTextUtils.java b/app/src/main/java/net/gsantner/opoc/format/GsTextUtils.java index 6cd235569b..bcc0137737 100644 --- a/app/src/main/java/net/gsantner/opoc/format/GsTextUtils.java +++ b/app/src/main/java/net/gsantner/opoc/format/GsTextUtils.java @@ -430,7 +430,11 @@ public static boolean isNewLine(CharSequence source, int start, int end) { } public static boolean isValidIndex(final CharSequence s, final int... indices) { - return s != null && inRange(0, s.length() - 1, indices); + return s != null && indices != null && inRange(0, s.length() - 1, indices); + } + + public static boolean isValidSelection(final CharSequence s, final int... indices) { + return s != null && indices != null && inRange(0, s.length(), indices); } // Checks if all values are in [min, max] _inclusive_ diff --git a/app/src/main/java/net/gsantner/opoc/frontend/GsSearchOrCustomTextDialog.java b/app/src/main/java/net/gsantner/opoc/frontend/GsSearchOrCustomTextDialog.java index ce3aea08bf..1895dcd7a0 100644 --- a/app/src/main/java/net/gsantner/opoc/frontend/GsSearchOrCustomTextDialog.java +++ b/app/src/main/java/net/gsantner/opoc/frontend/GsSearchOrCustomTextDialog.java @@ -361,9 +361,15 @@ public static void showMultiChoiceDialogWithSearchFilterUI(final Activity activi if (dopt.isSearchEnabled) { if (dopt.isSoftInputVisible) { win.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE | WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); + searchEditText.postDelayed(() -> win.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE | WindowManager.LayoutParams.SOFT_INPUT_STATE_UNCHANGED), 500); + searchEditText.requestFocus(); } else { win.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE | WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN); } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + win.setDecorFitsSystemWindows(true); + } } win.setLayout( @@ -379,10 +385,6 @@ public static void showMultiChoiceDialogWithSearchFilterUI(final Activity activi neutralButton.setOnClickListener((button) -> dopt.neutralButtonCallback.callback(dialog)); } - if (dopt.isSearchEnabled) { - searchEditText.requestFocus(); - } - if (dopt.defaultText != null) { listAdapter.filter(searchEditText.getText()); } @@ -545,7 +547,6 @@ private static View makeSearchView(final Context context, final DialogOptions do final AppCompatEditText searchEditText = new AppCompatEditText(context); searchEditText.setText(dopt.defaultText); searchEditText.setSingleLine(true); - searchEditText.setMaxLines(1); searchEditText.setTextColor(dopt.textColor); searchEditText.setHintTextColor((dopt.textColor & 0x00FFFFFF) | 0x99000000); searchEditText.setHint(dopt.searchHintText); diff --git a/app/src/main/java/net/gsantner/opoc/frontend/base/GsFragmentBase.java b/app/src/main/java/net/gsantner/opoc/frontend/base/GsFragmentBase.java index 8a7448671b..3cdebd5ef6 100644 --- a/app/src/main/java/net/gsantner/opoc/frontend/base/GsFragmentBase.java +++ b/app/src/main/java/net/gsantner/opoc/frontend/base/GsFragmentBase.java @@ -130,10 +130,10 @@ public String getAppLanguage() { /** * This will be called when this fragment gets the first time visible */ - public void onFragmentFirstTimeVisible() { + protected void onFragmentFirstTimeVisible() { } - private synchronized void checkRunFirstTimeVisible() { + private void checkRunFirstTimeVisible() { if (_fragmentFirstTimeVisible && isVisible() && isResumed()) { _fragmentFirstTimeVisible = false; onFragmentFirstTimeVisible(); @@ -173,7 +173,7 @@ public void onResume() { super.onResume(); final View view = getView(); if (view != null) { - view.postDelayed(this::checkRunFirstTimeVisible, 200); + view.post(this::checkRunFirstTimeVisible); // Add any remaining tasks while (!_postTasks.isEmpty()) { view.post(_postTasks.remove()); diff --git a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserDialog.java b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserDialog.java index 8d827011b2..9931815f7a 100644 --- a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserDialog.java +++ b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserDialog.java @@ -21,6 +21,7 @@ import android.app.Dialog; import android.content.DialogInterface; import android.os.Bundle; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -30,6 +31,7 @@ import android.widget.TextView; import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; @@ -80,6 +82,20 @@ public static GsFileBrowserDialog newInstance(final GsFileBrowserOptions.Options //######################## //## Methods //######################## + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + return new Dialog(getActivity()) { + @Override + public void onBackPressed() { + if (_filesystemViewerAdapter == null || !_filesystemViewerAdapter.goBack()) { + this.dismiss(); + } + } + }; + } + @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View root = inflater.inflate(R.layout.opoc_filesystem_dialog, container, false); @@ -127,6 +143,7 @@ public void onViewCreated(final View root, final @Nullable Bundle savedInstanceS _toolBar.setTitleTextColor(rcolor(_dopt.titleTextColor)); _toolBar.setTitle(_dopt.titleText); _toolBar.setSubtitleTextColor(rcolor(_dopt.secondaryTextColor)); + setSubtitleApprearance(_toolBar); _homeButton.setImageResource(_dopt.homeButtonImage); _homeButton.setVisibility(_dopt.homeButtonEnable ? View.VISIBLE : View.GONE); @@ -149,9 +166,9 @@ public void onViewCreated(final View root, final @Nullable Bundle savedInstanceS root.setBackgroundColor(rcolor(_dopt.backgroundColor)); - // final LinearLayoutManager lam = (LinearLayoutManager) _recyclerList.getLayoutManager(); - // final DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(activity, lam.getOrientation()); - // _recyclerList.addItemDecoration(dividerItemDecoration); + final LinearLayoutManager lam = (LinearLayoutManager) _recyclerList.getLayoutManager(); + final DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(activity, lam.getOrientation()); + _recyclerList.addItemDecoration(dividerItemDecoration); _recyclerList.setItemViewCacheSize(20); _filesystemViewerAdapter = new GsFileBrowserListAdapter(_dopt, activity); @@ -229,6 +246,8 @@ private void showNewDirDialog() { dopt.textColor = rcolor(_dopt.primaryTextColor); dopt.searchHintText = android.R.string.untitled; dopt.searchInputFilter = GsContextUtils.instance.makeFilenameInputFilter(); + dopt.isSearchEnabled = true; + dopt.isSoftInputVisible = true; dopt.callback = name -> _filesystemViewerAdapter.createDirectoryHere(name); GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); @@ -280,7 +299,7 @@ public void onFsViewerDoUiUpdate(GsFileBrowserListAdapter adapter) { _callback.onFsViewerDoUiUpdate(adapter); } if (adapter.getCurrentFolder() != null) { - _toolBar.setSubtitle(adapter.getCurrentFolder().getName()); + _toolBar.setSubtitle(adapter.getCurrentFolder().getPath()); } } @@ -299,4 +318,30 @@ public void onStart() { w.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT); } } + + private static void setSubtitleApprearance(final Toolbar toolbar) { + final String test = "__%%SUBTITLE%%__"; + toolbar.setSubtitle(test); + + for (int i = 0; i < toolbar.getChildCount(); i++) { + final View child = toolbar.getChildAt(i); + if (child instanceof TextView) { + final TextView tv = (TextView) child; + if (test.contentEquals(tv.getText())) { + + tv.setEllipsize(TextUtils.TruncateAt.START); + tv.setSingleLine(true); + final Toolbar.LayoutParams params = new Toolbar.LayoutParams( + Toolbar.LayoutParams.MATCH_PARENT, + Toolbar.LayoutParams.WRAP_CONTENT + ); + tv.setLayoutParams(params); + + break; + } + } + } + + toolbar.setSubtitle(""); + } } diff --git a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserFragment.java b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserFragment.java index 681b31aff6..9927f5a47f 100644 --- a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserFragment.java +++ b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserFragment.java @@ -91,6 +91,7 @@ public static GsFileBrowserFragment newInstance() { private Menu _fragmentMenu; private MarkorContextUtils _cu; private Toolbar _toolbar; + private File _lastSelectedFile; //######################## //## Methods @@ -183,6 +184,7 @@ private void checkOptions() { @Override public void onFsViewerSelected(String request, File file, final Integer lineNumber) { if (_callback != null) { + _filesystemViewerAdapter.showFileAfterNextLoad(file); _callback.onFsViewerSelected(_dopt.requestId, file, lineNumber); } } @@ -283,8 +285,7 @@ public void onFsViewerItemLongPressed(File file, boolean doSelectMultiple) { @Override public boolean onBackPressed() { - if (_filesystemViewerAdapter != null && _filesystemViewerAdapter.canGoUp() && !_filesystemViewerAdapter.isCurrentFolderHome()) { - _filesystemViewerAdapter.goUp(); + if (_filesystemViewerAdapter != null && _filesystemViewerAdapter.goBack()) { return true; } return super.onBackPressed(); @@ -462,9 +463,7 @@ public boolean onOptionsItemSelected(final MenuItem item) { if (confirmed) { Runnable deleter = () -> { WrMarkorSingleton.getInstance().deleteSelectedItems(currentSelection, getContext()); - _recyclerList.post(() -> { - _filesystemViewerAdapter.reloadCurrentFolder(); - }); + _recyclerList.post(() -> _filesystemViewerAdapter.reloadCurrentFolder()); }; new Thread(deleter).start(); } @@ -546,13 +545,13 @@ public void clearSelection() { /////////////// public void askForDeletingFilesRecursive(WrConfirmDialog.ConfirmDialogCallback confirmCallback) { final ArrayList itemsToDelete = new ArrayList<>(_filesystemViewerAdapter.getCurrentSelection()); - StringBuilder message = new StringBuilder(String.format(getString(R.string.do_you_really_want_to_delete_this_witharg), getResources().getQuantityString(R.plurals.documents, itemsToDelete.size())) + "\n\n"); + final StringBuilder message = new StringBuilder(String.format(getString(R.string.do_you_really_want_to_delete_this_witharg), getResources().getQuantityString(R.plurals.documents, itemsToDelete.size())) + "\n\n"); - for (File f : itemsToDelete) { - message.append("\n").append(f.getAbsolutePath()); + for (final File f : itemsToDelete) { + message.append("\n").append(f.getName()); } - WrConfirmDialog confirmDialog = WrConfirmDialog.newInstance(getString(R.string.confirm_delete), message.toString(), itemsToDelete, confirmCallback); + final WrConfirmDialog confirmDialog = WrConfirmDialog.newInstance(getString(R.string.confirm_delete), message.toString(), itemsToDelete, confirmCallback); confirmDialog.show(getChildFragmentManager(), WrConfirmDialog.FRAGMENT_TAG); } diff --git a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserListAdapter.java b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserListAdapter.java index f5c94d0ca5..899ac585c4 100644 --- a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserListAdapter.java +++ b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserListAdapter.java @@ -10,7 +10,6 @@ package net.gsantner.opoc.frontend.filebrowser; import android.content.Context; -import android.content.SharedPreferences; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; @@ -21,6 +20,7 @@ import android.text.TextUtils; import android.text.format.DateUtils; import android.text.style.StrikethroughSpan; +import android.util.Pair; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -37,8 +37,9 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; + import net.gsantner.notepad2.R; -import net.gsantner.opoc.format.GsTextUtils; + import net.gsantner.opoc.util.GsCollectionUtils; import net.gsantner.opoc.util.GsContextUtils; import net.gsantner.opoc.util.GsFileUtils; @@ -48,12 +49,14 @@ import java.io.FilenameFilter; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.Stack; import java.util.concurrent.ExecutorService; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadPoolExecutor; @@ -65,10 +68,12 @@ public class GsFileBrowserListAdapter extends RecyclerView.Adapter _adapterData; // List of current folder private final List _adapterDataFiltered; // Filtered list of current folder private final Set _currentSelection; - private File _currentFile; + private File _fileToShowAfterNextLoad; private File _currentFolder; private final Context _context; private StringFilter _filter; private RecyclerView _recyclerView; private LinearLayoutManager _layoutManager; - private final SharedPreferences _prefApp; - private final HashMap _virtualMapping = new HashMap<>(); + private final Map _virtualMapping; + private final Map _reverseVirtualMapping; private final Map _fileIdMap = new HashMap<>(); private final Map _folderScrollMap = new HashMap<>(); + private final Stack _backStack = new Stack<>(); + private long _prevModSum = 0; //######################## //## Methods @@ -102,7 +109,7 @@ public GsFileBrowserListAdapter(GsFileBrowserOptions.Options options, Context co _adapterDataFiltered = new ArrayList<>(); _currentSelection = new HashSet<>(); _context = context; - _prefApp = _context.getSharedPreferences("app", Context.MODE_PRIVATE); + GsContextUtils.instance.setAppLocale(_context, Locale.getDefault()); // Prevents view flicker - https://stackoverflow.com/a/32488059 setHasStableIds(true); @@ -130,9 +137,39 @@ public GsFileBrowserListAdapter(GsFileBrowserOptions.Options options, Context co _dopt.folderColor = cu.getResId(context, GsContextUtils.ResType.COLOR, "folder"); } + _virtualMapping = Collections.unmodifiableMap(getVirtualFolders()); + _reverseVirtualMapping = Collections.unmodifiableMap(GsCollectionUtils.reverse(_virtualMapping)); loadFolder(_dopt.startFolder != null ? _dopt.startFolder : _dopt.rootFolder, null); } + public Map getVirtualFolders() { + final GsContextUtils cu = GsContextUtils.instance; + + final Map map = new HashMap<>(); + + final File appDataFolder = _context.getFilesDir(); + if (appDataFolder.exists() || appDataFolder.mkdir()) { + map.put(VIRTUAL_STORAGE_APP_DATA_PRIVATE, appDataFolder); + } + + for (final File file : ContextCompat.getExternalFilesDirs(_context, null)) { + final File remap = new File(VIRTUAL_STORAGE_ROOT, "appdata-public (" + file.getName() + ")"); + map.put(remap, file); + } + + for (final Pair p : cu.getAppDataPublicDirs(_context, false, true, false)) { + final File remap = new File(VIRTUAL_STORAGE_ROOT, "sdcard (" + p.second + ")"); + map.put(remap, p.first); + } + + map.put(VIRTUAL_STORAGE_RECENTS, VIRTUAL_STORAGE_RECENTS); + map.put(VIRTUAL_STORAGE_POPULAR, VIRTUAL_STORAGE_POPULAR); + map.put(VIRTUAL_STORAGE_FAVOURITE, VIRTUAL_STORAGE_FAVOURITE); + map.put(VIRTUAL_STORAGE_EMULATED, VIRTUAL_STORAGE_EMULATED); + + return map; + } + @NonNull @Override public FilesystemViewerViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { @@ -151,48 +188,53 @@ public boolean isFileWriteable(File file, boolean isGoUp) { @Override @SuppressWarnings("ConstantConditions") public void onBindViewHolder(@NonNull FilesystemViewerViewHolder holder, int position) { - File file_pre = _adapterDataFiltered.get(position); - if (file_pre == null) { + final File displayFile = _adapterDataFiltered.get(position); + final File file; + if (displayFile == null) { holder.title.setText("????"); return; + } else if (_virtualMapping.containsKey(displayFile)) { + file = _virtualMapping.get(displayFile); + } else { + file = displayFile; } - GsContextUtils.instance.setAppLocale(_context, Locale.getDefault()); - final File file_pre_Parent = file_pre.getParentFile() == null ? new File("/") : file_pre.getParentFile(); - final String filename = file_pre.getName(); - if (_virtualMapping.containsKey(file_pre)) { - file_pre = _virtualMapping.get(file_pre); - } - final File file = file_pre; - final File fileParent = file.getParentFile() == null ? new File("/") : file.getParentFile(); - final File descriptionFile = file.equals(_currentFolder.getParentFile()) ? file : fileParent; - final boolean isGoUp = file.equals(_currentFolder.getParentFile()); - final boolean isSelected = _currentSelection.contains(file); - final boolean isFavourite = _dopt.favouriteFiles != null && _dopt.favouriteFiles.contains(file); - final boolean isPopular = _dopt.popularFiles != null && _dopt.popularFiles.contains(file); - final int descriptionRes = isSelected ? _dopt.contentDescriptionSelected : (file.isDirectory() ? _dopt.contentDescriptionFolder : _dopt.contentDescriptionFile); + + final String filename = displayFile.getName(); + final String currentFolderName = _currentFolder != null ? _currentFolder.getName() : ""; + final File currentFolderParent = _currentFolder != null ? _currentFolder.getParentFile() : null; + + final boolean isGoUp = VIRTUAL_STORAGE_ROOT.equals(displayFile) || file.equals(currentFolderParent); + final boolean isSelected = _currentSelection.contains(displayFile); + final boolean isFavourite = _dopt.favouriteFiles != null && _dopt.favouriteFiles.contains(displayFile); + final boolean isPopular = _dopt.popularFiles != null && _dopt.popularFiles.contains(displayFile); + final int descriptionRes = isSelected ? _dopt.contentDescriptionSelected : (displayFile.isDirectory() ? _dopt.contentDescriptionFolder : _dopt.contentDescriptionFile); + String titleText = filename; if (isCurrentFolderVirtual() && "index.html".equals(filename)) { - titleText += " [" + fileParent.getName() + "]"; + titleText += " [" + currentFolderName + "]"; } holder.title.setText(isGoUp ? ".." : titleText, TextView.BufferType.SPANNABLE); holder.title.setTextColor(ContextCompat.getColor(_context, _dopt.primaryTextColor)); - if (!isFileWriteable(file, isGoUp) && holder.title.length() > 0) { + + if (!isFileWriteable(displayFile, isGoUp) && !isVirtualFolder(displayFile) && holder.title.length() > 0) { try { ((Spannable) holder.title.getText()).setSpan(STRIKE_THROUGH_SPAN, 0, holder.title.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } catch (Exception ignored) { } } - final boolean isFile = file.isFile(); + final boolean isFile = displayFile.isFile(); - holder.description.setText(!_dopt.descModtimeInsteadOfParent || holder.title.getText().toString().equals("..") - ? descriptionFile.getAbsolutePath() : formatFileDescription(file, _prefApp.getString("pref_key__file_description_format", ""))); + holder.description.setText(!_dopt.descModtimeInsteadOfParent || isGoUp + ? file.getAbsolutePath() : formatFileDescription(file, _dopt.descriptionFormat)); holder.description.setTextColor(ContextCompat.getColor(_context, _dopt.secondaryTextColor)); + holder.image.setImageResource(isSelected ? _dopt.selectedItemImage : isFile ? _dopt.fileImage : _dopt.folderImage); holder.image.setColorFilter(ContextCompat.getColor(_context, - isSelected ? _dopt.accentColor : isFile ? _dopt.fileColor : _dopt.folderColor), + isSelected ? _dopt.accentColor : isFile? _dopt.fileColor : _dopt.folderColor), android.graphics.PorterDuff.Mode.SRC_ATOP); + if (!isSelected && isFavourite) { holder.image.setColorFilter(0xFFE3B51B); } @@ -204,10 +246,10 @@ public void onBindViewHolder(@NonNull FilesystemViewerViewHolder holder, int pos holder.itemRoot.setContentDescription((descriptionRes != 0 ? (_context.getString(descriptionRes) + " ") : "") + holder.title.getText().toString() + " " + holder.description.getText().toString()); holder.image.setOnLongClickListener(view -> { - Toast.makeText(_context, file.getAbsolutePath(), Toast.LENGTH_SHORT).show(); + Toast.makeText(_context, displayFile.getAbsolutePath(), Toast.LENGTH_SHORT).show(); return true; }); - holder.itemRoot.setTag(new TagContainer(file, position)); + holder.itemRoot.setTag(new TagContainer(displayFile, position)); holder.itemRoot.setOnClickListener(this); holder.itemRoot.setOnLongClickListener(this); @@ -254,7 +296,7 @@ public void restoreSavedInstanceState(final Bundle savedInstanceState) { final String path = savedInstanceState.getString(EXTRA_CURRENT_FOLDER); if (path != null) { final File f = new File(path); - final boolean isVirtualDirectory = _virtualMapping.containsKey(f) || isVirtualStorage(f); + final boolean isVirtualDirectory = _virtualMapping.containsKey(f) || isVirtualFolder(f); if (isVirtualDirectory && _dopt != null && _dopt.listener != null) { _dopt.listener.onFsViewerConfig(_dopt); @@ -361,10 +403,8 @@ public void onClick(View view) { case R.id.opoc_filesystem_item__root: { // A own item was clicked if (data.file != null) { - File file = data.file; - if (_virtualMapping.containsKey(file)) { - file = _virtualMapping.get(data.file); - } + final File file = GsCollectionUtils.getOrDefault(_virtualMapping, data.file, data.file); + if (areItemsSelected()) { // There are 1 or more items selected yet if (!toggleSelection(data) && file != null && file.isDirectory()) { @@ -372,10 +412,9 @@ public void onClick(View view) { } } else if (file != null) { // No pre-selection - if (file.isDirectory() || isVirtualStorage(file)) { - loadFolder(file, _currentFolder); + if (file.isDirectory() || isVirtualFolder(file)) { + loadFolder(file, isParent(file, _currentFolder) ? _currentFolder : null); } else if (file.isFile()) { - _currentFile = file; _dopt.listener.onFsViewerSelected(_dopt.requestId, file, null); } } @@ -452,11 +491,8 @@ public boolean toggleSelection(final TagContainer data) { } boolean clickHandled = false; - if (data.file != null && _currentFolder != null) { - if (data.file.isDirectory() && _currentFolder.getParentFile() != null && _currentFolder.getParentFile().equals(data.file)) { - // goUp - clickHandled = true; - } else if (_currentSelection.contains(data.file)) { + if (data.file != null && _currentFolder != null && !isParent(data.file, _currentFolder)) { + if (_currentSelection.contains(data.file)) { // Single selection _currentSelection.remove(data.file); clickHandled = true; @@ -481,15 +517,31 @@ public boolean toggleSelection(final TagContainer data) { return clickHandled; } + public boolean goBack() { + if (canGoBack()) { + final File show = GsCollectionUtils.getOrDefault(_reverseVirtualMapping, _currentFolder, _currentFolder); + loadFolder(GO_BACK_SIGNIFIER, show); + return true; + } + return false; + } + + public boolean canGoBack() { + return !_backStack.isEmpty(); + } + public boolean goUp() { - if (canGoUp()) { - final String absolutePath = _currentFolder.getAbsolutePath(); - if (_currentFolder != null && _currentFolder.getParentFile() != null && !_currentFolder.getParentFile().getAbsolutePath().equals(absolutePath)) { - unselectAll(); - loadFolder(_currentFolder.getParentFile(), _currentFolder); + if (_currentFolder != null && canGoUp()) { + if (_reverseVirtualMapping.containsKey(_currentFolder)) { + loadFolder(VIRTUAL_STORAGE_ROOT, _reverseVirtualMapping.get(_currentFolder)); return true; + } else { + final File parent = _currentFolder.getParentFile(); + if (parent != null) { + loadFolder(_currentFolder.getParentFile(), _currentFolder); + return true; + } } - return false; } return false; } @@ -499,8 +551,12 @@ public boolean canGoUp() { } public boolean canGoUp(final File folder) { - final File parent = folder != null ? folder.getParentFile() : null; - return parent != null && (!_dopt.mustStartWithRootFolder || parent.getAbsolutePath().startsWith(_dopt.rootFolder.getAbsolutePath())); + try { + final File parent = folder != null ? folder.getParentFile() : null; + return (parent != null && parent.canWrite()) || GsFileUtils.isChild(VIRTUAL_STORAGE_ROOT, folder); + } catch (SecurityException ignored) { + return false; + } } @Override @@ -551,7 +607,7 @@ public void showFile(final File file) { loadFolder(dir, file); } } else { - showAndFlash(file); + scrollToAndFlash(file); } } @@ -566,11 +622,11 @@ public void onLayoutChange(View v, int l, int t, int r, int b, int ol, int ot, i } /** - * Show a file in the current folder and blink it + * Scroll to a file in current folder and flash * * @param file File to blink */ - private void showAndFlash(final File file) { + public boolean scrollToAndFlash(final File file) { final int pos = getFilePosition(file); if (pos >= 0 && _layoutManager != null) { doAfterChange(() -> _recyclerView.postDelayed(() -> { @@ -580,7 +636,9 @@ private void showAndFlash(final File file) { } }, 400)); _layoutManager.scrollToPosition(pos); + return true; } + return false; } // Get the position of a file in the current view @@ -598,151 +656,136 @@ public int getFilePosition(final File file) { private static final ExecutorService executorService = new ThreadPoolExecutor(0, 3, 60, TimeUnit.SECONDS, new SynchronousQueue<>()); - private void loadFolder(final File folder, final @Nullable File toShow) { + private void loadFolder(final File folder, final File show) { + final boolean folderChanged = !folder.equals(_currentFolder); + if (folderChanged && _currentFolder != null && _layoutManager != null) { _folderScrollMap.put(_currentFolder, _layoutManager.onSaveInstanceState()); } - executorService.execute(() -> { - synchronized (_adapterData) { + final File toLoad; + if (GO_BACK_SIGNIFIER == folder) { + toLoad = _backStack.pop(); + } else { + if (folderChanged && _currentFolder != null) { + _backStack.push(_currentFolder); + } + toLoad = folder; + } - if (_dopt.refresh != null) { - _dopt.refresh.callback(); - } + if (_dopt.refresh != null) { + _dopt.refresh.callback(); + } - final HashMap virtualMapping = new HashMap<>(); - final List newData = new ArrayList<>(); - - if (folder.equals(VIRTUAL_STORAGE_ROOT)) { - // Scan for /storage/emulated/{0,1,2,..} - for (int i = 0; i < 10; i++) { - final File file = new File("/storage/emulated/" + i); - if (canWrite(file)) { - File remap = new File(folder, "emulated-" + i); - _virtualMapping.put(remap, file); - newData.add(remap); - } else { - break; - } - } + if (_fileToShowAfterNextLoad != null) { + _recyclerView.post(() -> { + scrollToAndFlash(_fileToShowAfterNextLoad); + _fileToShowAfterNextLoad = null; + }); + } - if (_dopt.recentFiles != null) { - virtualMapping.put(VIRTUAL_STORAGE_RECENTS, VIRTUAL_STORAGE_RECENTS); - newData.add(VIRTUAL_STORAGE_RECENTS); - } - if (_dopt.popularFiles != null) { - virtualMapping.put(VIRTUAL_STORAGE_POPULAR, VIRTUAL_STORAGE_POPULAR); - newData.add(VIRTUAL_STORAGE_POPULAR); - } - if (_dopt.favouriteFiles != null) { - virtualMapping.put(VIRTUAL_STORAGE_FAVOURITE, VIRTUAL_STORAGE_FAVOURITE); - newData.add(VIRTUAL_STORAGE_FAVOURITE); - } - final File appDataFolder = _context.getFilesDir(); - if (appDataFolder.exists() || appDataFolder.mkdir()) { - virtualMapping.put(VIRTUAL_STORAGE_APP_DATA_PRIVATE, appDataFolder); - newData.add(VIRTUAL_STORAGE_APP_DATA_PRIVATE); - } - } else if (folder.equals(VIRTUAL_STORAGE_RECENTS)) { - newData.addAll(_dopt.recentFiles); - } else if (folder.equals(VIRTUAL_STORAGE_POPULAR)) { - newData.addAll(_dopt.popularFiles); - } else if (folder.equals(VIRTUAL_STORAGE_FAVOURITE)) { - newData.addAll(_dopt.favouriteFiles); - } else if (folder.isDirectory()) { - GsCollectionUtils.addAll(newData, folder.listFiles(GsFileBrowserListAdapter.this)); - } + final File toShow = show == null ? _fileToShowAfterNextLoad : show; + _fileToShowAfterNextLoad = null; - // Some special folders get special children - if (folder.getAbsolutePath().equals("/storage/emulated")) { - newData.add(new File(folder, "0")); - } else if (folder.getAbsolutePath().equals("/")) { - newData.add(new File(folder, "storage")); - } else if (folder.equals(_context.getFilesDir().getParentFile())) { - // Private AppStorage: Allow to access to files directory only - // (don't allow access to internals like shared_preferences & databases) - newData.add(new File(folder, "files")); - } + executorService.execute(() -> _loadFolder(toLoad, toShow)); + } - for (final File externalFileDir : ContextCompat.getExternalFilesDirs(_context, null)) { - for (final File file : newData) { - final String absPath = file.getAbsolutePath(); - final String absExt = externalFileDir.getAbsolutePath(); - if (!canWrite(file) && !absPath.equals("/") && absExt.startsWith(absPath)) { - final int depth = GsTextUtils.countChars(absPath, '/')[0]; - if (depth < 3) { - final File parent = file.getParentFile(); - if (parent != null) { - final File remap = new File(parent.getAbsolutePath(), "appdata-public (" + file.getName() + ")"); - virtualMapping.put(remap, new File(absExt)); - newData.add(remap); - } - } - } - } - } + // This function is not called on the main thread, so post to the UI thread + private synchronized void _loadFolder(final @NonNull File folder, final @Nullable File toShow) { + + final boolean folderChanged = !folder.equals(_currentFolder); + + final List newData = new ArrayList<>(); + + if (folder.equals(VIRTUAL_STORAGE_RECENTS)) { + newData.addAll(_dopt.recentFiles); + } else if (folder.equals(VIRTUAL_STORAGE_POPULAR)) { + newData.addAll(_dopt.popularFiles); + } else if (folder.equals(VIRTUAL_STORAGE_FAVOURITE)) { + newData.addAll(_dopt.favouriteFiles); + } else if (folder.isDirectory()) { + GsCollectionUtils.addAll(newData, folder.listFiles(GsFileBrowserListAdapter.this)); + } - // Don't sort recent items - use the default order - if (!folder.equals(VIRTUAL_STORAGE_RECENTS)) { - GsFileUtils.sortFiles(newData, _dopt.sortByType, _dopt.sortFolderFirst, _dopt.sortReverse); + if (folder.equals(VIRTUAL_STORAGE_ROOT)) { + newData.addAll(_virtualMapping.keySet()); + } + + // Add all emulated folders under /storage/emulated + if (VIRTUAL_STORAGE_EMULATED.equals(folder)) { + newData.add(new File(folder, "0")); + for (int i = 1; i < 10; i++) { + final File f = new File(folder, String.valueOf(i)); + if (GsFileUtils.canCreate(f)) { + newData.add(f); } + } + } + + if (folder.getAbsolutePath().equals("/")) { + newData.add(new File(folder, VIRTUAL_STORAGE_ROOT.getName())); + } - if (canGoUp(folder)) { - newData.add(0, folder.equals(new File("/storage/emulated/0")) ? new File("/storage/emulated") : folder.getParentFile()); + GsCollectionUtils.deduplicate(newData); + + // Don't sort recent items - use the default order + if (!folder.equals(VIRTUAL_STORAGE_RECENTS)) { + GsFileUtils.sortFiles(newData, _dopt.sortByType, _dopt.sortFolderFirst, _dopt.sortReverse); + } + + // Testing if modtimes have changed (modtimes generally only increase) + final long modSum = GsCollectionUtils.accumulate(newData, (f, s) -> s + f.lastModified(), 0L); + final boolean modSumChanged = modSum != _prevModSum; + + if (canGoUp(folder)) { + if ( + isVirtualFolder(folder) || + _virtualMapping.containsValue(folder) || + !GsFileUtils.isChild(VIRTUAL_STORAGE_ROOT, folder) + ) { + newData.add(0, VIRTUAL_STORAGE_ROOT); + } else { + newData.add(0, folder.getParentFile()); + } + } + + if (folderChanged || modSumChanged || !newData.equals(_adapterData)) { + _recyclerView.post(() -> { + // Modify all these values in the UI thread + _adapterData.clear(); + _adapterData.addAll(newData); + _currentSelection.retainAll(_adapterData); + _filter.filter(_filter._lastFilter); + _currentFolder = folder; + _prevModSum = modSum; + + if (folderChanged) { + _fileIdMap.clear(); } - if (folderChanged || !newData.equals(_adapterData)) { - _recyclerView.post(() -> { - // Modify all these values in the UI thread - _adapterData.clear(); - _adapterData.addAll(newData); - _currentSelection.retainAll(_adapterData); - _filter.filter(_filter._lastFilter); - _currentFolder = folder; - - _virtualMapping.clear(); - _virtualMapping.putAll(virtualMapping); - - if (folderChanged) { - _fileIdMap.clear(); - } + // TODO - add logic to notify the changed bits + notifyDataSetChanged(); - // TODO - add logic to notify the changed bits - notifyDataSetChanged(); - - if (folderChanged) { - _recyclerView.post(() -> { - if (_layoutManager != null) { - _layoutManager.onRestoreInstanceState(_folderScrollMap.remove(_currentFolder)); - } - - if (GsFileUtils.isChild(_currentFolder, toShow)) { - _recyclerView.post(() -> showAndFlash(toShow)); - } - }); - } else if (toShow != null && _adapterData.contains(toShow)) { - _recyclerView.post(() -> showAndFlash(toShow)); + if (folderChanged) { + _recyclerView.post(() -> { + if (_layoutManager != null) { + _layoutManager.onRestoreInstanceState(_folderScrollMap.remove(_currentFolder)); } - if (_dopt.listener != null) { - _dopt.listener.onFsViewerDoUiUpdate(GsFileBrowserListAdapter.this); - } + _recyclerView.post(() -> scrollToAndFlash(toShow)); }); - } else if (toShow != null && _adapterData.contains(toShow)) { - showAndFlash(toShow); + } else if (toShow != null && _adapterDataFiltered.contains(toShow)) { + _recyclerView.post(() -> scrollToAndFlash(toShow)); } - if (_currentFile != null) { - _recyclerView.post(() -> { - showAndFlash(_currentFile); - final int position = getFilePosition(_currentFile); - if (position >= 0) notifyItemChanged(position); - _currentFile = null; - }); + if (_dopt.listener != null) { + _dopt.listener.onFsViewerDoUiUpdate(GsFileBrowserListAdapter.this); } - } - }); + }); + } else if (toShow != null && _adapterDataFiltered.contains(toShow)) { + scrollToAndFlash(toShow); + } } private boolean canWrite(File file) { @@ -776,13 +819,6 @@ public boolean isCurrentFolderHome() { return _currentFolder != null && _dopt.rootFolder != null && _dopt.rootFolder.getAbsolutePath().equals(_currentFolder.getAbsolutePath()); } - public static boolean isVirtualStorage(File file) { - return VIRTUAL_STORAGE_FAVOURITE.equals(file) || - VIRTUAL_STORAGE_APP_DATA_PRIVATE.equals(file) || - VIRTUAL_STORAGE_POPULAR.equals(file) || - VIRTUAL_STORAGE_RECENTS.equals(file); - } - //######################## //## //## StringFilter @@ -855,15 +891,18 @@ public static class FilesystemViewerViewHolder extends RecyclerView.ViewHolder { } public static boolean isVirtualFolder(final File file) { - return file != null && ( - file.equals(VIRTUAL_STORAGE_APP_DATA_PRIVATE) || - file.equals(VIRTUAL_STORAGE_FAVOURITE) || - file.equals(VIRTUAL_STORAGE_POPULAR) || - file.equals(VIRTUAL_STORAGE_RECENTS) || - file.equals(new File("/")) || - file.equals(new File("/storage")) || - file.equals(new File("/storage/self")) || - file.equals(new File("/storage/emulated")) - ); + return VIRTUAL_STORAGE_RECENTS.equals(file) || + VIRTUAL_STORAGE_FAVOURITE.equals(file) || + VIRTUAL_STORAGE_POPULAR.equals(file) || + VIRTUAL_STORAGE_APP_DATA_PRIVATE.equals(file) || + VIRTUAL_STORAGE_EMULATED.equals(file); + } + + private boolean isParent(File parent, File child) { + return (VIRTUAL_STORAGE_ROOT.equals(parent) && _virtualMapping.containsKey(child)) || GsFileUtils.isChild(parent, child); + } + + public void showFileAfterNextLoad(final File file) { + _fileToShowAfterNextLoad = file; } } diff --git a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserOptions.java b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserOptions.java index f0dccdf857..285fd5ac2a 100644 --- a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserOptions.java +++ b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserOptions.java @@ -49,14 +49,15 @@ public static class Options { public String requestId = "show_dialog"; public String sortByType = GsFileUtils.SORT_BY_NAME; + public String descriptionFormat = null; + // Dialog type public boolean doSelectFolder = true, doSelectFile = false, doSelectMultiple = false; - public boolean mustStartWithRootFolder = true, - sortFolderFirst = false, + public boolean sortFolderFirst = false, sortReverse = true, descModtimeInsteadOfParent = false, filterShowDotFiles = true; diff --git a/app/src/main/java/net/gsantner/opoc/util/GsCollectionUtils.java b/app/src/main/java/net/gsantner/opoc/util/GsCollectionUtils.java index 00e64c7ccb..eb0fe9f511 100644 --- a/app/src/main/java/net/gsantner/opoc/util/GsCollectionUtils.java +++ b/app/src/main/java/net/gsantner/opoc/util/GsCollectionUtils.java @@ -18,9 +18,11 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; // Class for general utilities @@ -258,28 +260,23 @@ public static List range(final int... ops) { return values; } - public static class Holder { - private T value; - - public Holder(T value) { - this.value = value; - } - - public T get() { - return value; + public static Map reverse(final Map map) { + final Map reversed = new HashMap<>(); + for (final Map.Entry entry : map.entrySet()) { + reversed.put(entry.getValue(), entry.getKey()); } + return reversed; + } - public Holder set(T value) { - this.value = value; - return this; - } + public static V getOrDefault(final Map map, final K key, final V defaultValue) { + return map.containsKey(key) ? map.get(key) : defaultValue; + } - public T clear() { - try { - return value; - } finally { - value = null; - } + public static void deduplicate(final Collection data) { + if (!(data instanceof Set)) { + final LinkedHashSet deduped = new LinkedHashSet<>(data); + data.clear(); + data.addAll(deduped); } } } diff --git a/app/src/main/java/net/gsantner/opoc/util/GsContextUtils.java b/app/src/main/java/net/gsantner/opoc/util/GsContextUtils.java index 01ea024848..d9fbb950e3 100644 --- a/app/src/main/java/net/gsantner/opoc/util/GsContextUtils.java +++ b/app/src/main/java/net/gsantner/opoc/util/GsContextUtils.java @@ -58,6 +58,7 @@ import android.net.Uri; import android.os.Build; import android.os.Environment; +import android.os.Handler; import android.os.IBinder; import android.os.ParcelFileDescriptor; import android.os.SystemClock; @@ -2537,7 +2538,7 @@ public T showSoftKeyboard(final Activity activity, fi if (show) { imm.showSoftInput(focus, InputMethodManager.SHOW_IMPLICIT); } else if (token != null) { - imm.hideSoftInputFromWindow(token, InputMethodManager.HIDE_IMPLICIT_ONLY); + imm.hideSoftInputFromWindow(token, InputMethodManager.HIDE_NOT_ALWAYS); } } } @@ -2742,12 +2743,14 @@ public void dialogFullWidth(AlertDialog dialog, boolean fullWidth, boolean showK } } - public static void windowAspectRatio(final Window window, - final DisplayMetrics displayMetrics, - float portraitWidthRatio, - float portraitHeightRatio, - float landscapeWidthRatio, - float landscapeHeightRatio) { + public static void windowAspectRatio( + final Window window, + final DisplayMetrics displayMetrics, + float portraitWidthRatio, + float portraitHeightRatio, + float landscapeWidthRatio, + float landscapeHeightRatio + ) { if (window == null) { return; } diff --git a/app/src/main/java/net/gsantner/opoc/util/GsFileUtils.java b/app/src/main/java/net/gsantner/opoc/util/GsFileUtils.java index 59a782cee8..302aeeb821 100644 --- a/app/src/main/java/net/gsantner/opoc/util/GsFileUtils.java +++ b/app/src/main/java/net/gsantner/opoc/util/GsFileUtils.java @@ -52,6 +52,7 @@ import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.List; @@ -770,19 +771,27 @@ private static String makeSortKey(final String sortBy, final File file) { } public static void sortFiles( - final List filesToSort, + final Collection filesToSort, final String sortBy, final boolean folderFirst, final boolean reverse ) { if (filesToSort != null && !filesToSort.isEmpty()) { try { - GsCollectionUtils.keySort(filesToSort, (f) -> makeSortKey(sortBy, f)); + final boolean copy = !(filesToSort instanceof List); + final List sortable = copy ? new ArrayList<>(filesToSort) : (List) filesToSort; + + GsCollectionUtils.keySort(sortable, (f) -> makeSortKey(sortBy, f)); if (reverse) { - Collections.reverse(filesToSort); + Collections.reverse(sortable); } if (folderFirst) { - GsCollectionUtils.keySort(filesToSort, (f) -> !f.isDirectory()); + GsCollectionUtils.keySort(sortable, (f) -> !f.isDirectory()); + } + + if (copy) { + filesToSort.clear(); + filesToSort.addAll(sortable); } } catch (Exception e) { e.printStackTrace(); diff --git a/app/src/main/res/layout/opoc_filesystem_item.xml b/app/src/main/res/layout/opoc_filesystem_item.xml index 7d927d4aad..189ec04fc3 100644 --- a/app/src/main/res/layout/opoc_filesystem_item.xml +++ b/app/src/main/res/layout/opoc_filesystem_item.xml @@ -50,7 +50,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical" - android:ellipsize="middle" + android:ellipsize="start" android:importantForAccessibility="no" android:singleLine="true" android:textAppearance="@style/TextAppearance.AppCompat.Caption" diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index bea7dc1ab6..307809d0e1 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -1,6 +1,6 @@ - 18dp + 10dp 16dp 32dp 8dp diff --git a/app/src/main/res/values/string-not_translatable.xml b/app/src/main/res/values/string-not_translatable.xml index 0be147e674..d07f5e1714 100644 --- a/app/src/main/res/values/string-not_translatable.xml +++ b/app/src/main/res/values/string-not_translatable.xml @@ -422,6 +422,7 @@ work. If not, see . pref_key__filetype_template_map pref_key__template_title_format_map pref_key__title_format_list + pref_key__format_share_as_link Square Brackets CSV OrgMode diff --git a/build.gradle b/build.gradle index 4b283a0b9a..c7245c4cc4 100644 --- a/build.gradle +++ b/build.gradle @@ -45,7 +45,9 @@ allprojects { mavenCentral() maven { url 'https://maven.google.com' } maven { url "https://jitpack.io" } + //jcenter() + google() }