Merge branch 'master' of github.com:stefan-niedermann/nextcloud-notes into markdown-context-menu-multiline

This commit is contained in:
desperateCoder 2021-06-19 12:57:28 +02:00
commit f3c40e5108
30 changed files with 543 additions and 120 deletions

40
FAQ.md
View file

@ -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.

View file

@ -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'])

View file

@ -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() {

View file

@ -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<String, ?> 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<Note> 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<String, ?> 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();
}

View file

@ -594,6 +594,10 @@ public class MainViewModel extends AndroidViewModel {
repo.createOrUpdateSingleNoteWidgetData(data);
}
public List<Note> getLocalModifiedNotes(long accountId) {
return repo.getLocalModifiedNotes(accountId);
}
public LiveData<Integer> getAccountsCount() {
return repo.countAccounts$();
}

View file

@ -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;
}
}

View file

@ -259,4 +259,7 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">Abyste mohli přidat účet je třeba, abyste byli připojení k Internetu.</string>
<string name="simple_next">Další</string>
<string name="simple_prev">Předchozí</string>
<string name="simple_backup">Zazálohovat</string>
<string name="simple_repair">Opravit</string>
<string name="backup_and_repair">Doporučujeme zazálohovat veškeré nesesynchronizované poznámky a pak se pokusit opravit nastavení.</string>
</resources>

View file

@ -249,4 +249,7 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">Sie müssen mit dem Internet verbunden sein, um ein Konto hinzufügen zu können.</string>
<string name="simple_next">Weiter</string>
<string name="simple_prev">Vorheriges</string>
<string name="simple_backup">Sicherung</string>
<string name="simple_repair">Reparieren</string>
<string name="backup_and_repair">Wir empfehlen, alle unsynchronisierten Notizen zu sichern und dann zu reparieren.</string>
</resources>

View file

@ -249,4 +249,4 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">Tienes que estar conectado a internet para poder añadir una cuenta.</string>
<string name="simple_next">Siguiente</string>
<string name="simple_prev">Anterior</string>
</resources>
</resources>

View file

@ -249,4 +249,4 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">Kontu bat gehitzeko internetera konektatuta egon behar zara.</string>
<string name="simple_next">Hurrengoa</string>
<string name="simple_prev">Aurrekoa</string>
</resources>
</resources>

View file

@ -245,4 +245,4 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">Sinun tulee olla yhteydessä internetiin, jotta voit lisätä tilin.</string>
<string name="simple_next">Seuraava</string>
<string name="simple_prev">Edellinen</string>
</resources>
</resources>

View file

@ -12,6 +12,7 @@
<string name="action_sorting_method">Méthode de tri</string>
<string name="simple_cancel">Annuler</string>
<string name="simple_edit">Modifier</string>
<string name="simple_remove">Supprimer</string>
<string name="action_edit_save">Enregistrer</string>
<string name="simple_about">À propos</string>
<string name="simple_link">Lien</string>

View file

@ -6,12 +6,14 @@
<string name="label_all_notes">Minden jegyzet</string>
<string name="label_favorites">Kedvencek</string>
<string name="action_create">Új jegyzet</string>
<string name="welcome_text">Üdvözli a %1$s</string>
<string name="action_settings">Beállítások</string>
<string name="action_trashbin">Törölt jegyzetek</string>
<string name="action_search">Keresés</string>
<string name="action_sorting_method">Rendezési mód</string>
<string name="simple_cancel">Mégse</string>
<string name="simple_edit">Szerkesztés</string>
<string name="simple_remove">Eltávolítás</string>
<string name="action_edit_save">Mentés</string>
<string name="simple_about">Névjegy</string>
<string name="simple_link">Hivatkozás</string>
@ -50,6 +52,7 @@
<string name="error_sync">Szinkronizálás sikertelen: %1$s</string>
<string name="error_synchronization">Szinkronizálás sikertelen</string>
<string name="error_no_network">Nincs hálózati kapcsolat</string>
<string name="error_maintenance_mode">A kiszolgáló karbantartási módban van</string>
<string name="error_unknown">Ismeretlen hiba történt.</string>
<string name="about_version_title">Verzió</string>
@ -184,7 +187,7 @@
<string name="formatting_help_codefence_javascript" translateable="false">```javascript</string>
<string name="formatting_help_cbf_title">Környezetfüggő formázás</string>
<string name="formatting_help_cbf_body_1">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.</string>
<string name="formatting_help_cbf_body_2">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.</string>
<string name="formatting_help_cbf_body_2">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.</string>
<string name="formatting_help_text_title">Szöveg</string>
<string name="formatting_help_text_body">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).</string>
@ -235,5 +238,15 @@
<string name="error_action_open_network">Hálózati beállítások</string>
<string name="no_account_configured_yet">Nincs még fiók beállítva</string>
<string name="no_other_accounts">Még egyetlen más fiókot sem állított be.</string>
<string name="choose_account">Válasszon fiókot</string>
<string name="context_based_formatting">Környezetfüggő formázási felbukkanó menü</string>
<plurals name="remove_account_message">
<item quantity="one">A(z) %1$s fiók eltávolítása véglegesen töröl egy nem szinkronizált változtatást.</item>
<item quantity="other">A(z) %1$s fiók eltávolítása véglegesen töröl %2$d nem szinkronizált változtatást.</item>
</plurals>
<string name="remove_account">%1$s eltávolítása</string>
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">Internetkapcsolatra van szükség, hogy fiókot adjon hozzá.</string>
<string name="simple_next">Következő</string>
<string name="simple_prev">Előző</string>
</resources>

View file

@ -249,4 +249,4 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">Devi essere connesso a Internet per aggiungere un account.</string>
<string name="simple_next">Successivo</string>
<string name="simple_prev">Precedente</string>
</resources>
</resources>

View file

@ -249,4 +249,4 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">Je moet verbonden zijn met het Internet om een account toe te voegen.</string>
<string name="simple_next">Volgende</string>
<string name="simple_prev">Vorige</string>
</resources>
</resources>

View file

@ -259,4 +259,7 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">Aby dodać konto, musisz mieć połączenie z Internetem.</string>
<string name="simple_next">Następna</string>
<string name="simple_prev">Poprzednia</string>
<string name="simple_backup">Kopia zapasowa</string>
<string name="simple_repair">Naprawa</string>
<string name="backup_and_repair">Zalecane wykonanie kopii zapasowej wszystkich niezsynchronizowanych notatek, a następnie próbę naprawy konfiguracji.</string>
</resources>

View file

@ -249,4 +249,7 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">Você deve estar conectado à Internet para adicionar uma conta. </string>
<string name="simple_next">Próximo</string>
<string name="simple_prev">Anterior</string>
<string name="simple_backup">Backup</string>
<string name="simple_repair">Reparar</string>
<string name="backup_and_repair">Recomendamos fazer backup de todas as notas não sincronizadas e, em seguida, tentar reparar a configuração. </string>
</resources>

View file

@ -259,4 +259,4 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">Za dodajanje računa mora biti vzpostavljena povezava z omrežjem.</string>
<string name="simple_next">Naslednje</string>
<string name="simple_prev">Predhodno</string>
</resources>
</resources>

View file

@ -249,4 +249,7 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">Bir hesap ekleyebilmeniz için çalışan bir İnternet bağlantınız olmalı.</string>
<string name="simple_next">Sonraki</string>
<string name="simple_prev">Önceki</string>
<string name="simple_backup">Yedekle</string>
<string name="simple_repair">Onar</string>
<string name="backup_and_repair">Eşitlenmemiş tüm notları yedekledikten sonra kurulumu onarmayı denemeniz önerilir.</string>
</resources>

View file

@ -226,4 +226,4 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">Bạn phải có kết nối internet để thêm tài khoản.</string>
<string name="simple_next">Tiếp</string>
<string name="simple_prev">Trước</string>
</resources>
</resources>

View file

@ -244,4 +244,7 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">要添加账号,你必须连接到互联网。</string>
<string name="simple_next">下一则 </string>
<string name="simple_prev">上一则</string>
<string name="simple_backup">备份</string>
<string name="simple_repair">修复</string>
<string name="backup_and_repair">我们建议备份所有未同步的笔记,然后尝试修复安装。</string>
</resources>

View file

@ -244,4 +244,7 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">您必須連線到互聯網才能新增賬戶。</string>
<string name="simple_next">下一步</string>
<string name="simple_prev">上一個</string>
<string name="simple_backup">備份</string>
<string name="simple_repair">修復</string>
<string name="backup_and_repair">我們建議備份所有未同步的筆記,然後嘗試修復設置。</string>
</resources>

View file

@ -295,4 +295,7 @@
<string name="you_have_to_be_connected_to_the_internet_in_order_to_add_an_account">You have to be connected to the internet in order to add an account.</string>
<string name="simple_next">Next</string>
<string name="simple_prev">Previous</string>
<string name="simple_backup">Backup</string>
<string name="simple_repair">Repair</string>
<string name="backup_and_repair">We recommend to backup all unsynchronized notes and then try to repair the setup.</string>
</resources>

View file

@ -1,3 +1,3 @@
v3.4.10
- Open trashbin of files app instead of Web UI (#238)
- 🗑 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)

View file

@ -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'
}

View file

@ -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<String, Boolean> callback) {

View file

@ -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<Span> 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<Range<Integer>> freeRanges = findFreeRanges(visitor.builder(), start, end);
for (Range<Integer> 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 <strong>not</strong> taken for a {@link ClickableSpan}.
*/
@NonNull
private static Collection<Range<Integer>> findFreeRanges(@NonNull SpannableBuilder builder, int start, int end) {
final List<Range<Integer>> freeRanges;
final List<Span> 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 <T> List<Span> getSortedSpans(@NonNull SpannableBuilder builder, @NonNull Class<T> 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;
}
}
}

View file

@ -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) {

View file

@ -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<Integer, Boolean> toggleListener;
final TaskListSpan span;
final String content;
private final AtomicBoolean enabled;
private final BiConsumer<Integer, Boolean> toggleListener;
private final TaskListSpan span;
private final int position;
public ToggleTaskListSpan(@NonNull AtomicBoolean enabled, @NonNull BiConsumer<Integer, Boolean> toggleListener, @NonNull TaskListSpan span, String content) {
public ToggleTaskListSpan(@NonNull AtomicBoolean enabled, @NonNull BiConsumer<Integer, Boolean> 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");
}

View file

@ -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<ToggleableTaskListPlugin.ToggleMarkerSpan> 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<SpannableBuilder.Span> 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<ToggleableTaskListPlugin.ToggleMarkerSpan> 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<SpannableBuilder.Span> clickableSpans;
clickableSpans = (List<SpannableBuilder.Span>) m.invoke(null, spannable, ClickableSpan.class, 0, 0);
assertEquals(0, clickableSpans.size());
clickableSpans = (List<SpannableBuilder.Span>) m.invoke(null, spannable, ClickableSpan.class, spannable.length() - 1, spannable.length() - 1);
assertEquals(0, clickableSpans.size());
clickableSpans = (List<SpannableBuilder.Span>) m.invoke(null, spannable, ClickableSpan.class, 0, 5);
assertEquals(0, clickableSpans.size());
clickableSpans = (List<SpannableBuilder.Span>) 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<SpannableBuilder.Span>) m.invoke(null, spannable, ClickableSpan.class, 0, 17);
assertEquals(1, clickableSpans.size());
assertEquals(firstClickableSpan, clickableSpans.get(0).what);
clickableSpans = (List<SpannableBuilder.Span>) m.invoke(null, spannable, ClickableSpan.class, 12, 22);
assertEquals(1, clickableSpans.size());
assertEquals(secondClickableSpan, clickableSpans.get(0).what);
clickableSpans = (List<SpannableBuilder.Span>) 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<Range<Integer>> freeRanges;
freeRanges = (List<Range<Integer>>) m.invoke(null, spannable, 0, 0);
assertEquals(0, freeRanges.size());
freeRanges = (List<Range<Integer>>) m.invoke(null, spannable, spannable.length() - 1, spannable.length() - 1);
assertEquals(0, freeRanges.size());
freeRanges = (List<Range<Integer>>) 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<Range<Integer>>) 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<Range<Integer>>) 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<Range<Integer>>) 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());
}
}