mirror of https://github.com/iSoron/uhabits.git
parent
f27a9f9103
commit
da018fc64d
@ -1,154 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.sync
|
|
||||||
|
|
||||||
import androidx.test.filters.MediumTest
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.engine.mock.MockEngine
|
|
||||||
import io.ktor.client.engine.mock.MockRequestHandleScope
|
|
||||||
import io.ktor.client.engine.mock.respond
|
|
||||||
import io.ktor.client.engine.mock.respondError
|
|
||||||
import io.ktor.client.engine.mock.respondOk
|
|
||||||
import io.ktor.client.features.json.JsonFeature
|
|
||||||
import io.ktor.client.request.HttpRequestData
|
|
||||||
import io.ktor.client.request.HttpResponseData
|
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.http.fullPath
|
|
||||||
import io.ktor.http.headersOf
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.isoron.uhabits.BaseAndroidTest
|
|
||||||
import org.isoron.uhabits.core.sync.AbstractSyncServer
|
|
||||||
import org.isoron.uhabits.core.sync.GetDataVersionResponse
|
|
||||||
import org.isoron.uhabits.core.sync.KeyNotFoundException
|
|
||||||
import org.isoron.uhabits.core.sync.RegisterReponse
|
|
||||||
import org.isoron.uhabits.core.sync.ServiceUnavailable
|
|
||||||
import org.isoron.uhabits.core.sync.SyncData
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
@MediumTest
|
|
||||||
class RemoteSyncServerTest : BaseAndroidTest() {
|
|
||||||
|
|
||||||
private val mapper = ObjectMapper()
|
|
||||||
val data = SyncData(1, "Hello world")
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun when_register_succeeds_should_return_key() = runBlocking {
|
|
||||||
val server = server("/register") {
|
|
||||||
respondWithJson(RegisterReponse("ABCDEF"))
|
|
||||||
}
|
|
||||||
assertEquals("ABCDEF", server.register())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test(expected = ServiceUnavailable::class)
|
|
||||||
fun when_register_fails_should_raise_correct_exception() = runBlocking {
|
|
||||||
val server = server("/register") {
|
|
||||||
respondError(HttpStatusCode.ServiceUnavailable)
|
|
||||||
}
|
|
||||||
server.register()
|
|
||||||
return@runBlocking
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun when_get_data_version_succeeds_should_return_version() = runBlocking {
|
|
||||||
server("/db/ABC/version") {
|
|
||||||
respondWithJson(GetDataVersionResponse(5))
|
|
||||||
}.apply {
|
|
||||||
assertEquals(5, getDataVersion("ABC"))
|
|
||||||
}
|
|
||||||
return@runBlocking
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test(expected = ServiceUnavailable::class)
|
|
||||||
fun when_get_data_version_with_server_error_should_raise_exception() = runBlocking {
|
|
||||||
server("/db/ABC/version") {
|
|
||||||
respondError(HttpStatusCode.InternalServerError)
|
|
||||||
}.apply {
|
|
||||||
getDataVersion("ABC")
|
|
||||||
}
|
|
||||||
return@runBlocking
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test(expected = KeyNotFoundException::class)
|
|
||||||
fun when_get_data_version_with_invalid_key_should_raise_exception() = runBlocking {
|
|
||||||
server("/db/ABC/version") {
|
|
||||||
respondError(HttpStatusCode.NotFound)
|
|
||||||
}.apply {
|
|
||||||
getDataVersion("ABC")
|
|
||||||
}
|
|
||||||
return@runBlocking
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun when_get_data_succeeds_should_return_data() = runBlocking {
|
|
||||||
server("/db/ABC") {
|
|
||||||
respondWithJson(data)
|
|
||||||
}.apply {
|
|
||||||
assertEquals(data, getData("ABC"))
|
|
||||||
}
|
|
||||||
return@runBlocking
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test(expected = KeyNotFoundException::class)
|
|
||||||
fun when_get_data_with_invalid_key_should_raise_exception() = runBlocking {
|
|
||||||
server("/db/ABC") {
|
|
||||||
respondError(HttpStatusCode.NotFound)
|
|
||||||
}.apply {
|
|
||||||
getData("ABC")
|
|
||||||
}
|
|
||||||
return@runBlocking
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun when_put_succeeds_should_not_raise_exceptions() = runBlocking {
|
|
||||||
server("/db/ABC") {
|
|
||||||
respondOk()
|
|
||||||
}.apply {
|
|
||||||
put("ABC", data)
|
|
||||||
}
|
|
||||||
return@runBlocking
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun server(
|
|
||||||
expectedPath: String,
|
|
||||||
action: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData
|
|
||||||
): AbstractSyncServer {
|
|
||||||
return RemoteSyncServer(
|
|
||||||
httpClient = HttpClient(MockEngine) {
|
|
||||||
install(JsonFeature)
|
|
||||||
engine {
|
|
||||||
addHandler { request ->
|
|
||||||
when (request.url.fullPath) {
|
|
||||||
expectedPath -> action(request)
|
|
||||||
else -> error("unexpected call: ${request.url.fullPath}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
preferences = prefs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun MockRequestHandleScope.respondWithJson(content: Any) =
|
|
||||||
respond(
|
|
||||||
mapper.writeValueAsBytes(content),
|
|
||||||
headers = headersOf("Content-Type" to listOf("application/json"))
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,45 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.activities.common.dialogs
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.DialogInterface
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import org.isoron.uhabits.R
|
|
||||||
import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback
|
|
||||||
import org.isoron.uhabits.inject.ActivityContext
|
|
||||||
|
|
||||||
class ConfirmSyncKeyDialog(
|
|
||||||
@ActivityContext context: Context,
|
|
||||||
callback: OnConfirmedCallback
|
|
||||||
) : AlertDialog(context) {
|
|
||||||
init {
|
|
||||||
setTitle(R.string.device_sync)
|
|
||||||
val res = context.resources
|
|
||||||
setMessage(res.getString(R.string.sync_confirm))
|
|
||||||
setButton(
|
|
||||||
BUTTON_POSITIVE,
|
|
||||||
res.getString(R.string.yes)
|
|
||||||
) { dialog: DialogInterface?, which: Int -> callback.onConfirmed() }
|
|
||||||
setButton(
|
|
||||||
BUTTON_NEGATIVE,
|
|
||||||
res.getString(R.string.no)
|
|
||||||
) { dialog: DialogInterface?, which: Int -> }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,130 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.activities.sync
|
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.text.Html
|
|
||||||
import android.view.View
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import com.google.zxing.BarcodeFormat
|
|
||||||
import com.google.zxing.qrcode.QRCodeWriter
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.invoke
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.isoron.uhabits.HabitsApplication
|
|
||||||
import org.isoron.uhabits.R
|
|
||||||
import org.isoron.uhabits.activities.AndroidThemeSwitcher
|
|
||||||
import org.isoron.uhabits.core.models.PaletteColor
|
|
||||||
import org.isoron.uhabits.core.ui.screens.sync.SyncBehavior
|
|
||||||
import org.isoron.uhabits.databinding.ActivitySyncBinding
|
|
||||||
import org.isoron.uhabits.sync.RemoteSyncServer
|
|
||||||
import org.isoron.uhabits.utils.InterfaceUtils.getFontAwesome
|
|
||||||
import org.isoron.uhabits.utils.setupToolbar
|
|
||||||
import org.isoron.uhabits.utils.showMessage
|
|
||||||
|
|
||||||
class SyncActivity : AppCompatActivity(), SyncBehavior.Screen {
|
|
||||||
|
|
||||||
private lateinit var binding: ActivitySyncBinding
|
|
||||||
private lateinit var behavior: SyncBehavior
|
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.Main)
|
|
||||||
|
|
||||||
override fun onCreate(savedInstance: Bundle?) {
|
|
||||||
super.onCreate(savedInstance)
|
|
||||||
val component = (application as HabitsApplication).component
|
|
||||||
val preferences = component.preferences
|
|
||||||
val server = RemoteSyncServer(preferences = preferences)
|
|
||||||
AndroidThemeSwitcher(this, component.preferences).apply()
|
|
||||||
|
|
||||||
behavior = SyncBehavior(this, preferences, server, component.logging)
|
|
||||||
binding = ActivitySyncBinding.inflate(layoutInflater)
|
|
||||||
binding.errorIcon.typeface = getFontAwesome(this)
|
|
||||||
binding.root.setupToolbar(
|
|
||||||
toolbar = binding.toolbar,
|
|
||||||
color = PaletteColor(11),
|
|
||||||
title = resources.getString(R.string.device_sync),
|
|
||||||
)
|
|
||||||
binding.syncLink.setOnClickListener { copyToClipboard() }
|
|
||||||
binding.instructions.text = Html.fromHtml(resources.getString(R.string.sync_instructions))
|
|
||||||
setContentView(binding.root)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
scope.launch {
|
|
||||||
behavior.onResume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun copyToClipboard() {
|
|
||||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("Loop Sync Link", binding.syncLink.text))
|
|
||||||
showMessage(resources.getString(R.string.copied_to_the_clipboard))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun generateQR(msg: String): Bitmap = Dispatchers.IO {
|
|
||||||
val writer = QRCodeWriter()
|
|
||||||
val matrix = writer.encode(msg, BarcodeFormat.QR_CODE, 1024, 1024)
|
|
||||||
val height = matrix.height
|
|
||||||
val width = matrix.width
|
|
||||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
|
|
||||||
val bgColor = Color.WHITE
|
|
||||||
val fgColor = Color.BLACK
|
|
||||||
for (x in 0 until width) {
|
|
||||||
for (y in 0 until height) {
|
|
||||||
val color = if (matrix.get(x, y)) fgColor else bgColor
|
|
||||||
bitmap.setPixel(x, y, color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return@IO bitmap
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun showQR(msg: String) {
|
|
||||||
binding.progress.visibility = View.GONE
|
|
||||||
binding.qrCode.visibility = View.VISIBLE
|
|
||||||
binding.qrCode.setImageBitmap(generateQR(msg))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun showLoadingScreen() {
|
|
||||||
binding.qrCode.visibility = View.GONE
|
|
||||||
binding.progress.visibility = View.VISIBLE
|
|
||||||
binding.errorPanel.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun showErrorScreen() {
|
|
||||||
binding.qrCode.visibility = View.GONE
|
|
||||||
binding.progress.visibility = View.GONE
|
|
||||||
binding.errorPanel.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun showLink(link: String) {
|
|
||||||
binding.qrCode.visibility = View.GONE
|
|
||||||
binding.progress.visibility = View.VISIBLE
|
|
||||||
binding.errorPanel.visibility = View.GONE
|
|
||||||
binding.syncLink.text = link
|
|
||||||
showQR(link)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,59 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.sync
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkRequest
|
|
||||||
import org.isoron.uhabits.core.sync.NetworkManager
|
|
||||||
|
|
||||||
class AndroidNetworkManager(
|
|
||||||
val context: Context,
|
|
||||||
) : NetworkManager, ConnectivityManager.NetworkCallback() {
|
|
||||||
|
|
||||||
val listeners = mutableListOf<NetworkManager.Listener>()
|
|
||||||
var connected = false
|
|
||||||
|
|
||||||
init {
|
|
||||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
cm.registerNetworkCallback(NetworkRequest.Builder().build(), this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addListener(listener: NetworkManager.Listener) {
|
|
||||||
if (connected) listener.onNetworkAvailable()
|
|
||||||
else listener.onNetworkLost()
|
|
||||||
listeners.add(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun remoteListener(listener: NetworkManager.Listener) {
|
|
||||||
listeners.remove(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAvailable(network: Network) {
|
|
||||||
connected = true
|
|
||||||
for (l in listeners) l.onNetworkAvailable()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLost(network: Network) {
|
|
||||||
connected = false
|
|
||||||
for (l in listeners) l.onNetworkLost()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,105 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.sync
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.engine.android.Android
|
|
||||||
import io.ktor.client.features.ClientRequestException
|
|
||||||
import io.ktor.client.features.ServerResponseException
|
|
||||||
import io.ktor.client.features.json.JsonFeature
|
|
||||||
import io.ktor.client.request.get
|
|
||||||
import io.ktor.client.request.header
|
|
||||||
import io.ktor.client.request.post
|
|
||||||
import io.ktor.client.request.put
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.invoke
|
|
||||||
import org.isoron.uhabits.core.preferences.Preferences
|
|
||||||
import org.isoron.uhabits.core.sync.AbstractSyncServer
|
|
||||||
import org.isoron.uhabits.core.sync.EditConflictException
|
|
||||||
import org.isoron.uhabits.core.sync.GetDataVersionResponse
|
|
||||||
import org.isoron.uhabits.core.sync.KeyNotFoundException
|
|
||||||
import org.isoron.uhabits.core.sync.RegisterReponse
|
|
||||||
import org.isoron.uhabits.core.sync.ServiceUnavailable
|
|
||||||
import org.isoron.uhabits.core.sync.SyncData
|
|
||||||
|
|
||||||
class RemoteSyncServer(
|
|
||||||
private val preferences: Preferences,
|
|
||||||
private val httpClient: HttpClient = HttpClient(Android) {
|
|
||||||
install(JsonFeature)
|
|
||||||
}
|
|
||||||
) : AbstractSyncServer {
|
|
||||||
|
|
||||||
override suspend fun register(): String = Dispatchers.IO {
|
|
||||||
try {
|
|
||||||
val url = "${preferences.syncBaseURL}/register"
|
|
||||||
Log.i("RemoteSyncServer", "POST $url")
|
|
||||||
val response: RegisterReponse = httpClient.post(url)
|
|
||||||
return@IO response.key
|
|
||||||
} catch (e: ServerResponseException) {
|
|
||||||
throw ServiceUnavailable()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun put(key: String, newData: SyncData) = Dispatchers.IO {
|
|
||||||
try {
|
|
||||||
val url = "${preferences.syncBaseURL}/db/$key"
|
|
||||||
Log.i("RemoteSyncServer", "PUT $url")
|
|
||||||
val response: String = httpClient.put(url) {
|
|
||||||
header("Content-Type", "application/json")
|
|
||||||
body = newData
|
|
||||||
}
|
|
||||||
} catch (e: ServerResponseException) {
|
|
||||||
throw ServiceUnavailable()
|
|
||||||
} catch (e: ClientRequestException) {
|
|
||||||
Log.w("RemoteSyncServer", "ClientRequestException", e)
|
|
||||||
if (e.message!!.contains("409")) throw EditConflictException()
|
|
||||||
if (e.message!!.contains("404")) throw KeyNotFoundException()
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getData(key: String): SyncData = Dispatchers.IO {
|
|
||||||
try {
|
|
||||||
val url = "${preferences.syncBaseURL}/db/$key"
|
|
||||||
Log.i("RemoteSyncServer", "GET $url")
|
|
||||||
return@IO httpClient.get<SyncData>(url)
|
|
||||||
} catch (e: ServerResponseException) {
|
|
||||||
throw ServiceUnavailable()
|
|
||||||
} catch (e: ClientRequestException) {
|
|
||||||
Log.w("RemoteSyncServer", "ClientRequestException", e)
|
|
||||||
throw KeyNotFoundException()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getDataVersion(key: String): Long = Dispatchers.IO {
|
|
||||||
try {
|
|
||||||
val url = "${preferences.syncBaseURL}/db/$key/version"
|
|
||||||
Log.i("RemoteSyncServer", "GET $url")
|
|
||||||
val response: GetDataVersionResponse = httpClient.get(url)
|
|
||||||
return@IO response.version
|
|
||||||
} catch (e: ServerResponseException) {
|
|
||||||
throw ServiceUnavailable()
|
|
||||||
} catch (e: ClientRequestException) {
|
|
||||||
Log.w("RemoteSyncServer", "ClientRequestException", e)
|
|
||||||
throw KeyNotFoundException()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,147 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ 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/>.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:background="?attr/contrast0"
|
|
||||||
android:fitsSystemWindows="true"
|
|
||||||
android:orientation="vertical"
|
|
||||||
tools:context=".activities.habits.edit.EditHabitActivity">
|
|
||||||
|
|
||||||
<com.google.android.material.appbar.AppBarLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content">
|
|
||||||
|
|
||||||
<androidx.appcompat.widget.Toolbar
|
|
||||||
android:id="@+id/toolbar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="?attr/colorPrimary"
|
|
||||||
android:elevation="2dp"
|
|
||||||
android:gravity="end"
|
|
||||||
android:minHeight="?attr/actionBarSize"
|
|
||||||
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
|
|
||||||
app:title="@string/device_sync"
|
|
||||||
app:titleTextColor="@color/white">
|
|
||||||
|
|
||||||
</androidx.appcompat.widget.Toolbar>
|
|
||||||
|
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:paddingLeft="4dp"
|
|
||||||
android:paddingTop="8dp"
|
|
||||||
android:paddingRight="4dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:lineSpacingExtra="4sp"
|
|
||||||
android:padding="8dp"
|
|
||||||
android:paddingBottom="16dp"
|
|
||||||
android:textSize="@dimen/regularTextSize"
|
|
||||||
android:id="@+id/instructions"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Sync Link (QR) -->
|
|
||||||
<FrameLayout style="@style/FormOuterBox">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
style="@style/FormInnerBox">
|
|
||||||
<TextView
|
|
||||||
style="@style/FormLabel"
|
|
||||||
android:translationZ="0.01dp"
|
|
||||||
android:text="@string/sync_link_qr" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/errorPanel"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="200dp"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/errorIcon"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="40dp"
|
|
||||||
android:layout_margin="40dp"
|
|
||||||
android:text="@string/fa_exclamation_circle" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Error generating code. Please try again later."
|
|
||||||
/>
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/progress"
|
|
||||||
android:layout_width="50dp"
|
|
||||||
android:layout_height="200dp"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:indeterminate="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/qrCode"
|
|
||||||
android:layout_margin="10dp"
|
|
||||||
android:layout_width="200dp"
|
|
||||||
android:layout_height="200dp"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
/>
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<!-- Sync Link -->
|
|
||||||
<FrameLayout style="@style/FormOuterBox">
|
|
||||||
|
|
||||||
<LinearLayout style="@style/FormInnerBox">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
style="@style/FormLabel"
|
|
||||||
android:text="@string/sync_link" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/syncLink"
|
|
||||||
style="@style/FormInput"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:text=""
|
|
||||||
android:background="@drawable/ripple"
|
|
||||||
android:textSize="@dimen/smallTextSize"
|
|
||||||
android:layout_margin="8dp"
|
|
||||||
/>
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
@ -1,60 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
}
|
|
@ -1,142 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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())
|
|
@ -1,29 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,29 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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)
|
|
@ -1,28 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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()
|
|
@ -1,183 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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()
|
|
@ -1,66 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,70 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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())
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in new issue