diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index c91842c57d39..0083e3b231dd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -106,6 +106,7 @@ import com.ichi2.ui.BadgeDrawableBuilder import com.ichi2.utils.* import com.ichi2.utils.Permissions.hasStorageAccessPermission import com.ichi2.widget.WidgetStatus +import net.ankiweb.rsdroid.BackendFactory import timber.log.Timber import java.io.File import kotlin.math.abs @@ -1238,7 +1239,7 @@ open class DeckPicker : NavigationDrawerActivity(), StudyOptionsListener, SyncEr * @param messageResource String resource for message */ @KotlinCleanup("nullOrEmpty") - private fun showSyncLogMessage(@StringRes messageResource: Int, syncMessage: String?) { + fun showSyncLogMessage(@StringRes messageResource: Int, syncMessage: String?) { if (mActivityPaused) { val res = AnkiDroidApp.getAppResources() showSimpleNotification( @@ -1442,22 +1443,27 @@ open class DeckPicker : NavigationDrawerActivity(), StudyOptionsListener, SyncEr override fun sync(conflict: ConflictResolution?) { val preferences = AnkiDroidApp.getSharedPrefs(baseContext) val hkey = preferences.getString("hkey", "") + val hostNum = HostNumFactory.getInstance(baseContext).getHostNum() if (hkey!!.isEmpty()) { Timber.w("User not logged in") mPullToSyncWrapper.isRefreshing = false showSyncErrorDialog(SyncErrorDialog.DIALOG_USER_NOT_LOGGED_IN_SYNC) } else { - Connection.sync( - mSyncListener, - Connection.Payload( - arrayOf( - hkey, - preferences.getBoolean("syncFetchesMedia", true), - conflict, - HostNumFactory.getInstance(baseContext) + if (!BackendFactory.defaultLegacySchema) { + handleNewSync(hkey, hostNum ?: 0, conflict) + } else { + Connection.sync( + mSyncListener, + Connection.Payload( + arrayOf( + hkey, + preferences.getBoolean("syncFetchesMedia", true), + conflict, + HostNumFactory.getInstance(baseContext) + ) ) ) - ) + } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/MyAccount.kt b/AnkiDroid/src/main/java/com/ichi2/anki/MyAccount.kt index db4eca813eaf..ae6094c30685 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/MyAccount.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/MyAccount.kt @@ -38,6 +38,7 @@ import com.ichi2.libanki.sync.CustomSyncServerUrlException import com.ichi2.themes.StyledProgressDialog import com.ichi2.ui.TextInputEditField import com.ichi2.utils.AdaptionUtil.isUserATestClient +import net.ankiweb.rsdroid.BackendFactory import timber.log.Timber import java.lang.Exception import java.net.UnknownHostException @@ -107,15 +108,19 @@ class MyAccount : AnkiActivity() { return } Timber.i("Attempting auto-login") - Connection.login( - mLoginListener, - Connection.Payload( - arrayOf( - username, password, - getInstance(this) + if (!BackendFactory.defaultLegacySchema) { + handleNewLogin(username, password) + } else { + Connection.login( + mLoginListener, + Connection.Payload( + arrayOf( + username, password, + getInstance(this) + ) ) ) - ) + } } private fun saveUserInformation(username: String, hkey: String) { @@ -142,15 +147,19 @@ class MyAccount : AnkiActivity() { mPassword.requestFocus() return } - Connection.login( - mLoginListener, - Connection.Payload( - arrayOf( - username, password, - getInstance(this) + if (!BackendFactory.defaultLegacySchema) { + handleNewLogin(username, password) + } else { + Connection.login( + mLoginListener, + Connection.Payload( + arrayOf( + username, password, + getInstance(this) + ) ) ) - ) + } } private fun logout() { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt new file mode 100644 index 000000000000..52d9d1e945a4 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt @@ -0,0 +1,242 @@ +/*************************************************************************************** + * Copyright (c) 2012 Ankitects Pty Ltd * + * * + * This program is free software; you can redistribute it and/or modify it under * + * the terms of the GNU General Public License as published by the Free Software * + * Foundation; either version 3 of the License, or (at your option) any later * + * version. * + * * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY * + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * + * PARTICULAR PURPOSE. See the GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License along with * + * this program. If not, see . * + ****************************************************************************************/ + +// This is a minimal example of integrating the new backend sync code into AnkiDroid. +// Work is required to make it robust: error handling, showing progress in the GUI instead +// of the console, keeping the screen on, preventing the user from interacting while syncing, +// etc. +// +// BackendFactory.defaultLegacySchema must be false to use this code. +// + +package com.ichi2.anki + +import android.content.Context +import androidx.core.content.edit +import androidx.lifecycle.lifecycleScope +import anki.collection.Progress +import anki.sync.SyncAuth +import anki.sync.SyncCollectionResponse +import com.ichi2.anim.ActivityTransitionAnimation +import com.ichi2.anki.dialogs.SyncErrorDialog +import com.ichi2.anki.web.HostNumFactory +import com.ichi2.async.Connection +import com.ichi2.libanki.CollectionV16 +import com.ichi2.libanki.getProgress +import com.ichi2.libanki.sync.* +import kotlinx.coroutines.* +import net.ankiweb.rsdroid.exceptions.BackendSyncException +import timber.log.Timber + +fun DeckPicker.handleNewSync( + hkey: String, + hostNum: Int, + conflict: Connection.ConflictResolution? +) { + val auth = SyncAuth.newBuilder().apply { + this.hkey = hkey + this.hostNumber = hostNum + }.build() + + val col = CollectionHelper.getInstance().getCol(baseContext).newBackend + val deckPicker = this + + lifecycleScope.launch { + try { + when (conflict) { + Connection.ConflictResolution.FULL_DOWNLOAD -> handleDownload(col, auth, deckPicker) + Connection.ConflictResolution.FULL_UPLOAD -> handleUpload(col, auth, deckPicker) + null -> handleNormalSync(baseContext, col, auth, deckPicker) + } + } catch (exc: BackendSyncException.BackendSyncAuthFailedException) { + // auth failed; log out + AnkiDroidApp.getSharedPrefs(baseContext).edit { + putString("hkey", "") + } + Timber.e("login failed") + // FIXME: inform user + } catch (exc: Exception) { + Timber.e("exception in sync: $exc") + // FIXME: inform user + } + deckPicker.refreshState() + } +} + +fun MyAccount.handleNewLogin(username: String, password: String) { + val col = CollectionHelper.getInstance().getCol(baseContext).newBackend + lifecycleScope.launch { + val auth = try { + runInBackgroundWithProgress(col, { }) { + col.syncLogin(username, password) + } + } catch (exc: BackendSyncException.BackendSyncAuthFailedException) { + Timber.e("login failed") + // FIXME: inform user + // auth failed; return empty values so preferences below logs us out + SyncAuth.newBuilder().build() + } + val preferences = AnkiDroidApp.getSharedPrefs(baseContext) + preferences.edit { + putString("username", username) + putString("hkey", auth.hkey) + } + finishWithAnimation(ActivityTransitionAnimation.Direction.FADE) + } +} + +private suspend fun handleNormalSync( + context: Context, + col: CollectionV16, + auth: SyncAuth, + deckPicker: DeckPicker +) { + val output = runInBackgroundWithProgress(col, { + if (it.hasNormalSync()) { + it.normalSync.run { updateProgress("$added $removed") } + } + }) { + col.syncCollection(auth) + } + + // Save current host number + HostNumFactory.getInstance(context).setHostNum(output.hostNumber) + + when (output.required) { + SyncCollectionResponse.ChangesRequired.NO_CHANGES -> { + // a successful sync returns this value + deckPicker.showSyncLogMessage(R.string.sync_database_acknowledge, output.serverMessage) + // kick off media sync - future implementations may want to run this in the + // background instead + handleMediaSync(col, auth) + } + + SyncCollectionResponse.ChangesRequired.FULL_DOWNLOAD -> { + handleDownload(col, auth, deckPicker) + handleMediaSync(col, auth) + } + + SyncCollectionResponse.ChangesRequired.FULL_UPLOAD -> { + handleUpload(col, auth, deckPicker) + handleMediaSync(col, auth) + } + + SyncCollectionResponse.ChangesRequired.FULL_SYNC -> { + deckPicker.showSyncErrorDialog(SyncErrorDialog.DIALOG_SYNC_CONFLICT_RESOLUTION) + } + + SyncCollectionResponse.ChangesRequired.NORMAL_SYNC, + SyncCollectionResponse.ChangesRequired.UNRECOGNIZED, + null -> { + TODO("should never happen") + } + } +} + +private suspend fun handleDownload( + col: CollectionV16, + auth: SyncAuth, + deckPicker: DeckPicker +) { + runInBackgroundWithProgress(col, { + if (it.hasFullSync()) { + it.fullSync.run { updateProgress("downloaded $transferred/$total") } + } + }) { + // TODO: backup + col.close(save = true, downgrade = false, forFullSync = true) + try { + col.fullDownload(auth) + } finally { + col.reopen(afterFullSync = true) + } + } + + Timber.i("Full Download Completed") + deckPicker.showSyncLogMessage(R.string.backup_full_sync_from_server, "") +} + +private suspend fun handleUpload( + col: CollectionV16, + auth: SyncAuth, + deckPicker: DeckPicker +) { + runInBackgroundWithProgress(col, { + if (it.hasFullSync()) { + it.fullSync.run { updateProgress("uploaded $transferred/$total") } + } + }) { + col.close(save = true, downgrade = false, forFullSync = true) + try { + col.fullUpload(auth) + } finally { + col.reopen(afterFullSync = true) + } + } + + Timber.i("Full Upload Completed") + deckPicker.showSyncLogMessage(R.string.sync_log_uploading_message, "") +} + +@Suppress("UNUSED_PARAMETER", "UNREACHABLE_CODE") +private suspend fun handleMediaSync( + col: CollectionV16, + auth: SyncAuth +) { + runInBackgroundWithProgress(col, { + if (it.hasMediaSync()) { + it.mediaSync.run { updateProgress("media: $added $removed $checked") } + } + }) { + col.syncMedia(auth) + } +} + +// FIXME: display/update a popup progress window instead of logging +fun updateProgress(text: String) { + Timber.i("progress: $text") +} + +suspend fun runInBackgroundWithProgress( + col: CollectionV16, + onProgress: (Progress) -> Unit, + op: suspend (CollectionV16) -> T +): T = coroutineScope { + val monitor = launch { monitorProgress(col, onProgress) } + try { + withContext(Dispatchers.IO) { + op(col) + } + } finally { + monitor.cancel() + } +} + +suspend fun monitorProgress(col: CollectionV16, op: (Progress) -> Unit) { + while (true) { + try { + val progress = col.getProgress() + // on main thread, so op can update UI + withContext(Dispatchers.Main) { + op(progress) + } + } catch (exc: Exception) { + Timber.e("exception in monitorProgress: $exc") + return + } + delay(100) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/Collection.kt b/AnkiDroid/src/main/java/com/ichi2/libanki/Collection.kt index 2c0c248d7479..11c6614d8110 100644 --- a/AnkiDroid/src/main/java/com/ichi2/libanki/Collection.kt +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/Collection.kt @@ -363,7 +363,7 @@ open class Collection constructor( */ @KotlinCleanup("scope function") @JvmOverloads - fun flush(mod: Long = 0) { + open fun flush(mod: Long = 0) { Timber.i("flush - Saving information to DB...") this.mod = if (mod == 0L) TimeManager.time.intTimeMS() else mod val values = ContentValues() @@ -434,9 +434,9 @@ open class Collection constructor( } @Synchronized - @KotlinCleanup("JvmOverloads") @KotlinCleanup("remove/rename val db") - fun close(save: Boolean, downgrade: Boolean) { + @JvmOverloads + fun close(save: Boolean, downgrade: Boolean, forFullSync: Boolean = false) { if (!dbClosed) { try { val db = db.database @@ -449,10 +449,9 @@ open class Collection constructor( Timber.w(e) CrashReportService.sendExceptionReport(e, "closeDB") } - if (!server) { - db.database.disableWriteAheadLogging() + if (!forFullSync) { + backend.closeCollection(downgrade) } - backend.closeCollection(downgrade) dbInternal = null media.close() _closeLog() @@ -461,11 +460,11 @@ open class Collection constructor( } /** True if DB was created */ - fun reopen(): Boolean { + @JvmOverloads + fun reopen(afterFullSync: Boolean = false): Boolean { Timber.i("(Re)opening Database: %s", path) if (dbClosed) { - // fixme: pass in time - val (db_, created) = Storage.openDB(path, backend) + val (db_, created) = Storage.openDB(path, backend, afterFullSync) dbInternal = db_ media.connect() _openLog() diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/Progress.kt b/AnkiDroid/src/main/java/com/ichi2/libanki/Progress.kt new file mode 100644 index 000000000000..abebab6c8d22 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/Progress.kt @@ -0,0 +1,23 @@ +/*************************************************************************************** + * Copyright (c) 2012 Ankitects Pty Ltd * + * * + * This program is free software; you can redistribute it and/or modify it under * + * the terms of the GNU General Public License as published by the Free Software * + * Foundation; either version 3 of the License, or (at your option) any later * + * version. * + * * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY * + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * + * PARTICULAR PURPOSE. See the GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License along with * + * this program. If not, see . * + ****************************************************************************************/ + +package com.ichi2.libanki + +import anki.collection.Progress + +fun CollectionV16.getProgress(): Progress { + return backend.latestProgress() +} diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/Storage.kt b/AnkiDroid/src/main/java/com/ichi2/libanki/Storage.kt index 19453b73c667..081d6645435d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/libanki/Storage.kt +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/Storage.kt @@ -94,13 +94,17 @@ object Storage { /** * Called as part of Collection initialization. Don't call directly. */ - internal fun openDB(path: String, backend: Backend): Pair { + internal fun openDB(path: String, backend: Backend, afterFullSync: Boolean): Pair { if (isLocked) { throw SQLiteDatabaseLockedException("AnkiDroid has locked the database") } val dbFile = File(path) - val create = !dbFile.exists() - backend.openCollection(if (isInMemory) ":memory:" else path) + var create = !dbFile.exists() + if (afterFullSync) { + create = false + } else { + backend.openCollection(if (isInMemory) ":memory:" else path) + } val db = DB.withRustBackend(backend) // initialize diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/sync/BackendSync.kt b/AnkiDroid/src/main/java/com/ichi2/libanki/sync/BackendSync.kt new file mode 100644 index 000000000000..7b4ec31ee8a7 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/sync/BackendSync.kt @@ -0,0 +1,41 @@ +/*************************************************************************************** + * Copyright (c) 2012 Ankitects Pty Ltd * + * * + * This program is free software; you can redistribute it and/or modify it under * + * the terms of the GNU General Public License as published by the Free Software * + * Foundation; either version 3 of the License, or (at your option) any later * + * version. * + * * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY * + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * + * PARTICULAR PURPOSE. See the GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License along with * + * this program. If not, see . * + ****************************************************************************************/ + +package com.ichi2.libanki.sync + +import anki.sync.SyncAuth +import anki.sync.SyncCollectionResponse +import com.ichi2.libanki.CollectionV16 + +fun CollectionV16.syncLogin(username: String, password: String): SyncAuth { + return backend.syncLogin(username, password) +} + +fun CollectionV16.syncCollection(auth: SyncAuth): SyncCollectionResponse { + return backend.syncCollection(auth) +} + +fun CollectionV16.fullUpload(auth: SyncAuth) { + return backend.fullUpload(auth) +} + +fun CollectionV16.fullDownload(auth: SyncAuth) { + return backend.fullDownload(auth) +} + +fun CollectionV16.syncMedia(auth: SyncAuth) { + return backend.syncMedia(auth) +}