#761 Import notes one by one to avoid read timeout for the first sync

Signed-off-by: Stefan Niedermann <info@niedermann.it>
This commit is contained in:
Stefan Niedermann 2021-09-30 12:36:14 +02:00
parent 411c61c21d
commit 5801f00bfb
13 changed files with 214 additions and 19 deletions

View file

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

View file

@ -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<Account> callback) {
repo.addAccount(url, username, accountName, capabilities, displayName, callback);
public LiveData<ImportStatus> addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @Nullable String displayName, @NonNull IResponseCallback<Account> callback) {
return repo.addAccount(url, username, accountName, capabilities, displayName, callback);
}
}

View file

@ -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<Account>() {
final var status$ = mainViewModel.addAccount(ssoAccount.url, ssoAccount.userId, ssoAccount.name, capabilities, displayName, new IResponseCallback<Account>() {
@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) {

View file

@ -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<Account> callback) {
repo.addAccount(url, username, accountName, capabilities, displayName, callback);
public LiveData<ImportStatus> addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @Nullable String displayName, @NonNull IResponseCallback<Account> callback) {
return repo.addAccount(url, username, accountName, capabilities, displayName, callback);
}
public LiveData<Note> getFullNote$(long id) {

View file

@ -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<ImportStatus> importNotes(@NonNull IResponseCallback<Void> callback) {
final var status$ = new MutableLiveData<ImportStatus>();
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$;
}
}

View file

@ -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<Account> callback) {
final var createdAccount = db.getAccountDao().getAccountById(db.getAccountDao().insert(new Account(url, username, accountName, displayName, capabilities)));
if (createdAccount == null) {
public LiveData<ImportStatus> addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @Nullable String displayName, @NonNull IResponseCallback<Account> 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

View file

@ -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<List<Long>> 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<ParsedResponse<Note>> 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<Note> createNote(Note note) {
if (ApiVersion.API_VERSION_1_0.equals(usedApiVersion)) {
return notesAPI_1_0.createNote(note);

View file

@ -25,9 +25,15 @@ public interface NotesAPI_0_2 {
@GET("notes")
Observable<ParsedResponse<List<Note>>> getNotes(@Query("pruneBefore") long lastModified, @Header("If-None-Match") String lastETag);
@GET("notes?exclude=etag,readonly,content,title,category,favorite,modified")
Observable<ParsedResponse<List<Note>>> getNotesIDs();
@POST("notes")
Call<Note> createNote(@Body NotesAPI.Note_0_2 note);
@GET("notes/{remoteId}")
Observable<ParsedResponse<Note>> getNote(@Path("remoteId") long remoteId);
@PUT("notes/{remoteId}")
Call<Note> editNote(@Body NotesAPI.Note_0_2 note, @Path("remoteId") long remoteId);

View file

@ -26,9 +26,15 @@ public interface NotesAPI_1_0 {
@GET("notes")
Observable<ParsedResponse<List<Note>>> getNotes(@Query("pruneBefore") long lastModified, @Header("If-None-Match") String lastETag);
@GET("notes?exclude=etag,readonly,content,title,category,favorite,modified")
Observable<ParsedResponse<List<Note>>> getNotesIDs();
@POST("notes")
Call<Note> createNote(@Body Note note);
@GET("notes/{remoteId}")
Observable<ParsedResponse<Note>> getNote(@Path("remoteId") long remoteId);
@PUT("notes/{remoteId}")
Call<Note> editNote(@Body Note note, @Path("remoteId") long remoteId);

View file

@ -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<Throwable> warnings = new LinkedList<>();
}

View file

@ -65,12 +65,24 @@
<ProgressBar
android:id="@+id/progress_circular"
android:layout_width="wrap_content"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/add_button"
android:layout_centerHorizontal="true"
android:layout_marginTop="32dp"
android:layout_marginTop="@dimen/spacer_5x"
android:indeterminate="true"
android:indeterminateTint="@color/defaultBrand"
android:visibility="gone" />
<TextView
android:id="@+id/progress_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/progress_circular"
android:layout_centerHorizontal="true"
android:layout_marginTop="@dimen/spacer_2x"
android:visibility="gone"
tools:text="@string/progress_import_indeterminate" />
</RelativeLayout>
</ScrollView>

View file

@ -12,6 +12,7 @@
<string name="action_search">Search</string>
<string name="action_sorting_method">Sorting method</string>
<string name="simple_cancel">Cancel</string>
<string name="simple_switch">Switch</string>
<string name="simple_edit">Edit</string>
<string name="simple_remove">Remove</string>
<string name="action_edit_save">Save</string>
@ -315,4 +316,7 @@
<string name="settings_file_suffix_description">File extension for new notes in your Nextcloud</string>
<string name="settings_file_suffix_success">New file suffix: %1$s</string>
<string name="http_status_code">HTTP status code: %1$d</string>
<string name="progress_import_indeterminate">Importing notes…</string>
<string name="progress_import">Importing note %1$d of %2$d…</string>
<string name="account_imported">Account imported.</string>
</resources>

View file

@ -0,0 +1 @@
- 🐞 Consistent timeouts when syncing (#761)