Merge branch 'feature/sync' into dev

pull/699/head
Alinson S. Xavier 5 years ago
commit 4908709296

@ -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 {

@ -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)

@ -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

@ -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

@ -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"

@ -0,0 +1,135 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
import androidx.test.filters.*
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")))
}

@ -0,0 +1,69 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.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())
}
}

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
~
~ This file is part of Loop Habit Tracker.
@ -18,11 +17,13 @@
~ with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.isoron.uhabits">
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".HabitsApplication"
@ -41,6 +42,14 @@
android:value=".activities.habits.list.ListHabitsActivity" />
</activity>
<activity
android:name=".activities.sync.SyncActivity"
android:exported="true">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".activities.settings.SettingsActivity" />
</activity>
<meta-data
android:name="com.google.android.backup.api_key"
android:value="AEdPqrEAAAAI6aeWncbnMNo8E5GWeZ44dlc5cQ7tCROwFhOtiw" />
@ -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">
<tools:validation testUrl="https://loophabits.org/sync/123" />
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="loophabits.org"
android:pathPrefix="/sync" />
</intent-filter>
</activity>
<activity-alias
android:name=".MainActivity"
@ -58,7 +78,6 @@
android:targetActivity=".activities.habits.list.ListHabitsActivity">
<intent-filter android:label="@string/main_activity_title">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
@ -123,11 +142,12 @@
android:excludeFromRecents="true"
android:theme="@style/Theme.AppCompat.Light.Dialog">
<intent-filter>
<action android:name="org.isoron.uhabits.ACTION_SHOW_NUMERICAL_VALUE_ACTIVITY"/>
<action android:name="org.isoron.uhabits.ACTION_SHOW_NUMERICAL_VALUE_ACTIVITY" />
</intent-filter>
</activity>
<activity android:name=".notifications.SnoozeDelayPickerActivity"
<activity
android:name=".notifications.SnoozeDelayPickerActivity"
android:excludeFromRecents="true"
android:launchMode="singleInstance"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
@ -217,14 +237,14 @@
<receiver android:name=".receivers.WidgetReceiver">
<intent-filter>
<category android:name="android.intent.category.DEFAULT"/>
<action android:name="org.isoron.uhabits.ACTION_SET_NUMERICAL_VALUE"/>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="org.isoron.uhabits.ACTION_SET_NUMERICAL_VALUE" />
<data
android:host="org.isoron.uhabits"
android:scheme="content"/>
android:scheme="content" />
</intent-filter>
<intent-filter>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="org.isoron.uhabits.ACTION_TOGGLE_REPETITION" />

@ -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();
}

@ -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()

@ -0,0 +1,58 @@
/*
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.dialogs;
import android.content.*;
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) -> {});
}
}

@ -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()
}
}

@ -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<ListHabitsBehavior>,
@ -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) {

@ -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)

@ -0,0 +1,179 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.sync
import android.content.*
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)
}
})
}
}

@ -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)
}
}

@ -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)
}

@ -0,0 +1,60 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
interface AbstractSyncServer {
/**
* Generates and returns a new sync key, which can be used to store and retrive
* data.
*
* @throws ServiceUnavailable If key cannot be generated at this time, for example,
* due to insufficient server resources, temporary server maintenance or network problems.
*/
suspend fun register(): String
/**
* Replaces data for a given sync key.
*
* @throws KeyNotFoundException If key is not found
* @throws EditConflictException If the version of the data provided is not
* exactly the current data version plus one.
* @throws ServiceUnavailable If data cannot be put at this time, for example, due
* to insufficient server resources or network problems.
*/
suspend fun put(key: String, newData: SyncData)
/**
* Returns data for a given sync key.
*
* @throws KeyNotFoundException If key is not found
* @throws ServiceUnavailable If data cannot be retrieved at this time, for example, due
* to insufficient server resources or network problems.
*/
suspend fun getData(key: String): SyncData
/**
* Returns the current data version for the given key
*
* @throws KeyNotFoundException If key is not found
* @throws ServiceUnavailable If data cannot be retrieved at this time, for example, due
* to insufficient server resources or network problems.
*/
suspend fun getDataVersion(key: String): Long
}

@ -0,0 +1,85 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
import android.util.*
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()
}
}
}

@ -0,0 +1,29 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
data class SyncData(
val version: Long,
val content: String
)
data class RegisterReponse(val key: String)
data class GetDataVersionResponse(val version: Long)

@ -0,0 +1,28 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
open class SyncException: RuntimeException()
class KeyNotFoundException: SyncException()
class ServiceUnavailable: SyncException()
class EditConflictException: SyncException()

@ -0,0 +1,178 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
import android.content.*
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()

@ -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();

@ -0,0 +1,133 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@file:Suppress("UnstableApiUsage")
package org.isoron.uhabits.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)

@ -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)

@ -0,0 +1,149 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/highContrastReverseTextColor"
android:fitsSystemWindows="true"
android:orientation="vertical"
tools:context=".activities.habits.edit.EditHabitActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorPrimary"
android:elevation="2dp"
android:gravity="end"
android:minHeight="?attr/actionBarSize"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:title="@string/device_sync"
app:titleTextColor="@color/white">
</androidx.appcompat.widget.Toolbar>
</com.google.android.material.appbar.AppBarLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="4dp"
android:paddingTop="8dp"
android:paddingRight="4dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:lineSpacingExtra="4sp"
android:padding="8dp"
android:paddingBottom="16dp"
android:textSize="@dimen/regularTextSize"
android:id="@+id/instructions"
/>
<!-- Sync Link (QR) -->
<FrameLayout style="@style/FormOuterBox">
<LinearLayout
style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:translationZ="0.01dp"
android:text="@string/sync_link_qr" />
<LinearLayout
android:id="@+id/errorPanel"
android:layout_width="match_parent"
android:layout_height="200dp"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/errorIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="40dp"
android:layout_margin="40dp"
android:text="@string/fa_exclamation_circle" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Error generating code. Please try again later."
/>
</LinearLayout>
<ProgressBar
android:id="@+id/progress"
android:layout_width="50dp"
android:layout_height="200dp"
android:layout_gravity="center"
android:indeterminate="true"
/>
<ImageView
android:id="@+id/qrCode"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_gravity="center"
/>
</LinearLayout>
</FrameLayout>
<!-- Sync Link -->
<FrameLayout style="@style/FormOuterBox">
<LinearLayout style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:text="@string/sync_link" />
<TextView
android:id="@+id/syncLink"
style="@style/FormInput"
android:singleLine="true"
android:text=""
android:background="@drawable/ripple"
android:textSize="@dimen/smallTextSize"
android:layout_margin="8dp"
/>
</LinearLayout>
</FrameLayout>
<!-- Password -->
<FrameLayout
style="@style/FormOuterBox"
android:visibility="gone">
<LinearLayout style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:text="@string/password" />
<TextView
android:id="@+id/password"
style="@style/FormInput"
android:singleLine="true"
android:letterSpacing=".5"
android:textSize="24sp"
android:textAlignment="center"
android:text="" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
</ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -27,6 +27,7 @@
<string name="translateURL">http://translate.loophabits.org/</string>
<string name="bugReportTo">dev@loophabits.org</string>
<string name="bugReportSubject">Bug Report - Loop Habit Tracker</string>
<string name="syncBaseURL" formatted="false">https://sync.loophabits.org</string>
<string-array name="snooze_interval_names">
<item>@string/interval_15_minutes</item>

@ -26,6 +26,7 @@
<string translatable="false" name="fa_skipped">&#xf068;</string>
<string translatable="false" name="fa_bell_o">&#xf0a2;</string>
<string translatable="false" name="fa_calendar">&#xf073;</string>
<string translatable="false" name="fa_exclamation_circle">&#xf06a;</string>
<string translatable="false" name="fa_question">&#xf128;</string>
<!--<string translatable="false" name="fa_glass">&#xf000;</string>-->
@ -124,7 +125,6 @@
<!--<string translatable="false" name="fa_plus">&#xf067;</string>-->
<!--<string translatable="false" name="fa_minus">&#xf068;</string>-->
<!--<string translatable="false" name="fa_asterisk">&#xf069;</string>-->
<!--<string translatable="false" name="fa_exclamation_circle">&#xf06a;</string>-->
<!--<string translatable="false" name="fa_gift">&#xf06b;</string>-->
<!--<string translatable="false" name="fa_leaf">&#xf06c;</string>-->
<!--<string translatable="false" name="fa_fire">&#xf06d;</string>-->

@ -203,6 +203,18 @@
<string name="decrement">Decrement</string>
<string name="pref_skip_title">Enable skip days</string>
<string name="pref_skip_description">Toggle twice to add a skip instead of a checkmark. Skips keep your score unchanged and don\'t break your streak.</string>
<string name="device_sync">Device sync</string>
<string name="pref_sync_summary">When enabled, an encrypted copy of your data will be uploaded to our servers. See privacy policy.</string>
<string name="pref_sync_title">Sync data across devices</string>
<string name="display_sync_code">Show device sync instructions</string>
<string name="sync_instructions"><![CDATA[<b>Instructions:</b><br/>1. Install Loop in your second device.<br/>2. Open the link below in your second device.<br/><b>Important:</b> Do not not make this information public. It gives anyone access to your data.]]></string>
<string name="sync_link">Sync link</string>
<string name="sync_link_qr">Sync link (QR code)</string>
<string name="password">Password</string>
<string name="copied_to_the_clipboard">Copied to the clipboard</string>
<string name="sync_confirm"><![CDATA[Are you trying to enable device sync?\n\nThis feature allows you to sync your data across multiple devices. When enabled, an encrypted copy of your data will be uploaded to our servers.]]></string>
<string name="sync_enabled">Device sync enabled</string>
<string name="sync_key_already_installed">Sync key already installed</string>
<string name="pref_unknown_title">Show question marks for missing data</string>
<string name="pref_unknown_description">Differentiate days without data from actual lapses. To enter a lapse, toggle twice.</string>
</resources>

@ -117,6 +117,30 @@
</PreferenceCategory>
<PreferenceCategory
android:key="deviceSync"
android:title="@string/device_sync" >
<CheckBoxPreference
android:defaultValue="false"
android:key="pref_sync_enabled_dummy"
android:summary="@string/pref_sync_summary"
android:title="@string/pref_sync_title"
app:iconSpaceReserved="false" />
<Preference
android:key="pref_sync_display"
android:title="@string/display_sync_code"
app:iconSpaceReserved="false">
<intent
android:action="android.intent.action.VIEW"
android:targetClass="org.isoron.uhabits.activities.sync.SyncActivity"
android:targetPackage="org.isoron.uhabits" />
</Preference>
</PreferenceCategory>
<PreferenceCategory
android:key="databaseCategory"
android:title="@string/database">
@ -204,6 +228,27 @@
android:title="Enable widget stacks"
app:iconSpaceReserved="false" />
<EditTextPreference
android:defaultValue="@string/syncBaseURL"
android:key="pref_sync_base_url"
android:title="Sync server"
app:iconSpaceReserved="false"
/>
<EditTextPreference
android:defaultValue=""
android:key="pref_sync_key"
android:title="Sync key"
app:iconSpaceReserved="false"
/>
<EditTextPreference
android:defaultValue=""
android:key="pref_encryption_key"
android:title="Encryption key"
app:iconSpaceReserved="false"
/>
</PreferenceCategory>
</PreferenceScreen>

@ -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;
}

@ -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()
{

@ -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);
}
}

@ -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<HabitRecord> records = habitsRepository.findAll("order by position");
for (HabitRecord habitRecord : records)
{
Habit h = modelFactory.buildHabit();
habitRecord.copyTo(h);
h.setId(null);
habitList.add(h);
List<RepetitionRecord> 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();
}
}

@ -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();
}
}

@ -83,6 +83,15 @@ public abstract class HabitList implements Iterable<Habit>
@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.
*

@ -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)

@ -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)

@ -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)
{

@ -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()
{
}
}

@ -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;

@ -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)

@ -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);
}
}

@ -0,0 +1,2 @@
alter table habits add column uuid text;
update habits set uuid = lower(hex(randomblob(16) || id));

@ -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)

@ -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));

@ -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: <null>, 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));
}

@ -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
{

7
server/.gitignore vendored

@ -0,0 +1,7 @@
/.gradle
/.idea
/out
/build
*.iml
*.ipr
*.iws

@ -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"]

@ -0,0 +1,84 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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'
}

@ -0,0 +1,23 @@
#
# Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
#
# This file is part of Loop Habit Tracker.
#
# Loop Habit Tracker is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# Loop Habit Tracker is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
ktor_version=1.4.1
kotlin.code.style=official
kotlin_version=1.4.10
logback_version=1.2.1

Binary file not shown.

@ -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

191
server/gradlew vendored

@ -0,0 +1,191 @@
#!/usr/bin/env sh
#
# Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
#
# This file is part of Loop Habit Tracker.
#
# Loop Habit Tracker is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# Loop Habit Tracker is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
##
## 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" "$@"

84
server/gradlew.bat vendored

@ -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

@ -0,0 +1,9 @@
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [ org.isoron.uhabits.sync.app.SyncApplicationKt.main ]
}
}

@ -0,0 +1,31 @@
<!--
~ Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
~
~ This file is part of Loop Habit Tracker.
~
~ Loop Habit Tracker is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by the
~ Free Software Foundation, either version 3 of the License, or (at your
~ option) any later version.
~
~ Loop Habit Tracker is distributed in the hope that it will be useful, but
~ WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
~ or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
~ more details.
~
~ You should have received a copy of the GNU General Public License along
~ with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="trace">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>

@ -0,0 +1,20 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
rootProject.name = "uhabits-server"

@ -0,0 +1,35 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
import 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)

@ -0,0 +1,28 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
open class SyncException: RuntimeException()
class KeyNotFoundException: SyncException()
class ServiceUnavailable: SyncException()
class EditConflictException: SyncException()

@ -0,0 +1,37 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.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)
}
}
}

@ -0,0 +1,62 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.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<SyncData>()
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)
}
}
}
}

@ -0,0 +1,51 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.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)
}
}
}

@ -0,0 +1,73 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.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")
}
}

@ -0,0 +1,46 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.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
}

@ -0,0 +1,62 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.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
}

@ -0,0 +1,77 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.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
}
}
}

@ -0,0 +1,60 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
import 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<EditConflictException> {
server.put(key, data2)
}
assertFailsWith<KeyNotFoundException> {
server.getData("INVALID")
}
assertFailsWith<KeyNotFoundException> {
server.put("INVALID", data0)
}
}
}

@ -0,0 +1,35 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.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()
}
}
}

@ -0,0 +1,50 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.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())
}
}
}

@ -0,0 +1,110 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.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)
}
}

@ -0,0 +1,53 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@file:Suppress("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))
}
}
Loading…
Cancel
Save