diff --git a/FAQ.md b/FAQ.md index 60797b52..17391397 100644 --- a/FAQ.md +++ b/FAQ.md @@ -27,17 +27,45 @@ Sorry. There are so many different environments, that it is impossible for us to First of all make sure you have updated to and tried with the latest available versions of both, this app and the [Notes server app](https://apps.nextcloud.com/apps/notes). -In case you receive a `NextcloudApiNotRespondingException`, try to disable the battery optimization for both apps. -In all other cases please try to clear the storage of **both** apps, Nextcloud Android **and** Nextcloud Notes Android. +### `NextcloudApiNotRespondingException` + +Try to disable the battery "optimization" for both apps. Some manufacturers prevent the app from communicating with the Nextcloud Android properly. +This is a [known issue of the SingleSignOn mechanism](https://github.com/nextcloud/Android-SingleSignOn#troubleshooting) which we only can work around but not solve on our side. + +### `UnknownErrorException: Read timed out` + +This issue is caused by a connection time out. This can be the case if there are infrastructural or environmental problems (like a misconfigured server or a bad network connection). +Probably you will experience it when importing an account, because at this moment, all your Notes will getting downloaded at once. Given you have a lots of notes, this might take longer than the connection is available. +Further synchronizations are usually not causing this issue, because the Notes app tries to synchronize only *changed* notes after the first import. +If your notes are not ten thousands of characters long, it is very unlikely that this causes a connection timeout. + +We plan to improve the import of an account and make it more reliable by [fetching notes step by step](https://github.com/stefan-niedermann/nextcloud-notes/issues/761#issuecomment-836989421) in a future release. +Until then you can as a workaround for the first import try to +1. move all your notes to a different folder on your Nextcloud instance +2. import your account on your smartphone +3. put your notes back to the original folder step by step and sync everytime you put some notes back + +### `NextcloudFilesAppAccountNotFoundException` + +We are not yet sure what exactly causes this issue, but investigate it by [adding more debug logs to recent versions](https://github.com/stefan-niedermann/nextcloud-notes/issues/1256#issuecomment-859505153). In theory this might happen if an already imported account has been deleted in the Nextcloud app. +As a workaround you can remove the account (or clear the storage of the app as described below if you can't access the account manager anymore) and import it again. + +### `TokenMismatchException` and all others + +In all other cases please try to clear the storage of **both** apps, Nextcloud Android **and** Nextcloud Notes Android. Not yet synchronized changes will be lost by performing this step. You can achieve this by navigating to ``` Android settings - ↳ Apps - ↳ Nextcloud / Notes - ↳ Storage - ↳ Clear storage + ↓ + Apps + ↓ +Nextcloud / Notes + ↓ + Storage + ↓ + Clear storage ``` Then set up your account in the Nextcloud Android app again and import the configured account in the Nextcloud Notes Android app. diff --git a/app/build.gradle b/app/build.gradle index 87b2002b..6218d391 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "it.niedermann.owncloud.notes" minSdkVersion 21 targetSdkVersion 30 - versionCode 3004009 - versionName "3.4.9" + versionCode 3004010 + versionName "3.4.10" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" javaCompileOptions { annotationProcessorOptions { @@ -86,7 +86,7 @@ dependencies { // Android X implementation 'androidx.appcompat:appcompat:1.3.0' - implementation 'androidx.fragment:fragment:1.3.4' + implementation 'androidx.fragment:fragment:1.3.5' implementation 'androidx.preference:preference:1.1.1' implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.recyclerview:recyclerview-selection:1.1.0' @@ -112,7 +112,7 @@ dependencies { testImplementation 'androidx.test:core:1.3.0' testImplementation 'androidx.arch.core:core-testing:2.1.0' testImplementation 'junit:junit:4.13.2' - testImplementation 'org.mockito:mockito-core:3.11.0' + testImplementation 'org.mockito:mockito-core:3.11.1' testImplementation 'org.robolectric:robolectric:4.5.1' implementation fileTree(dir: 'libs', include: ['*.jar']) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java index 53ffb3b4..83c8eb1a 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java @@ -29,6 +29,7 @@ import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.databinding.FragmentNoteEditBinding; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.shared.model.ISyncCallback; +import it.niedermann.owncloud.notes.shared.util.DisplayUtils; import static androidx.core.view.ViewCompat.isAttachedToWindow; import static it.niedermann.owncloud.notes.shared.util.NoteUtil.getFontSizeFromPreferences; @@ -59,6 +60,7 @@ public class NoteEditFragment extends SearchableBaseNoteFragment { } }; private TextWatcher textWatcher; + private boolean keyboardShown = false; @Override public void onCreate(@Nullable Bundle savedInstanceState) { @@ -138,22 +140,17 @@ public class NoteEditFragment extends SearchableBaseNoteFragment { public void onResume() { super.onResume(); binding.editContent.addTextChangedListener(textWatcher); + + if (keyboardShown) { + openSoftKeyboard(); + } } @Override protected void onNoteLoaded(Note note) { super.onNoteLoaded(note); if (TextUtils.isEmpty(note.getContent())) { - binding.editContent.post(() -> { - binding.editContent.requestFocus(); - - final InputMethodManager imm = (InputMethodManager) requireContext().getSystemService(Context.INPUT_METHOD_SERVICE); - if (imm != null) { - imm.showSoftInput(binding.editContent, InputMethodManager.SHOW_IMPLICIT); - } else { - Log.e(TAG, InputMethodManager.class.getSimpleName() + " is null."); - } - }); + openSoftKeyboard(); } binding.editContent.setMarkdownString(note.getContent()); @@ -166,11 +163,32 @@ public class NoteEditFragment extends SearchableBaseNoteFragment { } } + private void openSoftKeyboard() { + binding.editContent.postDelayed(() -> { + binding.editContent.requestFocus(); + + final InputMethodManager imm = (InputMethodManager) requireContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.showSoftInput(binding.editContent, InputMethodManager.SHOW_IMPLICIT); + } else { + Log.e(TAG, InputMethodManager.class.getSimpleName() + " is null."); + } + //Without a small delay the keyboard does not show reliably + }, 100); + } + @Override public void onPause() { super.onPause(); binding.editContent.removeTextChangedListener(textWatcher); cancelTimers(); + + final ViewGroup parentView = requireActivity().findViewById(android.R.id.content); + if (parentView != null && parentView.getChildCount() > 0) { + keyboardShown = DisplayUtils.isSoftKeyboardVisible(parentView.getChildAt(0)); + } else { + keyboardShown = false; + } } private void cancelTimers() { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java index 49f4a3e7..3d9e2ff0 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java @@ -15,9 +15,11 @@ import android.util.Log; import android.view.View; import android.view.ViewTreeObserver; import android.widget.LinearLayout; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.view.ActionMode; import androidx.appcompat.widget.SearchView; import androidx.coordinatorlayout.widget.CoordinatorLayout; @@ -50,9 +52,11 @@ import com.nextcloud.android.sso.helper.SingleAccountHelper; import java.net.HttpURLConnection; import java.util.Collection; import java.util.LinkedList; +import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.function.Function; import java.util.stream.Collectors; import it.niedermann.owncloud.notes.LockedActivity; @@ -90,6 +94,7 @@ import it.niedermann.owncloud.notes.shared.model.NavigationCategory; import it.niedermann.owncloud.notes.shared.model.NoteClickListener; import it.niedermann.owncloud.notes.shared.util.CustomAppGlideModule; import it.niedermann.owncloud.notes.shared.util.NoteUtil; +import it.niedermann.owncloud.notes.shared.util.ShareUtil; import static android.os.Build.VERSION.SDK_INT; import static android.os.Build.VERSION_CODES.O; @@ -175,25 +180,52 @@ public class MainActivity extends LockedActivity implements NoteClickListener, A try { final Account account = mainViewModel.getLocalAccountByAccountName(SingleAccountHelper.getCurrentSingleSignOnAccount(getApplicationContext()).name); runOnUiThread(() -> mainViewModel.postCurrentAccount(account)); - } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { + } catch (NextcloudFilesAppAccountNotFoundException e) { // Verbose log output for https://github.com/stefan-niedermann/nextcloud-notes/issues/1256 - final Throwable exception; - if (e instanceof NextcloudFilesAppAccountNotFoundException) { - final SharedPreferences ssoPreferences = AccountImporter.getSharedPreferences(getApplicationContext()); - final StringBuilder ssoPreferencesString = new StringBuilder() - .append("Current SSO account: ").append(ssoPreferences.getString("PREF_CURRENT_ACCOUNT_STRING", null)).append("\n") - .append("\n") - .append("SSO SharedPreferences: ").append("\n"); - for (Map.Entry entry : ssoPreferences.getAll().entrySet()) { - ssoPreferencesString.append(entry.getKey()).append(": ").append(entry.getValue()).append("\n"); - } - ssoPreferencesString.append("\n") - .append("Available accounts in DB: ").append(TextUtils.join(", ", mainViewModel.getAccounts().stream().map(Account::getAccountName).collect(Collectors.toList()))); - exception = new RuntimeException(((NextcloudFilesAppAccountNotFoundException) e).getMessage(), new RuntimeException(ssoPreferencesString.toString(), e)); - } else { - exception = e; - } - runOnUiThread(() -> ExceptionDialogFragment.newInstance(exception).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())); + runOnUiThread(() -> new AlertDialog.Builder(this) + .setTitle(NextcloudFilesAppAccountNotFoundException.class.getSimpleName()) + .setMessage(R.string.backup_and_repair) + .setPositiveButton(R.string.simple_repair, (a, b) -> { + executor.submit(() -> { + for (Account account : mainViewModel.getAccounts()) { + SingleAccountHelper.setCurrentAccount(this, account.getAccountName()); + runOnUiThread(this::recreate); + break; + } + }); + }) + .setNegativeButton(R.string.simple_backup, (a, b) -> { + executor.submit(() -> { + final List modifiedNotes = new LinkedList<>(); + for (Account account : mainViewModel.getAccounts()) { + modifiedNotes.addAll(mainViewModel.getLocalModifiedNotes(account.getId())); + } + if (modifiedNotes.size() == 1) { + final Note note = modifiedNotes.get(0); + ShareUtil.openShareDialog(this, note.getTitle(), note.getContent()); + } else { + ShareUtil.openShareDialog(this, + getResources().getQuantityString(R.plurals.share_multiple, modifiedNotes.size(), modifiedNotes.size()), + mainViewModel.collectNoteContents(modifiedNotes.stream().map(Note::getId).collect(Collectors.toList()))); + } + }); + }) + .setNeutralButton(android.R.string.cancel, (a, b) -> { + final SharedPreferences ssoPreferences = AccountImporter.getSharedPreferences(getApplicationContext()); + final StringBuilder ssoPreferencesString = new StringBuilder() + .append("Current SSO account: ").append(ssoPreferences.getString("PREF_CURRENT_ACCOUNT_STRING", null)).append("\n") + .append("\n") + .append("SSO SharedPreferences: ").append("\n"); + for (Map.Entry entry : ssoPreferences.getAll().entrySet()) { + ssoPreferencesString.append(entry.getKey()).append(": ").append(entry.getValue()).append("\n"); + } + ssoPreferencesString.append("\n") + .append("Available accounts in DB: ").append(TextUtils.join(", ", mainViewModel.getAccounts().stream().map(Account::getAccountName).collect(Collectors.toList()))); + runOnUiThread(() -> ExceptionDialogFragment.newInstance(new RuntimeException(e.getMessage(), new RuntimeException(ssoPreferencesString.toString(), e))).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())); + }) + .show()); + } catch (NoCurrentAccountSelectedException e) { + runOnUiThread(() -> ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())); } }); } @@ -744,6 +776,8 @@ public class MainActivity extends LockedActivity implements NoteClickListener, A public void onBackPressed() { if (activityBinding.toolbar.getVisibility() == VISIBLE) { updateToolbars(true); + } else if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { + binding.drawerLayout.closeDrawer(GravityCompat.START); } else { super.onBackPressed(); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java index 716bbd79..92790bc4 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java @@ -594,6 +594,10 @@ public class MainViewModel extends AndroidViewModel { repo.createOrUpdateSingleNoteWidgetData(data); } + public List getLocalModifiedNotes(long accountId) { + return repo.getLocalModifiedNotes(accountId); + } + public LiveData getAccountsCount() { return repo.countAccounts$(); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java index b0adc011..06cbf57d 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java @@ -2,27 +2,21 @@ package it.niedermann.owncloud.notes.shared.util; import android.content.Context; import android.content.res.Resources; -import android.graphics.Color; -import android.text.Spannable; -import android.text.TextPaint; -import android.text.TextUtils; -import android.text.style.MetricAffectingSpan; +import android.graphics.Rect; +import android.os.Build; +import android.util.TypedValue; +import android.view.View; +import android.view.WindowInsets; -import androidx.annotation.ColorInt; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; import java.util.Collection; import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; -import it.niedermann.android.util.ColorUtil; -import it.niedermann.owncloud.notes.NotesApplication; import it.niedermann.owncloud.notes.R; -import it.niedermann.owncloud.notes.branding.BrandingUtil; import it.niedermann.owncloud.notes.main.navigation.NavigationAdapter; import it.niedermann.owncloud.notes.main.navigation.NavigationItem; import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; @@ -52,4 +46,29 @@ public class DisplayUtils { } return new NavigationItem.CategoryNavigationItem("category:" + counter.getCategory(), counter.getCategory(), counter.getTotalNotes(), icon, counter.getAccountId(), counter.getCategory()); } + + /** + * Detect if the soft keyboard is open. + * On API prior to 30 we fall back to workaround which might be less reliable + * + * @param parentView View + * @return keyboardVisibility Boolean + */ + public static boolean isSoftKeyboardVisible(@NonNull View parentView) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + final WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(parentView); + if (insets != null) { + return insets.isVisible(WindowInsets.Type.ime()); + } + } + + //Arbitrary keyboard height + final int defaultKeyboardHeightDP = 100; + final int EstimatedKeyboardDP = defaultKeyboardHeightDP + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? 48 : 0); + final Rect rect = new Rect(); + final int estimatedKeyboardHeight = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, EstimatedKeyboardDP, parentView.getResources().getDisplayMetrics()); + parentView.getWindowVisibleDisplayFrame(rect); + final int heightDiff = parentView.getRootView().getHeight() - (rect.bottom - rect.top); + return heightDiff >= estimatedKeyboardHeight; + } } diff --git a/app/src/main/res/values-cs-rCZ/strings.xml b/app/src/main/res/values-cs-rCZ/strings.xml index 84a036de..912efab9 100644 --- a/app/src/main/res/values-cs-rCZ/strings.xml +++ b/app/src/main/res/values-cs-rCZ/strings.xml @@ -259,4 +259,7 @@ Abyste mohli přidat účet je třeba, abyste byli připojení k Internetu. Další Předchozí + Zazálohovat + Opravit + Doporučujeme zazálohovat veškeré nesesynchronizované poznámky a pak se pokusit opravit nastavení. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index fc6cf276..1ed1f92a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -249,4 +249,7 @@ Sie müssen mit dem Internet verbunden sein, um ein Konto hinzufügen zu können. Weiter Vorheriges + Sicherung + Reparieren + Wir empfehlen, alle unsynchronisierten Notizen zu sichern und dann zu reparieren. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f323d4c8..4e77a61f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -249,4 +249,4 @@ Tienes que estar conectado a internet para poder añadir una cuenta. Siguiente Anterior - + diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 4d387996..19b244ab 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -249,4 +249,4 @@ Kontu bat gehitzeko internetera konektatuta egon behar zara. Hurrengoa Aurrekoa - + diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 48ec1482..38bc538b 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -245,4 +245,4 @@ Sinun tulee olla yhteydessä internetiin, jotta voit lisätä tilin. Seuraava Edellinen - + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 435eb1be..64485bc5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -12,6 +12,7 @@ Méthode de tri Annuler Modifier + Supprimer Enregistrer À propos Lien diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 4b4eb9ff..d1adce14 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -6,12 +6,14 @@ Minden jegyzet Kedvencek Új jegyzet + Üdvözli a %1$s Beállítások Törölt jegyzetek Keresés Rendezési mód Mégse Szerkesztés + Eltávolítás Mentés Névjegy Hivatkozás @@ -50,6 +52,7 @@ Szinkronizálás sikertelen: %1$s Szinkronizálás sikertelen Nincs hálózati kapcsolat + A kiszolgáló karbantartási módban van Ismeretlen hiba történt. Verzió @@ -184,7 +187,7 @@ ```javascript Környezetfüggő formázás A Jegyzetek alkalmazás egyik célkitűzése, hogy egyszerű legyen, és ne vonja el a figyelmet. Viszont Markdownnal formázhatja a szövegeket. A lent említett példák némelyikénél rövidítéseket is használhat, így anélkül formázhatja meg a jegyzeteket, hogy beírná a lenti kódokat. - Csak válasszon ki egy szövegtartományt, vagy érintse meg a kurzort bármely helyen, és megjelenik egy előugró menü, amely az alapértelmezett bejegyzések mellett %1$s, %2$s, %3$s tartalmaz olyan bejegyzéseket, mint %4$s vagy %5$s. + Csak válasszon ki egy szövegtartományt, vagy érintse meg bárhol a kurzort, és megjelenik egy előugró menü, amely az alapértelmezett %1$s, %2$s, %3$s bejegyzések mellett ezeket is tartalmazza: %4$s, %5$s. Szöveg Nagyon egyszerűen írhat Markdownnal %1$sfélkövéren%1$s, valamint %2$sdőlten%2$s. Át is %3$shúzhat%3$s szavakat, valamint [hivatkozhat a Nextcloudra](https://nextcloud.com). @@ -235,5 +238,15 @@ Hálózati beállítások Nincs még fiók beállítva Még egyetlen más fiókot sem állított be. + Válasszon fiókot Környezetfüggő formázási felbukkanó menü + + A(z) %1$s fiók eltávolítása véglegesen töröl egy nem szinkronizált változtatást. + A(z) %1$s fiók eltávolítása véglegesen töröl %2$d nem szinkronizált változtatást. + + %1$s eltávolítása + + Internetkapcsolatra van szükség, hogy fiókot adjon hozzá. + Következő + Előző diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index dc800393..ed87afbb 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -249,4 +249,4 @@ Devi essere connesso a Internet per aggiungere un account. Successivo Precedente - + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 45787517..f1ef2fcb 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -249,4 +249,4 @@ Je moet verbonden zijn met het Internet om een account toe te voegen. Volgende Vorige - + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 62e415df..e6e1f2b8 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -259,4 +259,7 @@ Aby dodać konto, musisz mieć połączenie z Internetem. Następna Poprzednia + Kopia zapasowa + Naprawa + Zalecane wykonanie kopii zapasowej wszystkich niezsynchronizowanych notatek, a następnie próbę naprawy konfiguracji. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6955bf15..23f7ed9a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -249,4 +249,7 @@ Você deve estar conectado à Internet para adicionar uma conta. Próximo Anterior + Backup + Reparar + Recomendamos fazer backup de todas as notas não sincronizadas e, em seguida, tentar reparar a configuração. diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index aa77d43e..afdc562b 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -259,4 +259,4 @@ Za dodajanje računa mora biti vzpostavljena povezava z omrežjem. Naslednje Predhodno - + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index ecbe25aa..1f728e81 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -249,4 +249,7 @@ Bir hesap ekleyebilmeniz için çalışan bir İnternet bağlantınız olmalı. Sonraki Önceki + Yedekle + Onar + Eşitlenmemiş tüm notları yedekledikten sonra kurulumu onarmayı denemeniz önerilir. diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 69f72d24..b57ce4c7 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -226,4 +226,4 @@ Bạn phải có kết nối internet để thêm tài khoản. Tiếp Trước - + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 88981861..ba190366 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -244,4 +244,7 @@ 要添加账号,你必须连接到互联网。 下一则 上一则 + 备份 + 修复 + 我们建议备份所有未同步的笔记,然后尝试修复安装。 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 0b9f7615..b97ec305 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -244,4 +244,7 @@ 您必須連線到互聯網才能新增賬戶。 下一步 上一個 + 備份 + 修復 + 我們建議備份所有未同步的筆記,然後嘗試修復設置。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3f5b9e52..1fa41edf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -295,4 +295,7 @@ You have to be connected to the internet in order to add an account. Next Previous + Backup + Repair + We recommend to backup all unsynchronized notes and then try to repair the setup. diff --git a/fastlane/metadata/android/en-US/changelogs/3004010.txt b/fastlane/metadata/android/en-US/changelogs/3004010.txt index 199b9cc3..92da1c45 100644 --- a/fastlane/metadata/android/en-US/changelogs/3004010.txt +++ b/fastlane/metadata/android/en-US/changelogs/3004010.txt @@ -1,3 +1,3 @@ -v3.4.10 - -- Open trashbin of files app instead of Web UI (#238) \ No newline at end of file +- 🗑 Open trashbin of files app instead of Web UI (#238) +- ✅ Make links in checkbox list items clickable +- 🐞 NextcloudFilesAppAccountNotFoundException - Trying to provide backup & repair steps (#1256) \ No newline at end of file diff --git a/markdown/build.gradle b/markdown/build.gradle index 91d8d437..71ae490a 100644 --- a/markdown/build.gradle +++ b/markdown/build.gradle @@ -57,6 +57,9 @@ dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' + testImplementation 'androidx.test:core:1.3.0' + testImplementation 'androidx.arch.core:core-testing:2.1.0' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:3.11.1' testImplementation 'org.robolectric:robolectric:4.5.1' } \ No newline at end of file diff --git a/markdown/src/main/java/it/niedermann/android/markdown/markwon/plugins/LinkClickInterceptorPlugin.java b/markdown/src/main/java/it/niedermann/android/markdown/markwon/plugins/LinkClickInterceptorPlugin.java index e815b54b..81c48aa2 100644 --- a/markdown/src/main/java/it/niedermann/android/markdown/markwon/plugins/LinkClickInterceptorPlugin.java +++ b/markdown/src/main/java/it/niedermann/android/markdown/markwon/plugins/LinkClickInterceptorPlugin.java @@ -1,22 +1,19 @@ package it.niedermann.android.markdown.markwon.plugins; -import android.text.Spannable; -import android.text.style.URLSpan; -import android.widget.TextView; - import androidx.annotation.NonNull; +import org.commonmark.node.Link; + import java.util.Collection; import java.util.LinkedList; import java.util.function.Function; import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.MarkwonPlugin; +import io.noties.markwon.MarkwonSpansFactory; +import io.noties.markwon.core.CoreProps; import it.niedermann.android.markdown.markwon.span.InterceptedURLSpan; -import static android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE; -import static it.niedermann.android.markdown.MarkdownUtil.getContentAsSpannable; - public class LinkClickInterceptorPlugin extends AbstractMarkwonPlugin { @NonNull @@ -27,20 +24,9 @@ public class LinkClickInterceptorPlugin extends AbstractMarkwonPlugin { } @Override - public void afterSetText(@NonNull TextView textView) { - super.afterSetText(textView); - if (onLinkClickCallbacks.size() > 0) { - final Spannable spannable = getContentAsSpannable(textView); - final URLSpan[] spans = spannable.getSpans(0, spannable.length(), URLSpan.class); - - for (URLSpan originalSpan : spans) { - final InterceptedURLSpan interceptedSpan = new InterceptedURLSpan(onLinkClickCallbacks, originalSpan.getURL()); - final int start = spannable.getSpanStart(originalSpan); - final int end = spannable.getSpanEnd(originalSpan); - spannable.removeSpan(originalSpan); - spannable.setSpan(interceptedSpan, start, end, SPAN_EXCLUSIVE_EXCLUSIVE); - } - } + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + super.configureSpansFactory(builder); + builder.setFactory(Link.class, (configuration, props) -> new InterceptedURLSpan(onLinkClickCallbacks, CoreProps.LINK_DESTINATION.get(props))); } public void registerOnLinkClickCallback(@NonNull Function callback) { diff --git a/markdown/src/main/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPlugin.java b/markdown/src/main/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPlugin.java index 740e138b..7aee5a27 100644 --- a/markdown/src/main/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPlugin.java +++ b/markdown/src/main/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPlugin.java @@ -1,8 +1,12 @@ package it.niedermann.android.markdown.markwon.plugins; -import android.util.Log; +import android.text.Spannable; +import android.text.style.ClickableSpan; +import android.util.Range; +import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; import org.commonmark.node.AbstractVisitor; import org.commonmark.node.Block; @@ -12,16 +16,23 @@ import org.commonmark.node.Paragraph; import org.commonmark.node.SoftLineBreak; import org.commonmark.node.Text; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; +import java.util.stream.Collectors; import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.MarkwonVisitor; import io.noties.markwon.SpanFactory; import io.noties.markwon.SpannableBuilder; +import io.noties.markwon.SpannableBuilder.Span; import io.noties.markwon.ext.tasklist.TaskListItem; import io.noties.markwon.ext.tasklist.TaskListProps; import io.noties.markwon.ext.tasklist.TaskListSpan; +import it.niedermann.android.markdown.MarkdownUtil; import it.niedermann.android.markdown.markwon.span.ToggleTaskListSpan; /** @@ -30,8 +41,6 @@ import it.niedermann.android.markdown.markwon.span.ToggleTaskListSpan; */ public class ToggleableTaskListPlugin extends AbstractMarkwonPlugin { - private static final String TAG = ToggleableTaskListPlugin.class.getSimpleName(); - @NonNull private final AtomicBoolean enabled = new AtomicBoolean(true); @NonNull @@ -45,6 +54,10 @@ public class ToggleableTaskListPlugin extends AbstractMarkwonPlugin { this.enabled.set(enabled); } + /** + * Prepares {@link TaskListSpan}s and marks each one with a {@link ToggleMarkerSpan} in the first step. + * The {@link ToggleMarkerSpan} are different from {@link TaskListSpan}s as they will stop on nested tasks instead of spanning the whole tasks including its subtasks. + */ @Override public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { builder.on(TaskListItem.class, (visitor, node) -> { @@ -56,7 +69,6 @@ public class ToggleableTaskListPlugin extends AbstractMarkwonPlugin { .get(TaskListItem.class); final Object spans = spanFactory == null ? null : spanFactory.getSpans(visitor.configuration(), visitor.renderProps()); - if (spans != null) { final TaskListSpan taskListSpan; if (spans instanceof TaskListSpan[]) { @@ -75,13 +87,12 @@ public class ToggleableTaskListPlugin extends AbstractMarkwonPlugin { if (content > 0 && taskListSpan != null) { // maybe additionally identify this task list (for persistence) visitor.builder().setSpan( - new ToggleTaskListSpan(enabled, toggleListener, taskListSpan, visitor.builder().subSequence(length, length + content).toString()), + new ToggleMarkerSpan(taskListSpan), length, length + content ); } } - SpannableBuilder.setSpans( visitor.builder(), spans, @@ -95,7 +106,86 @@ public class ToggleableTaskListPlugin extends AbstractMarkwonPlugin { }); } - static class TaskListContextVisitor extends AbstractVisitor { + + /** + * Adds for each symbolic {@link ToggleMarkerSpan} an actual {@link ToggleTaskListSpan}s respecting existing {@link ClickableSpan}s. + */ + @Override + public void afterRender(@NonNull Node node, @NonNull MarkwonVisitor visitor) { + super.afterRender(node, visitor); + + final List markerSpans = getSortedSpans(visitor.builder(), ToggleMarkerSpan.class, 0, visitor.builder().length()); + + for (int position = 0; position < markerSpans.size(); position++) { + final Span markerSpan = markerSpans.get(position); + final int start = markerSpan.start; + final int end = markerSpan.end; + final Collection> freeRanges = findFreeRanges(visitor.builder(), start, end); + for (Range freeRange : freeRanges) { + visitor.builder().setSpan( + new ToggleTaskListSpan(enabled, toggleListener, ((ToggleMarkerSpan) markerSpan.what).getTaskListSpan(), position), + freeRange.getLower(), freeRange.getUpper()); + } + } + } + + /** + * Removes {@link ToggleMarkerSpan}s from {@param textView}. + */ + @Override + public void afterSetText(@NonNull TextView textView) { + super.afterSetText(textView); + final Spannable spannable = MarkdownUtil.getContentAsSpannable(textView); + for (ToggleMarkerSpan span : spannable.getSpans(0, spannable.length(), ToggleMarkerSpan.class)) { + spannable.removeSpan(span); + } + textView.setText(spannable); + } + + /** + * @return a {@link List} of {@link Range}s in the given {@param spanned} from {@param start} to {@param end} which is not taken for a {@link ClickableSpan}. + */ + @NonNull + private static Collection> findFreeRanges(@NonNull SpannableBuilder builder, int start, int end) { + final List> freeRanges; + final List clickableSpans = getSortedSpans(builder, ClickableSpan.class, start, end); + if (clickableSpans.size() > 0) { + freeRanges = new LinkedList<>(); + int from = start; + for (Span clickableSpan : clickableSpans) { + final int clickableStart = clickableSpan.start; + final int clickableEnd = clickableSpan.end; + if (from != clickableStart) { + freeRanges.add(new Range<>(from, clickableStart)); + } + from = clickableEnd; + } + if (clickableSpans.size() > 0) { + final int lastUpperBlocker = clickableSpans.get(clickableSpans.size() - 1).end; + if (lastUpperBlocker < end) { + freeRanges.add(new Range<>(lastUpperBlocker, end)); + } + } + } else if (start == end) { + freeRanges = Collections.emptyList(); + } else { + freeRanges = Collections.singletonList(new Range<>(start, end)); + } + return freeRanges; + } + + /** + * @return a {@link List} of {@link Span}s holding {@param type}s, sorted ascending by the span start. + */ + private static List getSortedSpans(@NonNull SpannableBuilder builder, @NonNull Class type, int start, int end) { + return builder.getSpans(start, end) + .stream() + .filter(span -> type.isInstance(span.what)) + .sorted((o1, o2) -> o1.start - o2.start) + .collect(Collectors.toList()); + } + + private static final class TaskListContextVisitor extends AbstractVisitor { private int contentLength = 0; static int contentLength(Node node) { @@ -139,4 +229,23 @@ public class ToggleableTaskListPlugin extends AbstractMarkwonPlugin { } } } + + /** + * Helper class which holds an {@link TaskListSpan} but does not include the range of child {@link TaskListSpan}s. + */ + @VisibleForTesting + static final class ToggleMarkerSpan { + + @NonNull + private final TaskListSpan taskListSpan; + + private ToggleMarkerSpan(@NonNull TaskListSpan taskListSpan) { + this.taskListSpan = taskListSpan; + } + + @NonNull + private TaskListSpan getTaskListSpan() { + return taskListSpan; + } + } } diff --git a/markdown/src/main/java/it/niedermann/android/markdown/markwon/span/InterceptedURLSpan.java b/markdown/src/main/java/it/niedermann/android/markdown/markwon/span/InterceptedURLSpan.java index 35c456e0..f370c006 100644 --- a/markdown/src/main/java/it/niedermann/android/markdown/markwon/span/InterceptedURLSpan.java +++ b/markdown/src/main/java/it/niedermann/android/markdown/markwon/span/InterceptedURLSpan.java @@ -1,8 +1,10 @@ package it.niedermann.android.markdown.markwon.span; +import android.text.Spanned; import android.text.style.URLSpan; import android.util.Log; import android.view.View; +import android.widget.TextView; import androidx.annotation.NonNull; @@ -22,7 +24,6 @@ public class InterceptedURLSpan extends URLSpan { super(url); this.onLinkClickCallbacks = onLinkClickCallbacks; } - @Override public void onClick(View widget) { if (onLinkClickCallbacks.size() > 0) { diff --git a/markdown/src/main/java/it/niedermann/android/markdown/markwon/span/ToggleTaskListSpan.java b/markdown/src/main/java/it/niedermann/android/markdown/markwon/span/ToggleTaskListSpan.java index a38ac65e..2b432602 100644 --- a/markdown/src/main/java/it/niedermann/android/markdown/markwon/span/ToggleTaskListSpan.java +++ b/markdown/src/main/java/it/niedermann/android/markdown/markwon/span/ToggleTaskListSpan.java @@ -1,15 +1,12 @@ package it.niedermann.android.markdown.markwon.span; -import android.text.Spanned; import android.text.TextPaint; import android.text.style.ClickableSpan; import android.util.Log; import android.view.View; -import android.widget.TextView; import androidx.annotation.NonNull; -import java.util.Arrays; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; @@ -19,42 +16,24 @@ public class ToggleTaskListSpan extends ClickableSpan { private static final String TAG = ToggleTaskListSpan.class.getSimpleName(); - final AtomicBoolean enabled; - final BiConsumer toggleListener; - final TaskListSpan span; - final String content; + private final AtomicBoolean enabled; + private final BiConsumer toggleListener; + private final TaskListSpan span; + private final int position; - public ToggleTaskListSpan(@NonNull AtomicBoolean enabled, @NonNull BiConsumer toggleListener, @NonNull TaskListSpan span, String content) { + public ToggleTaskListSpan(@NonNull AtomicBoolean enabled, @NonNull BiConsumer toggleListener, @NonNull TaskListSpan span, int position) { this.enabled = enabled; this.toggleListener = toggleListener; this.span = span; - this.content = content; + this.position = position; } @Override public void onClick(@NonNull View widget) { - if(enabled.get()) { + if (enabled.get()) { span.setDone(!span.isDone()); widget.invalidate(); - - // it must be a TextView - final TextView textView = (TextView) widget; - // it must be spanned - // TODO what if textView is not a spanned? - final Spanned spanned = (Spanned) textView.getText(); - - final ClickableSpan[] toggles = spanned.getSpans(0, spanned.length(), getClass()); - Arrays.sort(toggles, (o1, o2) -> spanned.getSpanStart(o1) - spanned.getSpanStart(o2)); - - int currentTogglePosition = -1; - for (int i = 0; i < toggles.length; i++) { - if (spanned.getSpanStart(toggles[i]) == spanned.getSpanStart(this) && spanned.getSpanEnd(toggles[i]) == spanned.getSpanEnd(this)) { - currentTogglePosition = i; - break; - } - } - - toggleListener.accept(currentTogglePosition, span.isDone()); + toggleListener.accept(position, span.isDone()); } else { Log.w(TAG, "Prevented toggling checkbox because the view is disabled"); } diff --git a/markdown/src/test/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPluginTest.java b/markdown/src/test/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPluginTest.java new file mode 100644 index 00000000..d864aea4 --- /dev/null +++ b/markdown/src/test/java/it/niedermann/android/markdown/markwon/plugins/ToggleableTaskListPluginTest.java @@ -0,0 +1,204 @@ +package it.niedermann.android.markdown.markwon.plugins; + +import android.text.Editable; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ClickableSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.URLSpan; +import android.util.Range; +import android.widget.TextView; + +import androidx.test.core.app.ApplicationProvider; + +import junit.framework.TestCase; + +import org.commonmark.node.Node; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.SpannableBuilder; +import io.noties.markwon.ext.tasklist.TaskListSpan; +import it.niedermann.android.markdown.markwon.span.InterceptedURLSpan; +import it.niedermann.android.markdown.markwon.span.ToggleTaskListSpan; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +public class ToggleableTaskListPluginTest extends TestCase { + + @Test + public void testAfterRender() throws IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchMethodException { + final Node node = mock(Node.class); + final MarkwonVisitor visitor = mock(MarkwonVisitor.class); + + final Constructor markerSpanConstructor = ToggleableTaskListPlugin.ToggleMarkerSpan.class.getDeclaredConstructor(TaskListSpan.class); + markerSpanConstructor.setAccessible(true); + + final SpannableBuilder builder = new SpannableBuilder("Lorem Ipsum Dolor \nSit Amet"); + builder.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 0, 6); + builder.setSpan(new URLSpan(""), 6, 11); + builder.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 11, 19); + builder.setSpan(new InterceptedURLSpan(Collections.emptyList(), ""), 19, 22); + builder.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 22, 27); + + when(visitor.builder()).thenReturn(builder); + + final ToggleableTaskListPlugin plugin = new ToggleableTaskListPlugin((i, b) -> { + // Do nothing... + }); + plugin.afterRender(node, visitor); + + // We ignore marker spans in this test. They will be removed in another step + final List spans = builder.getSpans(0, builder.length()) + .stream() + .filter(span -> span.what.getClass() != ToggleableTaskListPlugin.ToggleMarkerSpan.class) + .sorted((o1, o2) -> o1.start - o2.start) + .collect(Collectors.toList()); + + assertEquals(5, spans.size()); + assertEquals(ToggleTaskListSpan.class, spans.get(0).what.getClass()); + assertEquals(0, spans.get(0).start); + assertEquals(6, spans.get(0).end); + assertEquals(URLSpan.class, spans.get(1).what.getClass()); + assertEquals(6, spans.get(1).start); + assertEquals(11, spans.get(1).end); + assertEquals(ToggleTaskListSpan.class, spans.get(2).what.getClass()); + assertEquals(11, spans.get(2).start); + assertEquals(19, spans.get(2).end); + assertEquals(InterceptedURLSpan.class, spans.get(3).what.getClass()); + assertEquals(19, spans.get(3).start); + assertEquals(22, spans.get(3).end); + assertEquals(ToggleTaskListSpan.class, spans.get(4).what.getClass()); + assertEquals(22, spans.get(4).start); + assertEquals(27, spans.get(4).end); + } + + @Test + public void testAfterSetText() throws IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchMethodException { + final Constructor markerSpanConstructor = ToggleableTaskListPlugin.ToggleMarkerSpan.class.getDeclaredConstructor(TaskListSpan.class); + markerSpanConstructor.setAccessible(true); + + final Editable editable = new SpannableStringBuilder("Lorem Ipsum Dolor \nSit Amet"); + editable.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + editable.setSpan(new URLSpan(""), 6, 11, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + editable.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 11, 19, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + editable.setSpan(new InterceptedURLSpan(Collections.emptyList(), ""), 19, 22, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + editable.setSpan(markerSpanConstructor.newInstance(mock(TaskListSpan.class)), 22, 27, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + final TextView textView = new TextView(ApplicationProvider.getApplicationContext()); + textView.setText(editable); + + assertEquals(3, ((Spanned) textView.getText()).getSpans(0, textView.getText().length(), ToggleableTaskListPlugin.ToggleMarkerSpan.class).length); + + final ToggleableTaskListPlugin plugin = new ToggleableTaskListPlugin((i, b) -> { + // Do nothing... + }); + plugin.afterSetText(textView); + + assertEquals(0, ((Spanned) textView.getText()).getSpans(0, textView.getText().length(), ToggleableTaskListPlugin.ToggleMarkerSpan.class).length); + } + + @Test + @SuppressWarnings({"unchecked", "ConstantConditions"}) + public void testGetSortedSpans() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + final Method m = ToggleableTaskListPlugin.class.getDeclaredMethod("getSortedSpans", SpannableBuilder.class, Class.class, int.class, int.class); + m.setAccessible(true); + + final Object firstClickableSpan = new URLSpan(""); + final Object secondClickableSpan = new InterceptedURLSpan(Collections.emptyList(), ""); + final Object unclickableSpan = new ForegroundColorSpan(android.R.color.white); + + final SpannableBuilder spannable = new SpannableBuilder("Lorem Ipsum Dolor \nSit Amet"); + spannable.setSpan(firstClickableSpan, 6, 11, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + spannable.setSpan(secondClickableSpan, 19, 22, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + spannable.setSpan(unclickableSpan, 3, 20, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + List clickableSpans; + + clickableSpans = (List) m.invoke(null, spannable, ClickableSpan.class, 0, 0); + assertEquals(0, clickableSpans.size()); + + clickableSpans = (List) m.invoke(null, spannable, ClickableSpan.class, spannable.length() - 1, spannable.length() - 1); + assertEquals(0, clickableSpans.size()); + + clickableSpans = (List) m.invoke(null, spannable, ClickableSpan.class, 0, 5); + assertEquals(0, clickableSpans.size()); + + clickableSpans = (List) m.invoke(null, spannable, ClickableSpan.class, 0, spannable.length()); + assertEquals(2, clickableSpans.size()); + assertEquals(firstClickableSpan, clickableSpans.get(0).what); + assertEquals(secondClickableSpan, clickableSpans.get(1).what); + + clickableSpans = (List) m.invoke(null, spannable, ClickableSpan.class, 0, 17); + assertEquals(1, clickableSpans.size()); + assertEquals(firstClickableSpan, clickableSpans.get(0).what); + + clickableSpans = (List) m.invoke(null, spannable, ClickableSpan.class, 12, 22); + assertEquals(1, clickableSpans.size()); + assertEquals(secondClickableSpan, clickableSpans.get(0).what); + + clickableSpans = (List) m.invoke(null, spannable, ClickableSpan.class, 9, 20); + assertEquals(2, clickableSpans.size()); + assertEquals(firstClickableSpan, clickableSpans.get(0).what); + assertEquals(secondClickableSpan, clickableSpans.get(1).what); + } + + @Test + @SuppressWarnings({"unchecked", "ConstantConditions"}) + public void testFindFreeRanges() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + final Method m = ToggleableTaskListPlugin.class.getDeclaredMethod("findFreeRanges", SpannableBuilder.class, int.class, int.class); + m.setAccessible(true); + + final Object firstClickableSpan = new URLSpan(""); + final Object secondClickableSpan = new InterceptedURLSpan(Collections.emptyList(), ""); + final SpannableBuilder spannable = new SpannableBuilder("Lorem Ipsum Dolor \nSit Amet"); + spannable.setSpan(firstClickableSpan, 6, 11, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + spannable.setSpan(secondClickableSpan, 19, 22, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + List> freeRanges; + + freeRanges = (List>) m.invoke(null, spannable, 0, 0); + assertEquals(0, freeRanges.size()); + + freeRanges = (List>) m.invoke(null, spannable, spannable.length() - 1, spannable.length() - 1); + assertEquals(0, freeRanges.size()); + + freeRanges = (List>) m.invoke(null, spannable, 0, 6); + assertEquals(1, freeRanges.size()); + assertEquals(0, (int) freeRanges.get(0).getLower()); + assertEquals(6, (int) freeRanges.get(0).getUpper()); + + freeRanges = (List>) m.invoke(null, spannable, 0, 6); + assertEquals(1, freeRanges.size()); + assertEquals(0, (int) freeRanges.get(0).getLower()); + assertEquals(6, (int) freeRanges.get(0).getUpper()); + + freeRanges = (List>) m.invoke(null, spannable, 3, 15); + assertEquals(2, freeRanges.size()); + assertEquals(3, (int) freeRanges.get(0).getLower()); + assertEquals(6, (int) freeRanges.get(0).getUpper()); + assertEquals(11, (int) freeRanges.get(1).getLower()); + assertEquals(15, (int) freeRanges.get(1).getUpper()); + + freeRanges = (List>) m.invoke(null, spannable, 0, spannable.length()); + assertEquals(3, freeRanges.size()); + assertEquals(0, (int) freeRanges.get(0).getLower()); + assertEquals(6, (int) freeRanges.get(0).getUpper()); + assertEquals(11, (int) freeRanges.get(1).getLower()); + assertEquals(19, (int) freeRanges.get(1).getUpper()); + assertEquals(22, (int) freeRanges.get(2).getLower()); + assertEquals(27, (int) freeRanges.get(2).getUpper()); + } +}