mirror of
https://github.com/nextcloud/notes-android.git
synced 2024-11-21 12:25:57 +03:00
#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:
parent
411c61c21d
commit
5801f00bfb
13 changed files with 214 additions and 19 deletions
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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$;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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<>();
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
1
fastlane/metadata/android/en-US/changelogs/3004014.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/3004014.txt
Normal file
|
@ -0,0 +1 @@
|
|||
- 🐞 Consistent timeouts when syncing (#761)
|
Loading…
Reference in a new issue