From 9cfe47f1fb56be6b98c342f4f364ed303cf1f3e5 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sun, 4 Oct 2026 23:43:46 +0300 Subject: [PATCH 01/43] feat: introduce Encly and move the library to it in one tap My Notes gets a successor, Encly: an encrypted, offline notes app. A new "Meet Encly" page (drawer, next to the what's-new entry, plus a one-time dialog after the update) explains what Encly is and what does not move. With a trusted Encly installed, "Move my notes to Encly" writes a ZIP with handoff.json (format mynotes-handoff, schema 1) to the cache and hands it to com.pasich.encly.action.IMPORT_FROM_MY_NOTES through the FileProvider with a read grant. Before any plaintext leaves, the receiver must resolve the action and carry the pinned Encly signing certificate; API 26-27 fall back to GET_SIGNATURES with exactly one signer. The payload is raw My Notes data with stable ids from sync_metadata (or mynotes::); attachment files, reminders and pins are not sent, and the page counts what stays behind. The ZIP is deleted after the result, and leftovers are swept on the next start. Nothing is deleted automatically. After Encly confirms the move, "Clear My Notes" is offered as a separate step with its own confirmation; it cancels reminders, deletes the records with sync tombstones so a Drive copy follows, and removes attachment files. Refs: #167 --- CHANGELOG.md | 10 + app/src/main/AndroidManifest.xml | 7 + .../mynotes/cache/AppPreferencesCache.java | 13 + .../mynotes/data/database/dao/NoteDao.java | 10 + .../mynotes/data/database/dao/TagsDao.java | 9 + .../data/database/dao/TaskCategoryDao.java | 3 + .../mynotes/data/database/dao/TaskDao.java | 9 + .../data/sync/SyncMutationCoordinator.java | 37 ++ .../mainActivity/AppUpdateController.java | 34 +- .../mainActivity/NavigationController.java | 4 + .../ui/view/activity/MainActivity.java | 19 + .../ui/view/activity/MeetEnclyActivity.java | 454 ++++++++++++++++++ .../ui/view/dialogs/MeetEnclyDialog.java | 52 ++ .../pasich/mynotes/utils/UpdateChecker.java | 12 + .../constants/settings/PreferencesConfig.java | 2 + .../utils/encly/AndroidEnclyInspector.java | 105 ++++ .../mynotes/utils/encly/EnclyHandoff.java | 43 ++ .../utils/encly/EnclyMigrationRepository.java | 133 +++++ .../utils/encly/EnclyReceiverCheck.java | 125 +++++ .../mynotes/utils/encly/HandoffArchive.java | 69 +++ .../utils/encly/HandoffPayloadBuilder.java | 246 ++++++++++ .../mynotes/utils/encly/HandoffResult.java | 86 ++++ .../main/res/layout/activity_meet_encly.xml | 206 ++++++++ app/src/main/res/layout/dialog_meet_encly.xml | 51 ++ app/src/main/res/layout/nav_header_main.xml | 46 ++ app/src/main/res/values-be/strings.xml | 39 ++ app/src/main/res/values-de/strings.xml | 39 ++ app/src/main/res/values-en-rGB/strings.xml | 39 ++ app/src/main/res/values-es/strings.xml | 39 ++ app/src/main/res/values-fr/strings.xml | 39 ++ app/src/main/res/values-it/strings.xml | 39 ++ app/src/main/res/values-kk/strings.xml | 39 ++ app/src/main/res/values-pl/strings.xml | 39 ++ app/src/main/res/values-ru/strings.xml | 39 ++ app/src/main/res/values-uk/strings.xml | 39 ++ app/src/main/res/values/strings.xml | 39 ++ app/src/main/res/xml/file_paths.xml | 4 + .../sync/SyncMutationCoordinatorTest.java | 51 ++ .../utils/encly/EnclyReceiverCheckTest.java | 186 +++++++ .../encly/HandoffPayloadBuilderTest.java | 336 +++++++++++++ .../utils/encly/HandoffResultTest.java | 79 +++ 41 files changed, 2865 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java create mode 100644 app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MeetEnclyDialog.java create mode 100644 app/src/main/java/com/pasich/mynotes/utils/encly/AndroidEnclyInspector.java create mode 100644 app/src/main/java/com/pasich/mynotes/utils/encly/EnclyHandoff.java create mode 100644 app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java create mode 100644 app/src/main/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheck.java create mode 100644 app/src/main/java/com/pasich/mynotes/utils/encly/HandoffArchive.java create mode 100644 app/src/main/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilder.java create mode 100644 app/src/main/java/com/pasich/mynotes/utils/encly/HandoffResult.java create mode 100644 app/src/main/res/layout/activity_meet_encly.xml create mode 100644 app/src/main/res/layout/dialog_meet_encly.xml create mode 100644 app/src/test/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheckTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilderTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/utils/encly/HandoffResultTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index d80eab13..e3b258f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # CHANGELOG +## [Unreleased] + +**New** + +- **Meet Encly:** A new page in the menu, and a one-time dialog after this update, introduces + Encly, the encrypted, offline successor to My Notes. With Encly installed, one tap moves your + notes (including the trash), tasks, tags and task lists to it. Attachments, images, reminders + and pins stay behind, and the page tells you how many. Nothing is deleted from My Notes; after + a successful move you can clear it as a separate, confirmed step. + ## [2.6.55] - 25.09.2026 **Improvements** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c05d027..611e4c9e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,10 @@ + + + + diff --git a/app/src/main/java/com/pasich/mynotes/cache/AppPreferencesCache.java b/app/src/main/java/com/pasich/mynotes/cache/AppPreferencesCache.java index 8ab06bf2..72bff067 100644 --- a/app/src/main/java/com/pasich/mynotes/cache/AppPreferencesCache.java +++ b/app/src/main/java/com/pasich/mynotes/cache/AppPreferencesCache.java @@ -84,6 +84,19 @@ public synchronized void setLastKnownVersion(String version) { } } + /** Whether the one-time "Meet Encly" introduction has been shown. */ + public boolean isMeetEnclyShown() { + return prefs.getBoolean(PreferencesConfig.ARGUMENT_PREFERENCE_MEET_ENCLY_SHOWN, false); + } + + public synchronized void setMeetEnclyShown() { + try { + prefs.putBoolean(PreferencesConfig.ARGUMENT_PREFERENCE_MEET_ENCLY_SHOWN, true); + } catch (Exception e) { + Log.e(TAG, "Failed to save meet-encly flag", e); + } + } + public String getSortPref() { ensureInitialized(); return sortPref; diff --git a/app/src/main/java/com/pasich/mynotes/data/database/dao/NoteDao.java b/app/src/main/java/com/pasich/mynotes/data/database/dao/NoteDao.java index ff0a6198..9a247207 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/dao/NoteDao.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/dao/NoteDao.java @@ -26,6 +26,16 @@ public interface NoteDao { @Query("SELECT * FROM notes WHERE isTrash = 1") List getTrashNotesSync(); + /** Every note, trashed ones included — the Encly hand-off sends them all. */ + @Query("SELECT * FROM notes ORDER BY id") + List getAllNotesSync(); + + @Query("SELECT id FROM notes") + List getAllNoteIdsSync(); + + @Query("DELETE FROM notes") + void deleteAllNotes(); + @Query("SELECT * FROM notes WHERE id = :id LIMIT 1") Note getNoteSync(int id); diff --git a/app/src/main/java/com/pasich/mynotes/data/database/dao/TagsDao.java b/app/src/main/java/com/pasich/mynotes/data/database/dao/TagsDao.java index 9f19484f..705e0422 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/dao/TagsDao.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/dao/TagsDao.java @@ -27,6 +27,15 @@ public interface TagsDao { @Query("SELECT * FROM tags WHERE name = :name LIMIT 1") Tag getTagByNameSync(String name); + @Query( + "SELECT * FROM tags WHERE systemAction = " + + SystemTagsManager.SYSTEM_ACTION_USER_TAG + + " ORDER BY position ASC, id ASC") + List getUserTagsSync(); + + @Query("DELETE FROM tags WHERE systemAction = " + SystemTagsManager.SYSTEM_ACTION_USER_TAG) + void deleteUserTags(); + @Query("DELETE FROM tags WHERE id = :id") void deleteById(long id); diff --git a/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskCategoryDao.java b/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskCategoryDao.java index 3f2eebf6..3b4ab16f 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskCategoryDao.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskCategoryDao.java @@ -25,6 +25,9 @@ public interface TaskCategoryDao { @Query("SELECT * FROM task_categories ORDER BY position ASC, id ASC") List getCategoriesSync(); + @Query("DELETE FROM task_categories") + void deleteAllCategories(); + @Insert(onConflict = OnConflictStrategy.REPLACE) long insertCategory(TaskCategory category); diff --git a/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskDao.java b/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskDao.java index 30808808..ec9d0b4c 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskDao.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskDao.java @@ -33,6 +33,15 @@ public interface TaskDao { @Query("SELECT * FROM tasks WHERE isDone = 0 ORDER BY position ASC, createdAt ASC LIMIT 10") List getActiveTasksSync(); + @Query("SELECT * FROM tasks ORDER BY position ASC, createdAt ASC") + List getAllTasksSync(); + + @Query("SELECT id FROM tasks") + List getAllTaskIdsSync(); + + @Query("DELETE FROM tasks") + void deleteAllTasks(); + @Insert(onConflict = OnConflictStrategy.REPLACE) long insertTask(Task task); diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java b/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java index 47f42e9e..e8fc37b5 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java @@ -664,6 +664,43 @@ public void deleteCategory(@NonNull TaskCategory category) { }); } + /** + * Deletes every note (trashed ones included), task, user tag and task category in one + * transaction, leaving a tombstone for each so a sync propagates the deletion instead of + * restoring the records from the remote copy. Attachment files and alarms are the caller's. + */ + public void clearAllUserData() { + transactionExecutor.run( + () -> { + long timestamp = timeProvider.now(); + List noteIds = noteDao.getAllNoteIdsSync(); + noteDao.deleteAllNotes(); + markDeletedRecords(SyncMetadata.RECORD_TYPE_NOTE, noteIds, timestamp); + + List taskIds = taskDao.getAllTaskIdsSync(); + taskDao.deleteAllTasks(); + markDeletedRecords(SyncMetadata.RECORD_TYPE_TASK, taskIds, timestamp); + + List tags = tagsDao.getUserTagsSync(); + tagsDao.deleteUserTags(); + if (tags != null) { + for (Tag tag : tags) { + markDeletedRecord(SyncMetadata.RECORD_TYPE_TAG, tag.getId(), timestamp); + } + } + + List categories = taskCategoryDao.getCategoriesSync(); + taskCategoryDao.deleteAllCategories(); + if (categories != null) { + for (TaskCategory category : categories) { + markDeletedRecord( + SyncMetadata.RECORD_TYPE_CATEGORY, category.getId(), timestamp); + } + } + return null; + }); + } + private long insertNoteInternal(@NonNull Note note, long timestamp) { long insertedId = noteDao.addNote(note); int localId = resolveIntId(note.getId(), insertedId); diff --git a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/AppUpdateController.java b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/AppUpdateController.java index 625c64b6..1e31c0ac 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/AppUpdateController.java +++ b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/AppUpdateController.java @@ -4,7 +4,10 @@ import android.content.Intent; import androidx.activity.result.ActivityResultLauncher; import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.FragmentManager; import com.pasich.mynotes.ui.view.activity.ChangelogActivity; +import com.pasich.mynotes.ui.view.activity.MeetEnclyActivity; +import com.pasich.mynotes.ui.view.dialogs.MeetEnclyDialog; import com.pasich.mynotes.ui.view.dialogs.UpdateChangelogDialog; import com.pasich.mynotes.utils.UpdateChecker; @@ -14,6 +17,9 @@ */ public class AppUpdateController { + private static final String CHANGELOG_TAG = "UpdateChangelogDialog"; + private static final String MEET_ENCLY_TAG = "MeetEnclyDialog"; + private final Activity activity; private final UpdateChecker updateChecker; private final ActivityResultLauncher changelogLauncher; @@ -28,16 +34,34 @@ public AppUpdateController( updateChecker.initializeVersionCheck(); } - /** Shows the changelog dialog if a new app version was detected. */ + /** + * Shows the changelog dialog if a new app version was detected. On the first start of the + * version that introduces Encly, the one-time "Meet Encly" dialog takes its place; the + * changelog stays unread and follows on the next start. + */ public void showChangelogIfNeeded() { + FragmentManager fragmentManager = + ((AppCompatActivity) activity).getSupportFragmentManager(); + // A recreated activity gets its open dialog back from the fragment manager. + if (fragmentManager.findFragmentByTag(MEET_ENCLY_TAG) != null + || fragmentManager.findFragmentByTag(CHANGELOG_TAG) != null) { + return; + } + if (updateChecker.shouldShowMeetEncly()) { + updateChecker.markMeetEnclyShown(); + MeetEnclyDialog.newInstance().show(fragmentManager, MEET_ENCLY_TAG); + return; + } if (updateChecker.hasNewVersion()) { - UpdateChangelogDialog.newInstance() - .show( - ((AppCompatActivity) activity).getSupportFragmentManager(), - "UpdateChangelogDialog"); + UpdateChangelogDialog.newInstance().show(fragmentManager, CHANGELOG_TAG); } } + /** Opens the "Meet Encly" page. */ + public void openMeetEncly() { + activity.startActivity(new Intent(activity, MeetEnclyActivity.class)); + } + /** Returns whether a newer app version is available. */ public boolean hasNewVersion() { return updateChecker.hasNewVersion(); diff --git a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/NavigationController.java b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/NavigationController.java index 77e7b9f7..c99dc801 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/NavigationController.java +++ b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/NavigationController.java @@ -179,6 +179,9 @@ private void setupHeaderButtons() { new Intent( activity, SupportActivity.class)))); + header.findViewById(R.id.nav_meet_encly) + .setOnClickListener(v -> delay(appUpdateController::openMeetEncly)); + bindHeaderNewVersion(header); } @@ -261,6 +264,7 @@ public void destroy() { header.findViewById(R.id.nav_about).setOnClickListener(null); header.findViewById(R.id.nav_support).setOnClickListener(null); header.findViewById(R.id.drawerStatsCard).setOnClickListener(null); + header.findViewById(R.id.nav_meet_encly).setOnClickListener(null); View newVersion = header.findViewById(R.id.newVersion); if (newVersion != null) { diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java index 8a666f53..25bcc43d 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java @@ -52,12 +52,15 @@ import com.pasich.mynotes.utils.adapters.tagAdapter.TagsAdapter; import com.pasich.mynotes.utils.constants.NameTransition; import com.pasich.mynotes.utils.constants.SnackBarInfo; +import com.pasich.mynotes.utils.encly.EnclyMigrationRepository; import com.pasich.mynotes.utils.managers.SystemTagsManager; import com.pasich.mynotes.utils.navigation.NoteNavigator; import com.pasich.mynotes.utils.recycler.SpacesItemDecoration; import com.pasich.mynotes.utils.recycler.SwipeToListNotesCallback; import com.pasich.mynotes.utils.tool.FormatListTool; import dagger.hilt.android.AndroidEntryPoint; +import io.reactivex.Completable; +import io.reactivex.schedulers.Schedulers; import java.util.ArrayList; import java.util.List; import javax.inject.Inject; @@ -99,6 +102,7 @@ public class MainActivity extends BaseActivity implements MainContract.view { @Inject SearchNotesAdapter searchNotesAdapter; @Inject UpdateChecker updateChecker; @Inject ThemePreferencesCache themePreferencesCache; + @Inject EnclyMigrationRepository enclyMigrationRepository; private SearchController searchController; private AppUpdateController appUpdateController; private NavigationController navigationController; @@ -168,6 +172,10 @@ public void onTagFilterChanged(String tagName) { mainPresenter.updateSearchTagFilter(tagName); } }); + if (savedInstanceState == null) { + sweepEnclyHandoffFiles(); + } + appUpdateController = new AppUpdateController(this, updateChecker, changelogLauncher); appUpdateController.showChangelogIfNeeded(); @@ -184,6 +192,17 @@ public void onTagFilterChanged(String tagName) { mainRenderListsController = new MainRenderListsController(mActivityBinding); } + /** + * A hand-off to Encly is a plain-text copy of every note. It is deleted when Encly answers; + * this sweeps one left behind when the process died before that. + */ + private void sweepEnclyHandoffFiles() { + Completable.fromAction(enclyMigrationRepository::clearArchives) + .subscribeOn(Schedulers.io()) + .onErrorComplete() + .subscribe(); + } + @Override public void render(MainViewState state) { if (selectionController.isInSelectionMode()) { diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java new file mode 100644 index 00000000..8bb40ec2 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java @@ -0,0 +1,454 @@ +package com.pasich.mynotes.ui.view.activity; + +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.FileProvider; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.snackbar.Snackbar; +import com.pasich.mynotes.R; +import com.pasich.mynotes.base.activity.BaseActivity; +import com.pasich.mynotes.databinding.ActivityMeetEnclyBinding; +import com.pasich.mynotes.utils.constants.SnackBarInfo; +import com.pasich.mynotes.utils.encly.AndroidEnclyInspector; +import com.pasich.mynotes.utils.encly.EnclyHandoff; +import com.pasich.mynotes.utils.encly.EnclyMigrationRepository; +import com.pasich.mynotes.utils.encly.EnclyReceiverCheck; +import com.pasich.mynotes.utils.encly.HandoffPayloadBuilder; +import com.pasich.mynotes.utils.encly.HandoffResult; +import dagger.hilt.android.AndroidEntryPoint; +import io.reactivex.Completable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; +import java.io.File; +import java.util.Objects; +import javax.inject.Inject; + +/** + * "Meet Encly": introduces the successor app and hands the whole library over to it, one way + * (pasichDev/MyNotes#167). Nothing is deleted here unless the user asks for it as a separate, + * confirmed step after Encly reported a successful import. + */ +@AndroidEntryPoint +public class MeetEnclyActivity extends BaseActivity { + + private static final String TAG = "MeetEnclyActivity"; + private static final String STATE_HAS_RESULT = "encly_has_result"; + private static final String STATE_RESULT_CODE = "encly_result_code"; + private static final String STATE_NOTES = "encly_notes"; + private static final String STATE_TASKS = "encly_tasks"; + private static final String STATE_TAGS = "encly_tags"; + private static final String STATE_SKIPPED = "encly_skipped"; + private static final String STATE_REASON = "encly_reason"; + private static final String STATE_CLEARED = "encly_cleared"; + + @Inject EnclyMigrationRepository repository; + + private final CompositeDisposable disposables = new CompositeDisposable(); + private ActivityMeetEnclyBinding binding; + + @Nullable private EnclyReceiverCheck.Result receiver; + @Nullable private HandoffPayloadBuilder.Summary summary; + private int categories; + private boolean busy; + + @Nullable private HandoffResult result; + private int resultCode; + private int[] resultCounts = new int[4]; + @Nullable private String resultReason; + private boolean cleared; + + private final ActivityResultLauncher enclyLauncher = + registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), this::onEnclyResult); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + selectTheme(); + binding = ActivityMeetEnclyBinding.inflate(getLayoutInflater()); + setupEdgeToEdgeInsets(binding.getRoot()); + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true); + + if (savedInstanceState == null) { + // A hand-off file left behind by a crash or a killed process is plain text: sweep it. + clearArchivesInBackground(); + } else { + restoreResult(savedInstanceState); + } + initListeners(); + loadSummary(); + } + + @Override + protected void onResume() { + super.onResume(); + // The user may have just installed or updated Encly from the store. + refreshReceiver(); + render(); + } + + @Override + public void initListeners() { + binding.primaryButton.setOnClickListener(v -> onPrimaryClicked()); + binding.clearButton.setOnClickListener(v -> confirmClear()); + } + + private void refreshReceiver() { + receiver = EnclyReceiverCheck.check(new AndroidEnclyInspector(this, probeUri())); + } + + @NonNull + private Uri probeUri() { + return Uri.parse("content://" + authority() + "/encly_handoff/handoff.zip"); + } + + @NonNull + private String authority() { + return getPackageName() + ".provider"; + } + + private void loadSummary() { + disposables.add( + Single.fromCallable(repository::load) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + library -> { + summary = library.summary(); + categories = library.categories.size(); + render(); + }, + error -> Log.e(TAG, "load summary", error))); + } + + private void render() { + if (binding == null) return; + if (summary != null) { + binding.movesText.setText( + String.join( + "\n", + getString(R.string.meet_encly_moves_notes, summary.notes), + getString(R.string.meet_encly_moves_tasks, summary.tasks), + getString(R.string.meet_encly_moves_tags, summary.tags), + getString(R.string.meet_encly_moves_categories, categories))); + binding.staysText.setText( + String.join( + "\n", + getString(R.string.meet_encly_stays_attachments, summary.attachments), + getString(R.string.meet_encly_stays_reminders, summary.reminders), + getString(R.string.meet_encly_stays_pinned, summary.pinned))); + } + + EnclyReceiverCheck.Status status = + receiver == null ? EnclyReceiverCheck.Status.NOT_INSTALLED : receiver.status; + switch (status) { + case READY -> { + binding.primaryButton.setText(R.string.meet_encly_move); + binding.statusText.setVisibility(View.GONE); + } + case NEEDS_UPDATE -> { + binding.primaryButton.setText(R.string.meet_encly_update); + binding.statusText.setText(R.string.meet_encly_needs_update); + binding.statusText.setVisibility(View.VISIBLE); + } + case UNTRUSTED -> { + binding.primaryButton.setText(R.string.meet_encly_get); + binding.statusText.setText(R.string.meet_encly_untrusted); + binding.statusText.setVisibility(View.VISIBLE); + } + default -> { + binding.primaryButton.setText(R.string.meet_encly_get); + binding.statusText.setVisibility(View.GONE); + } + } + binding.primaryButton.setEnabled(!busy); + binding.progress.setVisibility(busy ? View.VISIBLE : View.GONE); + renderResult(); + } + + private void renderResult() { + if (result == null) { + binding.resultCard.setVisibility(View.GONE); + return; + } + binding.resultCard.setVisibility(View.VISIBLE); + if (result.success) { + String text = + getString( + R.string.meet_encly_result_ok, + result.notes, + result.tasks, + result.tags, + result.skipped); + if (summary != null && summary.attachments > 0) { + text += + "\n" + + getString( + R.string.meet_encly_result_attachments, + summary.attachments); + } + binding.resultText.setText(text); + binding.clearHint.setVisibility(cleared ? View.GONE : View.VISIBLE); + binding.clearButton.setVisibility(cleared ? View.GONE : View.VISIBLE); + binding.clearButton.setEnabled(!busy); + } else { + binding.resultText.setText(failureMessage(result.reason)); + binding.clearHint.setVisibility(View.GONE); + binding.clearButton.setVisibility(View.GONE); + } + } + + private int failureMessage(@Nullable HandoffResult.Reason reason) { + if (reason == null) return R.string.meet_encly_result_cancelled; + return switch (reason) { + case CANCELLED -> R.string.meet_encly_result_cancelled; + case UNTRUSTED_CALLER -> R.string.meet_encly_result_untrusted; + case UNSUPPORTED_SCHEMA -> R.string.meet_encly_result_unsupported; + case INVALID_PAYLOAD -> R.string.meet_encly_result_invalid; + case TOO_LARGE -> R.string.meet_encly_result_too_large; + case FAILED -> R.string.meet_encly_result_failed; + }; + } + + private void onPrimaryClicked() { + if (busy) return; + refreshReceiver(); + if (receiver != null && receiver.status == EnclyReceiverCheck.Status.READY) { + startHandoff(Objects.requireNonNull(receiver.activityClassName)); + } else { + render(); + openStore(); + } + } + + private void openStore() { + try { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(EnclyHandoff.MARKET_URI))); + } catch (ActivityNotFoundException noStore) { + try { + startActivity( + new Intent(Intent.ACTION_VIEW, Uri.parse(EnclyHandoff.WEB_STORE_URI))); + } catch (ActivityNotFoundException noBrowser) { + Log.w(TAG, "No app can open the store page", noBrowser); + } + } + } + + private void startHandoff(@NonNull String activityClassName) { + busy = true; + result = null; + render(); + disposables.add( + Single.fromCallable( + () -> { + repository.clearArchives(); + return repository.writeArchive(repository.load()); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + file -> launchEncly(file, activityClassName), + error -> { + Log.e(TAG, "prepare hand-off", error); + clearArchivesInBackground(); + busy = false; + render(); + onInfoSnack( + R.string.meet_encly_error_prepare, + null, + SnackBarInfo.Error, + Snackbar.LENGTH_LONG); + })); + } + + private void launchEncly(@NonNull File file, @NonNull String activityClassName) { + try { + Uri uri = FileProvider.getUriForFile(this, authority(), file); + Intent intent = + new Intent(EnclyHandoff.ACTION_IMPORT) + .setComponent( + new ComponentName( + EnclyHandoff.ENCLY_PACKAGE, activityClassName)) + .setDataAndType(uri, EnclyHandoff.MIME_TYPE) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + // For a result: Encly identifies the sender by getCallingPackage(), which is only set + // for an activity started for a result. + enclyLauncher.launch(intent); + } catch (ActivityNotFoundException | IllegalArgumentException | SecurityException e) { + Log.e(TAG, "launch Encly", e); + clearArchivesInBackground(); + onInfoSnack( + R.string.meet_encly_error_prepare, + null, + SnackBarInfo.Error, + Snackbar.LENGTH_LONG); + } finally { + busy = false; + render(); + } + } + + private void onEnclyResult(@NonNull ActivityResult activityResult) { + // Whatever Encly answered, the plain-text copy goes now. + clearArchivesInBackground(); + Intent data = activityResult.getData(); + Bundle extras = data == null ? null : data.getExtras(); + resultCode = activityResult.getResultCode(); + resultCounts = + new int[] { + intOr(extras, EnclyHandoff.EXTRA_NOTES), + intOr(extras, EnclyHandoff.EXTRA_TASKS), + intOr(extras, EnclyHandoff.EXTRA_TAGS), + intOr(extras, EnclyHandoff.EXTRA_SKIPPED) + }; + resultReason = extras == null ? null : extras.getString(EnclyHandoff.EXTRA_REASON); + result = parse(extras); + cleared = false; + busy = false; + render(); + } + + @NonNull + private HandoffResult parse(@Nullable Bundle extras) { + return HandoffResult.parse( + resultCode, + intExtra(extras, EnclyHandoff.EXTRA_NOTES), + intExtra(extras, EnclyHandoff.EXTRA_TASKS), + intExtra(extras, EnclyHandoff.EXTRA_TAGS), + intExtra(extras, EnclyHandoff.EXTRA_SKIPPED), + resultReason); + } + + @Nullable + private static Integer intExtra(@Nullable Bundle extras, @NonNull String key) { + if (extras == null || !extras.containsKey(key)) return null; + return extras.getInt(key); + } + + private static int intOr(@Nullable Bundle extras, @NonNull String key) { + Integer value = intExtra(extras, key); + return value == null ? -1 : value; + } + + private void confirmClear() { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.meet_encly_clear_confirm_title) + .setMessage(R.string.meet_encly_clear_confirm_message) + .setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()) + .setPositiveButton( + R.string.meet_encly_clear_confirm, + (dialog, which) -> { + dialog.dismiss(); + clearMyNotes(); + }) + .show(); + } + + private void clearMyNotes() { + busy = true; + render(); + disposables.add( + Completable.fromAction(repository::clearAllData) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + () -> { + busy = false; + cleared = true; + loadSummary(); + render(); + onInfoSnack( + R.string.meet_encly_cleared, + null, + SnackBarInfo.Success, + Snackbar.LENGTH_LONG); + }, + error -> { + Log.e(TAG, "clear My Notes", error); + busy = false; + render(); + onInfoSnack( + R.string.meet_encly_clear_failed, + null, + SnackBarInfo.Error, + Snackbar.LENGTH_LONG); + })); + } + + private void clearArchivesInBackground() { + disposables.add( + Completable.fromAction(repository::clearArchives) + .subscribeOn(Schedulers.io()) + .subscribe(() -> {}, error -> Log.e(TAG, "clear hand-off files", error))); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(STATE_HAS_RESULT, result != null); + outState.putInt(STATE_RESULT_CODE, resultCode); + outState.putInt(STATE_NOTES, resultCounts[0]); + outState.putInt(STATE_TASKS, resultCounts[1]); + outState.putInt(STATE_TAGS, resultCounts[2]); + outState.putInt(STATE_SKIPPED, resultCounts[3]); + outState.putString(STATE_REASON, resultReason); + outState.putBoolean(STATE_CLEARED, cleared); + } + + private void restoreResult(@NonNull Bundle state) { + cleared = state.getBoolean(STATE_CLEARED, false); + if (!state.getBoolean(STATE_HAS_RESULT, false)) return; + resultCode = state.getInt(STATE_RESULT_CODE); + resultCounts = + new int[] { + state.getInt(STATE_NOTES, -1), + state.getInt(STATE_TASKS, -1), + state.getInt(STATE_TAGS, -1), + state.getInt(STATE_SKIPPED, -1) + }; + resultReason = state.getString(STATE_REASON); + result = + HandoffResult.parse( + resultCode, + orNull(resultCounts[0]), + orNull(resultCounts[1]), + orNull(resultCounts[2]), + orNull(resultCounts[3]), + resultReason); + } + + @Nullable + private static Integer orNull(int value) { + return value < 0 ? null : value; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void onDestroy() { + disposables.clear(); + super.onDestroy(); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MeetEnclyDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MeetEnclyDialog.java new file mode 100644 index 00000000..7c64b4fb --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MeetEnclyDialog.java @@ -0,0 +1,52 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.pasich.mynotes.base.dialog.BaseDialogBottomSheets; +import com.pasich.mynotes.databinding.DialogMeetEnclyBinding; +import com.pasich.mynotes.ui.view.activity.MeetEnclyActivity; + +/** The one-time introduction of Encly, shown on the first start of the version that brings it. */ +public class MeetEnclyDialog extends BaseDialogBottomSheets { + + private DialogMeetEnclyBinding binding; + + public MeetEnclyDialog() {} + + public static MeetEnclyDialog newInstance() { + return new MeetEnclyDialog(); + } + + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + setState((BottomSheetDialog) requireDialog()); + binding = DialogMeetEnclyBinding.inflate(inflater, container, false); + initListeners(); + return binding.getRoot(); + } + + @Override + public void initListeners() { + binding.moreButton.setOnClickListener( + v -> { + startActivity(new Intent(requireActivity(), MeetEnclyActivity.class)); + dismiss(); + }); + binding.laterButton.setOnClickListener(v -> dismiss()); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/UpdateChecker.java b/app/src/main/java/com/pasich/mynotes/utils/UpdateChecker.java index 2264dc86..92e431d7 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/UpdateChecker.java +++ b/app/src/main/java/com/pasich/mynotes/utils/UpdateChecker.java @@ -61,6 +61,18 @@ public void markVersionAsRead() { cache.setLastKnownVersion(currentVersion); } + /** + * The one-time "Meet Encly" introduction is shown on the first start of an updated version, + * like the changelog it precedes, and never again once seen. + */ + public boolean shouldShowMeetEncly() { + return hasNewVersion() && !cache.isMeetEnclyShown(); + } + + public void markMeetEnclyShown() { + cache.setMeetEnclyShown(); + } + /** Initialize version check (should be called on the first launch) */ public void initializeVersionCheck() { String lastKnownVersion = cache.getLastKnownVersion(); diff --git a/app/src/main/java/com/pasich/mynotes/utils/constants/settings/PreferencesConfig.java b/app/src/main/java/com/pasich/mynotes/utils/constants/settings/PreferencesConfig.java index d1184d9d..8d47212c 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/constants/settings/PreferencesConfig.java +++ b/app/src/main/java/com/pasich/mynotes/utils/constants/settings/PreferencesConfig.java @@ -42,6 +42,8 @@ public static int normalizeNoteTextSize(int size) { public static final boolean ARGUMENT_DEFAULT_EXTENDED_EDITOR_VALUE = true; // default new user // LastKnownVersion public static final String ARGUMENT_PREFERENCE_LAST_KNOWN_VERSION = "lastKnownVersion"; + // One-time "Meet Encly" introduction (pasichDev/MyNotes#167) + public static final String ARGUMENT_PREFERENCE_MEET_ENCLY_SHOWN = "meetEnclyShown"; // ThemeMode public static final String ARGUMENT_PREFERENCE_THEME_MODE = "themeMode"; public static final int ARGUMENT_DEFAULT_THEME_MODE_VALUE = diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/AndroidEnclyInspector.java b/app/src/main/java/com/pasich/mynotes/utils/encly/AndroidEnclyInspector.java new file mode 100644 index 00000000..dd091220 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/AndroidEnclyInspector.java @@ -0,0 +1,105 @@ +package com.pasich.mynotes.utils.encly; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.Signature; +import android.net.Uri; +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; + +/** {@link EnclyReceiverCheck.Inspector} backed by the real package manager. */ +public final class AndroidEnclyInspector implements EnclyReceiverCheck.Inspector { + + private final PackageManager packageManager; + private final Uri probeUri; + + /** + * @param probeUri a URI of the same shape as the real hand-off file, so the lookup matches + * Encly's intent filter exactly as the launch will. + */ + public AndroidEnclyInspector(@NonNull Context context, @NonNull Uri probeUri) { + this.packageManager = context.getPackageManager(); + this.probeUri = probeUri; + } + + @Override + public int sdkInt() { + return Build.VERSION.SDK_INT; + } + + @Override + @SuppressWarnings("deprecation") + public boolean isInstalled(@NonNull String packageName) { + try { + packageManager.getPackageInfo(packageName, 0); + return true; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + + @Nullable + @Override + public String findActivity(@NonNull String packageName, @NonNull String action) { + Intent withData = new Intent(action).setPackage(packageName); + withData.setDataAndType(probeUri, EnclyHandoff.MIME_TYPE); + String found = firstExported(withData, packageName); + if (found != null) return found; + // A filter that declares only the action does not match an intent carrying a type; the + // launch names the component explicitly, so the action alone is enough to reach it. + return firstExported(new Intent(action).setPackage(packageName), packageName); + } + + @Nullable + @SuppressWarnings("deprecation") + private String firstExported(@NonNull Intent intent, @NonNull String packageName) { + List matches = packageManager.queryIntentActivities(intent, 0); + if (matches == null) return null; + for (ResolveInfo match : matches) { + ActivityInfo info = match.activityInfo; + if (info != null && info.exported && packageName.equals(info.packageName)) { + return info.name; + } + } + return null; + } + + @Override + public boolean hasSigningCertificateSha256( + @NonNull String packageName, @NonNull byte[] sha256) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false; + return packageManager.hasSigningCertificate( + packageName, sha256, PackageManager.CERT_INPUT_SHA256); + } + + /** + * Only reached below API 28. {@code GET_SIGNATURES} is unsafe when it is trusted with several + * signers; {@link EnclyReceiverCheck} accepts exactly one. + */ + @Nullable + @Override + @SuppressLint("PackageManagerGetSignatures") + @SuppressWarnings("deprecation") + public List legacySignatures(@NonNull String packageName) { + try { + PackageInfo info = + packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES); + if (info.signatures == null) return null; + List out = new ArrayList<>(info.signatures.length); + for (Signature signature : info.signatures) { + out.add(signature == null ? null : signature.toByteArray()); + } + return out; + } catch (PackageManager.NameNotFoundException e) { + return null; + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyHandoff.java b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyHandoff.java new file mode 100644 index 00000000..c1455347 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyHandoff.java @@ -0,0 +1,43 @@ +package com.pasich.mynotes.utils.encly; + +/** + * The hand-off contract shared with Encly (pasichDev/MyNotes#167, pasichDev/Encly#46). + * + *

Every value here is read by the other app as well, so none of them may change without a new + * {@link #SCHEMA} on both sides. + */ +public final class EnclyHandoff { + + public static final String ENCLY_PACKAGE = "com.pasich.encly"; + public static final String ACTION_IMPORT = "com.pasich.encly.action.IMPORT_FROM_MY_NOTES"; + + /** + * SHA-256 of Encly's signing certificate. The same certificate signs the Google Play, F-Droid + * and GitHub builds, so one pin covers every legitimate install. + */ + public static final String ENCLY_CERT_SHA256 = + "6884c693354964276231e6d0336b965f690bf3f15a0707bb59505c208e5e7554"; + + public static final String MIME_TYPE = "application/zip"; + public static final String ENTRY_NAME = "handoff.json"; + public static final String FORMAT = "mynotes-handoff"; + public static final int SCHEMA = 1; + + /** Subdirectory of {@code cacheDir}, exposed by the FileProvider as {@code encly_handoff}. */ + public static final String CACHE_DIR = "encly-handoff"; + + // Result extras (RESULT_OK) + public static final String EXTRA_NOTES = "notes"; + public static final String EXTRA_TASKS = "tasks"; + public static final String EXTRA_TAGS = "tags"; + public static final String EXTRA_SKIPPED = "skipped"; + + // Result extra (RESULT_CANCELED) + public static final String EXTRA_REASON = "reason"; + + public static final String MARKET_URI = "market://details?id=" + ENCLY_PACKAGE; + public static final String WEB_STORE_URI = + "https://play.google.com/store/apps/details?id=" + ENCLY_PACKAGE; + + private EnclyHandoff() {} +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java new file mode 100644 index 00000000..7bdb63aa --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java @@ -0,0 +1,133 @@ +package com.pasich.mynotes.utils.encly; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.database.entities.SyncMetadataEntity; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.data.model.TaskCategory; +import com.pasich.mynotes.data.sync.SyncMutationCoordinator; +import com.pasich.mynotes.extendedEditor.attach.AttachmentStorage; +import com.pasich.mynotes.utils.reminder.ReminderManager; +import com.pasich.mynotes.utils.reminder.TaskReminderManager; +import dagger.hilt.android.qualifiers.ApplicationContext; +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; +import javax.inject.Singleton; + +/** Reads the library for the Encly hand-off, and clears it afterwards when the user asks. */ +@Singleton +public class EnclyMigrationRepository { + + private final Context context; + private final AppDatabase database; + private final SyncMutationCoordinator syncMutationCoordinator; + + @Inject + public EnclyMigrationRepository( + @ApplicationContext Context context, + AppDatabase database, + SyncMutationCoordinator syncMutationCoordinator) { + this.context = context; + this.database = database; + this.syncMutationCoordinator = syncMutationCoordinator; + } + + /** Everything the hand-off would carry, read in one consistent snapshot. */ + public static final class Library { + public final List notes; + public final List tags; + public final List tasks; + public final List categories; + + Library(List notes, List tags, List tasks, List categories) { + this.notes = notes; + this.tags = tags; + this.tasks = tasks; + this.categories = categories; + } + + @NonNull + public HandoffPayloadBuilder.Summary summary() { + return HandoffPayloadBuilder.Summary.of(notes, tags, tasks); + } + } + + @WorkerThread + @NonNull + public Library load() { + return database.runInTransaction( + () -> + new Library( + database.noteDao().getAllNotesSync(), + database.tagsDao().getUserTagsSync(), + database.taskDao().getAllTasksSync(), + database.taskCategoryDao().getCategoriesSync())); + } + + /** Writes the hand-off ZIP into the cache and returns it. */ + @WorkerThread + @NonNull + public File writeArchive(@NonNull Library library) throws IOException { + Map stableIds = new HashMap<>(); + for (SyncMetadataEntity row : database.syncMetadataDao().getAll()) { + stableIds.put(key(row.recordType, row.localId), row.stableId); + } + HandoffPayloadBuilder payload = + new HandoffPayloadBuilder( + library.notes, + library.tags, + library.tasks, + library.categories, + (recordType, localId) -> stableIds.get(key(recordType, localId)), + System.currentTimeMillis()); + return HandoffArchive.write(context.getCacheDir(), payload); + } + + /** Deletes every hand-off file left in the cache. */ + public void clearArchives() { + HandoffArchive.clear(context.getCacheDir()); + } + + /** + * Deletes notes, tasks, tags, task categories and attachment files. Only ever called after + * Encly confirmed the move and the user confirmed this separate step. + */ + @WorkerThread + public void clearAllData() { + Library library = load(); + for (Note note : library.notes) { + if (note.getReminderTime() != null) { + ReminderManager.cancelReminder(context, note.getId()); + } + } + for (Task task : library.tasks) { + if (task.getReminderTime() != null) { + TaskReminderManager.cancelReminder(context, task.getId()); + } + } + syncMutationCoordinator.clearAllUserData(); + deleteRecursively(AttachmentStorage.baseDirPath(context)); + } + + private static String key(String recordType, long localId) { + return recordType + ":" + localId; + } + + private static void deleteRecursively(File file) { + if (file == null || !file.exists()) return; + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) deleteRecursively(child); + } + //noinspection ResultOfMethodCallIgnored + file.delete(); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheck.java b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheck.java new file mode 100644 index 00000000..29fe3f52 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheck.java @@ -0,0 +1,125 @@ +package com.pasich.mynotes.utils.encly; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; + +/** + * Decides whether the installed Encly may be handed every note in plain text. + * + *

The package name alone proves nothing — anything can be installed as {@code com.pasich.encly} + * from outside a store — so the signing certificate is pinned. The package manager calls sit behind + * {@link Inspector}, which keeps the decision itself, including the pre-API 28 path, under JVM + * tests. + */ +public final class EnclyReceiverCheck { + + /** First API level with {@code PackageManager.hasSigningCertificate}. */ + static final int API_HAS_SIGNING_CERTIFICATE = 28; + + public enum Status { + /** Encly is not installed: offer to get it. */ + NOT_INSTALLED, + /** Encly is installed but too old to receive a hand-off: ask for an update. */ + NEEDS_UPDATE, + /** Something named Encly is installed but not signed by Encly's key. Never send to it. */ + UNTRUSTED, + /** The official Encly is installed and can receive. */ + READY + } + + /** The package manager, reduced to what the decision needs. */ + public interface Inspector { + int sdkInt(); + + boolean isInstalled(@NonNull String packageName); + + /** + * The fully qualified class name of the exported activity in {@code packageName} that + * handles {@code action}, or {@code null} when none does. + */ + @Nullable + String findActivity(@NonNull String packageName, @NonNull String action); + + /** API 28+: {@code PackageManager.hasSigningCertificate(..., CERT_INPUT_SHA256)}. */ + boolean hasSigningCertificateSha256(@NonNull String packageName, @NonNull byte[] sha256); + + /** + * API 26–27: the encoded certificates from {@code GET_SIGNATURES}, or {@code null} when the + * package cannot be read. + */ + @Nullable + List legacySignatures(@NonNull String packageName); + } + + public static final class Result { + @NonNull public final Status status; + + /** The activity to start explicitly; set only when {@link #status} is READY. */ + @Nullable public final String activityClassName; + + Result(@NonNull Status status, @Nullable String activityClassName) { + this.status = status; + this.activityClassName = activityClassName; + } + } + + private EnclyReceiverCheck() {} + + @NonNull + public static Result check(@NonNull Inspector inspector) { + return check(inspector, EnclyHandoff.ENCLY_CERT_SHA256); + } + + /** {@link #check(Inspector)} against any pin, so tests can sign with a certificate they own. */ + @NonNull + static Result check(@NonNull Inspector inspector, @NonNull String pinnedSha256Hex) { + String pkg = EnclyHandoff.ENCLY_PACKAGE; + if (!inspector.isInstalled(pkg)) { + return new Result(Status.NOT_INSTALLED, null); + } + if (!isSignedBy(inspector, pkg, hexToBytes(pinnedSha256Hex))) { + return new Result(Status.UNTRUSTED, null); + } + String activity = inspector.findActivity(pkg, EnclyHandoff.ACTION_IMPORT); + if (activity == null || activity.isEmpty()) { + return new Result(Status.NEEDS_UPDATE, null); + } + return new Result(Status.READY, activity); + } + + static boolean isSignedBy( + @NonNull Inspector inspector, @NonNull String pkg, @NonNull byte[] pinned) { + if (inspector.sdkInt() >= API_HAS_SIGNING_CERTIFICATE) { + return inspector.hasSigningCertificateSha256(pkg, pinned); + } + // GET_SIGNATURES on API < 28 returns every signer, and is only safe to trust when there is + // exactly one: with several, an attacker's certificate could sit beside the real one. + List signatures = inspector.legacySignatures(pkg); + if (signatures == null || signatures.size() != 1 || signatures.get(0) == null) { + return false; + } + return MessageDigest.isEqual(pinned, sha256(signatures.get(0))); + } + + @NonNull + static byte[] sha256(@NonNull byte[] data) { + try { + return MessageDigest.getInstance("SHA-256").digest(data); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } + } + + @NonNull + static byte[] hexToBytes(@NonNull String hex) { + if (hex.length() % 2 != 0) throw new IllegalArgumentException("odd hex length"); + byte[] out = new byte[hex.length() / 2]; + for (int i = 0; i < out.length; i++) { + out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return out; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffArchive.java b/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffArchive.java new file mode 100644 index 00000000..7c31c02c --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffArchive.java @@ -0,0 +1,69 @@ +package com.pasich.mynotes.utils.encly; + +import androidx.annotation.NonNull; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * The hand-off file: a ZIP in {@code cacheDir/encly-handoff} holding exactly {@code handoff.json}. + * + *

It is every note in plain text, so it lives only as long as the hand-off: it is deleted when + * Encly answers, whatever the answer, and anything left over from a crash or a killed process is + * swept on the next start. + */ +public final class HandoffArchive { + + private HandoffArchive() {} + + @NonNull + public static File directory(@NonNull File cacheDir) { + return new File(cacheDir, EnclyHandoff.CACHE_DIR); + } + + /** Writes a fresh archive and returns it; a partial file is removed if writing fails. */ + @NonNull + public static File write(@NonNull File cacheDir, @NonNull HandoffPayloadBuilder payload) + throws IOException { + File dir = directory(cacheDir); + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("Cannot create " + dir); + } + File file = new File(dir, "handoff-" + System.currentTimeMillis() + ".zip"); + boolean done = false; + try (ZipOutputStream zip = + new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) { + zip.putNextEntry(new ZipEntry(EnclyHandoff.ENTRY_NAME)); + Writer writer = new OutputStreamWriter(zip, StandardCharsets.UTF_8); + payload.write(writer); + writer.flush(); + zip.closeEntry(); + done = true; + } finally { + if (!done) { + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + } + return file; + } + + /** Deletes every hand-off file. Returns the number of files that could not be deleted. */ + public static int clear(@NonNull File cacheDir) { + File dir = directory(cacheDir); + File[] files = dir.listFiles(); + int failed = 0; + if (files != null) { + for (File file : files) { + if (!file.delete() && file.exists()) failed++; + } + } + return failed; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilder.java b/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilder.java new file mode 100644 index 00000000..2ea7c061 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilder.java @@ -0,0 +1,246 @@ +package com.pasich.mynotes.utils.encly; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.stream.JsonWriter; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.data.model.TaskCategory; +import com.pasich.mynotes.data.sync.SyncMetadata; +import com.pasich.mynotes.extendedEditor.attach.EditorAttachmentBlocks; +import com.pasich.mynotes.utils.managers.SystemTagsManager; +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Writes {@code handoff.json} — the raw My Notes data Encly imports. + * + *

Raw on purpose: Encly maps it to its own blocks, so the Encly format stays private to Encly. + * Attachment files and reminders are never sent; each note only says how many attachments stay + * behind. Free of {@code android.*} so the exact wire shape is pinned by JVM tests. + */ +public final class HandoffPayloadBuilder { + + /** Looks up the durable sync identity of a local record. */ + public interface StableIds { + /** + * @return {@code sync_metadata.stableId} for that record, or {@code null} when it has none. + */ + @Nullable + String find(@NonNull String recordType, long localId); + } + + private final List notes; + private final List tags; + private final List tasks; + private final List categories; + private final StableIds stableIds; + private final long exportedAt; + + public HandoffPayloadBuilder( + @NonNull List notes, + @NonNull List tags, + @NonNull List tasks, + @NonNull List categories, + @NonNull StableIds stableIds, + long exportedAt) { + this.notes = notes; + this.tags = tags; + this.tasks = tasks; + this.categories = categories; + this.stableIds = stableIds; + this.exportedAt = exportedAt; + } + + /** The stable id used on the wire: the sync identity, else {@code mynotes::}. */ + @NonNull + public static String stableId( + @NonNull StableIds stableIds, @NonNull String recordType, long localId) { + String stable = stableIds.find(recordType, localId); + if (stable != null && !stable.trim().isEmpty()) return stable; + return "mynotes:" + recordType + ":" + localId; + } + + /** + * Attachments that stay in My Notes: the larger of the note's attachment list and the file + * references in its Editor.js document, so a note whose list is stale still says what is lost. + */ + public static int attachmentCount(@NonNull Note note) { + int listed = 0; + String json = note.getAttachments(); + if (json != null && !json.trim().isEmpty()) { + try { + JsonElement parsed = JsonParser.parseString(json); + if (parsed.isJsonArray()) listed = parsed.getAsJsonArray().size(); + } catch (RuntimeException unreadable) { + listed = 0; + } + } + int referenced = EditorAttachmentBlocks.fileUrls(note.getValueJson()).size(); + return Math.max(listed, referenced); + } + + /** User tags only; system tags are UI rows, not data. */ + public static boolean isUserTag(@NonNull Tag tag) { + return tag.getSystemAction() == SystemTagsManager.SYSTEM_ACTION_USER_TAG; + } + + /** + * The text of a task. The contract carries one {@code description}; a My Notes task has a + * required title and an optional description, so both travel, title first. + */ + @NonNull + public static String taskText(@NonNull Task task) { + String title = task.getTitle() == null ? "" : task.getTitle(); + String description = task.getDescription(); + if (description == null || description.trim().isEmpty()) return title; + if (title.isEmpty()) return description; + return title + "\n" + description; + } + + @NonNull + public String toJson() { + StringWriter out = new StringWriter(); + try { + write(out); + } catch (IOException impossible) { + throw new IllegalStateException(impossible); + } + return out.toString(); + } + + /** Streams the document, so a large library is never held twice in memory. */ + public void write(@NonNull Writer out) throws IOException { + JsonWriter json = new JsonWriter(out); + json.setHtmlSafe(false); + json.setSerializeNulls(true); + json.beginObject(); + json.name("format").value(EnclyHandoff.FORMAT); + json.name("schema").value(EnclyHandoff.SCHEMA); + json.name("exportedAt").value(exportedAt); + + json.name("tags").beginArray(); + for (Tag tag : tags) { + if (!isUserTag(tag)) continue; + json.beginObject(); + json.name("id").value(stableId(stableIds, SyncMetadata.RECORD_TYPE_TAG, tag.getId())); + json.name("name").value(tag.getNameTag()); + json.name("position").value(tag.getPosition()); + json.endObject(); + } + json.endArray(); + + json.name("notes").beginArray(); + for (Note note : notes) { + json.beginObject(); + json.name("id").value(stableId(stableIds, SyncMetadata.RECORD_TYPE_NOTE, note.getId())); + json.name("title").value(note.getTitle()); + json.name("value").value(note.getValue()); + String valueJson = note.getValueJson(); + if (valueJson.trim().isEmpty()) { + json.name("valueJson").nullValue(); + } else { + json.name("valueJson").value(valueJson); + } + json.name("date").value(note.getDate()); + String tag = note.getTag(); + if (tag.trim().isEmpty()) { + json.name("tag").nullValue(); + } else { + json.name("tag").value(tag); + } + json.name("isTrash").value(note.isTrash()); + json.name("isPinned").value(note.isPinned()); + json.name("attachments").value(attachmentCount(note)); + json.endObject(); + } + json.endArray(); + + Set categoryIds = new HashSet<>(); + json.name("taskCategories").beginArray(); + for (TaskCategory category : categories) { + categoryIds.add(category.getId()); + json.beginObject(); + json.name("id") + .value( + stableId( + stableIds, + SyncMetadata.RECORD_TYPE_CATEGORY, + category.getId())); + json.name("name").value(category.getName()); + json.name("position").value(category.getPosition()); + json.endObject(); + } + json.endArray(); + + json.name("tasks").beginArray(); + for (Task task : tasks) { + json.beginObject(); + json.name("id").value(stableId(stableIds, SyncMetadata.RECORD_TYPE_TASK, task.getId())); + json.name("description").value(taskText(task)); + json.name("isDone").value(task.isDone()); + json.name("createdAt").value(task.getCreatedAt()); + // 0 means "no category"; an id with no row would point Encly at nothing. + if (task.getCategoryId() != 0 && categoryIds.contains(task.getCategoryId())) { + json.name("categoryId") + .value( + stableId( + stableIds, + SyncMetadata.RECORD_TYPE_CATEGORY, + task.getCategoryId())); + } else { + json.name("categoryId").nullValue(); + } + json.name("position").value(task.getPosition()); + json.endObject(); + } + json.endArray(); + + json.endObject(); + json.flush(); + } + + /** Totals shown on the page before anything is sent. */ + public static final class Summary { + public final int notes; + public final int tasks; + public final int tags; + public final int attachments; + public final int reminders; + public final int pinned; + + public Summary(int notes, int tasks, int tags, int attachments, int reminders, int pinned) { + this.notes = notes; + this.tasks = tasks; + this.tags = tags; + this.attachments = attachments; + this.reminders = reminders; + this.pinned = pinned; + } + + @NonNull + public static Summary of( + @NonNull List notes, @NonNull List tags, @NonNull List tasks) { + int userTags = 0; + for (Tag tag : tags) if (isUserTag(tag)) userTags++; + int attachments = 0; + int reminders = 0; + int pinned = 0; + for (Note note : notes) { + attachments += attachmentCount(note); + if (note.getReminderTime() != null) reminders++; + if (note.isPinned()) pinned++; + } + for (Task task : tasks) if (task.getReminderTime() != null) reminders++; + return new Summary( + notes.size(), tasks.size(), userTags, attachments, reminders, pinned); + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffResult.java b/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffResult.java new file mode 100644 index 00000000..2a24b1c5 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffResult.java @@ -0,0 +1,86 @@ +package com.pasich.mynotes.utils.encly; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * What Encly answered. A missing reason — Encly crashed, or the user pressed back before it could + * answer — is read as a cancellation, never as a success. + */ +public final class HandoffResult { + + /** {@code android.app.Activity.RESULT_OK}; kept here so parsing stays a JVM concern. */ + public static final int RESULT_OK = -1; + + public enum Reason { + UNTRUSTED_CALLER("untrusted_caller"), + CANCELLED("cancelled"), + UNSUPPORTED_SCHEMA("unsupported_schema"), + INVALID_PAYLOAD("invalid_payload"), + TOO_LARGE("too_large"), + FAILED("failed"); + + public final String wire; + + Reason(String wire) { + this.wire = wire; + } + + @NonNull + static Reason fromWire(@Nullable String value) { + if (value == null || value.isEmpty()) return CANCELLED; + for (Reason reason : values()) { + if (reason.wire.equals(value)) return reason; + } + // A reason this build does not know yet is still a failure, not a silent cancel. + return FAILED; + } + } + + public final boolean success; + public final int notes; + public final int tasks; + public final int tags; + public final int skipped; + + /** Set only when {@link #success} is false. */ + @Nullable public final Reason reason; + + private HandoffResult( + boolean success, int notes, int tasks, int tags, int skipped, @Nullable Reason reason) { + this.success = success; + this.notes = notes; + this.tasks = tasks; + this.tags = tags; + this.skipped = skipped; + this.reason = reason; + } + + /** + * @param resultCode the activity result code. + * @param notes the {@code notes} extra, or {@code null} when absent; likewise for the rest. + */ + @NonNull + public static HandoffResult parse( + int resultCode, + @Nullable Integer notes, + @Nullable Integer tasks, + @Nullable Integer tags, + @Nullable Integer skipped, + @Nullable String reason) { + if (resultCode == RESULT_OK) { + return new HandoffResult( + true, + nonNegative(notes), + nonNegative(tasks), + nonNegative(tags), + nonNegative(skipped), + null); + } + return new HandoffResult(false, 0, 0, 0, 0, Reason.fromWire(reason)); + } + + private static int nonNegative(@Nullable Integer value) { + return value == null || value < 0 ? 0 : value; + } +} diff --git a/app/src/main/res/layout/activity_meet_encly.xml b/app/src/main/res/layout/activity_meet_encly.xml new file mode 100644 index 00000000..4585b0be --- /dev/null +++ b/app/src/main/res/layout/activity_meet_encly.xml @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_meet_encly.xml b/app/src/main/res/layout/dialog_meet_encly.xml new file mode 100644 index 00000000..8c04f0a8 --- /dev/null +++ b/app/src/main/res/layout/dialog_meet_encly.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/nav_header_main.xml b/app/src/main/res/layout/nav_header_main.xml index 1bcf7d35..1a812f11 100644 --- a/app/src/main/res/layout/nav_header_main.xml +++ b/app/src/main/res/layout/nav_header_main.xml @@ -254,6 +254,52 @@ android:textSize="16sp" /> + + + + + + + + + + + + Пасля пацвярджэння першай сінхранізацыі можна ўключыць фонавую. Яна запускаецца прыблізна кожныя 6 гадзін пры наяўнасці інтэрнэту, і яе можна выключыць у любы час. Вырашэнне канфліктаў Калі лакальная версія і версія на Drive адрозніваюцца, для кожнага канфлікту выберыце, якую захаваць: лакальную або з Google Drive. + + + Знаёмцеся: Encly + Пераемнік «Мае Нататкі» + Encly — новая праграма ад аўтара «Мае Нататкі»: цалкам зашыфраваныя нататкі, якія працуюць афлайн. Нататкі захоўваюцца ў зашыфраванай базе даных, праграма блакуецца PIN-кодам, а рэзервовыя копіі таксама зашыфраваныя. + Перанос аднабаковы: «Мае Нататкі» перадаюць вашы даныя ў Encly, а Encly нічога не адпраўляе назад. З «Мае Нататкі» нічога не выдаляецца, праграма працуе як раней. + Што пераносіцца + Нататкі, разам з сметніцай: %1$d + Задачы: %1$d + Тэгі: %1$d + Спісы задач (стануць тэгамі): %1$d + Што не пераносіцца + Укладанні і выявы: %1$d. Яны застануцца ў «Мае Нататкі». + Напаміны: %1$d. У Encly няма напамінаў. + Замацаваныя нататкі: %1$d. Яны перанясуцца, але без замацавання. + Атрымаць Encly + Абнавіць Encly + Усталяваная версія Encly пакуль не ўмее прымаць нататкі. Абнавіце яе да апошняй версіі і вярніцеся сюды. + Усталяваная праграма з назвай Encly не з\'яўляецца афіцыйнай зборкай, таму нататкі ёй не перадаюцца. Усталюйце Encly з Google Play, F-Droid або GitHub. + Перанесці мае нататкі ў Encly + Encly атрымаў вашы даныя. Нататак: %1$d, задач: %2$d, тэгаў: %3$d. Прапушчана: %4$d. + Укладанняў і выяў засталося ў «Мае Нататкі»: %1$d. + Перанос скасаваны. Нічога не змянілася. + Encly прымае нататкі толькі ад афіцыйнай праграмы «Мае Нататкі». Усталюйце «Мае Нататкі» з Google Play, F-Droid або GitHub і паспрабуйце зноў. + Гэтая версія Encly не можа прачытаць даныя з гэтай версіі «Мае Нататкі». Абнавіце Encly і паспрабуйце зноў. + Encly не змог прачытаць даныя. Нічога не імпартавана. + Даных занадта шмат, каб перанесці іх за адзін раз. Нічога не імпартавана. + Encly не змог завяршыць імпарт. З «Мае Нататкі» нічога не выдалена. + Не ўдалося падрыхтаваць нататкі для Encly. + Вашы нататкі цяпер у Encly. «Мае Нататкі» па-ранейшаму захоўваюць уласную копію. Яе можна ачысціць, калі яна больш не патрэбна. + Ачысціць «Мае Нататкі» + Ачысціць «Мае Нататкі»? + Гэта назаўсёды выдаліць з «Мае Нататкі» ўсе нататкі (разам з сметніцай), задачы, тэгі, спісы задач і файлы ўкладанняў. Калі ўключана сінхранізацыя з Google Дыскам, яны будуць выдалены і з сінхранізаванай копіі. Укладанні не пераносіліся ў Encly. Гэта дзеянне нельга адмяніць. + Выдаліць усё + «Мае Нататкі» ачышчаны. + Не ўдалося ачысціць «Мае Нататкі». + У «Мае Нататкі» з\'явіўся пераемнік — Encly, цалкам зашыфраваныя нататкі, якія працуюць афлайн. Усе нататкі можна перанесці ў яго адным дотыкам. + Падрабязней + Пазней diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 931eaf09..08a5c089 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -476,4 +476,43 @@ Nach der Bestätigung der ersten Synchronisierung können Sie die Hintergrundsynchronisierung aktivieren. Sie läuft bei bestehender Internetverbindung etwa alle 6 Stunden und kann jederzeit deaktiviert werden. Konflikte lösen Wenn sich die lokale und die Drive-Version unterscheiden, wählen Sie für jeden Konflikt, ob die lokale Version oder die Version aus Google Drive behalten werden soll. + + + Lerne Encly kennen + Der Nachfolger von Meine Notizen + Encly ist die nächste App vom Entwickler von Meine Notizen: eine vollständig verschlüsselte Notiz-App, die offline funktioniert. Deine Notizen liegen in einer verschlüsselten Datenbank, die App wird mit einer PIN gesperrt, und auch Sicherungen sind verschlüsselt. + Der Umzug geht nur in eine Richtung: Meine Notizen übergibt deine Daten an Encly, und Encly sendet nichts zurück. In Meine Notizen wird nichts gelöscht; die App funktioniert weiter wie bisher. + Was umzieht + Notizen, einschließlich Papierkorb: %1$d + Aufgaben: %1$d + Tags: %1$d + Aufgabenlisten (werden zu Tags): %1$d + Was nicht umzieht + Anhänge und Bilder: %1$d. Sie bleiben in Meine Notizen. + Erinnerungen: %1$d. Encly hat keine Erinnerungen. + Angeheftete Notizen: %1$d. Sie ziehen um, aber ohne Anheftung. + Encly holen + Encly aktualisieren + Das installierte Encly kann noch keine Notizen empfangen. Aktualisiere es auf die neueste Version und komm dann hierher zurück. + Die installierte App namens Encly ist nicht die offizielle Version, daher werden deine Notizen nicht an sie gesendet. Installiere Encly über Google Play, F-Droid oder GitHub. + Meine Notizen zu Encly umziehen + Encly hat deine Daten erhalten. Notizen: %1$d, Aufgaben: %2$d, Tags: %3$d. Übersprungen: %4$d. + In Meine Notizen verbliebene Anhänge und Bilder: %1$d. + Der Umzug wurde abgebrochen. Es wurde nichts geändert. + Encly nimmt Notizen nur von der offiziellen App Meine Notizen an. Installiere Meine Notizen über Google Play, F-Droid oder GitHub und versuche es erneut. + Dieses Encly kann die Daten dieser Version von Meine Notizen nicht lesen. Aktualisiere Encly und versuche es erneut. + Encly konnte die Daten nicht lesen. Es wurde nichts importiert. + Es sind zu viele Daten für einen Umzug auf einmal. Es wurde nichts importiert. + Encly konnte den Import nicht abschließen. In Meine Notizen wurde nichts gelöscht. + Deine Notizen konnten nicht für Encly vorbereitet werden. + Deine Notizen sind jetzt in Encly. Meine Notizen behält weiterhin eine eigene Kopie. Du kannst sie leeren, wenn du sie nicht mehr brauchst. + Meine Notizen leeren + Meine Notizen leeren? + Damit werden alle Notizen (einschließlich Papierkorb), Aufgaben, Tags, Aufgabenlisten und Anhangsdateien dauerhaft aus Meine Notizen gelöscht. Ist die Google-Drive-Synchronisierung aktiv, werden sie auch aus der synchronisierten Kopie gelöscht. Anhänge sind nicht zu Encly umgezogen. Das kann nicht rückgängig gemacht werden. + Alles löschen + Meine Notizen wurde geleert. + Meine Notizen konnte nicht geleert werden. + Meine Notizen hat einen Nachfolger: Encly, eine vollständig verschlüsselte Notiz-App, die offline funktioniert. Du kannst alle deine Notizen mit einem Tippen dorthin umziehen. + Mehr erfahren + Später diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 69567ba8..554174b2 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -523,4 +523,43 @@ After you confirm the first sync, you can enable background sync. It runs about every 6 hours when the device has an internet connection and can be turned off at any time. Resolve Conflicts When local and Drive versions differ, choose whether to keep the local version or the version from Google Drive for each conflict. + + + Meet Encly + The successor of My Notes + Encly is the next app from the author of My Notes: a fully encrypted notes app that works offline. Your notes live in an encrypted database, the app locks with a PIN, and backups are encrypted too. + Moving is one way: My Notes hands your data to Encly, and Encly never sends anything back. Nothing is deleted from My Notes; it keeps working as before. + What moves + Notes, including the trash: %1$d + Tasks: %1$d + Tags: %1$d + Task lists (they become tags): %1$d + What does not move + Attachments and images: %1$d. They stay in My Notes. + Reminders: %1$d. Encly has no reminders. + Pinned notes: %1$d. They move, but without the pin. + Get Encly + Update Encly + The installed Encly cannot receive notes yet. Update it to the latest version, then come back here. + The installed app called Encly is not the official build, so your notes will not be sent to it. Install Encly from Google Play, F-Droid or GitHub. + Move my notes to Encly + Encly received your data. Notes: %1$d, tasks: %2$d, tags: %3$d. Skipped: %4$d. + Attachments and images left in My Notes: %1$d. + The move was cancelled. Nothing has changed. + Encly accepts notes only from the official My Notes app. Install My Notes from Google Play, F-Droid or GitHub and try again. + This Encly cannot read data from this version of My Notes. Update Encly and try again. + Encly could not read the data. Nothing was imported. + There is too much data to move in one go. Nothing was imported. + Encly could not finish the import. Nothing was deleted from My Notes. + Could not prepare your notes for Encly. + Your notes are now in Encly. My Notes still keeps its own copy. You can clear it if you no longer need it. + Clear My Notes + Clear My Notes? + This permanently deletes all notes (including the trash), tasks, tags, task lists and attachment files from My Notes. If Google Drive sync is on, they are deleted from the synced copy too. Attachments did not move to Encly. This cannot be undone. + Delete everything + My Notes has been cleared. + Could not clear My Notes. + My Notes has a successor: Encly, a fully encrypted notes app that works offline. You can move all your notes to it in one tap. + Learn more + Later diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 20afc507..bd591958 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -477,4 +477,43 @@ Después de confirmar la primera sincronización, puedes activar la sincronización en segundo plano. Se ejecuta aproximadamente cada 6 horas cuando el dispositivo tiene conexión a internet y se puede desactivar en cualquier momento. Resolver conflictos Cuando las versiones local y de Drive son distintas, elige para cada conflicto si conservar la versión local o la versión de Google Drive. + + + Conoce Encly + El sucesor de Mis notas + Encly es la nueva app del autor de Mis notas: una app de notas totalmente cifrada que funciona sin conexión. Tus notas se guardan en una base de datos cifrada, la app se bloquea con un PIN y las copias de seguridad también están cifradas. + El traslado es en un solo sentido: Mis notas entrega tus datos a Encly, y Encly nunca devuelve nada. No se borra nada de Mis notas; seguirá funcionando como siempre. + Qué se traslada + Notas, incluida la papelera: %1$d + Tareas: %1$d + Etiquetas: %1$d + Listas de tareas (se convierten en etiquetas): %1$d + Qué no se traslada + Adjuntos e imágenes: %1$d. Se quedan en Mis notas. + Recordatorios: %1$d. Encly no tiene recordatorios. + Notas fijadas: %1$d. Se trasladan, pero sin fijar. + Obtener Encly + Actualizar Encly + La versión instalada de Encly todavía no puede recibir notas. Actualízala a la última versión y vuelve aquí. + La app instalada con el nombre Encly no es la versión oficial, así que tus notas no se le enviarán. Instala Encly desde Google Play, F-Droid o GitHub. + Trasladar mis notas a Encly + Encly recibió tus datos. Notas: %1$d, tareas: %2$d, etiquetas: %3$d. Omitidos: %4$d. + Adjuntos e imágenes que quedan en Mis notas: %1$d. + El traslado se canceló. No ha cambiado nada. + Encly solo acepta notas de la app oficial Mis notas. Instala Mis notas desde Google Play, F-Droid o GitHub e inténtalo de nuevo. + Esta versión de Encly no puede leer los datos de esta versión de Mis notas. Actualiza Encly e inténtalo de nuevo. + Encly no pudo leer los datos. No se importó nada. + Hay demasiados datos para trasladarlos de una vez. No se importó nada. + Encly no pudo terminar la importación. No se borró nada de Mis notas. + No se pudieron preparar tus notas para Encly. + Tus notas ya están en Encly. Mis notas sigue guardando su propia copia. Puedes vaciarla si ya no la necesitas. + Vaciar Mis notas + ¿Vaciar Mis notas? + Esto borra de forma permanente de Mis notas todas las notas (incluida la papelera), tareas, etiquetas, listas de tareas y archivos adjuntos. Si la sincronización con Google Drive está activada, también se borrarán de la copia sincronizada. Los adjuntos no se trasladaron a Encly. Esta acción no se puede deshacer. + Borrar todo + Mis notas se ha vaciado. + No se pudo vaciar Mis notas. + Mis notas tiene un sucesor: Encly, una app de notas totalmente cifrada que funciona sin conexión. Puedes trasladar todas tus notas con un solo toque. + Más información + Más tarde diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 59d23ae6..3124a8c1 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -472,4 +472,43 @@ Après avoir confirmé la première synchronisation, vous pouvez activer la synchronisation en arrière-plan. Elle s’exécute environ toutes les 6 heures lorsque l’appareil est connecté à Internet et peut être désactivée à tout moment. Résoudre les conflits Lorsque les versions locale et Drive diffèrent, choisissez pour chaque conflit de conserver la version locale ou celle de Google Drive. + + + Découvrez Encly + Le successeur de Mes notes + Encly est la nouvelle application de l\'auteur de Mes notes : une application de notes entièrement chiffrée qui fonctionne hors ligne. Vos notes sont stockées dans une base de données chiffrée, l\'application se verrouille par code PIN et les sauvegardes sont chiffrées elles aussi. + Le transfert est à sens unique : Mes notes transmet vos données à Encly, et Encly ne renvoie jamais rien. Rien n\'est supprimé de Mes notes, qui continue de fonctionner comme avant. + Ce qui est transféré + Notes, corbeille comprise : %1$d + Tâches : %1$d + Étiquettes : %1$d + Listes de tâches (elles deviennent des étiquettes) : %1$d + Ce qui n\'est pas transféré + Pièces jointes et images : %1$d. Elles restent dans Mes notes. + Rappels : %1$d. Encly n\'a pas de rappels. + Notes épinglées : %1$d. Elles sont transférées, mais sans l\'épingle. + Obtenir Encly + Mettre à jour Encly + La version installée d\'Encly ne peut pas encore recevoir de notes. Mettez-la à jour vers la dernière version, puis revenez ici. + L\'application installée sous le nom d\'Encly n\'est pas la version officielle : vos notes ne lui seront pas envoyées. Installez Encly depuis Google Play, F-Droid ou GitHub. + Transférer mes notes vers Encly + Encly a reçu vos données. Notes : %1$d, tâches : %2$d, étiquettes : %3$d. Ignorés : %4$d. + Pièces jointes et images restées dans Mes notes : %1$d. + Le transfert a été annulé. Rien n\'a changé. + Encly n\'accepte les notes que de l\'application officielle Mes notes. Installez Mes notes depuis Google Play, F-Droid ou GitHub, puis réessayez. + Cette version d\'Encly ne peut pas lire les données de cette version de Mes notes. Mettez Encly à jour, puis réessayez. + Encly n\'a pas pu lire les données. Rien n\'a été importé. + Il y a trop de données pour un seul transfert. Rien n\'a été importé. + Encly n\'a pas pu terminer l\'importation. Rien n\'a été supprimé de Mes notes. + Impossible de préparer vos notes pour Encly. + Vos notes sont maintenant dans Encly. Mes notes conserve toujours sa propre copie. Vous pouvez la vider si vous n\'en avez plus besoin. + Vider Mes notes + Vider Mes notes ? + Cette action supprime définitivement de Mes notes toutes les notes (corbeille comprise), les tâches, les étiquettes, les listes de tâches et les fichiers joints. Si la synchronisation Google Drive est activée, ils seront aussi supprimés de la copie synchronisée. Les pièces jointes n\'ont pas été transférées vers Encly. Cette action est irréversible. + Tout supprimer + Mes notes a été vidé. + Impossible de vider Mes notes. + Mes notes a un successeur : Encly, une application de notes entièrement chiffrée qui fonctionne hors ligne. Vous pouvez y transférer toutes vos notes en un seul geste. + En savoir plus + Plus tard diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0ce04eb4..48170c00 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -474,4 +474,43 @@ Dopo aver confermato la prima sincronizzazione, puoi attivare la sincronizzazione in background. Viene eseguita circa ogni 6 ore quando il dispositivo dispone di una connessione Internet e può essere disattivata in qualsiasi momento. Risolvi conflitti Quando le versioni locale e Drive differiscono, scegli per ogni conflitto se mantenere la versione locale o quella di Google Drive. + + + Scopri Encly + Il successore di Le mie note + Encly è la nuova app dell\'autore di Le mie note: un\'app di note completamente cifrata che funziona offline. Le note sono salvate in un database cifrato, l\'app si blocca con un PIN e anche i backup sono cifrati. + Il trasferimento è in una sola direzione: Le mie note consegna i tuoi dati a Encly, ed Encly non invia mai nulla indietro. Da Le mie note non viene eliminato nulla: l\'app continua a funzionare come prima. + Cosa viene trasferito + Note, cestino compreso: %1$d + Attività: %1$d + Tag: %1$d + Elenchi di attività (diventano tag): %1$d + Cosa non viene trasferito + Allegati e immagini: %1$d. Restano in Le mie note. + Promemoria: %1$d. Encly non ha promemoria. + Note fissate: %1$d. Vengono trasferite, ma senza essere fissate. + Scarica Encly + Aggiorna Encly + La versione installata di Encly non può ancora ricevere note. Aggiornala all\'ultima versione e poi torna qui. + L\'app installata con il nome Encly non è la versione ufficiale, quindi le tue note non le verranno inviate. Installa Encly da Google Play, F-Droid o GitHub. + Trasferisci le mie note su Encly + Encly ha ricevuto i tuoi dati. Note: %1$d, attività: %2$d, tag: %3$d. Saltati: %4$d. + Allegati e immagini rimasti in Le mie note: %1$d. + Il trasferimento è stato annullato. Non è cambiato nulla. + Encly accetta note solo dall\'app ufficiale Le mie note. Installa Le mie note da Google Play, F-Droid o GitHub e riprova. + Questa versione di Encly non può leggere i dati di questa versione di Le mie note. Aggiorna Encly e riprova. + Encly non è riuscito a leggere i dati. Non è stato importato nulla. + Ci sono troppi dati da trasferire in una volta sola. Non è stato importato nulla. + Encly non è riuscito a completare l\'importazione. Da Le mie note non è stato eliminato nulla. + Impossibile preparare le note per Encly. + Le tue note ora sono in Encly. Le mie note conserva ancora una propria copia. Puoi svuotarla se non ti serve più. + Svuota Le mie note + Svuotare Le mie note? + Verranno eliminati definitivamente da Le mie note tutte le note (cestino compreso), le attività, i tag, gli elenchi di attività e i file allegati. Se la sincronizzazione con Google Drive è attiva, verranno eliminati anche dalla copia sincronizzata. Gli allegati non sono stati trasferiti su Encly. L\'operazione non può essere annullata. + Elimina tutto + Le mie note è stata svuotata. + Impossibile svuotare Le mie note. + Le mie note ha un successore: Encly, un\'app di note completamente cifrata che funziona offline. Puoi trasferire tutte le tue note con un solo tocco. + Scopri di più + Più tardi diff --git a/app/src/main/res/values-kk/strings.xml b/app/src/main/res/values-kk/strings.xml index 509e1c6e..9a6b6458 100644 --- a/app/src/main/res/values-kk/strings.xml +++ b/app/src/main/res/values-kk/strings.xml @@ -473,4 +473,43 @@ Алғашқы синхрондауды растағаннан кейін фондық синхрондауды қоса аласыз. Ол құрылғы интернетке қосылған кезде шамамен әр 6 сағат сайын орындалады және оны кез келген уақытта өшіруге болады. Қайшылықтарды шешу Жергілікті және Drive нұсқалары әртүрлі болса, әр қайшылық үшін жергілікті нұсқаны не Google Drive нұсқасын сақтауды таңдаңыз. + + + Encly-мен танысыңыз + «Менің жазбаларым» қолданбасының мұрагері + Encly — «Менің жазбаларым» авторының жаңа қолданбасы: офлайн жұмыс істейтін толық шифрланған жазбалар. Жазбалар шифрланған дерекқорда сақталады, қолданба PIN-кодпен құлыпталады, ал сақтық көшірмелер де шифрланған. + Көшіру бір бағытта жүреді: «Менің жазбаларым» деректеріңізді Encly-ге береді, ал Encly ешнәрсені кері жібермейді. «Менің жазбаларым» қолданбасынан ештеңе жойылмайды, ол бұрынғыдай жұмыс істей береді. + Не көшіріледі + Жазбалар, себетті қоса: %1$d + Тапсырмалар: %1$d + Тегтер: %1$d + Тапсырмалар тізімдері (тегке айналады): %1$d + Не көшірілмейді + Тіркемелер мен суреттер: %1$d. Олар «Менің жазбаларым» қолданбасында қалады. + Еске салғыштар: %1$d. Encly-де еске салғыштар жоқ. + Бекітілген жазбалар: %1$d. Олар көшіріледі, бірақ бекітусіз. + Encly алу + Encly жаңарту + Орнатылған Encly нұсқасы әзірге жазбаларды қабылдай алмайды. Оны соңғы нұсқаға дейін жаңартып, осында қайта оралыңыз. + Encly деп аталатын орнатылған қолданба ресми нұсқа емес, сондықтан жазбаларыңыз оған жіберілмейді. Encly-ді Google Play, F-Droid немесе GitHub арқылы орнатыңыз. + Жазбаларымды Encly-ге көшіру + Encly деректеріңізді алды. Жазбалар: %1$d, тапсырмалар: %2$d, тегтер: %3$d. Өткізіліп жіберілді: %4$d. + «Менің жазбаларым» қолданбасында қалған тіркемелер мен суреттер: %1$d. + Көшіру тоқтатылды. Ештеңе өзгерген жоқ. + Encly жазбаларды тек ресми «Менің жазбаларым» қолданбасынан қабылдайды. «Менің жазбаларым» қолданбасын Google Play, F-Droid немесе GitHub арқылы орнатып, қайталап көріңіз. + Бұл Encly нұсқасы «Менің жазбаларым» қолданбасының осы нұсқасының деректерін оқи алмайды. Encly-ді жаңартып, қайталап көріңіз. + Encly деректерді оқи алмады. Ештеңе импортталған жоқ. + Бір реттік көшіру үшін деректер тым көп. Ештеңе импортталған жоқ. + Encly импортты аяқтай алмады. «Менің жазбаларым» қолданбасынан ештеңе жойылған жоқ. + Жазбаларды Encly үшін дайындау мүмкін болмады. + Жазбаларыңыз енді Encly-де. «Менің жазбаларым» әлі де өз көшірмесін сақтайды. Ол қажет болмаса, оны тазалауға болады. + «Менің жазбаларым» тазалау + «Менің жазбаларым» тазаланбақ па? + Бұл «Менің жазбаларым» қолданбасынан барлық жазбаларды (себетті қоса), тапсырмаларды, тегтерді, тапсырмалар тізімдерін және тіркеме файлдарын біржола жояды. Google Drive синхрондауы қосулы болса, олар синхрондалған көшірмеден де жойылады. Тіркемелер Encly-ге көшірілген жоқ. Бұл әрекетті болдырмау мүмкін емес. + Барлығын жою + «Менің жазбаларым» тазаланды. + «Менің жазбаларым» тазалау мүмкін болмады. + «Менің жазбаларым» қолданбасының мұрагері бар — Encly, офлайн жұмыс істейтін толық шифрланған жазбалар. Барлық жазбаларыңызды оған бір түртумен көшіруге болады. + Толығырақ + Кейінірек diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 3d704560..cc061221 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -477,4 +477,43 @@ Po potwierdzeniu pierwszej synchronizacji możesz włączyć synchronizację w tle. Uruchamia się ona mniej więcej co 6 godzin, gdy urządzenie ma połączenie z internetem, i można ją wyłączyć w dowolnym momencie. Rozwiązywanie konfliktów Gdy wersje lokalna i Drive się różnią, dla każdego konfliktu wybierz, czy zachować wersję lokalną, czy wersję z Google Drive. + + + Poznaj Encly + Następca aplikacji Moje notatki + Encly to nowa aplikacja autora Moich notatek: w pełni szyfrowane notatki działające offline. Notatki są przechowywane w zaszyfrowanej bazie danych, aplikacja jest blokowana kodem PIN, a kopie zapasowe również są szyfrowane. + Przeniesienie działa w jedną stronę: Moje notatki przekazują Twoje dane do Encly, a Encly niczego nie odsyła. Z Moich notatek nic nie jest usuwane; aplikacja działa tak jak wcześniej. + Co zostanie przeniesione + Notatki, łącznie z koszem: %1$d + Zadania: %1$d + Tagi: %1$d + Listy zadań (staną się tagami): %1$d + Co nie zostanie przeniesione + Załączniki i obrazy: %1$d. Zostaną w Moich notatkach. + Przypomnienia: %1$d. Encly nie ma przypomnień. + Przypięte notatki: %1$d. Zostaną przeniesione, ale bez przypięcia. + Pobierz Encly + Zaktualizuj Encly + Zainstalowana wersja Encly nie może jeszcze odbierać notatek. Zaktualizuj ją do najnowszej wersji i wróć tutaj. + Zainstalowana aplikacja o nazwie Encly nie jest oficjalną wersją, więc Twoje notatki nie zostaną do niej wysłane. Zainstaluj Encly z Google Play, F-Droid lub GitHub. + Przenieś moje notatki do Encly + Encly odebrał Twoje dane. Notatki: %1$d, zadania: %2$d, tagi: %3$d. Pominięte: %4$d. + Załączniki i obrazy pozostawione w Moich notatkach: %1$d. + Przenoszenie zostało anulowane. Nic się nie zmieniło. + Encly przyjmuje notatki tylko z oficjalnej aplikacji Moje notatki. Zainstaluj Moje notatki z Google Play, F-Droid lub GitHub i spróbuj ponownie. + Ta wersja Encly nie potrafi odczytać danych z tej wersji Moich notatek. Zaktualizuj Encly i spróbuj ponownie. + Encly nie mógł odczytać danych. Nic nie zostało zaimportowane. + Danych jest za dużo, by przenieść je za jednym razem. Nic nie zostało zaimportowane. + Encly nie mógł dokończyć importu. Z Moich notatek nic nie zostało usunięte. + Nie udało się przygotować notatek dla Encly. + Twoje notatki są teraz w Encly. Moje notatki nadal przechowują własną kopię. Możesz ją wyczyścić, jeśli nie jest już potrzebna. + Wyczyść Moje notatki + Wyczyścić Moje notatki? + Spowoduje to trwałe usunięcie z Moich notatek wszystkich notatek (łącznie z koszem), zadań, tagów, list zadań i plików załączników. Jeśli synchronizacja z Dyskiem Google jest włączona, zostaną one usunięte także z synchronizowanej kopii. Załączniki nie zostały przeniesione do Encly. Tej operacji nie można cofnąć. + Usuń wszystko + Moje notatki zostały wyczyszczone. + Nie udało się wyczyścić Moich notatek. + Moje notatki mają następcę: Encly, w pełni szyfrowane notatki działające offline. Możesz przenieść do niej wszystkie notatki jednym dotknięciem. + Dowiedz się więcej + Później diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b6ed3eaa..c9d43c76 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -481,4 +481,43 @@ После подтверждения первой синхронизации можно включить фоновую. Она запускается примерно каждые 6 часов при наличии интернета, и её можно отключить в любое время. Разрешение конфликтов Когда локальная версия и версия на Drive отличаются, для каждого конфликта выберите, какую сохранить: локальную или из Google Drive. + + + Знакомьтесь: Encly + Преемник «Мои Заметки» + Encly — новое приложение от автора «Мои Заметки»: полностью зашифрованные заметки, которые работают офлайн. Заметки хранятся в зашифрованной базе данных, приложение блокируется PIN-кодом, а резервные копии тоже зашифрованы. + Перенос односторонний: «Мои Заметки» передают ваши данные в Encly, а Encly ничего не отправляет обратно. Из «Мои Заметки» ничего не удаляется, приложение работает как прежде. + Что переносится + Заметки, включая корзину: %1$d + Задачи: %1$d + Теги: %1$d + Списки задач (станут тегами): %1$d + Что не переносится + Вложения и изображения: %1$d. Они останутся в «Мои Заметки». + Напоминания: %1$d. В Encly нет напоминаний. + Закреплённые заметки: %1$d. Они перенесутся, но без закрепления. + Получить Encly + Обновить Encly + Установленная версия Encly пока не умеет принимать заметки. Обновите её до последней версии и вернитесь сюда. + Установленное приложение с названием Encly не является официальной сборкой, поэтому заметки ему не передаются. Установите Encly из Google Play, F-Droid или GitHub. + Перенести мои заметки в Encly + Encly получил ваши данные. Заметок: %1$d, задач: %2$d, тегов: %3$d. Пропущено: %4$d. + Вложений и изображений осталось в «Мои Заметки»: %1$d. + Перенос отменён. Ничего не изменилось. + Encly принимает заметки только от официального приложения «Мои Заметки». Установите «Мои Заметки» из Google Play, F-Droid или GitHub и попробуйте снова. + Эта версия Encly не может прочитать данные из этой версии «Мои Заметки». Обновите Encly и попробуйте снова. + Encly не смог прочитать данные. Ничего не импортировано. + Данных слишком много, чтобы перенести их за один раз. Ничего не импортировано. + Encly не смог завершить импорт. Из «Мои Заметки» ничего не удалено. + Не удалось подготовить заметки для Encly. + Ваши заметки теперь в Encly. «Мои Заметки» всё ещё хранят собственную копию. Её можно очистить, если она больше не нужна. + Очистить «Мои Заметки» + Очистить «Мои Заметки»? + Это навсегда удалит из «Мои Заметки» все заметки (включая корзину), задачи, теги, списки задач и файлы вложений. Если включена синхронизация с Google Диском, они будут удалены и из синхронизированной копии. Вложения не переносились в Encly. Это действие нельзя отменить. + Удалить всё + «Мои Заметки» очищены. + Не удалось очистить «Мои Заметки». + У «Мои Заметки» появился преемник — Encly, полностью зашифрованные заметки, которые работают офлайн. Все заметки можно перенести в него одним касанием. + Подробнее + Позже diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 723aee71..1e4143e1 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -485,4 +485,43 @@ Синхронізація та копія на Google Диску доступні після входу. Синхронізація недоступна Синхронізація та копія на Google Диску потребують сервісів Google Play, яких немає на цьому пристрої. + + + Знайомтеся: Encly + Наступник «Мої Нотатки» + Encly — новий застосунок від автора «Мої Нотатки»: повністю зашифровані нотатки, що працюють офлайн. Нотатки зберігаються в зашифрованій базі даних, застосунок блокується PIN-кодом, а резервні копії теж зашифровані. + Перенесення одностороннє: «Мої Нотатки» передають ваші дані в Encly, а Encly нічого не надсилає назад. З «Мої Нотатки» нічого не видаляється, застосунок працює як і раніше. + Що переноситься + Нотатки, разом із кошиком: %1$d + Завдання: %1$d + Теги: %1$d + Списки завдань (стануть тегами): %1$d + Що не переноситься + Вкладення та зображення: %1$d. Вони залишаться в «Мої Нотатки». + Нагадування: %1$d. В Encly немає нагадувань. + Закріплені нотатки: %1$d. Вони перенесуться, але без закріплення. + Отримати Encly + Оновити Encly + Встановлена версія Encly ще не вміє приймати нотатки. Оновіть її до останньої версії і поверніться сюди. + Встановлений застосунок з назвою Encly не є офіційною збіркою, тому нотатки йому не передаватимуться. Встановіть Encly з Google Play, F-Droid або GitHub. + Перенести мої нотатки в Encly + Encly отримав ваші дані. Нотаток: %1$d, завдань: %2$d, тегів: %3$d. Пропущено: %4$d. + Вкладень і зображень залишилося в «Мої Нотатки»: %1$d. + Перенесення скасовано. Нічого не змінилося. + Encly приймає нотатки лише від офіційного застосунку «Мої Нотатки». Встановіть «Мої Нотатки» з Google Play, F-Droid або GitHub і спробуйте ще раз. + Ця версія Encly не може прочитати дані з цієї версії «Мої Нотатки». Оновіть Encly і спробуйте ще раз. + Encly не зміг прочитати дані. Нічого не імпортовано. + Даних забагато, щоб перенести їх за один раз. Нічого не імпортовано. + Encly не зміг завершити імпорт. З «Мої Нотатки» нічого не видалено. + Не вдалося підготувати нотатки для Encly. + Ваші нотатки тепер в Encly. «Мої Нотатки» досі зберігають власну копію. Її можна очистити, якщо вона більше не потрібна. + Очистити «Мої Нотатки» + Очистити «Мої Нотатки»? + Це назавжди видалить з «Мої Нотатки» всі нотатки (разом із кошиком), завдання, теги, списки завдань і файли вкладень. Якщо увімкнено синхронізацію з Google Диском, їх буде видалено й із синхронізованої копії. Вкладення не переносилися в Encly. Цю дію не можна скасувати. + Видалити все + «Мої Нотатки» очищено. + Не вдалося очистити «Мої Нотатки». + У «Мої Нотатки» з\'явився наступник — Encly, повністю зашифровані нотатки, що працюють офлайн. Усі нотатки можна перенести в нього одним дотиком. + Детальніше + Пізніше diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7188e8a8..2de39749 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -513,4 +513,43 @@ Sync and Google Drive backup become available after you sign in. Sync unavailable Sync and Google Drive backup need Google Play services, which this device does not have. + + + Meet Encly + The successor of My Notes + Encly is the next app from the author of My Notes: a fully encrypted notes app that works offline. Your notes live in an encrypted database, the app locks with a PIN, and backups are encrypted too. + Moving is one way: My Notes hands your data to Encly, and Encly never sends anything back. Nothing is deleted from My Notes; it keeps working as before. + What moves + Notes, including the trash: %1$d + Tasks: %1$d + Tags: %1$d + Task lists (they become tags): %1$d + What does not move + Attachments and images: %1$d. They stay in My Notes. + Reminders: %1$d. Encly has no reminders. + Pinned notes: %1$d. They move, but without the pin. + Get Encly + Update Encly + The installed Encly cannot receive notes yet. Update it to the latest version, then come back here. + The installed app called Encly is not the official build, so your notes will not be sent to it. Install Encly from Google Play, F-Droid or GitHub. + Move my notes to Encly + Encly received your data. Notes: %1$d, tasks: %2$d, tags: %3$d. Skipped: %4$d. + Attachments and images left in My Notes: %1$d. + The move was cancelled. Nothing has changed. + Encly accepts notes only from the official My Notes app. Install My Notes from Google Play, F-Droid or GitHub and try again. + This Encly cannot read data from this version of My Notes. Update Encly and try again. + Encly could not read the data. Nothing was imported. + There is too much data to move in one go. Nothing was imported. + Encly could not finish the import. Nothing was deleted from My Notes. + Could not prepare your notes for Encly. + Your notes are now in Encly. My Notes still keeps its own copy. You can clear it if you no longer need it. + Clear My Notes + Clear My Notes? + This permanently deletes all notes (including the trash), tasks, tags, task lists and attachment files from My Notes. If Google Drive sync is on, they are deleted from the synced copy too. Attachments did not move to Encly. This cannot be undone. + Delete everything + My Notes has been cleared. + Could not clear My Notes. + My Notes has a successor: Encly, a fully encrypted notes app that works offline. You can move all your notes to it in one tap. + Learn more + Later diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 07932b87..6787d375 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -3,6 +3,10 @@ + + diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java index f50a9f22..38bee94a 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java @@ -256,6 +256,57 @@ public void deleteAllTrashNotes_marksEveryDeletedNoteWithSameBatchTimestamp() { assertThat(second.deletedAt).isEqualTo(1_000L); } + @Test + public void clearAllUserData_deletesEverythingAndLeavesATombstoneForEachRecord() { + syncMetadataDao.insertIfAbsent( + new SyncMetadataEntity( + SyncMetadata.RECORD_TYPE_NOTE, 1L, "note-stable", 90L, null)); + when(noteDao.getAllNoteIdsSync()).thenReturn(List.of(1, 2)); + when(taskDao.getAllTaskIdsSync()).thenReturn(List.of(5)); + Tag tag = new Tag().create("Work"); + tag.id = 7; + when(tagsDao.getUserTagsSync()).thenReturn(List.of(tag)); + TaskCategory category = new TaskCategory("Home", "#000000"); + category.setId(8); + when(taskCategoryDao.getCategoriesSync()).thenReturn(List.of(category)); + + SyncMutationCoordinator clearing = + new SyncMutationCoordinator( + new SyncMutationCoordinator.TransactionExecutor() { + @Override + public T run( + SyncMutationCoordinator.TransactionCallable callable) { + return callable.call(); + } + }, + noteDao, + taskDao, + tagsDao, + taskCategoryDao, + transactions, + syncMetadataDao, + new FixedTimeProvider(1_000L), + new QueueStableIdGenerator("s1", "s2", "s3", "s4", "s5")); + + clearing.clearAllUserData(); + + verify(noteDao).deleteAllNotes(); + verify(taskDao).deleteAllTasks(); + verify(tagsDao).deleteUserTags(); + verify(taskCategoryDao).deleteAllCategories(); + SyncMetadataEntity seeded = syncMetadataDao.get(SyncMetadata.RECORD_TYPE_NOTE, 1L); + assertThat(seeded.stableId).isEqualTo("note-stable"); + assertThat(seeded.deletedAt).isEqualTo(1_000L); + assertThat(syncMetadataDao.get(SyncMetadata.RECORD_TYPE_NOTE, 2L).deletedAt) + .isEqualTo(1_000L); + assertThat(syncMetadataDao.get(SyncMetadata.RECORD_TYPE_TASK, 5L).deletedAt) + .isEqualTo(1_000L); + assertThat(syncMetadataDao.get(SyncMetadata.RECORD_TYPE_TAG, 7L).deletedAt) + .isEqualTo(1_000L); + assertThat(syncMetadataDao.get(SyncMetadata.RECORD_TYPE_CATEGORY, 8L).deletedAt) + .isEqualTo(1_000L); + } + @Test public void insertNotes_usesSingleImportTimestampForWholeBatch() { List notes = new ArrayList<>(); diff --git a/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheckTest.java b/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheckTest.java new file mode 100644 index 00000000..e03bc32d --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheckTest.java @@ -0,0 +1,186 @@ +package com.pasich.mynotes.utils.encly; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import org.junit.Test; + +public class EnclyReceiverCheckTest { + + private static final String ACTIVITY = "com.pasich.encly.ImportFromMyNotesActivity"; + + /** A certificate this test owns, so the legacy path can be shown to accept a real match. */ + private static final byte[] TEST_CERT = "test certificate".getBytes(StandardCharsets.UTF_8); + + private static final String TEST_PIN = hex(EnclyReceiverCheck.sha256(TEST_CERT)); + private static final byte[] OTHER_CERT = "someone else".getBytes(StandardCharsets.UTF_8); + + private static final class FakeInspector implements EnclyReceiverCheck.Inspector { + int sdk = 34; + boolean installed = true; + String activity = ACTIVITY; + boolean modernMatch = true; + List legacy; + byte[] askedSha; + String askedAction; + String askedPackage; + + @Override + public int sdkInt() { + return sdk; + } + + @Override + public boolean isInstalled(@NonNull String packageName) { + askedPackage = packageName; + return installed; + } + + @Nullable + @Override + public String findActivity(@NonNull String packageName, @NonNull String action) { + askedAction = action; + return activity; + } + + @Override + public boolean hasSigningCertificateSha256( + @NonNull String packageName, @NonNull byte[] sha256) { + askedSha = sha256; + return modernMatch; + } + + @Nullable + @Override + public List legacySignatures(@NonNull String packageName) { + return legacy; + } + } + + private static FakeInspector legacy(int sdk, byte[]... signers) { + FakeInspector inspector = new FakeInspector(); + inspector.sdk = sdk; + List list = new ArrayList<>(); + for (byte[] signer : signers) list.add(signer); + inspector.legacy = list; + return inspector; + } + + @Test + public void notInstalled() { + FakeInspector inspector = new FakeInspector(); + inspector.installed = false; + + EnclyReceiverCheck.Result result = EnclyReceiverCheck.check(inspector); + + assertThat(result.status).isEqualTo(EnclyReceiverCheck.Status.NOT_INSTALLED); + assertThat(result.activityClassName).isNull(); + assertThat(inspector.askedPackage).isEqualTo("com.pasich.encly"); + } + + @Test + public void ready_onApi28Plus_asksForThePinnedEnclyDigestAndTheImportAction() { + FakeInspector inspector = new FakeInspector(); + + EnclyReceiverCheck.Result result = EnclyReceiverCheck.check(inspector); + + assertThat(result.status).isEqualTo(EnclyReceiverCheck.Status.READY); + assertThat(result.activityClassName).isEqualTo(ACTIVITY); + assertThat(hex(inspector.askedSha)) + .isEqualTo("6884c693354964276231e6d0336b965f690bf3f15a0707bb59505c208e5e7554"); + assertThat(inspector.askedAction).isEqualTo("com.pasich.encly.action.IMPORT_FROM_MY_NOTES"); + } + + @Test + public void certificateMismatch_isUntrusted_evenWhenTheActionResolves() { + FakeInspector inspector = new FakeInspector(); + inspector.modernMatch = false; + + EnclyReceiverCheck.Result result = EnclyReceiverCheck.check(inspector); + + assertThat(result.status).isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + assertThat(result.activityClassName).isNull(); + } + + @Test + public void actionDoesNotResolve_needsUpdate() { + FakeInspector inspector = new FakeInspector(); + inspector.activity = null; + + EnclyReceiverCheck.Result result = EnclyReceiverCheck.check(inspector); + + assertThat(result.status).isEqualTo(EnclyReceiverCheck.Status.NEEDS_UPDATE); + assertThat(result.activityClassName).isNull(); + } + + @Test + public void api27_singleSignerWithThePinnedDigest_isReady() { + FakeInspector inspector = legacy(27, TEST_CERT); + inspector.modernMatch = false; // must not be consulted below API 28 + + EnclyReceiverCheck.Result result = EnclyReceiverCheck.check(inspector, TEST_PIN); + + assertThat(result.status).isEqualTo(EnclyReceiverCheck.Status.READY); + assertThat(inspector.askedSha).isNull(); + } + + @Test + public void api26_singleSignerWithAnotherDigest_isUntrusted() { + FakeInspector inspector = legacy(26, OTHER_CERT); + inspector.modernMatch = true; // would wrongly pass if consulted + + assertThat(EnclyReceiverCheck.check(inspector, TEST_PIN).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + assertThat(inspector.askedSha).isNull(); + } + + @Test + public void api27_realPin_rejectsAnUnknownCertificate() { + assertThat(EnclyReceiverCheck.check(legacy(27, TEST_CERT)).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + } + + @Test + public void api27_moreThanOneSigner_isUntrusted_evenIfOneMatches() { + FakeInspector inspector = legacy(27, TEST_CERT, OTHER_CERT); + + assertThat(EnclyReceiverCheck.check(inspector, TEST_PIN).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + } + + @Test + public void api27_unreadableOrEmptySignatures_isUntrusted() { + FakeInspector none = new FakeInspector(); + none.sdk = 27; + none.legacy = null; + assertThat(EnclyReceiverCheck.check(none, TEST_PIN).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + + assertThat(EnclyReceiverCheck.check(legacy(27), TEST_PIN).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + + assertThat(EnclyReceiverCheck.check(legacy(27, (byte[]) null), TEST_PIN).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + } + + @Test + public void certificateIsCheckedBeforeTheActionIsTrusted() { + FakeInspector inspector = legacy(27, OTHER_CERT); + inspector.activity = null; + + // An impostor is reported as untrusted, not as "update Encly". + assertThat(EnclyReceiverCheck.check(inspector, TEST_PIN).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + } + + private static String hex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) sb.append(String.format(Locale.ROOT, "%02x", b)); + return sb.toString(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilderTest.java b/app/src/test/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilderTest.java new file mode 100644 index 00000000..cd40a669 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilderTest.java @@ -0,0 +1,336 @@ +package com.pasich.mynotes.utils.encly; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.data.model.TaskCategory; +import com.pasich.mynotes.utils.managers.SystemTagsManager; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** Pins the wire shape of {@code handoff.json} that Encly parses (contract v1). */ +public class HandoffPayloadBuilderTest { + + private static final long EXPORTED_AT = 1_700_000_000_000L; + + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private final Map stable = new HashMap<>(); + private final HandoffPayloadBuilder.StableIds stableIds = + (type, localId) -> stable.get(type + ":" + localId); + + private JsonObject build( + List notes, List tags, List tasks, List categories) { + String json = + new HandoffPayloadBuilder(notes, tags, tasks, categories, stableIds, EXPORTED_AT) + .toJson(); + return JsonParser.parseString(json).getAsJsonObject(); + } + + private static Note note(int id, String title, String value) { + Note note = new Note().create(title, value, 1000L + id, ""); + note.setId(id); + return note; + } + + @Test + public void header() { + JsonObject root = build(List.of(), List.of(), List.of(), List.of()); + + assertThat(root.get("format").getAsString()).isEqualTo("mynotes-handoff"); + assertThat(root.get("schema").getAsInt()).isEqualTo(1); + assertThat(root.get("exportedAt").getAsLong()).isEqualTo(EXPORTED_AT); + assertThat(root.getAsJsonArray("tags")).isEmpty(); + assertThat(root.getAsJsonArray("notes")).isEmpty(); + assertThat(root.getAsJsonArray("taskCategories")).isEmpty(); + assertThat(root.getAsJsonArray("tasks")).isEmpty(); + } + + @Test + public void plainNote_hasNullValueJsonAndTagAndZeroAttachments() { + Note plain = note(3, "Shopping", "milk\nbread"); + + JsonObject n = + build(List.of(plain), List.of(), List.of(), List.of()) + .getAsJsonArray("notes") + .get(0) + .getAsJsonObject(); + + assertThat(n.get("id").getAsString()).isEqualTo("mynotes:note:3"); + assertThat(n.get("title").getAsString()).isEqualTo("Shopping"); + assertThat(n.get("value").getAsString()).isEqualTo("milk\nbread"); + assertThat(n.has("valueJson")).isTrue(); + assertThat(n.get("valueJson").isJsonNull()).isTrue(); + assertThat(n.get("date").getAsLong()).isEqualTo(1003L); + assertThat(n.get("tag").isJsonNull()).isTrue(); + assertThat(n.get("isTrash").getAsBoolean()).isFalse(); + assertThat(n.get("isPinned").getAsBoolean()).isFalse(); + assertThat(n.get("attachments").getAsInt()).isEqualTo(0); + } + + @Test + public void editorJsNote_sendsTheDocumentVerbatimAsAString() { + String doc = + "[{\"id\":\"a\",\"type\":\"paragraph\",\"data\":{\"text\":\"Hi & \\\"you\\\"\"}}]"; + Note rich = note(4, "Rich", "Hi & \"you\""); + rich.setValueJson(doc); + + JsonObject n = + build(List.of(rich), List.of(), List.of(), List.of()) + .getAsJsonArray("notes") + .get(0) + .getAsJsonObject(); + + assertThat(n.get("valueJson").isJsonPrimitive()).isTrue(); + assertThat(n.get("valueJson").getAsString()).isEqualTo(doc); + } + + @Test + public void trashedAndPinnedFlagsTravel() { + Note trashed = note(1, "old", "x"); + trashed.setTrash(true); + Note pinned = note(2, "top", "y"); + pinned.setPinned(true); + + JsonArray notes = + build(List.of(trashed, pinned), List.of(), List.of(), List.of()) + .getAsJsonArray("notes"); + + assertThat(notes.get(0).getAsJsonObject().get("isTrash").getAsBoolean()).isTrue(); + assertThat(notes.get(0).getAsJsonObject().get("isPinned").getAsBoolean()).isFalse(); + assertThat(notes.get(1).getAsJsonObject().get("isTrash").getAsBoolean()).isFalse(); + assertThat(notes.get(1).getAsJsonObject().get("isPinned").getAsBoolean()).isTrue(); + } + + @Test + public void attachmentsAreCountedNeverSent() { + Note withFiles = note(5, "trip", ""); + withFiles.setAttachments( + "[{\"url\":\"mynotes-attach://note_5/a.jpg\"},{\"url\":\"mynotes-attach://note_5/b.pdf\"}]"); + withFiles.setValueJson( + "[{\"type\":\"image\",\"data\":{\"file\":{\"url\":\"mynotes-attach://note_5/a.jpg\"}}}," + + "{\"type\":\"attaches\",\"data\":{\"file\":{\"url\":\"mynotes-attach://note_5/b.pdf\"}}}," + + "{\"type\":\"paragraph\",\"data\":{\"text\":\"t\"}}]"); + // A stale list must not hide references the document still has. + Note staleList = note(6, "stale", ""); + staleList.setAttachments("[]"); + staleList.setValueJson( + "[{\"type\":\"image\",\"data\":{\"file\":{\"url\":\"mynotes-attach://note_6/c.png\"}}}]"); + + JsonArray notes = + build(List.of(withFiles, staleList), List.of(), List.of(), List.of()) + .getAsJsonArray("notes"); + + assertThat(notes.get(0).getAsJsonObject().get("attachments").getAsInt()).isEqualTo(2); + assertThat(notes.get(1).getAsJsonObject().get("attachments").getAsInt()).isEqualTo(1); + // The note object carries exactly the contract fields; no file data, no reminder. + assertThat(notes.get(0).getAsJsonObject().keySet()) + .containsExactly( + "id", + "title", + "value", + "valueJson", + "date", + "tag", + "isTrash", + "isPinned", + "attachments"); + } + + @Test + public void unreadableAttachmentList_countsAsNone() { + Note broken = note(7, "b", ""); + broken.setAttachments("{not json"); + + assertThat(HandoffPayloadBuilder.attachmentCount(broken)).isEqualTo(0); + } + + @Test + public void noteTagIsSentByName_andOnlyUserTagsAreListed() { + Note tagged = note(8, "t", "v"); + tagged.setTag("Work"); + Tag work = new Tag().create("Work"); + work.id = 11; + work.setPosition(2); + Tag system = new Tag().create("allNotes", SystemTagsManager.SYSTEM_ACTION_ALL_NOTES); + system.id = 12; + + JsonObject root = build(List.of(tagged), List.of(work, system), List.of(), List.of()); + + assertThat(root.getAsJsonArray("notes").get(0).getAsJsonObject().get("tag").getAsString()) + .isEqualTo("Work"); + JsonArray tags = root.getAsJsonArray("tags"); + assertThat(tags).hasSize(1); + JsonObject t = tags.get(0).getAsJsonObject(); + assertThat(t.get("id").getAsString()).isEqualTo("mynotes:tag:11"); + assertThat(t.get("name").getAsString()).isEqualTo("Work"); + assertThat(t.get("position").getAsInt()).isEqualTo(2); + } + + @Test + public void stableIdComesFromSyncMetadata_elseFallback() { + stable.put("note:1", "3f6c1d1e-0000-4000-8000-000000000001"); + stable.put("tag:2", "3f6c1d1e-0000-4000-8000-000000000002"); + stable.put("task:3", "3f6c1d1e-0000-4000-8000-000000000003"); + stable.put("category:4", "3f6c1d1e-0000-4000-8000-000000000004"); + stable.put("note:9", " "); + Tag tag = new Tag().create("T"); + tag.id = 2; + Task task = new Task("Buy", 4); + task.setId(3); + TaskCategory category = new TaskCategory("Home", "#000000"); + category.setId(4); + + JsonObject root = + build( + List.of(note(1, "a", ""), note(9, "b", "")), + List.of(tag), + List.of(task), + List.of(category)); + + JsonArray notes = root.getAsJsonArray("notes"); + assertThat(notes.get(0).getAsJsonObject().get("id").getAsString()) + .isEqualTo("3f6c1d1e-0000-4000-8000-000000000001"); + // A blank stable id is no identity at all. + assertThat(notes.get(1).getAsJsonObject().get("id").getAsString()) + .isEqualTo("mynotes:note:9"); + assertThat(root.getAsJsonArray("tags").get(0).getAsJsonObject().get("id").getAsString()) + .isEqualTo("3f6c1d1e-0000-4000-8000-000000000002"); + JsonObject t = root.getAsJsonArray("tasks").get(0).getAsJsonObject(); + assertThat(t.get("id").getAsString()).isEqualTo("3f6c1d1e-0000-4000-8000-000000000003"); + assertThat(t.get("categoryId").getAsString()) + .isEqualTo("3f6c1d1e-0000-4000-8000-000000000004"); + assertThat( + root.getAsJsonArray("taskCategories") + .get(0) + .getAsJsonObject() + .get("id") + .getAsString()) + .isEqualTo("3f6c1d1e-0000-4000-8000-000000000004"); + } + + @Test + public void categoriesAndTasks() { + TaskCategory home = new TaskCategory("Home", "#112233"); + home.setId(4); + home.setPosition(1); + Task inHome = new Task("Fix sink", 4); + inHome.setId(20); + inHome.setDone(true); + inHome.setCreatedAt(5555L); + inHome.setPosition(3); + inHome.setDescription("call plumber"); + Task noCategory = new Task("Read", 0); + noCategory.setId(21); + Task danglingCategory = new Task("Ghost", 99); + danglingCategory.setId(22); + + JsonObject root = + build( + List.of(), + List.of(), + List.of(inHome, noCategory, danglingCategory), + List.of(home)); + + JsonObject c = root.getAsJsonArray("taskCategories").get(0).getAsJsonObject(); + assertThat(c.get("id").getAsString()).isEqualTo("mynotes:category:4"); + assertThat(c.get("name").getAsString()).isEqualTo("Home"); + assertThat(c.get("position").getAsInt()).isEqualTo(1); + assertThat(c.keySet()).containsExactly("id", "name", "position"); + + JsonArray tasks = root.getAsJsonArray("tasks"); + JsonObject first = tasks.get(0).getAsJsonObject(); + assertThat(first.get("id").getAsString()).isEqualTo("mynotes:task:20"); + assertThat(first.get("description").getAsString()).isEqualTo("Fix sink\ncall plumber"); + assertThat(first.get("isDone").getAsBoolean()).isTrue(); + assertThat(first.get("createdAt").getAsLong()).isEqualTo(5555L); + assertThat(first.get("categoryId").getAsString()).isEqualTo("mynotes:category:4"); + assertThat(first.get("position").getAsInt()).isEqualTo(3); + assertThat(first.keySet()) + .containsExactly( + "id", "description", "isDone", "createdAt", "categoryId", "position"); + + assertThat(tasks.get(1).getAsJsonObject().get("description").getAsString()) + .isEqualTo("Read"); + assertThat(tasks.get(1).getAsJsonObject().get("categoryId").isJsonNull()).isTrue(); + assertThat(tasks.get(2).getAsJsonObject().get("categoryId").isJsonNull()).isTrue(); + } + + @Test + public void summaryCountsWhatStaysBehind() { + Note a = note(1, "a", ""); + a.setAttachments("[{\"url\":\"x\"}]"); + a.setReminderTime(1L); + a.setPinned(true); + Note b = note(2, "b", ""); + Task task = new Task("t", 0); + task.setReminderTime(2L); + Tag user = new Tag().create("U"); + Tag system = new Tag().create("allNotes", SystemTagsManager.SYSTEM_ACTION_ALL_NOTES); + + HandoffPayloadBuilder.Summary summary = + HandoffPayloadBuilder.Summary.of( + List.of(a, b), List.of(user, system), List.of(task)); + + assertThat(summary.notes).isEqualTo(2); + assertThat(summary.tasks).isEqualTo(1); + assertThat(summary.tags).isEqualTo(1); + assertThat(summary.attachments).isEqualTo(1); + assertThat(summary.reminders).isEqualTo(2); + assertThat(summary.pinned).isEqualTo(1); + } + + @Test + public void archiveHoldsExactlyOneUtf8Entry_andClearRemovesIt() throws Exception { + File cache = temporaryFolder.newFolder("cache"); + Note unicode = note(1, "Нотатка ✓", "текст"); + List notes = new ArrayList<>(); + notes.add(unicode); + + File zip = + HandoffArchive.write( + cache, + new HandoffPayloadBuilder( + notes, List.of(), List.of(), List.of(), stableIds, EXPORTED_AT)); + + assertThat(zip.getParentFile()).isEqualTo(new File(cache, "encly-handoff")); + List names = new ArrayList<>(); + String body = null; + try (ZipInputStream in = + new ZipInputStream(new ByteArrayInputStream(Files.readAllBytes(zip.toPath())))) { + ZipEntry entry; + while ((entry = in.getNextEntry()) != null) { + names.add(entry.getName()); + body = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + assertThat(names).containsExactly("handoff.json"); + JsonObject root = JsonParser.parseString(body).getAsJsonObject(); + assertThat(root.getAsJsonArray("notes").get(0).getAsJsonObject().get("title").getAsString()) + .isEqualTo("Нотатка ✓"); + + assertThat(HandoffArchive.clear(cache)).isEqualTo(0); + assertThat(zip.exists()).isFalse(); + } + + @Test + public void clearWithoutDirectoryIsANoOp() throws Exception { + assertThat(HandoffArchive.clear(temporaryFolder.newFolder("empty"))).isEqualTo(0); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/encly/HandoffResultTest.java b/app/src/test/java/com/pasich/mynotes/utils/encly/HandoffResultTest.java new file mode 100644 index 00000000..561e287a --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/encly/HandoffResultTest.java @@ -0,0 +1,79 @@ +package com.pasich.mynotes.utils.encly; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +public class HandoffResultTest { + + private static final int RESULT_OK = -1; // android.app.Activity.RESULT_OK + private static final int RESULT_CANCELED = 0; // android.app.Activity.RESULT_CANCELED + + @Test + public void ok_withCounts() { + HandoffResult result = HandoffResult.parse(RESULT_OK, 12, 4, 3, 1, null); + + assertThat(result.success).isTrue(); + assertThat(result.notes).isEqualTo(12); + assertThat(result.tasks).isEqualTo(4); + assertThat(result.tags).isEqualTo(3); + assertThat(result.skipped).isEqualTo(1); + assertThat(result.reason).isNull(); + } + + @Test + public void ok_withMissingOrNegativeCounts_readsZero() { + HandoffResult result = HandoffResult.parse(RESULT_OK, null, null, -5, null, null); + + assertThat(result.success).isTrue(); + assertThat(result.notes).isEqualTo(0); + assertThat(result.tasks).isEqualTo(0); + assertThat(result.tags).isEqualTo(0); + assertThat(result.skipped).isEqualTo(0); + } + + @Test + public void canceled_withEveryKnownReason() { + assertThat(reason("untrusted_caller")).isEqualTo(HandoffResult.Reason.UNTRUSTED_CALLER); + assertThat(reason("cancelled")).isEqualTo(HandoffResult.Reason.CANCELLED); + assertThat(reason("unsupported_schema")).isEqualTo(HandoffResult.Reason.UNSUPPORTED_SCHEMA); + assertThat(reason("invalid_payload")).isEqualTo(HandoffResult.Reason.INVALID_PAYLOAD); + assertThat(reason("too_large")).isEqualTo(HandoffResult.Reason.TOO_LARGE); + assertThat(reason("failed")).isEqualTo(HandoffResult.Reason.FAILED); + } + + @Test + public void canceled_withoutReason_isACancellation() { + // Encly crashed, or the user backed out before it answered. + HandoffResult result = HandoffResult.parse(RESULT_CANCELED, null, null, null, null, null); + + assertThat(result.success).isFalse(); + assertThat(result.reason).isEqualTo(HandoffResult.Reason.CANCELLED); + assertThat(reason("")).isEqualTo(HandoffResult.Reason.CANCELLED); + } + + @Test + public void canceled_ignoresCountsItWasGiven() { + HandoffResult result = HandoffResult.parse(RESULT_CANCELED, 9, 9, 9, 9, "failed"); + + assertThat(result.success).isFalse(); + assertThat(result.notes).isEqualTo(0); + } + + @Test + public void unknownReason_isAFailure_notASilentCancel() { + assertThat(reason("disk_full")).isEqualTo(HandoffResult.Reason.FAILED); + } + + @Test + public void anyOtherResultCode_isNotASuccess() { + HandoffResult result = HandoffResult.parse(1, 5, 5, 5, 5, null); + + assertThat(result.success).isFalse(); + assertThat(result.reason).isEqualTo(HandoffResult.Reason.CANCELLED); + } + + private static HandoffResult.Reason reason(String wire) { + return HandoffResult.parse(RESULT_CANCELED, null, null, null, null, wire).reason; + } +} From 3d61f6315f4a2ad74c9a605744cf2248bd9ebbff Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 10:25:53 +0300 Subject: [PATCH 02/43] fix(main): keep the notes list visible and in sync after a sync After a Google Drive sync the main screen could go blank or show an empty category until the app was restarted, and the list animations often froze or drew one card on top of another. The selected category was a snapshot filtered by name, so a tag renamed, merged or deleted by sync emptied the page. The presenter now re-resolves the selection against every fresh tags list (by id, then by name, else All notes), stores the result and marks the chip from it. Tags and notes emissions are folded with a 50 ms debounce, and the view state compares by content, so a sync that rewrites rows without changing them no longer re-renders the list. The list, the empty state and the tags row are now driven toward a target state that cancels whatever animation is running, so an empty-then-filled update inside the hide animation can no longer leave the list invisible. States that arrive in selection mode are held and applied when it ends (dropping selected notes that sync removed), and states that arrive right after returning from the editor wait for the return transition. Card overlap came from change cross-fades and per-update span reassignment in the staggered grid. Change animations are off, spans are only rebuilt while the list is hidden, and a reorder (or an insert/remove in a multi-column grid) is shown as one short cross-fade of the list instead of cards sliding across columns. Item animations are skipped when the screen is not visible or system animations are off. Refs: #170 --- CHANGELOG.md | 11 + app/build.gradle | 2 + .../ui/controllers/SelectionController.java | 30 ++ .../MainRenderListsController.java | 318 ++++++++++-------- .../controllers/mainActivity/ViewFader.java | 183 ++++++++++ .../mynotes/ui/presenter/MainPresenter.java | 97 ++++-- .../mynotes/ui/state/MainViewState.java | 54 ++- .../ui/view/activity/MainActivity.java | 170 +++++++--- .../utils/recycler/NoteListTransition.java | 49 +++ .../utils/recycler/NotesItemAnimator.java | 76 +++++ .../utils/recycler/diffutil/NoteDiff.java | 15 +- app/src/main/res/layout/activity_main.xml | 8 +- .../mynotes/presenter/MainPresenterTest.java | 203 +++++++++++ .../main/MainRenderListsControllerTest.java | 161 +++++++++ .../recycler/NoteListTransitionTest.java | 48 +++ 15 files changed, 1196 insertions(+), 229 deletions(-) create mode 100644 app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/ViewFader.java create mode 100644 app/src/main/java/com/pasich/mynotes/utils/recycler/NoteListTransition.java create mode 100644 app/src/main/java/com/pasich/mynotes/utils/recycler/NotesItemAnimator.java create mode 100644 app/src/test/java/com/pasich/mynotes/ui/main/MainRenderListsControllerTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/utils/recycler/NoteListTransitionTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b258f6..8be5f13b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ and pins stay behind, and the page tells you how many. Nothing is deleted from My Notes; after a successful move you can clear it as a separate, confirmed step. +**Fixes** + +- Notes no longer vanish from the main screen after a Google Drive sync. The list, the tags row + and the selected category stay in step with what sync changed: a category renamed or merged on + another device stays selected, and one deleted there falls back to All notes instead of + showing an empty page. A sync that changes nothing leaves the screen untouched. +- The main list animates smoothly again. Cards no longer slide over one another when a note moves + to the top or the grid is rearranged, the list can no longer get stuck invisible or half-faded + after quick changes, and returning from a note no longer shuffles cards under the closing + animation. With system animations turned off, every change is applied instantly. + ## [2.6.55] - 25.09.2026 **Improvements** diff --git a/app/build.gradle b/app/build.gradle index cf28a82c..ed0ff336 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -137,6 +137,7 @@ android { testOptions { unitTests { returnDefaultValues = true + includeAndroidResources = true } } } @@ -215,6 +216,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:5.23.0' testImplementation 'com.google.truth:truth:1.4.2' + testImplementation 'org.robolectric:robolectric:4.16.1' // Room testing (integration tests) testImplementation "androidx.room:room-testing:$roomVersion" diff --git a/app/src/main/java/com/pasich/mynotes/ui/controllers/SelectionController.java b/app/src/main/java/com/pasich/mynotes/ui/controllers/SelectionController.java index f0dbb90b..ea6dcb03 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/controllers/SelectionController.java +++ b/app/src/main/java/com/pasich/mynotes/ui/controllers/SelectionController.java @@ -7,6 +7,7 @@ import com.pasich.mynotes.utils.adapters.notes.NoteAdapter; import com.pasich.mynotes.utils.recycler.payloads.NotePayloads; import java.util.ArrayList; +import java.util.Collection; import java.util.HashSet; import java.util.List; @@ -124,6 +125,35 @@ public void clearSelection() { notifyListener(); } + /** + * Drops selected notes that no longer exist (for example deleted by a sync). Leaves selection + * mode when nothing selected is left. + */ + public void retainOnly(Collection existingIds) { + if (!selectionMode) return; + HashSet keep = new HashSet<>(existingIds); + List gone = new ArrayList<>(); + for (int id : selectedIds) { + if (!keep.contains(id)) gone.add(id); + } + if (gone.isEmpty()) return; + + selectedIds.removeAll(gone); + for (int id : gone) { + updateNoteVisualState(id); + } + if (selectedIds.isEmpty()) { + clearSelection(); + return; + } + int count = selectedIds.size(); + panel.selectedCount.setText( + panel.getRoot() + .getResources() + .getQuantityString(R.plurals.selected_count, count, count)); + notifyListener(); + } + /** Returns the full Note objects whose IDs are currently selected. */ public List getSelectedNotes() { List result = new ArrayList<>(); diff --git a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/MainRenderListsController.java b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/MainRenderListsController.java index 5beaf714..4f264821 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/MainRenderListsController.java +++ b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/MainRenderListsController.java @@ -1,196 +1,228 @@ package com.pasich.mynotes.ui.controllers.mainActivity; +import android.animation.ValueAnimator; import android.content.res.Resources; import android.view.View; +import android.view.ViewTreeObserver; import android.view.animation.AccelerateInterpolator; import android.view.animation.DecelerateInterpolator; -import android.view.animation.OvershootInterpolator; +import android.widget.TextView; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; +import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.StaggeredGridLayoutManager; import com.pasich.mynotes.R; import com.pasich.mynotes.data.model.Tag; import com.pasich.mynotes.databinding.ActivityMainBinding; import com.pasich.mynotes.utils.managers.SystemTagsManager; import java.util.List; - -/** Controls note list and empty-state rendering with animations. */ +import java.util.function.BooleanSupplier; + +/** + * Shows the notes list, the empty state and the tags row. + * + *

Each of the three views is driven toward a target state ({@link ViewFader}), so overlapping + * requests — a sync that empties a category and fills it again a moment later, or a tag switch + * while the list is still fading — always settle on the latest one instead of leaving the screen + * blank. A full content swap (another category, another sort order, a reordered grid) fades the + * list out, replaces its content while nothing is visible, and fades it back in after the new + * layout is ready, so cards never slide across each other. + */ public class MainRenderListsController { - private final ActivityMainBinding binding; + private static final long LIST_SHOW_MS = 220; + private static final long LIST_HIDE_MS = 160; + private static final long LIST_DIM_MS = 110; + private static final long EMPTY_SHOW_MS = 200; + private static final long EMPTY_HIDE_MS = 150; + private static final long TAGS_SHOW_MS = 240; + private static final long TAGS_HIDE_MS = 180; + + private final RecyclerView listNotes; + private final TextView emptyText; + private final View emptyImage; private final Resources res; + private final BooleanSupplier animationsEnabled; + + private final ViewFader listFader; + private final ViewFader emptyFader; + private final ViewFader tagsFader; + + /** True from the start of a swap until the swapped-in content has been laid out. */ + private boolean swapping; + + @Nullable private ViewTreeObserver.OnPreDrawListener pendingReveal; public MainRenderListsController(ActivityMainBinding binding) { - this.binding = binding; - this.res = binding.getRoot().getResources(); + this( + binding.listNotes, + binding.includeEmpty.emptyViewNote, + binding.includeEmpty.emptyNotesText, + binding.includeEmpty.imageEmpty, + binding.listTags, + ValueAnimator::areAnimatorsEnabled); + } + + @VisibleForTesting + public MainRenderListsController( + RecyclerView listNotes, + View emptyView, + TextView emptyText, + View emptyImage, + View listTags, + BooleanSupplier animationsEnabled) { + this.listNotes = listNotes; + this.emptyText = emptyText; + this.emptyImage = emptyImage; + this.res = listNotes.getResources(); + this.animationsEnabled = animationsEnabled; + this.listFader = new ViewFader(listNotes, View.INVISIBLE, 0.97f, animationsEnabled); + this.emptyFader = new ViewFader(emptyView, View.GONE, 1f, animationsEnabled); + listTags.setPivotY(0f); + this.tagsFader = new ViewFader(listTags, View.GONE, 0.85f, animationsEnabled); + } + + /** Whether the system "remove animations" setting currently allows animation. */ + public boolean animationsEnabled() { + return animationsEnabled.getAsBoolean(); } - /** Briefly fades and rescales the list to signal a dataset change. */ - public void animateNoteListChange() { - binding.listNotes - .animate() - .alpha(0f) - .scaleY(0.97f) - .setDuration(120) - .withEndAction( - () -> { - binding.listNotes.scheduleLayoutAnimation(); - binding.listNotes - .animate() - .alpha(1f) - .scaleY(1f) - .setInterpolator(new OvershootInterpolator(0.6f)) - .setDuration(220) - .start(); - }) - .start(); + /** + * True while the list content is being replaced behind a fade. Item animations must not run in + * that window: the new layout is meant to appear in one piece. + */ + public boolean isSwapping() { + return swapping; } /** - * Handles switching between the notes list and the empty state view, including fade/scale - * animations and optional smooth scrolling. + * Shows the list when there are notes, the empty state otherwise. Safe to call any number of + * times, in any order: the last call wins. + * + * @param animate false to apply the result at once (for example while the screen is not + * visible) */ - public void showStateNoteList(@Nullable Tag selectedTag, int mNotesCount) { - if (mNotesCount == 0) { - animateHideList(binding.listNotes); - animateShowEmpty(binding.includeEmpty.emptyViewNote); - updateEmptyText(new EmptyStateBuilder().build(selectedTag, mNotesCount)); + public void showStateNoteList(@Nullable Tag selectedTag, int notesCount, boolean animate) { + if (notesCount == 0) { + cancelReveal(); + swapping = false; + listFader.hide(animate, LIST_HIDE_MS, new AccelerateInterpolator(1.4f)); + updateEmptyText(new EmptyStateBuilder().build(selectedTag, notesCount)); + emptyFader.show(animate, EMPTY_SHOW_MS, new DecelerateInterpolator()); return; } - animateHideEmpty(binding.includeEmpty.emptyViewNote); - animateShowList(binding.listNotes); - } - - /** Smoothly scrolls the notes list to the top. */ - public void scrollUpNoteList() { - binding.listNotes.post(() -> binding.listNotes.smoothScrollToPosition(0)); + emptyFader.hide(animate, EMPTY_HIDE_MS, new AccelerateInterpolator()); + if (swapping && animate) { + revealAfterLayout(); + } else { + cancelReveal(); + swapping = false; + listFader.show(animate, LIST_SHOW_MS, new DecelerateInterpolator(1.6f)); + } } - /** Instantly jumps the staggered-grid list to position zero. */ - public void scrollToTopInstant() { - binding.listNotes.post( - () -> { - StaggeredGridLayoutManager lm = - (StaggeredGridLayoutManager) binding.listNotes.getLayoutManager(); - if (lm != null) lm.scrollToPositionWithOffset(0, 0); - }); + /** + * Replaces the whole list content behind a short fade: {@code submit} runs once the list is no + * longer visible, and must end by calling {@link #showStateNoteList} from its commit callback. + * Runs {@code submit} immediately when there is nothing on screen to fade. + */ + public void swapListContent(boolean animate, @NonNull Runnable submit) { + if (!animate || !animationsEnabled() || !listFader.isTargetShown()) { + submit.run(); + return; + } + swapping = true; + cancelReveal(); + RecyclerView.ItemAnimator itemAnimator = listNotes.getItemAnimator(); + if (itemAnimator != null) itemAnimator.endAnimations(); + listFader.dim(true, LIST_DIM_MS, new AccelerateInterpolator(), submit); } - /** Smoothly shows the notes list using fade + scale animation. */ - private void animateShowList(View list) { - if (list.getVisibility() == View.VISIBLE) return; - - list.setVisibility(View.VISIBLE); - list.setAlpha(0f); - list.setScaleY(0.95f); - - list.animate() - .alpha(1f) - .scaleY(1f) - .setDuration(250) - .setInterpolator(new DecelerateInterpolator(1.6f)) - .start(); + /** Smoothly scrolls the notes list to the top. */ + public void scrollUpNoteList() { + listNotes.post(() -> listNotes.smoothScrollToPosition(0)); } - /** Smoothly hides the notes list with fade + scale collapse animation. */ - private void animateHideList(View list) { - if (list.getVisibility() != View.VISIBLE) return; - - list.animate() - .alpha(0f) - .scaleY(0.95f) - .setDuration(180) - .setInterpolator(new AccelerateInterpolator(1.4f)) - .withEndAction(() -> list.setVisibility(View.INVISIBLE)) - .start(); + /** Puts the first note at the top in the next layout pass, without animation. */ + public void jumpToTop() { + RecyclerView.LayoutManager lm = listNotes.getLayoutManager(); + if (lm instanceof StaggeredGridLayoutManager grid) { + grid.scrollToPositionWithOffset(0, 0); + } else if (lm != null) { + lm.scrollToPosition(0); + } } - /** Shows the empty state view using a simple fade-in animation. */ - private void animateShowEmpty(View empty) { - if (empty.getVisibility() == View.VISIBLE) return; - - empty.setVisibility(View.VISIBLE); - empty.setAlpha(0f); + /** Shows the tags row only when the user has tags of their own. */ + public void renderListTags(List tags, boolean animate) { + boolean hasUserTags = false; + for (Tag tag : tags) { + if (!SystemTagsManager.isSystemTag(tag)) { + hasUserTags = true; + break; + } + } - empty.animate().alpha(1f).setDuration(200).start(); + if (hasUserTags) { + tagsFader.show(animate, TAGS_SHOW_MS, new FastOutSlowInInterpolator()); + } else { + tagsFader.hide(animate, TAGS_HIDE_MS, new DecelerateInterpolator(1.6f)); + } } - /** Hides the empty state view using fade-out animation. */ - private void animateHideEmpty(View empty) { - if (empty.getVisibility() != View.VISIBLE) return; - - empty.animate() - .alpha(0f) - .setDuration(180) - .withEndAction(() -> empty.setVisibility(View.GONE)) - .start(); + /** Cancels running animations and pending callbacks; call when the screen goes away. */ + public void release() { + cancelReveal(); + listFader.cancel(); + emptyFader.cancel(); + tagsFader.cancel(); } /** - * Updates the empty state text based on the selected tag and note count. Also applies - * density-specific adjustments for low DPI devices. + * Fades the swapped-in list back in right before its first frame is drawn, so the new layout is + * complete (and item animations were skipped) by the time it becomes visible. */ - private void updateEmptyText(String emptyText) { - binding.includeEmpty.emptyNotesText.setText(emptyText); - - // Low-density devices optimization - if (binding.getRoot().getResources().getDisplayMetrics().density < 2.2) { - binding.includeEmpty.imageEmpty.setVisibility(View.GONE); - } + private void revealAfterLayout() { + if (pendingReveal != null) return; + ViewTreeObserver.OnPreDrawListener listener = + new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + removeReveal(this); + swapping = false; + if (animationsEnabled()) listNotes.scheduleLayoutAnimation(); + listFader.show(true, LIST_SHOW_MS, new DecelerateInterpolator(1.6f)); + return true; + } + }; + pendingReveal = listener; + listNotes.getViewTreeObserver().addOnPreDrawListener(listener); + listNotes.invalidate(); } - /** Expands a horizontal list (tags list) using soft scale + fade animation. */ - private void expandHorizontal(View view) { - if (view.getVisibility() == View.VISIBLE) return; - - view.setVisibility(View.VISIBLE); - view.setPivotY(0); - view.setScaleY(0.85f); - view.setAlpha(0f); - - view.animate() - .scaleY(1f) - .alpha(1f) - .setDuration(260) - .setInterpolator(new OvershootInterpolator(0.8f)) - .start(); + private void cancelReveal() { + if (pendingReveal != null) removeReveal(pendingReveal); } - /** Collapses a horizontal list with fade + slight scale-down animation. */ - private void collapseHorizontal(View view) { - if (view.getVisibility() == View.GONE) return; - - view.animate() - .scaleY(0.85f) - .alpha(0f) - .setDuration(200) - .setInterpolator(new DecelerateInterpolator(1.6f)) - .withEndAction( - () -> { - view.setVisibility(View.GONE); - view.setScaleY(1f); - view.setAlpha(1f); - }) - .start(); + private void removeReveal(ViewTreeObserver.OnPreDrawListener listener) { + ViewTreeObserver observer = listNotes.getViewTreeObserver(); + if (observer.isAlive()) observer.removeOnPreDrawListener(listener); + if (pendingReveal == listener) pendingReveal = null; } /** - * Handles rendering of the tags list with animations depending on whether user-defined tags - * exist. + * Updates the empty state text based on the selected tag and note count. Also applies + * density-specific adjustments for low DPI devices. */ - public void renderListTags(List tags) { - boolean hasUserTags = false; - for (Tag tag : tags) { - if (!SystemTagsManager.isSystemTag(tag)) { - hasUserTags = true; - break; - } - } + private void updateEmptyText(String text) { + emptyText.setText(text); - if (hasUserTags) { - expandHorizontal(binding.listTags); - } else { - collapseHorizontal(binding.listTags); + // Low-density devices optimization + if (res.getDisplayMetrics().density < 2.2) { + emptyImage.setVisibility(View.GONE); } } @@ -199,7 +231,7 @@ private class EmptyStateBuilder { String build(@Nullable Tag selectedTag, int notesCount) { if (notesCount > 0) return ""; - if (selectedTag == null || "allNotes".equals(selectedTag.getNameTag())) { + if (selectedTag == null || SystemTagsManager.isAllNotesTag(selectedTag)) { return res.getString(R.string.emptyNotes); } diff --git a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/ViewFader.java b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/ViewFader.java new file mode 100644 index 00000000..e5243722 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/ViewFader.java @@ -0,0 +1,183 @@ +package com.pasich.mynotes.ui.controllers.mainActivity; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.animation.TimeInterpolator; +import android.view.View; +import androidx.annotation.Nullable; +import java.util.function.BooleanSupplier; + +/** + * Moves one view toward a shown or hidden target. + * + *

Every request starts from wherever the view is right now and cancels the animation that was + * running, so a hide that is still in flight can never finish after a later show and leave the view + * invisible. Asking for the target the view is already heading to is a no-op. With system + * animations turned off the target is applied immediately. + */ +final class ViewFader { + + private final View view; + private final int hiddenVisibility; + private final float hiddenScaleY; + private final BooleanSupplier animationsEnabled; + + @Nullable private Animator running; + @Nullable private Runnable onDimmed; + private float runningTargetAlpha = -1f; + private boolean targetShown; + private boolean dimmed; + + ViewFader( + View view, + int hiddenVisibility, + float hiddenScaleY, + BooleanSupplier animationsEnabled) { + this.view = view; + this.hiddenVisibility = hiddenVisibility; + this.hiddenScaleY = hiddenScaleY; + this.animationsEnabled = animationsEnabled; + this.targetShown = view.getVisibility() == View.VISIBLE; + } + + /** True while the view is meant to end up visible (including while it is dimmed). */ + boolean isTargetShown() { + return targetShown; + } + + /** True from {@link #dim} until the next {@link #show} or {@link #hide}. */ + boolean isDimmed() { + return dimmed; + } + + void show(boolean animate, long durationMs, TimeInterpolator interpolator) { + if (targetShown && !dimmed && (running != null || isAtRest(1f, 1f, View.VISIBLE))) { + return; + } + targetShown = true; + dimmed = false; + onDimmed = null; + cancel(); + if (view.getVisibility() != View.VISIBLE) { + view.setAlpha(0f); + view.setScaleY(hiddenScaleY); + view.setVisibility(View.VISIBLE); + } + animateTo(1f, 1f, animate, durationMs, interpolator, null); + } + + void hide(boolean animate, long durationMs, TimeInterpolator interpolator) { + if (!targetShown && (running != null || view.getVisibility() == hiddenVisibility)) { + return; + } + targetShown = false; + dimmed = false; + onDimmed = null; + cancel(); + if (view.getVisibility() != View.VISIBLE) { + settleHidden(); + return; + } + animateTo(0f, hiddenScaleY, animate, durationMs, interpolator, this::settleHidden); + } + + /** + * Fades a shown view out without hiding it, then runs {@code whenDimmed}. A second call while + * the fade is running only replaces the callback, so the newest one wins. When the view is not + * on screen, or animations are off, {@code whenDimmed} runs right away. + */ + void dim(boolean animate, long durationMs, TimeInterpolator interpolator, Runnable whenDimmed) { + if (!targetShown || view.getVisibility() != View.VISIBLE || !canAnimate(animate)) { + whenDimmed.run(); + return; + } + boolean alreadyDark = dimmed && running == null && view.getAlpha() == 0f; + dimmed = true; + if (alreadyDark) { + onDimmed = null; + whenDimmed.run(); + return; + } + onDimmed = whenDimmed; + // Already fading out: keep going and let the newest callback run at the end. + if (isHeadingTo(0f)) return; + cancel(); + animateTo( + 0f, + 1f, + true, + durationMs, + interpolator, + () -> { + Runnable callback = onDimmed; + onDimmed = null; + if (callback != null) callback.run(); + }); + } + + /** Stops any running animation without applying its end state. */ + void cancel() { + Animator animator = running; + running = null; + runningTargetAlpha = -1f; + if (animator != null) animator.cancel(); + } + + private boolean isHeadingTo(float alpha) { + return running != null && runningTargetAlpha == alpha; + } + + private boolean isAtRest(float alpha, float scaleY, int visibility) { + return view.getVisibility() == visibility + && view.getAlpha() == alpha + && view.getScaleY() == scaleY; + } + + private boolean canAnimate(boolean animate) { + return animate && animationsEnabled.getAsBoolean(); + } + + private void settleHidden() { + view.setVisibility(hiddenVisibility); + view.setAlpha(1f); + view.setScaleY(1f); + } + + private void animateTo( + float alpha, + float scaleY, + boolean animate, + long durationMs, + TimeInterpolator interpolator, + @Nullable Runnable endAction) { + if (!canAnimate(animate)) { + view.setAlpha(alpha); + view.setScaleY(scaleY); + if (endAction != null) endAction.run(); + return; + } + ObjectAnimator animator = + ObjectAnimator.ofPropertyValuesHolder( + view, + PropertyValuesHolder.ofFloat(View.ALPHA, alpha), + PropertyValuesHolder.ofFloat(View.SCALE_Y, scaleY)); + animator.setDuration(durationMs); + animator.setInterpolator(interpolator); + animator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + // A cancelled or replaced animation is no longer `running`. + if (running != animation) return; + running = null; + runningTargetAlpha = -1f; + if (endAction != null) endAction.run(); + } + }); + running = animator; + runningTargetAlpha = alpha; + animator.start(); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java index 7d9edefa..814e9730 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java @@ -35,6 +35,13 @@ public class MainPresenter extends BasePresenter implements MainContract.presenter { private static final String TAG = "MainPresenter"; + + /** + * Room emits tags and notes separately, so one sync or edit can arrive as two or more emissions + * a few milliseconds apart. Waiting this long folds them into a single render. + */ + static final long STATE_DEBOUNCE_MS = 50; + private final Handler uiHandler = new Handler(Looper.getMainLooper()); private final BehaviorSubject selectedTag = BehaviorSubject.createDefault(SystemTagsManager.createAllNotesTag()); @@ -97,28 +104,13 @@ void starListsRenderStream() { private void initStreams() { tagsStream = - Observable.combineLatest( - getDataManager() - .getTags() - .toObservable() - .map( - tagList -> - TagsSorter.sortTags( - tagList, - getDataManager().getSortParamTags())), - selectedTag, - (tags, selected) -> - tags.stream() - .map( - t -> { - Tag copy = t.copy(); - copy.setSelected( - selected != null - && selected.getId() - == t.getId()); - return copy; - }) - .collect(Collectors.toList())); + getDataManager() + .getTags() + .toObservable() + .map( + tagList -> + TagsSorter.sortTags( + tagList, getDataManager().getSortParamTags())); notesStream = getDataManager().getNotes().toObservable(); } @@ -132,7 +124,10 @@ private void startStateCombiner() { selectedTag, sortParam, this::buildState) - .debounce(5, TimeUnit.MILLISECONDS) + .debounce( + STATE_DEBOUNCE_MS, + TimeUnit.MILLISECONDS, + getSchedulerProvider().computation()) .distinctUntilChanged() .subscribeOn(getSchedulerProvider().io()) .observeOn(getSchedulerProvider().ui()) @@ -142,7 +137,21 @@ private void startStateCombiner() { } private MainViewState buildState( - List tags, List notes, Tag selectedTag, String sort) { + List sortedTags, List notes, Tag requestedTag, String sort) { + + Tag selectedTag = resolveSelectedTag(sortedTags, requestedTag); + if (!sameSelection(selectedTag, requestedTag)) { + // The tag was renamed, merged or deleted by a sync: remember the fresh one so new + // notes, the next state and the chips all agree with what is on screen. + this.selectedTag.onNext(selectedTag); + } + + List tags = new ArrayList<>(sortedTags.size()); + for (Tag t : sortedTags) { + Tag copy = t.copy(); + copy.setSelected(t.getId() == selectedTag.getId()); + tags.add(copy); + } Set hidden = new HashSet<>(); for (Tag t : tags) { @@ -162,10 +171,7 @@ private MainViewState buildState( } } - boolean isAllNotes = - selectedTag == null - || selectedTag.getSystemAction() - == SystemTagsManager.SYSTEM_ACTION_ALL_NOTES; + boolean isAllNotes = SystemTagsManager.isAllNotesTag(selectedTag); List filtered; if (isAllNotes) { @@ -195,6 +201,39 @@ private MainViewState buildState( return new MainViewState(tags, sorted, selectedTag, lastUiEvent); } + /** + * Finds the selected category in a fresh tags list: by id first (survives a rename), then by + * name (survives a sync that replaced the row with an equal one), otherwise "All notes". + */ + static Tag resolveSelectedTag(List tags, Tag requested) { + Tag allNotes = null; + for (Tag t : tags) { + if (SystemTagsManager.isAllNotesTag(t)) { + allNotes = t; + break; + } + } + if (allNotes == null) allNotes = SystemTagsManager.createAllNotesTag(); + + if (requested == null || SystemTagsManager.isSystemTag(requested)) return allNotes; + + for (Tag t : tags) { + if (!SystemTagsManager.isSystemTag(t) && t.getId() == requested.getId()) return t; + } + String name = requested.getNameTag(); + for (Tag t : tags) { + if (!SystemTagsManager.isSystemTag(t) && t.getNameTag().equals(name)) return t; + } + return allNotes; + } + + private static boolean sameSelection(Tag a, Tag b) { + if (a == null || b == null) return a == b; + return a.getId() == b.getId() + && a.getSystemAction() == b.getSystemAction() + && a.getNameTag().equals(b.getNameTag()); + } + private void startSearchStream() { getCompositeDisposable() .add( @@ -247,12 +286,14 @@ public void updateSearchTagFilter(String tagName) { @Override public void onSortChanged(String newSort) { + if (newSort == null || newSort.equals(sortParam.getValue())) return; lastUiEvent = UiEvent.SORT_CHANGED; sortParam.onNext(newSort); } @Override public void onTagSelected(Tag tag) { + if (tag == null || sameSelection(tag, selectedTag.getValue())) return; lastUiEvent = UiEvent.TAG_CHANGED; selectedTag.onNext(tag); } diff --git a/app/src/main/java/com/pasich/mynotes/ui/state/MainViewState.java b/app/src/main/java/com/pasich/mynotes/ui/state/MainViewState.java index 8e2b45b4..11a48ab2 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/state/MainViewState.java +++ b/app/src/main/java/com/pasich/mynotes/ui/state/MainViewState.java @@ -1,11 +1,20 @@ package com.pasich.mynotes.ui.state; +import androidx.annotation.Nullable; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.utils.recycler.diffutil.NoteDiff; import java.util.List; import java.util.Objects; -/** Immutable snapshot of the main screen UI state. */ +/** + * Immutable snapshot of the main screen UI state. + * + *

Equality is by what the screen shows, not by object identity: Room hands out new {@code Note} + * and {@code Tag} instances on every emission, so a sync that rewrites rows without changing them + * produces an equal state and is dropped before it reaches the view. The {@code uiEvent} is not + * part of equality. + */ public record MainViewState(List tags, List notes, Tag selectedTag, UiEvent uiEvent) { public static MainViewState empty() { return new MainViewState(List.of(), List.of(), null, UiEvent.NONE); @@ -16,14 +25,47 @@ public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof MainViewState other)) return false; - return tags.equals(other.tags) - && notes.equals(other.notes) - && ((selectedTag == null && other.selectedTag == null) - || (selectedTag != null && selectedTag.equals(other.selectedTag))); + return sameTag(selectedTag, other.selectedTag) + && sameTags(tags, other.tags) + && sameNotes(notes, other.notes); } @Override public int hashCode() { - return Objects.hash(tags, notes, selectedTag); + int h = 1; + for (Note n : notes) h = 31 * h + n.getId(); + for (Tag t : tags) h = 31 * h + Long.hashCode(t.getId()); + if (selectedTag != null) h = 31 * h + Long.hashCode(selectedTag.getId()); + return h; + } + + private static boolean sameNotes(List a, List b) { + if (a.size() != b.size()) return false; + for (int i = 0; i < a.size(); i++) { + if (!NoteDiff.sameContent(a.get(i), b.get(i))) return false; + } + return true; + } + + private static boolean sameTags(List a, List b) { + if (a.size() != b.size()) return false; + for (int i = 0; i < a.size(); i++) { + Tag x = a.get(i); + Tag y = b.get(i); + if (!sameTag(x, y) + || x.getSelected() != y.getSelected() + || x.getVisibility() != y.getVisibility() + || x.getPosition() != y.getPosition()) { + return false; + } + } + return true; + } + + private static boolean sameTag(@Nullable Tag a, @Nullable Tag b) { + if (a == null || b == null) return a == b; + return a.getId() == b.getId() + && a.getSystemAction() == b.getSystemAction() + && Objects.equals(a.getNameTag(), b.getNameTag()); } } diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java index 25bcc43d..6f3852d7 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java @@ -3,16 +3,19 @@ import static com.pasich.mynotes.utils.navigation.ActivityResultKeys.EXTRA_UPDATE_THEME_STYLE; import static com.pasich.mynotes.utils.navigation.ActivityResultKeys.RESULT_CODE_THEME_UPDATE; +import android.animation.ValueAnimator; import android.app.Activity; import android.content.Intent; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; import android.view.View; import android.view.animation.Animation; import android.view.animation.AnimationUtils; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; -import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -55,6 +58,8 @@ import com.pasich.mynotes.utils.encly.EnclyMigrationRepository; import com.pasich.mynotes.utils.managers.SystemTagsManager; import com.pasich.mynotes.utils.navigation.NoteNavigator; +import com.pasich.mynotes.utils.recycler.NoteListTransition; +import com.pasich.mynotes.utils.recycler.NotesItemAnimator; import com.pasich.mynotes.utils.recycler.SpacesItemDecoration; import com.pasich.mynotes.utils.recycler.SwipeToListNotesCallback; import com.pasich.mynotes.utils.tool.FormatListTool; @@ -116,7 +121,23 @@ public class MainActivity extends BaseActivity implements MainContract.view { navigationController.updateNewVersionIndicator(hasNewVersion); } }); + + /** How long a return transition from the editor may take (300 ms) plus a margin. */ + private static final long RETURN_SETTLE_MS = 380; + + private final Handler settleHandler = new Handler(Looper.getMainLooper()); private MainRenderListsController mainRenderListsController; + + /** Latest state not applied yet (selection mode, or settling after a return). */ + private MainViewState pendingState; + + private boolean stopped; + private boolean settling; + private final Runnable endSettling = + () -> { + settling = false; + applyPendingState(); + }; private Tag currentSelectedTag = null; private List currentTags = new ArrayList<>(); @@ -135,6 +156,7 @@ public void onCreate(Bundle savedInstanceState) { selectionController = new SelectionController(mNoteAdapter, mActivityBinding.getRoot()); mNoteAdapter.setSelectionController(selectionController); selectionController.setPanelMode(SelectionController.Mode.NORMAL); + mainRenderListsController = new MainRenderListsController(mActivityBinding); mainPresenter.attachView(this); mainPresenter.viewIsReady(); @@ -188,8 +210,6 @@ public void onTagFilterChanged(String tagName) { this::finishActivity); navigationController.init(); navigationController.handleShortcuts(getIntent()); - - mainRenderListsController = new MainRenderListsController(mActivityBinding); } /** @@ -203,63 +223,125 @@ private void sweepEnclyHandoffFiles() { .subscribe(); } + /** + * Receives every new state. It is applied at once unless the user is selecting notes (the + * selection is kept in step with the new list and the state waits for selection to end) or the + * screen has just come back from the editor (the state waits for the return transition). + */ @Override public void render(MainViewState state) { + pendingState = state; if (selectionController.isInSelectionMode()) { + List ids = new ArrayList<>(state.notes().size()); + for (Note n : state.notes()) ids.add(n.getId()); + // May end selection mode, which applies the pending state. + selectionController.retainOnly(ids); return; } + applyPendingState(); + } + + private void applyPendingState() { + MainViewState state = pendingState; + if (state == null || settling || selectionController.isInSelectionMode()) return; + pendingState = null; + currentSelectedTag = state.selectedTag(); currentTags = state.tags(); - // render notes list renderNotes(state.notes(), state.selectedTag(), state.uiEvent()); - // render tags list renderTags(state.tags()); } + /** Item and visibility animations only run while the user is actually looking at the list. */ + private boolean isListInteractive() { + return !stopped && !settling; + } + private void renderTags(List tags) { - mainRenderListsController.renderListTags(tags); + mainRenderListsController.renderListTags(tags, isListInteractive()); tagsAdapter.submitList(tags); } - private void renderNotes(List notes, Tag currentSelectedTag, UiEvent event) { - switch (event) { - case SORT_CHANGED, TAG_CHANGED -> - mNoteAdapter.submitList( - new ArrayList<>(notes), - () -> { - RecyclerView.ItemAnimator animator = - mActivityBinding.listNotes.getItemAnimator(); - if (animator != null) animator.endAnimations(); - mainRenderListsController.showStateNoteList( - currentSelectedTag, notes.size()); - gridLayoutManager.invalidateSpanAssignments(); - mainRenderListsController.animateNoteListChange(); - }); - default -> { - boolean shouldScrollUp = event == UiEvent.NOTE_CREATED; - List currentList = mNoteAdapter.getCurrentList(); - int previousTopId = currentList.isEmpty() ? -1 : currentList.get(0).getId(); - mNoteAdapter.submitList( - new ArrayList<>(notes), - () -> { - RecyclerView.ItemAnimator animator = - mActivityBinding.listNotes.getItemAnimator(); - if (animator != null) animator.endAnimations(); - mainRenderListsController.showStateNoteList( - currentSelectedTag, notes.size()); - gridLayoutManager.invalidateSpanAssignments(); - if (shouldScrollUp) { - mainRenderListsController.scrollUpNoteList(); - } else if (!notes.isEmpty() && notes.get(0).getId() != previousTopId) { - mainRenderListsController.scrollToTopInstant(); - } - }); - } + private void renderNotes(List notes, Tag selectedTag, UiEvent event) { + boolean animate = isListInteractive(); + List previous = mNoteAdapter.getCurrentList(); + int previousTopId = previous.isEmpty() ? -1 : previous.get(0).getId(); + boolean topChanged = !notes.isEmpty() && notes.get(0).getId() != previousTopId; + boolean datasetChanged = event == UiEvent.SORT_CHANGED || event == UiEvent.TAG_CHANGED; + boolean swap = + datasetChanged + || NoteListTransition.needsCrossfade( + previous, notes, gridLayoutManager.getSpanCount()); + List next = new ArrayList<>(notes); + int count = notes.size(); + + if (swap) { + mainRenderListsController.swapListContent( + animate, + () -> + mNoteAdapter.submitList( + next, + () -> { + // Nothing is visible here: rebuild the columns from + // scratch so the grid comes back without gaps. + gridLayoutManager.invalidateSpanAssignments(); + if (datasetChanged + || topChanged + || event == UiEvent.NOTE_CREATED) { + mainRenderListsController.jumpToTop(); + } + mainRenderListsController.showStateNoteList( + selectedTag, count, animate); + })); + } else { + mNoteAdapter.submitList( + next, + () -> { + mainRenderListsController.showStateNoteList(selectedTag, count, animate); + if (event == UiEvent.NOTE_CREATED) { + mainRenderListsController.scrollUpNoteList(); + } else if (topChanged) { + mainRenderListsController.jumpToTop(); + } + }); } mainPresenter.clearUiEvent(); } + @Override + protected void onStart() { + super.onStart(); + if (stopped) { + stopped = false; + // Coming back, usually from the editor with a shared-element return transition. + // Moving cards under it makes the returning note land on a neighbour, so new states + // wait until the transition is over. + long settleMs = (long) (RETURN_SETTLE_MS * animatorDurationScale()); + if (settleMs > 0) { + settling = true; + settleHandler.removeCallbacks(endSettling); + settleHandler.postDelayed(endSettling, settleMs); + } + } + } + + @Override + protected void onStop() { + super.onStop(); + stopped = true; + settleHandler.removeCallbacks(endSettling); + settling = false; + // Anything held back is applied now, without animation, while nobody is looking. + applyPendingState(); + } + + private float animatorDurationScale() { + if (!ValueAnimator.areAnimatorsEnabled()) return 0f; + return Settings.Global.getFloat( + getContentResolver(), Settings.Global.ANIMATOR_DURATION_SCALE, 1f); + } + @Override public void renderDrawerStats(StatsData stats) { @@ -401,6 +483,8 @@ public void onLongClick(int position, Note model) { public void onSelectionModeChanged(boolean active) { mActivityBinding.newNotesButton.setVisibility( active ? View.GONE : View.VISIBLE); + // States that arrived while selecting were held back. + if (!active) applyPendingState(); } @Override @@ -452,7 +536,9 @@ public void settingsLists() { mActivityBinding.listNotes.addItemDecoration(itemDecorationNotes); mActivityBinding.listNotes.setLayoutManager(gridLayoutManager); mActivityBinding.listNotes.setAdapter(mNoteAdapter); - mActivityBinding.listNotes.setItemAnimator(new DefaultItemAnimator()); + mActivityBinding.listNotes.setItemAnimator( + new NotesItemAnimator( + () -> isListInteractive() && !mainRenderListsController.isSwapping())); new ItemTouchHelper( new SwipeToListNotesCallback( @@ -632,6 +718,8 @@ public void selectItemAction(Note note) { @Override protected void onDestroy() { + settleHandler.removeCallbacksAndMessages(null); + if (mainRenderListsController != null) mainRenderListsController.release(); super.onDestroy(); if (navigationController != null) { navigationController.destroy(); diff --git a/app/src/main/java/com/pasich/mynotes/utils/recycler/NoteListTransition.java b/app/src/main/java/com/pasich/mynotes/utils/recycler/NoteListTransition.java new file mode 100644 index 00000000..db1ae3f5 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/recycler/NoteListTransition.java @@ -0,0 +1,49 @@ +package com.pasich.mynotes.utils.recycler; + +import com.pasich.mynotes.data.model.Note; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** Decides how the notes grid should move from one list to the next. */ +public final class NoteListTransition { + + private NoteListTransition() {} + + /** + * True when the change should be shown as one cross-fade of the whole list instead of per-card + * animations. + * + *

A reorder always qualifies: an edited note jumping to the top would otherwise slide over + * every card in between. In a grid with more than one column, adding or removing a card also + * qualifies, because it moves the cards after it into the other column and those moves cut + * diagonally across their neighbours. Text-only changes, and anything involving an empty list, + * never do. + */ + public static boolean needsCrossfade(List previous, List next, int spanCount) { + if (previous.isEmpty() || next.isEmpty()) return false; + + Set previousIds = idsOf(previous); + Set nextIds = idsOf(next); + boolean membershipChanged = !previousIds.equals(nextIds); + if (membershipChanged && spanCount > 1) return true; + + return !commonOrder(previous, nextIds).equals(commonOrder(next, previousIds)); + } + + private static Set idsOf(List notes) { + Set ids = new HashSet<>(notes.size() * 2); + for (Note n : notes) ids.add(n.getId()); + return ids; + } + + /** The ids of {@code notes} that also appear in {@code keep}, in list order. */ + private static List commonOrder(List notes, Set keep) { + List order = new ArrayList<>(notes.size()); + for (Note n : notes) { + if (keep.contains(n.getId())) order.add(n.getId()); + } + return order; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/recycler/NotesItemAnimator.java b/app/src/main/java/com/pasich/mynotes/utils/recycler/NotesItemAnimator.java new file mode 100644 index 00000000..717624cb --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/recycler/NotesItemAnimator.java @@ -0,0 +1,76 @@ +package com.pasich.mynotes.utils.recycler; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.RecyclerView; +import java.util.function.BooleanSupplier; + +/** + * Item animator for the notes grid. + * + *

Change animations are off: a card whose text changed is rebound in place instead of being + * cross-faded with a second copy of itself, which in a staggered grid with a different height + * showed up as one note drawn over another. Whenever {@code allowed} says no (the screen is not + * visible, a return transition is running, or the whole list is being swapped behind a fade) every + * change is applied at once, without animation. + */ +public class NotesItemAnimator extends DefaultItemAnimator { + + private final BooleanSupplier allowed; + + public NotesItemAnimator(@NonNull BooleanSupplier allowed) { + this.allowed = allowed; + setSupportsChangeAnimations(false); + } + + @Override + public boolean animateAppearance( + @NonNull RecyclerView.ViewHolder holder, + @Nullable ItemHolderInfo preLayoutInfo, + @NonNull ItemHolderInfo postLayoutInfo) { + if (skip(holder)) return false; + return super.animateAppearance(holder, preLayoutInfo, postLayoutInfo); + } + + @Override + public boolean animateDisappearance( + @NonNull RecyclerView.ViewHolder holder, + @NonNull ItemHolderInfo preLayoutInfo, + @Nullable ItemHolderInfo postLayoutInfo) { + if (skip(holder)) return false; + return super.animateDisappearance(holder, preLayoutInfo, postLayoutInfo); + } + + @Override + public boolean animatePersistence( + @NonNull RecyclerView.ViewHolder holder, + @NonNull ItemHolderInfo preLayoutInfo, + @NonNull ItemHolderInfo postLayoutInfo) { + if (skip(holder)) return false; + return super.animatePersistence(holder, preLayoutInfo, postLayoutInfo); + } + + @Override + public boolean animateChange( + @NonNull RecyclerView.ViewHolder oldHolder, + @NonNull RecyclerView.ViewHolder newHolder, + @NonNull ItemHolderInfo preLayoutInfo, + @NonNull ItemHolderInfo postLayoutInfo) { + if (!allowed.getAsBoolean()) { + dispatchAnimationFinished(oldHolder); + if (newHolder != oldHolder) dispatchAnimationFinished(newHolder); + return false; + } + return super.animateChange(oldHolder, newHolder, preLayoutInfo, postLayoutInfo); + } + + private boolean skip(RecyclerView.ViewHolder holder) { + if (allowed.getAsBoolean()) return false; + holder.itemView.setAlpha(1f); + holder.itemView.setTranslationX(0f); + holder.itemView.setTranslationY(0f); + dispatchAnimationFinished(holder); + return true; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/recycler/diffutil/NoteDiff.java b/app/src/main/java/com/pasich/mynotes/utils/recycler/diffutil/NoteDiff.java index 61bec9a0..d9b4cc44 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/recycler/diffutil/NoteDiff.java +++ b/app/src/main/java/com/pasich/mynotes/utils/recycler/diffutil/NoteDiff.java @@ -22,12 +22,19 @@ public boolean areItemsTheSame(@NonNull Note oldItem, @NonNull Note newItem) { @Override public boolean areContentsTheSame(@NonNull Note oldItem, @NonNull Note newItem) { + return sameContent(oldItem, newItem); + } + + /** True when both notes would render the same card. */ + public static boolean sameContent(@NonNull Note oldItem, @NonNull Note newItem) { return oldItem.getId() == newItem.getId() - && oldItem.getTitle().equals(newItem.getTitle()) - && oldItem.getValue().equals(newItem.getValue()) - && oldItem.getTag().equals(newItem.getTag()) + && Objects.equals(oldItem.getTitle(), newItem.getTitle()) + && Objects.equals(oldItem.getValue(), newItem.getValue()) + && Objects.equals(oldItem.getTag(), newItem.getTag()) && oldItem.getDate() == newItem.getDate() && oldItem.isPinned() == newItem.isPinned() - && Objects.equals(oldItem.getReminderTime(), newItem.getReminderTime()); + && Objects.equals(oldItem.getReminderTime(), newItem.getReminderTime()) + && Objects.equals(oldItem.getReminderRepeat(), newItem.getReminderRepeat()) + && Objects.equals(oldItem.getAttachments(), newItem.getAttachments()); } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index f63d506b..106de4e8 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -10,10 +10,6 @@ - - + android:paddingBottom="80dp" /> > tagsDb; + private BehaviorProcessor> notesDb; + + /** Presenter wired to processors standing in for Room, with a controllable debounce clock. */ + private MainPresenter syncPresenter(List tags, List notes) { + tagsDb = BehaviorProcessor.createDefault(tags); + notesDb = BehaviorProcessor.createDefault(notes); + when(mockDataManager.getTags()).thenReturn(tagsDb); + when(mockDataManager.getNotes()).thenReturn(notesDb); + when(mockDataManager.getSortParam()).thenReturn(SortParam.DataSort); + SchedulerProvider provider = + new SchedulerProvider() { + @Override + public Scheduler ui() { + return Schedulers.trampoline(); + } + + @Override + public Scheduler computation() { + return debounceClock; + } + + @Override + public Scheduler io() { + return Schedulers.trampoline(); + } + }; + MainPresenter p = new MainPresenter(provider, new CompositeDisposable(), mockDataManager); + p.attachView(mockView); + p.viewIsReady(); + settle(); + return p; + } + + private void settle() { + debounceClock.advanceTimeBy(1, TimeUnit.SECONDS); + } + + private MainViewState lastRendered() { + ArgumentCaptor captor = ArgumentCaptor.forClass(MainViewState.class); + verify(mockView, atLeastOnce()).render(captor.capture()); + List all = captor.getAllValues(); + return all.get(all.size() - 1); + } + + private static Tag userTag(long id, String name) { + Tag t = new Tag().create(name); + t.id = id; + return t; + } + + private static List tagsWith(Tag... user) { + List list = new ArrayList<>(Arrays.asList(user)); + list.addAll(SystemTagsManager.getSystemTags()); + return list; + } + + private static Note note(int id, String title, long date, String tag) { + Note n = new Note().create(title, "body " + id, date, tag); + n.setId(id); + return n; + } + + private static List ids(MainViewState state) { + List ids = new ArrayList<>(); + for (Note n : state.notes()) ids.add(n.getId()); + return ids; + } + + private static Tag selectedChip(MainViewState state) { + for (Tag t : state.tags()) if (t.getSelected()) return t; + return null; + } + + @Test + public void syncRenamesSelectedTag_categoryKeepsItsNotes() { + MainPresenter p = + syncPresenter( + tagsWith(userTag(5, "work")), + Arrays.asList(note(1, "a", 10, "work"), note(2, "b", 20, ""))); + p.onTagSelected(userTag(5, "work")); + settle(); + assertThat(ids(lastRendered())).containsExactly(1); + + // A sync renames the tag on another device; the notes follow the new name. + tagsDb.onNext(tagsWith(userTag(5, "job"))); + notesDb.onNext(Arrays.asList(note(1, "a", 10, "job"), note(2, "b", 20, ""))); + settle(); + + MainViewState state = lastRendered(); + assertThat(state.selectedTag().getNameTag()).isEqualTo("job"); + assertThat(ids(state)).containsExactly(1); + assertThat(selectedChip(state).getId()).isEqualTo(5L); + } + + @Test + public void syncDeletesSelectedTag_fallsBackToAllNotes() { + MainPresenter p = + syncPresenter( + tagsWith(userTag(5, "work")), + Arrays.asList(note(1, "a", 10, "work"), note(2, "b", 20, ""))); + p.onTagSelected(userTag(5, "work")); + settle(); + + tagsDb.onNext(tagsWith()); + notesDb.onNext(Arrays.asList(note(1, "a", 10, ""), note(2, "b", 20, ""))); + settle(); + + MainViewState state = lastRendered(); + assertThat(SystemTagsManager.isAllNotesTag(state.selectedTag())).isTrue(); + assertThat(SystemTagsManager.isAllNotesTag(selectedChip(state))).isTrue(); + assertThat(ids(state)).containsExactly(2, 1).inOrder(); + } + + @Test + public void syncReplacesSelectedTagWithSameName_keepsCategory() { + MainPresenter p = + syncPresenter( + tagsWith(userTag(5, "work")), + Arrays.asList(note(1, "a", 10, "work"), note(2, "b", 20, ""))); + p.onTagSelected(userTag(5, "work")); + settle(); + + // Dedupe on sync: local row 5 is dropped in favour of the remote row 9 with the same name. + tagsDb.onNext(tagsWith(userTag(9, "work"))); + settle(); + + MainViewState state = lastRendered(); + assertThat(state.selectedTag().getId()).isEqualTo(9L); + assertThat(ids(state)).containsExactly(1); + assertThat(selectedChip(state).getId()).isEqualTo(9L); + } + + @Test + public void syncInsertsUpdatesAndDeletes_listFollows() { + syncPresenter(tagsWith(), Arrays.asList(note(1, "a", 10, ""), note(2, "b", 20, ""))); + assertThat(ids(lastRendered())).containsExactly(2, 1).inOrder(); + + notesDb.onNext( + Arrays.asList(note(1, "a", 10, ""), note(2, "b", 20, ""), note(3, "c", 30, ""))); + settle(); + assertThat(ids(lastRendered())).containsExactly(3, 2, 1).inOrder(); + + notesDb.onNext( + Arrays.asList( + note(1, "a edited", 40, ""), note(2, "b", 20, ""), note(3, "c", 30, ""))); + settle(); + MainViewState updated = lastRendered(); + assertThat(ids(updated)).containsExactly(1, 3, 2).inOrder(); + assertThat(updated.notes().get(0).getTitle()).isEqualTo("a edited"); + + notesDb.onNext(Arrays.asList(note(1, "a edited", 40, ""), note(2, "b", 20, ""))); + settle(); + assertThat(ids(lastRendered())).containsExactly(1, 2).inOrder(); + } + + @Test + public void identicalReEmission_doesNotRenderAgain() { + syncPresenter(tagsWith(userTag(5, "work")), Arrays.asList(note(1, "a", 10, "work"))); + clearInvocations(mockView); + + // A no-op sync: Room re-emits fresh but equal rows. + tagsDb.onNext(tagsWith(userTag(5, "work"))); + notesDb.onNext(Arrays.asList(note(1, "a", 10, "work"))); + settle(); + + verify(mockView, never()).render(org.mockito.Mockito.any()); + } + + @Test + public void tagsAndNotesEmittedTogether_renderOnce() { + syncPresenter(tagsWith(), Collections.emptyList()); + clearInvocations(mockView); + + tagsDb.onNext(tagsWith(userTag(5, "work"))); + debounceClock.advanceTimeBy(5, TimeUnit.MILLISECONDS); + notesDb.onNext(Arrays.asList(note(1, "a", 10, "work"))); + settle(); + + verify(mockView, times(1)).render(org.mockito.Mockito.any()); + assertThat(ids(lastRendered())).containsExactly(1); + } } diff --git a/app/src/test/java/com/pasich/mynotes/ui/main/MainRenderListsControllerTest.java b/app/src/test/java/com/pasich/mynotes/ui/main/MainRenderListsControllerTest.java new file mode 100644 index 00000000..26bbb255 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/main/MainRenderListsControllerTest.java @@ -0,0 +1,161 @@ +package com.pasich.mynotes.ui.main; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Activity; +import android.os.Looper; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.ui.controllers.mainActivity.MainRenderListsController; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class MainRenderListsControllerTest { + + private RecyclerView list; + private View empty; + private View tags; + private final AtomicBoolean animationsOn = new AtomicBoolean(true); + private MainRenderListsController controller; + + @Before + public void setUp() { + Activity activity = Robolectric.buildActivity(Activity.class).setup().get(); + FrameLayout root = new FrameLayout(activity); + list = new RecyclerView(activity); + list.setLayoutManager( + new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)); + empty = new FrameLayout(activity); + empty.setVisibility(View.GONE); + tags = new FrameLayout(activity); + tags.setVisibility(View.GONE); + root.addView(list); + root.addView(empty); + root.addView(tags); + activity.setContentView(root); + + controller = + new MainRenderListsController( + list, + empty, + new TextView(activity), + new ImageView(activity), + tags, + animationsOn::get); + } + + private static void runAnimations() { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(2)); + } + + @Test + public void emptyThenNotesWithinTheHideAnimation_listEndsVisible() { + controller.showStateNoteList(null, 0, true); + // Sync fills the list again before the 160 ms hide finishes. + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(40)); + controller.showStateNoteList(null, 5, true); + runAnimations(); + + assertThat(list.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(list.getAlpha()).isEqualTo(1f); + assertThat(empty.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void notesThenEmpty_emptyStateEndsVisible() { + controller.showStateNoteList(null, 5, true); + controller.showStateNoteList(null, 0, true); + runAnimations(); + + assertThat(list.getVisibility()).isEqualTo(View.INVISIBLE); + assertThat(empty.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(empty.getAlpha()).isEqualTo(1f); + } + + @Test + public void repeatedRequests_areIdempotent() { + controller.showStateNoteList(null, 0, true); + controller.showStateNoteList(null, 0, true); + runAnimations(); + controller.showStateNoteList(null, 3, true); + controller.showStateNoteList(null, 3, true); + runAnimations(); + + assertThat(list.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(list.getAlpha()).isEqualTo(1f); + assertThat(list.getScaleY()).isEqualTo(1f); + assertThat(empty.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void animationsOff_appliesImmediately() { + animationsOn.set(false); + controller.showStateNoteList(null, 0, true); + + assertThat(list.getVisibility()).isEqualTo(View.INVISIBLE); + assertThat(empty.getVisibility()).isEqualTo(View.VISIBLE); + + controller.showStateNoteList(null, 2, true); + assertThat(list.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(list.getAlpha()).isEqualTo(1f); + assertThat(empty.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void swap_submitsWhileHiddenAndRevealsAfterLayout() { + AtomicInteger submits = new AtomicInteger(); + controller.swapListContent(true, submits::incrementAndGet); + assertThat(controller.isSwapping()).isTrue(); + // A newer swap during the fade replaces the older one: only the latest list is submitted. + controller.swapListContent(true, submits::incrementAndGet); + runAnimations(); + assertThat(submits.get()).isEqualTo(1); + assertThat(list.getAlpha()).isEqualTo(0f); + + controller.showStateNoteList(null, 4, true); + list.requestLayout(); + runAnimations(); + + assertThat(controller.isSwapping()).isFalse(); + assertThat(list.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(list.getAlpha()).isEqualTo(1f); + } + + @Test + public void swapWithAnimationsOff_submitsAtOnce() { + animationsOn.set(false); + AtomicInteger submits = new AtomicInteger(); + controller.swapListContent(true, submits::incrementAndGet); + assertThat(submits.get()).isEqualTo(1); + assertThat(controller.isSwapping()).isFalse(); + } + + @Test + public void tagsRowCollapseThenExpand_endsVisible() { + controller.renderListTags(List.of(new Tag().create("work")), true); + runAnimations(); + controller.renderListTags(List.of(), true); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(30)); + controller.renderListTags(List.of(new Tag().create("work")), true); + runAnimations(); + + assertThat(tags.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(tags.getAlpha()).isEqualTo(1f); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteListTransitionTest.java b/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteListTransitionTest.java new file mode 100644 index 00000000..9c703222 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteListTransitionTest.java @@ -0,0 +1,48 @@ +package com.pasich.mynotes.utils.recycler; + +import static com.google.common.truth.Truth.assertThat; + +import com.pasich.mynotes.data.model.Note; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; + +public class NoteListTransitionTest { + + private static List notes(int... ids) { + List list = new ArrayList<>(); + for (int id : ids) { + Note n = new Note().create("t" + id, "v" + id, id, ""); + n.setId(id); + list.add(n); + } + return list; + } + + @Test + public void sameOrder_noCrossfade() { + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(3, 2, 1), 2)).isFalse(); + } + + @Test + public void reorder_crossfadesInAnyLayout() { + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(1, 3, 2), 1)).isTrue(); + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(1, 3, 2), 2)).isTrue(); + } + + @Test + public void insertOrRemove_crossfadesOnlyInGrid() { + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(4, 3, 2, 1), 1)) + .isFalse(); + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(3, 1), 1)).isFalse(); + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(4, 3, 2, 1), 2)) + .isTrue(); + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(3, 1), 2)).isTrue(); + } + + @Test + public void fromOrToEmpty_neverCrossfades() { + assertThat(NoteListTransition.needsCrossfade(notes(), notes(1, 2), 2)).isFalse(); + assertThat(NoteListTransition.needsCrossfade(notes(1, 2), notes(), 2)).isFalse(); + } +} From a5e1bd79563a168bafe2c8402f985fb6e2785575 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 10:48:27 +0300 Subject: [PATCH 03/43] fix(sync): write nothing when a sync changes nothing Every sync applies the whole merged snapshot, and the apply re-wrote every known record: notes with a REPLACE insert, tags, tasks and task lists with an update, plus two sync_metadata updates each. Room invalidates a query on any write to its tables, so even a sync with nothing new re-emitted the notes, tags and tasks lists and the main screen redrew and re-animated. The apply now builds the row a version would produce and writes it only when it differs from the stored row. The comparison is made on the row, not on the version hash, because a note whose attachment references are moved to this device's files on apply hashes the same before and after; that first normalising apply is still written. A note with no attachments counts as unchanged whether it stores null or "[]". Version bookkeeping is re-read inside the transaction and only the columns that differ are written, for live records and tombstones alike. Changed records, revivals and deletions are applied exactly as before, and the wire format is unchanged. New Robolectric tests count row writes with SQLite triggers on the watched tables: a repeated sync of an unchanged library writes no rows and does not re-emit the notes Flowable, while a changed note or task and a tombstone are still applied and written. An instrumented case covers a note with an attachment. Refs: #170 --- CHANGELOG.md | 3 + .../pasich/mynotes/db/RoomSyncStoreTest.java | 28 ++ .../mynotes/data/sync/RoomSyncStore.java | 115 ++++++-- .../data/sync/RoomSyncStoreNoOpApplyTest.java | 271 ++++++++++++++++++ 4 files changed, 387 insertions(+), 30 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreNoOpApplyTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be5f13b..ef319f40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and the selected category stay in step with what sync changed: a category renamed or merged on another device stays selected, and one deleted there falls back to All notes instead of showing an empty page. A sync that changes nothing leaves the screen untouched. +- A sync no longer rewrites notes, tags, tasks and task lists that did not change. Only what + another device actually changed is written, so a sync with nothing new finishes without the + main list redrawing or a card flickering. - The main list animates smoothly again. Cards no longer slide over one another when a note moves to the top or the grid is rearranged, the list can no longer get stuck invisible or half-faded after quick changes, and returning from a note no longer shuffles cards under the closing diff --git a/app/src/androidTest/java/com/pasich/mynotes/db/RoomSyncStoreTest.java b/app/src/androidTest/java/com/pasich/mynotes/db/RoomSyncStoreTest.java index 2132dcfb..ff75542c 100644 --- a/app/src/androidTest/java/com/pasich/mynotes/db/RoomSyncStoreTest.java +++ b/app/src/androidTest/java/com/pasich/mynotes/db/RoomSyncStoreTest.java @@ -256,6 +256,34 @@ public void applySnapshot_reapplyingTheLocalVersionKeepsItsAttachments() throws assertThat(readAll(new java.io.FileInputStream(restored))).isEqualTo(bytes); } + @Test + public void applySnapshot_aSecondSyncOfAnUnchangedNoteWithAnAttachmentReEmitsNothing() + throws Exception { + byte[] bytes = "photo bytes".getBytes(StandardCharsets.UTF_8); + int noteId = seedNoteWithAttachment("photo.png", bytes); + // The first apply moves the file to this device's canonical name and rewrites the column; + // that is a real change and is written. + store.applySnapshot(store.readSnapshot(), Collections.emptyList()); + String afterFirst = db.noteDao().getNoteSync(noteId).getAttachments(); + SyncSnapshot unchanged = store.readSnapshot(); + io.reactivex.subscribers.TestSubscriber> notes = + db.noteDao().getNotesAll().test(); + notes.awaitCount( + 1, io.reactivex.observers.BaseTestConsumer.TestWaitStrategy.SLEEP_10MS, 5_000L); + assertThat(notes.valueCount()).isEqualTo(1); + + store.applySnapshot(unchanged, Collections.emptyList()); + + // Every later sync re-applied the note with a REPLACE insert, so the main list was + // re-emitted and re-animated after every sync that changed nothing. + notes.awaitCount( + 2, io.reactivex.observers.BaseTestConsumer.TestWaitStrategy.SLEEP_10MS, 1_000L); + assertThat(notes.valueCount()).isEqualTo(1); + assertThat(db.noteDao().getNoteSync(noteId).getAttachments()).isEqualTo(afterFirst); + assertThat(resolveFirstAttachment(afterFirst).isFile()).isTrue(); + notes.dispose(); + } + @Test public void applySnapshot_repointsEditorBlocksAtTheFilesThisDeviceWrote() throws Exception { byte[] bytes = "photo bytes".getBytes(StandardCharsets.UTF_8); diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java index 9954bdc2..82e8f8c9 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java @@ -40,6 +40,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -363,17 +364,11 @@ private void applySnapshotInternal( } if (record.isTombstone()) { markDeleted(metadata); - database.syncMetadataDao() - .setVersion( - metadata.recordType, - metadata.localId, - record.getUpdatedAt().toEpochMilli(), - record.getDeletedAt().toEpochMilli()); - database.syncMetadataDao() - .setSyncedVersion( - metadata.recordType, - metadata.localId, - record.getCanonicalPayloadHash()); + recordAppliedVersion( + metadata, + record.getUpdatedAt().toEpochMilli(), + record.getDeletedAt().toEpochMilli(), + record.getCanonicalPayloadHash()); retireConflictsSupersededBy(record); transactionFailureInjector.afterRecordApplied(record); continue; @@ -382,17 +377,11 @@ private void applySnapshotInternal( skippedKeys.add(key); continue; } - database.syncMetadataDao() - .setVersion( - metadata.recordType, - metadata.localId, - record.getUpdatedAt().toEpochMilli(), - null); - database.syncMetadataDao() - .setSyncedVersion( - metadata.recordType, - metadata.localId, - record.getCanonicalPayloadHash()); + recordAppliedVersion( + metadata, + record.getUpdatedAt().toEpochMilli(), + null, + record.getCanonicalPayloadHash()); retireConflictsSupersededBy(record); transactionFailureInjector.afterRecordApplied(record); } @@ -843,7 +832,57 @@ else if (SyncMetadata.RECORD_TYPE_PREFERENCES.equals(metadata.recordType)) { } /** - * Writes one live version over the local row. + * Records the version this device now holds, writing only the columns that differ. + * + *

Every sync applies the whole merged snapshot, and nearly all of it is what this device + * already holds. Rewriting identical bookkeeping for each record made every sync a burst of + * writes for nothing. The row is read afresh rather than taken from the map built before the + * loop, because an earlier record in the same apply (a tag reconciled by name) can have changed + * it since. + */ + private void recordAppliedVersion( + @NonNull SyncMetadataEntity metadata, + long updatedAt, + @Nullable Long deletedAt, + @NonNull String versionId) { + SyncMetadataEntity current = + database.syncMetadataDao().get(metadata.recordType, metadata.localId); + if (current == null + || current.updatedAt != updatedAt + || !Objects.equals(current.deletedAt, deletedAt)) { + database.syncMetadataDao() + .setVersion(metadata.recordType, metadata.localId, updatedAt, deletedAt); + } + if (current == null || !versionId.equals(current.syncedVersionId)) { + database.syncMetadataDao() + .setSyncedVersion(metadata.recordType, metadata.localId, versionId); + } + } + + /** + * Whether two rows of the same entity hold the same values. + * + *

Compared through the same Gson the payloads are read with, which sees every stored column + * (the entities' only non-column fields are UI state that is never set on either side here). + */ + private boolean sameRow(@NonNull Object stored, @NonNull Object incoming) { + return gson.toJsonTree(stored).equals(gson.toJsonTree(incoming)); + } + + private static boolean isEmptyAttachmentList(@Nullable String attachments) { + if (attachments == null) return true; + String trimmed = attachments.trim(); + return trimmed.isEmpty() || trimmed.equals("[]"); + } + + /** + * Writes one live version over the local row, unless the row already holds exactly it. + * + *

The comparison is made against the row this version would produce, not against the version + * hash: a note whose attachment references are rewritten to this device's files on apply hashes + * the same before and after, yet only the apply puts its blocks right. When the row already + * matches, nothing is written, so a sync that changes nothing does not invalidate the notes, + * tags or tasks queries and the lists on screen are not re-emitted. * * @return false when the version was deliberately not applied and the record's local version * must therefore not be advanced to it. @@ -859,7 +898,17 @@ private boolean applyPayload(SyncMetadataEntity metadata, JsonObject payload) Note note = gson.fromJson(payload, Note.class); note.setId((int) metadata.localId); restoreAttachments(note, payload); - database.noteDao().addNote(note); + Note stored = revive ? null : database.noteDao().getNoteSync(note.getId()); + if (stored != null + && isEmptyAttachmentList(note.getAttachments()) + && isEmptyAttachmentList(stored.getAttachments())) { + // No attachments either way; the editor stores that as "[]" and a received + // version as null. Spelling it the local way keeps the row equal below. + note.setAttachments(stored.getAttachments()); + } + if (stored == null || !sameRow(stored, note)) { + database.noteDao().addNote(note); + } } else if ("task".equals(metadata.recordType)) { Task task = gson.fromJson(payload, Task.class); task.setId((int) metadata.localId); @@ -871,26 +920,32 @@ private boolean applyPayload(SyncMetadataEntity metadata, JsonObject payload) payload.get("categoryStableId").getAsString()); if (category != null) task.setCategoryId((int) category.localId); } - if (revive || database.taskDao().getTaskSync(task.getId()) == null) { + Task stored = revive ? null : database.taskDao().getTaskSync(task.getId()); + if (stored == null) { database.taskDao().insertTask(task); - } else { + } else if (!sameRow(stored, task)) { database.taskDao().updateTask(task); } } else if (SyncMetadata.RECORD_TYPE_CATEGORY.equals(metadata.recordType)) { TaskCategory category = gson.fromJson(payload, TaskCategory.class); category.setId((int) metadata.localId); - if (revive || database.taskCategoryDao().getCategorySync(category.getId()) == null) { + TaskCategory stored = + revive ? null : database.taskCategoryDao().getCategorySync(category.getId()); + if (stored == null) { database.taskCategoryDao().insertCategory(category); - } else { + } else if (!sameRow(stored, category)) { database.taskCategoryDao().updateCategory(category); } } else if ("tag".equals(metadata.recordType)) { Tag tag = gson.fromJson(payload, Tag.class); tag.id = metadata.localId; - if (revive || database.tagsDao().getTagSync(tag.id) == null) { + Tag stored = revive ? null : database.tagsDao().getTagSync(tag.id); + if (stored == null) { return reviveTag(metadata, tag); } - database.tagsDao().updateTag(tag); + if (!sameRow(stored, tag)) { + database.tagsDao().updateTag(tag); + } } else if (SyncMetadata.RECORD_TYPE_PREFERENCES.equals(metadata.recordType)) { // SharedPreferences is outside Room. applySnapshotInternal journals and commits this // payload only after the Room transaction succeeds. diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreNoOpApplyTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreNoOpApplyTest.java new file mode 100644 index 00000000..8d52adf8 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreNoOpApplyTest.java @@ -0,0 +1,271 @@ +package com.pasich.mynotes.data.sync; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; + +import android.content.Context; +import android.database.Cursor; +import androidx.room.Room; +import androidx.sqlite.db.SupportSQLiteDatabase; +import com.google.gson.JsonObject; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.database.entities.SyncMetadataEntity; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.data.model.TaskCategory; +import com.pasich.mynotes.data.preferences.PreferenceHelper; +import io.reactivex.observers.BaseTestConsumer; +import io.reactivex.subscribers.TestSubscriber; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** + * A sync that changes nothing must write nothing. + * + *

Every sync applies the whole merged snapshot. Re-applying each known record with a REPLACE + * insert or an update invalidated the notes, tags and tasks queries on every sync, so the main list + * was re-emitted and re-animated even when no other device had changed anything. The writes are + * counted with SQLite triggers on the same tables Room's invalidation tracker watches. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class RoomSyncStoreNoOpApplyTest { + + private static final String NOTE_ID = "11111111-1111-4111-8111-111111111111"; + private static final String EMPTY_LIST_NOTE_ID = "22222222-2222-4222-8222-222222222222"; + private static final String TAG_ID = "33333333-3333-4333-8333-333333333333"; + private static final String CATEGORY_ID = "44444444-4444-4444-8444-444444444444"; + private static final String TASK_ID = "55555555-5555-4555-8555-555555555555"; + private static final String[] WATCHED = + new String[] {"notes", "tags", "tasks", "task_categories", "sync_metadata"}; + + private AppDatabase db; + private RoomSyncStore store; + private int noteId; + + @Before + public void setUp() { + Context context = RuntimeEnvironment.getApplication(); + db = + Room.inMemoryDatabaseBuilder(context, AppDatabase.class) + .allowMainThreadQueries() + .build(); + store = new RoomSyncStore(context, db, mock(PreferenceHelper.class)); + + noteId = seedNote("Shopping", "Milk", null, NOTE_ID); + // The editor stores "no attachments" as "[]"; a received version carries none at all. + seedNote("Plain", "Body", "[]", EMPTY_LIST_NOTE_ID); + int tagId = (int) db.tagsDao().addTag(new Tag().create("Work")); + seedMetadata(SyncMetadata.RECORD_TYPE_TAG, tagId, TAG_ID); + int categoryId = + (int) db.taskCategoryDao().insertCategory(new TaskCategory("Home", "#123456")); + seedMetadata(SyncMetadata.RECORD_TYPE_CATEGORY, categoryId, CATEGORY_ID); + int taskId = (int) db.taskDao().insertTask(new Task("Call", categoryId)); + seedMetadata(SyncMetadata.RECORD_TYPE_TASK, taskId, TASK_ID); + } + + @After + public void tearDown() { + db.close(); + } + + @Test + public void applyingWhatThisDeviceAlreadyHoldsWritesNoRows() throws Exception { + SyncSnapshot first = store.readSnapshot(); + countWrites(); + + store.applySnapshot(first, Collections.emptyList()); + + // The first sync only records the versions the remote now holds. + Map writes = writesByTable(); + assertThat(writes.keySet()).containsExactly("sync_metadata"); + + SyncSnapshot second = store.readSnapshot(); + clearWrites(); + store.applySnapshot(second, Collections.emptyList()); + + // Every later sync of an unchanged library touches nothing at all. + assertThat(writesByTable()).isEmpty(); + assertThat(db.noteDao().getNoteSync(noteId).getTitle()).isEqualTo("Shopping"); + SyncMetadataEntity metadata = + db.syncMetadataDao().get(SyncMetadata.RECORD_TYPE_NOTE, noteId); + assertThat(metadata.syncedVersionId) + .isEqualTo(second.find(SyncRecord.Type.NOTE, NOTE_ID).getCanonicalPayloadHash()); + } + + @Test + public void aNoOpApplyDoesNotReEmitTheNotesList() throws Exception { + store.applySnapshot(store.readSnapshot(), Collections.emptyList()); + SyncSnapshot unchanged = store.readSnapshot(); + TestSubscriber> notes = db.noteDao().getNotesAll().test(); + notes.awaitCount(1, BaseTestConsumer.TestWaitStrategy.SLEEP_10MS, 5_000L); + assertThat(notes.valueCount()).isEqualTo(1); + + store.applySnapshot(unchanged, Collections.emptyList()); + notes.awaitCount(2, BaseTestConsumer.TestWaitStrategy.SLEEP_10MS, 1_000L); + assertThat(notes.valueCount()).isEqualTo(1); + + // The control: a version that does change the note is still delivered to the list. + store.applySnapshot( + new SyncSnapshot(Collections.singletonList(renamed(unchanged, "Groceries"))), + Collections.emptyList()); + notes.awaitCount(2, BaseTestConsumer.TestWaitStrategy.SLEEP_10MS, 5_000L); + assertThat(notes.valueCount()).isEqualTo(2); + notes.dispose(); + } + + @Test + public void aChangedRecordIsStillAppliedAndOnlyItIsWritten() throws Exception { + store.applySnapshot(store.readSnapshot(), Collections.emptyList()); + SyncSnapshot built = store.readSnapshot(); + SyncRecord edited = renamed(built, "Groceries"); + List merged = new ArrayList<>(); + for (SyncRecord record : built.getRecords()) { + merged.add(record.getId().equals(NOTE_ID) ? edited : record); + } + countWrites(); + + store.applySnapshot(new SyncSnapshot(merged), Collections.emptyList()); + + assertThat(db.noteDao().getNoteSync(noteId).getTitle()).isEqualTo("Groceries"); + SyncMetadataEntity metadata = + db.syncMetadataDao().get(SyncMetadata.RECORD_TYPE_NOTE, noteId); + assertThat(metadata.updatedAt).isEqualTo(edited.getUpdatedAt().toEpochMilli()); + assertThat(metadata.syncedVersionId).isEqualTo(edited.getCanonicalPayloadHash()); + assertThat(writesByTable().keySet()).containsExactly("notes", "sync_metadata"); + } + + @Test + public void aTombstoneIsStillApplied() throws Exception { + store.applySnapshot(store.readSnapshot(), Collections.emptyList()); + SyncRecord built = store.readSnapshot().find(SyncRecord.Type.NOTE, NOTE_ID); + Instant deletedAt = built.getUpdatedAt().plusSeconds(1); + SyncRecord tombstone = + SyncRecord.tombstone(SyncRecord.Type.NOTE, NOTE_ID, deletedAt, deletedAt); + + store.applySnapshot( + new SyncSnapshot(Collections.singletonList(tombstone)), Collections.emptyList()); + + assertThat(db.noteDao().getNoteSync(noteId)).isNull(); + SyncMetadataEntity metadata = + db.syncMetadataDao().get(SyncMetadata.RECORD_TYPE_NOTE, noteId); + assertThat(metadata.deletedAt).isEqualTo(deletedAt.toEpochMilli()); + assertThat(metadata.syncedVersionId).isEqualTo(tombstone.getCanonicalPayloadHash()); + + // Applied again, the same deletion has nothing left to do. + countWrites(); + store.applySnapshot( + new SyncSnapshot(Collections.singletonList(tombstone)), Collections.emptyList()); + assertThat(writesByTable()).isEmpty(); + } + + @Test + public void aChangedTaskIsStillAppliedWhileItsCategoryIsLeftAlone() throws Exception { + store.applySnapshot(store.readSnapshot(), Collections.emptyList()); + SyncSnapshot built = store.readSnapshot(); + SyncRecord task = built.find(SyncRecord.Type.TASK, TASK_ID); + JsonObject payload = task.getPayload().deepCopy(); + payload.addProperty("title", "Call back"); + List merged = new ArrayList<>(); + for (SyncRecord record : built.getRecords()) { + merged.add( + record.getId().equals(TASK_ID) + ? SyncRecord.live( + SyncRecord.Type.TASK, + TASK_ID, + task.getUpdatedAt().plusSeconds(1), + payload) + : record); + } + countWrites(); + + store.applySnapshot(new SyncSnapshot(merged), Collections.emptyList()); + + long taskId = + db.syncMetadataDao().getByStableId(SyncMetadata.RECORD_TYPE_TASK, TASK_ID).localId; + Task stored = db.taskDao().getTaskSync((int) taskId); + assertThat(stored.getTitle()).isEqualTo("Call back"); + long categoryId = + db.syncMetadataDao() + .getByStableId(SyncMetadata.RECORD_TYPE_CATEGORY, CATEGORY_ID) + .localId; + assertThat(stored.getCategoryId()).isEqualTo((int) categoryId); + assertThat(writesByTable().keySet()).containsExactly("tasks", "sync_metadata"); + } + + // ---- helpers ---- + + private SyncRecord renamed(SyncSnapshot snapshot, String title) { + SyncRecord note = snapshot.find(SyncRecord.Type.NOTE, NOTE_ID); + JsonObject payload = note.getPayload().deepCopy(); + payload.addProperty("b", title); + return SyncRecord.live( + SyncRecord.Type.NOTE, NOTE_ID, note.getUpdatedAt().plusSeconds(1), payload); + } + + private int seedNote(String title, String value, String attachmentsJson, String stableId) { + Note note = new Note().create(title, value, 1_000L, ""); + note.setAttachments(attachmentsJson); + int id = db.noteDao().addNote(note).intValue(); + seedMetadata(SyncMetadata.RECORD_TYPE_NOTE, id, stableId); + return id; + } + + private void seedMetadata(String recordType, long localId, String stableId) { + db.syncMetadataDao() + .insertIfAbsent( + new SyncMetadataEntity(recordType, localId, stableId, 1_000L, null)); + } + + private SupportSQLiteDatabase sql() { + return db.getOpenHelper().getWritableDatabase(); + } + + /** Installs triggers that log every row written to the watched tables from now on. */ + private void countWrites() { + sql().execSQL("CREATE TEMP TABLE IF NOT EXISTS row_writes(tbl TEXT NOT NULL)"); + for (String table : WATCHED) { + for (String operation : new String[] {"INSERT", "UPDATE", "DELETE"}) { + sql().execSQL( + "CREATE TEMP TRIGGER IF NOT EXISTS count_" + + table + + "_" + + operation + + " AFTER " + + operation + + " ON " + + table + + " BEGIN INSERT INTO row_writes VALUES('" + + table + + "'); END"); + } + } + clearWrites(); + } + + private void clearWrites() { + sql().execSQL("DELETE FROM row_writes"); + } + + private Map writesByTable() { + Map writes = new HashMap<>(); + try (Cursor cursor = sql().query("SELECT tbl, COUNT(*) FROM row_writes GROUP BY tbl")) { + while (cursor.moveToNext()) { + writes.put(cursor.getString(0), cursor.getInt(1)); + } + } + return writes; + } +} From f48d2ae8fb33905c477e68ed459ca6356b080c5d Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 10:58:50 +0300 Subject: [PATCH 04/43] feat(search): rank note search results by match quality Search only knew one rule: a title equal to the query came first and everything else followed by date. The check compared the untrimmed query, so a trailing space defeated it, tags were never searched, accents and repeated spaces made otherwise equal text miss, and queries shorter than two characters returned nothing at all. A new pure-Java NoteSearchRanker folds every note once per notes list (NFKD, diacritics removed, locale-independent lower case, whitespace collapsed) and sorts matches into buckets: exact title, title prefix, whole word in the title, title contains, tag name, then note text. Inside a bucket pinned notes come first, then the newest. A one-character query searches titles only; tags and text are searched from two characters. The ranking now runs after the debounce on a worker thread instead of on the main thread for every keystroke, and the view learns from the result whether an empty list should ask the user to keep typing. Search results highlight the matched part of the title and preview with the theme's primary container colours, mapped back through the folding so accented and decomposed text is marked at the right characters. SearchFilterTest now exercises the production ranker instead of a copy of the filter: every bucket in order, diacritics, Cyrillic, trailing and inner spaces, one-character queries, the pinned tie-break, the tag filter and a 5,000-note timing smoke test. MainPresenterTest covers the debounced search stream end to end. Refs: #175 --- CHANGELOG.md | 8 + .../mynotes/ui/contract/MainContract.java | 8 +- .../mynotes/ui/presenter/MainPresenter.java | 71 +++-- .../ui/view/activity/MainActivity.java | 13 +- .../searchAdapter/SearchNotesAdapter.java | 65 ++++- .../utils/search/NoteSearchRanker.java | 133 +++++++++ .../mynotes/utils/search/SearchHit.java | 64 +++++ .../mynotes/utils/search/SearchText.java | 138 +++++++++ app/src/main/res/layout/item_result.xml | 2 - .../mynotes/presenter/MainPresenterTest.java | 47 ++++ .../mynotes/utils/SearchFilterTest.java | 75 ----- .../utils/search/SearchFilterTest.java | 264 ++++++++++++++++++ 12 files changed, 764 insertions(+), 124 deletions(-) create mode 100644 app/src/main/java/com/pasich/mynotes/utils/search/NoteSearchRanker.java create mode 100644 app/src/main/java/com/pasich/mynotes/utils/search/SearchHit.java create mode 100644 app/src/main/java/com/pasich/mynotes/utils/search/SearchText.java delete mode 100644 app/src/test/java/com/pasich/mynotes/utils/SearchFilterTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/utils/search/SearchFilterTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index ef319f40..148c73fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ and pins stay behind, and the page tells you how many. Nothing is deleted from My Notes; after a successful move you can clear it as a separate, confirmed step. +**Improvements** + +- Search puts the best match first. A note whose title is exactly what you typed comes first, + then titles that start with it, contain it as a word or contain it at all, then notes whose + tag matches, and only then notes that mention it in their text; pinned and newer notes lead + within each group. Case, accents and extra spaces no longer matter, so "cafe" finds "Café". + A single letter already finds titles, and the matching part of each result is highlighted. + **Fixes** - Notes no longer vanish from the main screen after a Google Drive sync. The list, the tags row diff --git a/app/src/main/java/com/pasich/mynotes/ui/contract/MainContract.java b/app/src/main/java/com/pasich/mynotes/ui/contract/MainContract.java index 53295c3c..86a0c049 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/contract/MainContract.java +++ b/app/src/main/java/com/pasich/mynotes/ui/contract/MainContract.java @@ -8,6 +8,7 @@ import com.pasich.mynotes.data.model.Tag; import com.pasich.mynotes.ui.state.MainViewState; import com.pasich.mynotes.ui.state.StatsData; +import com.pasich.mynotes.utils.search.SearchHit; import dagger.hilt.android.scopes.ActivityScoped; import java.util.ArrayList; import java.util.List; @@ -35,8 +36,11 @@ interface view extends BaseView, MoreNoteMainActivityView { /** Shows the confirmation dialog to delete a tag. */ void startDeleteTagDialog(Tag tag); - /** Renders search results to the UI. */ - void renderSearch(List filtered); + /** + * Renders ranked search results. {@code titlesOnly} is true while the query is too short to + * search note text, so an empty result asks the user to keep typing. + */ + void renderSearch(List hits, boolean titlesOnly); /** Updates the drawer statistics panel. */ void renderDrawerStats(StatsData stats); diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java index 814e9730..db8a361f 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java @@ -15,18 +15,18 @@ import com.pasich.mynotes.utils.constants.settings.SortParam; import com.pasich.mynotes.utils.managers.SystemTagsManager; import com.pasich.mynotes.utils.rx.SchedulerProvider; +import com.pasich.mynotes.utils.search.NoteSearchRanker; +import com.pasich.mynotes.utils.search.SearchHit; import dagger.hilt.android.scopes.ActivityScoped; import io.reactivex.Flowable; import io.reactivex.Observable; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.subjects.BehaviorSubject; import java.util.ArrayList; -import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import javax.inject.Inject; /** Presenter backing the main notes list screen. */ @@ -42,6 +42,9 @@ public class MainPresenter extends BasePresenter */ static final long STATE_DEBOUNCE_MS = 50; + /** Folds the burst of a note edit and a query change into one ranking pass. */ + static final long SEARCH_DEBOUNCE_MS = 150; + private final Handler uiHandler = new Handler(Looper.getMainLooper()); private final BehaviorSubject selectedTag = BehaviorSubject.createDefault(SystemTagsManager.createAllNotesTag()); @@ -50,6 +53,8 @@ public class MainPresenter extends BasePresenter private final BehaviorSubject searchQuery = BehaviorSubject.createDefault(""); private final BehaviorSubject searchTagFilter = BehaviorSubject.createDefault(""); + private final NoteSearchRanker searchRanker = new NoteSearchRanker(); + private UiEvent lastUiEvent = UiEvent.NONE; private Note backupDeleteNote; private Observable> tagsStream; @@ -241,38 +246,48 @@ private void startSearchStream() { notesStream, searchQuery, searchTagFilter, - this::filterNotes) - .debounce(150, TimeUnit.MILLISECONDS) + SearchRequest::new) + .debounce( + SEARCH_DEBOUNCE_MS, + TimeUnit.MILLISECONDS, + getSchedulerProvider().computation()) + .map(this::runSearch) .subscribeOn(getSchedulerProvider().io()) .observeOn(getSchedulerProvider().ui()) .subscribe( - filtered -> getView().renderSearch(filtered), + result -> + getView() + .renderSearch( + result.hits, result.titlesOnly), throwable -> Log.e(TAG, "search error", throwable))); } - private List filterNotes(List notes, String query, String tagFilter) { - if (query == null || query.trim().length() < 2) return Collections.emptyList(); - - String q = query.toLowerCase().trim(); - boolean hasTagFilter = tagFilter != null && !tagFilter.isEmpty(); - - return notes.stream() - .filter( - n -> - (n.getTitle().toLowerCase().contains(q) - || n.getValue().toLowerCase().contains(q)) - && (!hasTagFilter || tagFilter.equals(n.getTag()))) - .sorted( - (n1, n2) -> { - boolean n1Exact = n1.getTitle().equalsIgnoreCase(query); - boolean n2Exact = n2.getTitle().equalsIgnoreCase(query); - - if (n1Exact && !n2Exact) return -1; - if (!n1Exact && n2Exact) return 1; - - return Long.compare(n2.getDate(), n1.getDate()); - }) - .collect(Collectors.toList()); + /** Ranks off the main thread: the debounce hands the latest request to a worker. */ + private SearchResult runSearch(SearchRequest request) { + List hits = searchRanker.rank(request.notes, request.query, request.tagFilter); + return new SearchResult(hits, NoteSearchRanker.searchesTitlesOnly(request.query)); + } + + private static final class SearchRequest { + final List notes; + final String query; + final String tagFilter; + + SearchRequest(List notes, String query, String tagFilter) { + this.notes = notes; + this.query = query; + this.tagFilter = tagFilter; + } + } + + private static final class SearchResult { + final List hits; + final boolean titlesOnly; + + SearchResult(List hits, boolean titlesOnly) { + this.hits = hits; + this.titlesOnly = titlesOnly; + } } public void updateSearchQuery(String query) { diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java index 6f3852d7..36a29f1a 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java @@ -62,6 +62,7 @@ import com.pasich.mynotes.utils.recycler.NotesItemAnimator; import com.pasich.mynotes.utils.recycler.SpacesItemDecoration; import com.pasich.mynotes.utils.recycler.SwipeToListNotesCallback; +import com.pasich.mynotes.utils.search.SearchHit; import com.pasich.mynotes.utils.tool.FormatListTool; import dagger.hilt.android.AndroidEntryPoint; import io.reactivex.Completable; @@ -398,18 +399,16 @@ public void onCancel() { } @Override - public void renderSearch(List filtered) { - searchNotesAdapter.submitList(filtered); - - String q = mActivityBinding.searchView.getEditText().getText().toString().trim(); - boolean shortQuery = q.length() < 2; - boolean hasResults = !filtered.isEmpty(); + public void renderSearch(List hits, boolean titlesOnly) { + searchNotesAdapter.submitList(hits); + boolean hasResults = !hits.isEmpty(); mActivityBinding.resultsSearchList.setVisibility(hasResults ? View.VISIBLE : View.GONE); mActivityBinding.searchEmptyState.setVisibility(hasResults ? View.GONE : View.VISIBLE); if (!hasResults) { + // One character only searches titles, so "nothing found" would be premature. mActivityBinding.searchEmptyText.setText( - shortQuery + titlesOnly ? R.string.search_hint_keep_typing : R.string.search_empty_no_results); } diff --git a/app/src/main/java/com/pasich/mynotes/utils/adapters/searchAdapter/SearchNotesAdapter.java b/app/src/main/java/com/pasich/mynotes/utils/adapters/searchAdapter/SearchNotesAdapter.java index 386b5c76..124db2e2 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/adapters/searchAdapter/SearchNotesAdapter.java +++ b/app/src/main/java/com/pasich/mynotes/utils/adapters/searchAdapter/SearchNotesAdapter.java @@ -1,32 +1,44 @@ package com.pasich.mynotes.utils.adapters.searchAdapter; -import android.annotation.SuppressLint; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; import android.view.LayoutInflater; import android.view.ViewGroup; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; +import com.google.android.material.color.MaterialColors; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.databinding.ItemResultBinding; +import com.pasich.mynotes.utils.search.NoteSearchRanker; +import com.pasich.mynotes.utils.search.SearchHit; +import com.pasich.mynotes.utils.search.SearchText; import dagger.hilt.android.scopes.ActivityScoped; import javax.inject.Inject; -/** RecyclerView adapter for displaying note search results. */ +/** + * RecyclerView adapter for ranked note search results. The part of the title and of the preview + * that matched the query is marked with the theme's primary container colour. + */ @ActivityScoped -public class SearchNotesAdapter extends ListAdapter { +public class SearchNotesAdapter extends ListAdapter { - private static final DiffUtil.ItemCallback DIFF_CALLBACK = + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback<>() { @Override - public boolean areItemsTheSame(@NonNull Note oldItem, @NonNull Note newItem) { - return oldItem.getId() == newItem.getId(); + public boolean areItemsTheSame( + @NonNull SearchHit oldItem, @NonNull SearchHit newItem) { + return oldItem.note().getId() == newItem.note().getId(); } - @SuppressLint("DiffUtilEquals") @Override - public boolean areContentsTheSame(@NonNull Note oldItem, @NonNull Note newItem) { - return oldItem.equals(newItem); + public boolean areContentsTheSame( + @NonNull SearchHit oldItem, @NonNull SearchHit newItem) { + return oldItem.sameContentAs(newItem); } }; @@ -65,13 +77,46 @@ public ViewHolder(ItemResultBinding binding, SetItemClickListener clickListener) this.clickListener = clickListener; } - public void bind(Note note) { + public void bind(SearchHit hit) { + Note note = hit.note(); binding.setNote(note); binding.executePendingBindings(); + // A one-letter query never searched the text, so marking a letter there would mislead. + boolean bodySearched = hit.query().length() >= NoteSearchRanker.MIN_BODY_QUERY_LENGTH; + setHighlighted(binding.nameNote, note.getTitle(), hit.query()); + setHighlighted( + binding.previewNote, note.getValuePreview(), bodySearched ? hit.query() : null); + if (clickListener != null) { itemView.setOnClickListener(v -> clickListener.onClick(note, binding.itemNote)); } } + + private static void setHighlighted(TextView view, String text, String query) { + int[] range = query == null ? null : SearchText.locate(text, query); + if (range == null) { + view.setText(text); + return; + } + SpannableString marked = new SpannableString(text); + int background = + MaterialColors.getColor( + view, com.google.android.material.R.attr.colorPrimaryContainer); + int foreground = + MaterialColors.getColor( + view, com.google.android.material.R.attr.colorOnPrimaryContainer); + marked.setSpan( + new BackgroundColorSpan(background), + range[0], + range[1], + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + marked.setSpan( + new ForegroundColorSpan(foreground), + range[0], + range[1], + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + view.setText(marked); + } } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/search/NoteSearchRanker.java b/app/src/main/java/com/pasich/mynotes/utils/search/NoteSearchRanker.java new file mode 100644 index 00000000..000bad46 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/search/NoteSearchRanker.java @@ -0,0 +1,133 @@ +package com.pasich.mynotes.utils.search; + +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.utils.search.SearchHit.MatchKind; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Ranks notes against a search query. + * + *

Matching is case-insensitive and ignores diacritics and extra whitespace (see {@link + * SearchText}). Results come in buckets, best first: exact title, title starts with the query, the + * query as a whole word in the title, the query anywhere in the title, the tag name, and finally + * the note text. Inside a bucket pinned notes come first, then the newest. + * + *

A one-character query only looks at titles: a single letter is in almost every note's text, so + * those matches would bury the useful ones. The text is searched from {@link + * #MIN_BODY_QUERY_LENGTH} characters. + * + *

Each note is folded once per list, not once per keystroke: the folded forms are kept for as + * long as the same list instance keeps coming back, which is the case while only the query changes. + * Not thread-safe; the presenter calls it from one stream. + */ +public final class NoteSearchRanker { + + /** Shortest query, in folded characters, that also searches tag names and note text. */ + public static final int MIN_BODY_QUERY_LENGTH = 2; + + private static final Comparator ORDER = + Comparator.comparingInt(r -> r.kind.ordinal()) + .thenComparing(r -> !r.note.isPinned()) + .thenComparing(r -> r.note.getDate(), Comparator.reverseOrder()) + .thenComparing(r -> r.note.getId(), Comparator.reverseOrder()); + + private List indexedNotes; + private Folded[] index; + + /** + * Whether {@code rawQuery} is too short to search note text, so an empty result should ask the + * user to keep typing rather than say nothing was found. + */ + public static boolean searchesTitlesOnly(String rawQuery) { + return SearchText.normalize(rawQuery).length() < MIN_BODY_QUERY_LENGTH; + } + + /** + * Returns the notes matching {@code rawQuery}, best first. An empty query matches nothing. A + * non-empty {@code tagFilter} keeps only notes with exactly that tag. + */ + public List rank(List notes, String rawQuery, String tagFilter) { + String query = SearchText.normalize(rawQuery); + if (query.isEmpty() || notes == null || notes.isEmpty()) return Collections.emptyList(); + + boolean titlesOnly = query.length() < MIN_BODY_QUERY_LENGTH; + boolean hasTagFilter = tagFilter != null && !tagFilter.isEmpty(); + Folded[] folded = indexFor(notes); + + List ranked = new ArrayList<>(); + for (int i = 0; i < folded.length; i++) { + Note note = notes.get(i); + if (hasTagFilter && !tagFilter.equals(note.getTag())) continue; + MatchKind kind = classify(folded[i], query, titlesOnly); + if (kind != null) ranked.add(new Ranked(note, kind)); + } + ranked.sort(ORDER); + + List hits = new ArrayList<>(ranked.size()); + for (Ranked r : ranked) hits.add(new SearchHit(r.note, r.kind, query)); + return hits; + } + + private Folded[] indexFor(List notes) { + if (notes != indexedNotes || index == null || index.length != notes.size()) { + Folded[] fresh = new Folded[notes.size()]; + for (int i = 0; i < fresh.length; i++) fresh[i] = new Folded(notes.get(i)); + index = fresh; + indexedNotes = notes; + } + return index; + } + + private static MatchKind classify(Folded note, String query, boolean titlesOnly) { + String title = note.title; + int at = title.indexOf(query); + if (at >= 0) { + if (at == 0 && title.length() == query.length()) return MatchKind.EXACT_TITLE; + if (at == 0) return MatchKind.TITLE_PREFIX; + if (hasWholeWord(title, query, at)) return MatchKind.TITLE_WORD; + return MatchKind.TITLE_CONTAINS; + } + if (titlesOnly) return null; + if (note.tag.contains(query)) return MatchKind.TAG; + if (note.body.contains(query)) return MatchKind.BODY; + return null; + } + + /** Whether any occurrence of {@code query} from {@code from} on stands as whole words. */ + private static boolean hasWholeWord(String text, String query, int from) { + for (int at = from; at >= 0; at = text.indexOf(query, at + 1)) { + int end = at + query.length(); + boolean startsWord = + !SearchText.isWordChar(text, at - 1) || !SearchText.isWordChar(text, at); + boolean endsWord = + !SearchText.isWordChar(text, end) || !SearchText.isWordChar(text, end - 1); + if (startsWord && endsWord) return true; + } + return false; + } + + private static final class Folded { + final String title; + final String tag; + final String body; + + Folded(Note note) { + title = SearchText.normalize(note.getTitle()); + tag = SearchText.normalize(note.getTag()); + body = SearchText.normalize(note.getValue()); + } + } + + private static final class Ranked { + final Note note; + final MatchKind kind; + + Ranked(Note note, MatchKind kind) { + this.note = note; + this.kind = kind; + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/search/SearchHit.java b/app/src/main/java/com/pasich/mynotes/utils/search/SearchHit.java new file mode 100644 index 00000000..6276279c --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/search/SearchHit.java @@ -0,0 +1,64 @@ +package com.pasich.mynotes.utils.search; + +import com.pasich.mynotes.data.model.Note; +import java.util.Objects; + +/** One search result: the note, why it matched, and the folded query that found it. */ +public final class SearchHit { + + private final Note note; + private final MatchKind kind; + private final String query; + + public SearchHit(Note note, MatchKind kind, String query) { + this.note = note; + this.kind = kind; + this.query = query; + } + + public Note note() { + return note; + } + + public MatchKind kind() { + return kind; + } + + /** The query as {@link SearchText#normalize} folded it, ready for {@link SearchText#locate}. */ + public String query() { + return query; + } + + /** Whether this hit draws the same card as {@code other}: same note row, same highlight. */ + public boolean sameContentAs(SearchHit other) { + return note == other.note && kind == other.kind && query.equals(other.query); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SearchHit)) return false; + return sameContentAs((SearchHit) o); + } + + @Override + public int hashCode() { + return Objects.hash(System.identityHashCode(note), kind, query); + } + + /** Ranking buckets, best first. */ + public enum MatchKind { + /** The whole title is the query. */ + EXACT_TITLE, + /** The title starts with the query. */ + TITLE_PREFIX, + /** The query is a whole word (or words) inside the title. */ + TITLE_WORD, + /** The query appears anywhere in the title. */ + TITLE_CONTAINS, + /** The note's tag name contains the query. */ + TAG, + /** Only the note's text contains the query. */ + BODY + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/search/SearchText.java b/app/src/main/java/com/pasich/mynotes/utils/search/SearchText.java new file mode 100644 index 00000000..cee6e27f --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/search/SearchText.java @@ -0,0 +1,138 @@ +package com.pasich.mynotes.utils.search; + +import java.text.Normalizer; +import java.util.Arrays; + +/** + * Folds text into the form search compares: compatibility-decomposed (NFKD), diacritics removed, + * lower-cased per code point with the locale-independent mapping, and every run of whitespace + * collapsed into a single space with none at either end. + * + *

So "Caf\u00e9", "CAFE" and "cafe" all fold to {@code cafe}, and " file notes " to {@code file + * notes}. Plain Java, no Android types, so it is unit-tested directly. + */ +public final class SearchText { + + private SearchText() {} + + /** Folds {@code text} for comparison. {@code null} folds to the empty string. */ + public static String normalize(String text) { + if (text == null || text.isEmpty()) return ""; + String decomposed = isAscii(text) ? text : Normalizer.normalize(text, Normalizer.Form.NFKD); + StringBuilder out = new StringBuilder(decomposed.length()); + boolean pendingSpace = false; + for (int i = 0; i < decomposed.length(); ) { + int cp = decomposed.codePointAt(i); + i += Character.charCount(cp); + pendingSpace = append(out, cp, pendingSpace, null, 0, 0); + } + return out.toString(); + } + + /** + * Finds the first place where already-normalized {@code query} occurs in {@code original} once + * that is folded, and returns it as a {@code [start, end)} range of {@code original}'s own + * indices, or {@code null} when it does not occur. Used to highlight a match in the text the + * user actually sees, where an accent or a ligature makes the two lengths differ. + */ + public static int[] locate(String original, String query) { + if (original == null || original.isEmpty() || query == null || query.isEmpty()) { + return null; + } + StringBuilder out = new StringBuilder(original.length()); + IndexMap map = new IndexMap(original.length()); + boolean pendingSpace = false; + for (int i = 0; i < original.length(); ) { + int cp = original.codePointAt(i); + int next = i + Character.charCount(cp); + if (cp < 0x80) { + pendingSpace = append(out, cp, pendingSpace, map, i, next); + } else { + String piece = + Normalizer.normalize(original.substring(i, next), Normalizer.Form.NFKD); + for (int j = 0; j < piece.length(); ) { + int pcp = piece.codePointAt(j); + j += Character.charCount(pcp); + pendingSpace = append(out, pcp, pendingSpace, map, i, next); + } + } + i = next; + } + int at = out.indexOf(query); + if (at < 0) return null; + return new int[] {map.start[at], map.end[at + query.length() - 1]}; + } + + /** Whether the character at a folded index is a letter or digit (a word, not a boundary). */ + static boolean isWordChar(String folded, int index) { + if (index < 0 || index >= folded.length()) return false; + return Character.isLetterOrDigit(folded.codePointAt(index)); + } + + /** + * Appends one decomposed code point to {@code out} and returns whether a space is still + * pending. The space is written only when a visible character follows it, which collapses runs + * and trims both ends in a single pass. + */ + private static boolean append( + StringBuilder out, int cp, boolean pendingSpace, IndexMap map, int from, int to) { + if (isMark(cp)) return pendingSpace; + if (isSpace(cp)) return pendingSpace || out.length() > 0; + if (pendingSpace) { + out.append(' '); + if (map != null) map.add(from, to); + } + int folded = fold(cp); + out.appendCodePoint(folded); + if (map != null) { + for (int k = Character.charCount(folded); k > 0; k--) map.add(from, to); + } + return false; + } + + private static int fold(int cp) { + int lower = Character.toLowerCase(cp); + // Final sigma is only a spelling of sigma; fold it so a word ending in it still matches. + return lower == '\u03C2' ? '\u03C3' : lower; + } + + private static boolean isMark(int cp) { + int type = Character.getType(cp); + return type == Character.NON_SPACING_MARK + || type == Character.COMBINING_SPACING_MARK + || type == Character.ENCLOSING_MARK; + } + + private static boolean isSpace(int cp) { + return Character.isWhitespace(cp) || Character.isSpaceChar(cp); + } + + private static boolean isAscii(String s) { + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) >= 0x80) return false; + } + return true; + } + + /** Original {@code [start, end)} range behind each folded char. */ + private static final class IndexMap { + int[] start; + int[] end; + int size; + + IndexMap(int capacity) { + start = new int[Math.max(capacity, 4)]; + end = new int[start.length]; + } + + void add(int from, int to) { + if (size == start.length) { + start = Arrays.copyOf(start, size * 2); + end = Arrays.copyOf(end, size * 2); + } + start[size] = from; + end[size] = to; + size++; + } + } +} diff --git a/app/src/main/res/layout/item_result.xml b/app/src/main/res/layout/item_result.xml index 764b2583..e504318d 100644 --- a/app/src/main/res/layout/item_result.xml +++ b/app/src/main/res/layout/item_result.xml @@ -29,14 +29,12 @@ lastSearch(boolean expectTitlesOnly) { + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(mockView, atLeastOnce()).renderSearch(captor.capture(), eq(expectTitlesOnly)); + List> all = captor.getAllValues(); + return all.get(all.size() - 1); + } + + @Test + public void search_ranksExactTitleFirstAfterDebounce() { + MainPresenter p = + syncPresenter( + tagsWith(), + Arrays.asList( + new Note().create("Weekly", "review the PR", 30, ""), + new Note().create("PR", "", 10, ""))); + clearInvocations(mockView); + + p.updateSearchQuery("PR "); + verify(mockView, never()).renderSearch(any(), anyBoolean()); + settle(); + + List hits = lastSearch(false); + assertThat(hits).hasSize(2); + assertThat(hits.get(0).note().getTitle()).isEqualTo("PR"); + assertThat(hits.get(0).kind()).isEqualTo(SearchHit.MatchKind.EXACT_TITLE); + } + + @Test + public void search_oneCharacterAsksToKeepTyping() { + MainPresenter p = + syncPresenter( + tagsWith(), Arrays.asList(new Note().create("Notes", "q is here", 30, ""))); + clearInvocations(mockView); + + p.updateSearchQuery("q"); + settle(); + + assertThat(lastSearch(true)).isEmpty(); + } } diff --git a/app/src/test/java/com/pasich/mynotes/utils/SearchFilterTest.java b/app/src/test/java/com/pasich/mynotes/utils/SearchFilterTest.java deleted file mode 100644 index 774dc440..00000000 --- a/app/src/test/java/com/pasich/mynotes/utils/SearchFilterTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.pasich.mynotes.utils; - -import static com.google.common.truth.Truth.assertThat; - -import com.pasich.mynotes.data.model.Note; -import java.util.Arrays; -import java.util.Date; -import java.util.List; -import java.util.stream.Collectors; -import org.junit.Test; - -public class SearchFilterTest { - - private Note makeNote(String title, String value) { - return new Note().create(title, value, new Date().getTime(), ""); - } - - private List filter(List notes, String query) { - if (query == null || query.trim().isEmpty()) return notes; - String q = query.toLowerCase().trim(); - return notes.stream() - .filter( - n -> - n.getTitle().toLowerCase().contains(q) - || n.getValue().toLowerCase().contains(q)) - .collect(Collectors.toList()); - } - - @Test - public void emptyQuery_returnsAllNotes() { - List notes = Arrays.asList(makeNote("A", "a"), makeNote("B", "b")); - assertThat(filter(notes, "")).hasSize(2); - } - - @Test - public void exactTitleMatch_returnsNote() { - List notes = - Arrays.asList(makeNote("Shopping List", "milk"), makeNote("Work", "tasks")); - assertThat(filter(notes, "Shopping List")).hasSize(1); - } - - @Test - public void partialTitleMatch_returnsNote() { - List notes = - Arrays.asList(makeNote("My Shopping", "items"), makeNote("Work", "tasks")); - assertThat(filter(notes, "Shop")).hasSize(1); - } - - @Test - public void caseInsensitiveSearch_works() { - List notes = - Arrays.asList(makeNote("UPPERCASE", "content"), makeNote("Other", "stuff")); - assertThat(filter(notes, "uppercase")).hasSize(1); - } - - @Test - public void contentSearch_matchesValue() { - List notes = - Arrays.asList( - makeNote("Title", "buy groceries"), makeNote("Title2", "meeting notes")); - assertThat(filter(notes, "groceries")).hasSize(1); - } - - @Test - public void noMatch_returnsEmpty() { - List notes = Arrays.asList(makeNote("Alpha", "beta"), makeNote("Gamma", "delta")); - assertThat(filter(notes, "zzznomatch")).isEmpty(); - } - - @Test - public void nullQuery_returnsAllNotes() { - List notes = Arrays.asList(makeNote("A", "a"), makeNote("B", "b")); - assertThat(filter(notes, null)).hasSize(2); - } -} diff --git a/app/src/test/java/com/pasich/mynotes/utils/search/SearchFilterTest.java b/app/src/test/java/com/pasich/mynotes/utils/search/SearchFilterTest.java new file mode 100644 index 00000000..4519f895 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/search/SearchFilterTest.java @@ -0,0 +1,264 @@ +package com.pasich.mynotes.utils.search; + +import static com.google.common.truth.Truth.assertThat; + +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.utils.search.SearchHit.MatchKind; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; + +/** Tests the production {@link NoteSearchRanker} and {@link SearchText} used by main search. */ +public class SearchFilterTest { + + private final NoteSearchRanker ranker = new NoteSearchRanker(); + private int nextId = 1; + + private Note note(String title, String body, long date) { + return note(title, body, date, ""); + } + + private Note note(String title, String body, long date, String tag) { + Note n = new Note().create(title, body, date, tag); + n.setId(nextId++); + return n; + } + + private List titles(List notes, String query) { + return titles(notes, query, ""); + } + + private List titles(List notes, String query, String tagFilter) { + List out = new ArrayList<>(); + for (SearchHit hit : ranker.rank(notes, query, tagFilter)) out.add(hit.note().getTitle()); + return out; + } + + // ---- Ranking buckets (#175) --------------------------------------------------------------- + + @Test + public void exactTitle_outranksNewerBodyMatches() { + List notes = + Arrays.asList( + note("Weekly", "review the PR before lunch", 300), + note("Standup", "two PRs left", 200), + note("PR", "", 100)); + assertThat(titles(notes, "PR")).containsExactly("PR", "Weekly", "Standup").inOrder(); + } + + @Test + public void allBuckets_inIssueOrder() { + List notes = + Arrays.asList( + note("Notes", "a plan for the week", 600), + note("Groceries", "", 500, "plan"), + note("Airplane", "", 400), + note("My plan for May", "", 300), + note("Planning", "", 200), + note("Plan", "", 100)); + List hits = ranker.rank(notes, "plan", ""); + + List kinds = new ArrayList<>(); + for (SearchHit h : hits) kinds.add(h.kind()); + assertThat(kinds) + .containsExactly( + MatchKind.EXACT_TITLE, + MatchKind.TITLE_PREFIX, + MatchKind.TITLE_WORD, + MatchKind.TITLE_CONTAINS, + MatchKind.TAG, + MatchKind.BODY) + .inOrder(); + assertThat(titles(notes, "plan")) + .containsExactly( + "Plan", "Planning", "My plan for May", "Airplane", "Groceries", "Notes") + .inOrder(); + } + + @Test + public void wholeWord_needsBoundariesOnBothSides() { + List notes = + Arrays.asList(note("Code review", "", 300), note("Ask a PR-review", "", 200)); + List hits = ranker.rank(notes, "review", ""); + assertThat(hits.get(0).kind()).isEqualTo(MatchKind.TITLE_WORD); + assertThat(hits.get(1).kind()).isEqualTo(MatchKind.TITLE_WORD); + + List partial = + ranker.rank(Collections.singletonList(note("Reviews", "", 1)), "view", ""); + assertThat(partial.get(0).kind()).isEqualTo(MatchKind.TITLE_CONTAINS); + } + + @Test + public void laterWholeWordOccurrence_stillCounts() { + // The first "art" is inside "Start", the second stands alone. + List hits = + ranker.rank(Collections.singletonList(note("Start art club", "", 1)), "art", ""); + assertThat(hits.get(0).kind()).isEqualTo(MatchKind.TITLE_WORD); + } + + // ---- Normalization ------------------------------------------------------------------------ + + @Test + public void caseAndDiacritics_areIgnored() { + List notes = + Arrays.asList( + note("Café menu", "", 300), + note("CAFE", "", 200), + note("Other", "naïve plan", 100)); + assertThat(titles(notes, "cafe")).containsExactly("CAFE", "Café menu").inOrder(); + assertThat(titles(notes, "CAFÉ")).containsExactly("CAFE", "Café menu").inOrder(); + assertThat(titles(notes, "naive")).containsExactly("Other"); + } + + @Test + public void cyrillic_matchesCaseInsensitively() { + List notes = Arrays.asList(note("Список", "", 200), note("Інше", "список", 100)); + // "СПИСОК" finds the title exactly and the other note through its text. + assertThat(titles(notes, "СПИСОК")).containsExactly("Список", "Інше").inOrder(); + } + + @Test + public void trailingAndInnerSpaces_areCollapsed() { + List notes = + Arrays.asList(note("Shopping list", "", 200), note("Shopping", "", 100)); + List hits = ranker.rank(notes, " shopping list ", ""); + assertThat(hits).hasSize(1); + assertThat(hits.get(0).kind()).isEqualTo(MatchKind.EXACT_TITLE); + + // A trailing space used to defeat the exact-title check. + assertThat(ranker.rank(notes, "shopping ", "").get(0).kind()) + .isEqualTo(MatchKind.EXACT_TITLE); + } + + @Test + public void normalize_foldsCompatibilityForms() { + assertThat(SearchText.normalize(" file \tNOTES ")).isEqualTo("file notes"); + assertThat(SearchText.normalize("Ångström")).isEqualTo("angstrom"); + assertThat(SearchText.normalize(null)).isEmpty(); + } + + @Test + public void locate_returnsRangeInOriginalText() { + // "é" here is e + a combining accent: two chars that fold to one. + String title = "Le café crème"; + int[] range = SearchText.locate(title, SearchText.normalize("café creme")); + assertThat(range).isNotNull(); + assertThat(title.substring(range[0], range[1])).isEqualTo("café crème"); + + assertThat(SearchText.locate("Groceries", "plan")).isNull(); + } + + // ---- Short queries ------------------------------------------------------------------------ + + @Test + public void emptyOrBlankQuery_matchesNothing() { + List notes = Arrays.asList(note("A", "a", 1), note("B", "b", 2)); + assertThat(ranker.rank(notes, "", "")).isEmpty(); + assertThat(ranker.rank(notes, " ", "")).isEmpty(); + assertThat(ranker.rank(notes, null, "")).isEmpty(); + assertThat(NoteSearchRanker.searchesTitlesOnly(" ")).isTrue(); + } + + @Test + public void oneCharacter_searchesTitlesOnly() { + List notes = + Arrays.asList( + note("Notes", "x marks the spot", 300, "x"), + note("Box", "", 200), + note("X", "", 100)); + assertThat(titles(notes, "x")).containsExactly("X", "Box").inOrder(); + assertThat(NoteSearchRanker.searchesTitlesOnly(" x ")).isTrue(); + assertThat(NoteSearchRanker.searchesTitlesOnly("xm")).isFalse(); + } + + @Test + public void twoCharacters_alsoSearchTagsAndText() { + List notes = + Arrays.asList(note("Notes", "x marks the spot", 300), note("Box", "", 200)); + assertThat(titles(notes, "x ma")).containsExactly("Notes"); + assertThat(titles(notes, "ox")).containsExactly("Box"); + } + + // ---- Ties and filters --------------------------------------------------------------------- + + @Test + public void sameBucket_pinnedFirstThenNewest() { + Note oldPinned = note("Trip budget", "", 100); + oldPinned.setPinned(true); + List notes = + Arrays.asList(note("Trip plan", "", 200), oldPinned, note("Trip photos", "", 300)); + assertThat(titles(notes, "trip")) + .containsExactly("Trip budget", "Trip photos", "Trip plan") + .inOrder(); + } + + @Test + public void pinning_neverBeatsABetterBucket() { + Note pinnedBody = note("Ideas", "PR checklist", 999); + pinnedBody.setPinned(true); + List notes = Arrays.asList(pinnedBody, note("PR", "", 1)); + assertThat(titles(notes, "pr")).containsExactly("PR", "Ideas").inOrder(); + } + + @Test + public void tagFilter_keepsOnlyThatTag() { + List notes = + Arrays.asList( + note("Work plan", "", 200, "work"), note("Home plan", "", 100, "home")); + assertThat(titles(notes, "plan", "home")).containsExactly("Home plan"); + assertThat(titles(notes, "plan", null)).containsExactly("Work plan", "Home plan").inOrder(); + } + + @Test + public void noMatch_returnsEmpty() { + List notes = Arrays.asList(note("Alpha", "beta", 1), note("Gamma", "delta", 2)); + assertThat(ranker.rank(notes, "zzznomatch", "")).isEmpty(); + } + + @Test + public void hitsCarryTheFoldedQuery() { + List hits = + ranker.rank(Collections.singletonList(note("Café", "", 1)), " CAFÉ ", ""); + assertThat(hits.get(0).query()).isEqualTo("cafe"); + } + + @Test + public void newListInstance_isReindexed() { + Note before = note("Draft", "", 1); + assertThat(titles(Collections.singletonList(before), "draft")).hasSize(1); + + Note renamed = note("Final", "", 1); + renamed.setId(before.getId()); + List afterSync = new ArrayList<>(Collections.singletonList(renamed)); + assertThat(titles(afterSync, "draft")).isEmpty(); + assertThat(titles(afterSync, "final")).containsExactly("Final"); + } + + // ---- Speed -------------------------------------------------------------------------------- + + @Test + public void fiveThousandNotes_rankQuickly() { + List notes = new ArrayList<>(); + String body = "Зустріч з командою, " + "café notes and a long paragraph of ordinary text. "; + StringBuilder longBody = new StringBuilder(); + for (int i = 0; i < 20; i++) longBody.append(body); + for (int i = 0; i < 5000; i++) { + notes.add(note("Note " + i + " meeting", longBody.toString(), i)); + } + notes.add(note("PR", "", 0)); + + long start = System.nanoTime(); + List first = ranker.rank(notes, "pr", ""); + for (String q : new String[] {"p", "pr", "mee", "meeting", "café", "zz"}) { + ranker.rank(notes, q, ""); + } + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + assertThat(first.get(0).note().getTitle()).isEqualTo("PR"); + // A smoke bound, generous enough for a loaded CI machine: indexing 5k notes once plus + // seven queries over them. + assertThat(elapsedMs).isLessThan(3000L); + } +} From ba59e147dcb7d80c206d3b156779156b65f7dbc2 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 11:08:55 +0300 Subject: [PATCH 05/43] feat(ui): simplify the main and note toolbars The main search bar carried two separate actions, Sort and Format, with vague labels and a layout icon that showed the opposite layout at launch because FormatListTool.init() was never called. They are now one View options action that opens a bottom sheet with the sort order (radio group) and a List/Grid segmented control. Choices apply at once and the sheet stays open; the activity saves and applies them, so the sheet survives recreation. The action's icon and TalkBack label show the current layout. Switching the layout now goes through the list's fade swap, so cards no longer slide over one another when the column count changes. The search hint lost its trailing space and falls back to a short "Search" when the full hint does not fit, so large text and long languages no longer cut it off. The drawer lists Tags and Trash first, then Tasks, and the stats card no longer duplicates the Support button; its decorative icons are hidden from TalkBack so rows are read once. In the note editors the toolbar keeps Back, the save status and More. The reminder bell, which duplicated Reminder in More, is replaced by a chip at the top of the note showing the upcoming reminder's day and time; tapping it opens the reminder picker and it is hidden when there is no reminder. The extended editor's reading-mode action has a localized title that follows its state. Strings are added in all shipped locales and the help texts describe the new places for sorting, layout and reminders. Refs: #180 --- CHANGELOG.md | 10 ++ .../mainActivity/NavigationController.java | 10 -- .../ui/view/activity/MainActivity.java | 78 +++++++--- .../noteEditor/BaseNoteEditorActivity.java | 66 +++++--- .../activity/noteEditor/NoteActivity.java | 7 + .../NoteExtendedEditorActivity.java | 8 + .../view/dialogs/main/ViewOptionsDialog.java | 146 ++++++++++++++++++ .../utils/search/SearchHintFitter.java | 48 ++++++ .../mynotes/utils/tool/FormatListTool.java | 74 +++++---- app/src/main/res/layout/activity_note.xml | 12 ++ .../layout/activity_note_extended_editor.xml | 22 ++- .../main/res/layout/dialog_view_options.xml | 122 +++++++++++++++ app/src/main/res/layout/nav_header_main.xml | 47 +++--- .../res/menu/menu_activity_toolbar_note.xml | 7 - .../menu_activity_toolbar_note_extendes.xml | 13 +- app/src/main/res/menu/menu_search_notes.xml | 18 +-- app/src/main/res/values-be/strings.xml | 23 ++- app/src/main/res/values-de/strings.xml | 23 ++- app/src/main/res/values-en-rGB/strings.xml | 21 ++- app/src/main/res/values-es/strings.xml | 21 ++- app/src/main/res/values-fr/strings.xml | 23 ++- app/src/main/res/values-it/strings.xml | 23 ++- app/src/main/res/values-kk/strings.xml | 23 ++- app/src/main/res/values-pl/strings.xml | 21 ++- app/src/main/res/values-ru/strings.xml | 23 ++- app/src/main/res/values-uk/strings.xml | 23 ++- app/src/main/res/values/strings.xml | 23 ++- app/src/main/res/values/styles.xml | 10 ++ .../ui/main/ViewOptionsDialogTest.java | 108 +++++++++++++ .../utils/search/SearchHintFitterTest.java | 26 ++++ .../utils/tool/FormatListToolTest.java | 81 ++++++++++ 31 files changed, 963 insertions(+), 197 deletions(-) create mode 100644 app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/ViewOptionsDialog.java create mode 100644 app/src/main/java/com/pasich/mynotes/utils/search/SearchHintFitter.java create mode 100644 app/src/main/res/layout/dialog_view_options.xml create mode 100644 app/src/test/java/com/pasich/mynotes/ui/main/ViewOptionsDialogTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/utils/search/SearchHintFitterTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/utils/tool/FormatListToolTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 148c73fe..b311e266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ **Improvements** +- The main screen's search bar is tidier. Sorting and the list or grid layout now live together + under one View options button, which opens a sheet where both can be changed in one visit; the + button shows the layout you are using. The search hint no longer gets cut off with large text, + and the menu puts Tags and Trash first, then Tasks, with a single way to support the developer. +- The note toolbar keeps only Back, the save status and More. An upcoming reminder now shows as a + chip at the top of the note with its day and time; tap it to change or remove the reminder. New + reminders are set from More. - Search puts the best match first. A note whose title is exactly what you typed comes first, then titles that start with it, contain it as a word or contain it at all, then notes whose tag matches, and only then notes that mention it in their text; pinned and newer notes lead @@ -20,6 +27,9 @@ **Fixes** +- Switching between list and grid no longer makes cards slide over one another: the list fades, + takes its new shape and fades back in. The layout button also shows the right layout as soon + as the app opens, instead of the opposite one. - Notes no longer vanish from the main screen after a Google Drive sync. The list, the tags row and the selected category stay in step with what sync changed: a category renamed or merged on another device stays selected, and one deleted there falls back to All notes instead of diff --git a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/NavigationController.java b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/NavigationController.java index c99dc801..8b20f9d8 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/NavigationController.java +++ b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/NavigationController.java @@ -170,15 +170,6 @@ private void setupHeaderButtons() { new Intent( activity, SupportActivity.class)))); - header.findViewById(R.id.drawerStatsCard) - .setOnClickListener( - v -> - delay( - () -> - activity.startActivity( - new Intent( - activity, SupportActivity.class)))); - header.findViewById(R.id.nav_meet_encly) .setOnClickListener(v -> delay(appUpdateController::openMeetEncly)); @@ -263,7 +254,6 @@ public void destroy() { header.findViewById(R.id.nav_backups).setOnClickListener(null); header.findViewById(R.id.nav_about).setOnClickListener(null); header.findViewById(R.id.nav_support).setOnClickListener(null); - header.findViewById(R.id.drawerStatsCard).setOnClickListener(null); header.findViewById(R.id.nav_meet_encly).setOnClickListener(null); View newVersion = header.findViewById(R.id.newVersion); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java index 36a29f1a..1b4d026a 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java @@ -10,12 +10,14 @@ import android.os.Handler; import android.os.Looper; import android.provider.Settings; +import android.view.MenuItem; import android.view.View; import android.view.animation.Animation; import android.view.animation.AnimationUtils; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -24,6 +26,7 @@ import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback; import com.pasich.mynotes.R; import com.pasich.mynotes.base.activity.BaseActivity; +import com.pasich.mynotes.cache.AppPreferencesCache; import com.pasich.mynotes.cache.ThemePreferencesCache; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.data.model.Tag; @@ -44,7 +47,7 @@ import com.pasich.mynotes.ui.view.dialogs.main.DeleteTagDialog; import com.pasich.mynotes.ui.view.dialogs.main.NameTagDialog; import com.pasich.mynotes.ui.view.dialogs.main.SingleTagSelectDialog; -import com.pasich.mynotes.ui.view.dialogs.main.SortDialog; +import com.pasich.mynotes.ui.view.dialogs.main.ViewOptionsDialog; import com.pasich.mynotes.ui.view.dialogs.main.popupWindowsTag.PopupWindowsTag; import com.pasich.mynotes.ui.view.dialogs.main.popupWindowsTag.PopupWindowsTagOnClickListener; import com.pasich.mynotes.utils.UpdateChecker; @@ -62,6 +65,7 @@ import com.pasich.mynotes.utils.recycler.NotesItemAnimator; import com.pasich.mynotes.utils.recycler.SpacesItemDecoration; import com.pasich.mynotes.utils.recycler.SwipeToListNotesCallback; +import com.pasich.mynotes.utils.search.SearchHintFitter; import com.pasich.mynotes.utils.search.SearchHit; import com.pasich.mynotes.utils.tool.FormatListTool; import dagger.hilt.android.AndroidEntryPoint; @@ -74,7 +78,8 @@ /** Main screen showing the notes list and tag navigation. */ @AndroidEntryPoint -public class MainActivity extends BaseActivity implements MainContract.view { +public class MainActivity extends BaseActivity + implements MainContract.view, ViewOptionsDialog.Listener { private final ActivityResultLauncher themeUpdateListener = registerForActivityResult( @@ -109,6 +114,7 @@ public class MainActivity extends BaseActivity implements MainContract.view { @Inject UpdateChecker updateChecker; @Inject ThemePreferencesCache themePreferencesCache; @Inject EnclyMigrationRepository enclyMigrationRepository; + @Inject AppPreferencesCache appPreferencesCache; private SearchController searchController; private AppUpdateController appUpdateController; private NavigationController navigationController; @@ -418,19 +424,17 @@ public void renderSearch(List hits, boolean titlesOnly) { public void initListeners() { mActivityBinding.actionSearch.setOnClickListener(v -> mActivityBinding.searchView.show()); searchNotesAdapter.setItemClickListener(this::openNoteEdit); + bindViewOptionsAction(formatList.getFormat()); + SearchHintFitter.attach( + mActivityBinding.actionSearch, + getString(R.string.search), + getString(R.string.search_short)); mActivityBinding.actionSearch.setOnMenuItemClickListener( menuItem -> { - int idItem = menuItem.getItemId(); - if (idItem == R.id.sort) { - if (!selectionController.isInSelectionMode()) showSortDialog(); - } else if (idItem == R.id.format) { - if (!selectionController.isInSelectionMode()) { - formatList.formatNote(menuItem); - gridLayoutManager.setSpanCount( - mainPresenter.getDataManager().getFormatCount()); - } + if (menuItem.getItemId() == R.id.viewOptions + && !selectionController.isInSelectionMode()) { + showViewOptions(); } - return true; }); @@ -512,19 +516,47 @@ private void shakeTagAt(int position) { } } - void showSortDialog() { - SortDialog dialog = SortDialog.newInstance(false); - dialog.setListener( - new SortDialog.SortListener() { - @Override - public void onSortSelected(String sortParam) { - mainPresenter.onSortChanged(sortParam); - } + private void showViewOptions() { + if (getSupportFragmentManager().findFragmentByTag(ViewOptionsDialog.TAG) != null) return; + ViewOptionsDialog.newInstance(appPreferencesCache.getSortPref(), formatList.getFormat()) + .show(getSupportFragmentManager(), ViewOptionsDialog.TAG); + } - @Override - public void onTagsSortSelected(String tagsSortParam) {} + @Override + public void onViewSortSelected(String sortParam) { + appPreferencesCache.setSortPref(sortParam); + mainPresenter.onSortChanged(sortParam); + } + + @Override + public void onViewLayoutSelected(int format) { + if (!formatList.setFormat(format, viewOptionsItem())) return; + bindViewOptionsAction(format); + int notesCount = mNoteAdapter.getItemCount(); + boolean animate = isListInteractive(); + // Changing the column count moves every card at once; doing it behind the list's fade + // keeps cards from sliding over one another, the same way a new sort order is shown. + mainRenderListsController.swapListContent( + animate, + () -> { + gridLayoutManager.setSpanCount(format); + gridLayoutManager.invalidateSpanAssignments(); + mainRenderListsController.showStateNoteList( + currentSelectedTag, notesCount, animate); }); - dialog.show(getSupportFragmentManager(), "SortDialog"); + } + + @Nullable + private MenuItem viewOptionsItem() { + return mActivityBinding.actionSearch.getMenu().findItem(R.id.viewOptions); + } + + /** Shows the current layout on the toolbar action, for sighted and TalkBack users alike. */ + private void bindViewOptionsAction(int format) { + MenuItem item = viewOptionsItem(); + if (item == null) return; + formatList.init(item); + item.setContentDescription(getString(FormatListTool.contentDescriptionFor(format))); } @Override diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java index a25cfdd7..879537bd 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java @@ -4,11 +4,10 @@ import static com.pasich.mynotes.utils.transition.TransitionUtil.buildContainerTransform; import android.content.Intent; -import android.content.res.ColorStateList; import android.content.res.Configuration; import android.os.Bundle; +import android.text.format.DateUtils; import android.util.Log; -import android.util.TypedValue; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; @@ -21,6 +20,7 @@ import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsControllerCompat; import androidx.viewbinding.ViewBinding; +import com.google.android.material.chip.Chip; import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback; import com.pasich.mynotes.R; import com.pasich.mynotes.base.activity.BaseActivity; @@ -43,7 +43,6 @@ public abstract class BaseNoteEditorActivity extends Base // Menu for the save status indicator protected MenuItem saveStatusMenuItem; - private MenuItem reminderMenuItem; protected T binding; protected long idNote; @@ -53,6 +52,9 @@ public abstract class BaseNoteEditorActivity extends Base protected abstract Toolbar getToolbar(); + /** The chip at the top of the note that shows the active reminder. */ + protected abstract Chip getReminderChip(); + // Returns toolbar instance protected abstract T inflateBinding(LayoutInflater inflater); @@ -101,6 +103,8 @@ public void onCreate(Bundle savedInstanceState) { onAfterPresenterReady(); + getReminderChip().setOnClickListener(v -> openReminderPicker()); + getSupportFragmentManager() .setFragmentResultListener( "reminderChanged", @@ -112,7 +116,7 @@ public void onCreate(Bundle savedInstanceState) { .getNote() .setReminderTime( hasReminder ? result.getLong("reminderTime") : null); - updateReminderIcon(notePresenter.getNote()); + updateReminderChip(notePresenter.getNote()); }); getOnBackPressedDispatcher() @@ -158,7 +162,6 @@ private void setupSharedTransition(View layout, long id) { public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(getMenuResId(), menu); saveStatusMenuItem = menu.findItem(R.id.saveStatusBut); - reminderMenuItem = menu.findItem(R.id.reminderBut); return true; } @@ -166,22 +169,41 @@ public boolean onCreateOptionsMenu(Menu menu) { public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); if (hasFocus && notePresenter != null && notePresenter.hasNote()) { - updateReminderIcon(notePresenter.getNote()); + // A reminder may have fired, or been changed from the notification, meanwhile. + updateReminderChip(notePresenter.getNote()); } } - protected void updateReminderIcon(Note note) { - if (reminderMenuItem == null || note == null) return; - boolean hasReminder = note.hasReminder(); - TypedValue tv = new TypedValue(); - getTheme() - .resolveAttribute( - hasReminder - ? android.R.attr.colorPrimary - : com.google.android.material.R.attr.colorOnBackground, - tv, - true); - reminderMenuItem.setIconTintList(ColorStateList.valueOf(tv.data)); + /** + * Shows the note's upcoming reminder as a chip at the top of the note, or hides the chip when + * there is none. Setting a reminder lives in More, so the toolbar keeps only Back, the save + * status and More. + */ + protected void updateReminderChip(Note note) { + Chip chip = getReminderChip(); + if (chip == null) return; + if (note == null || !note.hasReminder()) { + chip.setVisibility(View.GONE); + return; + } + String when = + DateUtils.formatDateTime( + this, + note.getReminderTime(), + DateUtils.FORMAT_SHOW_DATE + | DateUtils.FORMAT_SHOW_TIME + | DateUtils.FORMAT_SHOW_WEEKDAY + | DateUtils.FORMAT_ABBREV_ALL); + chip.setText(when); + chip.setContentDescription(getString(R.string.reminder_chip_cd, when)); + chip.setVisibility(View.VISIBLE); + } + + private void openReminderPicker() { + if (notePresenter == null || !notePresenter.hasNote()) return; + if (getSupportFragmentManager().findFragmentByTag("ReminderPicker") != null) return; + ReminderPickerBottomSheet.newInstance(notePresenter.getNote().getId()) + .show(getSupportFragmentManager(), "ReminderPicker"); } protected void settingsStatusBar(Window window) { @@ -213,13 +235,6 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { notePresenter.closeActivity(); } - if (item.getItemId() == R.id.reminderBut) { - if (notePresenter.hasNote()) { - ReminderPickerBottomSheet.newInstance(notePresenter.getNote().getId()) - .show(getSupportFragmentManager(), "ReminderPicker"); - } - } - if (item.getItemId() == R.id.moreBut) { if (notePresenter.hasNote()) { MoreNoteDialog dialog = @@ -290,6 +305,7 @@ public void updateSaveStatus(SaveState saveState) { @Override public void onDestroy() { + if (binding != null) getReminderChip().setOnClickListener(null); super.onDestroy(); if (notePresenter != null) { ((NotePresenter) notePresenter).cleanupHandlers(); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java index 7b1a818c..4305896b 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java @@ -15,6 +15,7 @@ import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import androidx.core.widget.NestedScrollView; +import com.google.android.material.chip.Chip; import com.pasich.mynotes.R; import com.pasich.mynotes.base.simplifications.TextWatcher; import com.pasich.mynotes.data.model.Note; @@ -65,6 +66,11 @@ protected Toolbar getToolbar() { return binding.toolbar; } + @Override + protected Chip getReminderChip() { + return binding.reminderChip; + } + @Override protected ActivityNoteBinding inflateBinding(LayoutInflater inflater) { return ActivityNoteBinding.inflate(inflater); @@ -383,6 +389,7 @@ public void loadingNote(Note note) { changeTag(tag != null ? tag : "", false); updateWordCount(value); + updateReminderChip(note); if (notePresenter.getNewNotesKey()) { activatedActivity(); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java index 7bfe2e5e..5571ab03 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java @@ -19,6 +19,7 @@ import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; +import com.google.android.material.chip.Chip; import com.pasich.mynotes.R; import com.pasich.mynotes.cache.AppPreferencesCache; import com.pasich.mynotes.data.model.Note; @@ -61,6 +62,11 @@ protected Toolbar getToolbar() { return binding.toolbar; } + @Override + protected Chip getReminderChip() { + return binding.reminderChip; + } + @Override protected ActivityNoteExtendedEditorBinding inflateBinding(LayoutInflater inflater) { return ActivityNoteExtendedEditorBinding.inflate(inflater); @@ -130,6 +136,7 @@ public void onError(String error) { @Override protected void onNewNoteInit(Note note) { binding.noteEditor.load(note); + updateReminderChip(note); } @Override @@ -242,6 +249,7 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { isReadMode = !isReadMode; binding.noteEditor.actionRead(); item.setIcon(isReadMode ? R.drawable.ic_edit : R.drawable.ic_read); + item.setTitle(isReadMode ? R.string.read_mode_exit : R.string.read_mode_enter); return true; } return super.onOptionsItemSelected(item); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/ViewOptionsDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/ViewOptionsDialog.java new file mode 100644 index 00000000..d6151c09 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/ViewOptionsDialog.java @@ -0,0 +1,146 @@ +package com.pasich.mynotes.ui.view.dialogs.main; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.pasich.mynotes.R; +import com.pasich.mynotes.base.dialog.BaseDialogBottomSheets; +import com.pasich.mynotes.databinding.DialogViewOptionsBinding; +import com.pasich.mynotes.utils.constants.settings.SortParam; +import com.pasich.mynotes.utils.tool.FormatListTool; + +/** + * Bottom sheet with the main screen's view options: the sort order of the notes and the list or + * grid layout. Every choice is reported to the host at once and the sheet stays open, so both can + * be changed in one visit. + * + *

The sheet holds no preferences of its own: it shows what it was opened with and the host + * ({@link Listener}, the activity) saves and applies the choice, so it survives recreation. + */ +public class ViewOptionsDialog extends BaseDialogBottomSheets { + + public static final String TAG = "ViewOptionsDialog"; + + private static final String ARG_SORT = "sort"; + private static final String ARG_FORMAT = "format"; + + private DialogViewOptionsBinding binding; + @Nullable private Listener listener; + private String sort; + private int format; + + public static ViewOptionsDialog newInstance(String sort, int format) { + ViewOptionsDialog dialog = new ViewOptionsDialog(); + Bundle args = new Bundle(); + args.putString(ARG_SORT, sort); + args.putInt(ARG_FORMAT, format); + dialog.setArguments(args); + return dialog; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + if (context instanceof Listener l) listener = l; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Bundle state = savedInstanceState != null ? savedInstanceState : getArguments(); + sort = state != null ? state.getString(ARG_SORT, SortParam.DataSort) : SortParam.DataSort; + format = + state != null + ? state.getInt(ARG_FORMAT, FormatListTool.FORMAT_LIST) + : FormatListTool.FORMAT_LIST; + } + + @Nullable + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + binding = DialogViewOptionsBinding.inflate(inflater, container, false); + bindState(); + initListeners(); + return binding.getRoot(); + } + + @Override + public void setState(BottomSheetDialog dialog) { + super.setState(dialog); + } + + private void bindState() { + binding.sortGroup.check( + SortParam.DataReserve.equals(sort) ? R.id.sortOldest : R.id.sortNewest); + binding.layoutGroup.check( + format == FormatListTool.FORMAT_GRID ? R.id.layoutGrid : R.id.layoutList); + } + + @Override + public void initListeners() { + binding.sortGroup.setOnCheckedChangeListener( + (group, checkedId) -> { + String chosen = + checkedId == R.id.sortOldest + ? SortParam.DataReserve + : SortParam.DataSort; + if (chosen.equals(sort)) return; + sort = chosen; + if (listener != null) listener.onViewSortSelected(chosen); + }); + binding.layoutGroup.addOnButtonCheckedListener( + (group, checkedId, isChecked) -> { + if (!isChecked) return; + int chosen = + checkedId == R.id.layoutGrid + ? FormatListTool.FORMAT_GRID + : FormatListTool.FORMAT_LIST; + if (chosen == format) return; + format = chosen; + if (listener != null) listener.onViewLayoutSelected(chosen); + }); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putString(ARG_SORT, sort); + outState.putInt(ARG_FORMAT, format); + } + + @Override + public void onDestroyView() { + if (binding != null) { + binding.sortGroup.setOnCheckedChangeListener(null); + binding.layoutGroup.clearOnButtonCheckedListeners(); + } + binding = null; + super.onDestroyView(); + } + + @Override + public void onDetach() { + listener = null; + super.onDetach(); + } + + /** Receives the choices made in the sheet; implemented by the host activity. */ + public interface Listener { + /** {@code sortParam} is one of the notes values of {@link SortParam}. */ + void onViewSortSelected(String sortParam); + + /** + * {@code format} is {@link FormatListTool#FORMAT_LIST} or {@link + * FormatListTool#FORMAT_GRID}. + */ + void onViewLayoutSelected(int format); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/search/SearchHintFitter.java b/app/src/main/java/com/pasich/mynotes/utils/search/SearchHintFitter.java new file mode 100644 index 00000000..0b01afb2 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/search/SearchHintFitter.java @@ -0,0 +1,48 @@ +package com.pasich.mynotes.utils.search; + +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import com.google.android.material.search.SearchBar; + +/** + * Keeps the search bar hint readable. The full hint ("Search notes") is long in some languages and + * at large font sizes it would be cut off with an ellipsis; when it does not fit the space the bar + * gives it, the short one ("Search") is shown instead. + */ +public final class SearchHintFitter { + + private SearchHintFitter() {} + + /** Re-checks the hint whenever the bar's text area is laid out again. */ + public static void attach( + @NonNull SearchBar searchBar, @NonNull String fullHint, @NonNull String shortHint) { + TextView text = searchBar.getTextView(); + text.addOnLayoutChangeListener( + (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + float available = + text.getWidth() - text.getPaddingLeft() - text.getPaddingRight(); + if (available <= 0) return; + String hint = + choose( + text.getPaint().measureText(fullHint), + available, + fullHint, + shortHint); + CharSequence current = searchBar.getHint(); + if (current == null || !hint.contentEquals(current)) { + // Changing the hint during layout would be ignored until the next pass. + text.post(() -> searchBar.setHint(hint)); + } + }); + } + + /** + * The full hint when it fits {@code available} pixels, the short one otherwise. Half a pixel of + * slack covers the rounding between a measured string and the width laid out for it. + */ + @VisibleForTesting + static String choose(float fullWidth, float available, String fullHint, String shortHint) { + return fullWidth <= available + 0.5f ? fullHint : shortHint; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/tool/FormatListTool.java b/app/src/main/java/com/pasich/mynotes/utils/tool/FormatListTool.java index 352060eb..b0308534 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/tool/FormatListTool.java +++ b/app/src/main/java/com/pasich/mynotes/utils/tool/FormatListTool.java @@ -1,11 +1,24 @@ package com.pasich.mynotes.utils.tool; import android.view.MenuItem; +import androidx.annotation.DrawableRes; +import androidx.annotation.StringRes; import com.pasich.mynotes.R; import com.pasich.mynotes.cache.AppPreferencesCache; import javax.inject.Inject; +/** + * Owns the notes layout preference (one column or a two-column grid) and keeps the "View options" + * toolbar action showing the layout that is currently on screen. + */ public class FormatListTool { + + /** One note per row. */ + public static final int FORMAT_LIST = 1; + + /** Two columns of cards. */ + public static final int FORMAT_GRID = 2; + private final AppPreferencesCache cache; @Inject @@ -14,38 +27,47 @@ public FormatListTool(AppPreferencesCache cache) { this.cache.initialize(); } - public void init(MenuItem button) { - button.setIcon(getParamIco(getParamFormatValue())); + /** The saved layout, {@link #FORMAT_LIST} or {@link #FORMAT_GRID}. */ + public int getFormat() { + return cache.getFormatPref() == FORMAT_GRID ? FORMAT_GRID : FORMAT_LIST; } - private int getParamFormatValue() { - return cache.getFormatPref(); - } - - /** The switch itself, which switches the operating mode depending on the selected parameter */ - public void formatNote(MenuItem menuItem) { - switch (getParamFormatValue()) { - case 1 -> { - cache.setFormatPref(2); - menuItem.setIcon(getParamIco(2)); - } - case 2 -> { - cache.setFormatPref(1); - menuItem.setIcon(getParamIco(1)); - } - } + /** Shows the saved layout on the toolbar action. Call once the menu exists. */ + public void init(MenuItem button) { + bind(button, getFormat()); } /** - * Method to return int drawable + * Saves {@code format} and updates the toolbar action. * - * @param param - The option chosen by the user - * @return - int drawable + * @return true when the layout actually changed */ - private int getParamIco(int param) { - if (param == 2) { - return R.drawable.ic_edit_format_list; - } - return R.drawable.ic_edit_format_tiles; + public boolean setFormat(int format, MenuItem button) { + int normalized = format == FORMAT_GRID ? FORMAT_GRID : FORMAT_LIST; + boolean changed = normalized != getFormat(); + if (changed) cache.setFormatPref(normalized); + bind(button, normalized); + return changed; + } + + private static void bind(MenuItem button, int format) { + if (button == null) return; + button.setIcon(iconFor(format)); + } + + /** The icon of the layout itself: a list for {@link #FORMAT_LIST}, tiles for the grid. */ + @DrawableRes + public static int iconFor(int format) { + return format == FORMAT_GRID + ? R.drawable.ic_edit_format_tiles + : R.drawable.ic_edit_format_list; + } + + /** What TalkBack reads for the toolbar action, including the current layout. */ + @StringRes + public static int contentDescriptionFor(int format) { + return format == FORMAT_GRID + ? R.string.view_options_cd_grid + : R.string.view_options_cd_list; } } diff --git a/app/src/main/res/layout/activity_note.xml b/app/src/main/res/layout/activity_note.xml index d9edaa0e..3fbdc916 100644 --- a/app/src/main/res/layout/activity_note.xml +++ b/app/src/main/res/layout/activity_note.xml @@ -183,6 +183,18 @@ android:layout_height="wrap_content" android:orientation="vertical"> + + + + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> @@ -75,6 +76,23 @@ + + + + app:layout_constraintTop_toBottomOf="@id/reminderChip" /> diff --git a/app/src/main/res/layout/dialog_view_options.xml b/app/src/main/res/layout/dialog_view_options.xml new file mode 100644 index 00000000..b569a5b9 --- /dev/null +++ b/app/src/main/res/layout/dialog_view_options.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/nav_header_main.xml b/app/src/main/res/layout/nav_header_main.xml index 1a812f11..6bf86783 100644 --- a/app/src/main/res/layout/nav_header_main.xml +++ b/app/src/main/res/layout/nav_header_main.xml @@ -17,9 +17,9 @@ - + @@ -127,7 +130,8 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_marginEnd="16dp" - android:contentDescription="@string/settings" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_interface_set" app:tint="?attr/colorPrimary" /> @@ -156,7 +160,8 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_marginEnd="16dp" - android:contentDescription="@string/your_data" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_backup_restore" app:tint="?attr/colorPrimary" /> @@ -185,7 +190,8 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_gravity="center_vertical" - android:contentDescription="@string/aboutApps" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_about" app:tint="?attr/colorPrimary" /> @@ -242,7 +248,8 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_marginEnd="16dp" - android:contentDescription="@string/whatNewVersion" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_new_w" app:tint="?attr/colorPrimary" /> @@ -271,7 +278,8 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_gravity="center_vertical" - android:contentDescription="@string/meet_encly_title" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_security" app:tint="?attr/colorPrimary" /> @@ -427,7 +435,8 @@ android:layout_height="20dp" android:layout_gravity="center_vertical" android:layout_marginEnd="8dp" - android:contentDescription="@string/help_title" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_heart_support" app:tint="?attr/colorOnPrimary" /> diff --git a/app/src/main/res/menu/menu_activity_toolbar_note.xml b/app/src/main/res/menu/menu_activity_toolbar_note.xml index f9c3d846..7112faf9 100644 --- a/app/src/main/res/menu/menu_activity_toolbar_note.xml +++ b/app/src/main/res/menu/menu_activity_toolbar_note.xml @@ -9,13 +9,6 @@ android:visible="false" app:showAsAction="ifRoom" /> - - -

- - diff --git a/app/src/main/res/menu/menu_search_notes.xml b/app/src/main/res/menu/menu_search_notes.xml index e4e39823..4dd38f7a 100644 --- a/app/src/main/res/menu/menu_search_notes.xml +++ b/app/src/main/res/menu/menu_search_notes.xml @@ -1,15 +1,11 @@ + xmlns:app="http://schemas.android.com/apk/res-auto"> + - - \ No newline at end of file + android:title="@string/view_options" + app:showAsAction="always" /> + diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index a40484ee..bdcf20b8 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -14,9 +14,8 @@ Адкрыць кантакты распрацоўніка Гэты кантэнт не падтрымліваецца. Адсутнічае прыкладанне Play Market - Фарматаваць нататкі - Пошук у нататках + Пошук у нататках Закрыць Дадаць пазнаку Выдаліць @@ -102,7 +101,6 @@ Памылка: Рэзервовая копія пашкоджана Памылка сервера, або дрэнная якасць інтэрнэту - Сартаваць нататкі Паважаны карыстальнік @@ -172,9 +170,9 @@ Пошук Выкарыстоўвайце панэль пошуку для хуткага знаходжання вашых нататак па назве або змесце Сартаванне - Сартуйце вашы нататкі па даце апошняй змены або назве ў ўзыходным ці спадным парадку + Націсніце кнопку параметраў прагляду ў радку пошуку, каб паказваць нядаўна змененыя нататкі першымі або апошнімі. Фармат адлюстравання - Пераключайцеся паміж спісам і сеткай для адлюстравання вашых нататак у зручным для вас макеце + У тых жа параметрах прагляду пераключайцеся паміж спісам і сеткай. Жэсты свайп @@ -449,7 +447,7 @@ Задайце нагадванне для любога задання, націснуўшы значок званочка. У прызначаны час прыйдзе апавяшчэнне. Націсніце на яго, каб адразу адкрыць заданне. Нагадванні Нагадванні пра нататкі - Адкрыйце любую нататку і націсніце значок званочка, каб задаць нагадванне. Выберыце час хуткім варыянтам або праз выбар даты і часу. У прызначаны час з’явіцца апавяшчэнне. + Адкрыйце нататку, націсніце «Больш» і выберыце «Нагадванне». Выберыце час хуткім варыянтам або праз выбар даты і часу. Нагадванне з’явіцца ўверсе нататкі, націсніце на яго, каб змяніць. У прызначаны час з’явіцца апавяшчэнне. Паўтарэнне Пры наладзе нагадвання для нататкі ўключыце пераключальнік «Паўтор», каб выбраць цыкл: штодня, штотыдня або штомесяц. Пасля кожнага апавяшчэння нагадванне пераносіцца аўтаматычна. Інтэрвальныя апавяшчэнні @@ -515,4 +513,17 @@ У «Мае Нататкі» з\'явіўся пераемнік — Encly, цалкам зашыфраваныя нататкі, якія працуюць афлайн. Усе нататкі можна перанесці ў яго адным дотыкам. Падрабязней Пазней + Пошук + Параметры прагляду + Параметры прагляду. Нататкі паказаны спісам + Параметры прагляду. Нататкі паказаны сеткай + Сартаваць па апошняй змене + Спачатку новыя + Спачатку старыя + Выгляд + Спіс + Сетка + Рэжым чытання + Вярнуцца да рэдагавання + Нагадванне: %1$s. Змяніць нагадванне diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 08a5c089..bef53eb9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -14,8 +14,7 @@ Entwicklerkontakte öffnen Dieser Inhalt wird nicht unterstützt. Play Market fehlt - Notizen formatieren - In Notizen suchen + In Notizen suchen Schließen Tag hinzufügen Löschen @@ -94,7 +93,6 @@ Datenwiederherstellung erfolgreich abgeschlossen Fehler: Sicherung beschädigt Serverfehler oder schlechte Internetqualität - Notizen sortieren Lieber Benutzer @@ -164,9 +162,9 @@ Suchen Verwenden Sie die Suchleiste, um Ihre Notizen schnell nach Titel oder Inhalt zu finden Sortierung - Sortieren Sie Ihre Notizen nach dem letzten Änderungsdatum oder Namen in aufsteigender oder absteigender Reihenfolge + Tippen Sie in der Suchleiste auf die Schaltfläche für Ansichtsoptionen, um zuletzt bearbeitete Notizen zuerst oder zuletzt anzuzeigen. Anzeigeformat - Wechseln Sie zwischen Liste und Raster, um Ihre Notizen in einem für Sie bequemen Layout anzuzeigen + In denselben Ansichtsoptionen wechseln Sie zwischen Liste und Raster. Wischgesten @@ -449,7 +447,7 @@ Legen Sie für jede Aufgabe eine Erinnerung fest, indem Sie auf das Glockensymbol tippen. Zur geplanten Zeit erhalten Sie eine Benachrichtigung. Tippen Sie darauf, um die Aufgabe direkt zu öffnen. Erinnerungen Notiz-Erinnerungen - Öffnen Sie eine Notiz und tippen Sie auf das Glockensymbol, um eine Erinnerung festzulegen. Wählen Sie eine Zeit über die Schnellauswahl oder die Datums- und Zeitauswahl. Zur geplanten Zeit erscheint eine Benachrichtigung. + Öffnen Sie eine Notiz, tippen Sie auf „Mehr“ und wählen Sie „Erinnerung“. Wählen Sie eine Zeit über die Schnellauswahl oder die Datums- und Zeitauswahl. Die Erinnerung erscheint oben in der Notiz; tippen Sie darauf, um sie zu ändern. Zur geplanten Zeit erscheint eine Benachrichtigung. Wiederholungsoptionen Aktivieren Sie beim Festlegen einer Notiz-Erinnerung den Schalter „Wiederholen“, um einen Zyklus zu wählen: täglich, wöchentlich oder monatlich. Nach jeder Benachrichtigung wird die Erinnerung automatisch neu geplant. Intervall-Benachrichtigungen @@ -515,4 +513,17 @@ Meine Notizen hat einen Nachfolger: Encly, eine vollständig verschlüsselte Notiz-App, die offline funktioniert. Du kannst alle deine Notizen mit einem Tippen dorthin umziehen. Mehr erfahren Später + Suchen + Ansichtsoptionen + Ansichtsoptionen. Notizen werden als Liste angezeigt + Ansichtsoptionen. Notizen werden als Raster angezeigt + Nach letzter Änderung sortieren + Neueste zuerst + Älteste zuerst + Darstellung + Liste + Raster + Lesemodus + Zurück zum Bearbeiten + Erinnerung: %1$s. Erinnerung ändern diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 554174b2..db78ed77 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -13,7 +13,6 @@ Repeat the action to exit Google Play app not found This content is not supported. - Format notes Search notes Close @@ -114,7 +113,6 @@ Data restored successfully Error: backup is corrupted Server error or poor network quality - Sort notes Dear user @@ -178,9 +176,9 @@ Search Quickly find notes by title or content. Sorting - Sort notes by name or modification date. + Tap the view options button in the search bar to show recently edited notes first or last. Display format - Switch between list and grid view. + In the same view options, switch between a list and a grid. Swipe gestures Swipe left to enter multi-selection mode, swipe right to delete a note. Available only in single-column mode. @@ -496,7 +494,7 @@ Set a reminder for any task by tapping the bell icon. You will receive a notification at the scheduled time. Tap the notification to open the task directly. Reminders Note Reminders - Open any note and tap the bell icon to set a reminder. Pick a time using quick options or the date and time picker. A notification will appear at the scheduled time. + Open a note, tap More and choose Reminder. Pick a time using quick options or the date and time picker. The reminder appears at the top of the note; tap it to change it. A notification will appear at the scheduled time. Repeat Options When setting a note reminder, enable the Repeat toggle to choose a repeat cycle: Daily, Weekly, or Monthly. The reminder reschedules automatically after each notification. Interval Notifications @@ -562,4 +560,17 @@ My Notes has a successor: Encly, a fully encrypted notes app that works offline. You can move all your notes to it in one tap. Learn more Later + Search + View options + View options. Notes are shown as a list + View options. Notes are shown as a grid + Sort by last edit + Newest first + Oldest first + Layout + List + Grid + Reading mode + Back to editing + Reminder: %1$s. Change reminder diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index bd591958..441c5a6b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -16,7 +16,6 @@ Abrir contactos de desarrollador Este contenido no es compatible. Falta la aplicación Play Market - Formato de notas Buscar en notas Cerrar @@ -103,7 +102,6 @@ Restauración de datos completada con éxito Error: la copia de seguridad está dañada Error del servidor o mala calidad de Internet - Ordenar notas Estimado usuario @@ -173,9 +171,9 @@ Búsqueda Usa la barra de búsqueda para encontrar rápidamente tus notas por título o contenido Ordenación - Ordena tus notas por fecha de última modificación o nombre en orden ascendente o descendente + Toca el botón de opciones de vista en la barra de búsqueda para mostrar primero o al final las notas editadas recientemente. Formato de visualización - Cambia entre lista y cuadrícula para mostrar tus notas en un diseño conveniente para ti + En las mismas opciones de vista, cambia entre lista y cuadrícula. Gestos de deslizar @@ -450,7 +448,7 @@ Configura un recordatorio para cualquier tarea tocando el icono de la campana. Recibirás una notificación a la hora prevista. Tócala para abrir la tarea directamente. Recordatorios Recordatorios de notas - Abre cualquier nota y toca el icono de la campana para configurar un recordatorio. Elige la hora con las opciones rápidas o con el selector de fecha y hora. A la hora prevista aparecerá una notificación. + Abre una nota, toca «Más» y elige «Recordatorio». Elige la hora con las opciones rápidas o con el selector de fecha y hora. El recordatorio aparece en la parte superior de la nota; tócalo para cambiarlo. A la hora prevista aparecerá una notificación. Opciones de repetición Al configurar un recordatorio de nota, activa el interruptor «Repetir» para elegir el ciclo: diario, semanal o mensual. El recordatorio se reprograma automáticamente tras cada notificación. Notificaciones por intervalo @@ -516,4 +514,17 @@ Mis notas tiene un sucesor: Encly, una app de notas totalmente cifrada que funciona sin conexión. Puedes trasladar todas tus notas con un solo toque. Más información Más tarde + Buscar + Opciones de vista + Opciones de vista. Las notas se muestran en lista + Opciones de vista. Las notas se muestran en cuadrícula + Ordenar por última modificación + Más recientes primero + Más antiguas primero + Diseño + Lista + Cuadrícula + Modo lectura + Volver a editar + Recordatorio: %1$s. Cambiar recordatorio diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 3124a8c1..f2dd2c94 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -14,8 +14,7 @@ Ouvrir les contacts des développeurs Ce contenu n\'est pas pris en charge. PlayMarket est manquant - Notes de mise en forme - Rechercher dans les notes + Rechercher dans les notes Fermer Ajouter une balise Supprimer @@ -99,7 +98,6 @@ Restauration des données terminée avec succès Erreur: Sauvegarde corrompue Erreur de serveur ou mauvaise qualité Internet - Trier les notes Cher utilisateur @@ -168,9 +166,9 @@ Recherche Utilisez la barre de recherche pour trouver rapidement vos notes par titre ou contenu Tri - Triez vos notes par date de dernière modification ou nom dans l\'ordre croissant ou décroissant + Appuyez sur le bouton des options d’affichage dans la barre de recherche pour afficher les notes modifiées récemment en premier ou en dernier. Format d\'affichage - Basculez entre la liste et la grille pour afficher vos notes dans une mise en page qui vous convient + Dans ces mêmes options d’affichage, basculez entre la liste et la grille. Gestes de balayage @@ -445,7 +443,7 @@ Définissez un rappel pour n’importe quelle tâche en appuyant sur l’icône de cloche. Vous recevrez une notification à l’heure prévue. Appuyez dessus pour ouvrir directement la tâche. Rappels Rappels de notes - Ouvrez une note et appuyez sur l’icône de cloche pour définir un rappel. Choisissez une heure via les options rapides ou le sélecteur de date et d’heure. Une notification apparaîtra à l’heure prévue. + Ouvrez une note, appuyez sur « Plus » et choisissez « Rappel ». Choisissez une heure via les options rapides ou le sélecteur de date et d’heure. Le rappel s’affiche en haut de la note ; appuyez dessus pour le modifier. Une notification apparaîtra à l’heure prévue. Options de répétition Lors de la création d’un rappel de note, activez l’option « Répéter » pour choisir un cycle : quotidien, hebdomadaire ou mensuel. Le rappel est replanifié automatiquement après chaque notification. Notifications par intervalle @@ -511,4 +509,17 @@ Mes notes a un successeur : Encly, une application de notes entièrement chiffrée qui fonctionne hors ligne. Vous pouvez y transférer toutes vos notes en un seul geste. En savoir plus Plus tard + Rechercher + Options d’affichage + Options d’affichage. Les notes sont affichées en liste + Options d’affichage. Les notes sont affichées en grille + Trier par dernière modification + Les plus récentes d’abord + Les plus anciennes d’abord + Disposition + Liste + Grille + Mode lecture + Revenir à l’édition + Rappel : %1$s. Modifier le rappel diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 48170c00..5c817b36 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -14,8 +14,7 @@ Apri contatti sviluppatore Questo contenuto non è supportato. Manca Play Market - Formatta le note - Cerca nelle note + Cerca nelle note Chiudi Aggiungi etichetta Elimina @@ -99,7 +98,6 @@ Errore: backup corrotto Errore del server o scarsa qualità di Internet - Ordina note Gentile utente @@ -168,9 +166,9 @@ Ricerca Usa la barra di ricerca per trovare rapidamente le tue note per titolo o contenuto Ordinamento - Ordina le tue note per data dell\'ultima modifica o nome in ordine crescente o decrescente + Tocca il pulsante delle opzioni di visualizzazione nella barra di ricerca per mostrare per prime o per ultime le note modificate di recente. Formato di visualizzazione - Passa tra lista e griglia per visualizzare le tue note in un layout comodo per te + Nelle stesse opzioni di visualizzazione, passa tra elenco e griglia. Gesti di scorrimento @@ -447,7 +445,7 @@ Imposta un promemoria per qualsiasi attività toccando l’icona della campanella. Riceverai una notifica all’ora prevista. Toccala per aprire direttamente l’attività. Promemoria Promemoria delle note - Apri una nota e tocca l’icona della campanella per impostare un promemoria. Scegli l’orario con le opzioni rapide o con il selettore di data e ora. All’ora prevista comparirà una notifica. + Apri una nota, tocca «Altro» e scegli «Promemoria». Scegli l’orario con le opzioni rapide o con il selettore di data e ora. Il promemoria compare in cima alla nota; toccalo per modificarlo. All’ora prevista comparirà una notifica. Opzioni di ripetizione Quando imposti un promemoria per una nota, attiva l’interruttore «Ripeti» per scegliere il ciclo: giornaliero, settimanale o mensile. Il promemoria viene riprogrammato automaticamente dopo ogni notifica. Notifiche a intervalli @@ -513,4 +511,17 @@ Le mie note ha un successore: Encly, un\'app di note completamente cifrata che funziona offline. Puoi trasferire tutte le tue note con un solo tocco. Scopri di più Più tardi + Cerca + Opzioni di visualizzazione + Opzioni di visualizzazione. Le note sono mostrate come elenco + Opzioni di visualizzazione. Le note sono mostrate come griglia + Ordina per ultima modifica + Prima le più recenti + Prima le meno recenti + Disposizione + Elenco + Griglia + Modalità lettura + Torna alla modifica + Promemoria: %1$s. Modifica promemoria diff --git a/app/src/main/res/values-kk/strings.xml b/app/src/main/res/values-kk/strings.xml index 9a6b6458..8dbc2304 100644 --- a/app/src/main/res/values-kk/strings.xml +++ b/app/src/main/res/values-kk/strings.xml @@ -14,8 +14,7 @@ Әзірлеуші ​​контактілерін ашыңыз Бұл мазмұнға қолдау көрсетілмейді. Play Market қолданбасы жоқ - Жазбаларды пішімдеу - Ескертпелердегі іздеу + Жазбалардан іздеу Жабу Тег қосу Текті @@ -101,7 +100,6 @@ Қате: сақтық көшірме бүлінген Сервер қатесі немесе нашар интернет сапасы - Ескертпелерді сұрыптау Құрметті пайдаланушы Жоба әзірлеушісі сатып алғаныңыз үшін алғыс білдіреді. Біз жақсырақ және ыңғайлырақ болуға тырысамыз. \n Құрметпен, менің жазбаларым! @@ -170,9 +168,9 @@ Іздеу Жазбаларыңызды атауы немесе мазмұны бойынша тез табу үшін іздеу жолағын пайдаланыңыз Сұрыптау - Жазбаларыңызды соңғы өзгерту күні немесе атауы бойынша өсу немесе кему ретімен сұрыптаңыз + Жақында өзгертілген жазбаларды басында немесе соңында көрсету үшін іздеу жолағындағы көрініс параметрлері түймесін басыңыз. Көрсету форматы - Жазбаларыңызды өзіңізге ыңғайлы орналасуда көрсету үшін тізім мен тор арасында ауысыңыз + Сол көрініс параметрлерінде тізім мен тор арасында ауысыңыз. Сырғыту жестері @@ -446,7 +444,7 @@ Қоңырау белгішесін басып, кез келген тапсырмаға еске салғыш қойыңыз. Белгіленген уақытта хабарландыру келеді. Тапсырманы бірден ашу үшін хабарландыруды басыңыз. Еске салғыштар Жазба еске салғыштары - Кез келген жазбаны ашып, еске салғыш қою үшін қоңырау белгішесін басыңыз. Уақытты жылдам нұсқалар арқылы немесе күн мен уақытты таңдау терезесінен көрсетіңіз. Белгіленген уақытта хабарландыру шығады. + Жазбаны ашып, «Қосымша» түймесін басып, «Еске салу» тармағын таңдаңыз. Уақытты жылдам нұсқалар арқылы немесе күн мен уақытты таңдау терезесінен көрсетіңіз. Еске салу жазбаның жоғарғы жағында көрсетіледі, өзгерту үшін оны басыңыз. Белгіленген уақытта хабарландыру шығады. Қайталау параметрлері Жазбаға еске салғыш қойғанда қайталау циклін таңдау үшін «Қайталау» қосқышын қосыңыз: күн сайын, апта сайын немесе ай сайын. Әр хабарландырудан кейін еске салғыш автоматты түрде қайта жоспарланады. Аралық хабарландырулар @@ -512,4 +510,17 @@ «Менің жазбаларым» қолданбасының мұрагері бар — Encly, офлайн жұмыс істейтін толық шифрланған жазбалар. Барлық жазбаларыңызды оған бір түртумен көшіруге болады. Толығырақ Кейінірек + Іздеу + Көрініс параметрлері + Көрініс параметрлері. Жазбалар тізім түрінде көрсетілген + Көрініс параметрлері. Жазбалар тор түрінде көрсетілген + Соңғы өзгеріс бойынша сұрыптау + Алдымен жаңалары + Алдымен ескілері + Орналасу + Тізім + Тор + Оқу режимі + Өңдеуге оралу + Еске салу: %1$s. Еске салуды өзгерту diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index cc061221..7aae15e2 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -14,7 +14,6 @@ Otwórz kontakty programistów Ta zawartość nie jest obsługiwana. Brak aplikacji Play Market - Formatuj notatki Szukaj w notatkach Zamknij @@ -103,7 +102,6 @@ Błąd: Uszkodzona kopia zapasowa Błąd serwera lub niska jakość internetu - Sortuj notatki Drogi użytkowniku @@ -172,9 +170,9 @@ Wyszukiwanie Użyj paska wyszukiwania, aby szybko znaleźć swoje notatki według tytułu lub zawartości Sortowanie - Sortuj swoje notatki według daty ostatniej modyfikacji lub nazwy w kolejności rosnącej lub malejącej + Naciśnij przycisk opcji widoku na pasku wyszukiwania, aby wyświetlać ostatnio edytowane notatki na początku lub na końcu. Format wyświetlania - Przełączaj między listą a siatką, aby wyświetlać swoje notatki w wygodnym dla siebie układzie + W tych samych opcjach widoku przełączaj między listą a siatką. Gesty przesuwania @@ -450,7 +448,7 @@ Ustaw przypomnienie dla dowolnego zadania, naciskając ikonę dzwonka. O wyznaczonej porze otrzymasz powiadomienie. Naciśnij je, aby od razu otworzyć zadanie. Przypomnienia Przypomnienia o notatkach - Otwórz dowolną notatkę i naciśnij ikonę dzwonka, aby ustawić przypomnienie. Wybierz godzinę za pomocą opcji szybkiego wyboru albo selektora daty i godziny. O wyznaczonej porze pojawi się powiadomienie. + Otwórz notatkę, naciśnij „Więcej” i wybierz „Przypomnienie”. Wybierz godzinę za pomocą opcji szybkiego wyboru albo selektora daty i godziny. Przypomnienie pojawi się na górze notatki; naciśnij je, aby je zmienić. O wyznaczonej porze pojawi się powiadomienie. Opcje powtarzania Podczas ustawiania przypomnienia do notatki włącz przełącznik „Powtarzaj”, aby wybrać cykl: codziennie, co tydzień lub co miesiąc. Po każdym powiadomieniu przypomnienie jest planowane ponownie automatycznie. Powiadomienia interwałowe @@ -516,4 +514,17 @@ Moje notatki mają następcę: Encly, w pełni szyfrowane notatki działające offline. Możesz przenieść do niej wszystkie notatki jednym dotknięciem. Dowiedz się więcej Później + Szukaj + Opcje widoku + Opcje widoku. Notatki są wyświetlane jako lista + Opcje widoku. Notatki są wyświetlane jako siatka + Sortuj według ostatniej zmiany + Najpierw najnowsze + Najpierw najstarsze + Układ + Lista + Siatka + Tryb czytania + Wróć do edycji + Przypomnienie: %1$s. Zmień przypomnienie diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c9d43c76..bdbe1437 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -19,9 +19,8 @@ Открыть контакты разработчика Этот контент не поддерживается. Отсутствует приложение Play Market - Форматировать заметки - Поиск в заметках + Поиск в заметках Закрыть Добавить метку Удалить @@ -104,7 +103,6 @@ Ошибка: Резервная копия повреждена Ошибка сервера, или плохое качество интернета - Сортировать заметки Уважаемый пользователь @@ -173,9 +171,9 @@ Поиск Используйте панель поиска для быстрого нахождения ваших заметок по названию или содержимому Сортировка - Сортируйте ваши заметки по дате последнего изменения или названию в восходящем или нисходящем порядке + Нажмите кнопку параметров просмотра в строке поиска, чтобы показывать недавно изменённые заметки первыми или последними. Формат отображения - Переключайтесь между списком и сеткой для отображения ваших заметок в удобном для вас макете + В тех же параметрах просмотра переключайтесь между списком и сеткой. Жесты свайп @@ -454,7 +452,7 @@ Задайте напоминание для любой задачи, нажав значок колокольчика. В указанное время придёт уведомление. Нажмите на него, чтобы сразу открыть задачу. Напоминания Напоминания о заметках - Откройте любую заметку и нажмите значок колокольчика, чтобы задать напоминание. Выберите время быстрым вариантом или через выбор даты и времени. В назначенное время появится уведомление. + Откройте заметку, нажмите «Больше» и выберите «Напоминание». Выберите время быстрым вариантом или через выбор даты и времени. Напоминание появится вверху заметки, нажмите на него, чтобы изменить. В назначенное время появится уведомление. Повторение При настройке напоминания для заметки включите переключатель «Повтор», чтобы выбрать цикл: ежедневно, еженедельно или ежемесячно. После каждого уведомления напоминание переносится автоматически. Интервальные уведомления @@ -520,4 +518,17 @@ У «Мои Заметки» появился преемник — Encly, полностью зашифрованные заметки, которые работают офлайн. Все заметки можно перенести в него одним касанием. Подробнее Позже + Поиск + Параметры просмотра + Параметры просмотра. Заметки показаны списком + Параметры просмотра. Заметки показаны сеткой + Сортировать по последнему изменению + Сначала новые + Сначала старые + Вид + Список + Сетка + Режим чтения + Вернуться к редактированию + Напоминание: %1$s. Изменить напоминание diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 1e4143e1..953cb9f5 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -14,8 +14,7 @@ Відкрити контакти розробника Цей вміст не підтримується. Відсутній Play Market - Форматувати нотатки - Пошук у нотатках + Пошук у нотатках Закрити Додати тег Видалити @@ -100,7 +99,6 @@ Відновлення даних успішно завершено Помилка: Резервна копія пошкоджена Помилка сервера або погана якість інтернету - Сортувати нотатки Шановний користувач Розробник проекту каже вам спасибі за вашу покупку. Ми будемо прагнути стати кращими і зручнішими. \n З повагою, мої нотатки! @@ -171,9 +169,9 @@ Пошук Використовуйте панель пошуку для швидкого знаходження ваших нотаток за назвою або змістом Сортування - Сортуйте ваші нотатки за датою останньої зміни або назвою у висхідному чи спадному порядку + Натисніть кнопку параметрів перегляду в рядку пошуку, щоб показувати нещодавно змінені нотатки першими або останніми. Формат відображення - Переключайтеся між списком та сіткою для відображення ваших нотаток у зручному для вас макеті + У тих самих параметрах перегляду перемикайтеся між списком і сіткою. Жести свайп @@ -249,7 +247,7 @@ Нагадування Нагадування для нотаток - Відкрийте будь-яку нотатку та натисніть іконку дзвіночка, щоб встановити нагадування. Виберіть час за допомогою швидких варіантів або вручну. Сповіщення з\'явиться у вказаний час. + Відкрийте нотатку, натисніть «Більше» й виберіть «Нагадування». Виберіть час за допомогою швидких варіантів або вручну. Нагадування з\'явиться вгорі нотатки, натисніть на нього, щоб змінити. Сповіщення з\'явиться у вказаний час. Параметри повторення При встановленні нагадування для нотатки увімкніть перемикач «Повторення», щоб вибрати цикл: щодня, щотижня або щомісяця. Нагадування автоматично переноситься після кожного спрацювання. Інтервальні сповіщення @@ -524,4 +522,17 @@ У «Мої Нотатки» з\'явився наступник — Encly, повністю зашифровані нотатки, що працюють офлайн. Усі нотатки можна перенести в нього одним дотиком. Детальніше Пізніше + Пошук + Параметри перегляду + Параметри перегляду. Нотатки показано списком + Параметри перегляду. Нотатки показано сіткою + Сортувати за останньою зміною + Спочатку нові + Спочатку старі + Вигляд + Список + Сітка + Режим читання + Повернутися до редагування + Нагадування: %1$s. Змінити нагадування diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2de39749..2ef3da84 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,10 +12,9 @@ To exit, you need to repeat the action Missing Play Market app This content is not supported. - Format notes - Search notes + Search notes Close Add Tag Delete @@ -117,7 +116,6 @@ Data restore completed successfully Error: Backup corrupted Server error or poor internet quality - Sort notes Dear User @@ -188,9 +186,9 @@ Search Use the search bar to quickly find your notes by title or content Sorting - Sort your notes by last modified date or name in ascending or descending order + Tap the view options button in the search bar to show recently edited notes first or last. Display Format - Switch between list and grid to display your notes in a layout convenient for you + In the same view options, switch between a list and a grid. Swipe Gestures @@ -253,7 +251,7 @@ Reminders Note Reminders - Open any note and tap the bell icon to set a reminder. Pick a time using quick options or the date and time picker. A notification will appear at the scheduled time. + Open a note, tap More and choose Reminder. Pick a time using quick options or the date and time picker. The reminder appears at the top of the note, tap it to change it. A notification will appear at the scheduled time. Repeat Options When setting a note reminder, enable the Repeat toggle to choose a repeat cycle: Daily, Weekly, or Monthly. The reminder reschedules automatically after each notification. Interval Notifications @@ -552,4 +550,17 @@ My Notes has a successor: Encly, a fully encrypted notes app that works offline. You can move all your notes to it in one tap. Learn more Later + Search + View options + View options. Notes are shown as a list + View options. Notes are shown as a grid + Sort by last edit + Newest first + Oldest first + Layout + List + Grid + Reading mode + Back to editing + Reminder: %1$s. Change reminder diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index a8ca6cdc..62cf6773 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -125,6 +125,16 @@ + + + - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/com/pasich/mynotes/utils/UpdateCheckerMeetEnclyTest.java b/app/src/test/java/com/pasich/mynotes/utils/UpdateCheckerMeetEnclyTest.java new file mode 100644 index 00000000..67025568 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/UpdateCheckerMeetEnclyTest.java @@ -0,0 +1,70 @@ +package com.pasich.mynotes.utils; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import com.pasich.mynotes.cache.AppPreferencesCache; +import com.pasich.mynotes.data.preferences.SafePreferences; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** The one-time "Meet Encly" sheet: shown on the first start after an update, then never again. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class UpdateCheckerMeetEnclyTest { + + private Context app; + + @Before + public void setUp() { + app = RuntimeEnvironment.getApplication(); + } + + /** A fresh checker over the same stored preferences, as after a process restart. */ + private UpdateChecker start(String installedVersion) throws Exception { + PackageInfo info = new PackageInfo(); + info.versionName = installedVersion; + PackageManager packageManager = mock(PackageManager.class); + when(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(info); + Context context = mock(Context.class); + when(context.getPackageManager()).thenReturn(packageManager); + when(context.getPackageName()).thenReturn("com.pasich.mynotes"); + return new UpdateChecker(context, new AppPreferencesCache(new SafePreferences(app))); + } + + @Test + public void firstStartAfterUpdate_showsOnce_thenNeverAgain() throws Exception { + // The previous version was started and its changelog read. + start("2.6.55").markVersionAsRead(); + + UpdateChecker updated = start("2.7.0"); + assertThat(updated.shouldShowMeetEncly()).isTrue(); + updated.markMeetEnclyShown(); + assertThat(updated.shouldShowMeetEncly()).isFalse(); + + // Restarts, and later updates, never bring it back. + assertThat(start("2.7.0").shouldShowMeetEncly()).isFalse(); + assertThat(start("2.8.0").shouldShowMeetEncly()).isFalse(); + } + + @Test + public void openingThePageFirst_marksTheIntroductionSeen() throws Exception { + // The previous version was started and its changelog read. + start("2.6.55").markVersionAsRead(); + + // MeetEnclyActivity marks it on open, before MainActivity could show the sheet. + start("2.7.0").markMeetEnclyShown(); + + assertThat(start("2.7.0").shouldShowMeetEncly()).isFalse(); + } +} From 5c07e18a1404ce7b0f5b0eee7b79af7ea4b61f39 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 12:57:56 +0300 Subject: [PATCH 16/43] fix(main): show the notes again after leaving an empty tag Switching from a tag with no notes back to All notes (or to another tag) left the list blank: the notes were there, but none was laid out until the app was restarted. On a device the list had all its items and no child views, and a forced layout pass did not help. When a swap brought in an empty list, the grid's columns were reset and it was scrolled to the top while it had no items. That left the StaggeredGridLayoutManager anchored wrong, so the next non-empty list was laid out below the screen. The column reset and the jump to the top now happen only when there are notes to show, also when the layout switches between list and grid on an empty tag. Refs: #170 --- .../MainRenderListsController.java | 18 ++++++++++ .../ui/view/activity/MainActivity.java | 12 +++---- .../main/MainRenderListsControllerTest.java | 33 +++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/MainRenderListsController.java b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/MainRenderListsController.java index 4f264821..303999a1 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/MainRenderListsController.java +++ b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/MainRenderListsController.java @@ -141,6 +141,24 @@ public void swapListContent(boolean animate, @NonNull Runnable submit) { listFader.dim(true, LIST_DIM_MS, new AccelerateInterpolator(), submit); } + /** + * Readies the list for content just swapped in behind the fade: rebuilds the grid columns from + * scratch so the grid comes back without gaps and, when {@code toTop}, puts the first note at + * the top. + * + *

Does nothing for an empty list. Resetting the spans of a {@link + * StaggeredGridLayoutManager} and asking it to scroll while it has no items leaves it anchored + * wrong, and the next non-empty content was laid out below the screen: switching from an empty + * tag back to all notes showed a blank list until the app was restarted. + */ + public void prepareSwappedList(int notesCount, boolean toTop) { + if (notesCount == 0) return; + if (listNotes.getLayoutManager() instanceof StaggeredGridLayoutManager grid) { + grid.invalidateSpanAssignments(); + } + if (toTop) jumpToTop(); + } + /** Smoothly scrolls the notes list to the top. */ public void scrollUpNoteList() { listNotes.post(() -> listNotes.smoothScrollToPosition(0)); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java index 24ca4d85..b83261f9 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java @@ -655,7 +655,7 @@ private void commitListSwap() { List notes = swapNotes; swapNotes = null; if (notes == null) { - gridLayoutManager.invalidateSpanAssignments(); + mainRenderListsController.prepareSwappedList(mNoteAdapter.getItemCount(), false); mainRenderListsController.showStateNoteList( currentSelectedTag, mNoteAdapter.getItemCount(), isListInteractive()); return; @@ -665,12 +665,10 @@ private void commitListSwap() { notes, () -> { // Nothing is visible here: rebuild the columns from scratch so the grid - // comes back without gaps. - gridLayoutManager.invalidateSpanAssignments(); - if (swapToTop) { - swapToTop = false; - mainRenderListsController.jumpToTop(); - } + // comes back without gaps (not while empty, see prepareSwappedList). + boolean toTop = swapToTop; + swapToTop = false; + mainRenderListsController.prepareSwappedList(notes.size(), toTop); mainRenderListsController.showStateNoteList( tag, notes.size(), isListInteractive()); }); diff --git a/app/src/test/java/com/pasich/mynotes/ui/main/MainRenderListsControllerTest.java b/app/src/test/java/com/pasich/mynotes/ui/main/MainRenderListsControllerTest.java index 26bbb255..54a1da61 100644 --- a/app/src/test/java/com/pasich/mynotes/ui/main/MainRenderListsControllerTest.java +++ b/app/src/test/java/com/pasich/mynotes/ui/main/MainRenderListsControllerTest.java @@ -20,6 +20,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.Mockito; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; @@ -158,4 +159,36 @@ public void tagsRowCollapseThenExpand_endsVisible() { assertThat(tags.getVisibility()).isEqualTo(View.VISIBLE); assertThat(tags.getAlpha()).isEqualTo(1f); } + + @Test + public void swappingInAnEmptyList_leavesTheGridAlone() { + // Resetting the spans of a StaggeredGridLayoutManager or scrolling it while it has no + // items left it anchored wrong on a device: the next list was laid out below the screen. + StaggeredGridLayoutManager grid = + Mockito.spy(new StaggeredGridLayoutManager(1, StaggeredGridLayoutManager.VERTICAL)); + list.setLayoutManager(grid); + Mockito.clearInvocations(grid); + + controller.prepareSwappedList(0, true); + + Mockito.verify(grid, Mockito.never()).invalidateSpanAssignments(); + Mockito.verify(grid, Mockito.never()) + .scrollToPositionWithOffset(Mockito.anyInt(), Mockito.anyInt()); + } + + @Test + public void swappingInNotes_rebuildsTheColumnsAndJumpsToTheTopWhenAsked() { + StaggeredGridLayoutManager grid = + Mockito.spy(new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)); + list.setLayoutManager(grid); + Mockito.clearInvocations(grid); + + controller.prepareSwappedList(14, false); + Mockito.verify(grid).invalidateSpanAssignments(); + Mockito.verify(grid, Mockito.never()) + .scrollToPositionWithOffset(Mockito.anyInt(), Mockito.anyInt()); + + controller.prepareSwappedList(14, true); + Mockito.verify(grid).scrollToPositionWithOffset(0, 0); + } } From 50d3261fb5ff42e334c756c76af5643f6c035c2d Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 14:39:11 +0300 Subject: [PATCH 17/43] feat(editor): move undo and redo to a bar above the keyboard Undo and Redo took two of the few places in the note toolbar, and in the extended editor they crowded Back, the title, the save status, Read and More. They now sit on a slim bar docked on top of the soft keyboard, the same EditorKeyboardBar in both editors: Undo and Redo at the start, Hide keyboard at the end, and room between them for formatting actions later. The toolbar is back to Back, the title, the save status, Read in the extended editor, and More. The bar is shown only while a note is edited and the on-screen keyboard is up, so it is absent in reading mode and with a hardware keyboard, where Ctrl+Z, Ctrl+Shift+Z and Ctrl+Y keep working. It is a 48dp row of 48dp icon buttons under a 1dp hairline in colorOutlineVariant, on colorSurfaceContainer; icons are colorOnSurfaceVariant and colorOnSurface at 38% when there is nothing to undo or redo. The buttons carry content descriptions and tooltips, and the arrows are auto-mirrored. The bar is laid out where the keyboard ends up and translated frame by frame with a WindowInsetsAnimationCompat callback, so it rides on the keyboard as it slides in and out instead of jumping ahead of it; with animations off the keyboard and the bar simply appear. Both editors end their content above the keyboard plus the bar: the simple editor's scroll view and caret scrolling, and the extended editor's WebView, which is now sized by a bottom margin instead of padding the root, so the page's own resize handling keeps the caret line above the bar. Side system bars and cutouts in landscape keep the buttons clear of them. Without the keyboard, Undo and Redo are in More while a note is edited, dimmed and disabled like the bar; the sheet stays open and follows the history, so several steps can be taken back in a row. Refs: #173 --- CHANGELOG.md | 22 +- .../base/view/MoreNoteNoteActivityView.java | 23 ++ .../noteEditor/BaseNoteEditorActivity.java | 131 +++++++--- .../activity/noteEditor/NoteActivity.java | 25 +- .../NoteExtendedEditorActivity.java | 34 ++- .../ui/view/dialogs/EditHistoryRows.java | 46 ++++ .../ui/view/dialogs/MoreNoteDialog.java | 23 ++ .../ui/view/widgets/EditorKeyboardBar.java | 241 ++++++++++++++++++ .../utils/editor/KeyboardBarState.java | 61 +++++ .../res/color/editor_keyboard_bar_icon.xml | 7 + .../main/res/drawable/ic_keyboard_hide.xml | 10 + app/src/main/res/layout/activity_note.xml | 6 + .../layout/activity_note_extended_editor.xml | 9 + app/src/main/res/layout/dialog_more_note.xml | 56 ++++ .../res/layout/view_editor_keyboard_bar.xml | 60 +++++ .../res/menu/menu_activity_toolbar_note.xml | 23 +- .../menu_activity_toolbar_note_extendes.xml | 23 +- app/src/main/res/values-be/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-en-rGB/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-kk/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values/dimens.xml | 4 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/styles.xml | 16 ++ .../noteEditor/NoteToolbarMenuTest.java | 66 +++++ .../ui/view/dialogs/EditHistoryRowsTest.java | 159 ++++++++++++ .../view/widgets/EditorKeyboardBarTest.java | 181 +++++++++++++ .../utils/editor/KeyboardBarStateTest.java | 79 ++++++ 34 files changed, 1210 insertions(+), 106 deletions(-) create mode 100644 app/src/main/java/com/pasich/mynotes/ui/view/dialogs/EditHistoryRows.java create mode 100644 app/src/main/java/com/pasich/mynotes/ui/view/widgets/EditorKeyboardBar.java create mode 100644 app/src/main/java/com/pasich/mynotes/utils/editor/KeyboardBarState.java create mode 100644 app/src/main/res/color/editor_keyboard_bar_icon.xml create mode 100644 app/src/main/res/drawable/ic_keyboard_hide.xml create mode 100644 app/src/main/res/layout/view_editor_keyboard_bar.xml create mode 100644 app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteToolbarMenuTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/ui/view/dialogs/EditHistoryRowsTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/ui/view/widgets/EditorKeyboardBarTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/utils/editor/KeyboardBarStateTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c506b8..0360369e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,13 +41,15 @@ and back, and it is saved in local backups. It stays on this device: moving a note is not an edit, so it never reaches Google Drive and never changes the order on your other devices. With TalkBack, each note offers Move up and Move down. -- **Undo and Redo.** While you edit a note, Undo and Redo sit in the toolbar of both editors and - are greyed out when there is nothing to take back or bring back. They step through your typing - a word at a time, and through deletions, pastes, formatting and blocks you add, remove or move, - putting the cursor back where it was. On a keyboard, Ctrl+Z undoes and Ctrl+Shift+Z or Ctrl+Y - redoes. An undo is saved like any other edit, and saving never clears the history. It belongs - to the note you are editing: it starts afresh whenever a note is opened, copied or restored - from its version history, and in the simple editor it also survives turning the screen. +- **Undo and Redo.** While you edit a note, Undo and Redo sit on a slim bar right above the + keyboard in both editors; it moves with the keyboard and also has a button to put the keyboard + away. With the keyboard closed, Undo and Redo are in More. They are greyed out when there is + nothing to take back or bring back. They step through your typing a word at a time, and through + deletions, pastes, formatting and blocks you add, remove or move, putting the cursor back where + it was. On a keyboard, Ctrl+Z undoes and Ctrl+Shift+Z or Ctrl+Y redoes. An undo is saved like + any other edit, and saving never clears the history. It belongs to the note you are editing: it + starts afresh whenever a note is opened, copied or restored from its version history, and in the + simple editor it also survives turning the screen. **Improvements** @@ -55,9 +57,9 @@ under one View options button, which opens a sheet where both can be changed in one visit; the button shows the layout you are using. The search hint no longer gets cut off with large text, and the menu puts Tags and Trash first, then Tasks, with a single way to support the developer. -- The note toolbar keeps only Back, the save status and More, plus Undo and Redo while you edit. - An upcoming reminder now shows as a chip at the top of the note with its day and time; tap it to - change or remove the reminder. New reminders are set from More. +- The note toolbar keeps only Back, the save status and More. An upcoming reminder now shows as a + chip at the top of the note with its day and time; tap it to change or remove the reminder. New + reminders are set from More. - Search puts the best match first. A note whose title is exactly what you typed comes first, then titles that start with it, contain it as a word or contain it at all, then notes whose tag matches, and only then notes that mention it in their text; pinned and newer notes lead diff --git a/app/src/main/java/com/pasich/mynotes/base/view/MoreNoteNoteActivityView.java b/app/src/main/java/com/pasich/mynotes/base/view/MoreNoteNoteActivityView.java index 74718ac5..16339877 100644 --- a/app/src/main/java/com/pasich/mynotes/base/view/MoreNoteNoteActivityView.java +++ b/app/src/main/java/com/pasich/mynotes/base/view/MoreNoteNoteActivityView.java @@ -1,5 +1,7 @@ package com.pasich.mynotes.base.view; +import androidx.annotation.Nullable; + public interface MoreNoteNoteActivityView { void changeTextStyle(); @@ -18,4 +20,25 @@ public interface MoreNoteNoteActivityView { /** Saves what is pending and opens the note's local version history. */ void openVersionHistory(); + + /** Whether the note is being edited, so Undo and Redo apply to it. */ + boolean isEditingNote(); + + /** Whether there is an edit to take back. */ + boolean canUndoEdit(); + + /** Whether there is an undone edit to apply again. */ + boolean canRedoEdit(); + + /** Takes back the last edit. */ + void undoLastEdit(); + + /** Applies the last undone edit again. */ + void redoLastEdit(); + + /** + * Runs {@code observer} whenever editing starts or stops or what Undo and Redo can do changes; + * {@code null} stops it. + */ + void setEditHistoryObserver(@Nullable Runnable observer); } diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java index 2f6d4921..dfbb6af0 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java @@ -5,7 +5,6 @@ import android.content.Intent; import android.content.res.Configuration; -import android.graphics.drawable.Drawable; import android.os.Bundle; import android.text.format.DateUtils; import android.util.Log; @@ -20,8 +19,10 @@ import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.MenuRes; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.widget.Toolbar; import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsControllerCompat; import androidx.viewbinding.ViewBinding; import com.google.android.material.chip.Chip; @@ -36,6 +37,7 @@ import com.pasich.mynotes.ui.view.activity.NoteHistoryActivity; import com.pasich.mynotes.ui.view.dialogs.MoreNoteDialog; import com.pasich.mynotes.ui.view.dialogs.ReminderPickerBottomSheet; +import com.pasich.mynotes.ui.view.widgets.EditorKeyboardBar; import com.pasich.mynotes.utils.enums.SaveState; import com.pasich.mynotes.utils.navigation.NoteExtras; import com.pasich.mynotes.utils.reminder.RepeatRuleFormatter; @@ -48,18 +50,15 @@ public abstract class BaseNoteEditorActivity extends Base @Inject protected NoteContract.presenter notePresenter; - /** Alpha of a disabled toolbar icon, the Material 38%. */ - private static final int DISABLED_ICON_ALPHA = 97; - // Menu for the save status indicator protected MenuItem saveStatusMenuItem; - private MenuItem undoMenuItem; - private MenuItem redoMenuItem; - // Undo and Redo show while the note is being edited and are enabled while there is something - // to take back or apply again. - private boolean undoRedoShown = false; + // Undo and Redo apply while the note is being edited and are enabled while there is something + // to take back or apply again. They live on the bar above the keyboard and in More. + private boolean editing = false; private boolean canUndo = false; private boolean canRedo = false; + // The More sheet, while it is open, follows the history state. + @Nullable private Runnable editHistoryObserver; protected T binding; protected long idNote; @@ -83,6 +82,9 @@ public abstract class BaseNoteEditorActivity extends Base protected abstract Toolbar getToolbar(); + /** The bar docked above the keyboard, with Undo, Redo and Hide keyboard. */ + protected abstract EditorKeyboardBar getKeyboardBar(); + /** The chip at the top of the note that shows the active reminder. */ protected abstract Chip getReminderChip(); @@ -131,6 +133,7 @@ public void onCreate(Bundle savedInstanceState) { setContentView(binding.getRoot()); applyEdgeToEdgeInsets(binding.getRoot()); + setupKeyboardBar(); bindingSetPresenter(binding); @@ -199,17 +202,49 @@ private void setupSharedTransition(View layout, long id) { public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(getMenuResId(), menu); saveStatusMenuItem = menu.findItem(R.id.saveStatusBut); - undoMenuItem = menu.findItem(R.id.actionUndo); - redoMenuItem = menu.findItem(R.id.actionRedo); - applyUndoRedoState(); return true; } - /** Shows Undo and Redo while the note is edited, hides them while it is read. */ - protected void setUndoRedoShown(boolean shown) { - if (undoRedoShown == shown) return; - undoRedoShown = shown; - applyUndoRedoState(); + private void setupKeyboardBar() { + EditorKeyboardBar bar = getKeyboardBar(); + if (bar == null) return; + bar.setActions( + new EditorKeyboardBar.Actions() { + @Override + public void onUndo() { + undoEdit(); + } + + @Override + public void onRedo() { + redoEdit(); + } + + @Override + public void onHideKeyboard() { + hideKeyboard(); + } + }); + bar.setEditing(editing); + bar.setHistoryState(canUndo, canRedo); + } + + /** Puts the on-screen keyboard away; the note stays in editing mode. */ + protected void hideKeyboard() { + WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()) + .hide(WindowInsetsCompat.Type.ime()); + } + + /** + * The note is being edited (true) or read. Undo and Redo, on the bar above the keyboard and in + * More, and their shortcuts apply only while editing. + */ + protected void setEditing(boolean editing) { + if (this.editing == editing) return; + this.editing = editing; + EditorKeyboardBar bar = binding != null ? getKeyboardBar() : null; + if (bar != null) bar.setEditing(editing); + notifyEditHistoryObserver(); } /** Enables Undo and Redo according to the editor's history. */ @@ -217,22 +252,43 @@ protected void setUndoRedoState(boolean canUndo, boolean canRedo) { if (this.canUndo == canUndo && this.canRedo == canRedo) return; this.canUndo = canUndo; this.canRedo = canRedo; - applyUndoRedoState(); + EditorKeyboardBar bar = binding != null ? getKeyboardBar() : null; + if (bar != null) bar.setHistoryState(canUndo, canRedo); + notifyEditHistoryObserver(); } - private void applyUndoRedoState() { - applyHistoryItem(undoMenuItem, canUndo); - applyHistoryItem(redoMenuItem, canRedo); + private void notifyEditHistoryObserver() { + if (editHistoryObserver != null) editHistoryObserver.run(); } - private void applyHistoryItem(MenuItem item, boolean enabled) { - if (item == null) return; - item.setVisible(undoRedoShown); - item.setEnabled(enabled); - // A disabled action button keeps its icon's colour; dim it so the state is visible. The - // menu has already mutated the icon to tint it, so this changes only this item. - Drawable icon = item.getIcon(); - if (icon != null) icon.setAlpha(enabled ? 255 : DISABLED_ICON_ALPHA); + @Override + public boolean isEditingNote() { + return editing; + } + + @Override + public boolean canUndoEdit() { + return editing && canUndo; + } + + @Override + public boolean canRedoEdit() { + return editing && canRedo; + } + + @Override + public void undoLastEdit() { + if (editing) undoEdit(); + } + + @Override + public void redoLastEdit() { + if (editing) redoEdit(); + } + + @Override + public void setEditHistoryObserver(@Nullable Runnable observer) { + editHistoryObserver = observer; } /** @@ -248,7 +304,7 @@ public boolean onKeyShortcut(int keyCode, KeyEvent event) { /** Runs Undo or Redo for their shortcut; returns whether the key was one of them. */ protected boolean onHistoryShortcut(int keyCode, KeyEvent event) { if (event.getAction() != KeyEvent.ACTION_DOWN) return false; - if (!undoRedoShown || !event.isCtrlPressed() || event.isAltPressed()) return false; + if (!editing || !event.isCtrlPressed() || event.isAltPressed()) return false; if (keyCode == KeyEvent.KEYCODE_Z) { if (event.isShiftPressed()) redoEdit(); else undoEdit(); @@ -333,15 +389,6 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { notePresenter.closeActivity(); } - if (item.getItemId() == R.id.actionUndo) { - undoEdit(); - return true; - } - if (item.getItemId() == R.id.actionRedo) { - redoEdit(); - return true; - } - if (item.getItemId() == R.id.moreBut) { if (notePresenter.hasNote()) { MoreNoteDialog dialog = @@ -412,7 +459,11 @@ public void updateSaveStatus(SaveState saveState) { @Override public void onDestroy() { - if (binding != null) getReminderChip().setOnClickListener(null); + if (binding != null) { + getReminderChip().setOnClickListener(null); + getKeyboardBar().setActions(null); + } + editHistoryObserver = null; super.onDestroy(); if (notePresenter != null) { ((NotePresenter) notePresenter).cleanupHandlers(); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java index 905b26f9..7a3dfb7c 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java @@ -34,6 +34,7 @@ import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.databinding.ActivityNoteBinding; import com.pasich.mynotes.ui.presenter.NotePresenter; +import com.pasich.mynotes.ui.view.widgets.EditorKeyboardBar; import com.pasich.mynotes.utils.editor.EditableLinkMovementMethod; import com.pasich.mynotes.utils.editor.EditorCursor; import com.pasich.mynotes.utils.editor.NoteViewState; @@ -334,6 +335,11 @@ protected Chip getReminderChip() { return binding.reminderChip; } + @Override + protected EditorKeyboardBar getKeyboardBar() { + return binding.keyboardBar; + } + @Override protected ActivityNoteBinding inflateBinding(LayoutInflater inflater) { return ActivityNoteBinding.inflate(inflater); @@ -433,7 +439,10 @@ private void scrollToCursor() { Insets systemInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()); int visibleHeight = - binding.getRoot().getHeight() - imeInsets.bottom - systemInsets.top; + binding.getRoot().getHeight() + - imeInsets.bottom + - systemInsets.top + - binding.keyboardBar.getReservedHeight(); int lineHeight = layout.getLineBottom(line) - layout.getLineTop(line); int lineVisibleTop = absoluteLineTop - currentScrollY; @@ -493,9 +502,10 @@ private void updateScrollProgress(int scrollY) { /** * Handles system insets and the keyboard: the root takes the status bar, the FAB clears the - * navigation bar, and the scroll view ends above the keyboard or the navigation bar. When the - * keyboard opens, the caret is brought into view; when it closes nothing is moved, so the text - * stays where the user left it. + * navigation bar, the bar with Undo and Redo sits on the keyboard, and the scroll view ends + * above that bar, or above the navigation bar while the keyboard is down. When the keyboard + * opens, the caret is brought into view; when it closes nothing is moved, so the text stays + * where the user left it. */ @Override protected void applyEdgeToEdgeInsets(View rootView) { @@ -524,7 +534,10 @@ protected void applyEdgeToEdgeInsets(View rootView) { v.setPadding(v.getPaddingLeft(), systemBars.top, v.getPaddingRight(), 0); - int bottomMargin = Math.max(imeInsets.bottom, systemBars.bottom); + binding.keyboardBar.onWindowInsets(insets); + int bottomMargin = + Math.max(imeInsets.bottom, systemBars.bottom) + + binding.keyboardBar.getReservedHeight(); android.widget.LinearLayout.LayoutParams params = (android.widget.LinearLayout.LayoutParams) binding.scrollView.getLayoutParams(); @@ -811,7 +824,7 @@ && isOnScreen(savedSelectionStart, firstVisible, lastVisible)) { restoredSelectionEnd = EditorCursor.NONE; binding.setActivateEdit(true); - setUndoRedoShown(true); + setEditing(true); binding.valueNote.setEnabled(true); binding.valueNote.setFocusable(true); binding.valueNote.setFocusableInTouchMode(true); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java index 1eb49644..79c091e1 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java @@ -37,6 +37,7 @@ import com.pasich.mynotes.extendedEditor.view.CopyTextDialog; import com.pasich.mynotes.ui.presenter.NotePresenter; import com.pasich.mynotes.ui.view.activity.PhotoViewActivity; +import com.pasich.mynotes.ui.view.widgets.EditorKeyboardBar; import com.pasich.mynotes.utils.editor.NoteViewState; import com.pasich.mynotes.utils.editor.NoteViewStateStore; import com.pasich.mynotes.utils.editor.PositionRestorer; @@ -122,6 +123,7 @@ public void onCreate(Bundle savedInstanceState) { if (restoreSavedPosition) binding.noteEditor.setAutofocus(false); binding.noteEditor.setFocusStart(editOnOpen); } + setEditing(!isReadMode); if (savedInstanceState != null && binding != null) { // A picker opened by the previous instance answers this one. binding.noteEditor.restoreChooserState( @@ -196,6 +198,11 @@ protected Chip getReminderChip() { return binding.reminderChip; } + @Override + protected EditorKeyboardBar getKeyboardBar() { + return binding.keyboardBar; + } + @Override protected ActivityNoteExtendedEditorBinding inflateBinding(LayoutInflater inflater) { return ActivityNoteExtendedEditorBinding.inflate(inflater); @@ -343,10 +350,11 @@ protected void setNewNoteTitle() { } /** - * The window draws edge to edge, so the keyboard does not resize it: the root is padded by the - * keyboard or the navigation bar, whichever is taller. Without it the keyboard covered the - * lower half of the editor and the caret typed out of sight; the editor page keeps the caret - * visible when its height changes. + * The window draws edge to edge, so the keyboard does not resize it: the editor ends above the + * keyboard or the navigation bar, whichever is taller, and above the bar with Undo and Redo + * that sits on the keyboard while editing. Without it the keyboard covered the lower half of + * the editor and the caret typed out of sight; the editor page keeps the caret visible when its + * height changes, so the caret line stays above the bar. */ @Override protected void applyEdgeToEdgeInsets(View rootView) { @@ -355,8 +363,18 @@ protected void applyEdgeToEdgeInsets(View rootView) { (v, insets) -> { Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); Insets ime = insets.getInsets(WindowInsetsCompat.Type.ime()); - int bottom = Math.max(ime.bottom, systemBars.bottom); - v.setPadding(v.getPaddingLeft(), systemBars.top, v.getPaddingRight(), bottom); + // The bottom is left to the editor and the bar, which follows the keyboard. + v.setPadding(v.getPaddingLeft(), systemBars.top, v.getPaddingRight(), 0); + binding.keyboardBar.onWindowInsets(insets); + int bottom = + Math.max(ime.bottom, systemBars.bottom) + + binding.keyboardBar.getReservedHeight(); + ViewGroup.MarginLayoutParams params = + (ViewGroup.MarginLayoutParams) binding.noteEditor.getLayoutParams(); + if (params.bottomMargin != bottom) { + params.bottomMargin = bottom; + binding.noteEditor.setLayoutParams(params); + } return insets; }); } @@ -455,10 +473,10 @@ public boolean onCreateOptionsMenu(Menu menu) { /** * Shows the action that leaves the current mode: Edit while reading, Read while editing. Undo - * and Redo are there only while editing. + * and Redo, on the bar above the keyboard and in More, apply only while editing. */ private void updateReadModeItem() { - setUndoRedoShown(!isReadMode); + setEditing(!isReadMode); if (readModeItem == null) return; readModeItem.setIcon(isReadMode ? R.drawable.ic_edit : R.drawable.ic_read); readModeItem.setTitle(isReadMode ? R.string.read_mode_exit : R.string.read_mode_enter); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/EditHistoryRows.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/EditHistoryRows.java new file mode 100644 index 00000000..c5469b00 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/EditHistoryRows.java @@ -0,0 +1,46 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.pasich.mynotes.base.view.MoreNoteNoteActivityView; + +/** + * Undo and Redo in the editor's More sheet. They are there while the note is being edited, so the + * history stays reachable with the keyboard (and the bar above it) put away, and are dimmed and + * disabled when there is nothing to take back or bring back. + */ +final class EditHistoryRows { + + /** The Material 3 disabled content opacity. */ + static final float DISABLED_ALPHA = 0.38f; + + private EditHistoryRows() {} + + /** Shows the rows while {@code editor} edits a note and enables them by its history. */ + static void apply( + @NonNull View group, + @NonNull View undo, + @NonNull View redo, + @Nullable MoreNoteNoteActivityView editor) { + boolean editing = editor != null && editor.isEditingNote(); + group.setVisibility(editing ? View.VISIBLE : View.GONE); + setRowEnabled(undo, editing && editor.canUndoEdit()); + setRowEnabled(redo, editing && editor.canRedoEdit()); + } + + /** Disables a row and dims its icon and label; the row's background keeps its colour. */ + static void setRowEnabled(@NonNull View row, boolean enabled) { + row.setEnabled(enabled); + float alpha = enabled ? 1f : DISABLED_ALPHA; + if (row instanceof ViewGroup) { + ViewGroup group = (ViewGroup) row; + for (int i = 0; i < group.getChildCount(); i++) { + group.getChildAt(i).setAlpha(alpha); + } + } else { + row.setAlpha(alpha); + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java index d99121db..0c2e6cdd 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java @@ -288,6 +288,19 @@ public void onStopTrackingTouch(@NonNull Slider slider) { binding.quickCopy.setVisibility(GONE); } + // The sheet stays open, so Undo can be pressed several times; the rows follow the + // history as it changes. + binding.moreUndo.setOnClickListener( + v -> { + if (noteActivity != null) noteActivity.undoLastEdit(); + }); + binding.moreRedo.setOnClickListener( + v -> { + if (noteActivity != null) noteActivity.redoLastEdit(); + }); + if (noteActivity != null) noteActivity.setEditHistoryObserver(this::applyEditHistory); + applyEditHistory(); + } else { binding.actionPanelActivate.setOnClickListener( view -> { @@ -355,6 +368,13 @@ public void onStopTrackingTouch(@NonNull Slider slider) { }); } + /** Shows Undo and Redo while the note is edited and enables them by its history. */ + private void applyEditHistory() { + if (binding == null) return; + EditHistoryRows.apply( + binding.editHistoryGroup, binding.moreUndo, binding.moreRedo, noteActivity); + } + @Override public void onDismiss(@NonNull DialogInterface dialog) { super.onDismiss(dialog); @@ -367,6 +387,9 @@ public void onDismiss(@NonNull DialogInterface dialog) { binding.quickTranslate.setOnClickListener(null); binding.quickPin.setOnClickListener(null); binding.settingsActivity.textStyleItem.setOnClickListener(null); + binding.moreUndo.setOnClickListener(null); + binding.moreRedo.setOnClickListener(null); + if (noteActivity != null) noteActivity.setEditHistoryObserver(null); noteActivity = null; } else { mainActivity = null; diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/widgets/EditorKeyboardBar.java b/app/src/main/java/com/pasich/mynotes/ui/view/widgets/EditorKeyboardBar.java new file mode 100644 index 00000000..19fc95bb --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/widgets/EditorKeyboardBar.java @@ -0,0 +1,241 @@ +package com.pasich.mynotes.ui.view.widgets; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationCompat; +import androidx.core.view.WindowInsetsCompat; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.color.MaterialColors; +import com.pasich.mynotes.R; +import com.pasich.mynotes.utils.editor.KeyboardBarState; +import java.util.List; + +/** + * The bar docked above the on-screen keyboard in both note editors: Undo and Redo at the start, + * Hide keyboard at the end, room for further actions between them. + * + *

It is shown only while the note is edited and the keyboard is up (see {@link + * KeyboardBarState}). The host places it at the bottom of its root, which must not be padded at the + * bottom, and passes every window insets dispatch to {@link #onWindowInsets}; the bar then sits on + * top of the keyboard, or of the navigation bar, and the host keeps its content clear of it by + * {@link #getReservedHeight()}. + * + *

While the keyboard slides in or out the bar is laid out where it ends and translated to follow + * the keyboard frame by frame, so it moves with the keyboard instead of jumping ahead of it. With + * animations turned off the keyboard has no animation and neither has the bar. + */ +public class EditorKeyboardBar extends LinearLayout { + + /** What the bar's buttons ask the editor to do. */ + public interface Actions { + void onUndo(); + + void onRedo(); + + void onHideKeyboard(); + } + + private final KeyboardBarState state = new KeyboardBarState(); + private View row; + private MaterialButton undoButton; + private MaterialButton redoButton; + private MaterialButton hideButton; + @Nullable private Actions actions; + + // Distance from the bottom of the parent to the bottom of the bar when it rests: the keyboard, + // or the navigation bar while the keyboard is down. + private int restingOffset = 0; + // A keyboard animation is running; hiding the bar waits for its end so the bar goes down with + // the keyboard rather than vanishing above it. + private boolean imeAnimating = false; + + public EditorKeyboardBar(Context context) { + super(context); + init(context); + } + + public EditorKeyboardBar(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(context); + } + + public EditorKeyboardBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context); + } + + private void init(Context context) { + setOrientation(VERTICAL); + inflate(context, R.layout.view_editor_keyboard_bar, this); + row = findViewById(R.id.keyboardBarRow); + undoButton = findViewById(R.id.keyboardBarUndo); + redoButton = findViewById(R.id.keyboardBarRedo); + hideButton = findViewById(R.id.keyboardBarHide); + undoButton.setOnClickListener( + v -> { + if (actions != null) actions.onUndo(); + }); + redoButton.setOnClickListener( + v -> { + if (actions != null) actions.onRedo(); + }); + hideButton.setOnClickListener( + v -> { + if (actions != null) actions.onHideKeyboard(); + }); + setBackgroundColor( + MaterialColors.getColor( + this, com.google.android.material.R.attr.colorSurfaceContainer)); + // Touches on the bar's empty space must not reach the note underneath. + setClickable(true); + setVisibility(GONE); + ViewCompat.setWindowInsetsAnimationCallback(this, new FollowKeyboard()); + applyHistory(); + } + + public void setActions(@Nullable Actions actions) { + this.actions = actions; + } + + /** The note is being edited (true) or read. */ + public void setEditing(boolean editing) { + boolean visibilityChanged = state.setEditing(editing); + applyHistory(); + if (visibilityChanged) { + applyVisibility(); + // The host gives the content back, or takes it, in its insets listener. + ViewCompat.requestApplyInsets(this); + } + } + + /** Enables Undo and Redo according to the editor's history. */ + public void setHistoryState(boolean canUndo, boolean canRedo) { + if (state.setHistory(canUndo, canRedo)) applyHistory(); + } + + /** + * Takes the window insets the host has just been given: places the bar on top of the keyboard + * or the navigation bar and shows or hides it with the keyboard. Call it before reading {@link + * #getReservedHeight()}. + */ + public void onWindowInsets(@NonNull WindowInsetsCompat insets) { + Insets bars = + insets.getInsets( + WindowInsetsCompat.Type.systemBars() + | WindowInsetsCompat.Type.displayCutout()); + Insets ime = insets.getInsets(WindowInsetsCompat.Type.ime()); + restingOffset = Math.max(ime.bottom, bars.bottom); + + ViewGroup.LayoutParams params = getLayoutParams(); + if (params instanceof MarginLayoutParams + && ((MarginLayoutParams) params).bottomMargin != restingOffset) { + ((MarginLayoutParams) params).bottomMargin = restingOffset; + setLayoutParams(params); + } + // In landscape the navigation bar or a cutout may sit at a side; the buttons stay clear of + // it while the background still runs edge to edge. + int side = getResources().getDimensionPixelSize(R.dimen.editor_keyboard_bar_side); + row.setPadding(bars.left + side, 0, bars.right + side, 0); + + if (state.setImeVisible(insets.isVisible(WindowInsetsCompat.Type.ime()))) { + applyVisibility(); + } + } + + /** + * Height the host keeps free above the keyboard so the bar never covers the caret line: the + * bar's height while it is shown, otherwise 0. + */ + public int getReservedHeight() { + if (!state.isShown()) return 0; + return getResources().getDimensionPixelSize(R.dimen.editor_keyboard_bar_row_height) + + getResources().getDimensionPixelSize(R.dimen.editor_keyboard_bar_divider); + } + + /** Whether the bar is meant to be on screen (it may still be finishing its way out). */ + public boolean isBarShown() { + return state.isShown(); + } + + @VisibleForTesting + KeyboardBarState getState() { + return state; + } + + private void applyHistory() { + undoButton.setEnabled(state.isUndoEnabled()); + redoButton.setEnabled(state.isRedoEnabled()); + } + + private void applyVisibility() { + if (state.isShown()) { + setVisibility(VISIBLE); + } else if (!imeAnimating) { + setTranslationY(0f); + setVisibility(GONE); + } + } + + /** + * Keeps the bar on top of the keyboard while it animates. The bar is laid out at the keyboard's + * final position as soon as the animation starts, so it is translated by the distance between + * that and the keyboard's current top. + */ + private final class FollowKeyboard extends WindowInsetsAnimationCompat.Callback { + + // Where the bar was drawn when the animation was prepared, measured from the bottom. + private int startOffset; + + FollowKeyboard() { + super(DISPATCH_MODE_CONTINUE_ON_SUBTREE); + } + + @Override + public void onPrepare(@NonNull WindowInsetsAnimationCompat animation) { + if (!isIme(animation)) return; + imeAnimating = true; + startOffset = restingOffset - Math.round(getTranslationY()); + } + + @NonNull + @Override + public WindowInsetsAnimationCompat.BoundsCompat onStart( + @NonNull WindowInsetsAnimationCompat animation, + @NonNull WindowInsetsAnimationCompat.BoundsCompat bounds) { + if (isIme(animation)) setTranslationY(restingOffset - startOffset); + return bounds; + } + + @NonNull + @Override + public WindowInsetsCompat onProgress( + @NonNull WindowInsetsCompat insets, + @NonNull List runningAnimations) { + if (!imeAnimating) return insets; + int ime = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + int bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom; + setTranslationY(restingOffset - Math.max(ime, bars)); + return insets; + } + + @Override + public void onEnd(@NonNull WindowInsetsAnimationCompat animation) { + if (!isIme(animation)) return; + imeAnimating = false; + setTranslationY(0f); + applyVisibility(); + } + + private boolean isIme(WindowInsetsAnimationCompat animation) { + return (animation.getTypeMask() & WindowInsetsCompat.Type.ime()) != 0; + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/KeyboardBarState.java b/app/src/main/java/com/pasich/mynotes/utils/editor/KeyboardBarState.java new file mode 100644 index 00000000..dec9f84c --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/KeyboardBarState.java @@ -0,0 +1,61 @@ +package com.pasich.mynotes.utils.editor; + +/** + * What the bar above the keyboard shows, kept free of Android types so it is covered by plain JVM + * tests. + * + *

The bar is there only while the note is being edited and the on-screen keyboard is up: in + * reading mode there is nothing to undo, and with a hardware keyboard (no on-screen keyboard) + * Ctrl+Z and Ctrl+Y do the job and the bar would only take space from the note. Undo and Redo are + * enabled while the editor's history has a step to take back or to apply again. + */ +public final class KeyboardBarState { + + private boolean editing; + private boolean imeVisible; + private boolean canUndo; + private boolean canRedo; + + /** Sets whether the note is being edited; returns whether the bar's visibility changed. */ + public boolean setEditing(boolean editing) { + boolean wasShown = isShown(); + this.editing = editing; + return wasShown != isShown(); + } + + /** + * Sets whether the on-screen keyboard is visible; returns whether the bar's visibility changed. + */ + public boolean setImeVisible(boolean imeVisible) { + boolean wasShown = isShown(); + this.imeVisible = imeVisible; + return wasShown != isShown(); + } + + /** Sets what the editor's history allows; returns whether either button changed. */ + public boolean setHistory(boolean canUndo, boolean canRedo) { + boolean changed = this.canUndo != canUndo || this.canRedo != canRedo; + this.canUndo = canUndo; + this.canRedo = canRedo; + return changed; + } + + public boolean isEditing() { + return editing; + } + + /** The bar sits above the keyboard only while the note is edited with the keyboard up. */ + public boolean isShown() { + return editing && imeVisible; + } + + /** Undo is offered while editing and there is a step to take back. */ + public boolean isUndoEnabled() { + return editing && canUndo; + } + + /** Redo is offered while editing and there is an undone step to apply again. */ + public boolean isRedoEnabled() { + return editing && canRedo; + } +} diff --git a/app/src/main/res/color/editor_keyboard_bar_icon.xml b/app/src/main/res/color/editor_keyboard_bar_icon.xml new file mode 100644 index 00000000..0b2cbc15 --- /dev/null +++ b/app/src/main/res/color/editor_keyboard_bar_icon.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_keyboard_hide.xml b/app/src/main/res/drawable/ic_keyboard_hide.xml new file mode 100644 index 00000000..3796e86e --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_hide.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_note.xml b/app/src/main/res/layout/activity_note.xml index 3fbdc916..63bba124 100644 --- a/app/src/main/res/layout/activity_note.xml +++ b/app/src/main/res/layout/activity_note.xml @@ -257,6 +257,12 @@ app:layout_dodgeInsetEdges="bottom" app:srcCompat="@drawable/ic_edit" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_note_extended_editor.xml b/app/src/main/res/layout/activity_note_extended_editor.xml index bae9daf2..4c52ebc8 100644 --- a/app/src/main/res/layout/activity_note_extended_editor.xml +++ b/app/src/main/res/layout/activity_note_extended_editor.xml @@ -103,5 +103,14 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/reminderChip" /> + + + diff --git a/app/src/main/res/layout/dialog_more_note.xml b/app/src/main/res/layout/dialog_more_note.xml index 7d5600ac..ade2076b 100644 --- a/app/src/main/res/layout/dialog_more_note.xml +++ b/app/src/main/res/layout/dialog_more_note.xml @@ -161,6 +161,62 @@ android:layout_height="8dp" android:visibility="@{activityNote ? View.VISIBLE : View.GONE}" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_activity_toolbar_note.xml b/app/src/main/res/menu/menu_activity_toolbar_note.xml index b1f6ddec..2f92c715 100644 --- a/app/src/main/res/menu/menu_activity_toolbar_note.xml +++ b/app/src/main/res/menu/menu_activity_toolbar_note.xml @@ -1,7 +1,6 @@ - +

- - - - - + - - - - Перамешчана на пазіцыю %1$d з %2$d Адрабіць Паўтарыць + Схаваць клавіятуру diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1525717b..30492e80 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -641,4 +641,5 @@ An Position %1$d von %2$d verschoben Rückgängig Wiederholen + Tastatur ausblenden diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 6a74ba3f..9e7dba2c 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -688,4 +688,5 @@ Moved to position %1$d of %2$d Undo Redo + Hide keyboard diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b38714ae..df5e6aa5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -642,4 +642,5 @@ Movida a la posición %1$d de %2$d Deshacer Rehacer + Ocultar teclado diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6190eb84..bd37b316 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -637,4 +637,5 @@ Déplacée en position %1$d sur %2$d Annuler Rétablir + Masquer le clavier diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b5663bab..6c5f8e7b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -639,4 +639,5 @@ Spostata in posizione %1$d di %2$d Annulla Ripeti + Nascondi tastiera diff --git a/app/src/main/res/values-kk/strings.xml b/app/src/main/res/values-kk/strings.xml index 59889c61..813c6a47 100644 --- a/app/src/main/res/values-kk/strings.xml +++ b/app/src/main/res/values-kk/strings.xml @@ -638,4 +638,5 @@ %2$d ішінен %1$d орынға жылжытылды Болдырмау Қайталау + Пернетақтаны жасыру diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index acc81a21..51cd8378 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -662,4 +662,5 @@ Przeniesiono na pozycję %1$d z %2$d Cofnij Ponów + Ukryj klawiaturę diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7cc1c22a..4d165c0f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -666,4 +666,5 @@ Перемещено на позицию %1$d из %2$d Отменить Повторить + Скрыть клавиатуру diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 5f28ccd2..38d23396 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -670,4 +670,5 @@ Переміщено на позицію %1$d з %2$d Скасувати Повторити + Сховати клавіатуру diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index d47003df..53676ce9 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -11,6 +11,10 @@ 10dp 16dp + + 48dp + 1dp + 4dp 10dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 72764e14..d18b38bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -679,4 +679,5 @@ Moved to position %1$d of %2$d Undo Redo + Hide keyboard diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 048dd416..2f4d2279 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -257,6 +257,22 @@ 0dp + + +