diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt index f09dd98b8..ff005c25c 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt @@ -24,6 +24,7 @@ import android.content.ClipboardManager import android.graphics.* import android.os.* import android.text.* +import android.util.* import android.view.* import com.google.zxing.* import com.google.zxing.qrcode.* @@ -98,12 +99,13 @@ class SyncActivity : BaseActivity() { private var error = false override fun doInBackground() { runBlocking { - val server = RemoteSyncServer(baseURL = preferences.syncBaseURL) try { + val server = RemoteSyncServer(baseURL = preferences.syncBaseURL) syncKey = server.register() encKey = EncryptionKey.generate() preferences.enableSync(syncKey, encKey.base64) - } catch (e: ServiceUnavailable) { + } catch (e: Exception) { + Log.e("SyncActivity", "Unexpected exception", e) error = true } } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt index 297bf60a4..ec5773244 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt @@ -28,7 +28,7 @@ import io.ktor.client.request.* import kotlinx.coroutines.* class RemoteSyncServer( - private val baseURL: String = "https://sync.loophabits.org", + private val baseURL: String, private val httpClient: HttpClient = HttpClient(Android) { install(JsonFeature) } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt index 339394294..8893058b3 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt @@ -20,6 +20,7 @@ package org.isoron.uhabits.sync import android.content.* +import android.net.* import android.util.* import kotlinx.coroutines.* import org.isoron.androidbase.* @@ -30,82 +31,103 @@ import org.isoron.uhabits.core.tasks.* import org.isoron.uhabits.tasks.* import org.isoron.uhabits.utils.* import java.io.* -import java.lang.RuntimeException import javax.inject.* @AppScope class SyncManager @Inject constructor( val preferences: Preferences, - val importDataTaskFactory: ImportDataTaskFactory, + private val importDataTaskFactory: ImportDataTaskFactory, val commandRunner: CommandRunner, @AppContext val context: Context -) : Preferences.Listener, CommandRunner.Listener { +) : Preferences.Listener, CommandRunner.Listener, ConnectivityManager.NetworkCallback() { + + private var connected = false + + private val server = RemoteSyncServer(baseURL = preferences.syncBaseURL) - private val server = RemoteSyncServer() private val tmpFile = File.createTempFile("import", "", context.externalCacheDir) + private var currVersion = 1L + private var dirty = true + private var taskRunner = SingleThreadTaskRunner() private lateinit var encryptionKey: EncryptionKey + private lateinit var syncKey: String init { preferences.addListener(this) commandRunner.addListener(this) + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + cm.registerNetworkCallback(NetworkRequest.Builder().build(), this) } - suspend fun sync() { + fun sync() = CoroutineScope(Dispatchers.Main).launch { if (!preferences.isSyncEnabled) { Log.i("SyncManager", "Device sync is disabled. Skipping sync.") - return + return@launch } + encryptionKey = EncryptionKey.fromBase64(preferences.encryptionKey) syncKey = preferences.syncKey + Log.i("SyncManager", "Starting sync (key: $syncKey)") + try { - Log.i("SyncManager", "Starting sync (key: $syncKey)") pull() push() - Log.i("SyncManager", "Sync finished") + } catch (e: ConnectionLostException) { + Log.i("SyncManager", "Network unavailable. Aborting sync.") + } catch (e: ServiceUnavailable) { + Log.i("SyncManager", "Sync service unavailable. Aborting sync.") } catch (e: Exception) { - Log.e("SyncManager", "Unexpected sync exception. Disabling sync", e) + Log.e("SyncManager", "Unexpected sync exception. Disabling sync.", e) preferences.disableSync() } + + Log.i("SyncManager", "Sync finished successfully.") } private suspend fun push(depth: Int = 0) { - if(depth >= 5) { + if (depth >= 5) { throw RuntimeException() } - if (dirty) { - Log.i("SyncManager", "Encrypting local database...") - val db = DatabaseUtils.getDatabaseFile(context) - val encryptedDB = db.encryptToString(encryptionKey) - val size = encryptedDB.length / 1024 - Log.i("SyncManager", "Pushing local database (version $currVersion, $size KB)") - try { - server.put(preferences.syncKey, SyncData(currVersion, encryptedDB)) - dirty = false - } catch (e: EditConflictException) { - Log.i("SyncManager", "Sync conflict detected while pushing.") - setCurrentVersion(0) - pull() - push(depth = depth + 1) - } - } else { + + if (!dirty) { Log.i("SyncManager", "Local database not modified. Skipping push.") + return + } + + Log.i("SyncManager", "Encrypting local database...") + val db = DatabaseUtils.getDatabaseFile(context) + val encryptedDB = db.encryptToString(encryptionKey) + val size = encryptedDB.length / 1024 + + try { + Log.i("SyncManager", "Pushing local database (version $currVersion, $size KB)") + assertConnected() + server.put(preferences.syncKey, SyncData(currVersion, encryptedDB)) + dirty = false + } catch (e: EditConflictException) { + Log.i("SyncManager", "Sync conflict detected while pushing.") + setCurrentVersion(0) + pull() + push(depth = depth + 1) } } private suspend fun pull() { Log.i("SyncManager", "Querying remote database version...") + assertConnected() val remoteVersion = server.getDataVersion(syncKey) - Log.i("SyncManager", "Remote database has version $remoteVersion") + Log.i("SyncManager", "Remote database version: $remoteVersion") if (remoteVersion <= currVersion) { Log.i("SyncManager", "Local database is up-to-date. Skipping merge.") } else { Log.i("SyncManager", "Pulling remote database...") + assertConnected() val data = server.getData(syncKey) val size = data.content.length / 1024 Log.i("SyncManager", "Pulled remote database (version ${data.version}, $size KB)") @@ -117,29 +139,41 @@ class SyncManager @Inject constructor( } } - private fun setCurrentVersion(v: Long) { - currVersion = v - Log.i("SyncManager", "Setting local database version to $currVersion") - } + fun onResume() = sync() - suspend fun onResume() { + fun onPause() = sync() + + override fun onSyncEnabled() { + Log.i("SyncManager", "Sync enabled.") + setCurrentVersion(1) + dirty = true sync() } - suspend fun onPause() { + override fun onAvailable(network: Network) { + Log.i("SyncManager", "Network available.") + connected = true sync() } - override fun onSyncEnabled() { - CoroutineScope(Dispatchers.Main).launch { - sync() - } + override fun onLost(network: Network) { + Log.i("SyncManager", "Network unavailable.") + connected = false } override fun onCommandExecuted(command: Command?, refreshKey: Long?) { - if (!dirty) { - setCurrentVersion(currVersion + 1) - } + if (!dirty) setCurrentVersion(currVersion + 1) dirty = true } -} \ No newline at end of file + + private fun assertConnected() { + if (!connected) throw ConnectionLostException() + } + + private fun setCurrentVersion(v: Long) { + currVersion = v + Log.i("SyncManager", "Setting local database version: $currVersion") + } +} + +class ConnectionLostException : RuntimeException()