⚙️ Support custom file extensions (Notes ≥ 4.5.0)

Signed-off-by: Stefan Niedermann <info@niedermann.it>
This commit is contained in:
Stefan Niedermann 2022-08-15 10:22:07 +02:00
parent 0f4c3a0baf
commit cfe2a8ccf6
9 changed files with 177 additions and 183 deletions

View file

@ -76,7 +76,7 @@ android {
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.0'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.6'
// Nextcloud SSO
implementation 'com.github.nextcloud:Android-SingleSignOn:0.6.1'

View file

@ -0,0 +1,16 @@
package it.niedermann.owncloud.notes.manageaccounts;
import androidx.annotation.NonNull;
import it.niedermann.owncloud.notes.persistence.entity.Account;
public interface IManageAccountsCallback {
void onSelect(@NonNull Account account);
void onDelete(@NonNull Account account);
void onChangeNotesPath(@NonNull Account account);
void onChangeFileSuffix(@NonNull Account account);
}

View file

@ -21,22 +21,10 @@ public class ManageAccountAdapter extends RecyclerView.Adapter<ManageAccountView
@NonNull
private final List<Account> localAccounts = new ArrayList<>();
@NonNull
private final Consumer<Account> onAccountClick;
@NonNull
private final Consumer<Account> onAccountDelete;
@NonNull
Consumer<Account> onChangeNotesPath;
@NonNull
Consumer<Account> onChangeFileSuffix;
private final IManageAccountsCallback callback;
public ManageAccountAdapter(@NonNull Consumer<Account> onAccountClick,
@NonNull Consumer<Account> onAccountDelete,
@NonNull Consumer<Account> onChangeNotesPath,
@NonNull Consumer<Account> onChangeFileSuffix) {
this.onAccountClick = onAccountClick;
this.onAccountDelete = onAccountDelete;
this.onChangeNotesPath = onChangeNotesPath;
this.onChangeFileSuffix = onChangeFileSuffix;
public ManageAccountAdapter(@NonNull IManageAccountsCallback callback) {
this.callback = callback;
setHasStableIds(true);
}
@ -54,10 +42,7 @@ public class ManageAccountAdapter extends RecyclerView.Adapter<ManageAccountView
@Override
public void onBindViewHolder(@NonNull ManageAccountViewHolder holder, int position) {
final var localAccount = localAccounts.get(position);
holder.bind(localAccount, (localAccountClicked) -> {
setCurrentLocalAccount(localAccountClicked);
onAccountClick.accept(localAccountClicked);
}, onAccountDelete, onChangeNotesPath, onChangeFileSuffix, currentLocalAccount != null && currentLocalAccount.getId() == localAccount.getId());
holder.bind(localAccount, callback, currentLocalAccount != null && currentLocalAccount.getId() == localAccount.getId());
}
@Override

View file

@ -1,31 +1,26 @@
package it.niedermann.owncloud.notes.manageaccounts;
import android.graphics.drawable.LayerDrawable;
import android.net.Uri;
import android.view.Menu;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.PopupMenu;
import androidx.core.util.Consumer;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.request.RequestOptions;
import java.util.stream.Stream;
import it.niedermann.nextcloud.sso.glide.SingleSignOnUrl;
import it.niedermann.owncloud.notes.R;
import it.niedermann.owncloud.notes.databinding.ItemAccountChooseBinding;
import it.niedermann.owncloud.notes.persistence.entity.Account;
import it.niedermann.owncloud.notes.shared.model.ApiVersion;
import static android.view.View.GONE;
import static android.view.View.VISIBLE;
import static it.niedermann.owncloud.notes.branding.BrandingUtil.applyBrandToLayerDrawable;
import static it.niedermann.owncloud.notes.shared.util.ApiVersionUtil.getPreferredApiVersion;
import android.graphics.drawable.LayerDrawable;
import android.net.Uri;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.PopupMenu;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.request.RequestOptions;
import it.niedermann.nextcloud.sso.glide.SingleSignOnUrl;
import it.niedermann.owncloud.notes.R;
import it.niedermann.owncloud.notes.databinding.ItemAccountChooseBinding;
import it.niedermann.owncloud.notes.persistence.entity.Account;
public class ManageAccountViewHolder extends RecyclerView.ViewHolder {
private final ItemAccountChooseBinding binding;
@ -37,10 +32,7 @@ public class ManageAccountViewHolder extends RecyclerView.ViewHolder {
public void bind(
@NonNull Account localAccount,
@NonNull Consumer<Account> onAccountClick,
@NonNull Consumer<Account> onAccountDelete,
@NonNull Consumer<Account> onChangeNotesPath,
@NonNull Consumer<Account> onChangeFileSuffix,
@NonNull IManageAccountsCallback callback,
boolean isCurrentAccount
) {
binding.accountName.setText(localAccount.getUserName());
@ -50,28 +42,31 @@ public class ManageAccountViewHolder extends RecyclerView.ViewHolder {
.error(R.drawable.ic_account_circle_grey_24dp)
.apply(RequestOptions.circleCropTransform())
.into(binding.accountItemAvatar);
itemView.setOnClickListener((v) -> onAccountClick.accept(localAccount));
itemView.setOnClickListener((v) -> callback.onSelect(localAccount));
binding.accountContextMenu.setVisibility(VISIBLE);
binding.accountContextMenu.setOnClickListener((v) -> {
final var popup = new PopupMenu(itemView.getContext(), v);
popup.inflate(R.menu.menu_account);
final var preferredApiVersion = getPreferredApiVersion(localAccount.getApiVersion());
if (preferredApiVersion != null && !preferredApiVersion.supportsSettings()) {
final var menu = popup.getMenu();
Stream.of(
R.id.notes_path,
R.id.file_suffix
).forEach((i) -> menu.removeItem(menu.findItem(i).getItemId()));
if (preferredApiVersion == null || !preferredApiVersion.supportsFileSuffixChange()) {
popup.getMenu().removeItem(popup.getMenu().findItem(R.id.file_suffix).getItemId());
}
if (preferredApiVersion == null || !preferredApiVersion.supportsNotesPathChange()) {
popup.getMenu().removeItem(popup.getMenu().findItem(R.id.notes_path).getItemId());
}
popup.setOnMenuItemClickListener(item -> {
if (item.getItemId() == R.id.notes_path) {
onChangeNotesPath.accept(localAccount);
callback.onChangeNotesPath(localAccount);
return true;
} else if (item.getItemId() == R.id.file_suffix) {
onChangeFileSuffix.accept(localAccount);
callback.onChangeFileSuffix(localAccount);
return true;
} else if (item.getItemId() == R.id.delete) {
onAccountDelete.accept(localAccount);
callback.onDelete(localAccount);
return true;
}
return false;

View file

@ -1,26 +1,28 @@
package it.niedermann.owncloud.notes.manageaccounts;
import static it.niedermann.owncloud.notes.shared.util.ApiVersionUtil.getPreferredApiVersion;
import android.accounts.NetworkErrorException;
import android.os.Bundle;
import android.util.TypedValue;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ProgressBar;
import android.widget.Spinner;
import android.widget.Toast;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.appcompat.app.AlertDialog;
import androidx.annotation.StringRes;
import androidx.lifecycle.ViewModelProvider;
import com.nextcloud.android.sso.AccountImporter;
import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException;
import java.util.function.Function;
import it.niedermann.owncloud.notes.LockedActivity;
import it.niedermann.owncloud.notes.R;
import it.niedermann.owncloud.notes.branding.BrandedAlertDialogBuilder;
@ -35,11 +37,7 @@ import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import static android.os.Build.VERSION.SDK_INT;
import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
import static it.niedermann.owncloud.notes.shared.util.ApiVersionUtil.getPreferredApiVersion;
public class ManageAccountsActivity extends LockedActivity {
public class ManageAccountsActivity extends LockedActivity implements IManageAccountsCallback {
private ActivityManageAccountsBinding binding;
private ManageAccountsViewModel viewModel;
@ -55,12 +53,7 @@ public class ManageAccountsActivity extends LockedActivity {
setContentView(binding.getRoot());
setSupportActionBar(binding.toolbar);
adapter = new ManageAccountAdapter(
this::selectAccount,
this::deleteAccount,
this::onChangeNotesPath,
this::onChangeFileSuffix
);
adapter = new ManageAccountAdapter(this);
binding.accounts.setAdapter(adapter);
viewModel.getAccounts$().observe(this, (accounts) -> {
@ -69,7 +62,7 @@ public class ManageAccountsActivity extends LockedActivity {
return;
}
this.adapter.setLocalAccounts(accounts);
viewModel.getCurrentAccount(this, new IResponseCallback<Account>() {
viewModel.getCurrentAccount(this, new IResponseCallback<>() {
@Override
public void onSuccess(Account result) {
runOnUiThread(() -> adapter.setCurrentLocalAccount(result));
@ -84,12 +77,13 @@ public class ManageAccountsActivity extends LockedActivity {
});
}
private void selectAccount(@NonNull Account accountToSelect) {
public void onSelect(@NonNull Account accountToSelect) {
adapter.setCurrentLocalAccount(accountToSelect);
viewModel.selectAccount(accountToSelect, this);
}
private void deleteAccount(@NonNull Account accountToDelete) {
viewModel.countUnsynchronizedNotes(accountToDelete.getId(), new IResponseCallback<Long>() {
public void onDelete(@NonNull Account accountToDelete) {
viewModel.countUnsynchronizedNotes(accountToDelete.getId(), new IResponseCallback<>() {
@Override
public void onSuccess(Long unsynchronizedChangesCount) {
runOnUiThread(() -> {
@ -113,38 +107,61 @@ public class ManageAccountsActivity extends LockedActivity {
});
}
private void onChangeNotesPath(@NonNull Account localAccount) {
public void onChangeNotesPath(@NonNull Account localAccount) {
changeAccountSetting(localAccount,
R.string.settings_notes_path,
R.string.settings_notes_path_description,
R.string.settings_notes_path_success,
NotesSettings::getNotesPath,
property -> new NotesSettings(property, null)
);
}
public void onChangeFileSuffix(@NonNull Account localAccount) {
changeAccountSetting(localAccount,
R.string.settings_file_suffix,
R.string.settings_file_suffix_description,
R.string.settings_file_suffix_success,
NotesSettings::getFileSuffix,
property -> new NotesSettings(null, property)
);
}
private void changeAccountSetting(@NonNull Account localAccount, @StringRes int title, @StringRes int message, @StringRes int successMessage, @NonNull Function<NotesSettings, String> propertyExtractor, @NonNull Function<String, NotesSettings> settingsFactory) {
final var repository = NotesRepository.getInstance(getApplicationContext());
final var editText = new EditText(this);
final var wrapper = createDialogViewWrapper();
final var dialog = new BrandedAlertDialogBuilder(this)
.setTitle(R.string.settings_notes_path)
.setMessage(R.string.settings_notes_path_description)
.setTitle(title)
.setMessage(message)
.setView(wrapper)
.setNeutralButton(android.R.string.cancel, null)
.setPositiveButton(R.string.action_edit_save, (v, d) -> new Thread(() -> {
try {
final var putSettingsCall = repository.putServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), new NotesSettings(editText.getText().toString(), null), getPreferredApiVersion(localAccount.getApiVersion()));
putSettingsCall.enqueue(new Callback<>() {
@Override
public void onResponse(@NonNull Call<NotesSettings> call, @NonNull Response<NotesSettings> response) {
final var body = response.body();
if (response.isSuccessful() && body != null) {
runOnUiThread(() -> Toast.makeText(ManageAccountsActivity.this, getString(R.string.settings_notes_path_success, body.getNotesPath()), Toast.LENGTH_LONG).show());
} else {
runOnUiThread(() -> Toast.makeText(ManageAccountsActivity.this, getString(R.string.http_status_code, response.code()), Toast.LENGTH_LONG).show());
.setPositiveButton(R.string.action_edit_save, (v, d) -> {
final var property = editText.getText().toString();
new Thread(() -> {
try {
final var putSettingsCall = repository.putServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), settingsFactory.apply(property), getPreferredApiVersion(localAccount.getApiVersion()));
putSettingsCall.enqueue(new Callback<>() {
@Override
public void onResponse(@NonNull Call<NotesSettings> call, @NonNull Response<NotesSettings> response) {
final var body = response.body();
if (response.isSuccessful() && body != null) {
runOnUiThread(() -> Toast.makeText(ManageAccountsActivity.this, getString(successMessage, propertyExtractor.apply(body)), Toast.LENGTH_LONG).show());
} else {
runOnUiThread(() -> Toast.makeText(ManageAccountsActivity.this, getString(R.string.http_status_code, response.code()), Toast.LENGTH_LONG).show());
}
}
}
@Override
public void onFailure(@NonNull Call<NotesSettings> call, @NonNull Throwable t) {
runOnUiThread(() -> ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()));
}
});
} catch (NextcloudFilesAppAccountNotFoundException e) {
ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName());
}
}).start())
@Override
public void onFailure(@NonNull Call<NotesSettings> call, @NonNull Throwable t) {
runOnUiThread(() -> ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()));
}
});
} catch (NextcloudFilesAppAccountNotFoundException e) {
ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName());
}
}).start();
})
.show();
try {
repository.getServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), getPreferredApiVersion(localAccount.getApiVersion()))
@ -155,8 +172,7 @@ public class ManageAccountsActivity extends LockedActivity {
final var body = response.body();
if (response.isSuccessful() && body != null) {
wrapper.removeAllViews();
final var editText = new EditText(ManageAccountsActivity.this);
editText.setText(body.getNotesPath());
editText.setText(propertyExtractor.apply(body));
wrapper.addView(editText);
} else {
dialog.dismiss();
@ -179,88 +195,13 @@ public class ManageAccountsActivity extends LockedActivity {
}
}
private void onChangeFileSuffix(@NonNull Account localAccount) {
final var repository = NotesRepository.getInstance(getApplicationContext());
final var spinner = new Spinner(this);
final var wrapper = createDialogViewWrapper();
final var adapter = ArrayAdapter.createFromResource(this, R.array.settings_file_suffixes, android.R.layout.simple_spinner_item);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinner.setAdapter(adapter);
final var dialog = new BrandedAlertDialogBuilder(this)
.setTitle(R.string.settings_file_suffix)
.setMessage(R.string.settings_file_suffix_description)
.setView(wrapper)
.setNeutralButton(android.R.string.cancel, null)
.setPositiveButton(R.string.action_edit_save, (v, d) -> new Thread(() -> {
try {
final Call<NotesSettings> putSettingsCall = repository.putServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), new NotesSettings(null, spinner.getSelectedItem().toString()), getPreferredApiVersion(localAccount.getApiVersion()));
putSettingsCall.enqueue(new Callback<>() {
@Override
public void onResponse(@NonNull Call<NotesSettings> call, @NonNull Response<NotesSettings> response) {
final var body = response.body();
if (response.isSuccessful() && body != null) {
runOnUiThread(() -> Toast.makeText(ManageAccountsActivity.this, getString(R.string.settings_file_suffix_success, body.getNotesPath()), Toast.LENGTH_LONG).show());
} else {
runOnUiThread(() -> Toast.makeText(ManageAccountsActivity.this, getString(R.string.http_status_code, response.code()), Toast.LENGTH_LONG).show());
}
}
@Override
public void onFailure(@NonNull Call<NotesSettings> call, @NonNull Throwable t) {
runOnUiThread(() -> ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()));
}
});
} catch (NextcloudFilesAppAccountNotFoundException e) {
runOnUiThread(() -> ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()));
}
}).start())
.show();
try {
repository.getServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), getPreferredApiVersion(localAccount.getApiVersion()))
.enqueue(new Callback<>() {
@Override
public void onResponse(@NonNull Call<NotesSettings> call, @NonNull Response<NotesSettings> response) {
final NotesSettings body = response.body();
runOnUiThread(() -> {
if (response.isSuccessful() && body != null) {
for (int i = 0; i < adapter.getCount(); i++) {
if (adapter.getItem(i).equals(body.getFileSuffix())) {
spinner.setSelection(i);
break;
}
}
wrapper.removeAllViews();
wrapper.addView(spinner);
} else {
dialog.dismiss();
ExceptionDialogFragment.newInstance(new Exception(getString(R.string.http_status_code, response.code()))).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName());
}
});
}
@Override
public void onFailure(@NonNull Call<NotesSettings> call, @NonNull Throwable t) {
runOnUiThread(() -> {
dialog.dismiss();
ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName());
});
}
});
} catch (NextcloudFilesAppAccountNotFoundException e) {
dialog.dismiss();
ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName());
}
}
@NonNull
private ViewGroup createDialogViewWrapper() {
final var progressBar = new ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal);
progressBar.setIndeterminate(true);
final var wrapper = new FrameLayout(this);
final int paddingVertical = getResources().getDimensionPixelSize(R.dimen.spacer_1x);
final int paddingHorizontal = SDK_INT >= LOLLIPOP_MR1
? getDimensionFromAttribute(android.R.attr.dialogPreferredPadding)
: getResources().getDimensionPixelSize(R.dimen.spacer_2x);
final int paddingHorizontal = getDimensionFromAttribute(android.R.attr.dialogPreferredPadding);
wrapper.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical);
wrapper.addView(progressBar);
return wrapper;

View file

@ -4,16 +4,16 @@ package it.niedermann.owncloud.notes.shared.model;
import androidx.annotation.NonNull;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@SuppressWarnings("WeakerAccess")
public class ApiVersion implements Comparable<ApiVersion> {
private static final Pattern NUMBER_EXTRACTION_PATTERN = Pattern.compile("[0-9]+");
private static final ApiVersion VERSION_1_2 = new ApiVersion("1.2", 1, 2);
public static final ApiVersion API_VERSION_0_2 = new ApiVersion(0, 2);
public static final ApiVersion API_VERSION_1_0 = new ApiVersion(1, 0);
public static final ApiVersion API_VERSION_0_2 = new ApiVersion("0.2", 0, 2);
public static final ApiVersion API_VERSION_1_0 = new ApiVersion("1.0", 1, 0);
public static final ApiVersion API_VERSION_1_2 = new ApiVersion("1.2", 1, 2);
public static final ApiVersion API_VERSION_1_3 = new ApiVersion("1.3", 1, 3);
public static final ApiVersion[] SUPPORTED_API_VERSIONS = new ApiVersion[]{
API_VERSION_1_0,
@ -45,7 +45,7 @@ public class ApiVersion implements Comparable<ApiVersion> {
public static ApiVersion of(String versionString) {
int major = 0, minor = 0;
if (versionString != null) {
String[] split = versionString.split("\\.");
final String[] split = versionString.split("\\.");
if (split.length > 0) {
major = extractNumber(split[0]);
if (split.length > 1) {
@ -80,8 +80,19 @@ public class ApiVersion implements Comparable<ApiVersion> {
return 0;
}
public boolean supportsSettings() {
return getMajor() >= 1 && getMinor() >= 2;
/**
* While setting the file suffix to <code>.txt</code> or <code>.md</code> was possible starting
* with {@link #API_VERSION_1_2}, we will only support this feature with {@link #API_VERSION_1_3}
* because it allows us to set any value and skip client side validations.
*
* @see <a href="https://github.com/nextcloud/notes/blob/master/docs/api/v1.md#settings">Settings API</a>
*/
public boolean supportsFileSuffixChange() {
return getMajor() >= API_VERSION_1_3.getMajor() && getMinor() >= API_VERSION_1_3.getMinor();
}
public boolean supportsNotesPathChange() {
return getMajor() >= API_VERSION_1_2.getMajor() && getMinor() >= API_VERSION_1_2.getMinor();
}
/**

View file

@ -0,0 +1,45 @@
package it.niedermann.owncloud.notes.shared.model;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class ApiVersionTest {
@Test
public void shouldOnlyCompareMajorApiVersions() {
final var apiVersion = new ApiVersion("1.0", 1, 0);
assertEquals(1, apiVersion.compareTo(ApiVersion.API_VERSION_0_2));
assertEquals(0, apiVersion.compareTo(ApiVersion.API_VERSION_1_0));
assertEquals(0, apiVersion.compareTo(ApiVersion.API_VERSION_1_2));
}
@Test
public void shouldOnlyEqualMajorApiVersions() {
final var apiVersion = new ApiVersion("1.0", 1, 0);
assertNotEquals(apiVersion, ApiVersion.API_VERSION_0_2);
assertEquals(apiVersion, ApiVersion.API_VERSION_1_0);
assertEquals(apiVersion, ApiVersion.API_VERSION_1_2);
}
@Test
public void shouldSupportFileSuffixChangesWithApi1_3andAbove() {
assertFalse(ApiVersion.API_VERSION_0_2.supportsFileSuffixChange());
assertFalse(ApiVersion.API_VERSION_1_0.supportsFileSuffixChange());
assertFalse(ApiVersion.API_VERSION_1_2.supportsFileSuffixChange());
assertTrue(ApiVersion.API_VERSION_1_3.supportsFileSuffixChange());
}
@Test
public void shouldSupportNotesPathChangesWithApi1_2andAbove() {
assertFalse(ApiVersion.API_VERSION_0_2.supportsNotesPathChange());
assertFalse(ApiVersion.API_VERSION_1_0.supportsNotesPathChange());
assertTrue(ApiVersion.API_VERSION_1_2.supportsNotesPathChange());
assertTrue(ApiVersion.API_VERSION_1_3.supportsNotesPathChange());
}
}

View file

@ -6,7 +6,7 @@ buildscript {
google()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.3.0-beta05'
classpath 'com.android.tools.build:gradle:7.2.2'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files

View file

@ -1,6 +1,7 @@
- 📱️ Add option to not keep screen on (#1531)
- 🐞 Favorite star in menu not clickable (#1541)
- 🐞 Fix back button behavior in widgets (#1412) - by @newhinton
- ⚙️ Support custom file extensions (Notes ≥ 4.5.0)
- ⚙️ Add monochrome icon (#1544) - by @salixor
- ⚙️ Use the new Android 12 SplashScreen API (#1546) - by @salixor
- 🌎 Updated translations