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 1e50ac0f..54e92baf 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 @@ -96,7 +96,7 @@ public class ImportAccountActivity extends AppCompatActivity { Log.i(TAG, "Loading capabilities for " + ssoAccount.name); final var capabilities = CapabilitiesClient.getCapabilities(getApplicationContext(), ssoAccount, null, ApiProvider.getInstance()); final String displayName = CapabilitiesClient.getDisplayName(getApplicationContext(), ssoAccount, ApiProvider.getInstance()); - importAccountViewModel.addAccount(ssoAccount.url, ssoAccount.userId, ssoAccount.name, capabilities, displayName, new IResponseCallback() { + final var status$ = importAccountViewModel.addAccount(ssoAccount.url, ssoAccount.userId, ssoAccount.name, capabilities, displayName, new IResponseCallback<>() { /** * Update syncing when adding account @@ -123,6 +123,16 @@ public class ImportAccountActivity extends AppCompatActivity { }); } }); + runOnUiThread(() -> status$.observe(ImportAccountActivity.this, (status) -> { + binding.progressText.setVisibility(View.VISIBLE); + Log.v(TAG, "Status: " + status.count + " of " + status.total); + if(status.count > 0) { + binding.progressCircular.setIndeterminate(false); + } + binding.progressText.setText(getString(R.string.progress_import, status.count + 1, status.total)); + binding.progressCircular.setProgress(status.count); + binding.progressCircular.setMax(status.total); + })); } catch (Throwable t) { t.printStackTrace(); ApiProvider.getInstance().invalidateAPICache(ssoAccount); @@ -162,6 +172,7 @@ public class ImportAccountActivity extends AppCompatActivity { runOnUiThread(() -> { binding.addButton.setEnabled(true); binding.progressCircular.setVisibility(View.GONE); + binding.progressText.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 70b3b565..1d60a043 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 @@ -11,11 +11,10 @@ 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; +import it.niedermann.owncloud.notes.shared.model.ImportStatus; public class ImportAccountViewModel extends AndroidViewModel { - private static final String TAG = ImportAccountViewModel.class.getSimpleName(); - @NonNull private final NotesRepository repo; @@ -24,7 +23,7 @@ public class ImportAccountViewModel extends AndroidViewModel { this.repo = NotesRepository.getInstance(application); } - public void addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @Nullable String displayName, @NonNull IResponseCallback callback) { - repo.addAccount(url, username, accountName, capabilities, displayName, callback); + public LiveData addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @Nullable String displayName, @NonNull IResponseCallback callback) { + return repo.addAccount(url, username, accountName, capabilities, displayName, 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 ee079fbd..132acc9c 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 @@ -668,27 +668,41 @@ public class MainActivity extends LockedActivity implements NoteClickListener, A AccountImporter.onActivityResult(requestCode, resultCode, data, this, (ssoAccount) -> { CapabilitiesWorker.update(this); executor.submit(() -> { + final var importSnackbar = BrandedSnackbar.make(binding.drawerLayout, R.string.progress_import_indeterminate, Snackbar.LENGTH_INDEFINITE); Log.i(TAG, "Added account: " + "name:" + ssoAccount.name + ", " + ssoAccount.url + ", userId" + ssoAccount.userId); try { Log.i(TAG, "Refreshing capabilities for " + ssoAccount.name); final var capabilities = CapabilitiesClient.getCapabilities(getApplicationContext(), ssoAccount, null, ApiProvider.getInstance()); final String displayName = CapabilitiesClient.getDisplayName(getApplicationContext(), ssoAccount, ApiProvider.getInstance()); - mainViewModel.addAccount(ssoAccount.url, ssoAccount.userId, ssoAccount.name, capabilities, displayName, new IResponseCallback() { + final var status$ = mainViewModel.addAccount(ssoAccount.url, ssoAccount.userId, ssoAccount.name, capabilities, displayName, new IResponseCallback() { @Override public void onSuccess(Account result) { executor.submit(() -> { + runOnUiThread(() -> { + importSnackbar.setText(R.string.account_imported); + importSnackbar.setAction(R.string.simple_switch, (v) -> mainViewModel.postCurrentAccount(mainViewModel.getLocalAccountByAccountName(ssoAccount.name))); + }); Log.i(TAG, capabilities.toString()); - final var a = mainViewModel.getLocalAccountByAccountName(ssoAccount.name); - runOnUiThread(() -> mainViewModel.postCurrentAccount(a)); }); } @Override public void onError(@NonNull Throwable t) { - runOnUiThread(() -> ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName())); + runOnUiThread(() -> { + importSnackbar.dismiss(); + ExceptionDialogFragment.newInstance(t).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + }); } }); + runOnUiThread(() -> status$.observe(this, (status) -> { + importSnackbar.show(); + Log.v(TAG, "Status: " + status.count + " of " + status.total); + if(status.count > 0) { + importSnackbar.setText(getString(R.string.progress_import, status.count + 1, status.total)); + } + })); } catch (Throwable e) { + importSnackbar.dismiss(); ApiProvider.getInstance().invalidateAPICache(ssoAccount); // Happens when importing an already existing account the second time if (e instanceof TokenMismatchException && mainViewModel.getLocalAccountByAccountName(ssoAccount.name) != null) { 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 e7cf1669..b52fdc79 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 @@ -46,6 +46,7 @@ import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; import it.niedermann.owncloud.notes.shared.model.Capabilities; import it.niedermann.owncloud.notes.shared.model.CategorySortingMethod; import it.niedermann.owncloud.notes.shared.model.IResponseCallback; +import it.niedermann.owncloud.notes.shared.model.ImportStatus; import it.niedermann.owncloud.notes.shared.model.Item; import it.niedermann.owncloud.notes.shared.model.NavigationCategory; @@ -538,8 +539,8 @@ public class MainViewModel extends AndroidViewModel { }); } - public void addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @Nullable String displayName, @NonNull IResponseCallback callback) { - repo.addAccount(url, username, accountName, capabilities, displayName, callback); + public LiveData addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @Nullable String displayName, @NonNull IResponseCallback callback) { + return repo.addAccount(url, username, accountName, capabilities, displayName, callback); } public LiveData getFullNote$(long id) { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesImportTask.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesImportTask.java new file mode 100644 index 00000000..acdf1441 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesImportTask.java @@ -0,0 +1,84 @@ +package it.niedermann.owncloud.notes.persistence; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.nextcloud.android.sso.AccountImporter; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.sync.NotesAPI; +import it.niedermann.owncloud.notes.shared.model.IResponseCallback; +import it.niedermann.owncloud.notes.shared.model.ImportStatus; +import it.niedermann.owncloud.notes.shared.util.ApiVersionUtil; + + +public class NotesImportTask { + + private static final String TAG = NotesImportTask.class.getSimpleName(); + + private final NotesAPI notesAPI; + @NonNull + private final NotesRepository repo; + @NonNull + private final Account localAccount; + @NonNull + private final ExecutorService executor; + @NonNull + private final ExecutorService fetchExecutor; + + NotesImportTask(@NonNull Context context, @NonNull NotesRepository repo, @NonNull Account localAccount, @NonNull ExecutorService executor, @NonNull ApiProvider apiProvider) throws NextcloudFilesAppAccountNotFoundException { + this(context, repo, localAccount, executor, Executors.newFixedThreadPool(20), apiProvider); + } + + private NotesImportTask(@NonNull Context context, @NonNull NotesRepository repo, @NonNull Account localAccount, @NonNull ExecutorService executor, @NonNull ExecutorService fetchExecutor, @NonNull ApiProvider apiProvider) throws NextcloudFilesAppAccountNotFoundException { + this.repo = repo; + this.localAccount = localAccount; + this.executor = executor; + this.fetchExecutor = fetchExecutor; + this.notesAPI = apiProvider.getNotesAPI(context, AccountImporter.getSingleSignOnAccount(context, localAccount.getAccountName()), ApiVersionUtil.getPreferredApiVersion(localAccount.getApiVersion())); + } + + public LiveData importNotes(@NonNull IResponseCallback callback) { + final var status$ = new MutableLiveData(); + Log.i(TAG, "STARTING IMPORT"); + executor.submit(() -> { + Log.i(TAG, "… Fetching notes IDs"); + final var status = new ImportStatus(); + final var remoteIds = notesAPI.getNotesIDs().blockingSingle(); + status.total = remoteIds.size(); + status$.postValue(status); + Log.i(TAG, "… Total count: " + remoteIds.size()); + final var latch = new CountDownLatch(remoteIds.size()); + for (long id : remoteIds) { + fetchExecutor.submit(() -> { + try { + repo.addNote(localAccount.getId(), notesAPI.getNote(id).blockingSingle().getResponse()); + } catch (Throwable t) { + Log.w(TAG, "Could not import note with remoteId " + id + ": " + t.getMessage()); + status.warnings.add(t); + } + status.count++; + status$.postValue(status); + latch.countDown(); + }); + } + try { + latch.await(); + Log.i(TAG, "IMPORT FINISHED"); + callback.onSuccess(null); + } catch (InterruptedException e) { + callback.onError(e); + } + }); + return status$; + } +} 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 index 59eafa05..8963d87f 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java @@ -58,6 +58,7 @@ 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.ImportStatus; import it.niedermann.owncloud.notes.shared.model.NavigationCategory; import it.niedermann.owncloud.notes.shared.model.NotesSettings; import it.niedermann.owncloud.notes.shared.model.SyncResultStatus; @@ -86,6 +87,7 @@ public class NotesRepository { private final ApiProvider apiProvider; private final ExecutorService executor; private final ExecutorService syncExecutor; + private final ExecutorService importExecutor; private final Context context; private final NotesDatabase db; private final String defaultNonEmptyTitle; @@ -138,16 +140,17 @@ public class NotesRepository { public static synchronized NotesRepository getInstance(@NonNull Context context) { if (instance == null) { - instance = new NotesRepository(context, NotesDatabase.getInstance(context.getApplicationContext()), Executors.newCachedThreadPool(), Executors.newSingleThreadExecutor(), ApiProvider.getInstance()); + instance = new NotesRepository(context, NotesDatabase.getInstance(context.getApplicationContext()), Executors.newCachedThreadPool(), Executors.newSingleThreadExecutor(), Executors.newSingleThreadExecutor(), ApiProvider.getInstance()); } return instance; } - private NotesRepository(@NonNull final Context context, @NonNull final NotesDatabase db, @NonNull final ExecutorService executor, @NonNull final ExecutorService syncExecutor, @NonNull ApiProvider apiProvider) { + private NotesRepository(@NonNull final Context context, @NonNull final NotesDatabase db, @NonNull final ExecutorService executor, @NonNull final ExecutorService syncExecutor, @NonNull final ExecutorService importExecutor, @NonNull ApiProvider apiProvider) { this.context = context.getApplicationContext(); this.db = db; this.executor = executor; this.syncExecutor = syncExecutor; + this.importExecutor = importExecutor; this.apiProvider = apiProvider; this.defaultNonEmptyTitle = NoteUtil.generateNonEmptyNoteTitle("", this.context); this.syncOnlyOnWifiKey = context.getApplicationContext().getResources().getString(R.string.pref_key_wifi_only); @@ -166,13 +169,36 @@ public class NotesRepository { // Accounts @AnyThread - public void addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @Nullable String displayName, @NonNull IResponseCallback callback) { - final var createdAccount = db.getAccountDao().getAccountById(db.getAccountDao().insert(new Account(url, username, accountName, displayName, capabilities))); - if (createdAccount == null) { + public LiveData addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @Nullable String displayName, @NonNull IResponseCallback callback) { + final var account = db.getAccountDao().getAccountById(db.getAccountDao().insert(new Account(url, username, accountName, displayName, capabilities))); + if (account == null) { callback.onError(new Exception("Could not read created account.")); } else { - callback.onSuccess(createdAccount); + if (isSyncPossible()) { + syncActive.put(account.getId(), true); + try { + Log.d(TAG, "... starting now"); + final NotesImportTask importTask = new NotesImportTask(context, this, account, importExecutor, apiProvider); + return importTask.importNotes(new IResponseCallback<>() { + @Override + public void onSuccess(Void result) { + callback.onSuccess(account); + } + + @Override + public void onError(@NonNull Throwable t) { + callback.onError(t); + } + }); + } catch (NextcloudFilesAppAccountNotFoundException e) { + Log.e(TAG, "... Could not find " + SingleSignOnAccount.class.getSimpleName() + " for account name " + account.getAccountName()); + callback.onError(e); + } + } else { + callback.onError(new NetworkErrorException()); + } } + return new MutableLiveData<>(new ImportStatus()); } @WorkerThread 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 index 3e552ae6..4f8bee92 100644 --- 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 @@ -12,6 +12,7 @@ import com.nextcloud.android.sso.api.ParsedResponse; import java.util.Calendar; import java.util.List; +import java.util.stream.Collectors; import io.reactivex.Observable; import it.niedermann.owncloud.notes.persistence.entity.Note; @@ -69,6 +70,26 @@ public class NotesAPI { } } + public Observable> getNotesIDs() { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.getNotesIDs().map(response -> response.getResponse().stream().map(Note::getRemoteId).collect(Collectors.toList())); + } else if (ApiVersion.API_VERSION_0_2.equals(usedApiVersion)) { + return notesAPI_0_2.getNotesIDs().map(response -> response.getResponse().stream().map(Note::getRemoteId).collect(Collectors.toList())); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support getNotesIDs()."); + } + } + + public Observable> getNote(long remoteId) { + if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { + return notesAPI_1_0.getNote(remoteId); + } else if (ApiVersion.API_VERSION_0_2.equals(usedApiVersion)) { + return notesAPI_0_2.getNote(remoteId); + } else { + throw new UnsupportedOperationException("Used API version " + usedApiVersion + " does not support getNote()."); + } + } + public Call createNote(Note note) { if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) { return notesAPI_1_0.createNote(note); 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 index fd642064..13d66c03 100644 --- 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 @@ -25,9 +25,15 @@ public interface NotesAPI_0_2 { @GET("notes") Observable>> getNotes(@Query("pruneBefore") long lastModified, @Header("If-None-Match") String lastETag); + @GET("notes?exclude=etag,readonly,content,title,category,favorite,modified") + Observable>> getNotesIDs(); + @POST("notes") Call createNote(@Body NotesAPI.Note_0_2 note); + @GET("notes/{remoteId}") + Observable> getNote(@Path("remoteId") long remoteId); + @PUT("notes/{remoteId}") Call editNote(@Body NotesAPI.Note_0_2 note, @Path("remoteId") long remoteId); 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 index dfc176c3..20f6f9a7 100644 --- 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 @@ -26,9 +26,15 @@ public interface NotesAPI_1_0 { @GET("notes") Observable>> getNotes(@Query("pruneBefore") long lastModified, @Header("If-None-Match") String lastETag); + @GET("notes?exclude=etag,readonly,content,title,category,favorite,modified") + Observable>> getNotesIDs(); + @POST("notes") Call createNote(@Body Note note); + @GET("notes/{remoteId}") + Observable> getNote(@Path("remoteId") long remoteId); + @PUT("notes/{remoteId}") Call editNote(@Body Note note, @Path("remoteId") long remoteId); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ImportStatus.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ImportStatus.java new file mode 100644 index 00000000..7ae189ef --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/ImportStatus.java @@ -0,0 +1,10 @@ +package it.niedermann.owncloud.notes.shared.model; + +import java.util.Collection; +import java.util.LinkedList; + +public class ImportStatus { + public int count = 0; + public int total = 0; + public final Collection warnings = new LinkedList<>(); +} diff --git a/app/src/main/res/layout/activity_import_account.xml b/app/src/main/res/layout/activity_import_account.xml index ed072796..7484a61b 100644 --- a/app/src/main/res/layout/activity_import_account.xml +++ b/app/src/main/res/layout/activity_import_account.xml @@ -65,12 +65,24 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 772a57fe..693946ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,6 +12,7 @@ Search Sorting method Cancel + Switch Edit Remove Save @@ -315,4 +316,7 @@ File extension for new notes in your Nextcloud New file suffix: %1$s HTTP status code: %1$d + Importing notes… + Importing note %1$d of %2$d… + Account imported. diff --git a/fastlane/metadata/android/en-US/changelogs/3004014.txt b/fastlane/metadata/android/en-US/changelogs/3004014.txt new file mode 100644 index 00000000..2f7b7716 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3004014.txt @@ -0,0 +1 @@ +- 🐞 Consistent timeouts when syncing (#761) \ No newline at end of file