diff options
Diffstat (limited to 'app/src/main/java/it/niedermann')
47 files changed, 2154 insertions, 1838 deletions
diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherDialog.java b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherDialog.java index 79467d86..f5643d86 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherDialog.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherDialog.java @@ -21,7 +21,7 @@ import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.branding.BrandedDialogFragment; import it.niedermann.owncloud.notes.databinding.DialogAccountSwitcherBinding; import it.niedermann.owncloud.notes.manageaccounts.ManageAccountsActivity; -import it.niedermann.owncloud.notes.persistence.NotesDatabase; +import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.Account; import static it.niedermann.owncloud.notes.branding.BrandingUtil.applyBrandToLayerDrawable; @@ -33,7 +33,7 @@ public class AccountSwitcherDialog extends BrandedDialogFragment { private static final String KEY_CURRENT_ACCOUNT_ID = "current_account_id"; - private NotesDatabase db; + private NotesRepository repo; private DialogAccountSwitcherBinding binding; private AccountSwitcherListener accountSwitcherListener; private long currentAccountId; @@ -55,7 +55,7 @@ public class AccountSwitcherDialog extends BrandedDialogFragment { this.currentAccountId = args.getLong(KEY_CURRENT_ACCOUNT_ID); } - db = NotesDatabase.getInstance(requireActivity()); + repo = NotesRepository.getInstance(requireContext()); } @NonNull @@ -63,7 +63,7 @@ public class AccountSwitcherDialog extends BrandedDialogFragment { public Dialog onCreateDialog(Bundle savedInstanceState) { binding = DialogAccountSwitcherBinding.inflate(requireActivity().getLayoutInflater()); - final LiveData<Account> account$ = db.getAccountDao().getAccountById$(currentAccountId); + final LiveData<Account> account$ = repo.getAccountById$(currentAccountId); account$.observe(requireActivity(), (currentLocalAccount) -> { account$.removeObservers(requireActivity()); @@ -81,7 +81,7 @@ public class AccountSwitcherDialog extends BrandedDialogFragment { dismiss(); })); binding.accountsList.setAdapter(adapter); - final LiveData<List<Account>> localAccounts$ = db.getAccountDao().getAccounts$(); + final LiveData<List<Account>> localAccounts$ = repo.getAccounts$(); localAccounts$.observe(requireActivity(), (localAccounts) -> { localAccounts$.removeObservers(requireActivity()); for (Account localAccount : localAccounts) { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java index 383d9e1b..b1cf1d54 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java @@ -41,7 +41,7 @@ import it.niedermann.owncloud.notes.edit.category.CategoryDialogFragment; import it.niedermann.owncloud.notes.edit.category.CategoryDialogFragment.CategoryDialogListener; import it.niedermann.owncloud.notes.edit.title.EditTitleDialogFragment; import it.niedermann.owncloud.notes.edit.title.EditTitleDialogFragment.EditTitleListener; -import it.niedermann.owncloud.notes.persistence.NotesDatabase; +import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.shared.model.ApiVersion; @@ -76,7 +76,7 @@ public abstract class BaseNoteFragment extends BrandedFragment implements Catego @Nullable private Note originalNote; private int originalScrollY; - protected NotesDatabase db; + protected NotesRepository repo; private NoteFragmentListener listener; private boolean titleModified = false; @@ -90,7 +90,7 @@ public abstract class BaseNoteFragment extends BrandedFragment implements Catego } catch (ClassCastException e) { throw new ClassCastException(context.getClass() + " must implement " + NoteFragmentListener.class); } - db = NotesDatabase.getInstance(context); + repo = NotesRepository.getInstance(context); } @Override @@ -99,7 +99,7 @@ public abstract class BaseNoteFragment extends BrandedFragment implements Catego new Thread(() -> { try { SingleSignOnAccount ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext().getApplicationContext()); - this.localAccount = db.getAccountDao().getAccountByName(ssoAccount.name); + this.localAccount = repo.getAccountByName(ssoAccount.name); if (savedInstanceState == null) { long id = requireArguments().getLong(PARAM_NOTE_ID); @@ -107,11 +107,11 @@ public abstract class BaseNoteFragment extends BrandedFragment implements Catego long accountId = requireArguments().getLong(PARAM_ACCOUNT_ID); if (accountId > 0) { /* Switch account if account id has been provided */ - this.localAccount = db.getAccountDao().getAccountById(accountId); + this.localAccount = repo.getAccountById(accountId); SingleAccountHelper.setCurrentAccount(requireContext().getApplicationContext(), localAccount.getAccountName()); } isNew = false; - note = originalNote = db.getNoteDao().getNoteById(id); + note = originalNote = repo.getNoteById(id); requireActivity().runOnUiThread(() -> onNoteLoaded(note)); requireActivity().invalidateOptionsMenu(); } else { @@ -126,7 +126,7 @@ public abstract class BaseNoteFragment extends BrandedFragment implements Catego requireActivity().invalidateOptionsMenu(); } } else { - note = db.addNote(localAccount.getId(), cloudNote); + note = repo.addNote(localAccount.getId(), cloudNote); originalNote = null; requireActivity().runOnUiThread(() -> onNoteLoaded(note)); requireActivity().invalidateOptionsMenu(); @@ -193,7 +193,7 @@ public abstract class BaseNoteFragment extends BrandedFragment implements Catego if (note != null) { prepareFavoriteOption(menu.findItem(R.id.menu_favorite)); - menu.findItem(R.id.menu_title).setVisible(localAccount.getPreferredApiVersion() != null && localAccount.getPreferredApiVersion().compareTo(new ApiVersion("1.0", 1, 0)) >= 0); + menu.findItem(R.id.menu_title).setVisible(localAccount.getPreferredApiVersion() != null && localAccount.getPreferredApiVersion().compareTo(ApiVersion.API_VERSION_1_0) >= 0); menu.findItem(R.id.menu_delete).setVisible(!isNew); } } @@ -213,19 +213,19 @@ public abstract class BaseNoteFragment extends BrandedFragment implements Catego if (itemId == R.id.menu_cancel) { new Thread(() -> { if (originalNote == null) { - db.deleteNoteAndSync(localAccount, note.getId()); + repo.deleteNoteAndSync(localAccount, note.getId()); } else { - db.updateNoteAndSync(localAccount, originalNote, null, null, null); + repo.updateNoteAndSync(localAccount, originalNote, null, null, null); } }).start(); listener.close(); return true; } else if (itemId == R.id.menu_delete) { - db.deleteNoteAndSync(localAccount, note.getId()); + repo.deleteNoteAndSync(localAccount, note.getId()); listener.close(); return true; } else if (itemId == R.id.menu_favorite) { - db.toggleFavoriteAndSync(localAccount, note.getId()); + repo.toggleFavoriteAndSync(localAccount, note.getId()); listener.onNoteUpdated(note); prepareFavoriteOption(item); return true; @@ -288,7 +288,7 @@ public abstract class BaseNoteFragment extends BrandedFragment implements Catego public void onCloseNote() { if (!titleModified && originalNote == null && getContent().isEmpty()) { - db.deleteNoteAndSync(localAccount, note.getId()); + repo.deleteNoteAndSync(localAccount, note.getId()); } } @@ -304,13 +304,13 @@ public abstract class BaseNoteFragment extends BrandedFragment implements Catego if (note.getContent().equals(newContent)) { if (note.getScrollY() != originalScrollY) { Log.v(TAG, "... only saving new scroll state, since content did not change"); - db.getNoteDao().updateScrollY(note.getId(), note.getScrollY()); + repo.updateScrollY(note.getId(), note.getScrollY()); } else { Log.v(TAG, "... not saving, since nothing has changed"); } } else { // FIXME requires database queries on main thread! - note = db.updateNoteAndSync(localAccount, note, newContent, null, callback); + note = repo.updateNoteAndSync(localAccount, note, newContent, null, callback); listener.onNoteUpdated(note); requireActivity().invalidateOptionsMenu(); } @@ -354,7 +354,7 @@ public abstract class BaseNoteFragment extends BrandedFragment implements Catego @Override public void onCategoryChosen(String category) { - db.setCategory(localAccount, note.getId(), category); + repo.setCategory(localAccount, note.getId(), category); note.setCategory(category); listener.onNoteUpdated(note); } @@ -364,13 +364,13 @@ public abstract class BaseNoteFragment extends BrandedFragment implements Catego titleModified = true; note.setTitle(newTitle); new Thread(() -> { - note = db.updateNoteAndSync(localAccount, note, note.getContent(), newTitle, null); + note = repo.updateNoteAndSync(localAccount, note, note.getContent(), newTitle, null); requireActivity().runOnUiThread(() -> listener.onNoteUpdated(note)); }).start(); } public void moveNote(Account account) { - db.moveNoteToAnotherAccount(account, note); + repo.moveNoteToAnotherAccount(account, note); listener.close(); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java index fca142e9..f1626149 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java @@ -125,7 +125,7 @@ public class NotePreviewFragment extends SearchableBaseNoteFragment implements O protected void registerInternalNoteLinkHandler() { binding.singleNoteContent.registerOnLinkClickCallback((link) -> { try { - final long noteLocalId = db.getNoteDao().getLocalIdByRemoteId(this.note.getAccountId(), Long.parseLong(link)); + final long noteLocalId = repo.getLocalIdByRemoteId(this.note.getAccountId(), Long.parseLong(link)); Log.i(TAG, "Found note for remoteId \"" + link + "\" in account \"" + this.note.getAccountId() + "\" with localId + \"" + noteLocalId + "\". Attempt to open " + EditNoteActivity.class.getSimpleName() + " for this note."); startActivity(new Intent(requireActivity().getApplicationContext(), EditNoteActivity.class).putExtra(EditNoteActivity.PARAM_NOTE_ID, noteLocalId)); return true; @@ -153,20 +153,20 @@ public class NotePreviewFragment extends SearchableBaseNoteFragment implements O @Override public void onRefresh() { - if (noteLoaded && db.getNoteServerSyncHelper().isSyncPossible() && SSOUtil.isConfigured(getContext())) { + if (noteLoaded && repo.isSyncPossible() && SSOUtil.isConfigured(getContext())) { binding.swiperefreshlayout.setRefreshing(true); new Thread(() -> { try { - final Account account = db.getAccountDao().getAccountByName(SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext()).name); - db.getNoteServerSyncHelper().addCallbackPull(account, () -> new Thread(() -> { - note = db.getNoteDao().getNoteById(note.getId()); + final Account account = repo.getAccountByName(SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext()).name); + repo.addCallbackPull(account, () -> new Thread(() -> { + note = repo.getNoteById(note.getId()); changedText = note.getContent(); requireActivity().runOnUiThread(() -> { binding.singleNoteContent.setMarkdownString(note.getContent()); binding.swiperefreshlayout.setRefreshing(false); }); }).start()); - db.getNoteServerSyncHelper().scheduleSync(account, false); + repo.scheduleSync(account, false); } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { e.printStackTrace(); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/category/CategoryViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/category/CategoryViewModel.java index 96900a3b..ea5efd37 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/category/CategoryViewModel.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/category/CategoryViewModel.java @@ -11,7 +11,7 @@ import androidx.lifecycle.MutableLiveData; import java.util.List; import it.niedermann.owncloud.notes.main.navigation.NavigationItem; -import it.niedermann.owncloud.notes.persistence.NotesDatabase; +import it.niedermann.owncloud.notes.persistence.NotesRepository; import static androidx.lifecycle.Transformations.map; import static androidx.lifecycle.Transformations.switchMap; @@ -19,14 +19,14 @@ import static it.niedermann.owncloud.notes.shared.util.DisplayUtils.convertToCat public class CategoryViewModel extends AndroidViewModel { - private final NotesDatabase db; + private final NotesRepository repo; @NonNull private final MutableLiveData<String> searchTerm = new MutableLiveData<>(""); public CategoryViewModel(@NonNull Application application) { super(application); - db = NotesDatabase.getInstance(application); + repo = NotesRepository.getInstance(application); } public void postSearchTerm(@NonNull String searchTerm) { @@ -36,7 +36,7 @@ public class CategoryViewModel extends AndroidViewModel { @NonNull public LiveData<List<NavigationItem.CategoryNavigationItem>> getCategories(long accountId) { return switchMap(this.searchTerm, searchTerm -> - map(db.getNoteDao().searchCategories$(accountId, TextUtils.isEmpty(searchTerm) ? "%" : "%" + searchTerm + "%"), + map(repo.searchCategories$(accountId, TextUtils.isEmpty(searchTerm) ? "%" : "%" + searchTerm + "%"), categories -> convertToCategoryNavigationItem(getApplication(), categories))); } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountActivity.java index 2695f08a..c942b345 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountActivity.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountActivity.java @@ -1,34 +1,36 @@ package it.niedermann.owncloud.notes.importaccount; +import android.accounts.NetworkErrorException; import android.content.Intent; -import android.graphics.drawable.Drawable; -import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.View; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.lifecycle.LiveData; import androidx.lifecycle.ViewModelProvider; import com.nextcloud.android.sso.AccountImporter; import com.nextcloud.android.sso.exceptions.AccountImportCancelledException; import com.nextcloud.android.sso.exceptions.AndroidGetAccountsPermissionNotGranted; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppNotInstalledException; +import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; +import com.nextcloud.android.sso.exceptions.UnknownErrorException; import com.nextcloud.android.sso.helper.SingleAccountHelper; import com.nextcloud.android.sso.ui.UiExceptionManager; +import java.net.HttpURLConnection; + import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.branding.BrandingUtil; import it.niedermann.owncloud.notes.databinding.ActivityImportAccountBinding; import it.niedermann.owncloud.notes.exception.ExceptionDialogFragment; import it.niedermann.owncloud.notes.exception.ExceptionHandler; import it.niedermann.owncloud.notes.persistence.CapabilitiesClient; +import it.niedermann.owncloud.notes.persistence.ApiProvider; import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.shared.model.Capabilities; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; public class ImportAccountActivity extends AppCompatActivity { @@ -52,6 +54,7 @@ public class ImportAccountActivity extends AppCompatActivity { binding.welcomeText.setText(getString(R.string.welcome_text, getString(R.string.app_name))); binding.addButton.setOnClickListener((v) -> { binding.addButton.setEnabled(false); + binding.status.setVisibility(View.GONE); try { AccountImporter.pickNewAccount(this); } catch (NextcloudFilesAppNotInstalledException e) { @@ -86,29 +89,50 @@ public class ImportAccountActivity extends AppCompatActivity { try { Log.i(TAG, "Loading capabilities for " + ssoAccount.name); final Capabilities capabilities = CapabilitiesClient.getCapabilities(getApplicationContext(), ssoAccount, null); - LiveData<Account> createLiveData = importAccountViewModel.addAccount(ssoAccount.url, ssoAccount.userId, ssoAccount.name, capabilities); - runOnUiThread(() -> createLiveData.observe(this, (account) -> { - if (account != null) { - Log.i(TAG, capabilities.toString()); - BrandingUtil.saveBrandColors(this, capabilities.getColor(), capabilities.getTextColor()); - setResult(RESULT_OK); - finish(); - } else { - binding.addButton.setEnabled(true); - ExceptionDialogFragment.newInstance(new IllegalStateException("Created account is null.")).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + importAccountViewModel.addAccount(ssoAccount.url, ssoAccount.userId, ssoAccount.name, capabilities, new IResponseCallback<Account>() { + @Override + public void onSuccess(Account account) { + runOnUiThread(() -> { + Log.i(TAG, capabilities.toString()); + BrandingUtil.saveBrandColors(ImportAccountActivity.this, capabilities.getColor(), capabilities.getTextColor()); + setResult(RESULT_OK); + finish(); + }); } - })); - } catch (Throwable e) { - e.printStackTrace(); + + @Override + public void onError(@NonNull Throwable t) { + runOnUiThread(() -> { + binding.addButton.setEnabled(true); + ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + }); + } + }); + } catch (Throwable t) { + t.printStackTrace(); + ApiProvider.invalidateAPICache(ssoAccount); + SingleAccountHelper.setCurrentAccount(this, null); runOnUiThread(() -> { - binding.addButton.setEnabled(true); - ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + restoreCleanState(); + if (t instanceof NextcloudHttpRequestFailedException && ((NextcloudHttpRequestFailedException) t).getStatusCode() == HttpURLConnection.HTTP_UNAVAILABLE) { + binding.status.setText(R.string.error_maintenance_mode); + binding.status.setVisibility(View.VISIBLE); + } else if (t instanceof NetworkErrorException) { + binding.status.setText(getString(R.string.error_sync, getString(R.string.error_no_network))); + binding.status.setVisibility(View.VISIBLE); + } else if (t instanceof UnknownErrorException && t.getMessage().contains("No address associated with hostname")) { + // https://github.com/stefan-niedermann/nextcloud-notes/issues/1014 + binding.status.setText(R.string.you_have_to_be_connected_to_the_internet_in_order_to_add_an_account); + binding.status.setVisibility(View.VISIBLE); + } else { + ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } }); } }).start(); }); } catch (AccountImportCancelledException e) { - runOnUiThread(() -> binding.addButton.setEnabled(true)); + restoreCleanState(); Log.i(TAG, "Account import has been canceled."); } } @@ -118,4 +142,11 @@ public class ImportAccountActivity extends AppCompatActivity { super.onRequestPermissionsResult(requestCode, permissions, grantResults); AccountImporter.onRequestPermissionsResult(requestCode, permissions, grantResults, this); } + + private void restoreCleanState() { + runOnUiThread(() -> { + binding.addButton.setEnabled(true); + binding.progressCircular.setVisibility(View.GONE); + }); + } }
\ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountViewModel.java index 04b30d8d..905a59b1 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountViewModel.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/importaccount/ImportAccountViewModel.java @@ -6,23 +6,24 @@ import androidx.annotation.NonNull; import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; -import it.niedermann.owncloud.notes.persistence.NotesDatabase; +import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.shared.model.Capabilities; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; public class ImportAccountViewModel extends AndroidViewModel { private static final String TAG = ImportAccountViewModel.class.getSimpleName(); @NonNull - private final NotesDatabase db; + private final NotesRepository repo; public ImportAccountViewModel(@NonNull Application application) { super(application); - this.db = NotesDatabase.getInstance(application.getApplicationContext()); + this.repo = NotesRepository.getInstance(application); } - public LiveData<Account> addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities) { - return db.addAccount(url, username, accountName, capabilities); + public void addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @NonNull IResponseCallback<Account> callback) { + repo.addAccount(url, username, accountName, capabilities, callback); } } 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 3600b0d6..d889689d 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 @@ -1,5 +1,6 @@ package it.niedermann.owncloud.notes.main; +import android.accounts.NetworkErrorException; import android.animation.AnimatorInflater; import android.app.SearchManager; import android.content.Intent; @@ -39,10 +40,13 @@ import com.google.android.material.snackbar.Snackbar; import com.nextcloud.android.sso.AccountImporter; import com.nextcloud.android.sso.exceptions.AccountImportCancelledException; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; import com.nextcloud.android.sso.exceptions.TokenMismatchException; +import com.nextcloud.android.sso.exceptions.UnknownErrorException; import com.nextcloud.android.sso.helper.SingleAccountHelper; +import java.net.HttpURLConnection; import java.util.Collection; import java.util.LinkedList; @@ -71,6 +75,7 @@ import it.niedermann.owncloud.notes.main.navigation.NavigationClickListener; import it.niedermann.owncloud.notes.main.navigation.NavigationItem; import it.niedermann.owncloud.notes.persistence.CapabilitiesClient; import it.niedermann.owncloud.notes.persistence.CapabilitiesWorker; +import it.niedermann.owncloud.notes.persistence.ApiProvider; import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.shared.model.Capabilities; @@ -267,19 +272,24 @@ public class MainActivity extends LockedActivity implements NoteClickListener, A .apply(RequestOptions.circleCropTransform()) .into(activityBinding.launchAccountSwitcher); - mainViewModel.synchronizeNotes(nextAccount, new IResponseCallback() { + mainViewModel.synchronizeNotes(nextAccount, new IResponseCallback<Void>() { @Override - public void onSuccess() { + public void onSuccess(Void v) { Log.d(TAG, "Successfully synchronized notes for " + nextAccount.getAccountName()); } @Override public void onError(@NonNull Throwable t) { runOnUiThread(() -> { - if (t.getClass() == IntendedOfflineException.class || t instanceof IntendedOfflineException) { + if (t instanceof IntendedOfflineException) { Log.i(TAG, "Capabilities and notes not updated because " + nextAccount.getAccountName() + " is offline by intention."); - } else { + } else if (t instanceof NetworkErrorException) { BrandedSnackbar.make(coordinatorLayout, getString(R.string.error_sync, getString(R.string.error_no_network)), Snackbar.LENGTH_LONG).show(); + } else { + BrandedSnackbar.make(coordinatorLayout, R.string.error_synchronization, Snackbar.LENGTH_LONG) + .setAction(R.string.simple_more, v -> ExceptionDialogFragment.newInstance(t) + .show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())) + .show(); } }); } @@ -309,17 +319,26 @@ public class MainActivity extends LockedActivity implements NoteClickListener, A final LiveData<Account> accountLiveData = mainViewModel.getCurrentAccount(); accountLiveData.observe(this, (currentAccount) -> { accountLiveData.removeObservers(this); - mainViewModel.synchronizeNotes(currentAccount, new IResponseCallback() { - @Override - public void onSuccess() { - Log.d(TAG, "Successfully synchronized notes for " + currentAccount.getAccountName()); - } + try { + // It is possible that after the deletion of the last account, this onResponse gets called before the ImportAccountActivity gets started. + if (SingleAccountHelper.getCurrentSingleSignOnAccount(this) != null) { + mainViewModel.synchronizeNotes(currentAccount, new IResponseCallback<Void>() { + @Override + public void onSuccess(Void v) { + Log.d(TAG, "Successfully synchronized notes for " + currentAccount.getAccountName()); + } - @Override - public void onError(@NonNull Throwable t) { - t.printStackTrace(); + @Override + public void onError(@NonNull Throwable t) { + t.printStackTrace(); + } + }); } - }); + } catch (NextcloudFilesAppAccountNotFoundException e) { + ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } catch (NoCurrentAccountSelectedException e) { + Log.i(TAG, "No current account is selected - maybe the last account has been deleted?"); + } }); super.onResume(); } @@ -428,9 +447,9 @@ public class MainActivity extends LockedActivity implements NoteClickListener, A final LiveData<Account> syncLiveData = mainViewModel.getCurrentAccount(); final Observer<Account> syncObserver = currentAccount -> { syncLiveData.removeObservers(this); - mainViewModel.synchronizeCapabilitiesAndNotes(currentAccount, new IResponseCallback() { + mainViewModel.synchronizeCapabilitiesAndNotes(currentAccount, new IResponseCallback<Void>() { @Override - public void onSuccess() { + public void onSuccess(Void v) { Log.d(TAG, "Successfully synchronized capabilities and notes for " + currentAccount.getAccountName()); } @@ -438,10 +457,17 @@ public class MainActivity extends LockedActivity implements NoteClickListener, A public void onError(@NonNull Throwable t) { runOnUiThread(() -> { swipeRefreshLayout.setRefreshing(false); - if (t.getClass() == IntendedOfflineException.class || t instanceof IntendedOfflineException) { + if (t instanceof IntendedOfflineException) { Log.i(TAG, "Capabilities and notes not updated because " + currentAccount.getAccountName() + " is offline by intention."); - } else { + } else if (t instanceof NextcloudHttpRequestFailedException && ((NextcloudHttpRequestFailedException) t).getStatusCode() == HttpURLConnection.HTTP_UNAVAILABLE) { + BrandedSnackbar.make(coordinatorLayout, R.string.error_maintenance_mode, Snackbar.LENGTH_LONG).show(); + } else if (t instanceof NetworkErrorException) { BrandedSnackbar.make(coordinatorLayout, getString(R.string.error_sync, getString(R.string.error_no_network)), Snackbar.LENGTH_LONG).show(); + } else { + BrandedSnackbar.make(coordinatorLayout, R.string.error_synchronization, Snackbar.LENGTH_LONG) + .setAction(R.string.simple_more, v -> ExceptionDialogFragment.newInstance(t) + .show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())) + .show(); } }); } @@ -631,15 +657,23 @@ public class MainActivity extends LockedActivity implements NoteClickListener, A try { Log.i(TAG, "Refreshing capabilities for " + ssoAccount.name); final Capabilities capabilities = CapabilitiesClient.getCapabilities(getApplicationContext(), ssoAccount, null); - LiveData<Account> createLiveData = mainViewModel.addAccount(ssoAccount.url, ssoAccount.userId, ssoAccount.name, capabilities); - runOnUiThread(() -> createLiveData.observe(this, (account) -> { - new Thread(() -> { - Log.i(TAG, capabilities.toString()); - final Account a = mainViewModel.getLocalAccountByAccountName(ssoAccount.name); - runOnUiThread(() -> mainViewModel.postCurrentAccount(a)); - }).start(); - })); - } catch (Exception e) { + mainViewModel.addAccount(ssoAccount.url, ssoAccount.userId, ssoAccount.name, capabilities, new IResponseCallback<Account>() { + @Override + public void onSuccess(Account result) { + new Thread(() -> { + Log.i(TAG, capabilities.toString()); + final Account a = mainViewModel.getLocalAccountByAccountName(ssoAccount.name); + runOnUiThread(() -> mainViewModel.postCurrentAccount(a)); + }).start(); + } + + @Override + public void onError(@NonNull Throwable t) { + runOnUiThread(() -> ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())); + } + }); + } catch (Throwable e) { + ApiProvider.invalidateAPICache(ssoAccount); // Happens when importing an already existing account the second time if (e instanceof TokenMismatchException && mainViewModel.getLocalAccountByAccountName(ssoAccount.name) != null) { Log.w(TAG, "Received " + TokenMismatchException.class.getSimpleName() + " and the given ssoAccount.name (" + ssoAccount.name + ") does already exist in the database. Assume that this account has already been imported."); @@ -648,6 +682,9 @@ public class MainActivity extends LockedActivity implements NoteClickListener, A // TODO there is already a sync in progress and results in displaying a TokenMissMatchException snackbar which conflicts with this one coordinatorLayout.post(() -> BrandedSnackbar.make(coordinatorLayout, R.string.account_already_imported, Snackbar.LENGTH_LONG).show()); }); + } else if (e instanceof UnknownErrorException && e.getMessage().contains("No address associated with hostname")) { + // https://github.com/stefan-niedermann/nextcloud-notes/issues/1014 + runOnUiThread(() -> Snackbar.make(coordinatorLayout, R.string.you_have_to_be_connected_to_the_internet_in_order_to_add_an_account, Snackbar.LENGTH_LONG).show()); } else { e.printStackTrace(); runOnUiThread(() -> { 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 0241951e..ec0e71c7 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 @@ -21,7 +21,6 @@ import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundExce import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; import com.nextcloud.android.sso.helper.SingleAccountHelper; -import java.net.HttpURLConnection; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -34,8 +33,7 @@ import it.niedermann.owncloud.notes.exception.IntendedOfflineException; import it.niedermann.owncloud.notes.main.navigation.NavigationAdapter; import it.niedermann.owncloud.notes.main.navigation.NavigationItem; import it.niedermann.owncloud.notes.persistence.CapabilitiesClient; -import it.niedermann.owncloud.notes.persistence.NotesDatabase; -import it.niedermann.owncloud.notes.persistence.NotesServerSyncHelper; +import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; import it.niedermann.owncloud.notes.persistence.entity.Note; @@ -60,6 +58,7 @@ import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType. import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.RECENT; import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.UNCATEGORIZED; import static it.niedermann.owncloud.notes.shared.util.DisplayUtils.convertToCategoryNavigationItem; +import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED; public class MainViewModel extends AndroidViewModel { @@ -73,7 +72,7 @@ public class MainViewModel extends AndroidViewModel { private static final String KEY_EXPANDED_CATEGORY = "expandedCategory"; @NonNull - private final NotesDatabase db; + private final NotesRepository repo; @NonNull private final MutableLiveData<Account> currentAccount = new MutableLiveData<>(); @@ -86,7 +85,7 @@ public class MainViewModel extends AndroidViewModel { public MainViewModel(@NonNull Application application, @NonNull SavedStateHandle savedStateHandle) { super(application); - this.db = NotesDatabase.getInstance(application); + this.repo = NotesRepository.getInstance(application); this.state = savedStateHandle; } @@ -175,7 +174,7 @@ public class MainViewModel extends AndroidViewModel { @NonNull @MainThread public LiveData<Pair<NavigationCategory, CategorySortingMethod>> getCategorySortingMethodOfSelectedCategory() { - return switchMap(getSelectedCategory(), selectedCategory -> map(db.getCategoryOrder(selectedCategory), sortingMethod -> new Pair<>(selectedCategory, sortingMethod))); + return switchMap(getSelectedCategory(), selectedCategory -> map(repo.getCategoryOrder(selectedCategory), sortingMethod -> new Pair<>(selectedCategory, sortingMethod))); } public LiveData<Void> modifyCategoryOrder(@NonNull NavigationCategory selectedCategory, @NonNull CategorySortingMethod sortingMethod) { @@ -184,7 +183,7 @@ public class MainViewModel extends AndroidViewModel { return new MutableLiveData<>(null); } else { Log.v(TAG, "[modifyCategoryOrder] - currentAccount: " + currentAccount.getAccountName()); - db.modifyCategoryOrder(currentAccount.getId(), selectedCategory, sortingMethod); + repo.modifyCategoryOrder(currentAccount.getId(), selectedCategory, sortingMethod); return new MutableLiveData<>(null); } }); @@ -225,22 +224,22 @@ public class MainViewModel extends AndroidViewModel { case RECENT: { Log.v(TAG, "[getNotesListLiveData] - category: " + RECENT); fromDatabase = sortingMethod.second == SORT_MODIFIED_DESC - ? db.getNoteDao().searchRecentByModified$(accountId, searchQueryOrWildcard) - : db.getNoteDao().searchRecentLexicographically$(accountId, searchQueryOrWildcard); + ? repo.searchRecentByModified$(accountId, searchQueryOrWildcard) + : repo.searchRecentLexicographically$(accountId, searchQueryOrWildcard); break; } case FAVORITES: { Log.v(TAG, "[getNotesListLiveData] - category: " + FAVORITES); fromDatabase = sortingMethod.second == SORT_MODIFIED_DESC - ? db.getNoteDao().searchFavoritesByModified$(accountId, searchQueryOrWildcard) - : db.getNoteDao().searchFavoritesLexicographically$(accountId, searchQueryOrWildcard); + ? repo.searchFavoritesByModified$(accountId, searchQueryOrWildcard) + : repo.searchFavoritesLexicographically$(accountId, searchQueryOrWildcard); break; } case UNCATEGORIZED: { Log.v(TAG, "[getNotesListLiveData] - category: " + UNCATEGORIZED); fromDatabase = sortingMethod.second == SORT_MODIFIED_DESC - ? db.getNoteDao().searchUncategorizedByModified$(accountId, searchQueryOrWildcard) - : db.getNoteDao().searchUncategorizedLexicographically$(accountId, searchQueryOrWildcard); + ? repo.searchUncategorizedByModified$(accountId, searchQueryOrWildcard) + : repo.searchUncategorizedLexicographically$(accountId, searchQueryOrWildcard); break; } case DEFAULT_CATEGORY: @@ -251,8 +250,8 @@ public class MainViewModel extends AndroidViewModel { } Log.v(TAG, "[getNotesListLiveData] - category: " + category); fromDatabase = sortingMethod.second == SORT_MODIFIED_DESC - ? db.getNoteDao().searchCategoryByModified$(accountId, searchQueryOrWildcard, category) - : db.getNoteDao().searchCategoryLexicographically$(accountId, searchQueryOrWildcard, category); + ? repo.searchCategoryByModified$(accountId, searchQueryOrWildcard, category) + : repo.searchCategoryLexicographically$(accountId, searchQueryOrWildcard, category); break; } } @@ -294,11 +293,11 @@ public class MainViewModel extends AndroidViewModel { Log.v(TAG, "[getNavigationCategories] - currentAccount: " + currentAccount.getAccountName()); return switchMap(getExpandedCategory(), expandedCategory -> { Log.v(TAG, "[getNavigationCategories] - expandedCategory: " + expandedCategory); - return switchMap(db.getNoteDao().count$(currentAccount.getId()), (count) -> { + return switchMap(repo.count$(currentAccount.getId()), (count) -> { Log.v(TAG, "[getNavigationCategories] - count: " + count); - return switchMap(db.getNoteDao().countFavorites$(currentAccount.getId()), (favoritesCount) -> { + return switchMap(repo.countFavorites$(currentAccount.getId()), (favoritesCount) -> { Log.v(TAG, "[getNavigationCategories] - favoritesCount: " + favoritesCount); - return distinctUntilChanged(map(db.getNoteDao().getCategories$(currentAccount.getId()), fromDatabase -> + return distinctUntilChanged(map(repo.getCategories$(currentAccount.getId()), fromDatabase -> fromCategoriesWithNotesCount(getApplication(), expandedCategory, fromDatabase, count, favoritesCount) )); }); @@ -369,11 +368,11 @@ public class MainViewModel extends AndroidViewModel { return items; } - public void synchronizeCapabilitiesAndNotes(@NonNull Account localAccount, @NonNull IResponseCallback callback) { + public void synchronizeCapabilitiesAndNotes(@NonNull Account localAccount, @NonNull IResponseCallback<Void> callback) { Log.i(TAG, "[synchronizeCapabilitiesAndNotes] Synchronize capabilities for " + localAccount.getAccountName()); - synchronizeCapabilities(localAccount, new IResponseCallback() { + synchronizeCapabilities(localAccount, new IResponseCallback<Void>() { @Override - public void onSuccess() { + public void onSuccess(Void v) { Log.i(TAG, "[synchronizeCapabilitiesAndNotes] Synchronize notes for " + localAccount.getAccountName()); synchronizeNotes(localAccount, callback); } @@ -388,35 +387,36 @@ public class MainViewModel extends AndroidViewModel { /** * Updates the network status if necessary and pulls the latest {@link Capabilities} of the given {@param localAccount} */ - public void synchronizeCapabilities(@NonNull Account localAccount, @NonNull IResponseCallback callback) { + public void synchronizeCapabilities(@NonNull Account localAccount, @NonNull IResponseCallback<Void> callback) { new Thread(() -> { - final NotesServerSyncHelper syncHelper = db.getNoteServerSyncHelper(); - if (!syncHelper.isSyncPossible()) { - syncHelper.updateNetworkStatus(); + if (!repo.isSyncPossible()) { + repo.updateNetworkStatus(); } - if (syncHelper.isSyncPossible()) { + if (repo.isSyncPossible()) { try { final Capabilities capabilities = CapabilitiesClient.getCapabilities(getApplication(), AccountImporter.getSingleSignOnAccount(getApplication(), localAccount.getAccountName()), localAccount.getCapabilitiesETag()); - db.getAccountDao().updateCapabilitiesETag(localAccount.getId(), capabilities.getETag()); - db.getAccountDao().updateBrand(localAccount.getId(), capabilities.getColor(), capabilities.getTextColor()); + repo.updateCapabilitiesETag(localAccount.getId(), capabilities.getETag()); + repo.updateBrand(localAccount.getId(), capabilities.getColor(), capabilities.getTextColor()); localAccount.setColor(capabilities.getColor()); localAccount.setTextColor(capabilities.getTextColor()); BrandingUtil.saveBrandColors(getApplication(), localAccount.getColor(), localAccount.getTextColor()); - db.updateApiVersion(localAccount.getId(), capabilities.getApiVersion()); - callback.onSuccess(); + repo.updateApiVersion(localAccount.getId(), capabilities.getApiVersion()); + callback.onSuccess(null); } catch (NextcloudFilesAppAccountNotFoundException e) { - db.getAccountDao().deleteAccount(localAccount); + repo.deleteAccount(localAccount); callback.onError(e); - } catch (Exception e) { - if (e instanceof NextcloudHttpRequestFailedException && ((NextcloudHttpRequestFailedException) e).getStatusCode() == HttpURLConnection.HTTP_NOT_MODIFIED) { - Log.i(TAG, "[synchronizeCapabilities] Capabilities not modified."); - callback.onSuccess(); - } else { - callback.onError(e); + } catch (Throwable t) { + if (t.getClass() == NextcloudHttpRequestFailedException.class || t instanceof NextcloudHttpRequestFailedException) { + if (((NextcloudHttpRequestFailedException) t).getStatusCode() == HTTP_NOT_MODIFIED) { + Log.d(TAG, "Server returned HTTP Status Code " + ((NextcloudHttpRequestFailedException) t).getStatusCode() + " - Capabilities not modified."); + callback.onSuccess(null); + return; + } } + callback.onError(t); } } else { - if (syncHelper.isNetworkConnected() && syncHelper.isSyncOnlyOnWifi()) { + if (repo.isNetworkConnected() && repo.isSyncOnlyOnWifi()) { callback.onError(new IntendedOfflineException("Network is connected, but sync is not possible.")); } else { callback.onError(new NetworkErrorException("Sync is not possible, because network is not connected.")); @@ -428,18 +428,17 @@ public class MainViewModel extends AndroidViewModel { /** * Updates the network status if necessary and pulls the latest notes of the given {@param localAccount} */ - public void synchronizeNotes(@NonNull Account currentAccount, @NonNull IResponseCallback callback) { + public void synchronizeNotes(@NonNull Account currentAccount, @NonNull IResponseCallback<Void> callback) { new Thread(() -> { Log.v(TAG, "[synchronize] - currentAccount: " + currentAccount.getAccountName()); - final NotesServerSyncHelper syncHelper = db.getNoteServerSyncHelper(); - if (!syncHelper.isSyncPossible()) { - syncHelper.updateNetworkStatus(); + if (!repo.isSyncPossible()) { + repo.updateNetworkStatus(); } - if (syncHelper.isSyncPossible()) { - syncHelper.scheduleSync(currentAccount, false); - callback.onSuccess(); + if (repo.isSyncPossible()) { + repo.scheduleSync(currentAccount, false); + callback.onSuccess(null); } else { // Sync is not possible - if (syncHelper.isNetworkConnected() && syncHelper.isSyncOnlyOnWifi()) { + if (repo.isNetworkConnected() && repo.isSyncOnlyOnWifi()) { callback.onError(new IntendedOfflineException("Network is connected, but sync is not possible.")); } else { callback.onError(new NetworkErrorException("Sync is not possible, because network is not connected.")); @@ -449,25 +448,25 @@ public class MainViewModel extends AndroidViewModel { } public LiveData<Boolean> getSyncStatus() { - return db.getNoteServerSyncHelper().getSyncStatus(); + return repo.getSyncStatus(); } public LiveData<ArrayList<Throwable>> getSyncErrors() { - return db.getNoteServerSyncHelper().getSyncErrors(); + return repo.getSyncErrors(); } public LiveData<Boolean> hasMultipleAccountsConfigured() { - return map(db.getAccountDao().countAccounts$(), (counter) -> counter != null && counter > 1); + return map(repo.countAccounts$(), (counter) -> counter != null && counter > 1); } @WorkerThread public Account getLocalAccountByAccountName(String accountName) { - return db.getAccountDao().getAccountByName(accountName); + return repo.getAccountByName(accountName); } @WorkerThread public List<Account> getAccounts() { - return db.getAccountDao().getAccounts(); + return repo.getAccounts(); } public LiveData<Void> setCategory(Iterable<Long> noteIds, @NonNull String category) { @@ -477,7 +476,7 @@ public class MainViewModel extends AndroidViewModel { } else { Log.v(TAG, "[setCategory] - currentAccount: " + currentAccount.getAccountName()); for (Long noteId : noteIds) { - db.setCategory(currentAccount, noteId, category); + repo.setCategory(currentAccount, noteId, category); } return new MutableLiveData<>(null); } @@ -485,9 +484,9 @@ public class MainViewModel extends AndroidViewModel { } public LiveData<Note> moveNoteToAnotherAccount(Account account, Long noteId) { - return switchMap(db.getNoteDao().getNoteById$(noteId), (note) -> { + return switchMap(repo.getNoteById$(noteId), (note) -> { Log.v(TAG, "[moveNoteToAnotherAccount] - note: " + note); - return db.moveNoteToAnotherAccount(account, note); + return repo.moveNoteToAnotherAccount(account, note); }); } @@ -497,7 +496,7 @@ public class MainViewModel extends AndroidViewModel { return new MutableLiveData<>(null); } else { Log.v(TAG, "[toggleFavoriteAndSync] - currentAccount: " + currentAccount.getAccountName()); - db.toggleFavoriteAndSync(currentAccount, noteId); + repo.toggleFavoriteAndSync(currentAccount, noteId); return new MutableLiveData<>(null); } }); @@ -509,7 +508,7 @@ public class MainViewModel extends AndroidViewModel { return new MutableLiveData<>(null); } else { Log.v(TAG, "[deleteNoteAndSync] - currentAccount: " + currentAccount.getAccountName()); - db.deleteNoteAndSync(currentAccount, id); + repo.deleteNoteAndSync(currentAccount, id); return new MutableLiveData<>(null); } }); @@ -522,15 +521,15 @@ public class MainViewModel extends AndroidViewModel { } else { Log.v(TAG, "[deleteNotesAndSync] - currentAccount: " + currentAccount.getAccountName()); for (Long id : ids) { - db.deleteNoteAndSync(currentAccount, id); + repo.deleteNoteAndSync(currentAccount, id); } return new MutableLiveData<>(null); } }); } - public LiveData<Account> addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities) { - return db.addAccount(url, username, accountName, capabilities); + public void addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @NonNull IResponseCallback<Account> callback) { + repo.addAccount(url, username, accountName, capabilities, callback); } public LiveData<Note> getFullNote$(long id) { @@ -539,7 +538,7 @@ public class MainViewModel extends AndroidViewModel { @WorkerThread public Note getFullNote(long id) { - return db.getNoteDao().getNoteById(id); + return repo.getNoteById(id); } public LiveData<List<Note>> getFullNotesWithCategory(@NonNull Collection<Long> ids) { @@ -552,7 +551,7 @@ public class MainViewModel extends AndroidViewModel { new Thread(() -> notes.postValue( ids .stream() - .map(id -> db.getNoteDao().getNoteById(id)) + .map(repo::getNoteById) .collect(Collectors.toList()) )).start(); return notes; @@ -566,7 +565,7 @@ public class MainViewModel extends AndroidViewModel { return new MutableLiveData<>(); } else { Log.v(TAG, "[addNoteAndSync] - currentAccount: " + currentAccount.getAccountName()); - return db.addNoteAndSync(currentAccount, note); + return repo.addNoteAndSync(currentAccount, note); } }); } @@ -575,25 +574,25 @@ public class MainViewModel extends AndroidViewModel { return switchMap(getCurrentAccount(), currentAccount -> { if (currentAccount != null) { Log.v(TAG, "[updateNoteAndSync] - currentAccount: " + currentAccount.getAccountName()); - db.updateNoteAndSync(currentAccount, oldNote, newContent, newTitle, null); + repo.updateNoteAndSync(currentAccount, oldNote, newContent, newTitle, null); } return new MutableLiveData<>(null); }); } public void createOrUpdateSingleNoteWidgetData(SingleNoteWidgetData data) { - db.getWidgetSingleNoteDao().createOrUpdateSingleNoteWidgetData(data); + repo.createOrUpdateSingleNoteWidgetData(data); } public LiveData<Integer> getAccountsCount() { - return db.getAccountDao().countAccounts$(); + return repo.countAccounts$(); } @WorkerThread public String collectNoteContents(@NonNull List<Long> noteIds) { final StringBuilder noteContents = new StringBuilder(); for (Long noteId : noteIds) { - final Note fullNote = db.getNoteDao().getNoteById(noteId); + final Note fullNote = repo.getNoteById(noteId); final String tempFullNote = fullNote.getContent(); if (!TextUtils.isEmpty(tempFullNote)) { if (noteContents.length() > 0) { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountAdapter.java b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountAdapter.java index c1a4e139..25155c9a 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountAdapter.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountAdapter.java @@ -57,16 +57,7 @@ public class ManageAccountAdapter extends RecyclerView.Adapter<ManageAccountView holder.bind(localAccount, (localAccountClicked) -> { setCurrentLocalAccount(localAccountClicked); onAccountClick.accept(localAccountClicked); - }, (localAccountToDelete -> { - for (int i = 0; i < localAccounts.size(); i++) { - if (localAccounts.get(i).getId() == localAccountToDelete.getId()) { - localAccounts.remove(i); - notifyItemRemoved(i); - break; - } - } - onAccountDelete.accept(localAccountToDelete); - }), onChangeNotesPath, onChangeFileSuffix, currentLocalAccount != null && currentLocalAccount.getId() == localAccount.getId()); + }, onAccountDelete, onChangeNotesPath, onChangeFileSuffix, currentLocalAccount != null && currentLocalAccount.getId() == localAccount.getId()); } @Override diff --git a/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountsActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountsActivity.java index 408c1f2d..01eafd62 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountsActivity.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountsActivity.java @@ -1,5 +1,6 @@ package it.niedermann.owncloud.notes.manageaccounts; +import android.accounts.NetworkErrorException; import android.os.Bundle; import android.util.TypedValue; import android.view.View; @@ -14,98 +15,104 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; import androidx.appcompat.app.AlertDialog; -import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModelProvider; import com.nextcloud.android.sso.AccountImporter; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; -import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; -import com.nextcloud.android.sso.helper.SingleAccountHelper; -import com.nextcloud.android.sso.model.SingleSignOnAccount; - -import java.util.ArrayList; -import java.util.List; import it.niedermann.owncloud.notes.LockedActivity; import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.branding.BrandedAlertDialogBuilder; +import it.niedermann.owncloud.notes.branding.BrandedDeleteAlertDialogBuilder; import it.niedermann.owncloud.notes.databinding.ActivityManageAccountsBinding; import it.niedermann.owncloud.notes.exception.ExceptionDialogFragment; -import it.niedermann.owncloud.notes.persistence.NotesClient; -import it.niedermann.owncloud.notes.persistence.NotesDatabase; +import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.Account; -import it.niedermann.owncloud.notes.shared.model.ServerSettings; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; +import it.niedermann.owncloud.notes.shared.model.NotesSettings; +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 androidx.lifecycle.Transformations.distinctUntilChanged; public class ManageAccountsActivity extends LockedActivity { private ActivityManageAccountsBinding binding; + private ManageAccountsViewModel viewModel; private ManageAccountAdapter adapter; - private NotesDatabase db = null; - private final List<Account> localAccounts = new ArrayList<>(); @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); binding = ActivityManageAccountsBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); + viewModel = new ViewModelProvider(this).get(ManageAccountsViewModel.class); + setContentView(binding.getRoot()); setSupportActionBar(binding.toolbar); - db = NotesDatabase.getInstance(this); - - distinctUntilChanged(db.getAccountDao().getAccounts$()).observe(this, (localAccounts) -> { - - this.localAccounts.clear(); - this.localAccounts.addAll(localAccounts); - - adapter = new ManageAccountAdapter( - (localAccount) -> SingleAccountHelper.setCurrentAccount(getApplicationContext(), localAccount.getAccountName()), - this::onAccountDelete, - this::onChangeNotesPath, - this::onChangeFileSuffix - ); - adapter.setLocalAccounts(localAccounts); - try { - final SingleSignOnAccount ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(this); - if (ssoAccount != null) { - new Thread(() -> { - final Account account = db.getAccountDao().getAccountByName(ssoAccount.name); - runOnUiThread(() -> adapter.setCurrentLocalAccount(account)); - }).start(); - } - } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { - e.printStackTrace(); + adapter = new ManageAccountAdapter( + this::selectAccount, + this::deleteAccount, + this::onChangeNotesPath, + this::onChangeFileSuffix + ); + binding.accounts.setAdapter(adapter); + + viewModel.getAccounts$().observe(this, (accounts) -> { + if (accounts == null || accounts.size() < 1) { + finish(); + return; } - binding.accounts.setAdapter(adapter); + this.adapter.setLocalAccounts(accounts); + viewModel.getCurrentAccount(this, new IResponseCallback<Account>() { + @Override + public void onSuccess(Account result) { + runOnUiThread(() -> adapter.setCurrentLocalAccount(result)); + } + + @Override + public void onError(@NonNull Throwable t) { + runOnUiThread(() -> adapter.setCurrentLocalAccount(null)); + t.printStackTrace(); + } + }); }); } - private void onAccountDelete(@NonNull Account localAccount) { - final LiveData<Void> deleteLiveData = db.deleteAccount(localAccount); - deleteLiveData.observe(this, (v) -> { - for (Account temp : localAccounts) { - if (temp.getId() == localAccount.getId()) { - localAccounts.remove(temp); - break; - } + private void selectAccount(@NonNull Account accountToSelect) { + viewModel.selectAccount(accountToSelect, this); + } + + private void deleteAccount(@NonNull Account accountToDelete) { + viewModel.countUnsynchronizedNotes(accountToDelete.getId(), new IResponseCallback<Long>() { + @Override + public void onSuccess(Long unsynchronizedChangesCount) { + runOnUiThread(() -> { + if (unsynchronizedChangesCount > 0) { + new BrandedDeleteAlertDialogBuilder(ManageAccountsActivity.this) + .setTitle(getString(R.string.remove_account, accountToDelete.getUserName())) + .setMessage(getResources().getQuantityString(R.plurals.remove_account_message, (int) unsynchronizedChangesCount.longValue(), accountToDelete.getAccountName(), unsynchronizedChangesCount)) + .setNeutralButton(android.R.string.cancel, null) + .setPositiveButton(R.string.simple_remove, (d, l) -> viewModel.deleteAccount(accountToDelete, ManageAccountsActivity.this)) + .show(); + } else { + viewModel.deleteAccount(accountToDelete, ManageAccountsActivity.this); + } + }); } - if (localAccounts.size() > 0) { - SingleAccountHelper.setCurrentAccount(getApplicationContext(), localAccounts.get(0).getAccountName()); - adapter.setCurrentLocalAccount(localAccounts.get(0)); - } else { - SingleAccountHelper.setCurrentAccount(getApplicationContext(), null); - finish(); + + @Override + public void onError(@NonNull Throwable t) { + ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); } - deleteLiveData.removeObservers(this); }); } private void onChangeNotesPath(@NonNull Account localAccount) { - final NotesClient client = NotesClient.newInstance(localAccount.getPreferredApiVersion(), getApplicationContext()); + final NotesRepository repository = NotesRepository.getInstance(getApplicationContext()); final EditText editText = new EditText(this); editText.setEnabled(false); final View wrapper = createDialogViewWrapper(editText); @@ -116,27 +123,55 @@ public class ManageAccountsActivity extends LockedActivity { .setNeutralButton(android.R.string.cancel, null) .setPositiveButton(R.string.action_edit_save, (v, d) -> new Thread(() -> { try { - final ServerSettings newSettings = client.putServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), new ServerSettings(editText.getText().toString(), null)); - Toast.makeText(this, "New notes path: " + newSettings.getNotesPath(), Toast.LENGTH_LONG).show(); - } catch (Exception e) { + final Call<NotesSettings> putSettingsCall = repository.putServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), new NotesSettings(editText.getText().toString(), null), localAccount.getPreferredApiVersion()); + putSettingsCall.enqueue(new Callback<NotesSettings>() { + @Override + public void onResponse(@NonNull Call<NotesSettings> call, @NonNull Response<NotesSettings> response) { + if (response.isSuccessful()) { + Toast.makeText(ManageAccountsActivity.this, "New notes path: " + response.body().getNotesPath(), Toast.LENGTH_LONG).show(); + } else { + Toast.makeText(ManageAccountsActivity.this, "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()) .show(); - new Thread(() -> { - try { - final ServerSettings oldSettings = client.getServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName())); - editText.setText(oldSettings.getNotesPath()); - editText.setEnabled(true); - } catch (Exception e) { - dialog.dismiss(); - ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); - } - }).start(); + try { + final Call<NotesSettings> oldSettingsCall = repository.getServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), localAccount.getPreferredApiVersion()); + oldSettingsCall.enqueue(new Callback<NotesSettings>() { + @Override + public void onResponse(@NonNull Call<NotesSettings> call, @NonNull Response<NotesSettings> response) { + runOnUiThread(() -> { + if (response.isSuccessful()) { + editText.setText(response.body().getNotesPath()); + editText.setEnabled(true); + } else { + ExceptionDialogFragment.newInstance(new NetworkErrorException("HTTP status code: " + response.code())).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } + }); + } + + @Override + public void onFailure(@NonNull Call<NotesSettings> call, @NonNull Throwable t) { + ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } + }); + } catch (NextcloudFilesAppAccountNotFoundException e) { + dialog.dismiss(); + ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } } private void onChangeFileSuffix(@NonNull Account localAccount) { - final NotesClient client = NotesClient.newInstance(localAccount.getPreferredApiVersion(), getApplicationContext()); + final NotesRepository repository = NotesRepository.getInstance(getApplicationContext()); final Spinner spinner = new Spinner(this); spinner.setEnabled(false); final View wrapper = createDialogViewWrapper(spinner); @@ -150,28 +185,57 @@ public class ManageAccountsActivity extends LockedActivity { .setNeutralButton(android.R.string.cancel, null) .setPositiveButton("Save", (v, d) -> new Thread(() -> { try { - final ServerSettings newSettings = client.putServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), new ServerSettings(null, spinner.getSelectedItem().toString())); - Toast.makeText(this, "New file suffix: " + newSettings.getNotesPath(), Toast.LENGTH_LONG).show(); - } catch (Exception e) { + final Call<NotesSettings> putSettingsCall = repository.putServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), new NotesSettings(null, spinner.getSelectedItem().toString()), localAccount.getPreferredApiVersion()); + putSettingsCall.enqueue(new Callback<NotesSettings>() { + @Override + public void onResponse(@NonNull Call<NotesSettings> call, @NonNull Response<NotesSettings> response) { + if (response.isSuccessful()) { + Toast.makeText(ManageAccountsActivity.this, "New file suffix: " + response.body().getNotesPath(), Toast.LENGTH_LONG).show(); + } else { + Toast.makeText(ManageAccountsActivity.this, "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()) .show(); - new Thread(() -> { - try { - final ServerSettings oldSettings = client.getServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName())); - for (int i = 0; i < adapter.getCount(); i++) { - if (adapter.getItem(i).equals(oldSettings.getFileSuffix())) { - spinner.setSelection(i); - break; - } + try { + final Call<NotesSettings> oldSettingsCall = repository.getServerSettings(AccountImporter.getSingleSignOnAccount(this, localAccount.getAccountName()), localAccount.getPreferredApiVersion()); + oldSettingsCall.enqueue(new Callback<NotesSettings>() { + @Override + public void onResponse(@NonNull Call<NotesSettings> call, @NonNull Response<NotesSettings> response) { + runOnUiThread(() -> { + if (response.isSuccessful()) { + for (int i = 0; i < adapter.getCount(); i++) { + if (adapter.getItem(i).equals(response.body().getFileSuffix())) { + spinner.setSelection(i); + break; + } + } + spinner.setEnabled(true); + } else { + ExceptionDialogFragment.newInstance(new Exception("HTTP status code: " + response.code())).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } + }); } - spinner.setEnabled(true); - } catch (Exception e) { - dialog.dismiss(); - ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); - } - }).start(); + + @Override + public void onFailure(@NonNull Call<NotesSettings> call, @NonNull Throwable t) { + dialog.dismiss(); + ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } + }); + } catch (NextcloudFilesAppAccountNotFoundException e) { + dialog.dismiss(); + ExceptionDialogFragment.newInstance(e).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } } @NonNull diff --git a/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountsViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountsViewModel.java new file mode 100644 index 00000000..2ee45cf8 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountsViewModel.java @@ -0,0 +1,74 @@ +package it.niedermann.owncloud.notes.manageaccounts; + +import android.app.Application; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; + +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; +import com.nextcloud.android.sso.helper.SingleAccountHelper; + +import java.util.List; + +import it.niedermann.owncloud.notes.persistence.NotesDatabase; +import it.niedermann.owncloud.notes.persistence.NotesRepository; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; + +import static androidx.lifecycle.Transformations.distinctUntilChanged; + +public class ManageAccountsViewModel extends AndroidViewModel { + + private static final String TAG = ManageAccountsViewModel.class.getSimpleName(); + + @NonNull + private final NotesRepository repo; + + public ManageAccountsViewModel(@NonNull Application application) { + super(application); + this.repo = NotesRepository.getInstance(application); + } + + public void getCurrentAccount(@NonNull Context context, @NonNull IResponseCallback<Account> callback) { + try { + callback.onSuccess(repo.getAccountByName((SingleAccountHelper.getCurrentSingleSignOnAccount(context).name))); + } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { + callback.onError(e); + } + } + + public LiveData<List<Account>> getAccounts$() { + return distinctUntilChanged(repo.getAccounts$()); + } + + public void deleteAccount(@NonNull Account account, @NonNull Context context) { + new Thread(() -> { + final List<Account> accounts = repo.getAccounts(); + for (int i = 0; i < accounts.size(); i++) { + if (accounts.get(i).getId() == account.getId()) { + if (i > 0) { + selectAccount(accounts.get(i - 1), context); + } else if (accounts.size() > 1) { + selectAccount(accounts.get(i + 1), context); + } else { + selectAccount(null, context); + } + repo.deleteAccount(accounts.get(i)); + break; + } + } + }).start(); + } + + public void selectAccount(@Nullable Account account, @NonNull Context context) { + SingleAccountHelper.setCurrentAccount(context, (account == null) ? null : account.getAccountName()); + } + + public void countUnsynchronizedNotes(long accountId, @NonNull IResponseCallback<Long> callback) { + new Thread(() -> callback.onSuccess(repo.countUnsynchronizedNotes(accountId))).start(); + } +}
\ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java new file mode 100644 index 00000000..b1bca215 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java @@ -0,0 +1,137 @@ +package it.niedermann.owncloud.notes.persistence; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializer; +import com.nextcloud.android.sso.api.NextcloudAPI; +import com.nextcloud.android.sso.model.SingleSignOnAccount; + +import java.util.Calendar; +import java.util.HashMap; +import java.util.Map; + +import it.niedermann.owncloud.notes.persistence.sync.CapabilitiesDeserializer; +import it.niedermann.owncloud.notes.persistence.sync.NotesAPI; +import it.niedermann.owncloud.notes.persistence.sync.OcsAPI; +import it.niedermann.owncloud.notes.shared.model.ApiVersion; +import it.niedermann.owncloud.notes.shared.model.Capabilities; +import retrofit2.NextcloudRetrofitApiBuilder; +import retrofit2.Retrofit; + +/** + * Since creating APIs via {@link Retrofit} uses reflection and {@link NextcloudAPI} <a href="https://github.com/nextcloud/Android-SingleSignOn/issues/120#issuecomment-540069990">is supposed to stay alive as long as possible</a>, those artifacts are going to be cached. + * They can be invalidated by using either {@link #invalidateAPICache()} for all or {@link #invalidateAPICache(SingleSignOnAccount)} for a specific {@link SingleSignOnAccount} and will be recreated when they are queried the next time. + */ +@WorkerThread +public class ApiProvider { + + private static final String TAG = ApiProvider.class.getSimpleName(); + + private static final String API_ENDPOINT_OCS = "/ocs/v2.php/cloud/"; + + private static final Map<String, NextcloudAPI> API_CACHE = new HashMap<>(); + + private static final Map<String, OcsAPI> API_CACHE_OCS = new HashMap<>(); + private static final Map<String, NotesAPI> API_CACHE_NOTES = new HashMap<>(); + + /** + * An {@link OcsAPI} currently shares the {@link Gson} configuration with the {@link NotesAPI} and therefore divides all {@link Calendar} milliseconds by 1000 while serializing and multiplies values by 1000 during deserialization. + */ + public static synchronized OcsAPI getOcsAPI(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount) { + if (API_CACHE_OCS.containsKey(ssoAccount.name)) { + return API_CACHE_OCS.get(ssoAccount.name); + } + final OcsAPI ocsAPI = new NextcloudRetrofitApiBuilder(getNextcloudAPI(context, ssoAccount), API_ENDPOINT_OCS).create(OcsAPI.class); + API_CACHE_OCS.put(ssoAccount.name, ocsAPI); + return ocsAPI; + } + + /** + * In case the {@param preferredApiVersion} changes, call {@link #invalidateAPICache(SingleSignOnAccount)} or {@link #invalidateAPICache()} to make sure that this call returns a {@link NotesAPI} that uses the correct compatibility layer. + */ + public static synchronized NotesAPI getNotesAPI(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount, @Nullable ApiVersion preferredApiVersion) { + if (API_CACHE_NOTES.containsKey(ssoAccount.name)) { + return API_CACHE_NOTES.get(ssoAccount.name); + } + final NotesAPI notesAPI = new NotesAPI(getNextcloudAPI(context, ssoAccount), preferredApiVersion); + API_CACHE_NOTES.put(ssoAccount.name, notesAPI); + return notesAPI; + } + + private static synchronized NextcloudAPI getNextcloudAPI(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount) { + if (API_CACHE.containsKey(ssoAccount.name)) { + return API_CACHE.get(ssoAccount.name); + } else { + Log.v(TAG, "NextcloudRequest account: " + ssoAccount.name); + final NextcloudAPI nextcloudAPI = new NextcloudAPI(context.getApplicationContext(), ssoAccount, + new GsonBuilder() + .excludeFieldsWithoutExposeAnnotation() + .registerTypeHierarchyAdapter(Calendar.class, (JsonSerializer<Calendar>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.getTimeInMillis() / 1_000)) + .registerTypeHierarchyAdapter(Calendar.class, (JsonDeserializer<Calendar>) (src, typeOfSrc, ctx) -> { + final Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(src.getAsLong() * 1_000); + return calendar; + }) + .registerTypeAdapter(Capabilities.class, new CapabilitiesDeserializer()) + .create(), new NextcloudAPI.ApiConnectedListener() { + @Override + public void onConnected() { + Log.i(TAG, "SSO API connected for " + ssoAccount); + } + + @Override + public void onError(Exception ex) { + ex.printStackTrace(); + invalidateAPICache(ssoAccount); + } + }); + API_CACHE.put(ssoAccount.name, nextcloudAPI); + return nextcloudAPI; + } + } + + /** + * Invalidates the API cache for the given {@param ssoAccount} + * + * @param ssoAccount the ssoAccount for which the API cache should be cleared. + */ + public static synchronized void invalidateAPICache(@NonNull SingleSignOnAccount ssoAccount) { + Log.v(TAG, "Invalidating API cache for " + ssoAccount.name); + if (API_CACHE.containsKey(ssoAccount.name)) { + final NextcloudAPI nextcloudAPI = API_CACHE.get(ssoAccount.name); + if (nextcloudAPI != null) { + nextcloudAPI.stop(); + } + API_CACHE.remove(ssoAccount.name); + } + API_CACHE_NOTES.remove(ssoAccount.name); + API_CACHE_OCS.remove(ssoAccount.name); + } + + /** + * Invalidates the whole API cache for all accounts + */ + public static synchronized void invalidateAPICache() { + for (String key : API_CACHE.keySet()) { + Log.v(TAG, "Invalidating API cache for " + key); + if (API_CACHE.containsKey(key)) { + final NextcloudAPI nextcloudAPI = API_CACHE.get(key); + if (nextcloudAPI != null) { + nextcloudAPI.stop(); + } + API_CACHE.remove(key); + } + } + API_CACHE_NOTES.clear(); + API_CACHE_OCS.clear(); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesClient.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesClient.java index 9a08bc1e..8afc64b8 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesClient.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesClient.java @@ -1,26 +1,18 @@ package it.niedermann.owncloud.notes.persistence; import android.content.Context; -import android.content.pm.PackageInfo; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; -import com.nextcloud.android.sso.aidl.NextcloudRequest; -import com.nextcloud.android.sso.api.AidlNetworkRequest; -import com.nextcloud.android.sso.api.Response; -import com.nextcloud.android.sso.exceptions.NextcloudFilesAppNotSupportedException; +import com.nextcloud.android.sso.api.ParsedResponse; import com.nextcloud.android.sso.model.SingleSignOnAccount; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; import java.util.Map; +import it.niedermann.owncloud.notes.persistence.sync.OcsAPI; import it.niedermann.owncloud.notes.shared.model.Capabilities; @WorkerThread @@ -28,60 +20,24 @@ public class CapabilitiesClient { private static final String TAG = CapabilitiesClient.class.getSimpleName(); - private static final int MIN_NEXTCLOUD_FILES_APP_VERSION_CODE = 30090000; - - protected static final String HEADER_KEY_IF_NONE_MATCH = "If-None-Match"; - protected static final String HEADER_KEY_ETAG = "ETag"; - - private static final String API_PATH = "/ocs/v2.php/cloud/capabilities"; - private static final String METHOD_GET = "GET"; - private static final String PARAM_KEY_FORMAT = "format"; - private static final String PARAM_VALUE_JSON = "json"; - - private static final Map<String, String> parameters = new HashMap<>(); - - static { - parameters.put(PARAM_KEY_FORMAT, PARAM_VALUE_JSON); - } - - public static Capabilities getCapabilities(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount, @Nullable String lastETag) throws Exception { - final NextcloudRequest.Builder requestBuilder = new NextcloudRequest.Builder() - .setMethod(METHOD_GET) - .setUrl(API_PATH) - .setParameter(parameters); - - final Map<String, List<String>> header = new HashMap<>(); - if (lastETag != null && !lastETag.isEmpty()) { - header.put(HEADER_KEY_IF_NONE_MATCH, Collections.singletonList('"' + lastETag + '"')); - requestBuilder.setHeader(header); - } - - final NextcloudRequest nextcloudRequest = requestBuilder.build(); - final StringBuilder result = new StringBuilder(); + private static final String HEADER_KEY_ETAG = "ETag"; + public static Capabilities getCapabilities(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount, @Nullable String lastETag) throws Throwable { + final OcsAPI ocsAPI = ApiProvider.getOcsAPI(context, ssoAccount); try { - Log.v(TAG, ssoAccount.name + " → " + nextcloudRequest.getMethod() + " " + nextcloudRequest.getUrl() + " "); - final Response response = SSOClient.requestFilesApp(context.getApplicationContext(), ssoAccount, nextcloudRequest); - Log.v(TAG, "NextcloudRequest: " + nextcloudRequest.toString()); - - final BufferedReader rd = new BufferedReader(new InputStreamReader(response.getBody())); - String line; - while ((line = rd.readLine()) != null) { - result.append(line); - } - response.getBody().close(); - - String etag = null; - final AidlNetworkRequest.PlainHeader eTagHeader = response.getPlainHeader(HEADER_KEY_ETAG); - if (eTagHeader != null) { - etag = eTagHeader.getValue().replace("\"", ""); + final ParsedResponse<Capabilities> response = ocsAPI.getCapabilities(lastETag).blockingSingle(); + final Capabilities capabilities = response.getResponse(); + final Map<String, String> headers = response.getHeaders(); + if (headers != null) { + capabilities.setETag(headers.get(HEADER_KEY_ETAG)); + } else { + Log.w(TAG, "Response headers of capabilities are null"); } - - return new Capabilities(result.toString(), etag); - } catch (NullPointerException e) { - final PackageInfo pInfo = context.getApplicationContext().getPackageManager().getPackageInfo("com.nextcloud.client", 0); - if (pInfo.versionCode < MIN_NEXTCLOUD_FILES_APP_VERSION_CODE) { - throw new NextcloudFilesAppNotSupportedException(); + return capabilities; + } catch (RuntimeException e) { + final Throwable cause = e.getCause(); + if(cause != null) { + throw cause; } else { throw e; } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java index a9590225..1dff46cf 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java @@ -42,21 +42,24 @@ public class CapabilitiesWorker extends Worker { @NonNull @Override public Result doWork() { - final NotesDatabase db = NotesDatabase.getInstance(getApplicationContext()); - for (Account account : db.getAccountDao().getAccounts()) { + final NotesRepository repo = NotesRepository.getInstance(getApplicationContext()); + for (Account account : repo.getAccounts()) { try { final SingleSignOnAccount ssoAccount = AccountImporter.getSingleSignOnAccount(getApplicationContext(), account.getAccountName()); Log.i(TAG, "Refreshing capabilities for " + ssoAccount.name); final Capabilities capabilities = CapabilitiesClient.getCapabilities(getApplicationContext(), ssoAccount, account.getCapabilitiesETag()); - db.getAccountDao().updateCapabilitiesETag(account.getId(), capabilities.getETag()); - db.getAccountDao().updateBrand(account.getId(), capabilities.getColor(), capabilities.getTextColor()); - db.updateApiVersion(account.getId(), capabilities.getApiVersion()); + repo.updateCapabilitiesETag(account.getId(), capabilities.getETag()); + repo.updateBrand(account.getId(), capabilities.getColor(), capabilities.getTextColor()); + repo.updateApiVersion(account.getId(), capabilities.getApiVersion()); Log.i(TAG, capabilities.toString()); - } catch (Exception e) { + } catch (Throwable e) { if (e instanceof NextcloudHttpRequestFailedException) { if (((NextcloudHttpRequestFailedException) e).getStatusCode() == HttpURLConnection.HTTP_NOT_MODIFIED) { Log.i(TAG, "Capabilities not modified."); return Result.success(); + } else if(((NextcloudHttpRequestFailedException) e).getStatusCode() == HttpURLConnection.HTTP_UNAVAILABLE) { + Log.i(TAG, "Server is in maintenance mode."); + return Result.success(); } } e.printStackTrace(); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesClient.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesClient.java deleted file mode 100644 index a03705e4..00000000 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesClient.java +++ /dev/null @@ -1,228 +0,0 @@ -package it.niedermann.owncloud.notes.persistence; - -import android.content.Context; -import android.content.pm.PackageInfo; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; - -import com.nextcloud.android.sso.aidl.NextcloudRequest; -import com.nextcloud.android.sso.api.AidlNetworkRequest; -import com.nextcloud.android.sso.api.Response; -import com.nextcloud.android.sso.exceptions.NextcloudFilesAppNotSupportedException; -import com.nextcloud.android.sso.model.SingleSignOnAccount; - -import org.json.JSONObject; - -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.util.Calendar; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -import it.niedermann.owncloud.notes.persistence.entity.Note; -import it.niedermann.owncloud.notes.shared.model.ApiVersion; -import it.niedermann.owncloud.notes.shared.model.ServerResponse.NoteResponse; -import it.niedermann.owncloud.notes.shared.model.ServerResponse.NotesResponse; -import it.niedermann.owncloud.notes.shared.model.ServerSettings; - -@SuppressWarnings("WeakerAccess") -@WorkerThread -public abstract class NotesClient { - - final static int MIN_NEXTCLOUD_FILES_APP_VERSION_CODE = 30090000; - private static final String TAG = NotesClient.class.getSimpleName(); - - protected final Context appContext; - - protected static final String GET_PARAM_KEY_PRUNE_BEFORE = "pruneBefore"; - - protected static final String HEADER_KEY_ETAG = "ETag"; - protected static final String HEADER_KEY_LAST_MODIFIED = "Last-Modified"; - protected static final String HEADER_KEY_CONTENT_TYPE = "Content-Type"; - protected static final String HEADER_KEY_IF_NONE_MATCH = "If-None-Match"; - protected static final String HEADER_KEY_X_NOTES_API_VERSIONS = "X-Notes-API-Versions"; - - protected static final String HEADER_VALUE_APPLICATION_JSON = "application/json"; - - protected static final String METHOD_GET = "GET"; - protected static final String METHOD_PUT = "PUT"; - protected static final String METHOD_POST = "POST"; - protected static final String METHOD_DELETE = "DELETE"; - - public static final String JSON_ID = "id"; - public static final String JSON_TITLE = "title"; - public static final String JSON_CONTENT = "content"; - public static final String JSON_FAVORITE = "favorite"; - public static final String JSON_CATEGORY = "category"; - public static final String JSON_ETAG = "etag"; - public static final String JSON_MODIFIED = "modified"; - public static final String JSON_SETTINGS_NOTES_PATH = "notesPath"; - public static final String JSON_SETTINGS_FILE_SUFFIX = "fileSuffix"; - - public static final ApiVersion[] SUPPORTED_API_VERSIONS = new ApiVersion[]{ - new ApiVersion(1, 0), - new ApiVersion(0, 2) - }; - - public static NotesClient newInstance(@Nullable ApiVersion preferredApiVersion, - @NonNull Context appContext) { - if (preferredApiVersion == null) { - Log.i(TAG, "apiVersion is null, using " + NotesClientV02.class.getSimpleName()); - return new NotesClientV02(appContext); - } else if (preferredApiVersion.compareTo(SUPPORTED_API_VERSIONS[0]) == 0) { - Log.i(TAG, "Using " + NotesClientV1.class.getSimpleName()); - return new NotesClientV1(appContext); - } else if (preferredApiVersion.compareTo(SUPPORTED_API_VERSIONS[1]) == 0) { - Log.i(TAG, "Using " + NotesClientV02.class.getSimpleName()); - return new NotesClientV02(appContext); - } - Log.w(TAG, "Unsupported API version " + preferredApiVersion + " - try using " + NotesClientV02.class.getSimpleName()); - return new NotesClientV02(appContext); - } - - @SuppressWarnings("WeakerAccess") - protected NotesClient(@NonNull Context appContext) { - this.appContext = appContext; - } - - /** - * Gets the list of notes from the server. - * - * @param ssoAccount Account to be used - * @param lastModified Last modified time of a former response (Unix timestamp in seconds!). All notes older than this time will be skipped. - * @param lastETag ETag of a former response. If nothing changed, the response will be 304 NOT MODIFIED. - * @return list of notes - * @throws Exception - */ - abstract NotesResponse getNotes(SingleSignOnAccount ssoAccount, Calendar lastModified, String lastETag) throws Exception; - - abstract NoteResponse createNote(SingleSignOnAccount ssoAccount, Note note) throws Exception; - - abstract NoteResponse editNote(SingleSignOnAccount ssoAccount, Note note) throws Exception; - - abstract void deleteNote(SingleSignOnAccount ssoAccount, long noteId) throws Exception; - - public ServerSettings getServerSettings(SingleSignOnAccount ssoAccount) throws Exception { - throw new UnsupportedOperationException("Not available in this API version"); - } - - public ServerSettings putServerSettings(SingleSignOnAccount ssoAccount, @NonNull ServerSettings settings) throws Exception { - throw new UnsupportedOperationException("Not available in this API version"); - } - - /** - * This entity class is used to return relevant data of the HTTP reponse. - */ - public static class ResponseData { - private final String content; - private final String etag; - private final String supportedApiVersions; - private final Calendar lastModified; - - ResponseData(@NonNull String content, String etag, @NonNull Calendar lastModified, @Nullable String supportedApiVersions) { - this.content = content; - this.etag = etag; - this.lastModified = lastModified; - this.supportedApiVersions = supportedApiVersions; - } - - public String getContent() { - return content; - } - - public String getETag() { - return etag; - } - - public Calendar getLastModified() { - return lastModified; - } - - public String getSupportedApiVersions() { - return this.supportedApiVersions; - } - } - - abstract protected String getApiPath(); - - /** - * Request-Method for POST, PUT with or without JSON-Object-Parameter - * - * @param target Filepath to the wanted function - * @param method GET, POST, DELETE or PUT - * @param parameter optional headers to be sent - * @param requestBody JSON Object which shall be transferred to the server. - * @param lastETag optional ETag of last response - * @return Body of answer - */ - protected ResponseData requestServer(SingleSignOnAccount ssoAccount, String target, String method, Map<String, String> parameter, JSONObject requestBody, String lastETag) throws Exception { - final NextcloudRequest.Builder requestBuilder = new NextcloudRequest.Builder() - .setMethod(method) - .setUrl(getApiPath() + target); - if (parameter != null) { - requestBuilder.setParameter(parameter); - } - - final Map<String, List<String>> header = new HashMap<>(); - if (requestBody != null) { - header.put(HEADER_KEY_CONTENT_TYPE, Collections.singletonList(HEADER_VALUE_APPLICATION_JSON)); - requestBuilder.setRequestBody(requestBody.toString()); - } - if (lastETag != null && !lastETag.isEmpty() && METHOD_GET.equals(method)) { - header.put(HEADER_KEY_IF_NONE_MATCH, Collections.singletonList('"' + lastETag + '"')); - requestBuilder.setHeader(header); - } - - final NextcloudRequest nextcloudRequest = requestBuilder.build(); - final StringBuilder result = new StringBuilder(); - - try { - Log.v(TAG, ssoAccount.name + " → " + nextcloudRequest.getMethod() + " " + nextcloudRequest.getUrl() + " "); - Log.d(TAG, "NextcloudRequest: " + nextcloudRequest.toString()); - final Response response = SSOClient.requestFilesApp(appContext, ssoAccount, nextcloudRequest); - - final BufferedReader rd = new BufferedReader(new InputStreamReader(response.getBody())); - String line; - while ((line = rd.readLine()) != null) { - result.append(line); - } - response.getBody().close(); - - String etag = ""; - final AidlNetworkRequest.PlainHeader eTagHeader = response.getPlainHeader(HEADER_KEY_ETAG); - if (eTagHeader != null) { - etag = Objects.requireNonNull(eTagHeader.getValue()).replace("\"", ""); - } - - final Calendar lastModified = Calendar.getInstance(); - lastModified.setTimeInMillis(0); - final AidlNetworkRequest.PlainHeader lastModifiedHeader = response.getPlainHeader(HEADER_KEY_LAST_MODIFIED); - if (lastModifiedHeader != null) - lastModified.setTimeInMillis(Date.parse(lastModifiedHeader.getValue())); - Log.d(TAG, "ETag: " + etag + "; Last-Modified: " + lastModified + " (" + lastModified + ")"); - - String supportedApiVersions = null; - final AidlNetworkRequest.PlainHeader supportedApiVersionsHeader = response.getPlainHeader(HEADER_KEY_X_NOTES_API_VERSIONS); - if (supportedApiVersionsHeader != null) { - supportedApiVersions = "[" + Objects.requireNonNull(supportedApiVersionsHeader.getValue()) + "]"; - } - - // return these header fields since they should only be saved after successful processing the result! - return new ResponseData(result.toString(), etag, lastModified, supportedApiVersions); - } catch (NullPointerException e) { - final PackageInfo pInfo = appContext.getPackageManager().getPackageInfo("com.nextcloud.client", 0); - if (pInfo.versionCode < MIN_NEXTCLOUD_FILES_APP_VERSION_CODE) { - throw new NextcloudFilesAppNotSupportedException(); - } else { - throw e; - } - } - } -} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesClientV02.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesClientV02.java deleted file mode 100644 index 31a4de39..00000000 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesClientV02.java +++ /dev/null @@ -1,63 +0,0 @@ -package it.niedermann.owncloud.notes.persistence; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.WorkerThread; - -import com.nextcloud.android.sso.model.SingleSignOnAccount; - -import org.json.JSONObject; - -import java.util.Calendar; -import java.util.HashMap; -import java.util.Map; - -import it.niedermann.owncloud.notes.persistence.entity.Note; -import it.niedermann.owncloud.notes.shared.model.ServerResponse.NoteResponse; -import it.niedermann.owncloud.notes.shared.model.ServerResponse.NotesResponse; - -@WorkerThread -public class NotesClientV02 extends NotesClient { - - private static final String API_PATH = "/index.php/apps/notes/api/v0.2/"; - - NotesClientV02(@NonNull Context appContext) { - super(appContext); - } - - NotesResponse getNotes(SingleSignOnAccount ssoAccount, Calendar lastModified, String lastETag) throws Exception { - final Map<String, String> parameter = new HashMap<>(); - parameter.put(GET_PARAM_KEY_PRUNE_BEFORE, Long.toString(lastModified == null ? 0 : lastModified.getTimeInMillis() / 1_000)); - return new NotesResponse(requestServer(ssoAccount, "notes", METHOD_GET, parameter, null, lastETag)); - } - - private NoteResponse putNote(SingleSignOnAccount ssoAccount, Note note, String path, String method) throws Exception { - JSONObject paramObject = new JSONObject(); - paramObject.accumulate(JSON_CONTENT, note.getContent()); - paramObject.accumulate(JSON_MODIFIED, note.getModified().getTimeInMillis() / 1_000); - paramObject.accumulate(JSON_FAVORITE, note.getFavorite()); - paramObject.accumulate(JSON_CATEGORY, note.getCategory()); - return new NoteResponse(requestServer(ssoAccount, path, method, null, paramObject, null)); - } - - @Override - NoteResponse createNote(SingleSignOnAccount ssoAccount, Note note) throws Exception { - return putNote(ssoAccount, note, "notes", METHOD_POST); - } - - @Override - NoteResponse editNote(SingleSignOnAccount ssoAccount, Note note) throws Exception { - return putNote(ssoAccount, note, "notes/" + note.getRemoteId(), METHOD_PUT); - } - - @Override - void deleteNote(SingleSignOnAccount ssoAccount, long noteId) throws Exception { - this.requestServer(ssoAccount, "notes/" + noteId, METHOD_DELETE, null, null, null); - } - - @Override - protected String getApiPath() { - return API_PATH; - } -} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesClientV1.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesClientV1.java deleted file mode 100644 index 614d99b5..00000000 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesClientV1.java +++ /dev/null @@ -1,78 +0,0 @@ -package it.niedermann.owncloud.notes.persistence; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.WorkerThread; - -import com.nextcloud.android.sso.model.SingleSignOnAccount; - -import org.json.JSONObject; - -import java.util.Calendar; -import java.util.HashMap; -import java.util.Map; - -import it.niedermann.owncloud.notes.persistence.entity.Note; -import it.niedermann.owncloud.notes.shared.model.ServerResponse.NoteResponse; -import it.niedermann.owncloud.notes.shared.model.ServerResponse.NotesResponse; -import it.niedermann.owncloud.notes.shared.model.ServerSettings; - -@WorkerThread -public class NotesClientV1 extends NotesClient { - - private static final String API_PATH = "/index.php/apps/notes/api/v1/"; - - NotesClientV1(@NonNull Context appContext) { - super(appContext); - } - - NotesResponse getNotes(SingleSignOnAccount ssoAccount, Calendar lastModified, String lastETag) throws Exception { - final Map<String, String> parameter = new HashMap<>(); - parameter.put(GET_PARAM_KEY_PRUNE_BEFORE, Long.toString(lastModified == null ? 0 : lastModified.getTimeInMillis() / 1_000)); - return new NotesResponse(requestServer(ssoAccount, "notes", METHOD_GET, parameter, null, lastETag)); - } - - private NoteResponse putNote(SingleSignOnAccount ssoAccount, Note note, String path, String method) throws Exception { - final JSONObject paramObject = new JSONObject(); - paramObject.accumulate(JSON_TITLE, note.getTitle()); - paramObject.accumulate(JSON_CONTENT, note.getContent()); - paramObject.accumulate(JSON_MODIFIED, note.getModified().getTimeInMillis() / 1_000); - paramObject.accumulate(JSON_FAVORITE, note.getFavorite()); - paramObject.accumulate(JSON_CATEGORY, note.getCategory()); - return new NoteResponse(requestServer(ssoAccount, path, method, null, paramObject, null)); - } - - @Override - NoteResponse createNote(SingleSignOnAccount ssoAccount, Note note) throws Exception { - return putNote(ssoAccount, note, "notes", METHOD_POST); - } - - @Override - NoteResponse editNote(SingleSignOnAccount ssoAccount, Note note) throws Exception { - return putNote(ssoAccount, note, "notes/" + note.getRemoteId(), METHOD_PUT); - } - - @Override - void deleteNote(SingleSignOnAccount ssoAccount, long noteId) throws Exception { - this.requestServer(ssoAccount, "notes/" + noteId, METHOD_DELETE, null, null, null); - } - - @Override - protected String getApiPath() { - return API_PATH; - } - - @Override - public ServerSettings getServerSettings(SingleSignOnAccount ssoAccount) throws Exception { - return ServerSettings.from(new JSONObject(this.requestServer(ssoAccount, "settings", METHOD_GET, null, null, null).getContent())); - } - - @Override - public ServerSettings putServerSettings(SingleSignOnAccount ssoAccount, @NonNull ServerSettings settings) throws Exception { - final JSONObject paramObject = new JSONObject(); - paramObject.accumulate(JSON_SETTINGS_NOTES_PATH, settings.getNotesPath()); - paramObject.accumulate(JSON_SETTINGS_FILE_SUFFIX, settings.getFileSuffix()); - return ServerSettings.from(new JSONObject(this.requestServer(ssoAccount, "settings", METHOD_PUT, null, paramObject, null).getContent())); - } -} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java index 40d918df..4f4aaa78 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java @@ -1,43 +1,15 @@ package it.niedermann.owncloud.notes.persistence; import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; -import android.graphics.drawable.Icon; -import android.text.TextUtils; import android.util.Log; -import androidx.annotation.AnyThread; -import androidx.annotation.MainThread; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.preference.PreferenceManager; import androidx.room.Database; import androidx.room.Room; import androidx.room.RoomDatabase; import androidx.room.TypeConverters; import androidx.sqlite.db.SupportSQLiteDatabase; -import com.nextcloud.android.sso.AccountImporter; -import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; - -import org.json.JSONArray; -import org.json.JSONException; - -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import it.niedermann.android.sharedpreferences.SharedPreferenceIntLiveData; -import it.niedermann.owncloud.notes.R; -import it.niedermann.owncloud.notes.edit.EditNoteActivity; import it.niedermann.owncloud.notes.persistence.dao.AccountDao; import it.niedermann.owncloud.notes.persistence.dao.CategoryOptionsDao; import it.niedermann.owncloud.notes.persistence.dao.NoteDao; @@ -61,24 +33,6 @@ import it.niedermann.owncloud.notes.persistence.migration.Migration_18_19; import it.niedermann.owncloud.notes.persistence.migration.Migration_19_20; import it.niedermann.owncloud.notes.persistence.migration.Migration_20_21; import it.niedermann.owncloud.notes.persistence.migration.Migration_9_10; -import it.niedermann.owncloud.notes.shared.model.ApiVersion; -import it.niedermann.owncloud.notes.shared.model.Capabilities; -import it.niedermann.owncloud.notes.shared.model.CategorySortingMethod; -import it.niedermann.owncloud.notes.shared.model.DBStatus; -import it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType; -import it.niedermann.owncloud.notes.shared.model.ISyncCallback; -import it.niedermann.owncloud.notes.shared.model.NavigationCategory; -import it.niedermann.owncloud.notes.shared.util.NoteUtil; - -import static android.os.Build.VERSION.SDK_INT; -import static android.os.Build.VERSION_CODES.O; -import static androidx.lifecycle.Transformations.map; -import static androidx.lifecycle.Transformations.switchMap; -import static it.niedermann.owncloud.notes.edit.EditNoteActivity.ACTION_SHORTCUT; -import static it.niedermann.owncloud.notes.shared.util.NoteUtil.generateNoteExcerpt; -import static it.niedermann.owncloud.notes.widget.notelist.NoteListWidget.updateNoteListWidgets; -import static it.niedermann.owncloud.notes.widget.singlenote.SingleNoteWidget.updateSingleNoteWidgets; -import static java.util.stream.Collectors.toMap; @Database( entities = { @@ -94,13 +48,16 @@ public abstract class NotesDatabase extends RoomDatabase { private static final String TAG = NotesDatabase.class.getSimpleName(); private static final String NOTES_DB_NAME = "OWNCLOUD_NOTES"; - private static NotesDatabase instance; - private static Context context; - private static NotesServerSyncHelper serverSyncHelper; - private static String defaultNonEmptyTitle; + private static volatile NotesDatabase instance; + + public static NotesDatabase getInstance(@NonNull Context context) { + if (instance == null) { + instance = create(context.getApplicationContext()); + } + return instance; + } private static NotesDatabase create(final Context context) { - defaultNonEmptyTitle = NoteUtil.generateNonEmptyNoteTitle("", context); return Room.databaseBuilder( context, NotesDatabase.class, @@ -110,9 +67,9 @@ public abstract class NotesDatabase extends RoomDatabase { new Migration_10_11(context), new Migration_11_12(context), new Migration_12_13(context), - new Migration_13_14(context, () -> instance.notifyWidgets()), + new Migration_13_14(context), new Migration_14_15(), - new Migration_15_16(context, () -> instance.notifyWidgets()), + new Migration_15_16(context), new Migration_16_17(), new Migration_17_18(), new Migration_18_19(context), @@ -144,407 +101,4 @@ public abstract class NotesDatabase extends RoomDatabase { public abstract WidgetSingleNoteDao getWidgetSingleNoteDao(); public abstract WidgetNotesListDao getWidgetNotesListDao(); - - public static NotesDatabase getInstance(@NonNull Context context) { - if (instance == null) { - instance = create(context.getApplicationContext()); - NotesDatabase.context = context.getApplicationContext(); - NotesDatabase.serverSyncHelper = NotesServerSyncHelper.getInstance(instance); - } - return instance; - } - - public NotesServerSyncHelper getNoteServerSyncHelper() { - return NotesDatabase.serverSyncHelper; - } - - /** - * Creates a new Note in the Database and adds a Synchronization Flag. - * - * @param note Note - */ - @NonNull - @MainThread - public LiveData<Note> addNoteAndSync(Account account, Note note) { - final Note entity = new Note(0, null, note.getModified(), note.getTitle(), note.getContent(), note.getCategory(), note.getFavorite(), note.getETag(), DBStatus.LOCAL_EDITED, account.getId(), generateNoteExcerpt(note.getContent(), note.getTitle()), 0); - final MutableLiveData<Note> ret = new MutableLiveData<>(); - new Thread(() -> ret.postValue(addNote(account.getId(), entity))).start(); - return map(ret, newNote -> { - notifyWidgets(); - serverSyncHelper.scheduleSync(account, true); - return newNote; - }); - } - - /** - * Inserts a note directly into the Database. - * Excerpt will be generated, {@link DBStatus#LOCAL_EDITED} will be applied in case the note has - * already has a local ID, otherwise {@link DBStatus#VOID} will be applied. - * No Synchronisation will be triggered! Use {@link #addNoteAndSync(Account, Note)}! - * - * @param note {@link Note} to be added. - */ - @NonNull - @WorkerThread - public Note addNote(long accountId, @NonNull Note note) { - note.setStatus(note.getId() > 0 ? DBStatus.LOCAL_EDITED : DBStatus.VOID); - note.setAccountId(accountId); - note.setExcerpt(generateNoteExcerpt(note.getContent(), note.getTitle())); - return getNoteDao().getNoteById(getNoteDao().addNote(note)); - } - - @MainThread - public LiveData<Note> moveNoteToAnotherAccount(Account account, @NonNull Note note) { - return switchMap(getNoteDao().getContent$(note.getId()), (content) -> { - final Note fullNote = new Note(null, note.getModified(), note.getTitle(), content, note.getCategory(), note.getFavorite(), null); - deleteNoteAndSync(account, note.getId()); - return addNoteAndSync(account, fullNote); - }); - } - - /** - * @return a {@link Map} of remote IDs as keys and local IDs as values of all {@link Note}s of - * the given {@param accountId} which are not {@link DBStatus#LOCAL_DELETED} - */ - @NonNull - @WorkerThread - public Map<Long, Long> getIdMap(long accountId) { - validateAccountId(accountId); - return getNoteDao() - .getRemoteIdAndId(accountId) - .stream() - .filter(note -> note.getRemoteId() != null) - .collect(toMap(Note::getRemoteId, Note::getId)); - } - - @AnyThread - public void toggleFavoriteAndSync(Account account, long noteId) { - new Thread(() -> { - getNoteDao().toggleFavorite(noteId); - serverSyncHelper.scheduleSync(account, true); - }).start(); - } - - /** - * Set the category for a given note. - * This method will search in the database to find out the category id in the db. - * If there is no such category existing, this method will create it and search again. - * - * @param account The single sign on account - * @param noteId The note which will be updated - * @param category The category title which should be used to find the category id. - */ - @AnyThread - public void setCategory(@NonNull Account account, long noteId, @NonNull String category) { - new Thread(() -> { - getNoteDao().updateStatus(noteId, DBStatus.LOCAL_EDITED); - getNoteDao().updateCategory(noteId, category); - serverSyncHelper.scheduleSync(account, true); - }).start(); - } - - /** - * Updates a single Note with a new content. - * The title is derived from the new content automatically, and modified date as well as DBStatus are updated, too -- if the content differs to the state in the database. - * - * @param oldNote Note to be changed - * @param newContent New content. If this is <code>null</code>, then <code>oldNote</code> is saved again (useful for undoing changes). - * @param newTitle New title. If this is <code>null</code>, then either the old title is reused (in case the note has been synced before) or a title is generated (in case it is a new note) - * @param callback When the synchronization is finished, this callback will be invoked (optional). - * @return changed {@link Note} if differs from database, otherwise the old {@link Note}. - */ - @WorkerThread - public Note updateNoteAndSync(Account localAccount, @NonNull Note oldNote, @Nullable String newContent, @Nullable String newTitle, @Nullable ISyncCallback callback) { - final Note newNote; - if (newContent == null) { - newNote = new Note(oldNote.getId(), oldNote.getRemoteId(), oldNote.getModified(), oldNote.getTitle(), oldNote.getContent(), oldNote.getCategory(), oldNote.getFavorite(), oldNote.getETag(), DBStatus.LOCAL_EDITED, localAccount.getId(), oldNote.getExcerpt(), oldNote.getScrollY()); - } else { - final String title; - if (newTitle != null) { - title = newTitle; - } else { - if ((oldNote.getRemoteId() == null || localAccount.getPreferredApiVersion() == null || localAccount.getPreferredApiVersion().compareTo(new ApiVersion("1.0", 0, 0)) < 0) && - (defaultNonEmptyTitle.equals(oldNote.getTitle()))) { - title = NoteUtil.generateNonEmptyNoteTitle(newContent, context); - } else { - title = oldNote.getTitle(); - } - } - newNote = new Note(oldNote.getId(), oldNote.getRemoteId(), Calendar.getInstance(), title, newContent, oldNote.getCategory(), oldNote.getFavorite(), oldNote.getETag(), DBStatus.LOCAL_EDITED, localAccount.getId(), generateNoteExcerpt(newContent, title), oldNote.getScrollY()); - } - int rows = getNoteDao().updateNote(newNote); - // if data was changed, set new status and schedule sync (with callback); otherwise invoke callback directly. - if (rows > 0) { - notifyWidgets(); - if (callback != null) { - serverSyncHelper.addCallbackPush(localAccount, callback); - } - serverSyncHelper.scheduleSync(localAccount, true); - return newNote; - } else { - if (callback != null) { - callback.onFinish(); - } - return oldNote; - } - } - - /** - * Marks a Note in the Database as Deleted. In the next Synchronization it will be deleted - * from the Server. - * - * @param id long - ID of the Note that should be deleted - */ - @AnyThread - public void deleteNoteAndSync(Account account, long id) { - new Thread(() -> { - getNoteDao().updateStatus(id, DBStatus.LOCAL_DELETED); - notifyWidgets(); - serverSyncHelper.scheduleSync(account, true); - - if (SDK_INT >= O) { - ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class); - if (shortcutManager != null) { - shortcutManager.getPinnedShortcuts().forEach((shortcut) -> { - String shortcutId = id + ""; - if (shortcut.getId().equals(shortcutId)) { - Log.v(TAG, "Removing shortcut for " + shortcutId); - shortcutManager.disableShortcuts(Collections.singletonList(shortcutId), context.getResources().getString(R.string.note_has_been_deleted)); - } - }); - } else { - Log.e(TAG, ShortcutManager.class.getSimpleName() + "is null."); - } - } - }).start(); - } - - /** - * Notify about changed notes. - */ - @AnyThread - protected void notifyWidgets() { - new Thread(() -> { - updateSingleNoteWidgets(context); - updateNoteListWidgets(context); - }).start(); - } - - @AnyThread - void updateDynamicShortcuts(long accountId) { - new Thread(() -> { - if (SDK_INT >= android.os.Build.VERSION_CODES.N_MR1) { - ShortcutManager shortcutManager = context.getApplicationContext().getSystemService(ShortcutManager.class); - if (shortcutManager != null) { - if (!shortcutManager.isRateLimitingActive()) { - List<ShortcutInfo> newShortcuts = new ArrayList<>(); - - for (Note note : getNoteDao().getRecentNotes(accountId)) { - if (!TextUtils.isEmpty(note.getTitle())) { - Intent intent = new Intent(context.getApplicationContext(), EditNoteActivity.class); - intent.putExtra(EditNoteActivity.PARAM_NOTE_ID, note.getId()); - intent.setAction(ACTION_SHORTCUT); - - newShortcuts.add(new ShortcutInfo.Builder(context.getApplicationContext(), note.getId() + "") - .setShortLabel(note.getTitle() + "") - .setIcon(Icon.createWithResource(context.getApplicationContext(), note.getFavorite() ? R.drawable.ic_star_yellow_24dp : R.drawable.ic_star_grey_ccc_24dp)) - .setIntent(intent) - .build()); - } else { - // Prevent crash https://github.com/stefan-niedermann/nextcloud-notes/issues/613 - Log.e(TAG, "shortLabel cannot be empty " + note); - } - } - Log.d(TAG, "Update dynamic shortcuts"); - shortcutManager.removeAllDynamicShortcuts(); - shortcutManager.addDynamicShortcuts(newShortcuts); - } - } - } - }).start(); - } - - @AnyThread - public LiveData<Account> addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities) { - return getAccountDao().getAccountById$(getAccountDao().insert(new Account(url, username, accountName, capabilities))); - } - - /** - * @param apiVersion has to be a JSON array as a string <code>["0.2", "1.0", ...]</code> - * @return whether or not the given {@link ApiVersion} has been written to the database - * @throws IllegalArgumentException if the apiVersion does not match the expected format - */ - public boolean updateApiVersion(long accountId, @Nullable String apiVersion) throws IllegalArgumentException { - validateAccountId(accountId); - if (apiVersion != null) { - try { - JSONArray apiVersions = new JSONArray(apiVersion); - for (int i = 0; i < apiVersions.length(); i++) { - ApiVersion.of(apiVersions.getString(i)); - } - if (apiVersions.length() > 0) { - final int updatedRows = getAccountDao().updateApiVersion(accountId, apiVersion); - if (updatedRows == 1) { - Log.i(TAG, "Updated apiVersion to \"" + apiVersion + "\" for accountId = " + accountId); - } else { - Log.e(TAG, "Updated " + updatedRows + " but expected only 1 for accountId = " + accountId + " and apiVersion = \"" + apiVersion + "\""); - } - return true; - } else { - Log.i(TAG, "Given API version is a valid JSON array but does not contain any valid API versions. Do not update database."); - } - } catch (NumberFormatException e) { - throw new IllegalArgumentException("API version does contain a non-valid version: " + apiVersion); - } catch (JSONException e) { - throw new IllegalArgumentException("API version must contain be a JSON array: " + apiVersion); - } - } else { - Log.v(TAG, "Given API version is null. Do not update database"); - } - return false; - } - - /** - * @param localAccount the {@link Account} that should be deleted - * @throws IllegalArgumentException if no account has been deleted by the given accountId - */ - @AnyThread - public LiveData<Void> deleteAccount(@NonNull Account localAccount) throws IllegalArgumentException { - validateAccountId(localAccount.getId()); - MutableLiveData<Void> ret = new MutableLiveData<>(); - new Thread(() -> { - int deletedAccounts = getAccountDao().deleteAccount(localAccount); - if (deletedAccounts < 1) { - Log.e(TAG, "AccountId '" + localAccount.getId() + "' did not delete any account"); - throw new IllegalArgumentException("The given accountId does not delete any row"); - } else if (deletedAccounts > 1) { - Log.e(TAG, "AccountId '" + localAccount.getId() + "' deleted unexpectedly '" + deletedAccounts + "' accounts"); - } - - try { - SSOClient.invalidateAPICache(AccountImporter.getSingleSignOnAccount(context, localAccount.getAccountName())); - } catch (NextcloudFilesAppAccountNotFoundException e) { - e.printStackTrace(); - SSOClient.invalidateAPICache(); - } - - // TODO this should already be handled by foreign key cascade, no? - final int deletedNotes = getNoteDao().deleteByAccountId(localAccount.getId()); - Log.v(TAG, "Deleted " + deletedNotes + " notes from account " + localAccount.getId()); - ret.postValue(null); - }).start(); - return ret; - } - - private static void validateAccountId(long accountId) { - if (accountId < 1) { - throw new IllegalArgumentException("accountId must be greater than 0"); - } - } - - /** - * Modifies the sorting method for one category, the category can be normal category or - * one of "All notes", "Favorite", and "Uncategorized". - * If category is one of these three, sorting method will be modified in android.content.SharedPreference. - * The user can determine use which sorting method to show the notes for a category. - * When the user changes the sorting method, this method should be called. - * - * @param accountId The user accountID - * @param selectedCategory The category to be modified - * @param sortingMethod The sorting method in {@link CategorySortingMethod} enum format - */ - @AnyThread - public void modifyCategoryOrder(long accountId, @NonNull NavigationCategory selectedCategory, @NonNull CategorySortingMethod sortingMethod) { - validateAccountId(accountId); - - new Thread(() -> { - final Context ctx = context.getApplicationContext(); - final SharedPreferences.Editor sp = PreferenceManager.getDefaultSharedPreferences(ctx).edit(); - int orderIndex = sortingMethod.getId(); - - switch (selectedCategory.getType()) { - case FAVORITES: { - sp.putInt(ctx.getString(R.string.action_sorting_method) + ' ' + ctx.getString(R.string.label_favorites), orderIndex); - break; - } - case UNCATEGORIZED: { - sp.putInt(ctx.getString(R.string.action_sorting_method) + ' ' + ctx.getString(R.string.action_uncategorized), orderIndex); - break; - } - case RECENT: { - sp.putInt(ctx.getString(R.string.action_sorting_method) + ' ' + ctx.getString(R.string.label_all_notes), orderIndex); - break; - } - case DEFAULT_CATEGORY: - default: { - final String category = selectedCategory.getCategory(); - if (category != null) { - if (getCategoryOptionsDao().modifyCategoryOrder(accountId, category, sortingMethod) == 0) { - // Nothing updated means we didn't have this yet - final CategoryOptions categoryOptions = new CategoryOptions(); - categoryOptions.setAccountId(accountId); - categoryOptions.setCategory(category); - categoryOptions.setSortingMethod(sortingMethod); - getCategoryOptionsDao().addCategoryOptions(categoryOptions); - } - } else { - throw new IllegalStateException("Tried to modify category order for " + ENavigationCategoryType.DEFAULT_CATEGORY + "but category is null."); - } - break; - } - } - sp.apply(); - }).start(); - } - - /** - * Gets the sorting method of a {@link NavigationCategory}, the category can be normal - * {@link CategoryOptions} or one of {@link ENavigationCategoryType}. - * If the category no normal {@link CategoryOptions}, sorting method will be got from - * {@link SharedPreferences}. - * <p> - * The sorting method of the category can be used to decide to use which sorting method to show - * the notes for each categories. - * - * @param selectedCategory The category - * @return The sorting method in CategorySortingMethod enum format - */ - @NonNull - @MainThread - public LiveData<CategorySortingMethod> getCategoryOrder(@NonNull NavigationCategory selectedCategory) { - final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - String prefKey; - - switch (selectedCategory.getType()) { - // TODO make this account specific - case RECENT: { - prefKey = context.getString(R.string.action_sorting_method) + ' ' + context.getString(R.string.label_all_notes); - break; - } - case FAVORITES: { - prefKey = context.getString(R.string.action_sorting_method) + ' ' + context.getString(R.string.label_favorites); - break; - } - case UNCATEGORIZED: { - prefKey = context.getString(R.string.action_sorting_method) + ' ' + context.getString(R.string.action_uncategorized); - break; - } - case DEFAULT_CATEGORY: - default: { - final String category = selectedCategory.getCategory(); - if (category != null) { - return getCategoryOptionsDao().getCategoryOrder(selectedCategory.getAccountId(), category); - } else { - Log.e(TAG, "Cannot read " + CategorySortingMethod.class.getSimpleName() + " for " + ENavigationCategoryType.DEFAULT_CATEGORY + "."); - return new MutableLiveData<>(CategorySortingMethod.SORT_MODIFIED_DESC); - } - } - } - - return map(new SharedPreferenceIntLiveData(sp, prefKey, CategorySortingMethod.SORT_MODIFIED_DESC.getId()), CategorySortingMethod::findById); - } - - public Context getContext() { - return NotesDatabase.context; - } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java new file mode 100644 index 00000000..afd6145d --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java @@ -0,0 +1,930 @@ +package it.niedermann.owncloud.notes.persistence; + +import android.accounts.NetworkErrorException; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.graphics.drawable.Icon; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.AnyThread; +import androidx.annotation.ColorInt; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.preference.PreferenceManager; + +import com.nextcloud.android.sso.AccountImporter; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; +import com.nextcloud.android.sso.helper.SingleAccountHelper; +import com.nextcloud.android.sso.model.SingleSignOnAccount; + +import org.json.JSONArray; +import org.json.JSONException; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import it.niedermann.android.sharedpreferences.SharedPreferenceIntLiveData; +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.edit.EditNoteActivity; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.CategoryOptions; +import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; +import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; +import it.niedermann.owncloud.notes.shared.model.ApiVersion; +import it.niedermann.owncloud.notes.shared.model.Capabilities; +import it.niedermann.owncloud.notes.shared.model.CategorySortingMethod; +import it.niedermann.owncloud.notes.shared.model.DBStatus; +import it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; +import it.niedermann.owncloud.notes.shared.model.ISyncCallback; +import it.niedermann.owncloud.notes.shared.model.NavigationCategory; +import it.niedermann.owncloud.notes.shared.model.NotesSettings; +import it.niedermann.owncloud.notes.shared.model.SyncResultStatus; +import it.niedermann.owncloud.notes.shared.util.NoteUtil; +import it.niedermann.owncloud.notes.shared.util.SSOUtil; +import retrofit2.Call; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.O; +import static androidx.lifecycle.Transformations.distinctUntilChanged; +import static androidx.lifecycle.Transformations.map; +import static androidx.lifecycle.Transformations.switchMap; +import static it.niedermann.owncloud.notes.edit.EditNoteActivity.ACTION_SHORTCUT; +import static it.niedermann.owncloud.notes.shared.util.NoteUtil.generateNoteExcerpt; +import static it.niedermann.owncloud.notes.widget.notelist.NoteListWidget.updateNoteListWidgets; +import static it.niedermann.owncloud.notes.widget.singlenote.SingleNoteWidget.updateSingleNoteWidgets; +import static java.util.stream.Collectors.toMap; + +@SuppressWarnings("UnusedReturnValue") +public class NotesRepository { + + private static final String TAG = NotesRepository.class.getSimpleName(); + + private static NotesRepository instance; + + private final ExecutorService executor; + private final Context context; + private final NotesDatabase db; + private final String defaultNonEmptyTitle; + + /** + * Track network connection changes using a {@link BroadcastReceiver} + */ + private boolean isSyncPossible = false; + private boolean networkConnected = false; + private String syncOnlyOnWifiKey; + private boolean syncOnlyOnWifi; + private final MutableLiveData<Boolean> syncStatus = new MutableLiveData<>(false); + private final MutableLiveData<ArrayList<Throwable>> syncErrors = new MutableLiveData<>(); + + /** + * @see <a href="https://stackoverflow.com/a/3104265">Do not make this a local variable.</a> + */ + @SuppressWarnings("FieldCanBeLocal") + private final SharedPreferences.OnSharedPreferenceChangeListener onSharedPreferenceChangeListener = (SharedPreferences prefs, String key) -> { + if (syncOnlyOnWifiKey.equals(key)) { + syncOnlyOnWifi = prefs.getBoolean(syncOnlyOnWifiKey, false); + updateNetworkStatus(); + } + }; + + private final BroadcastReceiver networkReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + updateNetworkStatus(); + if (isSyncPossible() && SSOUtil.isConfigured(context)) { + executor.submit(() -> { + try { + scheduleSync(getAccountByName(SingleAccountHelper.getCurrentSingleSignOnAccount(context).name), false); + } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { + Log.v(TAG, "Can not select current SingleSignOn account after network changed, do not sync."); + } + }); + } + } + }; + + // current state of the synchronization + private final Map<Long, Boolean> syncActive = new HashMap<>(); + private final Map<Long, Boolean> syncScheduled = new HashMap<>(); + + // list of callbacks for both parts of synchronization + private final Map<Long, List<ISyncCallback>> callbacksPush = new HashMap<>(); + private final Map<Long, List<ISyncCallback>> callbacksPull = new HashMap<>(); + + + public static synchronized NotesRepository getInstance(@NonNull Context context) { + if (instance == null) { + instance = new NotesRepository(context, NotesDatabase.getInstance(context.getApplicationContext()), Executors.newCachedThreadPool()); + } + return instance; + } + + private NotesRepository(@NonNull final Context context, @NonNull final NotesDatabase db, @NonNull final ExecutorService executor) { + this.context = context.getApplicationContext(); + this.db = db; + this.executor = executor; + this.defaultNonEmptyTitle = NoteUtil.generateNonEmptyNoteTitle("", this.context); + this.syncOnlyOnWifiKey = context.getApplicationContext().getResources().getString(R.string.pref_key_wifi_only); + + // Registers BroadcastReceiver to track network connection changes. + context.getApplicationContext().registerReceiver(networkReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this.context.getApplicationContext()); + prefs.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener); + syncOnlyOnWifi = prefs.getBoolean(syncOnlyOnWifiKey, false); + + updateNetworkStatus(); + } + + + // Accounts + + @AnyThread + public void addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @NonNull IResponseCallback<Account> callback) { + final Account createdAccount = db.getAccountDao().getAccountById(db.getAccountDao().insert(new Account(url, username, accountName, capabilities))); + if (createdAccount == null) { + callback.onError(new Exception("Could not read created account.")); + } else { + callback.onSuccess(createdAccount); + } + } + + @WorkerThread + public List<Account> getAccounts() { + return db.getAccountDao().getAccounts(); + } + + @WorkerThread + public void deleteAccount(@NonNull Account account) { + try { + ApiProvider.invalidateAPICache(AccountImporter.getSingleSignOnAccount(context, account.getAccountName())); + } catch (NextcloudFilesAppAccountNotFoundException e) { + e.printStackTrace(); + ApiProvider.invalidateAPICache(); + } + + db.getAccountDao().deleteAccount(account); + } + + public Account getAccountByName(String accountName) { + return db.getAccountDao().getAccountByName(accountName); + } + + public Account getAccountById(long accountId) { + return db.getAccountDao().getAccountById(accountId); + } + + public LiveData<List<Account>> getAccounts$() { + return db.getAccountDao().getAccounts$(); + } + + public LiveData<Account> getAccountById$(long accountId) { + return db.getAccountDao().getAccountById$(accountId); + } + + public LiveData<Integer> countAccounts$() { + return db.getAccountDao().countAccounts$(); + } + + public void updateBrand(long id, @ColorInt Integer color, @ColorInt Integer textColor) { + db.getAccountDao().updateBrand(id, color, textColor); + } + + public void updateETag(long id, String eTag) { + db.getAccountDao().updateETag(id, eTag); + } + + public void updateCapabilitiesETag(long id, String capabilitiesETag) { + db.getAccountDao().updateCapabilitiesETag(id, capabilitiesETag); + } + + public void updateModified(long id, long modified) { + db.getAccountDao().updateModified(id, modified); + } + + + // Notes + + public LiveData<Note> getNoteById$(long id) { + return db.getNoteDao().getNoteById$(id); + } + + public Note getNoteById(long id) { + return db.getNoteDao().getNoteById(id); + } + + public LiveData<Integer> count$(long accountId) { + return db.getNoteDao().count$(accountId); + } + + public LiveData<Integer> countFavorites$(long accountId) { + return db.getNoteDao().countFavorites$(accountId); + } + + public void updateScrollY(long id, int scrollY) { + db.getNoteDao().updateScrollY(id, scrollY); + } + + public LiveData<List<CategoryWithNotesCount>> searchCategories$(Long accountId, String searchTerm) { + return db.getNoteDao().searchCategories$(accountId, searchTerm); + } + + public LiveData<List<Note>> searchRecentByModified$(long accountId, String query) { + return db.getNoteDao().searchRecentByModified$(accountId, query); + } + + public List<Note> searchRecentByModified(long accountId, String query) { + return db.getNoteDao().searchRecentByModified(accountId, query); + } + + public LiveData<List<Note>> searchRecentLexicographically$(long accountId, String query) { + return db.getNoteDao().searchRecentLexicographically$(accountId, query); + } + + public LiveData<List<Note>> searchFavoritesByModified$(long accountId, String query) { + return db.getNoteDao().searchFavoritesByModified$(accountId, query); + } + + public List<Note> searchFavoritesByModified(long accountId, String query) { + return db.getNoteDao().searchFavoritesByModified(accountId, query); + } + + public LiveData<List<Note>> searchFavoritesLexicographically$(long accountId, String query) { + return db.getNoteDao().searchFavoritesLexicographically$(accountId, query); + } + + public LiveData<List<Note>> searchUncategorizedByModified$(long accountId, String query) { + return db.getNoteDao().searchUncategorizedByModified$(accountId, query); + } + + public List<Note> searchUncategorizedByModified(long accountId, String query) { + return db.getNoteDao().searchUncategorizedByModified(accountId, query); + } + + public LiveData<List<Note>> searchUncategorizedLexicographically$(long accountId, String query) { + return db.getNoteDao().searchUncategorizedLexicographically$(accountId, query); + } + + public LiveData<List<Note>> searchCategoryByModified$(long accountId, String query, String category) { + return db.getNoteDao().searchCategoryByModified$(accountId, query, category); + } + + public List<Note> searchCategoryByModified(long accountId, String query, String category) { + return db.getNoteDao().searchCategoryByModified(accountId, query, category); + } + + public LiveData<List<Note>> searchCategoryLexicographically$(long accountId, String query, String category) { + return db.getNoteDao().searchCategoryLexicographically$(accountId, query, category); + } + + public LiveData<List<CategoryWithNotesCount>> getCategories$(Long accountId) { + return db.getNoteDao().getCategories$(accountId); + } + + public void updateRemoteId(long id, Long remoteId) { + db.getNoteDao().updateRemoteId(id, remoteId); + } + + public Long getLocalIdByRemoteId(long accountId, long remoteId) { + return db.getNoteDao().getLocalIdByRemoteId(accountId, remoteId); + } + + public List<Note> getLocalModifiedNotes(long accountId) { + return db.getNoteDao().getLocalModifiedNotes(accountId); + } + + public void deleteByNoteId(long id, DBStatus forceDBStatus) { + db.getNoteDao().deleteByNoteId(id, forceDBStatus); + } + + /** + * Please note, that db.updateNote() realized an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. + */ + public int updateIfNotModifiedLocallyDuringSync(long noteId, Long targetModified, String targetTitle, boolean targetFavorite, String targetETag, String targetContent, String targetExcerpt, String contentBeforeSyncStart, String categoryBeforeSyncStart, boolean favoriteBeforeSyncStart) { + return db.getNoteDao().updateIfNotModifiedLocallyDuringSync(noteId, targetModified, targetTitle, targetFavorite, targetETag, targetContent, targetExcerpt, contentBeforeSyncStart, categoryBeforeSyncStart, favoriteBeforeSyncStart); + } + + public int updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged(long id, Long modified, String title, boolean favorite, String category, String eTag, String content, String excerpt) { + return db.getNoteDao().updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged(id, modified, title, favorite, category, eTag, content, excerpt); + } + + public long countUnsynchronizedNotes(long accountId) { + final Long unsynchronizedNotesCount = db.getNoteDao().countUnsynchronizedNotes(accountId); + return unsynchronizedNotesCount == null ? 0 : unsynchronizedNotesCount; + } + + + // SingleNoteWidget + + public void createOrUpdateSingleNoteWidgetData(SingleNoteWidgetData data) { + db.getWidgetSingleNoteDao().createOrUpdateSingleNoteWidgetData(data); + } + + public void removeSingleNoteWidget(int id) { + db.getWidgetSingleNoteDao().removeSingleNoteWidget(id); + } + + public SingleNoteWidgetData getSingleNoteWidgetData(int id) { + return db.getWidgetSingleNoteDao().getSingleNoteWidgetData(id); + } + + + // ListWidget + + public void createOrUpdateNoteListWidgetData(NotesListWidgetData data) { + db.getWidgetNotesListDao().createOrUpdateNoteListWidgetData(data); + } + + public void removeNoteListWidget(int appWidgetId) { + db.getWidgetNotesListDao().removeNoteListWidget(appWidgetId); + } + + public NotesListWidgetData getNoteListWidgetData(int appWidgetId) { + return db.getWidgetNotesListDao().getNoteListWidgetData(appWidgetId); + } + + /** + * Creates a new Note in the Database and adds a Synchronization Flag. + * + * @param note Note + */ + @NonNull + @MainThread + public LiveData<Note> addNoteAndSync(Account account, Note note) { + final Note entity = new Note(0, null, note.getModified(), note.getTitle(), note.getContent(), note.getCategory(), note.getFavorite(), note.getETag(), DBStatus.LOCAL_EDITED, account.getId(), generateNoteExcerpt(note.getContent(), note.getTitle()), 0); + final MutableLiveData<Note> ret = new MutableLiveData<>(); + executor.submit(() -> ret.postValue(addNote(account.getId(), entity))); + return map(ret, newNote -> { + notifyWidgets(); + scheduleSync(account, true); + return newNote; + }); + } + + /** + * Inserts a note directly into the Database. + * Excerpt will be generated, {@link DBStatus#LOCAL_EDITED} will be applied in case the note has + * already has a local ID, otherwise {@link DBStatus#VOID} will be applied. + * No Synchronisation will be triggered! Use {@link #addNoteAndSync(Account, Note)}! + * + * @param note {@link Note} to be added. + */ + @NonNull + @WorkerThread + public Note addNote(long accountId, @NonNull Note note) { + note.setStatus(note.getId() > 0 ? DBStatus.LOCAL_EDITED : DBStatus.VOID); + note.setAccountId(accountId); + note.setExcerpt(generateNoteExcerpt(note.getContent(), note.getTitle())); + return db.getNoteDao().getNoteById(db.getNoteDao().addNote(note)); + } + + @MainThread + public LiveData<Note> moveNoteToAnotherAccount(Account account, @NonNull Note note) { + return switchMap(db.getNoteDao().getContent$(note.getId()), (content) -> { + final Note fullNote = new Note(null, note.getModified(), note.getTitle(), content, note.getCategory(), note.getFavorite(), null); + deleteNoteAndSync(account, note.getId()); + return addNoteAndSync(account, fullNote); + }); + } + + /** + * @return a {@link Map} of remote IDs as keys and local IDs as values of all {@link Note}s of + * the given {@param accountId} which are not {@link DBStatus#LOCAL_DELETED} + */ + @NonNull + @WorkerThread + public Map<Long, Long> getIdMap(long accountId) { + return db.getNoteDao() + .getRemoteIdAndId(accountId) + .stream() + .collect(toMap(Note::getRemoteId, Note::getId)); + } + + @AnyThread + public void toggleFavoriteAndSync(Account account, long noteId) { + executor.submit(() -> { + db.getNoteDao().toggleFavorite(noteId); + scheduleSync(account, true); + }); + } + + /** + * Set the category for a given note. + * This method will search in the database to find out the category id in the db. + * If there is no such category existing, this method will create it and search again. + * + * @param account The single sign on account + * @param noteId The note which will be updated + * @param category The category title which should be used to find the category id. + */ + @AnyThread + public void setCategory(@NonNull Account account, long noteId, @NonNull String category) { + executor.submit(() -> { + db.getNoteDao().updateStatus(noteId, DBStatus.LOCAL_EDITED); + db.getNoteDao().updateCategory(noteId, category); + scheduleSync(account, true); + }); + } + + /** + * Updates a single Note with a new content. + * The title is derived from the new content automatically, and modified date as well as DBStatus are updated, too -- if the content differs to the state in the database. + * + * @param oldNote Note to be changed + * @param newContent New content. If this is <code>null</code>, then <code>oldNote</code> is saved again (useful for undoing changes). + * @param newTitle New title. If this is <code>null</code>, then either the old title is reused (in case the note has been synced before) or a title is generated (in case it is a new note) + * @param callback When the synchronization is finished, this callback will be invoked (optional). + * @return changed {@link Note} if differs from database, otherwise the old {@link Note}. + */ + @WorkerThread + public Note updateNoteAndSync(Account localAccount, @NonNull Note oldNote, @Nullable String newContent, @Nullable String newTitle, @Nullable ISyncCallback callback) { + final Note newNote; + if (newContent == null) { + newNote = new Note(oldNote.getId(), oldNote.getRemoteId(), oldNote.getModified(), oldNote.getTitle(), oldNote.getContent(), oldNote.getCategory(), oldNote.getFavorite(), oldNote.getETag(), DBStatus.LOCAL_EDITED, localAccount.getId(), oldNote.getExcerpt(), oldNote.getScrollY()); + } else { + final String title; + if (newTitle != null) { + title = newTitle; + } else { + if ((oldNote.getRemoteId() == null || localAccount.getPreferredApiVersion() == null || localAccount.getPreferredApiVersion().compareTo(ApiVersion.API_VERSION_1_0) < 0) && + (defaultNonEmptyTitle.equals(oldNote.getTitle()))) { + title = NoteUtil.generateNonEmptyNoteTitle(newContent, context); + } else { + title = oldNote.getTitle(); + } + } + newNote = new Note(oldNote.getId(), oldNote.getRemoteId(), Calendar.getInstance(), title, newContent, oldNote.getCategory(), oldNote.getFavorite(), oldNote.getETag(), DBStatus.LOCAL_EDITED, localAccount.getId(), generateNoteExcerpt(newContent, title), oldNote.getScrollY()); + } + int rows = db.getNoteDao().updateNote(newNote); + // if data was changed, set new status and schedule sync (with callback); otherwise invoke callback directly. + if (rows > 0) { + notifyWidgets(); + if (callback != null) { + addCallbackPush(localAccount, callback); + } + scheduleSync(localAccount, true); + return newNote; + } else { + if (callback != null) { + callback.onFinish(); + } + return oldNote; + } + } + + /** + * Marks a Note in the Database as Deleted. In the next Synchronization it will be deleted + * from the Server. + * + * @param id long - ID of the Note that should be deleted + */ + @AnyThread + public void deleteNoteAndSync(Account account, long id) { + executor.submit(() -> { + db.getNoteDao().updateStatus(id, DBStatus.LOCAL_DELETED); + notifyWidgets(); + scheduleSync(account, true); + + if (SDK_INT >= O) { + ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class); + if (shortcutManager != null) { + shortcutManager.getPinnedShortcuts().forEach((shortcut) -> { + String shortcutId = id + ""; + if (shortcut.getId().equals(shortcutId)) { + Log.v(TAG, "Removing shortcut for " + shortcutId); + shortcutManager.disableShortcuts(Collections.singletonList(shortcutId), context.getResources().getString(R.string.note_has_been_deleted)); + } + }); + } else { + Log.e(TAG, ShortcutManager.class.getSimpleName() + "is null."); + } + } + }); + } + + /** + * Notify about changed notes. + */ + @AnyThread + private void notifyWidgets() { + executor.submit(() -> { + updateSingleNoteWidgets(context); + updateNoteListWidgets(context); + }); + } + + @AnyThread + private void updateDynamicShortcuts(long accountId) { + executor.submit(() -> { + if (SDK_INT >= android.os.Build.VERSION_CODES.N_MR1) { + ShortcutManager shortcutManager = context.getApplicationContext().getSystemService(ShortcutManager.class); + if (shortcutManager != null) { + if (!shortcutManager.isRateLimitingActive()) { + List<ShortcutInfo> newShortcuts = new ArrayList<>(); + + for (Note note : db.getNoteDao().getRecentNotes(accountId)) { + if (!TextUtils.isEmpty(note.getTitle())) { + Intent intent = new Intent(context.getApplicationContext(), EditNoteActivity.class); + intent.putExtra(EditNoteActivity.PARAM_NOTE_ID, note.getId()); + intent.setAction(ACTION_SHORTCUT); + + newShortcuts.add(new ShortcutInfo.Builder(context.getApplicationContext(), note.getId() + "") + .setShortLabel(note.getTitle() + "") + .setIcon(Icon.createWithResource(context.getApplicationContext(), note.getFavorite() ? R.drawable.ic_star_yellow_24dp : R.drawable.ic_star_grey_ccc_24dp)) + .setIntent(intent) + .build()); + } else { + // Prevent crash https://github.com/stefan-niedermann/nextcloud-notes/issues/613 + Log.e(TAG, "shortLabel cannot be empty " + note); + } + } + Log.d(TAG, "Update dynamic shortcuts"); + shortcutManager.removeAllDynamicShortcuts(); + shortcutManager.addDynamicShortcuts(newShortcuts); + } + } + } + }); + } + + /** + * @param apiVersion has to be a JSON array as a string <code>["0.2", "1.0", ...]</code> + * @return whether or not the given {@link ApiVersion} has been written to the database + * @throws IllegalArgumentException if the apiVersion does not match the expected format + */ + public boolean updateApiVersion(long accountId, @Nullable String apiVersion) throws IllegalArgumentException { + if (apiVersion != null) { + try { + JSONArray apiVersions = new JSONArray(apiVersion); + for (int i = 0; i < apiVersions.length(); i++) { + ApiVersion.of(apiVersions.getString(i)); + } + if (apiVersions.length() > 0) { + final int updatedRows = db.getAccountDao().updateApiVersion(accountId, apiVersion); + if (updatedRows == 0) { + Log.d(TAG, "ApiVersion not updated, because it did not change"); + } else if (updatedRows == 1) { + Log.i(TAG, "Updated apiVersion to \"" + apiVersion + "\" for accountId = " + accountId); + ApiProvider.invalidateAPICache(); + } else { + Log.w(TAG, "Updated " + updatedRows + " but expected only 1 for accountId = " + accountId + " and apiVersion = \"" + apiVersion + "\""); + } + return true; + } else { + Log.i(TAG, "Given API version is a valid JSON array but does not contain any valid API versions. Do not update database."); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException("API version does contain a non-valid version: " + apiVersion); + } catch (JSONException e) { + throw new IllegalArgumentException("API version must contain be a JSON array: " + apiVersion); + } + } else { + Log.v(TAG, "Given API version is null. Do not update database"); + } + return false; + } + + /** + * Modifies the sorting method for one category, the category can be normal category or + * one of "All notes", "Favorite", and "Uncategorized". + * If category is one of these three, sorting method will be modified in android.content.SharedPreference. + * The user can determine use which sorting method to show the notes for a category. + * When the user changes the sorting method, this method should be called. + * + * @param accountId The user accountID + * @param selectedCategory The category to be modified + * @param sortingMethod The sorting method in {@link CategorySortingMethod} enum format + */ + @AnyThread + public void modifyCategoryOrder(long accountId, @NonNull NavigationCategory selectedCategory, @NonNull CategorySortingMethod sortingMethod) { + executor.submit(() -> { + final Context ctx = context.getApplicationContext(); + final SharedPreferences.Editor sp = PreferenceManager.getDefaultSharedPreferences(ctx).edit(); + int orderIndex = sortingMethod.getId(); + + switch (selectedCategory.getType()) { + case FAVORITES: { + sp.putInt(ctx.getString(R.string.action_sorting_method) + ' ' + ctx.getString(R.string.label_favorites), orderIndex); + break; + } + case UNCATEGORIZED: { + sp.putInt(ctx.getString(R.string.action_sorting_method) + ' ' + ctx.getString(R.string.action_uncategorized), orderIndex); + break; + } + case RECENT: { + sp.putInt(ctx.getString(R.string.action_sorting_method) + ' ' + ctx.getString(R.string.label_all_notes), orderIndex); + break; + } + case DEFAULT_CATEGORY: + default: { + final String category = selectedCategory.getCategory(); + if (category != null) { + if (db.getCategoryOptionsDao().modifyCategoryOrder(accountId, category, sortingMethod) == 0) { + // Nothing updated means we didn't have this yet + final CategoryOptions categoryOptions = new CategoryOptions(); + categoryOptions.setAccountId(accountId); + categoryOptions.setCategory(category); + categoryOptions.setSortingMethod(sortingMethod); + db.getCategoryOptionsDao().addCategoryOptions(categoryOptions); + } + } else { + throw new IllegalStateException("Tried to modify category order for " + ENavigationCategoryType.DEFAULT_CATEGORY + "but category is null."); + } + break; + } + } + sp.apply(); + }); + } + + /** + * Gets the sorting method of a {@link NavigationCategory}, the category can be normal + * {@link CategoryOptions} or one of {@link ENavigationCategoryType}. + * If the category no normal {@link CategoryOptions}, sorting method will be got from + * {@link SharedPreferences}. + * <p> + * The sorting method of the category can be used to decide to use which sorting method to show + * the notes for each categories. + * + * @param selectedCategory The category + * @return The sorting method in CategorySortingMethod enum format + */ + @NonNull + @MainThread + public LiveData<CategorySortingMethod> getCategoryOrder(@NonNull NavigationCategory selectedCategory) { + final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); + String prefKey; + + switch (selectedCategory.getType()) { + // TODO make this account specific + case RECENT: { + prefKey = context.getString(R.string.action_sorting_method) + ' ' + context.getString(R.string.label_all_notes); + break; + } + case FAVORITES: { + prefKey = context.getString(R.string.action_sorting_method) + ' ' + context.getString(R.string.label_favorites); + break; + } + case UNCATEGORIZED: { + prefKey = context.getString(R.string.action_sorting_method) + ' ' + context.getString(R.string.action_uncategorized); + break; + } + case DEFAULT_CATEGORY: + default: { + final String category = selectedCategory.getCategory(); + if (category != null) { + return db.getCategoryOptionsDao().getCategoryOrder(selectedCategory.getAccountId(), category); + } else { + Log.e(TAG, "Cannot read " + CategorySortingMethod.class.getSimpleName() + " for " + ENavigationCategoryType.DEFAULT_CATEGORY + "."); + return new MutableLiveData<>(CategorySortingMethod.SORT_MODIFIED_DESC); + } + } + } + + return map(new SharedPreferenceIntLiveData(sp, prefKey, CategorySortingMethod.SORT_MODIFIED_DESC.getId()), CategorySortingMethod::findById); + } + + @Override + protected void finalize() throws Throwable { + context.getApplicationContext().unregisterReceiver(networkReceiver); + super.finalize(); + } + + /** + * Synchronization is only possible, if there is an active network connection. + * <p> + * This method respects the user preference "Sync on Wi-Fi only". + * <p> + * NoteServerSyncHelper observes changes in the network connection. + * The current state can be retrieved with this method. + * + * @return true if sync is possible, otherwise false. + */ + public boolean isSyncPossible() { + return isSyncPossible; + } + + public boolean isNetworkConnected() { + return networkConnected; + } + + public boolean isSyncOnlyOnWifi() { + return syncOnlyOnWifi; + } + + /** + * Adds a callback method to the NoteServerSyncHelper for the synchronization part push local changes to the server. + * All callbacks will be executed once the synchronization operations are done. + * After execution the callback will be deleted, so it has to be added again if it shall be + * executed the next time all synchronize operations are finished. + * + * @param callback Implementation of ISyncCallback, contains one method that shall be executed. + */ + private void addCallbackPush(Account account, ISyncCallback callback) { + if (account == null) { + Log.i(TAG, "ssoAccount is null. Is this a local account?"); + callback.onScheduled(); + callback.onFinish(); + } else { + if (!callbacksPush.containsKey(account.getId())) { + callbacksPush.put(account.getId(), new ArrayList<>()); + } + Objects.requireNonNull(callbacksPush.get(account.getId())).add(callback); + } + } + + /** + * Adds a callback method to the NoteServerSyncHelper for the synchronization part pull remote changes from the server. + * All callbacks will be executed once the synchronization operations are done. + * After execution the callback will be deleted, so it has to be added again if it shall be + * executed the next time all synchronize operations are finished. + * + * @param callback Implementation of ISyncCallback, contains one method that shall be executed. + */ + public void addCallbackPull(Account account, ISyncCallback callback) { + if (account == null) { + Log.i(TAG, "ssoAccount is null. Is this a local account?"); + callback.onScheduled(); + callback.onFinish(); + } else { + if (!callbacksPull.containsKey(account.getId())) { + callbacksPull.put(account.getId(), new ArrayList<>()); + } + Objects.requireNonNull(callbacksPull.get(account.getId())).add(callback); + } + } + + /** + * Schedules a synchronization and start it directly, if the network is connected and no + * synchronization is currently running. + * + * @param onlyLocalChanges Whether to only push local changes to the server or to also load the whole list of notes from the server. + */ + public synchronized void scheduleSync(Account account, boolean onlyLocalChanges) { + if (account == null) { + Log.i(TAG, SingleSignOnAccount.class.getSimpleName() + " is null. Is this a local account?"); + } else { + if (syncActive.get(account.getId()) == null) { + syncActive.put(account.getId(), false); + } + Log.d(TAG, "Sync requested (" + (onlyLocalChanges ? "onlyLocalChanges" : "full") + "; " + (Boolean.TRUE.equals(syncActive.get(account.getId())) ? "sync active" : "sync NOT active") + ") ..."); + if (isSyncPossible() && (!Boolean.TRUE.equals(syncActive.get(account.getId())) || onlyLocalChanges)) { + syncActive.put(account.getId(), true); + try { + Log.d(TAG, "... starting now"); + final NotesServerSyncTask syncTask = new NotesServerSyncTask(context, this, account, onlyLocalChanges) { + @Override + void onPreExecute() { + syncStatus.postValue(true); + if (!syncScheduled.containsKey(localAccount.getId()) || syncScheduled.get(localAccount.getId()) == null) { + syncScheduled.put(localAccount.getId(), false); + } + if (!onlyLocalChanges && Boolean.TRUE.equals(syncScheduled.get(localAccount.getId()))) { + syncScheduled.put(localAccount.getId(), false); + } + } + + @Override + void onPostExecute(SyncResultStatus status) { + for (Throwable e : exceptions) { + Log.e(TAG, e.getMessage(), e); + } + if (!status.pullSuccessful || !status.pushSuccessful) { + syncErrors.postValue(exceptions); + } + syncActive.put(localAccount.getId(), false); + // notify callbacks + if (callbacks.containsKey(localAccount.getId()) && callbacks.get(localAccount.getId()) != null) { + for (ISyncCallback callback : Objects.requireNonNull(callbacks.get(localAccount.getId()))) { + callback.onFinish(); + } + } + notifyWidgets(); + updateDynamicShortcuts(localAccount.getId()); + // start next sync if scheduled meanwhile + if (syncScheduled.containsKey(localAccount.getId()) && syncScheduled.get(localAccount.getId()) != null && Boolean.TRUE.equals(syncScheduled.get(localAccount.getId()))) { + scheduleSync(localAccount, false); + } + syncStatus.postValue(false); + } + }; + syncTask.addCallbacks(account, callbacksPush.get(account.getId())); + callbacksPush.put(account.getId(), new ArrayList<>()); + if (!onlyLocalChanges) { + syncTask.addCallbacks(account, callbacksPull.get(account.getId())); + callbacksPull.put(account.getId(), new ArrayList<>()); + } + executor.submit(syncTask); + } catch (NextcloudFilesAppAccountNotFoundException e) { + Log.e(TAG, "... Could not find " + SingleSignOnAccount.class.getSimpleName() + " for account name " + account.getAccountName()); + e.printStackTrace(); + } + } else if (!onlyLocalChanges) { + Log.d(TAG, "... scheduled"); + syncScheduled.put(account.getId(), true); + if (callbacksPush.containsKey(account.getId()) && callbacksPush.get(account.getId()) != null) { + final List<ISyncCallback> callbacks = callbacksPush.get(account.getId()); + if (callbacks != null) { + for (ISyncCallback callback : callbacks) { + callback.onScheduled(); + } + } else { + Log.w(TAG, "List of push-callbacks was set for account \"" + account.getAccountName() + "\" but it was null"); + } + } + } else { + Log.d(TAG, "... do nothing"); + if (callbacksPush.containsKey(account.getId()) && callbacksPush.get(account.getId()) != null) { + final List<ISyncCallback> callbacks = callbacksPush.get(account.getId()); + if (callbacks != null) { + for (ISyncCallback callback : callbacks) { + callback.onScheduled(); + } + } else { + Log.w(TAG, "List of push-callbacks was set for account \"" + account.getAccountName() + "\" but it was null"); + } + } + } + } + } + + public void updateNetworkStatus() { + try { + final ConnectivityManager connMgr = (ConnectivityManager) context.getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE); + if (connMgr == null) { + throw new NetworkErrorException("ConnectivityManager is null"); + } + + final NetworkInfo activeInfo = connMgr.getActiveNetworkInfo(); + if (activeInfo == null) { + throw new NetworkErrorException("NetworkInfo is null"); + } + + if (activeInfo.isConnected()) { + networkConnected = true; + + final NetworkInfo networkInfo = connMgr.getNetworkInfo((ConnectivityManager.TYPE_WIFI)); + if (networkInfo == null) { + throw new NetworkErrorException("connMgr.getNetworkInfo(ConnectivityManager.TYPE_WIFI) is null"); + } + + isSyncPossible = !syncOnlyOnWifi || networkInfo.isConnected(); + + if (isSyncPossible) { + Log.d(TAG, "Network connection established."); + } else { + Log.d(TAG, "Network connected, but not used because only synced on wifi."); + } + } else { + networkConnected = false; + isSyncPossible = false; + Log.d(TAG, "No network connection."); + } + } catch (NetworkErrorException e) { + Log.i(TAG, e.getMessage()); + networkConnected = false; + isSyncPossible = false; + } + } + + @NonNull + public LiveData<Boolean> getSyncStatus() { + return distinctUntilChanged(this.syncStatus); + } + + @NonNull + public LiveData<ArrayList<Throwable>> getSyncErrors() { + return this.syncErrors; + } + + public Call<NotesSettings> getServerSettings(@NonNull SingleSignOnAccount ssoAccount, @Nullable ApiVersion preferredApiVersion) { + return ApiProvider.getNotesAPI(context, ssoAccount, preferredApiVersion).getSettings(); + } + + public Call<NotesSettings> putServerSettings(@NonNull SingleSignOnAccount ssoAccount, @NonNull NotesSettings settings, @Nullable ApiVersion preferredApiVersion) { + return ApiProvider.getNotesAPI(context, ssoAccount, preferredApiVersion).putSettings(settings); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncHelper.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncHelper.java deleted file mode 100644 index e9fab1b0..00000000 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncHelper.java +++ /dev/null @@ -1,346 +0,0 @@ -package it.niedermann.owncloud.notes.persistence; - -import android.accounts.NetworkErrorException; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.preference.PreferenceManager; - -import com.nextcloud.android.sso.AccountImporter; -import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; -import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; -import com.nextcloud.android.sso.helper.SingleAccountHelper; -import com.nextcloud.android.sso.model.SingleSignOnAccount; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -import it.niedermann.owncloud.notes.R; -import it.niedermann.owncloud.notes.persistence.entity.Account; -import it.niedermann.owncloud.notes.shared.model.ISyncCallback; -import it.niedermann.owncloud.notes.shared.model.SyncResultStatus; -import it.niedermann.owncloud.notes.shared.util.SSOUtil; - -import static androidx.lifecycle.Transformations.distinctUntilChanged; - -/** - * Helps to synchronize the Database to the Server. - */ -public class NotesServerSyncHelper { - - private static final String TAG = NotesServerSyncHelper.class.getSimpleName(); - - private final ExecutorService executor = Executors.newSingleThreadExecutor(); - - private static NotesServerSyncHelper instance; - - private final NotesDatabase db; - private final Context context; - - /** - * Track network connection changes using a {@link BroadcastReceiver} - */ - private boolean isSyncPossible = false; - private boolean networkConnected = false; - private String syncOnlyOnWifiKey; - private boolean syncOnlyOnWifi; - private final MutableLiveData<Boolean> syncStatus = new MutableLiveData<>(false); - private final MutableLiveData<ArrayList<Throwable>> syncErrors = new MutableLiveData<>(); - - /** - * @see <a href="https://stackoverflow.com/a/3104265">Do not make this a local variable.</a> - */ - @SuppressWarnings("FieldCanBeLocal") - private final SharedPreferences.OnSharedPreferenceChangeListener onSharedPreferenceChangeListener = (SharedPreferences prefs, String key) -> { - if (syncOnlyOnWifiKey.equals(key)) { - syncOnlyOnWifi = prefs.getBoolean(syncOnlyOnWifiKey, false); - updateNetworkStatus(); - } - }; - - private final BroadcastReceiver networkReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - updateNetworkStatus(); - if (isSyncPossible() && SSOUtil.isConfigured(context)) { - new Thread(() -> { - try { - scheduleSync(db.getAccountDao().getAccountByName(SingleAccountHelper.getCurrentSingleSignOnAccount(context).name), false); - } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { - Log.v(TAG, "Can not select current SingleSignOn account after network changed, do not sync."); - } - }).start(); - } - } - }; - - // current state of the synchronization - private final Map<Long, Boolean> syncActive = new HashMap<>(); - private final Map<Long, Boolean> syncScheduled = new HashMap<>(); - - // list of callbacks for both parts of synchronization - private final Map<Long, List<ISyncCallback>> callbacksPush = new HashMap<>(); - private final Map<Long, List<ISyncCallback>> callbacksPull = new HashMap<>(); - - private NotesServerSyncHelper(NotesDatabase db) { - this.db = db; - this.context = db.getContext(); - this.syncOnlyOnWifiKey = context.getApplicationContext().getResources().getString(R.string.pref_key_wifi_only); - - // Registers BroadcastReceiver to track network connection changes. - context.getApplicationContext().registerReceiver(networkReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this.context.getApplicationContext()); - prefs.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener); - syncOnlyOnWifi = prefs.getBoolean(syncOnlyOnWifiKey, false); - - updateNetworkStatus(); - } - - /** - * Get (or create) instance from NoteServerSyncHelper. - * This has to be a singleton in order to realize correct registering and unregistering of - * the BroadcastReceiver, which listens on changes of network connectivity. - * - * @param db {@link NotesDatabase} - * @return NoteServerSyncHelper - */ - public static synchronized NotesServerSyncHelper getInstance(NotesDatabase db) { - if (instance == null) { - instance = new NotesServerSyncHelper(db); - } - return instance; - } - - @Override - protected void finalize() throws Throwable { - context.getApplicationContext().unregisterReceiver(networkReceiver); - super.finalize(); - } - - /** - * Synchronization is only possible, if there is an active network connection. - * <p> - * This method respects the user preference "Sync on Wi-Fi only". - * <p> - * NoteServerSyncHelper observes changes in the network connection. - * The current state can be retrieved with this method. - * - * @return true if sync is possible, otherwise false. - */ - public boolean isSyncPossible() { - return isSyncPossible; - } - - public boolean isNetworkConnected() { - return networkConnected; - } - - public boolean isSyncOnlyOnWifi() { - return syncOnlyOnWifi; - } - - /** - * Adds a callback method to the NoteServerSyncHelper for the synchronization part push local changes to the server. - * All callbacks will be executed once the synchronization operations are done. - * After execution the callback will be deleted, so it has to be added again if it shall be - * executed the next time all synchronize operations are finished. - * - * @param callback Implementation of ISyncCallback, contains one method that shall be executed. - */ - public void addCallbackPush(Account account, ISyncCallback callback) { - if (account == null) { - Log.i(TAG, "ssoAccount is null. Is this a local account?"); - callback.onScheduled(); - callback.onFinish(); - } else { - if (!callbacksPush.containsKey(account.getId())) { - callbacksPush.put(account.getId(), new ArrayList<>()); - } - Objects.requireNonNull(callbacksPush.get(account.getId())).add(callback); - } - } - - /** - * Adds a callback method to the NoteServerSyncHelper for the synchronization part pull remote changes from the server. - * All callbacks will be executed once the synchronization operations are done. - * After execution the callback will be deleted, so it has to be added again if it shall be - * executed the next time all synchronize operations are finished. - * - * @param callback Implementation of ISyncCallback, contains one method that shall be executed. - */ - public void addCallbackPull(Account account, ISyncCallback callback) { - if (account == null) { - Log.i(TAG, "ssoAccount is null. Is this a local account?"); - callback.onScheduled(); - callback.onFinish(); - } else { - if (!callbacksPull.containsKey(account.getId())) { - callbacksPull.put(account.getId(), new ArrayList<>()); - } - Objects.requireNonNull(callbacksPull.get(account.getId())).add(callback); - } - } - - /** - * Schedules a synchronization and start it directly, if the network is connected and no - * synchronization is currently running. - * - * @param onlyLocalChanges Whether to only push local changes to the server or to also load the whole list of notes from the server. - */ - public void scheduleSync(Account account, boolean onlyLocalChanges) { - if (account == null) { - Log.i(TAG, SingleSignOnAccount.class.getSimpleName() + " is null. Is this a local account?"); - } else { - if (syncActive.get(account.getId()) == null) { - syncActive.put(account.getId(), false); - } - Log.d(TAG, "Sync requested (" + (onlyLocalChanges ? "onlyLocalChanges" : "full") + "; " + (Boolean.TRUE.equals(syncActive.get(account.getId())) ? "sync active" : "sync NOT active") + ") ..."); - if (isSyncPossible() && (!Boolean.TRUE.equals(syncActive.get(account.getId())) || onlyLocalChanges)) { - try { - SingleSignOnAccount ssoAccount = AccountImporter.getSingleSignOnAccount(context, account.getAccountName()); - Log.d(TAG, "... starting now"); - final NotesClient notesClient = NotesClient.newInstance(account.getPreferredApiVersion(), context); - final NotesServerSyncTask syncTask = new NotesServerSyncTask(notesClient, db, account, ssoAccount, onlyLocalChanges) { - @Override - void onPreExecute() { - syncStatus.postValue(true); - if (!syncScheduled.containsKey(localAccount.getId()) || syncScheduled.get(localAccount.getId()) == null) { - syncScheduled.put(localAccount.getId(), false); - } - if (!onlyLocalChanges && Boolean.TRUE.equals(syncScheduled.get(localAccount.getId()))) { - syncScheduled.put(localAccount.getId(), false); - } - syncActive.put(localAccount.getId(), true); - } - - @Override - void onPostExecute(SyncResultStatus status) { - for (Throwable e : exceptions) { - Log.e(TAG, e.getMessage(), e); - } - if (!status.pullSuccessful || !status.pushSuccessful) { - syncErrors.postValue(exceptions); - } - syncActive.put(localAccount.getId(), false); - // notify callbacks - if (callbacks.containsKey(localAccount.getId()) && callbacks.get(localAccount.getId()) != null) { - for (ISyncCallback callback : Objects.requireNonNull(callbacks.get(localAccount.getId()))) { - callback.onFinish(); - } - } - db.notifyWidgets(); - db.updateDynamicShortcuts(localAccount.getId()); - // start next sync if scheduled meanwhile - if (syncScheduled.containsKey(localAccount.getId()) && syncScheduled.get(localAccount.getId()) != null && Boolean.TRUE.equals(syncScheduled.get(localAccount.getId()))) { - scheduleSync(localAccount, false); - } - syncStatus.postValue(false); - } - }; - syncTask.addCallbacks(account, callbacksPush.get(account.getId())); - callbacksPush.put(account.getId(), new ArrayList<>()); - if (!onlyLocalChanges) { - syncTask.addCallbacks(account, callbacksPull.get(account.getId())); - callbacksPull.put(account.getId(), new ArrayList<>()); - } - executor.submit(syncTask); - } catch (NextcloudFilesAppAccountNotFoundException e) { - Log.e(TAG, "... Could not find " + SingleSignOnAccount.class.getSimpleName() + " for account name " + account.getAccountName()); - e.printStackTrace(); - } - } else if (!onlyLocalChanges) { - Log.d(TAG, "... scheduled"); - syncScheduled.put(account.getId(), true); - if (callbacksPush.containsKey(account.getId()) && callbacksPush.get(account.getId()) != null) { - final List<ISyncCallback> callbacks = callbacksPush.get(account.getId()); - if (callbacks != null) { - for (ISyncCallback callback : callbacks) { - callback.onScheduled(); - } - } else { - Log.w(TAG, "List of push-callbacks was set for account \"" + account.getAccountName() + "\" but it was null"); - } - } - } else { - Log.d(TAG, "... do nothing"); - if (callbacksPush.containsKey(account.getId()) && callbacksPush.get(account.getId()) != null) { - final List<ISyncCallback> callbacks = callbacksPush.get(account.getId()); - if (callbacks != null) { - for (ISyncCallback callback : callbacks) { - callback.onScheduled(); - } - } else { - Log.w(TAG, "List of push-callbacks was set for account \"" + account.getAccountName() + "\" but it was null"); - } - } - } - } - } - - public void updateNetworkStatus() { - try { - final ConnectivityManager connMgr = (ConnectivityManager) context.getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE); - - if (connMgr == null) { - throw new NetworkErrorException("ConnectivityManager is null"); - } - - final NetworkInfo activeInfo = connMgr.getActiveNetworkInfo(); - - if (activeInfo == null) { - throw new NetworkErrorException("NetworkInfo is null"); - } - - if (activeInfo.isConnected()) { - networkConnected = true; - - final NetworkInfo networkInfo = connMgr.getNetworkInfo((ConnectivityManager.TYPE_WIFI)); - - if (networkInfo == null) { - throw new NetworkErrorException("connMgr.getNetworkInfo(ConnectivityManager.TYPE_WIFI) is null"); - } - - isSyncPossible = !syncOnlyOnWifi || networkInfo.isConnected(); - - if (isSyncPossible) { - Log.d(TAG, "Network connection established."); - } else { - Log.d(TAG, "Network connected, but not used because only synced on wifi."); - } - } else { - networkConnected = false; - isSyncPossible = false; - Log.d(TAG, "No network connection."); - } - } catch (NetworkErrorException e) { - e.printStackTrace(); - networkConnected = false; - isSyncPossible = false; - } - } - - @NonNull - public LiveData<Boolean> getSyncStatus() { - return distinctUntilChanged(this.syncStatus); - } - - @NonNull - public LiveData<ArrayList<Throwable>> getSyncErrors() { - return this.syncErrors; - } -} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java index ddc05981..88fb44f3 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java @@ -1,31 +1,40 @@ package it.niedermann.owncloud.notes.persistence; +import android.content.Context; import android.util.Log; import androidx.annotation.NonNull; +import com.nextcloud.android.sso.AccountImporter; +import com.nextcloud.android.sso.api.ParsedResponse; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; import com.nextcloud.android.sso.exceptions.TokenMismatchException; import com.nextcloud.android.sso.model.SingleSignOnAccount; import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.persistence.sync.NotesAPI; import it.niedermann.owncloud.notes.shared.model.DBStatus; import it.niedermann.owncloud.notes.shared.model.ISyncCallback; -import it.niedermann.owncloud.notes.shared.model.ServerResponse; import it.niedermann.owncloud.notes.shared.model.SyncResultStatus; +import retrofit2.Response; import static it.niedermann.owncloud.notes.shared.model.DBStatus.LOCAL_DELETED; import static it.niedermann.owncloud.notes.shared.util.NoteUtil.generateNoteExcerpt; import static java.net.HttpURLConnection.HTTP_NOT_FOUND; import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED; +import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; /** @@ -36,10 +45,15 @@ abstract class NotesServerSyncTask extends Thread { private static final String TAG = NotesServerSyncTask.class.getSimpleName(); + private static final String HEADER_KEY_X_NOTES_API_VERSIONS = "X-Notes-API-Versions"; + private static final String HEADER_KEY_ETAG = "ETag"; + private static final String HEADER_KEY_LAST_MODIFIED = "Last-Modified"; + + private NotesAPI notesAPI; @NonNull - private final NotesClient notesClient; + private final Context context; @NonNull - private final NotesDatabase db; + private final NotesRepository repo; @NonNull protected final Account localAccount; @NonNull @@ -50,12 +64,12 @@ abstract class NotesServerSyncTask extends Thread { @NonNull protected final ArrayList<Throwable> exceptions = new ArrayList<>(); - NotesServerSyncTask(@NonNull NotesClient notesClient, @NonNull NotesDatabase db, @NonNull Account localAccount, @NonNull SingleSignOnAccount ssoAccount, boolean onlyLocalChanges) { + NotesServerSyncTask(@NonNull Context context, @NonNull NotesRepository repo, @NonNull Account localAccount, boolean onlyLocalChanges) throws NextcloudFilesAppAccountNotFoundException { super(TAG); - this.notesClient = notesClient; - this.db = db; + this.context = context; + this.repo = repo; this.localAccount = localAccount; - this.ssoAccount = ssoAccount; + this.ssoAccount = AccountImporter.getSingleSignOnAccount(context, localAccount.getAccountName()); this.onlyLocalChanges = onlyLocalChanges; } @@ -67,12 +81,16 @@ abstract class NotesServerSyncTask extends Thread { public void run() { onPreExecute(); + notesAPI = ApiProvider.getNotesAPI(context, ssoAccount, localAccount.getPreferredApiVersion()); + Log.i(TAG, "STARTING SYNCHRONIZATION"); + final SyncResultStatus status = new SyncResultStatus(); status.pushSuccessful = pushLocalChanges(); if (!onlyLocalChanges) { status.pullSuccessful = pullRemoteChanges(); } + Log.i(TAG, "SYNCHRONIZATION FINISHED"); onPostExecute(status); @@ -89,7 +107,7 @@ abstract class NotesServerSyncTask extends Thread { Log.d(TAG, "pushLocalChanges()"); boolean success = true; - final List<Note> notes = db.getNoteDao().getLocalModifiedNotes(localAccount.getId()); + final List<Note> notes = repo.getLocalModifiedNotes(localAccount.getId()); for (Note note : notes) { Log.d(TAG, " Process Local Note: " + note); try { @@ -99,41 +117,51 @@ abstract class NotesServerSyncTask extends Thread { Log.v(TAG, " ...create/edit"); if (note.getRemoteId() != null) { Log.v(TAG, " ...Note has remoteId → try to edit"); - try { - remoteNote = notesClient.editNote(ssoAccount, note).getNote(); - } catch (NextcloudHttpRequestFailedException e) { - if (e.getStatusCode() == HTTP_NOT_FOUND) { + final Response<Note> editResponse = notesAPI.editNote(note).execute(); + if (editResponse.isSuccessful()) { + remoteNote = editResponse.body(); + } else { + if (editResponse.code() == HTTP_NOT_FOUND) { Log.v(TAG, " ...Note does no longer exist on server → recreate"); - remoteNote = notesClient.createNote(ssoAccount, note).getNote(); + final Response<Note> createResponse = notesAPI.createNote(note).execute(); + if (createResponse.isSuccessful()) { + remoteNote = createResponse.body(); + } else { + throw new Exception(createResponse.errorBody().string()); + } } else { - throw e; + throw new Exception(editResponse.errorBody().string()); } } } else { Log.v(TAG, " ...Note does not have a remoteId yet → create"); - remoteNote = notesClient.createNote(ssoAccount, note).getNote(); - db.getNoteDao().updateRemoteId(note.getId(), remoteNote.getRemoteId()); + final Response<Note> createResponse = notesAPI.createNote(note).execute(); + if (createResponse.isSuccessful()) { + remoteNote = createResponse.body(); + repo.updateRemoteId(note.getId(), remoteNote.getRemoteId()); + } else { + throw new Exception(createResponse.errorBody().string()); + } } // Please note, that db.updateNote() realized an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. - db.getNoteDao().updateIfNotModifiedLocallyDuringSync(note.getId(), remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle()), note.getContent(), note.getCategory(), note.getFavorite()); + repo.updateIfNotModifiedLocallyDuringSync(note.getId(), remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle()), note.getContent(), note.getCategory(), note.getFavorite()); break; case LOCAL_DELETED: if (note.getRemoteId() == null) { Log.v(TAG, " ...delete (only local, since it has never been synchronized)"); } else { Log.v(TAG, " ...delete (from server and local)"); - try { - notesClient.deleteNote(ssoAccount, note.getRemoteId()); - } catch (NextcloudHttpRequestFailedException e) { - if (e.getStatusCode() == HTTP_NOT_FOUND) { + final Response<Void> deleteResponse = notesAPI.deleteNote(note.getRemoteId()).execute(); + if (!deleteResponse.isSuccessful()) { + if (deleteResponse.code() == HTTP_NOT_FOUND) { Log.v(TAG, " ...delete (note has already been deleted remotely)"); } else { - throw e; + throw new Exception(deleteResponse.errorBody().string()); } } } // Please note, that db.deleteNote() realizes an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. - db.getNoteDao().deleteByNoteId(note.getId(), LOCAL_DELETED); + repo.deleteByNoteId(note.getId(), LOCAL_DELETED); break; default: throw new IllegalStateException("Unknown State of Note " + note + ": " + note.getStatus()); @@ -147,7 +175,7 @@ abstract class NotesServerSyncTask extends Thread { } } catch (Exception e) { if (e instanceof TokenMismatchException) { - SSOClient.invalidateAPICache(ssoAccount); + ApiProvider.invalidateAPICache(ssoAccount); } exceptions.add(e); success = false; @@ -162,15 +190,19 @@ abstract class NotesServerSyncTask extends Thread { private boolean pullRemoteChanges() { Log.d(TAG, "pullRemoteChanges() for account " + localAccount.getAccountName()); try { - final Map<Long, Long> idMap = db.getIdMap(localAccount.getId()); + final Map<Long, Long> idMap = repo.getIdMap(localAccount.getId()); // FIXME re-reading the localAccount is only a workaround for a not-up-to-date eTag in localAccount. - final Account accountFromDatabase = db.getAccountDao().getAccountById(localAccount.getId()); + final Account accountFromDatabase = repo.getAccountById(localAccount.getId()); + if (accountFromDatabase == null) { + callbacks.remove(localAccount.getId()); + return true; + } localAccount.setModified(accountFromDatabase.getModified()); localAccount.setETag(accountFromDatabase.getETag()); - final ServerResponse.NotesResponse response = notesClient.getNotes(ssoAccount, localAccount.getModified(), localAccount.getETag()); - final List<Note> remoteNotes = response.getNotes(); + final ParsedResponse<List<Note>> fetchResponse = notesAPI.getNotes(localAccount.getModified(), localAccount.getETag()).blockingSingle(); + final List<Note> remoteNotes = fetchResponse.getResponse(); final Set<Long> remoteIDs = new HashSet<>(); // pull remote changes: update or create each remote note for (Note remoteNote : remoteNotes) { @@ -182,14 +214,14 @@ abstract class NotesServerSyncTask extends Thread { Log.v(TAG, " ... found → Update"); Long localId = idMap.get(remoteNote.getRemoteId()); if (localId != null) { - db.getNoteDao().updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged( + repo.updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged( localId, remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getCategory(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle())); } else { Log.e(TAG, "Tried to update note from server, but local id of note is null. " + remoteNote); } } else { Log.v(TAG, " ... create"); - db.addNote(localAccount.getId(), remoteNote); + repo.addNote(localAccount.getId(), remoteNote); } } Log.d(TAG, " Remove remotely deleted Notes (only those without local changes)"); @@ -197,36 +229,54 @@ abstract class NotesServerSyncTask extends Thread { for (Map.Entry<Long, Long> entry : idMap.entrySet()) { if (!remoteIDs.contains(entry.getKey())) { Log.v(TAG, " ... remove " + entry.getValue()); - db.getNoteDao().deleteByNoteId(entry.getValue(), DBStatus.VOID); + repo.deleteByNoteId(entry.getValue(), DBStatus.VOID); } } // update ETag and Last-Modified in order to reduce size of next response - localAccount.setETag(response.getETag()); - localAccount.setModified(response.getLastModified()); - db.getAccountDao().updateETag(localAccount.getId(), localAccount.getETag()); - db.getAccountDao().updateModified(localAccount.getId(), localAccount.getModified().getTimeInMillis()); + localAccount.setETag(fetchResponse.getHeaders().get(HEADER_KEY_ETAG)); + + final Calendar lastModified = Calendar.getInstance(); + lastModified.setTimeInMillis(0); + final String lastModifiedHeader = fetchResponse.getHeaders().get(HEADER_KEY_LAST_MODIFIED); + if (lastModifiedHeader != null) + lastModified.setTimeInMillis(Date.parse(lastModifiedHeader)); + Log.d(TAG, "ETag: " + fetchResponse.getHeaders().get(HEADER_KEY_ETAG) + "; Last-Modified: " + lastModified + " (" + lastModified + ")"); + + localAccount.setModified(lastModified); + + repo.updateETag(localAccount.getId(), localAccount.getETag()); + repo.updateModified(localAccount.getId(), localAccount.getModified().getTimeInMillis()); + + + String supportedApiVersions = null; + final String supportedApiVersionsHeader = fetchResponse.getHeaders().get(HEADER_KEY_X_NOTES_API_VERSIONS); + if (supportedApiVersionsHeader != null) { + supportedApiVersions = "[" + Objects.requireNonNull(supportedApiVersionsHeader) + "]"; + } try { - if (db.updateApiVersion(localAccount.getId(), response.getSupportedApiVersions())) { - localAccount.setApiVersion(response.getSupportedApiVersions()); + if (repo.updateApiVersion(localAccount.getId(), supportedApiVersions)) { + localAccount.setApiVersion(supportedApiVersions); } } catch (Exception e) { exceptions.add(e); } return true; - } catch (NextcloudHttpRequestFailedException e) { - Log.d(TAG, "Server returned HTTP Status Code " + e.getStatusCode() + " - " + e.getMessage()); - if (e.getStatusCode() == HTTP_NOT_MODIFIED) { - return true; - } else { - exceptions.add(e); - return false; - } - } catch (Exception e) { - if (e instanceof TokenMismatchException) { - SSOClient.invalidateAPICache(ssoAccount); + } catch (Throwable t) { + final Throwable cause = t.getCause(); + if (t.getClass() == RuntimeException.class && cause != null) { + if (cause.getClass() == NextcloudHttpRequestFailedException.class || cause instanceof NextcloudHttpRequestFailedException) { + final NextcloudHttpRequestFailedException httpException = (NextcloudHttpRequestFailedException) cause; + if (httpException.getStatusCode() == HTTP_NOT_MODIFIED) { + Log.d(TAG, "Server returned HTTP Status Code " + httpException.getStatusCode() + " - Notes not modified."); + return true; + } else if (httpException.getStatusCode() == HTTP_UNAVAILABLE) { + Log.d(TAG, "Server returned HTTP Status Code " + httpException.getStatusCode() + " - Server is in maintenance mode."); + return true; + } + } } - exceptions.add(e); + exceptions.add(t); return false; } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/SSOClient.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/SSOClient.java deleted file mode 100644 index d3976617..00000000 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/SSOClient.java +++ /dev/null @@ -1,82 +0,0 @@ -package it.niedermann.owncloud.notes.persistence; - -import android.content.Context; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.WorkerThread; - -import com.google.gson.GsonBuilder; -import com.nextcloud.android.sso.aidl.NextcloudRequest; -import com.nextcloud.android.sso.api.NextcloudAPI; -import com.nextcloud.android.sso.api.Response; -import com.nextcloud.android.sso.model.SingleSignOnAccount; - -import java.util.HashMap; -import java.util.Map; - -@SuppressWarnings("WeakerAccess") -@WorkerThread -public class SSOClient { - - private static final String TAG = SSOClient.class.getSimpleName(); - - private static final Map<String, NextcloudAPI> mNextcloudAPIs = new HashMap<>(); - - public static Response requestFilesApp(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount, @NonNull NextcloudRequest nextcloudRequest) throws Exception { - return getNextcloudAPI(context.getApplicationContext(), ssoAccount).performNetworkRequestV2(nextcloudRequest); - } - - private static NextcloudAPI getNextcloudAPI(Context appContext, SingleSignOnAccount ssoAccount) { - if (mNextcloudAPIs.containsKey(ssoAccount.name)) { - return mNextcloudAPIs.get(ssoAccount.name); - } else { - Log.v(TAG, "NextcloudRequest account: " + ssoAccount.name); - final NextcloudAPI nextcloudAPI = new NextcloudAPI(appContext, ssoAccount, new GsonBuilder().create(), new NextcloudAPI.ApiConnectedListener() { - @Override - public void onConnected() { - Log.i(TAG, "SSO API connected for " + ssoAccount); - } - - @Override - public void onError(Exception ex) { - ex.printStackTrace(); - } - }); - mNextcloudAPIs.put(ssoAccount.name, nextcloudAPI); - return nextcloudAPI; - } - } - - /** - * Invalidates thes API cache for the given ssoAccount - * - * @param ssoAccount the ssoAccount for which the API cache should be cleared. - */ - public static void invalidateAPICache(@NonNull SingleSignOnAccount ssoAccount) { - Log.v(TAG, "Invalidating API cache for " + ssoAccount.name); - if (mNextcloudAPIs.containsKey(ssoAccount.name)) { - final NextcloudAPI nextcloudAPI = mNextcloudAPIs.get(ssoAccount.name); - if (nextcloudAPI != null) { - nextcloudAPI.stop(); - } - mNextcloudAPIs.remove(ssoAccount.name); - } - } - - /** - * Invalidates the whole API cache for all accounts - */ - public static void invalidateAPICache() { - for (String key : mNextcloudAPIs.keySet()) { - Log.v(TAG, "Invalidating API cache for " + key); - if (mNextcloudAPIs.containsKey(key)) { - final NextcloudAPI nextcloudAPI = mNextcloudAPIs.get(key); - if (nextcloudAPI != null) { - nextcloudAPI.stop(); - } - mNextcloudAPIs.remove(key); - } - } - } -} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncWorker.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncWorker.java index 1b86e0aa..1d4a8bc7 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncWorker.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/SyncWorker.java @@ -34,11 +34,11 @@ public class SyncWorker extends Worker { @NonNull @Override public Result doWork() { - NotesDatabase db = NotesDatabase.getInstance(getApplicationContext()); - for (Account account : db.getAccountDao().getAccounts()) { + NotesRepository repo = NotesRepository.getInstance(getApplicationContext()); + for (Account account : repo.getAccounts()) { Log.v(TAG, "Starting background synchronization for " + account.getAccountName()); - db.getNoteServerSyncHelper().addCallbackPull(account, () -> Log.v(TAG, "Finished background synchronization for " + account.getAccountName())); - db.getNoteServerSyncHelper().scheduleSync(account, false); + repo.addCallbackPull(account, () -> Log.v(TAG, "Finished background synchronization for " + account.getAccountName())); + repo.scheduleSync(account, false); } // TODO return result depending on callbackPull return Result.success(); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/AccountDao.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/AccountDao.java index b0828717..085c0a16 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/AccountDao.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/AccountDao.java @@ -53,6 +53,6 @@ public interface AccountDao { @Query("UPDATE Account SET MODIFIED = :modified WHERE id = :id") void updateModified(long id, long modified); - @Query("UPDATE Account SET APIVERSION = :apiVersion WHERE id = :id") + @Query("UPDATE Account SET APIVERSION = :apiVersion WHERE id = :id AND ((APIVERSION IS NULL AND :apiVersion IS NOT NULL) OR (APIVERSION IS NOT NULL AND :apiVersion IS NULL) OR APIVERSION <> :apiVersion)") int updateApiVersion(Long id, String apiVersion); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java index b0e93d2c..618dff37 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java @@ -10,7 +10,6 @@ import androidx.room.Update; import java.util.List; import java.util.Set; -import it.niedermann.owncloud.notes.persistence.NotesServerSyncHelper; import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; import it.niedermann.owncloud.notes.persistence.entity.Note; @@ -139,7 +138,11 @@ public interface NoteDao { @Query("SELECT DISTINCT remoteId FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED'") List<Long> getRemoteIds(long accountId); - @Query("SELECT id, remoteId, 0 as accountId, '' as title, 0 as favorite, '' as excerpt, 0 as modified, '' as eTag, 0 as status, '' as category, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED'") + /** + * Gets a list of {@link Note} objects with filled {@link Note#id} and {@link Note#remoteId}, + * where {@link Note#remoteId} is not <code>null</code> + */ + @Query("SELECT id, remoteId, 0 as accountId, '' as title, 0 as favorite, '' as excerpt, 0 as modified, '' as eTag, 0 as status, '' as category, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND remoteId IS NOT NULL") List<Note> getRemoteIdAndId(long accountId); /** @@ -169,7 +172,7 @@ public interface NoteDao { void updateRemoteId(long id, Long remoteId); /** - * used by: {@link NotesServerSyncHelper.SyncTask#pushLocalChanges()} update only, if not modified locally during the synchronization + * used by: {@link it.niedermann.owncloud.notes.persistence.NotesServerSyncTask#pushLocalChanges()} update only, if not modified locally during the synchronization * (i.e. all (!) user changeable columns (content, favorite, category) must still have the same value), uses reference value gathered at start of synchronization */ @Query("UPDATE NOTE SET title = :targetTitle, modified = :targetModified, favorite = :targetFavorite, etag = :targetETag, content = :targetContent, status = '', excerpt = :targetExcerpt " + @@ -177,7 +180,7 @@ public interface NoteDao { int updateIfNotModifiedLocallyDuringSync(long noteId, Long targetModified, String targetTitle, boolean targetFavorite, String targetETag, String targetContent, String targetExcerpt, String contentBeforeSyncStart, String categoryBeforeSyncStart, boolean favoriteBeforeSyncStart); /** - * used by: {@link NotesServerSyncHelper.SyncTask#pullRemoteChanges()} update only, if not modified locally (i.e. STATUS="") and if modified remotely (i.e. any (!) column has changed) + * used by: {@link it.niedermann.owncloud.notes.persistence.NotesServerSyncTask#pullRemoteChanges()} update only, if not modified locally (i.e. STATUS="") and if modified remotely (i.e. any (!) column has changed) */ @Query("UPDATE NOTE SET title = :title, modified = :modified, favorite = :favorite, etag = :eTag, content = :content, status = '', excerpt = :excerpt " + "WHERE id = :id AND status = '' AND (title != :title OR modified != :modified OR favorite != :favorite OR category != :category OR (eTag IS NULL OR eTag != :eTag) OR content != :content)") @@ -194,4 +197,7 @@ public interface NoteDao { @Query("SELECT accountId, category, COUNT(*) as 'totalNotes' FROM NOTE WHERE STATUS != 'LOCAL_DELETED' AND accountId = :accountId AND category != '' AND category LIKE :searchTerm GROUP BY category") LiveData<List<CategoryWithNotesCount>> searchCategories$(Long accountId, String searchTerm); + + @Query("SELECT COUNT(*) FROM NOTE WHERE STATUS != '' AND accountId = :accountId") + Long countUnsynchronizedNotes(long accountId); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java index ed6dcd6c..09f3fc26 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java @@ -7,7 +7,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.room.ColumnInfo; import androidx.room.Entity; -import androidx.room.Ignore; import androidx.room.Index; import androidx.room.PrimaryKey; @@ -21,7 +20,6 @@ import java.util.Collections; import java.util.HashSet; import java.util.NoSuchElementException; -import it.niedermann.owncloud.notes.persistence.NotesClient; import it.niedermann.owncloud.notes.shared.model.ApiVersion; import it.niedermann.owncloud.notes.shared.model.Capabilities; @@ -83,8 +81,8 @@ public class Account implements Serializable { final Collection<ApiVersion> supportedApiVersions = new HashSet<>(versionsArray.length()); for (int i = 0; i < versionsArray.length(); i++) { final ApiVersion parsedApiVersion = ApiVersion.of(versionsArray.getString(i)); - for (ApiVersion temp : NotesClient.SUPPORTED_API_VERSIONS) { - if (temp.compareTo(parsedApiVersion) == 0) { + for (ApiVersion temp : ApiVersion.SUPPORTED_API_VERSIONS) { + if (temp.equals(parsedApiVersion)) { supportedApiVersions.add(parsedApiVersion); break; } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java index 7224d4eb..376c099d 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java @@ -9,6 +9,9 @@ import androidx.room.Ignore; import androidx.room.Index; import androidx.room.PrimaryKey; +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + import java.io.Serializable; import java.util.Calendar; @@ -34,31 +37,52 @@ import it.niedermann.owncloud.notes.shared.model.Item; } ) public class Note implements Serializable, Item { + @SerializedName("localId") @PrimaryKey(autoGenerate = true) private long id; + @Nullable + @Expose + @SerializedName("id") private Long remoteId; + private long accountId; + @NonNull private DBStatus status = DBStatus.VOID; + @NonNull @ColumnInfo(defaultValue = "") + @Expose private String title = ""; + @NonNull + @Expose @ColumnInfo(defaultValue = "") private String category = ""; + + @Expose @Nullable private Calendar modified; + @NonNull @ColumnInfo(defaultValue = "") + @Expose private String content = ""; + + @Expose @ColumnInfo(defaultValue = "0") private boolean favorite = false; + + @Expose @Nullable + @SerializedName("etag") private String eTag; + @NonNull @ColumnInfo(defaultValue = "") private String excerpt = ""; + @ColumnInfo(defaultValue = "0") private int scrollY = 0; diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_13_14.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_13_14.java index 85e02617..3d0147fb 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_13_14.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_13_14.java @@ -1,7 +1,9 @@ package it.niedermann.owncloud.notes.persistence.migration; +import android.appwidget.AppWidgetManager; import android.content.ContentValues; import android.content.Context; +import android.content.Intent; import android.content.SharedPreferences; import android.util.Log; @@ -14,19 +16,18 @@ import androidx.sqlite.db.SupportSQLiteDatabase; import java.util.Map; import it.niedermann.owncloud.notes.preferences.DarkModeSetting; +import it.niedermann.owncloud.notes.widget.notelist.NoteListWidget; +import it.niedermann.owncloud.notes.widget.singlenote.SingleNoteWidget; public class Migration_13_14 extends Migration { private static final String TAG = Migration_13_14.class.getSimpleName(); @NonNull private final Context context; - @NonNull - private final Runnable notifyWidgets; - public Migration_13_14(@NonNull Context context, @NonNull Runnable notifyWidgets) { + public Migration_13_14(@NonNull Context context) { super(13, 14); this.context = context; - this.notifyWidgets = notifyWidgets; } /** @@ -86,6 +87,7 @@ public class Migration_13_14 extends Migration { } } editor.apply(); - notifyWidgets.run(); + context.sendBroadcast(new Intent(context, SingleNoteWidget.class).setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE)); + context.sendBroadcast(new Intent(context, NoteListWidget.class).setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE)); } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_15_16.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_15_16.java index 2732151f..48b7195b 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_15_16.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_15_16.java @@ -1,7 +1,9 @@ package it.niedermann.owncloud.notes.persistence.migration; +import android.appwidget.AppWidgetManager; import android.content.ContentValues; import android.content.Context; +import android.content.Intent; import android.content.SharedPreferences; import android.database.Cursor; import android.util.Log; @@ -15,19 +17,18 @@ import androidx.sqlite.db.SupportSQLiteDatabase; import java.util.Map; import it.niedermann.owncloud.notes.preferences.DarkModeSetting; +import it.niedermann.owncloud.notes.widget.notelist.NoteListWidget; +import it.niedermann.owncloud.notes.widget.singlenote.SingleNoteWidget; public class Migration_15_16 extends Migration { private static final String TAG = Migration_15_16.class.getSimpleName(); @NonNull private final Context context; - @NonNull - private final Runnable notifyWidgets; - public Migration_15_16(@NonNull Context context, @NonNull Runnable notifyWidgets) { + public Migration_15_16(@NonNull Context context) { super(15, 16); this.context = context; - this.notifyWidgets = notifyWidgets; } /** @@ -104,6 +105,7 @@ public class Migration_15_16 extends Migration { } } editor.apply(); - notifyWidgets.run(); + context.sendBroadcast(new Intent(context, SingleNoteWidget.class).setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE)); + context.sendBroadcast(new Intent(context, NoteListWidget.class).setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE)); } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java new file mode 100644 index 00000000..c9bf78da --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java @@ -0,0 +1,81 @@ +package it.niedermann.owncloud.notes.persistence.sync; + +import android.graphics.Color; +import android.util.Log; + +import com.bumptech.glide.load.HttpException; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; + +import java.lang.reflect.Type; + +import it.niedermann.android.util.ColorUtil; +import it.niedermann.owncloud.notes.shared.model.Capabilities; + +import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; + +public class CapabilitiesDeserializer implements JsonDeserializer<Capabilities> { + + private static final String TAG = CapabilitiesDeserializer.class.getSimpleName(); + + private static final String JSON_OCS = "ocs"; + private static final String JSON_OCS_META = "meta"; + private static final String JSON_OCS_META_STATUSCODE = "statuscode"; + private static final String JSON_OCS_DATA = "data"; + private static final String JSON_OCS_DATA_CAPABILITIES = "capabilities"; + private static final String JSON_OCS_DATA_CAPABILITIES_NOTES = "notes"; + private static final String JSON_OCS_DATA_CAPABILITIES_NOTES_API_VERSION = "api_version"; + private static final String JSON_OCS_DATA_CAPABILITIES_THEMING = "theming"; + private static final String JSON_OCS_DATA_CAPABILITIES_THEMING_COLOR = "color"; + private static final String JSON_OCS_DATA_CAPABILITIES_THEMING_COLOR_TEXT = "color-text"; + + @Override + public Capabilities deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + final Capabilities response = new Capabilities(); + final JsonObject ocs = json.getAsJsonObject().getAsJsonObject(JSON_OCS); + if (ocs.has(JSON_OCS_META)) { + final JsonObject meta = ocs.getAsJsonObject(JSON_OCS_META); + if (meta.has(JSON_OCS_META_STATUSCODE)) { + if (meta.get(JSON_OCS_META_STATUSCODE).getAsInt() == HTTP_UNAVAILABLE) { + Log.i(TAG, "Capabilities Endpoint: This instance is currently in maintenance mode."); + throw new JsonParseException(new NextcloudHttpRequestFailedException(HTTP_UNAVAILABLE, new HttpException(HTTP_UNAVAILABLE))); + } + } + } + if (ocs.has(JSON_OCS_DATA)) { + final JsonObject data = ocs.getAsJsonObject(JSON_OCS_DATA); + if (data.has(JSON_OCS_DATA_CAPABILITIES)) { + final JsonObject capabilities = data.getAsJsonObject(JSON_OCS_DATA_CAPABILITIES); + if (capabilities.has(JSON_OCS_DATA_CAPABILITIES_NOTES)) { + final JsonObject notes = capabilities.getAsJsonObject(JSON_OCS_DATA_CAPABILITIES_NOTES); + if (notes.has(JSON_OCS_DATA_CAPABILITIES_NOTES_API_VERSION)) { + final JsonElement apiVersion = notes.get(JSON_OCS_DATA_CAPABILITIES_NOTES_API_VERSION); + response.setApiVersion(apiVersion.isJsonArray() ? apiVersion.toString() : null); + } + } + if (capabilities.has(JSON_OCS_DATA_CAPABILITIES_THEMING)) { + final JsonObject theming = capabilities.getAsJsonObject(JSON_OCS_DATA_CAPABILITIES_THEMING); + if (theming.has(JSON_OCS_DATA_CAPABILITIES_THEMING_COLOR)) { + try { + response.setColor(Color.parseColor(ColorUtil.INSTANCE.formatColorToParsableHexString(theming.get(JSON_OCS_DATA_CAPABILITIES_THEMING_COLOR).getAsString()))); + } catch (Exception e) { + e.printStackTrace(); + } + } + if (theming.has(JSON_OCS_DATA_CAPABILITIES_THEMING_COLOR_TEXT)) { + try { + response.setTextColor(Color.parseColor(ColorUtil.INSTANCE.formatColorToParsableHexString(theming.get(JSON_OCS_DATA_CAPABILITIES_THEMING_COLOR_TEXT).getAsString()))); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + } + } + return response; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI.java new file mode 100644 index 00000000..4ab8371e --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI.java @@ -0,0 +1,142 @@ +package it.niedermann.owncloud.notes.persistence.sync; + + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.gson.annotations.Expose; +import com.nextcloud.android.sso.api.NextcloudAPI; +import com.nextcloud.android.sso.api.ParsedResponse; + +import java.util.Calendar; +import java.util.List; + +import io.reactivex.Observable; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.ApiVersion; +import it.niedermann.owncloud.notes.shared.model.NotesSettings; +import retrofit2.Call; +import retrofit2.NextcloudRetrofitApiBuilder; + +/** + * Compatibility layer to support multiple API versions + */ +public class NotesAPI { + + private static final String TAG = NotesAPI.class.getSimpleName(); + + private static final String API_ENDPOINT_NOTES_1_0 = "/index.php/apps/notes/api/v1/"; + private static final String API_ENDPOINT_NOTES_0_2 = "/index.php/apps/notes/api/v0.2/"; + + @NonNull + private final ApiVersion usedApiVersion; + private final NotesAPI_0_2 notesAPI_0_2; + private final NotesAPI_1_0 notesAPI_1_0; + + public NotesAPI(@NonNull NextcloudAPI nextcloudAPI, @Nullable ApiVersion preferredApiVersion) { + if (preferredApiVersion == null) { + Log.i(TAG, "Using " + ApiVersion.API_VERSION_0_2 + ", preferredApiVersion is null"); + usedApiVersion = ApiVersion.API_VERSION_0_2; + notesAPI_0_2 = new NextcloudRetrofitApiBuilder(nextcloudAPI, API_ENDPOINT_NOTES_0_2).create(NotesAPI_0_2.class); + notesAPI_1_0 = null; + } else if (ApiVersion.API_VERSION_1_0.equals(preferredApiVersion)) { + Log.i(TAG, "Using " + ApiVersion.API_VERSION_1_0); + usedApiVersion = ApiVersion.API_VERSION_1_0; + notesAPI_0_2 = null; + notesAPI_1_0 = new NextcloudRetrofitApiBuilder(nextcloudAPI, API_ENDPOINT_NOTES_1_0).create(NotesAPI_1_0.class); + } else if (ApiVersion.API_VERSION_0_2.equals(preferredApiVersion)) { + Log.i(TAG, "Using " + ApiVersion.API_VERSION_0_2); + usedApiVersion = ApiVersion.API_VERSION_0_2; + notesAPI_0_2 = new NextcloudRetrofitApiBuilder(nextcloudAPI, API_ENDPOINT_NOTES_0_2).create(NotesAPI_0_2.class); + notesAPI_1_0 = null; + } else { + Log.w(TAG, "Unsupported API version " + preferredApiVersion + " - try using " + ApiVersion.API_VERSION_0_2); + usedApiVersion = ApiVersion.API_VERSION_0_2; + notesAPI_0_2 = new NextcloudRetrofitApiBuilder(nextcloudAPI, API_ENDPOINT_NOTES_0_2).create(NotesAPI_0_2.class); + notesAPI_1_0 = null; + } + } + + public Observable<ParsedResponse<List<Note>>> getNotes(@NonNull Calendar lastModified, String lastETag) { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.getNotes(lastModified.getTimeInMillis() / 1_000, lastETag); + } else if (ApiVersion.API_VERSION_0_2.equals(usedApiVersion)) { + return notesAPI_0_2.getNotes(lastModified.getTimeInMillis() / 1_000, lastETag); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support getNotes()."); + } + } + + public Call<Note> createNote(Note note) { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.createNote(note); + } else if (ApiVersion.API_VERSION_0_2.equals(usedApiVersion)) { + return notesAPI_0_2.createNote(new Note_0_2(note)); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support createNote()."); + } + } + + public Call<Note> editNote(@NonNull Note note) { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.editNote(note, note.getRemoteId()); + } else if (ApiVersion.API_VERSION_0_2.equals(usedApiVersion)) { + return notesAPI_0_2.editNote(new Note_0_2(note), note.getRemoteId()); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support editNote()."); + } + } + + public Call<Void> deleteNote(long noteId) { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.deleteNote(noteId); + } else if (ApiVersion.API_VERSION_0_2.equals(usedApiVersion)) { + return notesAPI_0_2.deleteNote(noteId); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support createNote()."); + } + } + + + public Call<NotesSettings> getSettings() { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.getSettings(); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support getSettings()."); + } + } + + public Call<NotesSettings> putSettings(NotesSettings settings) { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.putSettings(settings); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support putSettings()."); + } + } + + /** + * {@link ApiVersion#API_VERSION_0_2} didn't have a separate <code>title</code> property. + */ + static class Note_0_2 { + @Expose + public final String category; + @Expose + public final Calendar modified; + @Expose + public final String content; + @Expose + public final boolean favorite; + + private Note_0_2(Note note) { + if (note == null) { + throw new IllegalArgumentException(Note.class.getSimpleName() + " can not be converted to " + Note_0_2.class.getSimpleName() + " because it is null."); + } + this.category = note.getCategory(); + this.modified = note.getModified(); + this.content = note.getContent(); + this.favorite = note.getFavorite(); + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_0_2.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_0_2.java new file mode 100644 index 00000000..fd642064 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_0_2.java @@ -0,0 +1,36 @@ +package it.niedermann.owncloud.notes.persistence.sync; + + +import com.nextcloud.android.sso.api.ParsedResponse; + +import java.util.List; + +import io.reactivex.Observable; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import retrofit2.Call; +import retrofit2.http.Body; +import retrofit2.http.DELETE; +import retrofit2.http.GET; +import retrofit2.http.Header; +import retrofit2.http.POST; +import retrofit2.http.PUT; +import retrofit2.http.Path; +import retrofit2.http.Query; + +/** + * @link <a href="https://github.com/nextcloud/notes/wiki/API-0.2">Notes API v0.2</a> + */ +public interface NotesAPI_0_2 { + + @GET("notes") + Observable<ParsedResponse<List<Note>>> getNotes(@Query("pruneBefore") long lastModified, @Header("If-None-Match") String lastETag); + + @POST("notes") + Call<Note> createNote(@Body NotesAPI.Note_0_2 note); + + @PUT("notes/{remoteId}") + Call<Note> editNote(@Body NotesAPI.Note_0_2 note, @Path("remoteId") long remoteId); + + @DELETE("notes/{remoteId}") + Call<Void> deleteNote(@Path("remoteId") long noteId); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_1_0.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_1_0.java new file mode 100644 index 00000000..dfc176c3 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_1_0.java @@ -0,0 +1,43 @@ +package it.niedermann.owncloud.notes.persistence.sync; + + +import com.nextcloud.android.sso.api.ParsedResponse; + +import java.util.List; + +import io.reactivex.Observable; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.shared.model.NotesSettings; +import retrofit2.Call; +import retrofit2.http.Body; +import retrofit2.http.DELETE; +import retrofit2.http.GET; +import retrofit2.http.Header; +import retrofit2.http.POST; +import retrofit2.http.PUT; +import retrofit2.http.Path; +import retrofit2.http.Query; + +/** + * @link <a href="https://github.com/nextcloud/notes/blob/master/docs/api/README.md">Notes API v1</a> + */ +public interface NotesAPI_1_0 { + + @GET("notes") + Observable<ParsedResponse<List<Note>>> getNotes(@Query("pruneBefore") long lastModified, @Header("If-None-Match") String lastETag); + + @POST("notes") + Call<Note> createNote(@Body Note note); + + @PUT("notes/{remoteId}") + Call<Note> editNote(@Body Note note, @Path("remoteId") long remoteId); + + @DELETE("notes/{remoteId}") + Call<Void> deleteNote(@Path("remoteId") long noteId); + + @GET("settings") + Call<NotesSettings> getSettings(); + + @PUT("settings") + Call<NotesSettings> putSettings(@Body NotesSettings settings); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/OcsAPI.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/OcsAPI.java new file mode 100644 index 00000000..27ef57c4 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/OcsAPI.java @@ -0,0 +1,18 @@ +package it.niedermann.owncloud.notes.persistence.sync; + + +import com.nextcloud.android.sso.api.ParsedResponse; + +import io.reactivex.Observable; +import it.niedermann.owncloud.notes.shared.model.Capabilities; +import retrofit2.http.GET; +import retrofit2.http.Header; + +/** + * @link <a href="https://deck.readthedocs.io/en/latest/API/">Deck REST API</a> + */ +public interface OcsAPI { + + @GET("capabilities?format=json") + Observable<ParsedResponse<Capabilities>> getCapabilities(@Header("If-None-Match") String eTag); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ApiVersion.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ApiVersion.java index b4c62f5b..ee2fdc3a 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ApiVersion.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ApiVersion.java @@ -3,6 +3,7 @@ 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; @@ -11,6 +12,14 @@ 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[] SUPPORTED_API_VERSIONS = new ApiVersion[]{ + API_VERSION_1_0, + API_VERSION_0_2 + }; + private String originalVersion = "?"; private final int major; private final int minor; @@ -66,7 +75,7 @@ public class ApiVersion implements Comparable<ApiVersion> { * 1 if the compared major version is <strong>lower</strong> than the current major version */ @Override - public int compareTo(ApiVersion compare) { + public int compareTo(@NonNull ApiVersion compare) { if (compare.getMajor() > getMajor()) { return -1; } else if (compare.getMajor() < getMajor()) { @@ -77,7 +86,21 @@ public class ApiVersion implements Comparable<ApiVersion> { public boolean supportsSettings() { // TODO - return true;//getMajor() >= VERSION_1_2.getMajor() && getMinor() >= VERSION_1_2.getMinor(); + return true; +// return getMajor() >= 1 && getMinor() >= 2; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ApiVersion that = (ApiVersion) o; + return compareTo(that) == 0; + } + + @Override + public int hashCode() { + return Objects.hash(major, minor); } @NonNull diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java index 1c1bed3d..5514a91b 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java @@ -6,6 +6,7 @@ import android.util.Log; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.bumptech.glide.load.HttpException; import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; @@ -38,12 +39,17 @@ public class Capabilities { private String apiVersion = null; @ColorInt - private Integer color = -16743735; + private int color = -16743735; @ColorInt - private Integer textColor = -16777216; + private int textColor = -16777216; @Nullable - private final String eTag; + private String eTag; + public Capabilities() { + + } + + @VisibleForTesting public Capabilities(@NonNull String response, @Nullable String eTag) throws NextcloudHttpRequestFailedException { this.eTag = eTag; final JSONObject ocs; @@ -92,6 +98,10 @@ public class Capabilities { } } + public void setApiVersion(String apiVersion) { + this.apiVersion = apiVersion; + } + public String getApiVersion() { return apiVersion; } @@ -101,14 +111,26 @@ public class Capabilities { return eTag; } - public Integer getColor() { + public void setETag(@Nullable String eTag) { + this.eTag = eTag; + } + + public int getColor() { return color; } - public Integer getTextColor() { + public void setColor(@ColorInt int color) { + this.color = color; + } + + public int getTextColor() { return textColor; } + public void setTextColor(@ColorInt int textColor) { + this.textColor = textColor; + } + @NonNull @Override public String toString() { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/IResponseCallback.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/IResponseCallback.java index 2c329727..707931b0 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/IResponseCallback.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/IResponseCallback.java @@ -2,8 +2,8 @@ package it.niedermann.owncloud.notes.shared.model; import androidx.annotation.NonNull; -public interface IResponseCallback { - void onSuccess(); +public interface IResponseCallback<T> { + void onSuccess(T result); void onError(@NonNull Throwable t); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NotesSettings.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NotesSettings.java new file mode 100644 index 00000000..ff11434f --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NotesSettings.java @@ -0,0 +1,34 @@ +package it.niedermann.owncloud.notes.shared.model; + +import androidx.annotation.Nullable; + +public class NotesSettings { + + @Nullable + private String notesPath; + @Nullable + private String fileSuffix; + + public NotesSettings(@Nullable String notesPath, @Nullable String fileSuffix) { + this.notesPath = notesPath; + this.fileSuffix = fileSuffix; + } + + @Nullable + public String getNotesPath() { + return notesPath; + } + + public void setNotesPath(@Nullable String notesPath) { + this.notesPath = notesPath; + } + + @Nullable + public String getFileSuffix() { + return fileSuffix; + } + + public void setFileSuffix(@Nullable String fileSuffix) { + this.fileSuffix = fileSuffix; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ServerResponse.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ServerResponse.java deleted file mode 100644 index dca53fb9..00000000 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ServerResponse.java +++ /dev/null @@ -1,103 +0,0 @@ -package it.niedermann.owncloud.notes.shared.model; - -import androidx.annotation.Nullable; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.util.ArrayList; -import java.util.Calendar; -import java.util.List; - -import it.niedermann.owncloud.notes.persistence.NotesClient; -import it.niedermann.owncloud.notes.persistence.entity.Note; - -/** - * Provides entity classes for handling server responses with a single note ({@link NoteResponse}) or a list of notes ({@link NotesResponse}). - */ -public class ServerResponse { - - public static class NoteResponse extends ServerResponse { - public NoteResponse(NotesClient.ResponseData response) { - super(response); - } - - public Note getNote() throws JSONException { - return getNoteFromJSON(new JSONObject(getContent())); - } - } - - public static class NotesResponse extends ServerResponse { - public NotesResponse(NotesClient.ResponseData response) { - super(response); - } - - public List<Note> getNotes() throws JSONException { - List<Note> notesList = new ArrayList<>(); - JSONArray notes = new JSONArray(getContent()); - for (int i = 0; i < notes.length(); i++) { - JSONObject json = notes.getJSONObject(i); - notesList.add(getNoteFromJSON(json)); - } - return notesList; - } - } - - - private final NotesClient.ResponseData response; - - ServerResponse(NotesClient.ResponseData response) { - this.response = response; - } - - protected String getContent() { - return response == null ? null : response.getContent(); - } - - public String getETag() { - return response.getETag(); - } - - public Calendar getLastModified() { - return response.getLastModified(); - } - - @Nullable - public String getSupportedApiVersions() { - return response.getSupportedApiVersions(); - } - - Note getNoteFromJSON(JSONObject json) throws JSONException { - long remoteId = 0; - String title = ""; - String content = ""; - Calendar modified = null; - boolean favorite = false; - String category = ""; - String etag = null; - if (!json.isNull(NotesClient.JSON_ID)) { - remoteId = json.getLong(NotesClient.JSON_ID); - } - if (!json.isNull(NotesClient.JSON_TITLE)) { - title = json.getString(NotesClient.JSON_TITLE); - } - if (!json.isNull(NotesClient.JSON_CONTENT)) { - content = json.getString(NotesClient.JSON_CONTENT); - } - if (!json.isNull(NotesClient.JSON_MODIFIED)) { - modified = Calendar.getInstance(); - modified.setTimeInMillis(json.getLong(NotesClient.JSON_MODIFIED) * 1_000); - } - if (!json.isNull(NotesClient.JSON_FAVORITE)) { - favorite = json.getBoolean(NotesClient.JSON_FAVORITE); - } - if (!json.isNull(NotesClient.JSON_CATEGORY)) { - category = json.getString(NotesClient.JSON_CATEGORY); - } - if (!json.isNull(NotesClient.JSON_ETAG)) { - etag = json.getString(NotesClient.JSON_ETAG); - } - return new Note(remoteId, modified, title, content, category, favorite, etag); - } -} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ServerSettings.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ServerSettings.java deleted file mode 100644 index e977b697..00000000 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ServerSettings.java +++ /dev/null @@ -1,47 +0,0 @@ -package it.niedermann.owncloud.notes.shared.model; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.Serializable; - -import static it.niedermann.owncloud.notes.persistence.NotesClient.JSON_SETTINGS_FILE_SUFFIX; -import static it.niedermann.owncloud.notes.persistence.NotesClient.JSON_SETTINGS_NOTES_PATH; - -public class ServerSettings implements Serializable { - private String notesPath = ""; - private String fileSuffix = ""; - - public ServerSettings(String notesPath, String fileSuffix) { - setNotesPath(notesPath); - setFileSuffix(fileSuffix); - } - - public static ServerSettings from(JSONObject settings) throws JSONException { - String notesPath = ""; - if (settings.has(JSON_SETTINGS_NOTES_PATH)) { - notesPath = settings.getString(JSON_SETTINGS_NOTES_PATH); - } - String fileSuffix = ""; - if (settings.has(JSON_SETTINGS_FILE_SUFFIX)) { - fileSuffix = settings.getString(JSON_SETTINGS_FILE_SUFFIX); - } - return new ServerSettings(notesPath, fileSuffix); - } - - public String getNotesPath() { - return notesPath; - } - - public void setNotesPath(String notesPath) { - this.notesPath = notesPath; - } - - public String getFileSuffix() { - return fileSuffix; - } - - public void setFileSuffix(String fileSuffix) { - this.fileSuffix = fileSuffix; - } -}
\ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/SyncResultStatus.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/SyncResultStatus.java index 2031568b..41ba850a 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/SyncResultStatus.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/SyncResultStatus.java @@ -3,4 +3,11 @@ package it.niedermann.owncloud.notes.shared.model; public class SyncResultStatus { public boolean pullSuccessful = true; public boolean pushSuccessful = true; + + public static final SyncResultStatus FAILED = new SyncResultStatus(); + + static { + FAILED.pullSuccessful = false; + FAILED.pushSuccessful = false; + } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListViewModel.java index ad3830a4..9ec3e355 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListViewModel.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListViewModel.java @@ -14,7 +14,7 @@ import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.main.MainActivity; import it.niedermann.owncloud.notes.main.navigation.NavigationAdapter; import it.niedermann.owncloud.notes.main.navigation.NavigationItem; -import it.niedermann.owncloud.notes.persistence.NotesDatabase; +import it.niedermann.owncloud.notes.persistence.NotesRepository; import static androidx.lifecycle.Transformations.distinctUntilChanged; import static androidx.lifecycle.Transformations.map; @@ -28,20 +28,20 @@ public class NoteListViewModel extends AndroidViewModel { private static final String TAG = NoteListViewModel.class.getSimpleName(); @NonNull - private final NotesDatabase db; + private final NotesRepository repo; public NoteListViewModel(@NonNull Application application) { super(application); - this.db = NotesDatabase.getInstance(application); + this.repo = NotesRepository.getInstance(application); } public LiveData<List<NavigationItem>> getAdapterCategories(Long accountId) { return distinctUntilChanged( - switchMap(distinctUntilChanged(db.getNoteDao().count$(accountId)), (count) -> { + switchMap(distinctUntilChanged(repo.count$(accountId)), (count) -> { Log.v(TAG, "[getAdapterCategories] countLiveData: " + count); - return switchMap(distinctUntilChanged(db.getNoteDao().countFavorites$(accountId)), (favoritesCount) -> { + return switchMap(distinctUntilChanged(repo.countFavorites$(accountId)), (favoritesCount) -> { Log.v(TAG, "[getAdapterCategories] getFavoritesCountLiveData: " + favoritesCount); - return map(distinctUntilChanged(db.getNoteDao().getCategories$(accountId)), fromDatabase -> { + return map(distinctUntilChanged(repo.getCategories$(accountId)), fromDatabase -> { final List<NavigationItem.CategoryNavigationItem> categories = convertToCategoryNavigationItem(getApplication(), fromDatabase); final List<NavigationItem> items = new ArrayList<>(fromDatabase.size() + 3); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidget.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidget.java index 98ebf2fe..7cc84de3 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidget.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidget.java @@ -13,20 +13,20 @@ import android.widget.RemoteViews; import java.util.NoSuchElementException; import it.niedermann.owncloud.notes.R; -import it.niedermann.owncloud.notes.persistence.NotesDatabase; +import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; public class NoteListWidget extends AppWidgetProvider { private static final String TAG = NoteListWidget.class.getSimpleName(); static void updateAppWidget(Context context, AppWidgetManager awm, int[] appWidgetIds) { - final NotesDatabase db = NotesDatabase.getInstance(context); + final NotesRepository repo = NotesRepository.getInstance(context); RemoteViews views; for (int appWidgetId : appWidgetIds) { try { - final NotesListWidgetData data = db.getWidgetNotesListDao().getNoteListWidgetData(appWidgetId); + final NotesListWidgetData data = repo.getNoteListWidgetData(appWidgetId); final Intent serviceIntent = new Intent(context, NoteListWidgetService.class); serviceIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); @@ -80,10 +80,10 @@ public class NoteListWidget extends AppWidgetProvider { @Override public void onDeleted(Context context, int[] appWidgetIds) { super.onDeleted(context, appWidgetIds); - final NotesDatabase db = NotesDatabase.getInstance(context); + final NotesRepository repo = NotesRepository.getInstance(context); for (int appWidgetId : appWidgetIds) { - new Thread(() -> db.getWidgetNotesListDao().removeNoteListWidget(appWidgetId)).start(); + new Thread(() -> repo.removeNoteListWidget(appWidgetId)).start(); } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetConfigurationActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetConfigurationActivity.java index 2c2aa086..a750ee1e 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetConfigurationActivity.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetConfigurationActivity.java @@ -21,11 +21,9 @@ import it.niedermann.owncloud.notes.databinding.ActivityNoteListConfigurationBin import it.niedermann.owncloud.notes.main.navigation.NavigationAdapter; import it.niedermann.owncloud.notes.main.navigation.NavigationClickListener; import it.niedermann.owncloud.notes.main.navigation.NavigationItem; -import it.niedermann.owncloud.notes.persistence.NotesDatabase; +import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.Account; -import it.niedermann.owncloud.notes.persistence.entity.CategoryOptions; import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; -import it.niedermann.owncloud.notes.shared.model.CategorySortingMethod; import static it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData.MODE_DISPLAY_ALL; import static it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData.MODE_DISPLAY_CATEGORY; @@ -43,14 +41,14 @@ public class NoteListWidgetConfigurationActivity extends LockedActivity { private ActivityNoteListConfigurationBinding binding; private NoteListViewModel viewModel; private NavigationAdapter adapterCategories; - private NotesDatabase db = null; + private NotesRepository repo = null; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setResult(RESULT_CANCELED); - db = NotesDatabase.getInstance(this); + repo = NotesRepository.getInstance(this); final Bundle extras = getIntent().getExtras(); if (extras != null) { @@ -107,7 +105,7 @@ public class NoteListWidgetConfigurationActivity extends LockedActivity { data.setThemeMode(NotesApplication.getAppTheme(getApplicationContext()).getModeId()); new Thread(() -> { - db.getWidgetNotesListDao().createOrUpdateNoteListWidgetData(data); + repo.createOrUpdateNoteListWidgetData(data); final Intent updateIntent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, null, getApplicationContext(), NoteListWidget.class) .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); @@ -126,7 +124,7 @@ public class NoteListWidgetConfigurationActivity extends LockedActivity { new Thread(() -> { try { - this.localAccount = db.getAccountDao().getAccountByName(SingleAccountHelper.getCurrentSingleSignOnAccount(this).name); + this.localAccount = repo.getAccountByName(SingleAccountHelper.getCurrentSingleSignOnAccount(this).name); } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { e.printStackTrace(); Toast.makeText(this, R.string.widget_not_logged_in, Toast.LENGTH_LONG).show(); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetFactory.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetFactory.java index 7917b25a..b7482233 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetFactory.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/notelist/NoteListWidgetFactory.java @@ -19,7 +19,7 @@ import java.util.List; import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.edit.EditNoteActivity; import it.niedermann.owncloud.notes.main.MainActivity; -import it.niedermann.owncloud.notes.persistence.NotesDatabase; +import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; @@ -37,7 +37,7 @@ public class NoteListWidgetFactory implements RemoteViewsService.RemoteViewsFact private final Context context; private final int appWidgetId; - private final NotesDatabase db; + private final NotesRepository repo; @NonNull private final List<Note> dbNotes = new ArrayList<>(); private NotesListWidgetData data; @@ -45,7 +45,7 @@ public class NoteListWidgetFactory implements RemoteViewsService.RemoteViewsFact NoteListWidgetFactory(Context context, Intent intent) { this.context = context; this.appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); - db = NotesDatabase.getInstance(context); + repo = NotesRepository.getInstance(context); } @Override @@ -57,21 +57,21 @@ public class NoteListWidgetFactory implements RemoteViewsService.RemoteViewsFact public void onDataSetChanged() { dbNotes.clear(); try { - data = db.getWidgetNotesListDao().getNoteListWidgetData(appWidgetId); + data = repo.getNoteListWidgetData(appWidgetId); Log.v(TAG, "--- data - " + data); switch (data.getMode()) { case MODE_DISPLAY_ALL: - dbNotes.addAll(db.getNoteDao().searchRecentByModified(data.getAccountId(), "%")); + dbNotes.addAll(repo.searchRecentByModified(data.getAccountId(), "%")); break; case MODE_DISPLAY_STARRED: - dbNotes.addAll(db.getNoteDao().searchFavoritesByModified(data.getAccountId(), "%")); + dbNotes.addAll(repo.searchFavoritesByModified(data.getAccountId(), "%")); break; case MODE_DISPLAY_CATEGORY: default: if (data.getCategory() != null) { - dbNotes.addAll(db.getNoteDao().searchCategoryByModified(data.getAccountId(), "%", data.getCategory())); + dbNotes.addAll(repo.searchCategoryByModified(data.getAccountId(), "%", data.getCategory())); } else { - dbNotes.addAll(db.getNoteDao().searchUncategorizedByModified(data.getAccountId(), "%")); + dbNotes.addAll(repo.searchUncategorizedByModified(data.getAccountId(), "%")); } break; } @@ -95,7 +95,7 @@ public class NoteListWidgetFactory implements RemoteViewsService.RemoteViewsFact final RemoteViews note_content; if (position == 0) { - final Account localAccount = db.getAccountDao().getAccountById(data.getAccountId()); + final Account localAccount = repo.getAccountById(data.getAccountId()); final Intent openIntent = new Intent(Intent.ACTION_MAIN).setComponent(new ComponentName(context.getPackageName(), MainActivity.class.getName())); final Intent createIntent = new Intent(context, EditNoteActivity.class); final Bundle extras = new Bundle(); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidget.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidget.java index 3ad56c76..e208603a 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidget.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidget.java @@ -11,9 +11,9 @@ import android.util.Log; import android.widget.RemoteViews; import it.niedermann.owncloud.notes.R; -import it.niedermann.owncloud.notes.edit.EditNoteActivity; import it.niedermann.owncloud.notes.edit.BaseNoteFragment; -import it.niedermann.owncloud.notes.persistence.NotesDatabase; +import it.niedermann.owncloud.notes.edit.EditNoteActivity; +import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; public class SingleNoteWidget extends AppWidgetProvider { @@ -22,11 +22,11 @@ public class SingleNoteWidget extends AppWidgetProvider { static void updateAppWidget(Context context, AppWidgetManager awm, int[] appWidgetIds) { final Intent templateIntent = new Intent(context, EditNoteActivity.class); - final NotesDatabase db = NotesDatabase.getInstance(context); + final NotesRepository repo = NotesRepository.getInstance(context); for (int appWidgetId : appWidgetIds) { - final SingleNoteWidgetData data = db.getWidgetSingleNoteDao().getSingleNoteWidgetData(appWidgetId); - if(data != null) { + final SingleNoteWidgetData data = repo.getSingleNoteWidgetData(appWidgetId); + if (data != null) { templateIntent.putExtra(BaseNoteFragment.PARAM_ACCOUNT_ID, data.getAccountId()); final PendingIntent templatePendingIntent = PendingIntent.getActivity(context, appWidgetId, templateIntent, @@ -66,10 +66,10 @@ public class SingleNoteWidget extends AppWidgetProvider { @Override public void onDeleted(Context context, int[] appWidgetIds) { - final NotesDatabase db = NotesDatabase.getInstance(context); + final NotesRepository repo = NotesRepository.getInstance(context); for (int appWidgetId : appWidgetIds) { - new Thread(() -> db.getWidgetSingleNoteDao().removeSingleNoteWidget(appWidgetId)).start(); + new Thread(() -> repo.removeSingleNoteWidget(appWidgetId)).start(); } super.onDeleted(context, appWidgetIds); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidgetFactory.java b/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidgetFactory.java index db26da24..f6ca4a24 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidgetFactory.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/singlenote/SingleNoteWidgetFactory.java @@ -13,7 +13,7 @@ import androidx.annotation.Nullable; import it.niedermann.android.markdown.MarkdownUtil; import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.edit.EditNoteActivity; -import it.niedermann.owncloud.notes.persistence.NotesDatabase; +import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; @@ -22,7 +22,7 @@ public class SingleNoteWidgetFactory implements RemoteViewsService.RemoteViewsFa private final Context context; private final int appWidgetId; - private final NotesDatabase db; + private final NotesRepository repo; @Nullable private Note note; @@ -31,7 +31,7 @@ public class SingleNoteWidgetFactory implements RemoteViewsService.RemoteViewsFa SingleNoteWidgetFactory(Context context, Intent intent) { this.context = context; this.appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); - this.db = NotesDatabase.getInstance(context); + this.repo = NotesRepository.getInstance(context); } @Override @@ -41,11 +41,11 @@ public class SingleNoteWidgetFactory implements RemoteViewsService.RemoteViewsFa @Override public void onDataSetChanged() { - final SingleNoteWidgetData data = db.getWidgetSingleNoteDao().getSingleNoteWidgetData(appWidgetId); - if(data != null) { + final SingleNoteWidgetData data = repo.getSingleNoteWidgetData(appWidgetId); + if (data != null) { final long noteId = data.getNoteId(); Log.v(TAG, "Fetch note with id " + noteId); - note = db.getNoteDao().getNoteById(noteId); + note = repo.getNoteById(noteId); if (note == null) { Log.e(TAG, "Error: note not found"); |