diff --git a/uhabits-android/build.gradle.kts b/uhabits-android/build.gradle.kts index 4d05305d6..d6996f857 100644 --- a/uhabits-android/build.gradle.kts +++ b/uhabits-android/build.gradle.kts @@ -122,6 +122,7 @@ dependencies { implementation("androidx.legacy:legacy-preference-v14:1.0.0") implementation("androidx.legacy:legacy-support-v4:1.0.0") implementation("com.google.android.material:material:1.3.0") + implementation("com.google.zxing:core:3.4.1") implementation("com.opencsv:opencsv:5.4") implementation(project(":uhabits-core")) kapt("com.google.dagger:dagger-compiler:$daggerVersion") diff --git a/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt b/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt new file mode 100644 index 000000000..619af32b6 --- /dev/null +++ b/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2016-2021 Álinson Santos Xavier + * + * 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 . + */ + +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")) + ) +} diff --git a/uhabits-android/src/main/AndroidManifest.xml b/uhabits-android/src/main/AndroidManifest.xml index b5d06f181..141cfc414 100644 --- a/uhabits-android/src/main/AndroidManifest.xml +++ b/uhabits-android/src/main/AndroidManifest.xml @@ -22,6 +22,8 @@ + + + + + + @@ -49,6 +59,16 @@ android:exported="true" android:label="@string/main_activity_title" android:launchMode="singleTop"> + + + + + + + + * + * 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 . + */ +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 -> } + } +} diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt index b8dc0229d..6059b3cd9 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt @@ -26,10 +26,12 @@ import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.isoron.uhabits.BaseExceptionHandler import org.isoron.uhabits.HabitsApplication import org.isoron.uhabits.activities.habits.list.views.HabitCardListAdapter import org.isoron.uhabits.core.preferences.Preferences +import org.isoron.uhabits.core.sync.SyncManager import org.isoron.uhabits.core.tasks.TaskRunner import org.isoron.uhabits.core.ui.ThemeSwitcher.Companion.THEME_DARK import org.isoron.uhabits.core.utils.MidnightTimer @@ -47,6 +49,7 @@ class ListHabitsActivity : AppCompatActivity() { lateinit var screen: ListHabitsScreen lateinit var prefs: Preferences lateinit var midnightTimer: MidnightTimer + lateinit var syncManager: SyncManager private val scope = CoroutineScope(Dispatchers.Main) private lateinit var menu: ListHabitsMenu @@ -63,6 +66,7 @@ class ListHabitsActivity : AppCompatActivity() { component.themeSwitcher.apply() prefs = appComponent.preferences + syncManager = appComponent.syncManager pureBlack = prefs.isPureBlackEnabled midnightTimer = appComponent.midnightTimer rootView = component.listHabitsRootView @@ -79,6 +83,9 @@ class ListHabitsActivity : AppCompatActivity() { midnightTimer.onPause() screen.onDettached() adapter.cancelRefresh() + scope.launch { + syncManager.onPause() + } super.onPause() } @@ -87,6 +94,9 @@ class ListHabitsActivity : AppCompatActivity() { screen.onAttached() rootView.postInvalidate() midnightTimer.onResume() + scope.launch { + syncManager.onResume() + } taskRunner.run { AutoBackup(this@ListHabitsActivity).run() } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt index 5ede0ab44..6168ed781 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt @@ -22,11 +22,13 @@ package org.isoron.uhabits.activities.habits.list import android.app.Activity import android.content.Context import android.content.Intent +import android.util.Log import androidx.appcompat.app.AppCompatActivity import dagger.Lazy import org.isoron.uhabits.R import org.isoron.uhabits.activities.common.dialogs.ColorPickerDialogFactory import org.isoron.uhabits.activities.common.dialogs.ConfirmDeleteDialog +import org.isoron.uhabits.activities.common.dialogs.ConfirmSyncKeyDialog import org.isoron.uhabits.activities.common.dialogs.NumberPickerFactory import org.isoron.uhabits.activities.habits.edit.HabitTypeDialog import org.isoron.uhabits.activities.habits.list.views.HabitCardListAdapter @@ -51,6 +53,8 @@ import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior.Message import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior.Message.FILE_NOT_RECOGNIZED import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior.Message.IMPORT_FAILED import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior.Message.IMPORT_SUCCESSFUL +import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior.Message.SYNC_ENABLED +import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior.Message.SYNC_KEY_ALREADY_INSTALLED import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsMenuBehavior import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsSelectionMenuBehavior import org.isoron.uhabits.inject.ActivityContext @@ -99,6 +103,14 @@ class ListHabitsScreen fun onAttached() { commandRunner.addListener(this) + if (activity.intent.action == "android.intent.action.VIEW") { + val uri = activity.intent.data!!.toString() + val parts = uri.replace(Regex("^.*sync/"), "").split("#") + val syncKey = parts[0] + val encKey = parts[1] + Log.i("ListHabitsScreen", "sync: $syncKey enc: $encKey") + behavior.get().onSyncKeyOffer(syncKey, encKey) + } } fun onDettached() { @@ -196,6 +208,8 @@ class ListHabitsScreen DATABASE_REPAIRED -> R.string.database_repaired COULD_NOT_GENERATE_BUG_REPORT -> R.string.bug_report_failed FILE_NOT_RECOGNIZED -> R.string.file_not_recognized + SYNC_ENABLED -> R.string.sync_enabled + SYNC_KEY_ALREADY_INSTALLED -> R.string.sync_key_already_installed } ) ) @@ -230,6 +244,10 @@ class ListHabitsScreen numberPickerFactory.create(value, unit, callback).show() } + override fun showConfirmInstallSyncKey(callback: OnConfirmedCallback) { + ConfirmSyncKeyDialog(activity, callback).show() + } + private fun getExecuteString(command: Command): String? { when (command) { is ArchiveHabitsCommand -> { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.kt index cc8bcecfe..448d84c9d 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.kt @@ -19,6 +19,7 @@ package org.isoron.uhabits.activities.settings import android.app.backup.BackupManager +import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.SharedPreferences.OnSharedPreferenceChangeListener @@ -27,6 +28,7 @@ import android.os.Build.VERSION import android.os.Bundle import android.provider.Settings import android.util.Log +import androidx.preference.CheckBoxPreference import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceCategory @@ -41,6 +43,7 @@ import org.isoron.uhabits.activities.habits.list.RESULT_REPAIR_DB import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.ui.NotificationTray import org.isoron.uhabits.core.utils.DateUtils.Companion.getLongWeekdayNames +import org.isoron.uhabits.intents.IntentFactory import org.isoron.uhabits.notifications.AndroidNotificationTray.Companion.createAndroidNotificationChannel import org.isoron.uhabits.notifications.RingtoneManager import org.isoron.uhabits.widgets.WidgetUpdater @@ -97,6 +100,13 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis intent.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationTray.REMINDERS_CHANNEL_ID) startActivity(intent) return true + } else if (key == "pref_sync_enabled_dummy") { + if (prefs.isSyncEnabled) { + prefs.disableSync() + } else { + val context: Context? = activity + context!!.startActivity(IntentFactory().startSyncActivity(context)) + } } return super.onPreferenceTreeClick(preference) } @@ -111,6 +121,7 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis devCategory.isVisible = false } updateWeekdayPreference() + updateSyncPreferences() if (VERSION.SDK_INT < Build.VERSION_CODES.O) findPreference("reminderCustomize").isVisible = false @@ -119,6 +130,12 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis } } + private fun updateSyncPreferences() { + findPreference("pref_sync_display").isVisible = prefs.isSyncEnabled + (findPreference("pref_sync_enabled_dummy") as CheckBoxPreference).isChecked = + prefs.isSyncEnabled + } + private fun updateWeekdayPreference() { val weekdayPref = findPreference("pref_first_weekday") as ListPreference val currentFirstWeekday = prefs.firstWeekday.daysSinceSunday + 1 @@ -140,6 +157,7 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis } BackupManager.dataChanged("org.isoron.uhabits") updateWeekdayPreference() + updateSyncPreferences() } private fun setResultOnPreferenceClick(key: String, result: Int) { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt new file mode 100644 index 000000000..5b84ef8c2 --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2016-2021 Álinson Santos Xavier + * + * 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 . + */ + +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) + } +} diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt index da7f957f3..49bb8d67f 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt @@ -29,6 +29,7 @@ import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.WidgetPreferences import org.isoron.uhabits.core.reminders.ReminderScheduler +import org.isoron.uhabits.core.sync.SyncManager import org.isoron.uhabits.core.tasks.TaskRunner import org.isoron.uhabits.core.ui.NotificationTray import org.isoron.uhabits.core.ui.screens.habits.list.HabitCardListCache @@ -63,4 +64,5 @@ interface HabitsApplicationComponent { val taskRunner: TaskRunner val widgetPreferences: WidgetPreferences val widgetUpdater: WidgetUpdater + val syncManager: SyncManager } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt index c7b6843d0..8a0e1c242 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt @@ -19,6 +19,7 @@ package org.isoron.uhabits.inject +import android.content.Context import dagger.Module import dagger.Provides import org.isoron.uhabits.core.AppScope @@ -33,6 +34,8 @@ import org.isoron.uhabits.core.models.sqlite.SQLiteHabitList import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.WidgetPreferences import org.isoron.uhabits.core.reminders.ReminderScheduler +import org.isoron.uhabits.core.sync.AbstractSyncServer +import org.isoron.uhabits.core.sync.NetworkManager import org.isoron.uhabits.core.tasks.TaskRunner import org.isoron.uhabits.core.ui.NotificationTray import org.isoron.uhabits.database.AndroidDatabase @@ -41,6 +44,8 @@ import org.isoron.uhabits.intents.IntentScheduler import org.isoron.uhabits.io.AndroidLogging import org.isoron.uhabits.notifications.AndroidNotificationTray import org.isoron.uhabits.preferences.SharedPreferencesStorage +import org.isoron.uhabits.sync.AndroidNetworkManager +import org.isoron.uhabits.sync.RemoteSyncServer import org.isoron.uhabits.utils.DatabaseUtils import java.io.File @@ -109,6 +114,18 @@ class HabitsModule(dbFile: File) { return AndroidLogging() } + @Provides + @AppScope + fun getNetworkManager(@AppContext context: Context): NetworkManager { + return AndroidNetworkManager(context) + } + + @Provides + @AppScope + fun getSyncServer(preferences: Preferences): AbstractSyncServer { + return RemoteSyncServer(preferences) + } + @Provides @AppScope fun getDatabase(): Database { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt index 71b2938e7..3ee368a02 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt @@ -28,6 +28,7 @@ import org.isoron.uhabits.activities.habits.edit.EditHabitActivity import org.isoron.uhabits.activities.habits.show.ShowHabitActivity import org.isoron.uhabits.activities.intro.IntroActivity import org.isoron.uhabits.activities.settings.SettingsActivity +import org.isoron.uhabits.activities.sync.SyncActivity import org.isoron.uhabits.core.models.Habit import javax.inject.Inject @@ -100,4 +101,8 @@ class IntentFactory intent.putExtra("habitType", habitType) return intent } + + fun startSyncActivity(context: Context): Intent { + return Intent(context, SyncActivity::class.java) + } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/sync/AndroidNetworkManager.kt b/uhabits-android/src/main/java/org/isoron/uhabits/sync/AndroidNetworkManager.kt new file mode 100644 index 000000000..ed40b9161 --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/sync/AndroidNetworkManager.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2016-2021 Álinson Santos Xavier + * + * 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 . + */ + +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() + 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() + } +} diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt b/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt new file mode 100644 index 000000000..4e8f4a705 --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2016-2021 Álinson Santos Xavier + * + * 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 . + */ + +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(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() + } + } +} diff --git a/uhabits-android/src/main/res/layout/activity_sync.xml b/uhabits-android/src/main/res/layout/activity_sync.xml new file mode 100644 index 000000000..5d7040d5e --- /dev/null +++ b/uhabits-android/src/main/res/layout/activity_sync.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/uhabits-android/src/main/res/values/strings.xml b/uhabits-android/src/main/res/values/strings.xml index 4418fdd65..71400de62 100644 --- a/uhabits-android/src/main/res/values/strings.xml +++ b/uhabits-android/src/main/res/values/strings.xml @@ -221,6 +221,18 @@ Decrement Enable skip days Toggle twice to add a skip instead of a checkmark. Skips keep your score unchanged and don\'t break your streak. + Device sync + When enabled, an encrypted copy of your data will be uploaded to our servers. See privacy policy. + Sync data across devices + Show device sync instructions + Instructions:
1. Install Loop in your second device.
2. Open the link below in your second device.
Important: Do not not make this information public. It gives anyone access to your data.]]>
+ Sync link + Sync link (QR code) + Password + Copied to the clipboard + + Device sync enabled + Sync key already installed Show question marks for missing data Differentiate days without data from actual lapses. To enter a lapse, toggle twice. You are now a developer diff --git a/uhabits-android/src/main/res/xml/preferences.xml b/uhabits-android/src/main/res/xml/preferences.xml index baf20e5f0..6ddeebb79 100644 --- a/uhabits-android/src/main/res/xml/preferences.xml +++ b/uhabits-android/src/main/res/xml/preferences.xml @@ -114,6 +114,30 @@ + + + + + + + + + + + diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/Preferences.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/Preferences.kt index 8fdd26af9..e739840a6 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/Preferences.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/Preferences.kt @@ -204,6 +204,27 @@ open class Preferences(private val storage: Storage) { set(value) { storage.putBoolean("pref_skip_enabled", value) } + val syncBaseURL: String + get() = storage.getString("pref_sync_base_url", "") + val syncKey: String + get() = storage.getString("pref_sync_key", "") + val encryptionKey: String + get() = storage.getString("pref_encryption_key", "") + val isSyncEnabled: Boolean + get() = storage.getBoolean("pref_sync_enabled", false) + + fun enableSync(syncKey: String, encKey: String) { + storage.putBoolean("pref_sync_enabled", true) + storage.putString("pref_sync_key", syncKey) + storage.putString("pref_encryption_key", encKey) + for (l in listeners) l.onSyncEnabled() + } + + fun disableSync() { + storage.putBoolean("pref_sync_enabled", false) + storage.putString("pref_sync_key", "") + storage.putString("pref_encryption_key", "") + } fun areQuestionMarksEnabled(): Boolean { return storage.getBoolean("pref_unknown_enabled", false) @@ -240,6 +261,7 @@ open class Preferences(private val storage: Storage) { interface Listener { fun onCheckmarkSequenceChanged() {} fun onNotificationsChanged() {} + fun onSyncEnabled() {} } interface Storage { diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/AbstractSyncServer.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/AbstractSyncServer.kt new file mode 100644 index 000000000..713c81a5e --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/AbstractSyncServer.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2016-2021 Álinson Santos Xavier + * + * 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 . + */ + +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 +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/EncryptionExt.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/EncryptionExt.kt new file mode 100644 index 000000000..2134c9908 --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/EncryptionExt.kt @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2016-2021 Álinson Santos Xavier + * + * 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 . + */ + +@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()) diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/NetworkManager.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/NetworkManager.kt new file mode 100644 index 000000000..9ba68ff8d --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/NetworkManager.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2016-2021 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.core.sync + +interface NetworkManager { + fun addListener(listener: Listener) + fun remoteListener(listener: Listener) + interface Listener { + fun onNetworkAvailable() + fun onNetworkLost() + } +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/SyncData.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/SyncData.kt new file mode 100644 index 000000000..3910ce775 --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/SyncData.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2016-2021 Álinson Santos Xavier + * + * 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 . + */ + +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) diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/SyncException.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/SyncException.kt new file mode 100644 index 000000000..407880566 --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/SyncException.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2016-2021 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.core.sync + +open class SyncException : RuntimeException() + +class KeyNotFoundException : SyncException() + +class ServiceUnavailable : SyncException() + +class EditConflictException : SyncException() diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/SyncManager.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/SyncManager.kt new file mode 100644 index 000000000..5c26969aa --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/sync/SyncManager.kt @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2016-2021 Álinson Santos Xavier + * + * 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 . + */ + +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() diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt index 89fb09af4..9877e8ad4 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt @@ -26,6 +26,7 @@ import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.tasks.ExportCSVTask import org.isoron.uhabits.core.tasks.TaskRunner +import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday import java.io.File import java.io.IOException @@ -110,6 +111,17 @@ open class ListHabitsBehavior @Inject constructor( ) } + fun onSyncKeyOffer(syncKey: String, encryptionKey: String) { + if (prefs.syncKey == syncKey) { + screen.showMessage(Message.SYNC_KEY_ALREADY_INSTALLED) + return + } + screen.showConfirmInstallSyncKey { + prefs.enableSync(syncKey, encryptionKey) + screen.showMessage(Message.SYNC_ENABLED) + } + } + enum class Message { COULD_NOT_EXPORT, IMPORT_SUCCESSFUL, @@ -117,6 +129,8 @@ open class ListHabitsBehavior @Inject constructor( DATABASE_REPAIRED, COULD_NOT_GENERATE_BUG_REPORT, FILE_NOT_RECOGNIZED, + SYNC_ENABLED, + SYNC_KEY_ALREADY_INSTALLED } interface BugReporter { @@ -147,5 +161,6 @@ open class ListHabitsBehavior @Inject constructor( fun showSendBugReportToDeveloperScreen(log: String) fun showSendFileScreen(filename: String) + fun showConfirmInstallSyncKey(callback: OnConfirmedCallback) } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/sync/SyncBehavior.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/sync/SyncBehavior.kt new file mode 100644 index 000000000..3bf80b6da --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/sync/SyncBehavior.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2016-2021 Álinson Santos Xavier + * + * 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 . + */ + +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) + } +} diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/sync/EncryptionExtTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/sync/EncryptionExtTest.kt new file mode 100644 index 000000000..3a7584f21 --- /dev/null +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/sync/EncryptionExtTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2016-2021 Álinson Santos Xavier + * + * 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 . + */ +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()) + } +}