diff --git a/android/android-base/build.gradle b/android/android-base/build.gradle index f10169233..ba3cc6c54 100644 --- a/android/android-base/build.gradle +++ b/android/android-base/build.gradle @@ -7,8 +7,8 @@ android { defaultConfig { minSdkVersion MIN_SDK_VERSION as Integer targetSdkVersion TARGET_SDK_VERSION as Integer - versionCode VERSION_CODE as Integer - versionName "$VERSION_NAME" + buildConfigField 'int', 'VERSION_CODE', "$VERSION_CODE" + buildConfigField 'String', 'VERSION_NAME', "\"$VERSION_NAME\"" } compileOptions { diff --git a/android/android-base/src/main/java/org/isoron/androidbase/activities/BaseScreen.kt b/android/android-base/src/main/java/org/isoron/androidbase/activities/BaseScreen.kt index 443344e87..ab223523d 100644 --- a/android/android-base/src/main/java/org/isoron/androidbase/activities/BaseScreen.kt +++ b/android/android-base/src/main/java/org/isoron/androidbase/activities/BaseScreen.kt @@ -127,8 +127,7 @@ open class BaseScreen(@JvmField protected var activity: BaseActivity) { * * @param stringId the string resource id for this message. */ - fun showMessage(@StringRes stringId: Int?) { - val rootView = this.rootView + fun showMessage(@StringRes stringId: Int?, rootView: View?) { var snackbar = this.snackbar if (stringId == null || rootView == null) return if (snackbar == null) { @@ -142,6 +141,10 @@ open class BaseScreen(@JvmField protected var activity: BaseActivity) { snackbar.show() } + fun showMessage(@StringRes stringId: Int?) { + showMessage(stringId, this.rootView) + } + fun showSendEmailScreen(@StringRes toId: Int, @StringRes subjectId: Int, content: String?) { val to = activity.getString(toId) val subject = activity.getString(subjectId) diff --git a/android/gradle.properties b/android/gradle.properties index 65bdc8bb9..60cb81260 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,15 +1,17 @@ VERSION_CODE = 20000 -VERSION_NAME = 2.0.0 +VERSION_NAME = 2.0.0-alpha MIN_SDK_VERSION = 23 TARGET_SDK_VERSION = 29 COMPILE_SDK_VERSION = 29 DAGGER_VERSION = 2.25.4 -KOTLIN_VERSION = 1.3.61 +KOTLIN_VERSION = 1.4.0 +KX_COROUTINES_VERSION = 1.4.2 SUPPORT_LIBRARY_VERSION = 28.0.0 AUTO_FACTORY_VERSION = 1.0-beta6 -BUILD_TOOLS_VERSION = 4.0.0 +BUILD_TOOLS_VERSION = 4.1.0 +KTOR_VERSION=1.4.2 org.gradle.parallel=false org.gradle.daemon=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 84337ad35..2aa714b7c 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Sat Nov 28 09:55:24 CST 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip diff --git a/android/uhabits-android/build.gradle b/android/uhabits-android/build.gradle index 3fc63730b..8cc8882cf 100644 --- a/android/uhabits-android/build.gradle +++ b/android/uhabits-android/build.gradle @@ -93,7 +93,15 @@ dependencies { implementation "com.google.code.gson:gson:2.8.5" implementation "com.google.code.findbugs:jsr305:3.0.2" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$KOTLIN_VERSION" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$KX_COROUTINES_VERSION" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$KX_COROUTINES_VERSION" implementation "androidx.constraintlayout:constraintlayout:2.0.0-beta4" + implementation 'com.google.zxing:core:3.4.1' + implementation "io.ktor:ktor-client-core:$KTOR_VERSION" + implementation "io.ktor:ktor-client-android:$KTOR_VERSION" + implementation "io.ktor:ktor-client-json:$KTOR_VERSION" + implementation "io.ktor:ktor-client-jackson:$KTOR_VERSION" + implementation "com.google.guava:guava:30.0-android" coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1' @@ -114,7 +122,8 @@ dependencies { androidTestImplementation 'androidx.annotation:annotation:1.0.0' androidTestImplementation 'androidx.test:rules:1.1.1' androidTestImplementation 'androidx.test.ext:junit:1.1.1' - androidTestImplementation "com.google.guava:guava:24.1-android" + androidTestImplementation "io.ktor:ktor-client-mock:$KTOR_VERSION" + androidTestImplementation "io.ktor:ktor-jackson:$KTOR_VERSION" androidTestImplementation project(":uhabits-core") kaptAndroidTest "com.google.dagger:dagger-compiler:$DAGGER_VERSION" diff --git a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt new file mode 100644 index 000000000..df2e13efa --- /dev/null +++ b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2016-2020 Á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.* +import com.fasterxml.jackson.databind.* +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.client.features.json.* +import io.ktor.client.request.* +import io.ktor.http.* +import junit.framework.Assert.* +import kotlinx.coroutines.* +import org.junit.* + +@MediumTest +class RemoteSyncServerTest { + + 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}") + } + } + } + }, baseURL = "") + } + + private fun MockRequestHandleScope.respondWithJson(content: Any) = + respond(mapper.writeValueAsBytes(content), + headers = headersOf("Content-Type" to listOf("application/json"))) +} \ No newline at end of file diff --git a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt new file mode 100644 index 000000000..5569ef1f7 --- /dev/null +++ b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2016-2020 Á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.utils + +import androidx.test.filters.* +import org.hamcrest.Matchers.* +import org.isoron.uhabits.* +import org.junit.* +import org.junit.Assert.* +import java.io.* +import java.util.* + +@MediumTest +class EncryptionExtTest : BaseAndroidTest() { + + @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() { + 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() { + 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()) + } +} diff --git a/android/uhabits-android/src/main/AndroidManifest.xml b/android/uhabits-android/src/main/AndroidManifest.xml index 1a268ce57..189b8d8b6 100644 --- a/android/uhabits-android/src/main/AndroidManifest.xml +++ b/android/uhabits-android/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - - + + + + + @@ -49,7 +58,18 @@ android:name=".activities.habits.list.ListHabitsActivity" android:exported="true" android:label="@string/main_activity_title" - android:launchMode="singleTop" /> + android:launchMode="singleTop"> + + + + + + + + - @@ -123,11 +142,12 @@ android:excludeFromRecents="true" android:theme="@style/Theme.AppCompat.Light.Dialog"> - + - @@ -217,14 +237,14 @@ - - + + + android:scheme="content" /> - + diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplicationComponent.java b/android/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplicationComponent.java index cc5e55b20..f20c9254f 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplicationComponent.java +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplicationComponent.java @@ -34,6 +34,7 @@ import org.isoron.uhabits.core.ui.screens.habits.list.*; import org.isoron.uhabits.core.utils.*; import org.isoron.uhabits.intents.*; import org.isoron.uhabits.receivers.*; +import org.isoron.uhabits.sync.*; import org.isoron.uhabits.tasks.*; import org.isoron.uhabits.widgets.*; @@ -85,4 +86,6 @@ public interface HabitsApplicationComponent WidgetPreferences getWidgetPreferences(); WidgetUpdater getWidgetUpdater(); + + SyncManager getSyncManager(); } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsActivity.kt index 6b4365f04..47175d7b1 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsActivity.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsActivity.kt @@ -34,8 +34,11 @@ abstract class HabitsActivity : BaseActivity() { appComponent = (applicationContext as HabitsApplication).component - val habit = getHabitFromIntent(appComponent.habitList) - ?: appComponent.modelFactory.buildHabit() + var habit = appComponent.modelFactory.buildHabit() + if(intent.action != "android.intent.action.VIEW") { + val intentHabit = getHabitFromIntent(appComponent.habitList) + if (intentHabit != null) habit = intentHabit + } component = DaggerHabitsActivityComponent .builder() diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/ConfirmSyncKeyDialog.java b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/ConfirmSyncKeyDialog.java new file mode 100644 index 000000000..3d034269d --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/ConfirmSyncKeyDialog.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2016 Á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.common.dialogs; + +import android.content.*; + +import androidx.annotation.*; +import androidx.appcompat.app.*; + +import com.google.auto.factory.*; + +import org.isoron.androidbase.activities.*; +import org.isoron.uhabits.core.ui.callbacks.*; +import org.isoron.uhabits.R; + +import butterknife.*; + +@AutoFactory(allowSubclasses = true) +public class ConfirmSyncKeyDialog extends AlertDialog +{ + @BindString(R.string.sync_confirm) + protected String question; + + @BindString(R.string.yes) + protected String yes; + + @BindString(R.string.no) + protected String no; + + protected ConfirmSyncKeyDialog(@Provided @ActivityContext Context context, + @NonNull OnConfirmedCallback callback) + { + super(context); + ButterKnife.bind(this); + + setTitle(R.string.device_sync); + setMessage(question); + setButton(BUTTON_POSITIVE, yes, (dialog, which) -> callback.onConfirmed()); + setButton(BUTTON_NEGATIVE, no, (dialog, which) -> {}); + } +} diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt index 227aeb674..b40b71ef0 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt @@ -20,6 +20,7 @@ package org.isoron.uhabits.activities.habits.list import android.os.* +import kotlinx.coroutines.* import org.isoron.uhabits.* import org.isoron.uhabits.activities.* import org.isoron.uhabits.activities.habits.list.views.* @@ -28,6 +29,7 @@ import org.isoron.uhabits.core.tasks.* import org.isoron.uhabits.core.ui.ThemeSwitcher.* import org.isoron.uhabits.core.utils.* import org.isoron.uhabits.database.* +import org.isoron.uhabits.sync.* class ListHabitsActivity : HabitsActivity() { @@ -38,10 +40,13 @@ class ListHabitsActivity : HabitsActivity() { lateinit var screen: ListHabitsScreen lateinit var prefs: Preferences lateinit var midnightTimer: MidnightTimer + lateinit var syncManager: SyncManager + private val scope = CoroutineScope(Dispatchers.Main) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) prefs = appComponent.preferences + syncManager = appComponent.syncManager pureBlack = prefs.isPureBlackEnabled midnightTimer = appComponent.midnightTimer rootView = component.listHabitsRootView @@ -58,6 +63,9 @@ class ListHabitsActivity : HabitsActivity() { midnightTimer.onPause() screen.onDettached() adapter.cancelRefresh() + scope.launch { + syncManager.onPause() + } super.onPause() } @@ -66,14 +74,15 @@ class ListHabitsActivity : HabitsActivity() { screen.onAttached() rootView.postInvalidate() midnightTimer.onResume() + scope.launch { + syncManager.onResume() + } taskRunner.run { AutoBackup(this@ListHabitsActivity).run() } - if (prefs.theme == THEME_DARK && prefs.isPureBlackEnabled != pureBlack) { restartWithFade(ListHabitsActivity::class.java) } - super.onResume() } } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt index e4c3752a1..9bac43d59 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt @@ -21,6 +21,7 @@ package org.isoron.uhabits.activities.habits.list import android.app.* import android.content.* +import android.util.* import androidx.annotation.* import dagger.* import org.isoron.androidbase.activities.* @@ -31,7 +32,6 @@ import org.isoron.uhabits.activities.habits.edit.* import org.isoron.uhabits.activities.habits.list.views.* import org.isoron.uhabits.core.commands.* import org.isoron.uhabits.core.models.* -import org.isoron.uhabits.core.preferences.* import org.isoron.uhabits.core.tasks.* import org.isoron.uhabits.core.ui.* import org.isoron.uhabits.core.ui.callbacks.* @@ -42,13 +42,13 @@ import org.isoron.uhabits.tasks.* import java.io.* import javax.inject.* -const val RESULT_IMPORT_DATA = 1 -const val RESULT_EXPORT_CSV = 2 -const val RESULT_EXPORT_DB = 3 -const val RESULT_BUG_REPORT = 4 -const val RESULT_REPAIR_DB = 5 -const val REQUEST_OPEN_DOCUMENT = 6 -const val REQUEST_SETTINGS = 7 +const val RESULT_IMPORT_DATA = 101 +const val RESULT_EXPORT_CSV = 102 +const val RESULT_EXPORT_DB = 103 +const val RESULT_BUG_REPORT = 104 +const val RESULT_REPAIR_DB = 105 +const val REQUEST_OPEN_DOCUMENT = 106 +const val REQUEST_SETTINGS = 107 @ActivityScope class ListHabitsScreen @@ -63,6 +63,7 @@ class ListHabitsScreen private val exportDBFactory: ExportDBTaskFactory, private val importTaskFactory: ImportDataTaskFactory, private val confirmDeleteDialogFactory: ConfirmDeleteDialogFactory, + private val confirmSyncKeyDialogFactory: ConfirmSyncKeyDialogFactory, private val colorPickerFactory: ColorPickerDialogFactory, private val numberPickerFactory: NumberPickerFactory, private val behavior: Lazy, @@ -82,15 +83,23 @@ class ListHabitsScreen setMenu(menu.get()) setSelectionMenu(selectionMenu.get()) 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() { commandRunner.removeListener(this) } - override fun onCommandExecuted(command: Command, refreshKey: Long?) { - if (command.isRemote) return - showMessage(getExecuteString(command)) + override fun onCommandExecuted(command: Command?, refreshKey: Long?) { + if (command != null) + showMessage(getExecuteString(command)) } override fun onResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -171,13 +180,15 @@ class ListHabitsScreen override fun showMessage(m: ListHabitsBehavior.Message) { showMessage(when (m) { - COULD_NOT_EXPORT -> R.string.could_not_export - IMPORT_SUCCESSFUL -> R.string.habits_imported - IMPORT_FAILED -> R.string.could_not_import - DATABASE_REPAIRED -> R.string.database_repaired - COULD_NOT_GENERATE_BUG_REPORT -> R.string.bug_report_failed - FILE_NOT_RECOGNIZED -> R.string.file_not_recognized - }) + COULD_NOT_EXPORT -> R.string.could_not_export + IMPORT_SUCCESSFUL -> R.string.habits_imported + IMPORT_FAILED -> R.string.could_not_import + 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 + }) } override fun showSendBugReportToDeveloperScreen(log: String) { @@ -204,6 +215,10 @@ class ListHabitsScreen numberPickerFactory.create(value, unit, callback).show() } + override fun showConfirmInstallSyncKey(callback: OnConfirmedCallback) { + activity.showDialog(confirmSyncKeyDialogFactory.create(callback)) + } + @StringRes private fun getExecuteString(command: Command): Int? { when (command) { diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java index 7f9430b7b..2b7f00070 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java @@ -26,17 +26,15 @@ import android.os.*; import android.provider.*; import android.util.*; -import androidx.annotation.Nullable; -import androidx.preference.ListPreference; -import androidx.preference.Preference; -import androidx.preference.PreferenceCategory; -import androidx.preference.PreferenceFragmentCompat; +import androidx.annotation.*; +import androidx.preference.*; import org.isoron.uhabits.R; import org.isoron.uhabits.*; import org.isoron.uhabits.core.preferences.*; import org.isoron.uhabits.core.ui.*; import org.isoron.uhabits.core.utils.*; +import org.isoron.uhabits.intents.*; import org.isoron.uhabits.notifications.*; import org.isoron.uhabits.widgets.*; @@ -47,7 +45,7 @@ import static android.os.Build.VERSION.*; import static org.isoron.uhabits.activities.habits.list.ListHabitsScreenKt.*; public class SettingsFragment extends PreferenceFragmentCompat - implements SharedPreferences.OnSharedPreferenceChangeListener + implements SharedPreferences.OnSharedPreferenceChangeListener { private static int RINGTONE_REQUEST_CODE = 1; @@ -55,7 +53,7 @@ public class SettingsFragment extends PreferenceFragmentCompat private RingtoneManager ringtoneManager; - @Nullable + @NonNull private Preferences prefs; @Nullable @@ -93,6 +91,7 @@ public class SettingsFragment extends PreferenceFragmentCompat setResultOnPreferenceClick("exportDB", RESULT_EXPORT_DB); setResultOnPreferenceClick("repairDB", RESULT_REPAIR_DB); setResultOnPreferenceClick("bugReport", RESULT_BUG_REPORT); + } @Override @@ -129,6 +128,18 @@ public class SettingsFragment extends PreferenceFragmentCompat startActivity(intent); return true; } + else if (key.equals("pref_sync_enabled_dummy")) + { + if (prefs.isSyncEnabled()) + { + prefs.disableSync(); + } + else + { + Context context = getActivity(); + context.startActivity(new IntentFactory().startSyncActivity(context)); + } + } return super.onPreferenceTreeClick(preference); } @@ -142,24 +153,29 @@ public class SettingsFragment extends PreferenceFragmentCompat sharedPrefs = getPreferenceManager().getSharedPreferences(); sharedPrefs.registerOnSharedPreferenceChangeListener(this); - if (prefs != null && !prefs.isDeveloper()) + if (!prefs.isDeveloper()) { PreferenceCategory devCategory = - (PreferenceCategory) findPreference("devCategory"); - devCategory.removeAll(); + (PreferenceCategory) findPreference("devCategory"); devCategory.setVisible(false); } updateWeekdayPreference(); + updateSyncPreferences(); // Temporarily disable this; we now always ask findPreference("reminderSound").setVisible(false); findPreference("pref_snooze_interval").setVisible(false); } + private void updateSyncPreferences() + { + findPreference("pref_sync_display").setVisible(prefs.isSyncEnabled()); + ((CheckBoxPreference) findPreference("pref_sync_enabled_dummy")).setChecked(prefs.isSyncEnabled()); + } + private void updateWeekdayPreference() { - if (prefs == null) return; ListPreference weekdayPref = (ListPreference) findPreference("pref_first_weekday"); int currentFirstWeekday = prefs.getFirstWeekday(); String[] dayNames = DateUtils.getLongWeekdayNames(Calendar.SATURDAY); @@ -179,8 +195,10 @@ public class SettingsFragment extends PreferenceFragmentCompat Log.d("SettingsFragment", "updating widgets"); widgetUpdater.updateWidgets(); } - if (key.equals("pref_first_weekday")) updateWeekdayPreference(); + BackupManager.dataChanged("org.isoron.uhabits"); + updateWeekdayPreference(); + updateSyncPreferences(); } private void setResultOnPreferenceClick(String key, final int result) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt new file mode 100644 index 000000000..ff005c25c --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2016-2020 Á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.* +import android.content.ClipboardManager +import android.graphics.* +import android.os.* +import android.text.* +import android.util.* +import android.view.* +import com.google.zxing.* +import com.google.zxing.qrcode.* +import kotlinx.coroutines.* +import org.isoron.androidbase.activities.* +import org.isoron.androidbase.utils.* +import org.isoron.androidbase.utils.InterfaceUtils.getFontAwesome +import org.isoron.uhabits.* +import org.isoron.uhabits.core.preferences.* +import org.isoron.uhabits.core.tasks.* +import org.isoron.uhabits.databinding.* +import org.isoron.uhabits.sync.* +import org.isoron.uhabits.utils.* + + +class SyncActivity : BaseActivity() { + + private lateinit var syncManager: SyncManager + private lateinit var preferences: Preferences + private lateinit var taskRunner: TaskRunner + private lateinit var baseScreen: BaseScreen + private lateinit var binding: ActivitySyncBinding + + private var styledResources = StyledResources(this) + + override fun onCreate(state: Bundle?) { + super.onCreate(state) + + baseScreen = BaseScreen(this) + + val component = (application as HabitsApplication).component + taskRunner = component.taskRunner + preferences = component.preferences + syncManager = component.syncManager + + binding = ActivitySyncBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.errorIcon.typeface = getFontAwesome(this) + + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportActionBar?.elevation = 10.0f + + binding.instructions.setText(Html.fromHtml(resources.getString(R.string.sync_instructions))) + + binding.syncLink.setOnClickListener { + copyToClipboard() + } + } + + override fun onResume() { + super.onResume() + if(preferences.syncKey.isBlank()) { + register() + } else { + displayCurrentKey() + } + } + + private fun displayCurrentKey() { + displayLink("https://loophabits.org/sync/${preferences.syncKey}#${preferences.encryptionKey}") + displayPassword("6B2W9F5X") + } + + private fun register() { + displayLoading() + taskRunner.execute(object : Task { + private lateinit var encKey: EncryptionKey + private lateinit var syncKey: String + private var error = false + override fun doInBackground() { + runBlocking { + try { + val server = RemoteSyncServer(baseURL = preferences.syncBaseURL) + syncKey = server.register() + encKey = EncryptionKey.generate() + preferences.enableSync(syncKey, encKey.base64) + } catch (e: Exception) { + Log.e("SyncActivity", "Unexpected exception", e) + error = true + } + } + } + + override fun onPostExecute() { + if (error) { + displayError() + return; + } + displayCurrentKey() + } + }) + } + + private fun displayLoading() { + binding.qrCode.visibility = View.GONE + binding.progress.visibility = View.VISIBLE + binding.errorPanel.visibility = View.GONE + } + + private fun displayError() { + binding.qrCode.visibility = View.GONE + binding.progress.visibility = View.GONE + binding.errorPanel.visibility = View.VISIBLE + } + + private fun copyToClipboard() { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Loop Sync Link", binding.syncLink.text)) + baseScreen.showMessage(R.string.copied_to_the_clipboard, binding.root) + } + + private fun displayPassword(pin: String) { + binding.password.text = pin + } + + private fun displayLink(link: String) { + binding.qrCode.visibility = View.GONE + binding.progress.visibility = View.VISIBLE + binding.errorPanel.visibility = View.GONE + binding.syncLink.text = link + displayQR(link) + } + + private fun displayQR(msg: String) { + taskRunner.execute(object : Task { + lateinit var bitmap: Bitmap + override fun doInBackground() { + val writer = QRCodeWriter() + val matrix = writer.encode(msg, BarcodeFormat.QR_CODE, 1024, 1024) + val height = matrix.height + val width = matrix.width + bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) + val bgColor = styledResources.getColor(R.attr.highContrastReverseTextColor) + val fgColor = styledResources.getColor(R.attr.highContrastTextColor) + 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) + } + } + } + override fun onPostExecute() { + binding.progress.visibility = View.GONE + binding.qrCode.visibility = View.VISIBLE + binding.qrCode.setImageBitmap(bitmap) + } + }) + } +} \ No newline at end of file diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt index 2287a56d1..520758705 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt @@ -21,13 +21,13 @@ package org.isoron.uhabits.intents import android.content.* import android.net.* -import org.isoron.androidbase.activities.* import org.isoron.uhabits.* import org.isoron.uhabits.activities.about.* import org.isoron.uhabits.activities.habits.edit.* import org.isoron.uhabits.activities.habits.show.* import org.isoron.uhabits.activities.intro.* import org.isoron.uhabits.activities.settings.* +import org.isoron.uhabits.activities.sync.* import org.isoron.uhabits.core.models.* import javax.inject.* @@ -100,4 +100,8 @@ class IntentFactory intent.putExtra("habitType", habitType) return intent } + + fun startSyncActivity(context: Context): Intent { + return Intent(context, SyncActivity::class.java) + } } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/preferences/SharedPreferencesStorage.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/preferences/SharedPreferencesStorage.kt index 55a9defa7..b92f10385 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/preferences/SharedPreferencesStorage.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/preferences/SharedPreferencesStorage.kt @@ -87,8 +87,6 @@ class SharedPreferencesStorage preferences.setNotificationsSticky(getBoolean(key, false)) "pref_led_notifications" -> preferences.setNotificationsLed(getBoolean(key, false)) - "pref_feature_sync" -> - preferences.isSyncEnabled = getBoolean(key, false) } sharedPreferences.registerOnSharedPreferenceChangeListener(this) } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/AbstractSyncServer.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/AbstractSyncServer.kt new file mode 100644 index 000000000..070b13d0a --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/AbstractSyncServer.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2016-2020 Alinson 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 + +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 +} \ No newline at end of file diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt new file mode 100644 index 000000000..ec5773244 --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2016-2020 Á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.* +import io.ktor.client.* +import io.ktor.client.engine.android.* +import io.ktor.client.features.* +import io.ktor.client.features.json.* +import io.ktor.client.request.* +import kotlinx.coroutines.* + +class RemoteSyncServer( + private val baseURL: String, + private val httpClient: HttpClient = HttpClient(Android) { + install(JsonFeature) + } +) : AbstractSyncServer { + + override suspend fun register(): String = Dispatchers.IO { + try { + val response: RegisterReponse = httpClient.post("$baseURL/register") + return@IO response.key + } catch(e: ServerResponseException) { + throw ServiceUnavailable() + } + } + + override suspend fun put(key: String, newData: SyncData) = Dispatchers.IO { + try { + val response: String = httpClient.put("$baseURL/db/$key") { + 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 data: SyncData = httpClient.get("$baseURL/db/$key") + return@IO data + } 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 response: GetDataVersionResponse = httpClient.get("$baseURL/db/$key/version") + return@IO response.version + } catch(e: ServerResponseException) { + throw ServiceUnavailable() + } catch (e: ClientRequestException) { + Log.w("RemoteSyncServer", "ClientRequestException", e) + throw KeyNotFoundException() + } + } +} diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncData.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncData.kt new file mode 100644 index 000000000..72960909b --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncData.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2016-2020 Alinson 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 + +data class SyncData( + val version: Long, + val content: String +) + +data class RegisterReponse(val key: String) + +data class GetDataVersionResponse(val version: Long) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncException.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncException.kt new file mode 100644 index 000000000..5c5a81403 --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncException.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2016-2020 Alinson 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 + +open class SyncException: RuntimeException() + +class KeyNotFoundException: SyncException() + +class ServiceUnavailable: SyncException() + +class EditConflictException: SyncException() \ No newline at end of file diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt new file mode 100644 index 000000000..e3ada333a --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2016-2020 Á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.* +import android.net.* +import android.util.* +import kotlinx.coroutines.* +import org.isoron.androidbase.* +import org.isoron.uhabits.core.* +import org.isoron.uhabits.core.commands.* +import org.isoron.uhabits.core.preferences.* +import org.isoron.uhabits.core.tasks.* +import org.isoron.uhabits.tasks.* +import org.isoron.uhabits.utils.* +import java.io.* +import javax.inject.* + +@AppScope +class SyncManager @Inject constructor( + val preferences: Preferences, + private val importDataTaskFactory: ImportDataTaskFactory, + val commandRunner: CommandRunner, + @AppContext val context: Context +) : Preferences.Listener, CommandRunner.Listener, ConnectivityManager.NetworkCallback() { + + private var connected = false + + private val server = RemoteSyncServer(baseURL = preferences.syncBaseURL) + + private val tmpFile = File.createTempFile("import", "", context.externalCacheDir) + + private var currVersion = 1L + + private var dirty = true + + private var taskRunner = SingleThreadTaskRunner() + + private lateinit var encryptionKey: EncryptionKey + + private lateinit var syncKey: String + + init { + preferences.addListener(this) + commandRunner.addListener(this) + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + cm.registerNetworkCallback(NetworkRequest.Builder().build(), this) + } + + fun sync() = CoroutineScope(Dispatchers.Main).launch { + if (!preferences.isSyncEnabled) { + Log.i("SyncManager", "Device sync is disabled. Skipping sync.") + return@launch + } + + encryptionKey = EncryptionKey.fromBase64(preferences.encryptionKey) + syncKey = preferences.syncKey + Log.i("SyncManager", "Starting sync (key: $syncKey)") + + try { + pull() + push() + Log.i("SyncManager", "Sync finished successfully.") + } catch (e: ConnectionLostException) { + Log.i("SyncManager", "Network unavailable. Aborting sync.") + } catch (e: ServiceUnavailable) { + Log.i("SyncManager", "Sync service unavailable. Aborting sync.") + } catch (e: Exception) { + Log.e("SyncManager", "Unexpected sync exception. Disabling sync.", e) + preferences.disableSync() + } + } + + private suspend fun push(depth: Int = 0) { + if (depth >= 5) { + throw RuntimeException() + } + + if (!dirty) { + Log.i("SyncManager", "Local database not modified. Skipping push.") + return + } + + Log.i("SyncManager", "Encrypting local database...") + val db = DatabaseUtils.getDatabaseFile(context) + val encryptedDB = db.encryptToString(encryptionKey) + val size = encryptedDB.length / 1024 + + try { + Log.i("SyncManager", "Pushing local database (version $currVersion, $size KB)") + assertConnected() + server.put(preferences.syncKey, SyncData(currVersion, encryptedDB)) + dirty = false + } catch (e: EditConflictException) { + Log.i("SyncManager", "Sync conflict detected while pushing.") + setCurrentVersion(0) + pull() + push(depth = depth + 1) + } + } + + private suspend fun pull() { + Log.i("SyncManager", "Querying remote database version...") + assertConnected() + val remoteVersion = server.getDataVersion(syncKey) + Log.i("SyncManager", "Remote database version: $remoteVersion") + + if (remoteVersion <= currVersion) { + Log.i("SyncManager", "Local database is up-to-date. Skipping merge.") + } else { + Log.i("SyncManager", "Pulling remote database...") + assertConnected() + val data = server.getData(syncKey) + val size = data.content.length / 1024 + Log.i("SyncManager", "Pulled remote database (version ${data.version}, $size KB)") + Log.i("SyncManager", "Decrypting remote database and merging with local changes...") + data.content.decryptToFile(encryptionKey, tmpFile) + taskRunner.execute(importDataTaskFactory.create(tmpFile) { tmpFile.delete() }) + dirty = true + setCurrentVersion(data.version + 1) + } + } + + fun onResume() = sync() + + fun onPause() = sync() + + override fun onSyncEnabled() { + Log.i("SyncManager", "Sync enabled.") + setCurrentVersion(1) + dirty = true + sync() + } + + override fun onAvailable(network: Network) { + Log.i("SyncManager", "Network available.") + connected = true + sync() + } + + override fun onLost(network: Network) { + Log.i("SyncManager", "Network unavailable.") + connected = false + } + + override fun onCommandExecuted(command: Command?, refreshKey: Long?) { + if (!dirty) setCurrentVersion(currVersion + 1) + dirty = true + } + + private fun assertConnected() { + if (!connected) throw ConnectionLostException() + } + + private fun setCurrentVersion(v: Long) { + currVersion = v + Log.i("SyncManager", "Setting local database version: $currVersion") + } +} + +class ConnectionLostException : RuntimeException() diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.java b/android/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.java index 6740e2ab4..db56a3857 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.java +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.java @@ -19,6 +19,8 @@ package org.isoron.uhabits.tasks; +import android.util.*; + import androidx.annotation.NonNull; import com.google.auto.factory.*; @@ -83,7 +85,7 @@ public class ImportDataTask implements Task catch (Exception e) { result = FAILED; - e.printStackTrace(); + Log.e("ImportDataTask", "Import failed", e); } modelFactory.db.endTransaction(); diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt new file mode 100644 index 000000000..86f0cce44 --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2016-2020 Á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.utils + +import android.util.* +import com.google.common.io.* +import java.io.* +import java.nio.* +import java.util.zip.* +import javax.crypto.* +import javax.crypto.spec.* + +/** + * 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) + } + + fun generate(): EncryptionKey { + try { + val generator = KeyGenerator.getInstance("AES").apply { init(256) } + return 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.encodeToString(this, Base64.DEFAULT) +fun String.decodeBase64(): ByteArray = Base64.decode(this, Base64.DEFAULT) + diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/WidgetUpdater.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/WidgetUpdater.kt index 3427bce26..c28cfa68f 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/WidgetUpdater.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/WidgetUpdater.kt @@ -28,6 +28,7 @@ import org.isoron.uhabits.core.tasks.* import org.isoron.uhabits.core.utils.* import org.isoron.uhabits.intents.* import javax.inject.* +import kotlin.math.* /** * A WidgetUpdater listens to the commands being executed by the application and @@ -42,7 +43,9 @@ class WidgetUpdater private val intentScheduler: IntentScheduler ) : CommandRunner.Listener { - override fun onCommandExecuted(command: Command, refreshKey: Long?) { + private var lastUpdated = 0L + + override fun onCommandExecuted(command: Command?, refreshKey: Long?) { updateWidgets(refreshKey) } @@ -69,6 +72,10 @@ class WidgetUpdater } fun updateWidgets(modifiedHabitId: Long?) { + val now = DateUtils.getLocalTime() + if (abs(now - lastUpdated) < 60_000) return + lastUpdated = now + taskRunner.execute { updateWidgets(modifiedHabitId, CheckmarkWidgetProvider::class.java) updateWidgets(modifiedHabitId, HistoryWidgetProvider::class.java) diff --git a/android/uhabits-android/src/main/res/layout/activity_sync.xml b/android/uhabits-android/src/main/res/layout/activity_sync.xml new file mode 100644 index 000000000..67416d582 --- /dev/null +++ b/android/uhabits-android/src/main/res/layout/activity_sync.xml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/uhabits-android/src/main/res/values/constants.xml b/android/uhabits-android/src/main/res/values/constants.xml index c034ad08b..3756855f4 100644 --- a/android/uhabits-android/src/main/res/values/constants.xml +++ b/android/uhabits-android/src/main/res/values/constants.xml @@ -27,6 +27,7 @@ http://translate.loophabits.org/ dev@loophabits.org Bug Report - Loop Habit Tracker + https://sync.loophabits.org @string/interval_15_minutes diff --git a/android/uhabits-android/src/main/res/values/fontawesome.xml b/android/uhabits-android/src/main/res/values/fontawesome.xml index f12f99732..b648a188c 100644 --- a/android/uhabits-android/src/main/res/values/fontawesome.xml +++ b/android/uhabits-android/src/main/res/values/fontawesome.xml @@ -26,6 +26,7 @@ + @@ -124,7 +125,6 @@ - diff --git a/android/uhabits-android/src/main/res/values/strings.xml b/android/uhabits-android/src/main/res/values/strings.xml index 3ed35315c..a7c7cf397 100644 --- a/android/uhabits-android/src/main/res/values/strings.xml +++ b/android/uhabits-android/src/main/res/values/strings.xml @@ -203,6 +203,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. \ No newline at end of file diff --git a/android/uhabits-android/src/main/res/xml/preferences.xml b/android/uhabits-android/src/main/res/xml/preferences.xml index 183447762..0f1e2d654 100644 --- a/android/uhabits-android/src/main/res/xml/preferences.xml +++ b/android/uhabits-android/src/main/res/xml/preferences.xml @@ -117,6 +117,30 @@ + + + + + + + + + + + @@ -204,6 +228,27 @@ android:title="Enable widget stacks" app:iconSpaceReserved="false" /> + + + + + + \ No newline at end of file diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/Config.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/Config.java index 286755a33..1a5383cee 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/Config.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/Config.java @@ -22,5 +22,5 @@ package org.isoron.uhabits.core; public class Config { public static final String DATABASE_FILENAME = "uhabits.db"; - public static int DATABASE_VERSION = 23; + public static int DATABASE_VERSION = 24; } diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/Command.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/Command.java index f8d7feb16..37f1d9254 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/Command.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/Command.java @@ -38,18 +38,14 @@ public abstract class Command { private String id; - private boolean isRemote; - public Command() { id = StringUtils.getRandomId(); - isRemote = false; } public Command(String id) { this.id = id; - isRemote = false; } public abstract void execute(); @@ -64,16 +60,6 @@ public abstract class Command this.id = id; } - public boolean isRemote() - { - return isRemote; - } - - public void setRemote(boolean remote) - { - isRemote = remote; - } - @NonNull public String toJson() { diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/CommandRunner.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/CommandRunner.java index a70500c87..5e46ebe32 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/CommandRunner.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/CommandRunner.java @@ -66,12 +66,17 @@ public class CommandRunner @Override public void onPostExecute() { - for (Listener l : listeners) - l.onCommandExecuted(command, refreshKey); + notifyListeners(command, refreshKey); } }); } + public void notifyListeners(Command command, Long refreshKey) + { + for (Listener l : listeners) + l.onCommandExecuted(command, refreshKey); + } + public void removeListener(Listener l) { listeners.remove(l); @@ -83,7 +88,7 @@ public class CommandRunner */ public interface Listener { - void onCommandExecuted(@NonNull Command command, + void onCommandExecuted(@Nullable Command command, @Nullable Long refreshKey); } } diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java index d5a2e0671..9cbb81f0b 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java @@ -21,6 +21,7 @@ package org.isoron.uhabits.core.io; import androidx.annotation.*; +import org.isoron.uhabits.core.commands.*; import org.isoron.uhabits.core.database.*; import org.isoron.uhabits.core.models.*; import org.isoron.uhabits.core.models.sqlite.records.*; @@ -42,15 +43,19 @@ public class LoopDBImporter extends AbstractImporter @NonNull private final DatabaseOpener opener; + @NonNull + private final CommandRunner runner; @Inject public LoopDBImporter(@NonNull HabitList habitList, @NonNull ModelFactory modelFactory, - @NonNull DatabaseOpener opener) + @NonNull DatabaseOpener opener, + @NonNull CommandRunner runner) { super(habitList); this.modelFactory = modelFactory; this.opener = opener; + this.runner = runner; } @Override @@ -85,7 +90,6 @@ public class LoopDBImporter extends AbstractImporter @Override public synchronized void importHabitsFromFile(@NonNull File file) - throws IOException { Database db = opener.open(file); MigrationHelper helper = new MigrationHelper(db); @@ -96,20 +100,43 @@ public class LoopDBImporter extends AbstractImporter habitsRepository = new Repository<>(HabitRecord.class, db); repsRepository = new Repository<>(RepetitionRecord.class, db); - for (HabitRecord habitRecord : habitsRepository.findAll( - "order by position")) + List records = habitsRepository.findAll("order by position"); + for (HabitRecord habitRecord : records) { - Habit h = modelFactory.buildHabit(); - habitRecord.copyTo(h); - h.setId(null); - habitList.add(h); - List reps = - repsRepository.findAll("where habit = ?", - habitRecord.id.toString()); + repsRepository.findAll("where habit = ?", + habitRecord.id.toString()); + + Habit habit = habitList.getByUUID(habitRecord.uuid); + if (habit == null) + { + habit = modelFactory.buildHabit(); + habitRecord.id = null; + habitRecord.copyTo(habit); + new CreateHabitCommand(modelFactory, habitList, habit).execute(); + } + else + { + Habit modified = modelFactory.buildHabit(); + habitRecord.id = habit.id; + habitRecord.copyTo(modified); + if (!modified.getData().equals(habit.getData())) + new EditHabitCommand(modelFactory, habitList, habit, modified).execute(); + } + + // Reload saved version of the habit + habit = habitList.getByUUID(habitRecord.uuid); for (RepetitionRecord r : reps) - h.getRepetitions().setValue(new Timestamp(r.timestamp), r.value); + { + Timestamp t = new Timestamp(r.timestamp); + Repetition rep = habit.getRepetitions().getByTimestamp(t); + if(rep == null || rep.getValue() != r.value) + new CreateRepetitionCommand(habitList, habit, t, r.value).execute(); + } } + + runner.notifyListeners(null, null); + db.close(); } } diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/Habit.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/Habit.java index 19a0f5119..2c3ff86d4 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/Habit.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/Habit.java @@ -356,14 +356,27 @@ public class Habit } @NonNull - public String getQuestion() { + public String getQuestion() + { return data.question; } - public void setQuestion(@NonNull String question) { + public void setQuestion(@NonNull String question) + { data.question = question; } + @NonNull + public String getUUID() + { + return data.uuid; + } + + public void setUUID(@NonNull String uuid) + { + data.uuid = uuid; + } + public static final class HabitData { @NonNull @@ -388,6 +401,8 @@ public class Habit public int type; + public String uuid; + @NonNull public String unit; @@ -409,6 +424,7 @@ public class Habit this.targetValue = 100; this.unit = ""; this.position = 0; + this.uuid = UUID.randomUUID().toString().replace("-", ""); } public HabitData(@NonNull HabitData model) @@ -425,6 +441,7 @@ public class Habit this.unit = model.unit; this.reminder = model.reminder; this.position = model.position; + this.uuid = model.uuid; } @Override @@ -443,6 +460,7 @@ public class Habit .append("reminder", reminder) .append("position", position) .append("question", question) + .append("uuid", uuid) .toString(); } @@ -468,6 +486,7 @@ public class Habit .append(reminder, habitData.reminder) .append(position, habitData.position) .append(question, habitData.question) + .append(uuid, habitData.uuid) .isEquals(); } @@ -487,6 +506,7 @@ public class Habit .append(reminder) .append(position) .append(question) + .append(uuid) .toHashCode(); } } diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/HabitList.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/HabitList.java index 8f19258d0..47b3a1cef 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/HabitList.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/HabitList.java @@ -83,6 +83,15 @@ public abstract class HabitList implements Iterable @Nullable public abstract Habit getById(long id); + /** + * Returns the habit with specified UUID. + * + * @param uuid the UUID of the habit + * @return the habit, or null if none exist + */ + @Nullable + public abstract Habit getByUUID(String uuid); + /** * Returns the habit that occupies a certain position. * diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/memory/MemoryHabitList.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/memory/MemoryHabitList.java index 84716e771..503d5278d 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/memory/MemoryHabitList.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/memory/MemoryHabitList.java @@ -94,6 +94,13 @@ public class MemoryHabitList extends HabitList return null; } + @Override + public synchronized Habit getByUUID(String uuid) + { + for (Habit h : list) if (h.getUUID().equals(uuid)) return h; + return null; + } + @NonNull @Override public synchronized Habit getByPosition(int position) diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.java index 1ba429bfd..f940c436a 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.java @@ -98,6 +98,14 @@ public class SQLiteHabitList extends HabitList return list.getById(id); } + @Override + @Nullable + public synchronized Habit getByUUID(String uuid) + { + loadRecords(); + return list.getByUUID(uuid); + } + @Override @NonNull public synchronized Habit getByPosition(int position) diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.java index 523da0542..93f9a331b 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.java @@ -81,6 +81,9 @@ public class HabitRecord @Column public Long id; + @Column + public String uuid; + public void copyFrom(Habit model) { this.id = model.getId(); @@ -95,6 +98,7 @@ public class HabitRecord this.unit = model.getUnit(); this.position = model.getPosition(); this.question = model.getQuestion(); + this.uuid = model.getUUID(); Frequency freq = model.getFrequency(); this.freqNum = freq.getNumerator(); @@ -126,6 +130,7 @@ public class HabitRecord habit.setTargetValue(this.targetValue); habit.setUnit(this.unit); habit.setPosition(this.position); + habit.setUUID(this.uuid); if (reminderHour != null && reminderMin != null) { diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java index eb64168fd..3f3688e06 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java @@ -29,10 +29,6 @@ import java.util.*; public class Preferences { - - public static final String DEFAULT_SYNC_SERVER = - "https://sync.loophabits.org"; - @NonNull private final Storage storage; @@ -130,16 +126,6 @@ public class Preferences else return new Timestamp(unixTime); } - public long getLastSync() - { - return storage.getLong("last_sync", 0); - } - - public void setLastSync(long timestamp) - { - storage.putLong("last_sync", timestamp); - } - public boolean getShowArchived() { return storage.getBoolean("pref_show_archived", false); @@ -170,39 +156,6 @@ public class Preferences storage.putString("pref_snooze_interval", String.valueOf(interval)); } - public String getSyncAddress() - { - return storage.getString("pref_sync_address", DEFAULT_SYNC_SERVER); - } - - public void setSyncAddress(String address) - { - storage.putString("pref_sync_address", address); - for (Listener l : listeners) l.onSyncFeatureChanged(); - } - - public String getSyncClientId() - { - String id = storage.getString("pref_sync_client_id", ""); - if (!id.isEmpty()) return id; - - id = UUID.randomUUID().toString(); - storage.putString("pref_sync_client_id", id); - - return id; - } - - public String getSyncKey() - { - return storage.getString("pref_sync_key", ""); - } - - public void setSyncKey(String key) - { - storage.putString("pref_sync_key", key); - for (Listener l : listeners) l.onSyncFeatureChanged(); - } - public int getTheme() { return storage.getInt("pref_theme", ThemeSwitcher.THEME_AUTOMATIC); @@ -263,17 +216,6 @@ public class Preferences storage.putBoolean("pref_short_toggle", enabled); } - public boolean isSyncEnabled() - { - return storage.getBoolean("pref_feature_sync", false); - } - - public void setSyncEnabled(boolean isEnabled) - { - storage.putBoolean("pref_feature_sync", isEnabled); - for (Listener l : listeners) l.onSyncFeatureChanged(); - } - public boolean isWidgetStackEnabled() { return storage.getBoolean("pref_feature_widget_stack", false); @@ -367,6 +309,41 @@ public class Preferences storage.putBoolean("pref_skip_enabled", value); } + public String getSyncBaseURL() + { + return storage.getString("pref_sync_base_url", ""); + } + + public String getSyncKey() + { + return storage.getString("pref_sync_key", ""); + } + + public String getEncryptionKey() + { + return storage.getString("pref_encryption_key", ""); + } + + public boolean isSyncEnabled() + { + return storage.getBoolean("pref_sync_enabled", false); + } + + public void enableSync(String syncKey, String encKey) + { + storage.putBoolean("pref_sync_enabled", true); + storage.putString("pref_sync_key", syncKey); + storage.putString("pref_encryption_key", encKey); + for (Listener l : listeners) l.onSyncEnabled(); + } + + public void disableSync() + { + storage.putBoolean("pref_sync_enabled", false); + storage.putString("pref_sync_key", ""); + storage.putString("pref_encryption_key", ""); + } + public boolean areQuestionMarksEnabled() { return storage.getBoolean("pref_unknown_enabled", false); @@ -395,7 +372,7 @@ public class Preferences { } - default void onSyncFeatureChanged() + default void onSyncEnabled() { } } diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/reminders/ReminderScheduler.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/reminders/ReminderScheduler.java index 35fa3140b..89f2b7154 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/reminders/ReminderScheduler.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/reminders/ReminderScheduler.java @@ -56,7 +56,7 @@ public class ReminderScheduler implements CommandRunner.Listener } @Override - public synchronized void onCommandExecuted(@NonNull Command command, + public synchronized void onCommandExecuted(@Nullable Command command, @Nullable Long refreshKey) { if (command instanceof CreateRepetitionCommand) return; diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/NotificationTray.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/NotificationTray.java index f41b57e43..266b84a74 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/NotificationTray.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/NotificationTray.java @@ -73,7 +73,7 @@ public class NotificationTray } @Override - public void onCommandExecuted(@NonNull Command command, + public void onCommandExecuted(@Nullable Command command, @Nullable Long refreshKey) { if (command instanceof CreateRepetitionCommand) diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java index 870ad65f3..719f9d895 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java @@ -25,7 +25,9 @@ import org.isoron.uhabits.core.commands.*; import org.isoron.uhabits.core.models.*; import org.isoron.uhabits.core.preferences.*; import org.isoron.uhabits.core.tasks.*; +import org.isoron.uhabits.core.ui.callbacks.*; import org.isoron.uhabits.core.utils.*; +import org.jetbrains.annotations.*; import java.io.*; import java.util.*; @@ -156,10 +158,22 @@ public class ListHabitsBehavior habit.getId()); } + public void onSyncKeyOffer(@NotNull String syncKey, @NotNull String encryptionKey) + { + if(prefs.getSyncKey().equals(syncKey)) { + screen.showMessage(Message.SYNC_KEY_ALREADY_INSTALLED); + return; + } + screen.showConfirmInstallSyncKey(() -> { + prefs.enableSync(syncKey, encryptionKey); + screen.showMessage(Message.SYNC_ENABLED); + }); + } + public enum Message { COULD_NOT_EXPORT, IMPORT_SUCCESSFUL, IMPORT_FAILED, DATABASE_REPAIRED, - COULD_NOT_GENERATE_BUG_REPORT, FILE_NOT_RECOGNIZED + COULD_NOT_GENERATE_BUG_REPORT, FILE_NOT_RECOGNIZED, SYNC_ENABLED, SYNC_KEY_ALREADY_INSTALLED } public interface BugReporter @@ -196,5 +210,7 @@ public class ListHabitsBehavior void showSendBugReportToDeveloperScreen(String log); void showSendFileScreen(@NonNull String filename); + + void showConfirmInstallSyncKey(@NonNull OnConfirmedCallback callback); } } diff --git a/android/uhabits-core/src/main/resources/migrations/24.sql b/android/uhabits-core/src/main/resources/migrations/24.sql new file mode 100644 index 000000000..a8c13d6c8 --- /dev/null +++ b/android/uhabits-core/src/main/resources/migrations/24.sql @@ -0,0 +1,2 @@ +alter table habits add column uuid text; +update habits set uuid = lower(hex(randomblob(16) || id)); \ No newline at end of file diff --git a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/BaseUnitTest.java b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/BaseUnitTest.java index c377d3187..7e9d6e813 100644 --- a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/BaseUnitTest.java +++ b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/BaseUnitTest.java @@ -127,7 +127,7 @@ public class BaseUnitTest DriverManager.getConnection("jdbc:sqlite::memory:")); db.execute("pragma user_version=8;"); MigrationHelper helper = new MigrationHelper(db); - helper.migrateTo(23); + helper.migrateTo(Config.DATABASE_VERSION); return db; } catch (SQLException e) diff --git a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/io/ImportTest.java b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/io/ImportTest.java index 6b1cf540a..9092f127a 100644 --- a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/io/ImportTest.java +++ b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/io/ImportTest.java @@ -134,7 +134,7 @@ public class ImportTest extends BaseUnitTest assertTrue(file.canRead()); GenericImporter importer = new GenericImporter(habitList, - new LoopDBImporter(habitList, modelFactory, databaseOpener), + new LoopDBImporter(habitList, modelFactory, databaseOpener, commandRunner), new RewireDBImporter(habitList, modelFactory, databaseOpener), new TickmateDBImporter(habitList, modelFactory, databaseOpener), new HabitBullCSVImporter(habitList, modelFactory)); diff --git a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/models/HabitTest.java b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/models/HabitTest.java index 327e8856a..226b0a00e 100644 --- a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/models/HabitTest.java +++ b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/models/HabitTest.java @@ -148,6 +148,7 @@ public class HabitTest extends BaseUnitTest public void testToString() throws Exception { Habit h = modelFactory.buildHabit(); + h.setUUID("nnnn"); h.setReminder(new Reminder(22, 30, WeekdayList.EVERY_DAY)); String expected = "{id: , data: {name: , description: ," + " frequency: {numerator: 3, denominator: 7}," + @@ -155,7 +156,7 @@ public class HabitTest extends BaseUnitTest " targetValue: 100.0, type: 0, unit: ," + " reminder: {hour: 22, minute: 30," + " days: {weekdays: [true,true,true,true,true,true,true]}}," + - " position: 0, question: }}"; + " position: 0, question: , uuid: nnnn}}"; assertThat(h.toString(), equalTo(expected)); } diff --git a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/preferences/PreferencesTest.java b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/preferences/PreferencesTest.java index 409a9fa9e..a58abd868 100644 --- a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/preferences/PreferencesTest.java +++ b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/preferences/PreferencesTest.java @@ -109,37 +109,6 @@ public class PreferencesTest extends BaseUnitTest assertThat(prefs.getLastHintTimestamp(), equalTo(Timestamp.ZERO.plus(100))); } - @Test - public void testSync() throws Exception - { - assertThat(prefs.getLastSync(), equalTo(0L)); - prefs.setLastSync(100); - assertThat(prefs.getLastSync(), equalTo(100L)); - - assertThat(prefs.getSyncAddress(), - equalTo(Preferences.DEFAULT_SYNC_SERVER)); - prefs.setSyncAddress("example"); - assertThat(prefs.getSyncAddress(), equalTo("example")); - verify(listener).onSyncFeatureChanged(); - reset(listener); - - assertThat(prefs.getSyncKey(), equalTo("")); - prefs.setSyncKey("123"); - assertThat(prefs.getSyncKey(), equalTo("123")); - verify(listener).onSyncFeatureChanged(); - reset(listener); - - assertFalse(prefs.isSyncEnabled()); - prefs.setSyncEnabled(true); - assertTrue(prefs.isSyncEnabled()); - verify(listener).onSyncFeatureChanged(); - reset(listener); - - String id = prefs.getSyncClientId(); - assertFalse(id.isEmpty()); - assertThat(prefs.getSyncClientId(), equalTo(id)); - } - @Test public void testTheme() throws Exception { diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 000000000..fae6b746e --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,7 @@ +/.gradle +/.idea +/out +/build +*.iml +*.ipr +*.iws diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 000000000..34d77779a --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,11 @@ +FROM openjdk:8-jre-alpine +RUN mkdir /app +COPY uhabits-server.jar /app/uhabits-server.jar +ENV LOOP_REPO_PATH /data/ +WORKDIR /app +CMD ["java", \ + "-server", \ + "-XX:MaxGCPauseMillis=100", \ + "-XX:+UseStringDeduplication", \ + "-jar", \ + "uhabits-server.jar"] \ No newline at end of file diff --git a/server/build.gradle b/server/build.gradle new file mode 100644 index 000000000..b7f118308 --- /dev/null +++ b/server/build.gradle @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2016-2020 Alinson 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 . + */ + +buildscript { + repositories { + jcenter() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "com.github.jengelman.gradle.plugins:shadow:5.2.0" + classpath "com.palantir.gradle.docker:gradle-docker:0.25.0" + } +} + +apply plugin: 'kotlin' +apply plugin: "com.github.johnrengelman.shadow" +apply plugin: 'application' +apply plugin: "com.palantir.docker" +apply plugin: "com.palantir.docker-run" + +group 'org.isoron.uhabits' +version '0.0.1' +mainClassName = "io.ktor.server.netty.EngineMain" + +sourceSets { + main.kotlin.srcDirs = main.java.srcDirs = ['src'] + test.kotlin.srcDirs = test.java.srcDirs = ['test'] + main.resources.srcDirs = ['resources'] + test.resources.srcDirs = ['testresources'] +} + +repositories { + mavenLocal() + jcenter() + maven { url 'https://kotlin.bintray.com/ktor' } + maven { url 'https://kotlin.bintray.com/kotlin-js-wrappers' } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + implementation "io.ktor:ktor-server-netty:$ktor_version" + implementation "ch.qos.logback:logback-classic:$logback_version" + implementation "io.ktor:ktor-server-core:$ktor_version" + implementation "io.ktor:ktor-html-builder:$ktor_version" + implementation "io.ktor:ktor-jackson:$ktor_version" + implementation "org.jetbrains:kotlin-css-jvm:1.0.0-pre.31-kotlin-1.2.41" + testImplementation "io.ktor:ktor-server-tests:$ktor_version" + testImplementation "org.mockito:mockito-core:2.+" +} + +shadowJar { + baseName = 'uhabits-server' + classifier = null + version = null +} + +docker { + name = "docker.axavier.org/uhabits-server:$version" + files "build/libs/uhabits-server.jar" +} + +dockerRun { + name = 'uhabits-server' + image "uhabits-server:$version" + ports '8080:8080' + arguments '--restart=always' +} \ No newline at end of file diff --git a/server/gradle.properties b/server/gradle.properties new file mode 100644 index 000000000..6ea1a269c --- /dev/null +++ b/server/gradle.properties @@ -0,0 +1,23 @@ +# +# Copyright (C) 2016-2020 Alinson 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 . +# + +ktor_version=1.4.1 +kotlin.code.style=official +kotlin_version=1.4.10 +logback_version=1.2.1 diff --git a/server/gradle/wrapper/gradle-wrapper.jar b/server/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..28861d273 Binary files /dev/null and b/server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/server/gradle/wrapper/gradle-wrapper.properties b/server/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..33682bbbf --- /dev/null +++ b/server/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.6.1-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/server/gradlew b/server/gradlew new file mode 100755 index 000000000..beb887fbf --- /dev/null +++ b/server/gradlew @@ -0,0 +1,191 @@ +#!/usr/bin/env sh + +# +# Copyright (C) 2016-2020 Alinson 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 . +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/server/gradlew.bat b/server/gradlew.bat new file mode 100644 index 000000000..f9553162f --- /dev/null +++ b/server/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/server/resources/application.conf b/server/resources/application.conf new file mode 100644 index 000000000..d5b282277 --- /dev/null +++ b/server/resources/application.conf @@ -0,0 +1,9 @@ +ktor { + deployment { + port = 8080 + port = ${?PORT} + } + application { + modules = [ org.isoron.uhabits.sync.app.SyncApplicationKt.main ] + } +} diff --git a/server/resources/logback.xml b/server/resources/logback.xml new file mode 100644 index 000000000..04028d5de --- /dev/null +++ b/server/resources/logback.xml @@ -0,0 +1,31 @@ + + + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/server/settings.gradle b/server/settings.gradle new file mode 100644 index 000000000..78f29ed87 --- /dev/null +++ b/server/settings.gradle @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2016-2020 Alinson 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 . + */ + +rootProject.name = "uhabits-server" diff --git a/server/src/org/isoron/uhabits/sync/SyncData.kt b/server/src/org/isoron/uhabits/sync/SyncData.kt new file mode 100644 index 000000000..3b99535e0 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/SyncData.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2016-2020 Alinson 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 com.fasterxml.jackson.databind.* + +data class SyncData( + val version: Long, + val content: String +) + +data class RegisterReponse(val key: String) + +data class GetDataVersionResponse(val version: Long) + +val defaultMapper = ObjectMapper() +fun SyncData.toJson(): String = defaultMapper.writeValueAsString(this) +fun GetDataVersionResponse.toJson(): String = defaultMapper.writeValueAsString(this) \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/SyncException.kt b/server/src/org/isoron/uhabits/sync/SyncException.kt new file mode 100644 index 000000000..5c5a81403 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/SyncException.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2016-2020 Alinson 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 + +open class SyncException: RuntimeException() + +class KeyNotFoundException: SyncException() + +class ServiceUnavailable: SyncException() + +class EditConflictException: SyncException() \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/app/RegistrationModule.kt b/server/src/org/isoron/uhabits/sync/app/RegistrationModule.kt new file mode 100644 index 000000000..c2a3a30e8 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/app/RegistrationModule.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2016-2020 Alinson 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.app + +import io.ktor.application.* +import io.ktor.http.* +import io.ktor.response.* +import io.ktor.routing.* +import org.isoron.uhabits.sync.* + +fun Routing.registration(app: SyncApplication) { + post("/register") { + try { + val key = app.server.register() + call.respond(HttpStatusCode.OK, RegisterReponse(key)) + } catch (e: ServiceUnavailable) { + call.respond(HttpStatusCode.ServiceUnavailable) + } + } +} diff --git a/server/src/org/isoron/uhabits/sync/app/StorageModule.kt b/server/src/org/isoron/uhabits/sync/app/StorageModule.kt new file mode 100644 index 000000000..d1f6718e9 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/app/StorageModule.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2016-2020 Alinson 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.app + +import io.ktor.application.* +import io.ktor.http.* +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.* +import org.isoron.uhabits.sync.* + +fun Routing.storage(app: SyncApplication) { + route("/db/{key}") { + get { + val key = call.parameters["key"]!! + try { + val data = app.server.getData(key) + call.respond(HttpStatusCode.OK, data) + } catch(e: KeyNotFoundException) { + call.respond(HttpStatusCode.NotFound) + } + } + put { + val key = call.parameters["key"]!! + val data = call.receive() + try { + app.server.put(key, data) + call.respond(HttpStatusCode.OK) + } catch (e: KeyNotFoundException) { + call.respond(HttpStatusCode.NotFound) + } catch (e: EditConflictException) { + call.respond(HttpStatusCode.Conflict) + } + } + get("version") { + val key = call.parameters["key"]!! + try { + val version = app.server.getDataVersion(key) + call.respond(HttpStatusCode.OK, GetDataVersionResponse(version)) + } catch(e: KeyNotFoundException) { + call.respond(HttpStatusCode.NotFound) + } + } + } +} \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt b/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt new file mode 100644 index 000000000..16fc71106 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2016-2020 Alinson 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.app + +import io.ktor.application.* +import io.ktor.features.* +import io.ktor.jackson.* +import io.ktor.routing.* +import org.isoron.uhabits.sync.* +import org.isoron.uhabits.sync.repository.* +import org.isoron.uhabits.sync.server.* +import java.nio.file.* + +fun Application.main() = SyncApplication().apply { main() } + +val REPOSITORY_PATH: Path = Paths.get(System.getenv("LOOP_REPO_PATH")!!) + +class SyncApplication( + val server: AbstractSyncServer = RepositorySyncServer( + FileRepository(REPOSITORY_PATH), + ), +) { + fun Application.main() { + install(DefaultHeaders) + install(CallLogging) + install(ContentNegotiation) { + jackson { } + } + routing { + registration(this@SyncApplication) + storage(this@SyncApplication) + } + } +} diff --git a/server/src/org/isoron/uhabits/sync/repository/FileRepository.kt b/server/src/org/isoron/uhabits/sync/repository/FileRepository.kt new file mode 100644 index 000000000..07c793049 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/repository/FileRepository.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2016-2020 Alinson 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.repository + +import org.isoron.uhabits.sync.* +import java.io.* +import java.nio.file.* + +class FileRepository( + private val basepath: Path, +) : Repository { + + override suspend fun put(key: String, data: SyncData) { + // Create directory + val dataPath = key.toDataPath() + val dataDir = dataPath.toFile() + dataDir.mkdirs() + + // Create metadata + val metadataFile = dataPath.resolve("version").toFile() + metadataFile.outputStream().use { outputStream -> + PrintWriter(outputStream).use { printWriter -> + printWriter.print(data.version) + } + } + + // Create data file + val dataFile = dataPath.resolve("content").toFile() + dataFile.outputStream().use { outputStream -> + PrintWriter(outputStream).use { printWriter -> + printWriter.print(data.content) + } + } + } + + override suspend fun get(key: String): SyncData { + val dataPath = key.toDataPath() + val contentFile = dataPath.resolve("content").toFile() + val versionFile = dataPath.resolve("version").toFile() + if (!contentFile.exists() || !versionFile.exists()) { + throw KeyNotFoundException() + } + val version = versionFile.readText().trim().toLong() + return SyncData(version, contentFile.readText()) + } + + override suspend fun contains(key: String): Boolean { + val dataPath = key.toDataPath() + val versionFile = dataPath.resolve("version").toFile() + return versionFile.exists() + } + + private fun String.toDataPath(): Path { + return basepath.resolve("${this[0]}/${this[1]}/${this[2]}/${this[3]}/$this") + } +} \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/repository/Repository.kt b/server/src/org/isoron/uhabits/sync/repository/Repository.kt new file mode 100644 index 000000000..aee4ee535 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/repository/Repository.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016-2020 Alinson 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.repository + +import com.sun.org.apache.xpath.internal.operations.* +import org.isoron.uhabits.sync.* + +/** + * A class that knows how to store and retrieve a large number of [SyncData] items. + */ +interface Repository { + /** + * Stores a data item, under the provided key. The item can be later retrieved with [get]. + * Replaces existing items silently. + */ + suspend fun put(key: String, data: SyncData) + + /** + * Retrieves a data item that was previously stored using [put]. + * @throws KeyNotFoundException If no such key exists. + */ + suspend fun get(key: String): SyncData + + /** + * Returns true if the repository contains a given key. + */ + suspend fun contains(key: String): Boolean +} + diff --git a/server/src/org/isoron/uhabits/sync/server/AbstractSyncServer.kt b/server/src/org/isoron/uhabits/sync/server/AbstractSyncServer.kt new file mode 100644 index 000000000..3aac2ee02 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/server/AbstractSyncServer.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2016-2020 Alinson 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.server + +import org.isoron.uhabits.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/server/src/org/isoron/uhabits/sync/server/RepositorySyncServer.kt b/server/src/org/isoron/uhabits/sync/server/RepositorySyncServer.kt new file mode 100644 index 000000000..fa75b7d22 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/server/RepositorySyncServer.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2016-2020 Alinson 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.server + +import org.isoron.uhabits.sync.* +import org.isoron.uhabits.sync.repository.* +import java.util.* +import kotlin.streams.* + +/** + * An AbstractSyncServer that stores all data in a [Repository]. + */ +class RepositorySyncServer( + private val repo: Repository, +) : AbstractSyncServer { + + override suspend fun register(): String { + val key = generateKey() + repo.put(key, SyncData(0, "")) + return key + } + + override suspend fun put(key: String, newData: SyncData) { + if (!repo.contains(key)) { + throw KeyNotFoundException() + } + val prevData = repo.get(key) + if (newData.version != prevData.version + 1) { + throw EditConflictException() + } + repo.put(key, newData) + } + + override suspend fun getData(key: String): SyncData { + if (!repo.contains(key)) { + throw KeyNotFoundException() + } + return repo.get(key) + } + + override suspend fun getDataVersion(key: String): Long { + if (!repo.contains(key)) { + throw KeyNotFoundException() + } + return repo.get(key).version + } + + private suspend fun generateKey(): String { + val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + while (true) { + val key = Random().ints(64, 0, chars.length) + .asSequence() + .map(chars::get) + .joinToString("") + if (!repo.contains(key)) + return key + } + + } +} diff --git a/server/test/org/isoron/uhabits/sync/RepositorySyncServerTest.kt b/server/test/org/isoron/uhabits/sync/RepositorySyncServerTest.kt new file mode 100644 index 000000000..cae1291ee --- /dev/null +++ b/server/test/org/isoron/uhabits/sync/RepositorySyncServerTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2016-2020 Alinson 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 kotlinx.coroutines.* +import org.isoron.uhabits.sync.repository.* +import org.isoron.uhabits.sync.server.* +import org.junit.Test +import java.nio.file.* +import kotlin.test.* + +class RepositorySyncServerTest { + + private val tempdir = Files.createTempDirectory("db") + private val server = RepositorySyncServer(FileRepository(tempdir)) + private val key = runBlocking { server.register() } + + @Test + fun testUsage(): Unit = runBlocking { + val data0 = SyncData(0, "") + assertEquals(server.getData(key), data0) + + val data1 = SyncData(1, "Hello world") + server.put(key, data1) + assertEquals(server.getData(key), data1) + + val data2 = SyncData(2, "Hello new world") + server.put(key, data2) + assertEquals(server.getData(key), data2) + + assertFailsWith { + server.put(key, data2) + } + + assertFailsWith { + server.getData("INVALID") + } + + assertFailsWith { + server.put("INVALID", data0) + } + } +} \ No newline at end of file diff --git a/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt b/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt new file mode 100644 index 000000000..abcc5f0f5 --- /dev/null +++ b/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2016-2020 Alinson 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.app + +import io.ktor.application.* +import org.isoron.uhabits.sync.server.* +import org.mockito.Mockito.* + +open class BaseApplicationTest { + + protected val server: AbstractSyncServer = mock(AbstractSyncServer::class.java) + + protected fun app(): Application.() -> Unit = { + SyncApplication(server).apply { + main() + } + } +} diff --git a/server/test/org/isoron/uhabits/sync/app/RegistrationModuleTest.kt b/server/test/org/isoron/uhabits/sync/app/RegistrationModuleTest.kt new file mode 100644 index 000000000..cd58fc4a8 --- /dev/null +++ b/server/test/org/isoron/uhabits/sync/app/RegistrationModuleTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2016-2020 Alinson 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.app + +import io.ktor.http.* +import io.ktor.server.testing.* +import kotlinx.coroutines.* +import org.isoron.uhabits.sync.* +import org.junit.Test +import org.mockito.* +import org.mockito.Mockito.* +import kotlin.test.* + +class RegistrationModuleTest : BaseApplicationTest() { + @Test + fun `when register succeeds should return generated key`():Unit = runBlocking { + `when`(server.register()).thenReturn("ABCDEF") + withTestApplication(app()) { + val call = handleRequest(HttpMethod.Post, "/register") + assertEquals(HttpStatusCode.OK, call.response.status()) + assertEquals("{\"key\":\"ABCDEF\"}", call.response.content) + } + } + + @Test + fun `when registration is unavailable should return 503`():Unit = runBlocking { + `when`(server.register()).thenThrow(ServiceUnavailable()) + withTestApplication(app()) { + val call = handleRequest(HttpMethod.Post, "/register") + assertEquals(HttpStatusCode.ServiceUnavailable, call.response.status()) + } + } +} \ No newline at end of file diff --git a/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt b/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt new file mode 100644 index 000000000..5e0aacc56 --- /dev/null +++ b/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2016-2020 Alinson 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.app + +import io.ktor.http.* +import io.ktor.server.testing.* +import kotlinx.coroutines.* +import org.isoron.uhabits.sync.* +import org.junit.Test +import org.mockito.Mockito.* +import kotlin.test.* + +class StorageModuleTest : BaseApplicationTest() { + private val data1 = SyncData(1, "Hello world") + private val data2 = SyncData(2, "Hello new world") + + @Test + fun `when get succeeds should return data`(): Unit = runBlocking { + `when`(server.getData("k1")).thenReturn(data1) + withTestApplication(app()) { + handleGet("/db/k1").apply { + assertEquals(HttpStatusCode.OK, response.status()) + assertEquals(data1.toJson(), response.content) + } + } + } + + @Test + fun `when get version succeeds should return version`(): Unit = runBlocking { + `when`(server.getDataVersion("k1")).thenReturn(30) + withTestApplication(app()) { + handleGet("/db/k1/version").apply { + assertEquals(HttpStatusCode.OK, response.status()) + assertEquals(GetDataVersionResponse(30).toJson(), response.content) + } + } + } + + @Test + fun `when get with invalid key should return 404`(): Unit = runBlocking { + `when`(server.getData("k1")).thenThrow(KeyNotFoundException()) + withTestApplication(app()) { + handleGet("/db/k1").apply { + assertEquals(HttpStatusCode.NotFound, response.status()) + } + } + } + + + @Test + fun `when put succeeds should return OK`(): Unit = runBlocking { + withTestApplication(app()) { + handlePut("/db/k1", data1).apply { + runBlocking { + assertEquals(HttpStatusCode.OK, response.status()) + verify(server).put("k1", data1) + } + } + } + } + + @Test + fun `when put with invalid key should return 404`(): Unit = runBlocking { + `when`(server.put("k1", data1)).thenThrow(KeyNotFoundException()) + withTestApplication(app()) { + handlePut("/db/k1", data1).apply { + assertEquals(HttpStatusCode.NotFound, response.status()) + } + } + } + + @Test + fun `when put with invalid version should return 409 and current data`(): Unit = runBlocking { + `when`(server.put("k1", data1)).thenThrow(EditConflictException()) + `when`(server.getData("k1")).thenReturn(data2) + withTestApplication(app()) { + handlePut("/db/k1", data1).apply { + assertEquals(HttpStatusCode.Conflict, response.status()) + } + } + } + + private fun TestApplicationEngine.handlePut(url: String, data: SyncData): TestApplicationCall { + return handleRequest(HttpMethod.Put, url) { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(data.toJson()) + } + } + + private fun TestApplicationEngine.handleGet(url: String): TestApplicationCall { + return handleRequest(HttpMethod.Get, url) + } +} diff --git a/server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt b/server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt new file mode 100644 index 000000000..047e0928e --- /dev/null +++ b/server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2016-2020 Alinson 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("BlockingMethodInNonBlockingContext") + +package org.isoron.uhabits.sync.repository + +import kotlinx.coroutines.* +import org.hamcrest.CoreMatchers.* +import org.isoron.uhabits.sync.* +import org.junit.* +import org.junit.Assert.* +import java.nio.file.* + +class FileRepositoryTest { + + @Test + fun testUsage() = runBlocking { + val tempdir = Files.createTempDirectory("db")!! + val repo = FileRepository(tempdir) + + val original = SyncData(10, "Hello world") + repo.put("abcdefg", original) + + val metaPath = tempdir.resolve("a/b/c/d/abcdefg/version") + assertTrue("$metaPath should exist", Files.exists(metaPath)) + assertEquals("10", metaPath.toFile().readText()) + + val dataPath = tempdir.resolve("a/b/c/d/abcdefg/content") + assertTrue("$dataPath should exist", Files.exists(dataPath)) + assertEquals("Hello world", dataPath.toFile().readText()) + + val retrieved = repo.get("abcdefg") + assertThat(retrieved, equalTo(original)) + } +} \ No newline at end of file