mirror of https://github.com/iSoron/uhabits.git
parent
59a4d7552c
commit
7f1a1add8c
@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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"))
|
||||
)
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 -> }
|
||||
}
|
||||
}
|
@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,147 @@
|
||||
<?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>
|
@ -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
|
||||
}
|
@ -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())
|
@ -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()
|
||||
}
|
||||
}
|
@ -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)
|
@ -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()
|
@ -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()
|
@ -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)
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
Loading…
Reference in new issue