Revert "Temporarily remove device sync"

This reverts commit da018fc64d.
This commit is contained in:
2021-04-17 19:54:09 -05:00
parent 59a4d7552c
commit 7f1a1add8c
26 changed files with 1411 additions and 0 deletions

View File

@@ -204,6 +204,27 @@ open class Preferences(private val storage: Storage) {
set(value) {
storage.putBoolean("pref_skip_enabled", value)
}
val syncBaseURL: String
get() = storage.getString("pref_sync_base_url", "")
val syncKey: String
get() = storage.getString("pref_sync_key", "")
val encryptionKey: String
get() = storage.getString("pref_encryption_key", "")
val isSyncEnabled: Boolean
get() = storage.getBoolean("pref_sync_enabled", false)
fun enableSync(syncKey: String, encKey: String) {
storage.putBoolean("pref_sync_enabled", true)
storage.putString("pref_sync_key", syncKey)
storage.putString("pref_encryption_key", encKey)
for (l in listeners) l.onSyncEnabled()
}
fun disableSync() {
storage.putBoolean("pref_sync_enabled", false)
storage.putString("pref_sync_key", "")
storage.putString("pref_encryption_key", "")
}
fun areQuestionMarksEnabled(): Boolean {
return storage.getBoolean("pref_unknown_enabled", false)
@@ -240,6 +261,7 @@ open class Preferences(private val storage: Storage) {
interface Listener {
fun onCheckmarkSequenceChanged() {}
fun onNotificationsChanged() {}
fun onSyncEnabled() {}
}
interface Storage {

View File

@@ -0,0 +1,60 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker 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.
*
* Loop Habit Tracker 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 <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.sync
interface AbstractSyncServer {
/**
* Generates and returns a new sync key, which can be used to store and retrive
* data.
*
* @throws ServiceUnavailable If key cannot be generated at this time, for example,
* due to insufficient server resources, temporary server maintenance or network problems.
*/
suspend fun register(): String
/**
* Replaces data for a given sync key.
*
* @throws KeyNotFoundException If key is not found
* @throws EditConflictException If the version of the data provided is not
* exactly the current data version plus one.
* @throws ServiceUnavailable If data cannot be put at this time, for example, due
* to insufficient server resources or network problems.
*/
suspend fun put(key: String, newData: SyncData)
/**
* Returns data for a given sync key.
*
* @throws KeyNotFoundException If key is not found
* @throws ServiceUnavailable If data cannot be retrieved at this time, for example, due
* to insufficient server resources or network problems.
*/
suspend fun getData(key: String): SyncData
/**
* Returns the current data version for the given key
*
* @throws KeyNotFoundException If key is not found
* @throws ServiceUnavailable If data cannot be retrieved at this time, for example, due
* to insufficient server resources or network problems.
*/
suspend fun getDataVersion(key: String): Long
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker 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.
*
* Loop Habit Tracker 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 <http://www.gnu.org/licenses/>.
*/
@file:Suppress("UnstableApiUsage")
package org.isoron.uhabits.core.sync
import com.google.common.io.ByteStreams
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.invoke
import org.apache.commons.codec.binary.Base64
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Encryption key which can be used with [File.encryptToString], [String.decryptToFile],
* [ByteArray.encrypt] and [ByteArray.decrypt].
*
* To randomly generate a new key, use [EncryptionKey.generate]. To load a key from a
* Base64-encoded string, use [EncryptionKey.fromBase64].
*/
class EncryptionKey private constructor(
val base64: String,
val secretKey: SecretKey
) {
companion object {
fun fromBase64(base64: String): EncryptionKey {
val keySpec = SecretKeySpec(base64.decodeBase64(), "AES")
return EncryptionKey(base64, keySpec)
}
private fun fromSecretKey(spec: SecretKey): EncryptionKey {
val base64 = spec.encoded.encodeBase64().trim()
return EncryptionKey(base64, spec)
}
suspend fun generate(): EncryptionKey = Dispatchers.IO {
try {
val generator = KeyGenerator.getInstance("AES").apply { init(256) }
return@IO fromSecretKey(generator.generateKey())
} catch (e: Exception) {
throw RuntimeException(e)
}
}
}
}
/**
* Encrypts the byte stream using the provided symmetric encryption key.
*
* The initialization vector (16 bytes) is prepended to the cipher text. To decrypt the result, use
* [ByteArray.decrypt], providing the same key.
*/
fun ByteArray.encrypt(key: EncryptionKey): ByteArray {
val cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING")
cipher.init(Cipher.ENCRYPT_MODE, key.secretKey)
val encrypted = cipher.doFinal(this)
return ByteBuffer
.allocate(16 + encrypted.size)
.put(cipher.iv)
.put(encrypted)
.array()
}
/**
* Decrypts a byte stream generated by [ByteArray.encrypt].
*/
fun ByteArray.decrypt(key: EncryptionKey): ByteArray {
val buffer = ByteBuffer.wrap(this)
val iv = ByteArray(16)
buffer.get(iv)
val encrypted = ByteArray(buffer.remaining())
buffer.get(encrypted)
val cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING")
cipher.init(Cipher.DECRYPT_MODE, key.secretKey, IvParameterSpec(iv))
return cipher.doFinal(encrypted)
}
/**
* Takes a string produced by [File.encryptToString], decodes it with Base64, decompresses it with
* gzip, decrypts it with the provided key, then writes the output to the specified file.
*/
fun String.decryptToFile(key: EncryptionKey, output: File) {
val bytes = this.decodeBase64().decrypt(key)
ByteArrayInputStream(bytes).use { bytesInputStream ->
GZIPInputStream(bytesInputStream).use { gzipInputStream ->
FileOutputStream(output).use { fileOutputStream ->
ByteStreams.copy(gzipInputStream, fileOutputStream)
}
}
}
}
/**
* Compresses the file with gzip, encrypts it using the the provided key, then returns a string
* containing the Base64-encoded cipher bytes.
*
* To decrypt and decompress the cipher text back into a file, use [String.decryptToFile].
*/
fun File.encryptToString(key: EncryptionKey): String {
ByteArrayOutputStream().use { bytesOutputStream ->
FileInputStream(this).use { inputStream ->
GZIPOutputStream(bytesOutputStream).use { gzipOutputStream ->
ByteStreams.copy(inputStream, gzipOutputStream)
gzipOutputStream.close()
val bytes = bytesOutputStream.toByteArray()
return bytes.encrypt(key).encodeBase64()
}
}
}
}
fun ByteArray.encodeBase64(): String = Base64.encodeBase64(this).decodeToString()
fun String.decodeBase64(): ByteArray = Base64.decodeBase64(this.toByteArray())

View File

@@ -0,0 +1,29 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker 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.
*
* Loop Habit Tracker 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 <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.sync
interface NetworkManager {
fun addListener(listener: Listener)
fun remoteListener(listener: Listener)
interface Listener {
fun onNetworkAvailable()
fun onNetworkLost()
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker 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.
*
* Loop Habit Tracker 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 <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.sync
data class SyncData(
val version: Long,
val content: String
)
data class RegisterReponse(val key: String)
data class GetDataVersionResponse(val version: Long)

View File

@@ -0,0 +1,28 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker 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.
*
* Loop Habit Tracker 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 <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.sync
open class SyncException : RuntimeException()
class KeyNotFoundException : SyncException()
class ServiceUnavailable : SyncException()
class EditConflictException : SyncException()

View File

@@ -0,0 +1,183 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker 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.
*
* Loop Habit Tracker 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 <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.sync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.invoke
import kotlinx.coroutines.launch
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.commands.Command
import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.io.Logging
import org.isoron.uhabits.core.io.LoopDBImporter
import org.isoron.uhabits.core.preferences.Preferences
import java.io.File
import javax.inject.Inject
@AppScope
class SyncManager @Inject constructor(
val preferences: Preferences,
val commandRunner: CommandRunner,
val logging: Logging,
val networkManager: NetworkManager,
val server: AbstractSyncServer,
val db: Database,
val dbImporter: LoopDBImporter,
) : Preferences.Listener, CommandRunner.Listener, NetworkManager.Listener {
private var logger = logging.getLogger("SyncManager")
private var connected = false
private val tmpFile = File.createTempFile("import", "")
private var currVersion = 1L
private var dirty = true
private lateinit var encryptionKey: EncryptionKey
private lateinit var syncKey: String
init {
preferences.addListener(this)
commandRunner.addListener(this)
networkManager.addListener(this)
}
fun sync() = CoroutineScope(Dispatchers.Main).launch {
if (!preferences.isSyncEnabled) {
logger.info("Device sync is disabled. Skipping sync.")
return@launch
}
encryptionKey = EncryptionKey.fromBase64(preferences.encryptionKey)
syncKey = preferences.syncKey
logger.info("Starting sync (key: $syncKey)")
try {
pull()
push()
logger.info("Sync finished successfully.")
} catch (e: ConnectionLostException) {
logger.info("Network unavailable. Aborting sync.")
} catch (e: ServiceUnavailable) {
logger.info("Sync service unavailable. Aborting sync.")
} catch (e: Exception) {
logger.error("Unexpected sync exception. Disabling sync.")
logger.error(e)
preferences.disableSync()
}
}
private suspend fun push(depth: Int = 0) {
if (depth >= 5) {
throw RuntimeException()
}
if (!dirty) {
logger.info("Local database not modified. Skipping push.")
return
}
logger.info("Encrypting local database...")
val encryptedDB = db.file!!.encryptToString(encryptionKey)
val size = encryptedDB.length / 1024
try {
logger.info("Pushing local database (version $currVersion, $size KB)")
assertConnected()
server.put(preferences.syncKey, SyncData(currVersion, encryptedDB))
dirty = false
} catch (e: EditConflictException) {
logger.info("Sync conflict detected while pushing.")
setCurrentVersion(0)
pull()
push(depth = depth + 1)
}
}
private suspend fun pull() = Dispatchers.IO {
logger.info("Querying remote database version...")
assertConnected()
val remoteVersion = server.getDataVersion(syncKey)
logger.info("Remote database version: $remoteVersion")
if (remoteVersion <= currVersion) {
logger.info("Local database is up-to-date. Skipping merge.")
} else {
logger.info("Pulling remote database...")
assertConnected()
val data = server.getData(syncKey)
val size = data.content.length / 1024
logger.info("Pulled remote database (version ${data.version}, $size KB)")
logger.info("Decrypting remote database and merging with local changes...")
data.content.decryptToFile(encryptionKey, tmpFile)
try {
db.beginTransaction()
dbImporter.importHabitsFromFile(tmpFile)
db.setTransactionSuccessful()
} catch (e: Exception) {
logger.error("Failed to import database")
logger.error(e)
} finally {
db.endTransaction()
}
dirty = true
setCurrentVersion(data.version + 1)
}
}
fun onResume() = sync()
fun onPause() = sync()
override fun onSyncEnabled() {
logger.info("Sync enabled.")
setCurrentVersion(1)
dirty = true
sync()
}
override fun onNetworkAvailable() {
logger.info("Network available.")
connected = true
sync()
}
override fun onNetworkLost() {
logger.info("Network unavailable.")
connected = false
}
override fun onCommandFinished(command: Command) {
if (!dirty) setCurrentVersion(currVersion + 1)
dirty = true
}
private fun assertConnected() {
if (!connected) throw ConnectionLostException()
}
private fun setCurrentVersion(v: Long) {
currVersion = v
logger.info("Setting local database version: $currVersion")
}
}
class ConnectionLostException : RuntimeException()

View File

@@ -26,6 +26,7 @@ import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.tasks.ExportCSVTask
import org.isoron.uhabits.core.tasks.TaskRunner
import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback
import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday
import java.io.File
import java.io.IOException
@@ -110,6 +111,17 @@ open class ListHabitsBehavior @Inject constructor(
)
}
fun onSyncKeyOffer(syncKey: String, encryptionKey: String) {
if (prefs.syncKey == syncKey) {
screen.showMessage(Message.SYNC_KEY_ALREADY_INSTALLED)
return
}
screen.showConfirmInstallSyncKey {
prefs.enableSync(syncKey, encryptionKey)
screen.showMessage(Message.SYNC_ENABLED)
}
}
enum class Message {
COULD_NOT_EXPORT,
IMPORT_SUCCESSFUL,
@@ -117,6 +129,8 @@ open class ListHabitsBehavior @Inject constructor(
DATABASE_REPAIRED,
COULD_NOT_GENERATE_BUG_REPORT,
FILE_NOT_RECOGNIZED,
SYNC_ENABLED,
SYNC_KEY_ALREADY_INSTALLED
}
interface BugReporter {
@@ -147,5 +161,6 @@ open class ListHabitsBehavior @Inject constructor(
fun showSendBugReportToDeveloperScreen(log: String)
fun showSendFileScreen(filename: String)
fun showConfirmInstallSyncKey(callback: OnConfirmedCallback)
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker 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.
*
* Loop Habit Tracker 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 <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.ui.screens.sync
import org.isoron.uhabits.core.io.Logging
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.sync.AbstractSyncServer
import org.isoron.uhabits.core.sync.EncryptionKey
class SyncBehavior(
val screen: Screen,
val preferences: Preferences,
val server: AbstractSyncServer,
val logging: Logging,
) {
val logger = logging.getLogger("SyncBehavior")
suspend fun onResume() {
if (preferences.syncKey.isBlank()) {
register()
} else {
displayCurrentKey()
}
}
suspend fun displayCurrentKey() {
screen.showLink("https://loophabits.org/sync/${preferences.syncKey}#${preferences.encryptionKey}")
}
suspend fun register() {
screen.showLoadingScreen()
try {
val syncKey = server.register()
val encKey = EncryptionKey.generate()
preferences.enableSync(syncKey, encKey.base64)
displayCurrentKey()
} catch (e: Exception) {
logger.error("Unexpected exception")
logger.error(e)
screen.showErrorScreen()
}
}
interface Screen {
suspend fun showLoadingScreen()
suspend fun showErrorScreen()
suspend fun showLink(link: String)
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker 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.
*
* Loop Habit Tracker 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 <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.sync
import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.Matchers.greaterThan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThat
import org.junit.Test
import java.io.File
import java.io.PrintWriter
import java.util.Random
class EncryptionExtTest {
@Test
fun test_encode_decode() {
val original = ByteArray(5000)
Random().nextBytes(original)
val encoded = original.encodeBase64()
val decoded = encoded.decodeBase64()
assertThat(decoded, equalTo(original))
}
@Test
fun test_encrypt_decrypt_bytes() = runBlocking {
val original = ByteArray(5000)
Random().nextBytes(original)
val key = EncryptionKey.generate()
val encrypted = original.encrypt(key)
val decrypted = encrypted.decrypt(key)
assertThat(decrypted, equalTo(original))
}
@Test
fun test_encrypt_decrypt_file() = runBlocking {
val original = File.createTempFile("file", ".txt")
val writer = PrintWriter(original.outputStream())
writer.println("hello world")
writer.println("encryption test")
writer.close()
assertThat(original.length(), equalTo(28L))
val key = EncryptionKey.generate()
val encrypted = original.encryptToString(key)
assertThat(encrypted.length, greaterThan(10))
val decrypted = File.createTempFile("file", ".txt")
encrypted.decryptToFile(key, decrypted)
assertThat(decrypted.length(), equalTo(28L))
assertEquals("hello world\nencryption test\n", decrypted.readText())
}
}