mirror of
https://github.com/iSoron/uhabits.git
synced 2025-12-06 01:08:50 -06:00
Merge branch 'feature/sync' into dev
This commit is contained in:
@@ -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)
|
||||
|
||||
149
android/uhabits-android/src/main/res/layout/activity_sync.xml
Normal file
149
android/uhabits-android/src/main/res/layout/activity_sync.xml
Normal file
@@ -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"></string>
|
||||
<string translatable="false" name="fa_bell_o"></string>
|
||||
<string translatable="false" name="fa_calendar"></string>
|
||||
<string translatable="false" name="fa_exclamation_circle"></string>
|
||||
<string translatable="false" name="fa_question"></string>
|
||||
|
||||
<!--<string translatable="false" name="fa_glass"></string>-->
|
||||
@@ -124,7 +125,6 @@
|
||||
<!--<string translatable="false" name="fa_plus"></string>-->
|
||||
<!--<string translatable="false" name="fa_minus"></string>-->
|
||||
<!--<string translatable="false" name="fa_asterisk"></string>-->
|
||||
<!--<string translatable="false" name="fa_exclamation_circle"></string>-->
|
||||
<!--<string translatable="false" name="fa_gift"></string>-->
|
||||
<!--<string translatable="false" name="fa_leaf"></string>-->
|
||||
<!--<string translatable="false" name="fa_fire"></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
Normal file
7
server/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/.gradle
|
||||
/.idea
|
||||
/out
|
||||
/build
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
11
server/Dockerfile
Normal file
11
server/Dockerfile
Normal file
@@ -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"]
|
||||
84
server/build.gradle
Normal file
84
server/build.gradle
Normal file
@@ -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'
|
||||
}
|
||||
23
server/gradle.properties
Normal file
23
server/gradle.properties
Normal file
@@ -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
|
||||
BIN
server/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
server/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
server/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
server/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
Executable file
191
server/gradlew
vendored
Executable file
@@ -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
Normal file
84
server/gradlew.bat
vendored
Normal file
@@ -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
|
||||
9
server/resources/application.conf
Normal file
9
server/resources/application.conf
Normal file
@@ -0,0 +1,9 @@
|
||||
ktor {
|
||||
deployment {
|
||||
port = 8080
|
||||
port = ${?PORT}
|
||||
}
|
||||
application {
|
||||
modules = [ org.isoron.uhabits.sync.app.SyncApplicationKt.main ]
|
||||
}
|
||||
}
|
||||
31
server/resources/logback.xml
Normal file
31
server/resources/logback.xml
Normal file
@@ -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>
|
||||
20
server/settings.gradle
Normal file
20
server/settings.gradle
Normal file
@@ -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"
|
||||
35
server/src/org/isoron/uhabits/sync/SyncData.kt
Normal file
35
server/src/org/isoron/uhabits/sync/SyncData.kt
Normal file
@@ -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)
|
||||
28
server/src/org/isoron/uhabits/sync/SyncException.kt
Normal file
28
server/src/org/isoron/uhabits/sync/SyncException.kt
Normal file
@@ -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()
|
||||
37
server/src/org/isoron/uhabits/sync/app/RegistrationModule.kt
Normal file
37
server/src/org/isoron/uhabits/sync/app/RegistrationModule.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
62
server/src/org/isoron/uhabits/sync/app/StorageModule.kt
Normal file
62
server/src/org/isoron/uhabits/sync/app/StorageModule.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
server/src/org/isoron/uhabits/sync/app/SyncApplication.kt
Normal file
51
server/src/org/isoron/uhabits/sync/app/SyncApplication.kt
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
46
server/src/org/isoron/uhabits/sync/repository/Repository.kt
Normal file
46
server/src/org/isoron/uhabits/sync/repository/Repository.kt
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
110
server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt
Normal file
110
server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user