From b4f36dd25843788db376adf350bb026d0aa1c75b Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 21 Nov 2020 20:12:14 -0600 Subject: [PATCH 01/31] Initial version of Ktor sync server --- server/.gitignore | 7 + server/build.gradle | 61 ++++++ server/gradle.properties | 23 +++ server/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 56172 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + server/gradlew | 191 ++++++++++++++++++ server/gradlew.bat | 84 ++++++++ server/resources/application.conf | 9 + server/resources/logback.xml | 31 +++ server/settings.gradle | 20 ++ .../isoron/uhabits/sync/AbstractSyncServer.kt | 48 +++++ .../isoron/uhabits/sync/MemorySyncServer.kt | 73 +++++++ .../src/org/isoron/uhabits/sync/SyncData.kt | 30 +++ .../org/isoron/uhabits/sync/SyncException.kt | 31 +++ .../uhabits/sync/app/RegistrationModule.kt | 37 ++++ .../isoron/uhabits/sync/app/StorageModule.kt | 54 +++++ .../uhabits/sync/app/SyncApplication.kt | 44 ++++ .../uhabits/sync/MemorySyncServerTest.kt | 55 +++++ .../uhabits/sync/app/BaseApplicationTest.kt | 35 ++++ .../sync/app/RegistrationModuleTest.kt | 49 +++++ .../uhabits/sync/app/StorageModuleTest.kt | 97 +++++++++ 21 files changed, 984 insertions(+) create mode 100644 server/.gitignore create mode 100644 server/build.gradle create mode 100644 server/gradle.properties create mode 100644 server/gradle/wrapper/gradle-wrapper.jar create mode 100644 server/gradle/wrapper/gradle-wrapper.properties create mode 100755 server/gradlew create mode 100644 server/gradlew.bat create mode 100644 server/resources/application.conf create mode 100644 server/resources/logback.xml create mode 100644 server/settings.gradle create mode 100644 server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt create mode 100644 server/src/org/isoron/uhabits/sync/MemorySyncServer.kt create mode 100644 server/src/org/isoron/uhabits/sync/SyncData.kt create mode 100644 server/src/org/isoron/uhabits/sync/SyncException.kt create mode 100644 server/src/org/isoron/uhabits/sync/app/RegistrationModule.kt create mode 100644 server/src/org/isoron/uhabits/sync/app/StorageModule.kt create mode 100644 server/src/org/isoron/uhabits/sync/app/SyncApplication.kt create mode 100644 server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt create mode 100644 server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt create mode 100644 server/test/org/isoron/uhabits/sync/app/RegistrationModuleTest.kt create mode 100644 server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 000000000..fae6b746e --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,7 @@ +/.gradle +/.idea +/out +/build +*.iml +*.ipr +*.iws diff --git a/server/build.gradle b/server/build.gradle new file mode 100644 index 000000000..543e5531a --- /dev/null +++ b/server/build.gradle @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +buildscript { + repositories { + jcenter() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +apply plugin: 'kotlin' +apply plugin: 'application' + +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.+" +} diff --git a/server/gradle.properties b/server/gradle.properties new file mode 100644 index 000000000..9df9c1449 --- /dev/null +++ b/server/gradle.properties @@ -0,0 +1,23 @@ +# +# Copyright (C) 2016-2020 Alinson Santos Xavier +# +# This file is part of Loop Habit Tracker. +# +# Loop Habit Tracker is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# Loop Habit Tracker is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . +# + +ktor_version=1.4.2 +kotlin.code.style=official +kotlin_version=1.4.10 +logback_version=1.2.1 diff --git a/server/gradle/wrapper/gradle-wrapper.jar b/server/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..28861d273a5d270fd8f65dd74570c17c9c507736 GIT binary patch literal 56172 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

ZM+x{iQDVZRnLYbdXrQjU&=u%hsN4|smH&B~F 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 b+=;!jU|P+J`>$CfFsoU|bwOU-ceCsYH7qU$ literal 0 HcmV?d00001 diff --git a/server/gradle/wrapper/gradle-wrapper.properties b/server/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..33682bbbf --- /dev/null +++ b/server/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.6.1-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/server/gradlew b/server/gradlew new file mode 100755 index 000000000..beb887fbf --- /dev/null +++ b/server/gradlew @@ -0,0 +1,191 @@ +#!/usr/bin/env sh + +# +# Copyright (C) 2016-2020 Alinson Santos Xavier +# +# This file is part of Loop Habit Tracker. +# +# Loop Habit Tracker is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# Loop Habit Tracker is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/server/gradlew.bat b/server/gradlew.bat new file mode 100644 index 000000000..f9553162f --- /dev/null +++ b/server/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/server/resources/application.conf b/server/resources/application.conf new file mode 100644 index 000000000..d5b282277 --- /dev/null +++ b/server/resources/application.conf @@ -0,0 +1,9 @@ +ktor { + deployment { + port = 8080 + port = ${?PORT} + } + application { + modules = [ org.isoron.uhabits.sync.app.SyncApplicationKt.main ] + } +} diff --git a/server/resources/logback.xml b/server/resources/logback.xml new file mode 100644 index 000000000..04028d5de --- /dev/null +++ b/server/resources/logback.xml @@ -0,0 +1,31 @@ + + + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/server/settings.gradle b/server/settings.gradle new file mode 100644 index 000000000..e866a88ff --- /dev/null +++ b/server/settings.gradle @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +rootProject.name = "syncserver" diff --git a/server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt b/server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt new file mode 100644 index 000000000..efaa7ed06 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync + +interface AbstractSyncServer { + /** + * Generates and returns a new sync key, which can be used to store and retrive + * data. + * + * @throws RegistrationUnavailableException If key cannot be generated at this + * time, for example, due to insufficient server resources or temporary + * maintenance. + */ + 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. + */ + fun put(key: String, newData: SyncData) + + /** + * Returns data for a given sync key. + * + * @throws KeyNotFoundException If key is not found + */ + fun get(key: String): SyncData +} \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/MemorySyncServer.kt b/server/src/org/isoron/uhabits/sync/MemorySyncServer.kt new file mode 100644 index 000000000..44798665f --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/MemorySyncServer.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync + +import java.util.* +import kotlin.streams.* + +/** + * An AbstractSyncServer that stores all data in memory. + */ +class MemorySyncServer : AbstractSyncServer { + private val db = mutableMapOf() + + override fun register(): String { + synchronized(db) { + val key = generateKey() + db[key] = SyncData(0, "") + return key + } + } + + override fun put(key: String, newData: SyncData) { + synchronized(db) { + if (!db.containsKey(key)) { + throw KeyNotFoundException() + } + val prevData = db.getValue(key) + if (newData.version != prevData.version + 1) { + throw EditConflictException() + } + db[key] = newData + } + } + + override fun get(key: String): SyncData { + synchronized(db) { + if (!db.containsKey(key)) { + throw KeyNotFoundException() + } + return db.getValue(key) + } + } + + private fun generateKey(): String { + val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + while (true) { + val key = Random().ints(64, 0, chars.length) + .asSequence() + .map(chars::get) + .joinToString("") + if (!db.containsKey(key)) + return key + } + + } +} diff --git a/server/src/org/isoron/uhabits/sync/SyncData.kt b/server/src/org/isoron/uhabits/sync/SyncData.kt new file mode 100644 index 000000000..634982ec5 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/SyncData.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync + +import com.fasterxml.jackson.databind.* + +data class SyncData( + val version: Int, + val content: String, +) + +val defaultMapper = ObjectMapper() +fun SyncData.toJson(): String = defaultMapper.writeValueAsString(this) diff --git a/server/src/org/isoron/uhabits/sync/SyncException.kt b/server/src/org/isoron/uhabits/sync/SyncException.kt new file mode 100644 index 000000000..8be49b301 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/SyncException.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync + +/** + * Generic class for all exceptions thrown by SyncServer. + */ +open class SyncException: RuntimeException() + +class KeyNotFoundException: SyncException() + +class RegistrationUnavailableException: SyncException() + +class EditConflictException: SyncException() \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/app/RegistrationModule.kt b/server/src/org/isoron/uhabits/sync/app/RegistrationModule.kt new file mode 100644 index 000000000..adc8f0e5d --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/app/RegistrationModule.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.app + +import io.ktor.application.* +import io.ktor.http.* +import io.ktor.response.* +import io.ktor.routing.* +import org.isoron.uhabits.sync.* + +fun Routing.registration(app: SyncApplication) { + post("/register") { + try { + val key = app.server.register() + call.respond(HttpStatusCode.OK, mapOf("key" to key)) + } catch (e: RegistrationUnavailableException) { + call.respond(HttpStatusCode.ServiceUnavailable) + } + } +} diff --git a/server/src/org/isoron/uhabits/sync/app/StorageModule.kt b/server/src/org/isoron/uhabits/sync/app/StorageModule.kt new file mode 100644 index 000000000..5d1a17679 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/app/StorageModule.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.app + +import io.ktor.application.* +import io.ktor.http.* +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.* +import org.isoron.uhabits.sync.* + +fun Routing.storage(app: SyncApplication) { + route("/db/{key}") { + get { + val key = call.parameters["key"]!! + try { + val data = app.server.get(key) + call.respond(HttpStatusCode.OK, data) + } catch(e: KeyNotFoundException) { + call.respond(HttpStatusCode.NotFound) + } + } + put { + val key = call.parameters["key"]!! + val data = call.receive() + try { + app.server.put(key, data) + call.respond(HttpStatusCode.OK) + } catch (e: KeyNotFoundException) { + call.respond(HttpStatusCode.NotFound) + } catch (e: EditConflictException) { + val currData = app.server.get(key) + call.respond(HttpStatusCode.Conflict, currData) + } + } + } +} \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt b/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt new file mode 100644 index 000000000..92798b58b --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.app + +import io.ktor.application.* +import io.ktor.features.* +import io.ktor.jackson.* +import io.ktor.routing.* +import org.isoron.uhabits.sync.* + +fun Application.main() = SyncApplication().apply { main() } + +class SyncApplication( + val server: AbstractSyncServer = MemorySyncServer(), +) { + fun Application.main() { + install(DefaultHeaders) + install(CallLogging) + install(ContentNegotiation) { + jackson { } + } + routing { + registration(this@SyncApplication) + storage(this@SyncApplication) + } + } +} diff --git a/server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt b/server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt new file mode 100644 index 000000000..c7ec025ee --- /dev/null +++ b/server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync + +import org.junit.Test +import kotlin.test.* + +class MemorySyncServerTest { + + private val server = MemorySyncServer() + private val key = server.register() + + @Test + fun testUsage() { + val data0 = SyncData(0, "") + assertEquals(server.get(key), data0) + + val data1 = SyncData(1, "Hello world") + server.put(key, data1) + assertEquals(server.get(key), data1) + + val data2 = SyncData(2, "Hello new world") + server.put(key, data2) + assertEquals(server.get(key), data2) + + assertFailsWith { + server.put(key, data2) + } + + assertFailsWith { + server.get("INVALID") + } + + assertFailsWith { + server.put("INVALID", data0) + } + } +} \ No newline at end of file diff --git a/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt b/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt new file mode 100644 index 000000000..190fc6fcf --- /dev/null +++ b/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.app + +import io.ktor.application.* +import org.isoron.uhabits.sync.* +import org.mockito.Mockito.* + +open class BaseApplicationTest { + + protected val server: AbstractSyncServer = mock(AbstractSyncServer::class.java) + + protected fun app(): Application.() -> Unit = { + SyncApplication(server).apply { + main() + } + } +} diff --git a/server/test/org/isoron/uhabits/sync/app/RegistrationModuleTest.kt b/server/test/org/isoron/uhabits/sync/app/RegistrationModuleTest.kt new file mode 100644 index 000000000..2375fe4fb --- /dev/null +++ b/server/test/org/isoron/uhabits/sync/app/RegistrationModuleTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.app + +import io.ktor.http.* +import io.ktor.server.testing.* +import 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`() { + `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`() { + `when`(server.register()).thenThrow(RegistrationUnavailableException()) + withTestApplication(app()) { + val call = handleRequest(HttpMethod.Post, "/register") + assertEquals(HttpStatusCode.ServiceUnavailable, call.response.status()) + } + } +} \ No newline at end of file diff --git a/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt b/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt new file mode 100644 index 000000000..01e23b328 --- /dev/null +++ b/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.app + +import io.ktor.http.* +import io.ktor.server.testing.* +import 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`() { + `when`(server.get("k1")).thenReturn(data1) + withTestApplication(app()) { + handleGet("/db/k1").apply { + assertEquals(HttpStatusCode.OK, response.status()) + assertEquals(data1.toJson(), response.content) + } + } + } + + @Test + fun `when get with invalid key should return 404`() { + `when`(server.get("k1")).thenThrow(KeyNotFoundException()) + withTestApplication(app()) { + handleGet("/db/k1").apply { + assertEquals(HttpStatusCode.NotFound, response.status()) + } + } + } + + + @Test + fun `when put succeeds should return OK`() { + withTestApplication(app()) { + handlePut("/db/k1", data1).apply { + assertEquals(HttpStatusCode.OK, response.status()) + verify(server).put("k1", data1) + } + } + } + + @Test + fun `when put with invalid key should return 404`() { + `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`() { + `when`(server.put("k1", data1)).thenThrow(EditConflictException()) + `when`(server.get("k1")).thenReturn(data2) + withTestApplication(app()) { + handlePut("/db/k1", data1).apply { + assertEquals(HttpStatusCode.Conflict, response.status()) + assertEquals(data2.toJson(), response.content) + } + } + } + + 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) + } +} From 8fa3ba1b1853303c02df981c077d6d0ccfd0b87e Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 21 Nov 2020 20:49:48 -0600 Subject: [PATCH 02/31] Add docker tasks to gradle --- server/Dockerfile | 10 ++++++++++ server/build.gradle | 25 ++++++++++++++++++++++++- server/settings.gradle | 2 +- 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 server/Dockerfile diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 000000000..33977a5d5 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,10 @@ +FROM openjdk:8-jre-alpine +RUN mkdir /app +COPY uhabits-server.jar /app/uhabits-server.jar +WORKDIR /app +CMD ["java", \ + "-server", \ + "-XX:MaxGCPauseMillis=100", \ + "-XX:+UseStringDeduplication", \ + "-jar", \ + "uhabits-server.jar"] \ No newline at end of file diff --git a/server/build.gradle b/server/build.gradle index 543e5531a..69b0f3ab4 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -21,14 +21,19 @@ 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' @@ -59,3 +64,21 @@ dependencies { testImplementation "io.ktor:ktor-server-tests:$ktor_version" testImplementation "org.mockito:mockito-core:2.+" } + +shadowJar { + baseName = 'uhabits-server' + classifier = null + version = null +} + +docker { + name = "uhabits-server:$version" + files "build/libs/uhabits-server.jar" +} + +dockerRun { + name = 'uhabits-server' + image "uhabits-server:$version" + ports '8080:8080' + arguments '--restart=always' +} \ No newline at end of file diff --git a/server/settings.gradle b/server/settings.gradle index e866a88ff..78f29ed87 100644 --- a/server/settings.gradle +++ b/server/settings.gradle @@ -17,4 +17,4 @@ * with this program. If not, see . */ -rootProject.name = "syncserver" +rootProject.name = "uhabits-server" From 2848c4e77b0845682055a1aa192ecda92c7b21a8 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sun, 22 Nov 2020 09:49:35 -0600 Subject: [PATCH 03/31] Server: Implement get version --- .../src/org/isoron/uhabits/sync/app/StorageModule.kt | 9 +++++++++ .../org/isoron/uhabits/sync/app/StorageModuleTest.kt | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/server/src/org/isoron/uhabits/sync/app/StorageModule.kt b/server/src/org/isoron/uhabits/sync/app/StorageModule.kt index 5d1a17679..fb85b7edf 100644 --- a/server/src/org/isoron/uhabits/sync/app/StorageModule.kt +++ b/server/src/org/isoron/uhabits/sync/app/StorageModule.kt @@ -50,5 +50,14 @@ fun Routing.storage(app: SyncApplication) { call.respond(HttpStatusCode.Conflict, currData) } } + get("version") { + val key = call.parameters["key"]!! + try { + val data = app.server.get(key) + call.respond(HttpStatusCode.OK, data.version) + } catch(e: KeyNotFoundException) { + call.respond(HttpStatusCode.NotFound) + } + } } } \ No newline at end of file diff --git a/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt b/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt index 01e23b328..0ef5f014c 100644 --- a/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt +++ b/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt @@ -41,6 +41,17 @@ class StorageModuleTest : BaseApplicationTest() { } } + @Test + fun `when get version succeeds should return version`() { + `when`(server.get("k1")).thenReturn(data1) + withTestApplication(app()) { + handleGet("/db/k1/version").apply { + assertEquals(HttpStatusCode.OK, response.status()) + assertEquals("1", response.content) + } + } + } + @Test fun `when get with invalid key should return 404`() { `when`(server.get("k1")).thenThrow(KeyNotFoundException()) From 0497890cb0f1b202beef24eba3aa71e4d638a437 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sun, 22 Nov 2020 10:02:53 -0600 Subject: [PATCH 04/31] Update docker registry URL --- server/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/build.gradle b/server/build.gradle index 69b0f3ab4..b7f118308 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -72,7 +72,7 @@ shadowJar { } docker { - name = "uhabits-server:$version" + name = "docker.axavier.org/uhabits-server:$version" files "build/libs/uhabits-server.jar" } From 5376f4bff88be768699a8df99bb56cd698d26585 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sun, 22 Nov 2020 10:07:34 -0600 Subject: [PATCH 05/31] Implement SyncActivity (with static data) --- .../androidbase/activities/BaseScreen.kt | 7 +- android/uhabits-android/build.gradle | 1 + .../src/main/AndroidManifest.xml | 8 ++ .../uhabits/activities/sync/SyncActivity.kt | 100 ++++++++++++++++ .../src/main/res/layout/activity_sync.xml | 113 ++++++++++++++++++ .../src/main/res/values/strings.xml | 9 ++ .../src/main/res/xml/preferences.xml | 21 ++++ 7 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt create mode 100644 android/uhabits-android/src/main/res/layout/activity_sync.xml diff --git a/android/android-base/src/main/java/org/isoron/androidbase/activities/BaseScreen.kt b/android/android-base/src/main/java/org/isoron/androidbase/activities/BaseScreen.kt index 443344e87..ab223523d 100644 --- a/android/android-base/src/main/java/org/isoron/androidbase/activities/BaseScreen.kt +++ b/android/android-base/src/main/java/org/isoron/androidbase/activities/BaseScreen.kt @@ -127,8 +127,7 @@ open class BaseScreen(@JvmField protected var activity: BaseActivity) { * * @param stringId the string resource id for this message. */ - fun showMessage(@StringRes stringId: Int?) { - val rootView = this.rootView + fun showMessage(@StringRes stringId: Int?, rootView: View?) { var snackbar = this.snackbar if (stringId == null || rootView == null) return if (snackbar == null) { @@ -142,6 +141,10 @@ open class BaseScreen(@JvmField protected var activity: BaseActivity) { snackbar.show() } + fun showMessage(@StringRes stringId: Int?) { + showMessage(stringId, this.rootView) + } + fun showSendEmailScreen(@StringRes toId: Int, @StringRes subjectId: Int, content: String?) { val to = activity.getString(toId) val subject = activity.getString(subjectId) diff --git a/android/uhabits-android/build.gradle b/android/uhabits-android/build.gradle index 45d2a0416..a4ce06526 100644 --- a/android/uhabits-android/build.gradle +++ b/android/uhabits-android/build.gradle @@ -93,6 +93,7 @@ dependencies { implementation "com.google.code.findbugs:jsr305:3.0.2" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$KOTLIN_VERSION" implementation "androidx.constraintlayout:constraintlayout:2.0.0-beta4" + implementation 'com.google.zxing:core:3.4.1' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' compileOnly "javax.annotation:jsr250-api:1.0" diff --git a/android/uhabits-android/src/main/AndroidManifest.xml b/android/uhabits-android/src/main/AndroidManifest.xml index 1a268ce57..549e635e9 100644 --- a/android/uhabits-android/src/main/AndroidManifest.xml +++ b/android/uhabits-android/src/main/AndroidManifest.xml @@ -41,6 +41,14 @@ android:value=".activities.habits.list.ListHabitsActivity" /> + + + + diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt new file mode 100644 index 000000000..2cf8cb1aa --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2016-2020 Álinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.activities.sync + +import android.content.* +import android.content.ClipboardManager +import android.graphics.* +import android.os.* +import android.text.* +import com.google.zxing.* +import com.google.zxing.qrcode.* +import org.isoron.androidbase.activities.* +import org.isoron.androidbase.utils.* +import org.isoron.uhabits.* +import org.isoron.uhabits.activities.* +import org.isoron.uhabits.databinding.* + + +class SyncActivity : BaseActivity() { + + private lateinit var baseScreen: BaseScreen + private lateinit var themeSwitcher: AndroidThemeSwitcher + private lateinit var binding: ActivitySyncBinding + + override fun onCreate(state: Bundle?) { + super.onCreate(state) + + baseScreen = BaseScreen(this) + + val component = (application as HabitsApplication).component + themeSwitcher = AndroidThemeSwitcher(this, component.preferences) + themeSwitcher.apply() + + binding = ActivitySyncBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportActionBar?.elevation = 10.0f + + binding.instructions.setText(Html.fromHtml(resources.getString(R.string.sync_instructions))) + + displayLink("https://loophabits.org/sync/KA9GvblSWrcLk9iwJrplHvWiWdE6opAokdf2qqRl6n6ECX8IUhvcksqlfkQACoMM") + displayPassword("6B2W9F5X") + + binding.syncLink.setOnClickListener { + copyToClipboard() + } + } + + 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.syncLink.text = link + displayQR(link) + } + + private fun displayQR(msg: String) { + val writer = QRCodeWriter() + val matrix = writer.encode(msg, BarcodeFormat.QR_CODE, 1024, 1024) + val height = matrix.height + val width = matrix.width + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) + val bgColor = StyledResources(this).getColor(R.attr.highContrastReverseTextColor) + val fgColor = StyledResources(this).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) + } + } + binding.qrCode.setImageBitmap(bitmap) + } +} \ No newline at end of file diff --git a/android/uhabits-android/src/main/res/layout/activity_sync.xml b/android/uhabits-android/src/main/res/layout/activity_sync.xml new file mode 100644 index 000000000..b2f8dcb52 --- /dev/null +++ b/android/uhabits-android/src/main/res/layout/activity_sync.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/uhabits-android/src/main/res/values/strings.xml b/android/uhabits-android/src/main/res/values/strings.xml index 7f807dccd..bdd797672 100644 --- a/android/uhabits-android/src/main/res/values/strings.xml +++ b/android/uhabits-android/src/main/res/values/strings.xml @@ -203,4 +203,13 @@ Decrement Enable skip days Toggle twice to add a skip instead of a checkmark. Skips keep your score unchanged and don\'t break your streak. + Device sync + This feature allows you to synchronize data between multiple devices. When enabled, an encrypted copy of your data will be uploaded to Loop Habit Tracker servers. See privacy policy for more details. + Enable device sync + Display sync instructions + Instructions:
1. Install Loop in your second device.
2. Open the link below in your second device.
3. Provide the 8-character password below.
Important: Do not publish this information. It gives anyone access to your entire data.]]>
+ Sync link + Sync link (QR code) + Password + Copied to the clipboard \ No newline at end of file diff --git a/android/uhabits-android/src/main/res/xml/preferences.xml b/android/uhabits-android/src/main/res/xml/preferences.xml index 7b0d75489..39524368d 100644 --- a/android/uhabits-android/src/main/res/xml/preferences.xml +++ b/android/uhabits-android/src/main/res/xml/preferences.xml @@ -110,6 +110,27 @@ + + + + + + + + + + From a2400172e2c57f6e61a1ae6e086daa244f8b3b81 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sun, 22 Nov 2020 13:00:59 -0600 Subject: [PATCH 06/31] Make registration functional --- android/gradle.properties | 1 + android/uhabits-android/build.gradle | 6 + .../uhabits/sync/RemoteSyncServerTest.kt | 133 ++++++++++++++++++ .../src/main/AndroidManifest.xml | 2 + .../uhabits/activities/sync/SyncActivity.kt | 54 ++++++- .../isoron/uhabits/sync/AbstractSyncServer.kt | 60 ++++++++ .../isoron/uhabits/sync/RemoteSyncServer.kt | 80 +++++++++++ .../java/org/isoron/uhabits/sync/SyncData.kt | 25 ++++ .../org/isoron/uhabits/sync/SyncException.kt | 28 ++++ .../src/main/res/layout/activity_sync.xml | 66 ++++++--- .../src/main/res/values/fontawesome.xml | 2 +- 11 files changed, 437 insertions(+), 20 deletions(-) create mode 100644 android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt create mode 100644 android/uhabits-android/src/main/java/org/isoron/uhabits/sync/AbstractSyncServer.kt create mode 100644 android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt create mode 100644 android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncData.kt create mode 100644 android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncException.kt diff --git a/android/gradle.properties b/android/gradle.properties index 570285804..ca000507d 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -10,6 +10,7 @@ KOTLIN_VERSION = 1.3.61 SUPPORT_LIBRARY_VERSION = 28.0.0 AUTO_FACTORY_VERSION = 1.0-beta6 BUILD_TOOLS_VERSION = 4.0.0 +KTOR_VERSION=1.4.2 org.gradle.parallel=false org.gradle.daemon=true diff --git a/android/uhabits-android/build.gradle b/android/uhabits-android/build.gradle index a4ce06526..c1f7b6659 100644 --- a/android/uhabits-android/build.gradle +++ b/android/uhabits-android/build.gradle @@ -94,6 +94,10 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$KOTLIN_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 'androidx.constraintlayout:constraintlayout:1.1.3' compileOnly "javax.annotation:jsr250-api:1.0" @@ -113,6 +117,8 @@ dependencies { androidTestImplementation 'androidx.test:rules:1.1.1' androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation "com.google.guava:guava:24.1-android" + androidTestImplementation "io.ktor:ktor-client-mock:$KTOR_VERSION" + androidTestImplementation "io.ktor:ktor-jackson:$KTOR_VERSION" androidTestImplementation project(":uhabits-core") kaptAndroidTest "com.google.dagger:dagger-compiler:$DAGGER_VERSION" diff --git a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt new file mode 100644 index 000000000..e9ef2c3c5 --- /dev/null +++ b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2016-2020 Álinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync + +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.* + +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("/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("/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("/ABC/version") { + respondError(HttpStatusCode.NotFound) + }.apply { + getDataVersion("ABC") + } + return@runBlocking + } + + @Test + fun when_get_data_succeeds_should_return_data() = runBlocking { + server("/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("/ABC") { + respondError(HttpStatusCode.NotFound) + }.apply { + getData("ABC") + } + return@runBlocking + } + + @Test + fun when_put_succeeds_should_not_raise_exceptions() = runBlocking { + server("/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}") + } + } + } + }) + } + + private fun MockRequestHandleScope.respondWithJson(content: Any) = + respond(mapper.writeValueAsBytes(content), + headers = headersOf("Content-Type" to listOf("application/json"))) +} \ No newline at end of file diff --git a/android/uhabits-android/src/main/AndroidManifest.xml b/android/uhabits-android/src/main/AndroidManifest.xml index 549e635e9..f043a92fb 100644 --- a/android/uhabits-android/src/main/AndroidManifest.xml +++ b/android/uhabits-android/src/main/AndroidManifest.xml @@ -23,6 +23,8 @@ + + + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync + +interface AbstractSyncServer { + /** + * Generates and returns a new sync key, which can be used to store and retrive + * data. + * + * @throws ServiceUnavailable If key cannot be generated at this time, for example, + * due to insufficient server resources, temporary server maintenance or network problems. + */ + suspend fun register(): String + + /** + * Replaces data for a given sync key. + * + * @throws KeyNotFoundException If key is not found + * @throws EditConflictException If the version of the data provided is not + * exactly the current data version plus one. + * @throws ServiceUnavailable If data cannot be put at this time, for example, due + * to insufficient server resources or network problems. + */ + suspend fun put(key: String, newData: SyncData) + + /** + * Returns data for a given sync key. + * + * @throws KeyNotFoundException If key is not found + * @throws ServiceUnavailable If data cannot be retrieved at this time, for example, due + * to insufficient server resources or network problems. + */ + suspend fun getData(key: String): SyncData + + /** + * Returns the current data version for the given key + * + * @throws KeyNotFoundException If key is not found + * @throws ServiceUnavailable If data cannot be retrieved at this time, for example, due + * to insufficient server resources or network problems. + */ + suspend fun getDataVersion(key: String): Long +} \ No newline at end of file diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt new file mode 100644 index 000000000..bfe1b5714 --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2016-2020 Álinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync + +import 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.* + +data class RegisterReponse(val key: String) +data class GetDataVersionResponse(val version: Long) + +class RemoteSyncServer( + private val baseURL: String = "https://sync.loophabits.org", + private val httpClient: HttpClient = HttpClient(Android) { + install(JsonFeature) + } +) : AbstractSyncServer { + + override suspend fun register(): String { + try { + val response: RegisterReponse = httpClient.post("$baseURL/register") + return response.key + } catch(e: ServerResponseException) { + throw ServiceUnavailable() + } + } + + override suspend fun put(key: String, newData: SyncData) { + try { + val response: String = httpClient.put("$baseURL/$key") { + header("Content-Type", "application/json") + body = newData + } + } catch (e: ServerResponseException) { + throw ServiceUnavailable() + } catch (e: ClientRequestException) { + throw KeyNotFoundException() + } + } + + override suspend fun getData(key: String): SyncData { + try { + return httpClient.get("$baseURL/$key") + } catch (e: ServerResponseException) { + throw ServiceUnavailable() + } catch (e: ClientRequestException) { + throw KeyNotFoundException() + } + } + + override suspend fun getDataVersion(key: String): Long { + try { + val response: GetDataVersionResponse = httpClient.get("$baseURL/$key/version") + return response.version + } catch(e: ServerResponseException) { + throw ServiceUnavailable() + } catch (e: ClientRequestException) { + throw KeyNotFoundException() + } + } +} diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncData.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncData.kt new file mode 100644 index 000000000..5f57d3c9c --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncData.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync + +data class SyncData( + val version: Long, + val content: String +) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncException.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncException.kt new file mode 100644 index 000000000..5c5a81403 --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncException.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync + +open class SyncException: RuntimeException() + +class KeyNotFoundException: SyncException() + +class ServiceUnavailable: SyncException() + +class EditConflictException: SyncException() \ No newline at end of file diff --git a/android/uhabits-android/src/main/res/layout/activity_sync.xml b/android/uhabits-android/src/main/res/layout/activity_sync.xml index b2f8dcb52..660d31bbd 100644 --- a/android/uhabits-android/src/main/res/layout/activity_sync.xml +++ b/android/uhabits-android/src/main/res/layout/activity_sync.xml @@ -52,6 +52,55 @@ android:id="@+id/instructions" /> + + + + + + + + + + + + + + + + + + + @@ -73,22 +122,7 @@ - - - - - - - - - + diff --git a/android/uhabits-android/src/main/res/values/fontawesome.xml b/android/uhabits-android/src/main/res/values/fontawesome.xml index 7c95f1b38..d4c3b4687 100644 --- a/android/uhabits-android/src/main/res/values/fontawesome.xml +++ b/android/uhabits-android/src/main/res/values/fontawesome.xml @@ -26,6 +26,7 @@ + @@ -123,7 +124,6 @@ - From 23f2978a64dae614041b9dba2498f2ffccf36e9c Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sun, 22 Nov 2020 15:39:08 -0600 Subject: [PATCH 07/31] Add sync preferences to settings screen --- .../activities/settings/SettingsFragment.java | 38 ++++++--- .../uhabits/activities/sync/SyncActivity.kt | 29 +++++-- .../isoron/uhabits/intents/IntentFactory.kt | 6 +- .../preferences/SharedPreferencesStorage.kt | 2 - .../src/main/res/values/constants.xml | 1 + .../src/main/res/values/strings.xml | 6 +- .../src/main/res/xml/preferences.xml | 27 ++++-- .../uhabits/core/preferences/Preferences.java | 82 +++++-------------- .../core/preferences/PreferencesTest.java | 31 ------- 9 files changed, 101 insertions(+), 121 deletions(-) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java index 7f9430b7b..1faca385f 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java @@ -26,17 +26,15 @@ import android.os.*; import android.provider.*; import android.util.*; -import androidx.annotation.Nullable; -import androidx.preference.ListPreference; -import androidx.preference.Preference; -import androidx.preference.PreferenceCategory; -import androidx.preference.PreferenceFragmentCompat; +import androidx.annotation.*; +import androidx.preference.*; import org.isoron.uhabits.R; import org.isoron.uhabits.*; import org.isoron.uhabits.core.preferences.*; import org.isoron.uhabits.core.ui.*; import org.isoron.uhabits.core.utils.*; +import org.isoron.uhabits.intents.*; import org.isoron.uhabits.notifications.*; import org.isoron.uhabits.widgets.*; @@ -47,7 +45,7 @@ import static android.os.Build.VERSION.*; import static org.isoron.uhabits.activities.habits.list.ListHabitsScreenKt.*; public class SettingsFragment extends PreferenceFragmentCompat - implements SharedPreferences.OnSharedPreferenceChangeListener + implements SharedPreferences.OnSharedPreferenceChangeListener { private static int RINGTONE_REQUEST_CODE = 1; @@ -55,7 +53,7 @@ public class SettingsFragment extends PreferenceFragmentCompat private RingtoneManager ringtoneManager; - @Nullable + @NonNull private Preferences prefs; @Nullable @@ -93,6 +91,7 @@ public class SettingsFragment extends PreferenceFragmentCompat setResultOnPreferenceClick("exportDB", RESULT_EXPORT_DB); setResultOnPreferenceClick("repairDB", RESULT_REPAIR_DB); setResultOnPreferenceClick("bugReport", RESULT_BUG_REPORT); + } @Override @@ -142,24 +141,31 @@ public class SettingsFragment extends PreferenceFragmentCompat sharedPrefs = getPreferenceManager().getSharedPreferences(); sharedPrefs.registerOnSharedPreferenceChangeListener(this); - if (prefs != null && !prefs.isDeveloper()) + if (!prefs.isDeveloper()) { PreferenceCategory devCategory = - (PreferenceCategory) findPreference("devCategory"); + (PreferenceCategory) findPreference("devCategory"); devCategory.removeAll(); 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_base_url").setSummary(prefs.getSyncBaseURL()); + findPreference("pref_sync_key").setSummary(prefs.getSyncKey()); + findPreference("pref_sync_display").setVisible(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 +185,18 @@ public class SettingsFragment extends PreferenceFragmentCompat Log.d("SettingsFragment", "updating widgets"); widgetUpdater.updateWidgets(); } - if (key.equals("pref_first_weekday")) updateWeekdayPreference(); + if (key.equals("pref_sync_enabled")) + { + Context context = getActivity(); + if (prefs.isSyncEnabled()) + { + Intent intent = new IntentFactory().startSyncActivity(context); + context.startActivity(intent); + } + } BackupManager.dataChanged("org.isoron.uhabits"); + updateWeekdayPreference(); + updateSyncPreferences(); } private void setResultOnPreferenceClick(String key, final int result) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt index a4b9e2fa4..231a2a467 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt @@ -33,6 +33,7 @@ import org.isoron.androidbase.utils.* import org.isoron.androidbase.utils.InterfaceUtils.getFontAwesome import org.isoron.uhabits.* import org.isoron.uhabits.activities.* +import org.isoron.uhabits.core.preferences.* import org.isoron.uhabits.core.tasks.* import org.isoron.uhabits.databinding.* import org.isoron.uhabits.sync.* @@ -40,6 +41,7 @@ import org.isoron.uhabits.sync.* class SyncActivity : BaseActivity() { + private lateinit var preferences: Preferences private lateinit var taskRunner: TaskRunner private lateinit var baseScreen: BaseScreen private lateinit var themeSwitcher: AndroidThemeSwitcher @@ -54,6 +56,7 @@ class SyncActivity : BaseActivity() { themeSwitcher = AndroidThemeSwitcher(this, component.preferences) themeSwitcher.apply() taskRunner = component.taskRunner + preferences = component.preferences binding = ActivitySyncBinding.inflate(layoutInflater) setContentView(binding.root) @@ -74,27 +77,41 @@ class SyncActivity : BaseActivity() { override fun onResume() { super.onResume() + if(preferences.syncKey.isBlank()) { + register() + } else { + displayCurrentKey() + } + } + + private fun displayCurrentKey() { + displayLink("${preferences.syncBaseURL}/sync/${preferences.syncKey}") + displayPassword("6B2W9F5X") + } + + private fun register() { displayLoading() taskRunner.execute(object : Task { private var key = "" private var error = false override fun doInBackground() { runBlocking { - val server = RemoteSyncServer() + val server = RemoteSyncServer(baseURL = preferences.syncBaseURL) try { key = server.register() - } catch(e: ServiceUnavailable) { + } catch (e: ServiceUnavailable) { error = true } } } + override fun onPostExecute() { - if(error) { + if (error) { displayError() - } else { - displayLink("https://loophabits.org/sync/$key") - displayPassword("6B2W9F5X") + return; } + preferences.syncKey = key; + displayCurrentKey() } }) } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt index 2287a56d1..520758705 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt @@ -21,13 +21,13 @@ package org.isoron.uhabits.intents import android.content.* import android.net.* -import org.isoron.androidbase.activities.* import org.isoron.uhabits.* import org.isoron.uhabits.activities.about.* import org.isoron.uhabits.activities.habits.edit.* import org.isoron.uhabits.activities.habits.show.* import org.isoron.uhabits.activities.intro.* import org.isoron.uhabits.activities.settings.* +import org.isoron.uhabits.activities.sync.* import org.isoron.uhabits.core.models.* import javax.inject.* @@ -100,4 +100,8 @@ class IntentFactory intent.putExtra("habitType", habitType) return intent } + + fun startSyncActivity(context: Context): Intent { + return Intent(context, SyncActivity::class.java) + } } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/preferences/SharedPreferencesStorage.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/preferences/SharedPreferencesStorage.kt index 55a9defa7..b92f10385 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/preferences/SharedPreferencesStorage.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/preferences/SharedPreferencesStorage.kt @@ -87,8 +87,6 @@ class SharedPreferencesStorage preferences.setNotificationsSticky(getBoolean(key, false)) "pref_led_notifications" -> preferences.setNotificationsLed(getBoolean(key, false)) - "pref_feature_sync" -> - preferences.isSyncEnabled = getBoolean(key, false) } sharedPreferences.registerOnSharedPreferenceChangeListener(this) } diff --git a/android/uhabits-android/src/main/res/values/constants.xml b/android/uhabits-android/src/main/res/values/constants.xml index c034ad08b..3756855f4 100644 --- a/android/uhabits-android/src/main/res/values/constants.xml +++ b/android/uhabits-android/src/main/res/values/constants.xml @@ -27,6 +27,7 @@ http://translate.loophabits.org/ dev@loophabits.org Bug Report - Loop Habit Tracker + https://sync.loophabits.org @string/interval_15_minutes diff --git a/android/uhabits-android/src/main/res/values/strings.xml b/android/uhabits-android/src/main/res/values/strings.xml index bdd797672..d0f79b0cd 100644 --- a/android/uhabits-android/src/main/res/values/strings.xml +++ b/android/uhabits-android/src/main/res/values/strings.xml @@ -204,10 +204,10 @@ Enable skip days Toggle twice to add a skip instead of a checkmark. Skips keep your score unchanged and don\'t break your streak. Device sync - This feature allows you to synchronize data between multiple devices. When enabled, an encrypted copy of your data will be uploaded to Loop Habit Tracker servers. See privacy policy for more details. - Enable device sync + When enabled, an encrypted copy of your data will be uploaded to our servers. See privacy policy. + Sync data across devices Display sync instructions - Instructions:
1. Install Loop in your second device.
2. Open the link below in your second device.
3. Provide the 8-character password below.
Important: Do not publish this information. It gives anyone access to your entire data.]]>
+ Instructions:
1. Install Loop in your second device.
2. Open the link below in your second device.
3. Provide the 8-character password below.
Important: Do not publish this information. It gives anyone access to your data.]]>
Sync link Sync link (QR code) Password diff --git a/android/uhabits-android/src/main/res/xml/preferences.xml b/android/uhabits-android/src/main/res/xml/preferences.xml index 39524368d..e311649a6 100644 --- a/android/uhabits-android/src/main/res/xml/preferences.xml +++ b/android/uhabits-android/src/main/res/xml/preferences.xml @@ -116,21 +116,24 @@ + app:iconSpaceReserved="false"> + +
- @@ -218,6 +221,20 @@ android:title="Enable widget stacks" app:iconSpaceReserved="false" /> + + + + \ No newline at end of file diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java index 857989aea..a2b22fc0e 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java @@ -29,10 +29,6 @@ import java.util.*; public class Preferences { - - public static final String DEFAULT_SYNC_SERVER = - "https://sync.loophabits.org"; - @NonNull private final Storage storage; @@ -130,16 +126,6 @@ public class Preferences else return new Timestamp(unixTime); } - public long getLastSync() - { - return storage.getLong("last_sync", 0); - } - - public void setLastSync(long timestamp) - { - storage.putLong("last_sync", timestamp); - } - public boolean getShowArchived() { return storage.getBoolean("pref_show_archived", false); @@ -170,39 +156,6 @@ public class Preferences storage.putString("pref_snooze_interval", String.valueOf(interval)); } - public String getSyncAddress() - { - return storage.getString("pref_sync_address", DEFAULT_SYNC_SERVER); - } - - public void setSyncAddress(String address) - { - storage.putString("pref_sync_address", address); - for (Listener l : listeners) l.onSyncFeatureChanged(); - } - - public String getSyncClientId() - { - String id = storage.getString("pref_sync_client_id", ""); - if (!id.isEmpty()) return id; - - id = UUID.randomUUID().toString(); - storage.putString("pref_sync_client_id", id); - - return id; - } - - public String getSyncKey() - { - return storage.getString("pref_sync_key", ""); - } - - public void setSyncKey(String key) - { - storage.putString("pref_sync_key", key); - for (Listener l : listeners) l.onSyncFeatureChanged(); - } - public int getTheme() { return storage.getInt("pref_theme", ThemeSwitcher.THEME_AUTOMATIC); @@ -263,17 +216,6 @@ public class Preferences storage.putBoolean("pref_short_toggle", enabled); } - public boolean isSyncEnabled() - { - return storage.getBoolean("pref_feature_sync", false); - } - - public void setSyncEnabled(boolean isEnabled) - { - storage.putBoolean("pref_feature_sync", isEnabled); - for (Listener l : listeners) l.onSyncFeatureChanged(); - } - public boolean isWidgetStackEnabled() { return storage.getBoolean("pref_feature_widget_stack", false); @@ -367,6 +309,26 @@ 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 void setSyncKey(String key) + { + storage.putString("pref_sync_key", key); + } + + public boolean isSyncEnabled() + { + return storage.getBoolean("pref_sync_enabled", false); + } + /** * @return An integer representing the first day of the week. Sunday @@ -390,10 +352,6 @@ public class Preferences default void onNotificationsChanged() { } - - default void onSyncFeatureChanged() - { - } } public interface Storage diff --git a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/preferences/PreferencesTest.java b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/preferences/PreferencesTest.java index 409a9fa9e..a58abd868 100644 --- a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/preferences/PreferencesTest.java +++ b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/preferences/PreferencesTest.java @@ -109,37 +109,6 @@ public class PreferencesTest extends BaseUnitTest assertThat(prefs.getLastHintTimestamp(), equalTo(Timestamp.ZERO.plus(100))); } - @Test - public void testSync() throws Exception - { - assertThat(prefs.getLastSync(), equalTo(0L)); - prefs.setLastSync(100); - assertThat(prefs.getLastSync(), equalTo(100L)); - - assertThat(prefs.getSyncAddress(), - equalTo(Preferences.DEFAULT_SYNC_SERVER)); - prefs.setSyncAddress("example"); - assertThat(prefs.getSyncAddress(), equalTo("example")); - verify(listener).onSyncFeatureChanged(); - reset(listener); - - assertThat(prefs.getSyncKey(), equalTo("")); - prefs.setSyncKey("123"); - assertThat(prefs.getSyncKey(), equalTo("123")); - verify(listener).onSyncFeatureChanged(); - reset(listener); - - assertFalse(prefs.isSyncEnabled()); - prefs.setSyncEnabled(true); - assertTrue(prefs.isSyncEnabled()); - verify(listener).onSyncFeatureChanged(); - reset(listener); - - String id = prefs.getSyncClientId(); - assertFalse(id.isEmpty()); - assertThat(prefs.getSyncClientId(), equalTo(id)); - } - @Test public void testTheme() throws Exception { From 0859cec853af183f199abf3693c8378acdaa7855 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sun, 22 Nov 2020 17:00:37 -0600 Subject: [PATCH 08/31] Implement intent filter; hide password for now --- .../src/main/AndroidManifest.xml | 9 ++- .../uhabits/activities/HabitsActivity.kt | 7 ++- .../common/dialogs/ConfirmSyncKeyDialog.java | 58 +++++++++++++++++++ .../habits/list/ListHabitsScreen.kt | 42 +++++++++----- .../activities/settings/SettingsFragment.java | 5 +- .../uhabits/activities/sync/SyncActivity.kt | 46 +++++++++------ .../src/main/res/layout/activity_sync.xml | 4 +- .../src/main/res/values/strings.xml | 6 +- .../uhabits/core/preferences/Preferences.java | 5 ++ .../habits/list/ListHabitsBehavior.java | 15 ++++- 10 files changed, 155 insertions(+), 42 deletions(-) create mode 100644 android/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/ConfirmSyncKeyDialog.java diff --git a/android/uhabits-android/src/main/AndroidManifest.xml b/android/uhabits-android/src/main/AndroidManifest.xml index f043a92fb..a054ce37d 100644 --- a/android/uhabits-android/src/main/AndroidManifest.xml +++ b/android/uhabits-android/src/main/AndroidManifest.xml @@ -68,9 +68,16 @@ android:targetActivity=".activities.habits.list.ListHabitsActivity"> - + + + + + + + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.activities.common.dialogs; + +import android.content.*; + +import androidx.annotation.*; +import androidx.appcompat.app.*; + +import com.google.auto.factory.*; + +import org.isoron.androidbase.activities.*; +import org.isoron.uhabits.core.ui.callbacks.*; +import org.isoron.uhabits.R; + +import butterknife.*; + +@AutoFactory(allowSubclasses = true) +public class ConfirmSyncKeyDialog extends AlertDialog +{ + @BindString(R.string.sync_confirm) + protected String question; + + @BindString(R.string.yes) + protected String yes; + + @BindString(R.string.no) + protected String no; + + protected ConfirmSyncKeyDialog(@Provided @ActivityContext Context context, + @NonNull OnConfirmedCallback callback) + { + super(context); + ButterKnife.bind(this); + + setTitle(R.string.device_sync); + setMessage(question); + setButton(BUTTON_POSITIVE, yes, (dialog, which) -> callback.onConfirmed()); + setButton(BUTTON_NEGATIVE, no, (dialog, which) -> {}); + } +} diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt index e4c3752a1..4d2d32602 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt @@ -21,6 +21,7 @@ package org.isoron.uhabits.activities.habits.list import android.app.* import android.content.* +import android.util.* import androidx.annotation.* import dagger.* import org.isoron.androidbase.activities.* @@ -31,7 +32,6 @@ import org.isoron.uhabits.activities.habits.edit.* import org.isoron.uhabits.activities.habits.list.views.* import org.isoron.uhabits.core.commands.* import org.isoron.uhabits.core.models.* -import org.isoron.uhabits.core.preferences.* import org.isoron.uhabits.core.tasks.* import org.isoron.uhabits.core.ui.* import org.isoron.uhabits.core.ui.callbacks.* @@ -42,13 +42,13 @@ import org.isoron.uhabits.tasks.* import java.io.* import javax.inject.* -const val RESULT_IMPORT_DATA = 1 -const val RESULT_EXPORT_CSV = 2 -const val RESULT_EXPORT_DB = 3 -const val RESULT_BUG_REPORT = 4 -const val RESULT_REPAIR_DB = 5 -const val REQUEST_OPEN_DOCUMENT = 6 -const val REQUEST_SETTINGS = 7 +const val RESULT_IMPORT_DATA = 101 +const val RESULT_EXPORT_CSV = 102 +const val RESULT_EXPORT_DB = 103 +const val RESULT_BUG_REPORT = 104 +const val RESULT_REPAIR_DB = 105 +const val REQUEST_OPEN_DOCUMENT = 106 +const val REQUEST_SETTINGS = 107 @ActivityScope class ListHabitsScreen @@ -63,6 +63,7 @@ class ListHabitsScreen private val exportDBFactory: ExportDBTaskFactory, private val importTaskFactory: ImportDataTaskFactory, private val confirmDeleteDialogFactory: ConfirmDeleteDialogFactory, + private val confirmSyncKeyDialogFactory: ConfirmSyncKeyDialogFactory, private val colorPickerFactory: ColorPickerDialogFactory, private val numberPickerFactory: NumberPickerFactory, private val behavior: Lazy, @@ -82,6 +83,12 @@ 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 key = uri.replace(Regex("^.*sync/"), "") + Log.i("ListHabitsScreen", key) + behavior.get().onSyncKeyOffer(key) + } } fun onDettached() { @@ -171,13 +178,14 @@ 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 + }) } override fun showSendBugReportToDeveloperScreen(log: String) { @@ -204,6 +212,10 @@ class ListHabitsScreen numberPickerFactory.create(value, unit, callback).show() } + override fun showConfirmInstallSyncKey(callback: OnConfirmedCallback) { + activity.showDialog(confirmSyncKeyDialogFactory.create(callback)) + } + @StringRes private fun getExecuteString(command: Command): Int? { when (command) { diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java index 1faca385f..64153beda 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java @@ -145,7 +145,6 @@ public class SettingsFragment extends PreferenceFragmentCompat { PreferenceCategory devCategory = (PreferenceCategory) findPreference("devCategory"); - devCategory.removeAll(); devCategory.setVisible(false); } @@ -159,6 +158,10 @@ public class SettingsFragment extends PreferenceFragmentCompat private void updateSyncPreferences() { + if(prefs.getSyncKey().isEmpty()) { + prefs.setSyncEnabled(false); + ((CheckBoxPreference) findPreference("pref_sync_enabled")).setChecked(false); + } findPreference("pref_sync_base_url").setSummary(prefs.getSyncBaseURL()); findPreference("pref_sync_key").setSummary(prefs.getSyncKey()); findPreference("pref_sync_display").setVisible(prefs.isSyncEnabled()); diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt index 231a2a467..7d04793be 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt @@ -44,17 +44,16 @@ class SyncActivity : BaseActivity() { private lateinit var preferences: Preferences private lateinit var taskRunner: TaskRunner private lateinit var baseScreen: BaseScreen - private lateinit var themeSwitcher: AndroidThemeSwitcher 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 - themeSwitcher = AndroidThemeSwitcher(this, component.preferences) - themeSwitcher.apply() taskRunner = component.taskRunner preferences = component.preferences @@ -85,7 +84,7 @@ class SyncActivity : BaseActivity() { } private fun displayCurrentKey() { - displayLink("${preferences.syncBaseURL}/sync/${preferences.syncKey}") + displayLink("https://loophabits.org/sync/${preferences.syncKey}") displayPassword("6B2W9F5X") } @@ -139,27 +138,36 @@ class SyncActivity : BaseActivity() { } private fun displayLink(link: String) { - binding.qrCode.visibility = View.VISIBLE - binding.progress.visibility = View.GONE + 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) { - val writer = QRCodeWriter() - val matrix = writer.encode(msg, BarcodeFormat.QR_CODE, 1024, 1024) - val height = matrix.height - val width = matrix.width - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) - val bgColor = StyledResources(this).getColor(R.attr.highContrastReverseTextColor) - val fgColor = StyledResources(this).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) + 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) + } + } } - } - binding.qrCode.setImageBitmap(bitmap) + override fun onPostExecute() { + binding.progress.visibility = View.GONE + binding.qrCode.visibility = View.VISIBLE + binding.qrCode.setImageBitmap(bitmap) + } + }) } } \ No newline at end of file diff --git a/android/uhabits-android/src/main/res/layout/activity_sync.xml b/android/uhabits-android/src/main/res/layout/activity_sync.xml index 660d31bbd..67416d582 100644 --- a/android/uhabits-android/src/main/res/layout/activity_sync.xml +++ b/android/uhabits-android/src/main/res/layout/activity_sync.xml @@ -123,7 +123,9 @@ - + diff --git a/android/uhabits-android/src/main/res/values/strings.xml b/android/uhabits-android/src/main/res/values/strings.xml index d0f79b0cd..31afe7709 100644 --- a/android/uhabits-android/src/main/res/values/strings.xml +++ b/android/uhabits-android/src/main/res/values/strings.xml @@ -206,10 +206,12 @@ Device sync When enabled, an encrypted copy of your data will be uploaded to our servers. See privacy policy. Sync data across devices - Display sync instructions - Instructions:
1. Install Loop in your second device.
2. Open the link below in your second device.
3. Provide the 8-character password below.
Important: Do not publish this information. It gives anyone access to your data.]]>
+ Show device sync instructions + Instructions:
1. Install Loop in your second device.
2. Open the link below in your second device.
Important: Do not not make this information public. It gives anyone access to your data.]]>
Sync link Sync link (QR code) Password Copied to the clipboard + + Device sync enabled \ No newline at end of file diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java index a2b22fc0e..af1a37bba 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java @@ -329,6 +329,11 @@ public class Preferences return storage.getBoolean("pref_sync_enabled", false); } + public void setSyncEnabled(boolean enabled) + { + storage.putBoolean("pref_sync_enabled", enabled); + } + /** * @return An integer representing the first day of the week. Sunday diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java index 870ad65f3..6f6c3c5a2 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java @@ -25,7 +25,9 @@ import org.isoron.uhabits.core.commands.*; import org.isoron.uhabits.core.models.*; import org.isoron.uhabits.core.preferences.*; import org.isoron.uhabits.core.tasks.*; +import org.isoron.uhabits.core.ui.callbacks.*; import org.isoron.uhabits.core.utils.*; +import org.jetbrains.annotations.*; import java.io.*; import java.util.*; @@ -156,10 +158,19 @@ public class ListHabitsBehavior habit.getId()); } + public void onSyncKeyOffer(@NotNull String key) + { + screen.showConfirmInstallSyncKey(() -> { + prefs.setSyncKey(key); + prefs.setSyncEnabled(true); + 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 } public interface BugReporter @@ -196,5 +207,7 @@ public class ListHabitsBehavior void showSendBugReportToDeveloperScreen(String log); void showSendFileScreen(@NonNull String filename); + + void showConfirmInstallSyncKey(@NonNull OnConfirmedCallback callback); } } From 294aee5d1272f37b7eb15d162f303e10f2e4d1b3 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sun, 22 Nov 2020 22:39:22 -0600 Subject: [PATCH 09/31] Implement cryptography extensions --- .../isoron/uhabits/utils/EncryptionExtTest.kt | 50 +++++++++++ .../org/isoron/uhabits/utils/EncryptionExt.kt | 85 +++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt create mode 100644 android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt diff --git a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt new file mode 100644 index 000000000..258022b24 --- /dev/null +++ b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2016-2020 Álinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ +package org.isoron.uhabits.utils + +import org.isoron.uhabits.* +import org.junit.* +import java.io.* + +class EncryptionExtTest : BaseAndroidTest() { + @Test + fun test_encrypt_decrypt_string() { + val original = "Hello world!" + val key = generateEncryptionKey() + val encrypted = original.encrypt(key) + val decrypted = encrypted.decrypt(key) + assertEquals("Hello world!", decrypted) + } + + @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() + + val key = generateEncryptionKey() + val encrypted = original.encryptToString(key) + + val decrypted = File.createTempFile("file", ".txt") + encrypted.decryptToFile(key, decrypted) + assertEquals("hello world\nencryption test\n", decrypted.readText()) + } +} diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt new file mode 100644 index 000000000..8beb90c5b --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2016-2020 Álinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.utils + +import android.util.* +import java.io.* +import java.nio.* +import java.nio.charset.StandardCharsets.* +import javax.crypto.* +import javax.crypto.spec.* + +fun ByteArray.encrypt(key: String): ByteArray { + val keySpec = SecretKeySpec(Base64.decode(key, Base64.DEFAULT), "AES") + val cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING") + cipher.init(Cipher.ENCRYPT_MODE, keySpec) + val encrypted = cipher.doFinal(this) + return ByteBuffer + .allocate(16 + encrypted.size) + .put(cipher.iv) + .put(encrypted) + .array() +} + +fun ByteArray.decrypt(key: String): ByteArray { + val buffer = ByteBuffer.wrap(this) + val iv = ByteArray(16) + buffer.get(iv) + val encrypted = ByteArray(buffer.remaining()) + buffer.get(encrypted) + val keySpec = SecretKeySpec(Base64.decode(key, Base64.DEFAULT), "AES") + val cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING") + cipher.init(Cipher.DECRYPT_MODE, keySpec, IvParameterSpec(iv)) + return cipher.doFinal(encrypted) +} + +fun String.encrypt(key: String): String { + return Base64.encodeToString(this.toByteArray().encrypt(key), Base64.DEFAULT) +} + +fun String.decrypt(key: String): String { + return String(Base64.decode(this, Base64.DEFAULT).decrypt(key), UTF_8) +} + +fun String.decryptToFile(key: String, output: File) +{ + val outputStream = FileOutputStream(output) + output.writeBytes(Base64.decode(this, Base64.DEFAULT).decrypt(key)) + outputStream.close() +} + +fun File.encryptToString(key: String): String { + val bytes = ByteArray(this.length().toInt()) + val inputStream = FileInputStream(this) + inputStream.read(bytes) + inputStream.close() + return Base64.encodeToString(bytes.encrypt(key), Base64.DEFAULT) +} + +fun generateEncryptionKey(): String { + return try { + val keygen = KeyGenerator.getInstance("AES") + keygen.init(256) + val key = keygen.generateKey() + Base64.encodeToString(key.encoded, Base64.DEFAULT) + } catch (e: Exception) { + throw RuntimeException(e) + } +} \ No newline at end of file From 576a334dc9c140377de3526ce44d5413a7ba1e1a Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 24 Nov 2020 06:32:21 -0600 Subject: [PATCH 10/31] Update sync intent-filter --- .../src/main/AndroidManifest.xml | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/android/uhabits-android/src/main/AndroidManifest.xml b/android/uhabits-android/src/main/AndroidManifest.xml index a054ce37d..189b8d8b6 100644 --- a/android/uhabits-android/src/main/AndroidManifest.xml +++ b/android/uhabits-android/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - - @@ -25,7 +25,6 @@ - + android:launchMode="singleTop"> + + + + + + + +
- - - - - - - + - @@ -234,14 +237,14 @@ - - + + + android:scheme="content" /> - + From 35ca041bc2d0dd6ea45bee9e8b687876d51851c7 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 24 Nov 2020 06:51:08 -0600 Subject: [PATCH 11/31] EncExt: Trim keys --- .../src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt index 8beb90c5b..eb72eac19 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt @@ -78,7 +78,7 @@ fun generateEncryptionKey(): String { val keygen = KeyGenerator.getInstance("AES") keygen.init(256) val key = keygen.generateKey() - Base64.encodeToString(key.encoded, Base64.DEFAULT) + Base64.encodeToString(key.encoded, Base64.DEFAULT).trim() } catch (e: Exception) { throw RuntimeException(e) } From 06e5d517cca2404079f756d06030b816364d42d0 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 24 Nov 2020 06:51:34 -0600 Subject: [PATCH 12/31] Downgrade Ktor --- server/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/gradle.properties b/server/gradle.properties index 9df9c1449..6ea1a269c 100644 --- a/server/gradle.properties +++ b/server/gradle.properties @@ -17,7 +17,7 @@ # with this program. If not, see . # -ktor_version=1.4.2 +ktor_version=1.4.1 kotlin.code.style=official kotlin_version=1.4.10 logback_version=1.2.1 From 0de86ac66cbc4779c5dc18b2fbe459a25452428f Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 24 Nov 2020 06:53:20 -0600 Subject: [PATCH 13/31] Update widgets at most once per minute --- .../main/java/org/isoron/uhabits/widgets/WidgetUpdater.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/WidgetUpdater.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/WidgetUpdater.kt index 3427bce26..81e3619ab 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/WidgetUpdater.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/WidgetUpdater.kt @@ -28,6 +28,7 @@ import org.isoron.uhabits.core.tasks.* import org.isoron.uhabits.core.utils.* import org.isoron.uhabits.intents.* import javax.inject.* +import kotlin.math.* /** * A WidgetUpdater listens to the commands being executed by the application and @@ -42,6 +43,8 @@ class WidgetUpdater private val intentScheduler: IntentScheduler ) : CommandRunner.Listener { + 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) From b1560dd69440596c1f90befd6abdc7efcc8709eb Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 24 Nov 2020 06:54:28 -0600 Subject: [PATCH 14/31] LoopDBImporter: Use commands instead of directly modifying DB --- .../uhabits/core/io/LoopDBImporter.java | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java index 31553d75f..329e88681 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java @@ -21,6 +21,7 @@ package org.isoron.uhabits.core.io; import androidx.annotation.*; +import org.isoron.uhabits.core.commands.*; import org.isoron.uhabits.core.database.*; import org.isoron.uhabits.core.models.*; import org.isoron.uhabits.core.models.sqlite.records.*; @@ -42,15 +43,19 @@ public class LoopDBImporter extends AbstractImporter @NonNull private final DatabaseOpener opener; + @NonNull + private final CommandRunner runner; @Inject public LoopDBImporter(@NonNull HabitList habitList, @NonNull ModelFactory modelFactory, - @NonNull DatabaseOpener opener) + @NonNull DatabaseOpener opener, + @NonNull CommandRunner runner) { super(habitList); this.modelFactory = modelFactory; this.opener = opener; + this.runner = runner; } @Override @@ -97,19 +102,34 @@ public class LoopDBImporter extends AbstractImporter repsRepository = new Repository<>(RepetitionRecord.class, db); for (HabitRecord habitRecord : habitsRepository.findAll( - "order by position")) + "order by position")) { - Habit h = modelFactory.buildHabit(); - habitRecord.copyTo(h); - h.setId(null); - habitList.add(h); + Habit habit = habitList.getById(habitRecord.id); + if (habit == null) + { + habit = modelFactory.buildHabit(); + habitRecord.copyTo(habit); + runner.execute(new CreateHabitCommand(modelFactory, habitList, habit), null); + } + else + { + Habit modified = modelFactory.buildHabit(); + habitRecord.copyTo(modified); + if (!modified.getData().equals(habit.getData())) + runner.execute(new EditHabitCommand(modelFactory, habitList, habit, modified), null); + } List reps = - repsRepository.findAll("where habit = ?", - habitRecord.id.toString()); + repsRepository.findAll("where habit = ?", + habitRecord.id.toString()); for (RepetitionRecord r : reps) - h.getRepetitions().toggle(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) + runner.execute(new CreateRepetitionCommand(habitList, habit, t, r.value), habit.id); + } } } } From 659c528744b67eb3243ebbd201c9d0b566781723 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 24 Nov 2020 06:55:37 -0600 Subject: [PATCH 15/31] SyncManager: First version --- .../uhabits/HabitsApplicationComponent.java | 3 + .../habits/list/ListHabitsActivity.kt | 7 +- .../habits/list/ListHabitsScreen.kt | 9 +- .../activities/settings/SettingsFragment.java | 16 ++- .../uhabits/activities/sync/SyncActivity.kt | 17 +++- .../isoron/uhabits/sync/RemoteSyncServer.kt | 6 +- .../org/isoron/uhabits/sync/SyncManager.kt | 99 +++++++++++++++++++ .../src/main/res/values/strings.xml | 1 + .../src/main/res/xml/preferences.xml | 7 ++ .../uhabits/core/preferences/Preferences.java | 15 +++ .../habits/list/ListHabitsBehavior.java | 11 ++- 11 files changed, 166 insertions(+), 25 deletions(-) create mode 100644 android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplicationComponent.java b/android/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplicationComponent.java index cc5e55b20..f20c9254f 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplicationComponent.java +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplicationComponent.java @@ -34,6 +34,7 @@ import org.isoron.uhabits.core.ui.screens.habits.list.*; import org.isoron.uhabits.core.utils.*; import org.isoron.uhabits.intents.*; import org.isoron.uhabits.receivers.*; +import org.isoron.uhabits.sync.*; import org.isoron.uhabits.tasks.*; import org.isoron.uhabits.widgets.*; @@ -85,4 +86,6 @@ public interface HabitsApplicationComponent WidgetPreferences getWidgetPreferences(); WidgetUpdater getWidgetUpdater(); + + SyncManager getSyncManager(); } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt index 227aeb674..61179d467 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt @@ -28,6 +28,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 +39,12 @@ class ListHabitsActivity : HabitsActivity() { lateinit var screen: ListHabitsScreen lateinit var prefs: Preferences lateinit var midnightTimer: MidnightTimer + lateinit var syncManager: SyncManager 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 +61,7 @@ class ListHabitsActivity : HabitsActivity() { midnightTimer.onPause() screen.onDettached() adapter.cancelRefresh() + syncManager.onPause() super.onPause() } @@ -66,14 +70,13 @@ class ListHabitsActivity : HabitsActivity() { screen.onAttached() rootView.postInvalidate() midnightTimer.onResume() + syncManager.onResume() taskRunner.run { AutoBackup(this@ListHabitsActivity).run() } - if (prefs.theme == THEME_DARK && prefs.isPureBlackEnabled != pureBlack) { restartWithFade(ListHabitsActivity::class.java) } - super.onResume() } } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt index 4d2d32602..eac956516 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt @@ -85,9 +85,11 @@ class ListHabitsScreen commandRunner.addListener(this) if(activity.intent.action == "android.intent.action.VIEW") { val uri = activity.intent.data!!.toString() - val key = uri.replace(Regex("^.*sync/"), "") - Log.i("ListHabitsScreen", key) - behavior.get().onSyncKeyOffer(key) + 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) } } @@ -185,6 +187,7 @@ class ListHabitsScreen 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 }) } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java index 64153beda..5847f2395 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java @@ -158,12 +158,6 @@ public class SettingsFragment extends PreferenceFragmentCompat private void updateSyncPreferences() { - if(prefs.getSyncKey().isEmpty()) { - prefs.setSyncEnabled(false); - ((CheckBoxPreference) findPreference("pref_sync_enabled")).setChecked(false); - } - findPreference("pref_sync_base_url").setSummary(prefs.getSyncBaseURL()); - findPreference("pref_sync_key").setSummary(prefs.getSyncKey()); findPreference("pref_sync_display").setVisible(prefs.isSyncEnabled()); } @@ -190,11 +184,15 @@ public class SettingsFragment extends PreferenceFragmentCompat } if (key.equals("pref_sync_enabled")) { - Context context = getActivity(); if (prefs.isSyncEnabled()) { - Intent intent = new IntentFactory().startSyncActivity(context); - context.startActivity(intent); + Context context = getActivity(); + context.startActivity(new IntentFactory().startSyncActivity(context)); + } + else + { + prefs.setEncryptionKey(""); + prefs.setSyncKey(""); } } BackupManager.dataChanged("org.isoron.uhabits"); diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt index 7d04793be..3a86bae06 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt @@ -32,15 +32,16 @@ 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.activities.* 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 @@ -56,6 +57,7 @@ class SyncActivity : BaseActivity() { val component = (application as HabitsApplication).component taskRunner = component.taskRunner preferences = component.preferences + syncManager = component.syncManager binding = ActivitySyncBinding.inflate(layoutInflater) setContentView(binding.root) @@ -84,20 +86,26 @@ class SyncActivity : BaseActivity() { } private fun displayCurrentKey() { - displayLink("https://loophabits.org/sync/${preferences.syncKey}") + displayLink("https://loophabits.org/sync/${preferences.syncKey}#${preferences.encryptionKey}") displayPassword("6B2W9F5X") } private fun register() { displayLoading() taskRunner.execute(object : Task { - private var key = "" + private lateinit var encKey: String + private lateinit var syncKey: String private var error = false override fun doInBackground() { runBlocking { val server = RemoteSyncServer(baseURL = preferences.syncBaseURL) try { - key = server.register() + syncKey = server.register() + encKey = generateEncryptionKey() + preferences.isSyncEnabled = true + preferences.encryptionKey = encKey + preferences.syncKey = syncKey; + syncManager.sync() } catch (e: ServiceUnavailable) { error = true } @@ -109,7 +117,6 @@ class SyncActivity : BaseActivity() { displayError() return; } - preferences.syncKey = key; displayCurrentKey() } }) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt index bfe1b5714..bc46d8189 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt @@ -46,7 +46,7 @@ class RemoteSyncServer( override suspend fun put(key: String, newData: SyncData) { try { - val response: String = httpClient.put("$baseURL/$key") { + val response: String = httpClient.put("$baseURL/db/$key") { header("Content-Type", "application/json") body = newData } @@ -59,7 +59,7 @@ class RemoteSyncServer( override suspend fun getData(key: String): SyncData { try { - return httpClient.get("$baseURL/$key") + return httpClient.get("$baseURL/db/$key") } catch (e: ServerResponseException) { throw ServiceUnavailable() } catch (e: ClientRequestException) { @@ -69,7 +69,7 @@ class RemoteSyncServer( override suspend fun getDataVersion(key: String): Long { try { - val response: GetDataVersionResponse = httpClient.get("$baseURL/$key/version") + val response: GetDataVersionResponse = httpClient.get("$baseURL/db/$key/version") return response.version } catch(e: ServerResponseException) { throw ServiceUnavailable() diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt new file mode 100644 index 000000000..b2744e94d --- /dev/null +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2016-2020 Álinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync + +import android.content.* +import android.util.* +import kotlinx.coroutines.* +import org.isoron.androidbase.* +import org.isoron.uhabits.core.* +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, + val taskRunner: TaskRunner, + val importDataTaskFactory: ImportDataTaskFactory, + @AppContext val context: Context +) : Preferences.Listener { + + private val server = RemoteSyncServer() + private val tmpFile = File.createTempFile("import", "", context.externalCacheDir) + private var currVersion = 0L + + init { + preferences.addListener(this) + } + + + fun sync() { + if(!preferences.isSyncEnabled) { + Log.i("SyncManager", "Device sync is disabled. Skipping sync") + return + } + taskRunner.execute { + runBlocking { + try { + Log.i("SyncManager", "Starting sync (key: ${preferences.syncKey})") + fetchAndMerge() + upload() + Log.i("SyncManager", "Sync finished") + } catch (e: Exception) { + Log.e("SyncManager", "Unexpected sync exception. Disabling sync", e) + preferences.isSyncEnabled = false + preferences.syncKey = "" + preferences.encryptionKey = "" + } + return@runBlocking + } + } + } + + suspend fun upload() { + Log.i("SyncManager", "Encrypting database...") + val db = DatabaseUtils.getDatabaseFile(context) + val encryptedDB = db.encryptToString(preferences.encryptionKey) + Log.i("SyncManager", "Uploading database (version ${currVersion}, ${encryptedDB.length / 1024} KB)") + server.put(preferences.syncKey, SyncData(currVersion, encryptedDB)) + } + + suspend fun fetchAndMerge() { + Log.i("SyncManager", "Fetching database from server...") + val data = server.getData(preferences.syncKey) + Log.i("SyncManager", "Fetched database (version ${data.version}, ${data.content.length / 1024} KB)") + if (data.version <= currVersion) { + Log.i("SyncManager", "Local version is up-to-date. Skipping merge.") + } else { + Log.i("SyncManager", "Decrypting and merging with local changes...") + data.content.decryptToFile(preferences.encryptionKey, tmpFile) + taskRunner.execute(importDataTaskFactory.create(tmpFile) { tmpFile.delete() }) + } + currVersion = data.version + 1 + } + + fun onResume() = sync() + fun onPause() = sync() + override fun onSyncEnabled() = sync() +} \ No newline at end of file diff --git a/android/uhabits-android/src/main/res/values/strings.xml b/android/uhabits-android/src/main/res/values/strings.xml index 31afe7709..f6e6e1426 100644 --- a/android/uhabits-android/src/main/res/values/strings.xml +++ b/android/uhabits-android/src/main/res/values/strings.xml @@ -214,4 +214,5 @@ Copied to the clipboard Device sync enabled + Sync key already installed \ No newline at end of file diff --git a/android/uhabits-android/src/main/res/xml/preferences.xml b/android/uhabits-android/src/main/res/xml/preferences.xml index e311649a6..e805fc985 100644 --- a/android/uhabits-android/src/main/res/xml/preferences.xml +++ b/android/uhabits-android/src/main/res/xml/preferences.xml @@ -235,6 +235,13 @@ app:iconSpaceReserved="false" /> + + \ No newline at end of file diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java index af1a37bba..23ce47d5c 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java @@ -324,6 +324,16 @@ public class Preferences storage.putString("pref_sync_key", key); } + public String getEncryptionKey() + { + return storage.getString("pref_encryption_key", ""); + } + + public void setEncryptionKey(String key) + { + storage.putString("pref_encryption_key", key); + } + public boolean isSyncEnabled() { return storage.getBoolean("pref_sync_enabled", false); @@ -332,6 +342,7 @@ public class Preferences public void setSyncEnabled(boolean enabled) { storage.putBoolean("pref_sync_enabled", enabled); + if(enabled) for (Listener l : listeners) l.onSyncEnabled(); } @@ -357,6 +368,10 @@ public class Preferences default void onNotificationsChanged() { } + + default void onSyncEnabled() + { + } } public interface Storage diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java index 6f6c3c5a2..fb52b006f 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java @@ -158,10 +158,15 @@ public class ListHabitsBehavior habit.getId()); } - public void onSyncKeyOffer(@NotNull String key) + 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.setSyncKey(key); + prefs.setSyncKey(syncKey); + prefs.setEncryptionKey(encryptionKey); prefs.setSyncEnabled(true); screen.showMessage(Message.SYNC_ENABLED); }); @@ -170,7 +175,7 @@ public class ListHabitsBehavior public enum Message { COULD_NOT_EXPORT, IMPORT_SUCCESSFUL, IMPORT_FAILED, DATABASE_REPAIRED, - COULD_NOT_GENERATE_BUG_REPORT, FILE_NOT_RECOGNIZED, SYNC_ENABLED + COULD_NOT_GENERATE_BUG_REPORT, FILE_NOT_RECOGNIZED, SYNC_ENABLED, SYNC_KEY_ALREADY_INSTALLED } public interface BugReporter From 68ccf37fd51efc5cf44d613bf3e20af7c3448938 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 24 Nov 2020 07:28:16 -0600 Subject: [PATCH 16/31] Add UUID to habits --- .../habits/list/ListHabitsScreen.kt | 6 ++--- .../isoron/uhabits/tasks/ImportDataTask.java | 4 ++- .../isoron/uhabits/widgets/WidgetUpdater.kt | 2 +- .../java/org/isoron/uhabits/core/Config.java | 2 +- .../isoron/uhabits/core/commands/Command.java | 14 ----------- .../uhabits/core/commands/CommandRunner.java | 11 +++++--- .../uhabits/core/io/LoopDBImporter.java | 25 ++++++++++++------- .../org/isoron/uhabits/core/models/Habit.java | 24 ++++++++++++++++-- .../isoron/uhabits/core/models/HabitList.java | 9 +++++++ .../core/models/memory/MemoryHabitList.java | 7 ++++++ .../core/models/sqlite/SQLiteHabitList.java | 8 ++++++ .../models/sqlite/records/HabitRecord.java | 5 ++++ .../core/reminders/ReminderScheduler.java | 2 +- .../uhabits/core/ui/NotificationTray.java | 2 +- .../src/main/resources/migrations/24.sql | 2 ++ .../org/isoron/uhabits/core/BaseUnitTest.java | 2 +- .../isoron/uhabits/core/io/ImportTest.java | 2 +- .../isoron/uhabits/core/models/HabitTest.java | 3 ++- 18 files changed, 91 insertions(+), 39 deletions(-) create mode 100644 android/uhabits-core/src/main/resources/migrations/24.sql diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt index eac956516..9bac43d59 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt @@ -97,9 +97,9 @@ class ListHabitsScreen 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?) { diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.java b/android/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.java index 6740e2ab4..db56a3857 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.java +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.java @@ -19,6 +19,8 @@ package org.isoron.uhabits.tasks; +import android.util.*; + import androidx.annotation.NonNull; import com.google.auto.factory.*; @@ -83,7 +85,7 @@ public class ImportDataTask implements Task catch (Exception e) { result = FAILED; - e.printStackTrace(); + Log.e("ImportDataTask", "Import failed", e); } modelFactory.db.endTransaction(); diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/WidgetUpdater.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/WidgetUpdater.kt index 81e3619ab..c28cfa68f 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/WidgetUpdater.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/WidgetUpdater.kt @@ -45,7 +45,7 @@ class WidgetUpdater private var lastUpdated = 0L - override fun onCommandExecuted(command: Command, refreshKey: Long?) { + override fun onCommandExecuted(command: Command?, refreshKey: Long?) { updateWidgets(refreshKey) } diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/Config.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/Config.java index 286755a33..1a5383cee 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/Config.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/Config.java @@ -22,5 +22,5 @@ package org.isoron.uhabits.core; public class Config { public static final String DATABASE_FILENAME = "uhabits.db"; - public static int DATABASE_VERSION = 23; + public static int DATABASE_VERSION = 24; } diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/Command.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/Command.java index f8d7feb16..37f1d9254 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/Command.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/Command.java @@ -38,18 +38,14 @@ public abstract class Command { private String id; - private boolean isRemote; - public Command() { id = StringUtils.getRandomId(); - isRemote = false; } public Command(String id) { this.id = id; - isRemote = false; } public abstract void execute(); @@ -64,16 +60,6 @@ public abstract class Command this.id = id; } - public boolean isRemote() - { - return isRemote; - } - - public void setRemote(boolean remote) - { - isRemote = remote; - } - @NonNull public String toJson() { diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/CommandRunner.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/CommandRunner.java index a70500c87..5e46ebe32 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/CommandRunner.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/commands/CommandRunner.java @@ -66,12 +66,17 @@ public class CommandRunner @Override public void onPostExecute() { - for (Listener l : listeners) - l.onCommandExecuted(command, refreshKey); + notifyListeners(command, refreshKey); } }); } + public void notifyListeners(Command command, Long refreshKey) + { + for (Listener l : listeners) + l.onCommandExecuted(command, refreshKey); + } + public void removeListener(Listener l) { listeners.remove(l); @@ -83,7 +88,7 @@ public class CommandRunner */ public interface Listener { - void onCommandExecuted(@NonNull Command command, + void onCommandExecuted(@Nullable Command command, @Nullable Long refreshKey); } } diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java index 329e88681..59aec5010 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java @@ -101,35 +101,42 @@ public class LoopDBImporter extends AbstractImporter habitsRepository = new Repository<>(HabitRecord.class, db); repsRepository = new Repository<>(RepetitionRecord.class, db); - for (HabitRecord habitRecord : habitsRepository.findAll( - "order by position")) + List records = habitsRepository.findAll("order by position"); + for (HabitRecord habitRecord : records) { - Habit habit = habitList.getById(habitRecord.id); + List reps = + 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); - runner.execute(new CreateHabitCommand(modelFactory, habitList, habit), null); + new CreateHabitCommand(modelFactory, habitList, habit).execute(); } else { Habit modified = modelFactory.buildHabit(); + habitRecord.id = habit.id; habitRecord.copyTo(modified); if (!modified.getData().equals(habit.getData())) - runner.execute(new EditHabitCommand(modelFactory, habitList, habit, modified), null); + new EditHabitCommand(modelFactory, habitList, habit, modified).execute(); } - List reps = - repsRepository.findAll("where habit = ?", - habitRecord.id.toString()); + // Reload saved version of the habit + habit = habitList.getByUUID(habitRecord.uuid); for (RepetitionRecord r : reps) { Timestamp t = new Timestamp(r.timestamp); Repetition rep = habit.getRepetitions().getByTimestamp(t); if(rep == null || rep.getValue() != r.value) - runner.execute(new CreateRepetitionCommand(habitList, habit, t, r.value), habit.id); + new CreateRepetitionCommand(habitList, habit, t, r.value).execute(); } } + + runner.notifyListeners(null, null); } } diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/Habit.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/Habit.java index 6d2a555cd..2eef8245e 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/Habit.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/Habit.java @@ -356,14 +356,27 @@ public class Habit } @NonNull - public String getQuestion() { + public String getQuestion() + { return data.question; } - public void setQuestion(@NonNull String question) { + public void setQuestion(@NonNull String question) + { data.question = question; } + @NonNull + public String getUUID() + { + return data.uuid; + } + + public void setUUID(@NonNull String uuid) + { + data.uuid = uuid; + } + public static final class HabitData { @NonNull @@ -388,6 +401,8 @@ public class Habit public int type; + public String uuid; + @NonNull public String unit; @@ -409,6 +424,7 @@ public class Habit this.targetValue = 100; this.unit = ""; this.position = 0; + this.uuid = UUID.randomUUID().toString().replace("-", ""); } public HabitData(@NonNull HabitData model) @@ -425,6 +441,7 @@ public class Habit this.unit = model.unit; this.reminder = model.reminder; this.position = model.position; + this.uuid = model.uuid; } @Override @@ -443,6 +460,7 @@ public class Habit .append("reminder", reminder) .append("position", position) .append("question", question) + .append("uuid", uuid) .toString(); } @@ -468,6 +486,7 @@ public class Habit .append(reminder, habitData.reminder) .append(position, habitData.position) .append(question, habitData.question) + .append(uuid, habitData.uuid) .isEquals(); } @@ -487,6 +506,7 @@ public class Habit .append(reminder) .append(position) .append(question) + .append(uuid) .toHashCode(); } } diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/HabitList.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/HabitList.java index 8f19258d0..47b3a1cef 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/HabitList.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/HabitList.java @@ -83,6 +83,15 @@ public abstract class HabitList implements Iterable @Nullable public abstract Habit getById(long id); + /** + * Returns the habit with specified UUID. + * + * @param uuid the UUID of the habit + * @return the habit, or null if none exist + */ + @Nullable + public abstract Habit getByUUID(String uuid); + /** * Returns the habit that occupies a certain position. * diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/memory/MemoryHabitList.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/memory/MemoryHabitList.java index 84716e771..503d5278d 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/memory/MemoryHabitList.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/memory/MemoryHabitList.java @@ -94,6 +94,13 @@ public class MemoryHabitList extends HabitList return null; } + @Override + public synchronized Habit getByUUID(String uuid) + { + for (Habit h : list) if (h.getUUID().equals(uuid)) return h; + return null; + } + @NonNull @Override public synchronized Habit getByPosition(int position) diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.java index 1ba429bfd..f940c436a 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.java @@ -98,6 +98,14 @@ public class SQLiteHabitList extends HabitList return list.getById(id); } + @Override + @Nullable + public synchronized Habit getByUUID(String uuid) + { + loadRecords(); + return list.getByUUID(uuid); + } + @Override @NonNull public synchronized Habit getByPosition(int position) diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.java index 523da0542..93f9a331b 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.java @@ -81,6 +81,9 @@ public class HabitRecord @Column public Long id; + @Column + public String uuid; + public void copyFrom(Habit model) { this.id = model.getId(); @@ -95,6 +98,7 @@ public class HabitRecord this.unit = model.getUnit(); this.position = model.getPosition(); this.question = model.getQuestion(); + this.uuid = model.getUUID(); Frequency freq = model.getFrequency(); this.freqNum = freq.getNumerator(); @@ -126,6 +130,7 @@ public class HabitRecord habit.setTargetValue(this.targetValue); habit.setUnit(this.unit); habit.setPosition(this.position); + habit.setUUID(this.uuid); if (reminderHour != null && reminderMin != null) { diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/reminders/ReminderScheduler.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/reminders/ReminderScheduler.java index 35fa3140b..89f2b7154 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/reminders/ReminderScheduler.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/reminders/ReminderScheduler.java @@ -56,7 +56,7 @@ public class ReminderScheduler implements CommandRunner.Listener } @Override - public synchronized void onCommandExecuted(@NonNull Command command, + public synchronized void onCommandExecuted(@Nullable Command command, @Nullable Long refreshKey) { if (command instanceof CreateRepetitionCommand) return; diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/NotificationTray.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/NotificationTray.java index 4ada13756..4adb888a8 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/NotificationTray.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/NotificationTray.java @@ -73,7 +73,7 @@ public class NotificationTray } @Override - public void onCommandExecuted(@NonNull Command command, + public void onCommandExecuted(@Nullable Command command, @Nullable Long refreshKey) { if (command instanceof CreateRepetitionCommand) diff --git a/android/uhabits-core/src/main/resources/migrations/24.sql b/android/uhabits-core/src/main/resources/migrations/24.sql new file mode 100644 index 000000000..a8c13d6c8 --- /dev/null +++ b/android/uhabits-core/src/main/resources/migrations/24.sql @@ -0,0 +1,2 @@ +alter table habits add column uuid text; +update habits set uuid = lower(hex(randomblob(16) || id)); \ No newline at end of file diff --git a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/BaseUnitTest.java b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/BaseUnitTest.java index c377d3187..7e9d6e813 100644 --- a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/BaseUnitTest.java +++ b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/BaseUnitTest.java @@ -127,7 +127,7 @@ public class BaseUnitTest DriverManager.getConnection("jdbc:sqlite::memory:")); db.execute("pragma user_version=8;"); MigrationHelper helper = new MigrationHelper(db); - helper.migrateTo(23); + helper.migrateTo(Config.DATABASE_VERSION); return db; } catch (SQLException e) diff --git a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/io/ImportTest.java b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/io/ImportTest.java index 1fa3ba513..8c95ff082 100644 --- a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/io/ImportTest.java +++ b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/io/ImportTest.java @@ -132,7 +132,7 @@ public class ImportTest extends BaseUnitTest assertTrue(file.canRead()); GenericImporter importer = new GenericImporter(habitList, - new LoopDBImporter(habitList, modelFactory, databaseOpener), + new LoopDBImporter(habitList, modelFactory, databaseOpener, commandRunner), new RewireDBImporter(habitList, modelFactory, databaseOpener), new TickmateDBImporter(habitList, modelFactory, databaseOpener), new HabitBullCSVImporter(habitList, modelFactory)); diff --git a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/models/HabitTest.java b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/models/HabitTest.java index e99e954c0..7cab255bc 100644 --- a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/models/HabitTest.java +++ b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/models/HabitTest.java @@ -148,6 +148,7 @@ public class HabitTest extends BaseUnitTest public void testToString() throws Exception { Habit h = modelFactory.buildHabit(); + h.setUUID("nnnn"); h.setReminder(new Reminder(22, 30, WeekdayList.EVERY_DAY)); String expected = "{id: , data: {name: , description: ," + " frequency: {numerator: 3, denominator: 7}," + @@ -155,7 +156,7 @@ public class HabitTest extends BaseUnitTest " targetValue: 100.0, type: 0, unit: ," + " reminder: {hour: 22, minute: 30," + " days: {weekdays: [true,true,true,true,true,true,true]}}," + - " position: 0, question: }}"; + " position: 0, question: , uuid: nnnn}}"; assertThat(h.toString(), equalTo(expected)); } From 53ebdf4f14c4ea6c41609093aeab2ea8fa0c4cdc Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Thu, 26 Nov 2020 23:43:47 -0600 Subject: [PATCH 17/31] Skip upload if database has not changed --- .../org/isoron/uhabits/sync/SyncManager.kt | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt index b2744e94d..262c00fe8 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt @@ -24,6 +24,7 @@ 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.* @@ -36,18 +37,20 @@ class SyncManager @Inject constructor( val preferences: Preferences, val taskRunner: TaskRunner, val importDataTaskFactory: ImportDataTaskFactory, + val commandRunner: CommandRunner, @AppContext val context: Context -) : Preferences.Listener { +) : Preferences.Listener, CommandRunner.Listener { private val server = RemoteSyncServer() private val tmpFile = File.createTempFile("import", "", context.externalCacheDir) private var currVersion = 0L + private var dirty = true init { preferences.addListener(this) + commandRunner.addListener(this) } - fun sync() { if(!preferences.isSyncEnabled) { Log.i("SyncManager", "Device sync is disabled. Skipping sync") @@ -72,11 +75,16 @@ class SyncManager @Inject constructor( } suspend fun upload() { + if(!dirty) { + Log.i("SyncManager", "Database not dirty. Skipping upload.") + return + } Log.i("SyncManager", "Encrypting database...") val db = DatabaseUtils.getDatabaseFile(context) val encryptedDB = db.encryptToString(preferences.encryptionKey) Log.i("SyncManager", "Uploading database (version ${currVersion}, ${encryptedDB.length / 1024} KB)") server.put(preferences.syncKey, SyncData(currVersion, encryptedDB)) + dirty = false } suspend fun fetchAndMerge() { @@ -93,7 +101,16 @@ class SyncManager @Inject constructor( currVersion = data.version + 1 } - fun onResume() = sync() - fun onPause() = sync() + fun onResume() { + sync() + } + + fun onPause() { + sync() + } override fun onSyncEnabled() = sync() + + override fun onCommandExecuted(command: Command?, refreshKey: Long?) { + dirty = true + } } \ No newline at end of file From 67ef3bb90c8bedd987dd980921eefd8ba22c4b31 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 27 Nov 2020 09:13:17 -0600 Subject: [PATCH 18/31] SyncManager: Switch to coroutines --- android/gradle.properties | 3 +- android/uhabits-android/build.gradle | 2 + .../habits/list/ListHabitsActivity.kt | 10 ++++- .../isoron/uhabits/sync/RemoteSyncServer.kt | 16 +++---- .../org/isoron/uhabits/sync/SyncManager.kt | 42 +++++++++---------- 5 files changed, 42 insertions(+), 31 deletions(-) diff --git a/android/gradle.properties b/android/gradle.properties index 5659e1893..b872d9d12 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -6,7 +6,8 @@ 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 diff --git a/android/uhabits-android/build.gradle b/android/uhabits-android/build.gradle index c1f7b6659..2ac303436 100644 --- a/android/uhabits-android/build.gradle +++ b/android/uhabits-android/build.gradle @@ -92,6 +92,8 @@ 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" diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt index 61179d467..b40b71ef0 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt @@ -20,6 +20,7 @@ package org.isoron.uhabits.activities.habits.list import android.os.* +import kotlinx.coroutines.* import org.isoron.uhabits.* import org.isoron.uhabits.activities.* import org.isoron.uhabits.activities.habits.list.views.* @@ -40,6 +41,7 @@ class ListHabitsActivity : HabitsActivity() { 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) @@ -61,7 +63,9 @@ class ListHabitsActivity : HabitsActivity() { midnightTimer.onPause() screen.onDettached() adapter.cancelRefresh() - syncManager.onPause() + scope.launch { + syncManager.onPause() + } super.onPause() } @@ -70,7 +74,9 @@ class ListHabitsActivity : HabitsActivity() { screen.onAttached() rootView.postInvalidate() midnightTimer.onResume() - syncManager.onResume() + scope.launch { + syncManager.onResume() + } taskRunner.run { AutoBackup(this@ListHabitsActivity).run() } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt index bc46d8189..1e0e16781 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt @@ -24,6 +24,7 @@ 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.* data class RegisterReponse(val key: String) data class GetDataVersionResponse(val version: Long) @@ -35,16 +36,16 @@ class RemoteSyncServer( } ) : AbstractSyncServer { - override suspend fun register(): String { + override suspend fun register(): String = Dispatchers.IO { try { val response: RegisterReponse = httpClient.post("$baseURL/register") - return response.key + return@IO response.key } catch(e: ServerResponseException) { throw ServiceUnavailable() } } - override suspend fun put(key: String, newData: SyncData) { + override suspend fun put(key: String, newData: SyncData) = Dispatchers.IO { try { val response: String = httpClient.put("$baseURL/db/$key") { header("Content-Type", "application/json") @@ -57,9 +58,10 @@ class RemoteSyncServer( } } - override suspend fun getData(key: String): SyncData { + override suspend fun getData(key: String): SyncData = Dispatchers.IO { try { - return httpClient.get("$baseURL/db/$key") + val data: SyncData = httpClient.get("$baseURL/db/$key") + return@IO data } catch (e: ServerResponseException) { throw ServiceUnavailable() } catch (e: ClientRequestException) { @@ -67,10 +69,10 @@ class RemoteSyncServer( } } - override suspend fun getDataVersion(key: String): Long { + override suspend fun getDataVersion(key: String): Long = Dispatchers.IO { try { val response: GetDataVersionResponse = httpClient.get("$baseURL/db/$key/version") - return response.version + return@IO response.version } catch(e: ServerResponseException) { throw ServiceUnavailable() } catch (e: ClientRequestException) { diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt index 262c00fe8..e28a642df 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt @@ -51,31 +51,26 @@ class SyncManager @Inject constructor( commandRunner.addListener(this) } - fun sync() { - if(!preferences.isSyncEnabled) { + suspend fun sync() { + if (!preferences.isSyncEnabled) { Log.i("SyncManager", "Device sync is disabled. Skipping sync") return } - taskRunner.execute { - runBlocking { - try { - Log.i("SyncManager", "Starting sync (key: ${preferences.syncKey})") - fetchAndMerge() - upload() - Log.i("SyncManager", "Sync finished") - } catch (e: Exception) { - Log.e("SyncManager", "Unexpected sync exception. Disabling sync", e) - preferences.isSyncEnabled = false - preferences.syncKey = "" - preferences.encryptionKey = "" - } - return@runBlocking - } + try { + Log.i("SyncManager", "Starting sync (key: ${preferences.syncKey})") + fetchAndMerge() + upload() + Log.i("SyncManager", "Sync finished") + } catch (e: Exception) { + Log.e("SyncManager", "Unexpected sync exception. Disabling sync", e) + preferences.isSyncEnabled = false + preferences.syncKey = "" + preferences.encryptionKey = "" } } suspend fun upload() { - if(!dirty) { + if (!dirty) { Log.i("SyncManager", "Database not dirty. Skipping upload.") return } @@ -101,14 +96,19 @@ class SyncManager @Inject constructor( currVersion = data.version + 1 } - fun onResume() { + suspend fun onResume() { sync() } - fun onPause() { + suspend fun onPause() { sync() } - override fun onSyncEnabled() = sync() + + override fun onSyncEnabled() { + CoroutineScope(Dispatchers.Main).launch { + sync() + } + } override fun onCommandExecuted(command: Command?, refreshKey: Long?) { dirty = true From ce0cbb6ee2f539ecc3a3a4a5ff4a25d21d8b7de7 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 27 Nov 2020 10:55:55 -0600 Subject: [PATCH 19/31] Sync: Improve encryption and preferences API --- android/uhabits-android/build.gradle | 2 +- .../isoron/uhabits/utils/EncryptionExtTest.kt | 11 +- .../activities/settings/SettingsFragment.java | 27 ++-- .../uhabits/activities/sync/SyncActivity.kt | 9 +- .../org/isoron/uhabits/sync/SyncManager.kt | 29 +++-- .../org/isoron/uhabits/utils/EncryptionExt.kt | 116 ++++++++++++------ .../src/main/res/xml/preferences.xml | 2 +- .../uhabits/core/preferences/Preferences.java | 23 ++-- .../habits/list/ListHabitsBehavior.java | 4 +- 9 files changed, 130 insertions(+), 93 deletions(-) diff --git a/android/uhabits-android/build.gradle b/android/uhabits-android/build.gradle index 2ac303436..6d1d802de 100644 --- a/android/uhabits-android/build.gradle +++ b/android/uhabits-android/build.gradle @@ -100,6 +100,7 @@ dependencies { 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" implementation 'androidx.constraintlayout:constraintlayout:1.1.3' compileOnly "javax.annotation:jsr250-api:1.0" @@ -118,7 +119,6 @@ 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") diff --git a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt index 258022b24..48f37d75f 100644 --- a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt +++ b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt @@ -23,15 +23,6 @@ import org.junit.* import java.io.* class EncryptionExtTest : BaseAndroidTest() { - @Test - fun test_encrypt_decrypt_string() { - val original = "Hello world!" - val key = generateEncryptionKey() - val encrypted = original.encrypt(key) - val decrypted = encrypted.decrypt(key) - assertEquals("Hello world!", decrypted) - } - @Test fun test_encrypt_decrypt_file() { val original = File.createTempFile("file", ".txt") @@ -40,7 +31,7 @@ class EncryptionExtTest : BaseAndroidTest() { writer.println("encryption test") writer.close() - val key = generateEncryptionKey() + val key = EncryptionKey.generate() val encrypted = original.encryptToString(key) val decrypted = File.createTempFile("file", ".txt") diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java index 5847f2395..2b7f00070 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java @@ -128,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); } @@ -159,6 +171,7 @@ public class SettingsFragment extends PreferenceFragmentCompat private void updateSyncPreferences() { findPreference("pref_sync_display").setVisible(prefs.isSyncEnabled()); + ((CheckBoxPreference) findPreference("pref_sync_enabled_dummy")).setChecked(prefs.isSyncEnabled()); } private void updateWeekdayPreference() @@ -182,19 +195,7 @@ public class SettingsFragment extends PreferenceFragmentCompat Log.d("SettingsFragment", "updating widgets"); widgetUpdater.updateWidgets(); } - if (key.equals("pref_sync_enabled")) - { - if (prefs.isSyncEnabled()) - { - Context context = getActivity(); - context.startActivity(new IntentFactory().startSyncActivity(context)); - } - else - { - prefs.setEncryptionKey(""); - prefs.setSyncKey(""); - } - } + BackupManager.dataChanged("org.isoron.uhabits"); updateWeekdayPreference(); updateSyncPreferences(); diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt index 3a86bae06..f09dd98b8 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt @@ -93,7 +93,7 @@ class SyncActivity : BaseActivity() { private fun register() { displayLoading() taskRunner.execute(object : Task { - private lateinit var encKey: String + private lateinit var encKey: EncryptionKey private lateinit var syncKey: String private var error = false override fun doInBackground() { @@ -101,11 +101,8 @@ class SyncActivity : BaseActivity() { val server = RemoteSyncServer(baseURL = preferences.syncBaseURL) try { syncKey = server.register() - encKey = generateEncryptionKey() - preferences.isSyncEnabled = true - preferences.encryptionKey = encKey - preferences.syncKey = syncKey; - syncManager.sync() + encKey = EncryptionKey.generate() + preferences.enableSync(syncKey, encKey.base64) } catch (e: ServiceUnavailable) { error = true } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt index e28a642df..de545a73f 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt @@ -46,6 +46,9 @@ class SyncManager @Inject constructor( private var currVersion = 0L private var dirty = true + private lateinit var encryptionKey: EncryptionKey + private lateinit var syncKey: String + init { preferences.addListener(this) commandRunner.addListener(this) @@ -53,44 +56,48 @@ class SyncManager @Inject constructor( suspend fun sync() { if (!preferences.isSyncEnabled) { - Log.i("SyncManager", "Device sync is disabled. Skipping sync") + Log.i("SyncManager", "Device sync is disabled. Skipping sync.") return } + encryptionKey = EncryptionKey.fromBase64(preferences.encryptionKey) + syncKey = preferences.syncKey try { - Log.i("SyncManager", "Starting sync (key: ${preferences.syncKey})") - fetchAndMerge() - upload() + Log.i("SyncManager", "Starting sync (key: ${encryptionKey.base64})") + pull() + push() Log.i("SyncManager", "Sync finished") } catch (e: Exception) { Log.e("SyncManager", "Unexpected sync exception. Disabling sync", e) - preferences.isSyncEnabled = false - preferences.syncKey = "" - preferences.encryptionKey = "" + preferences.disableSync() } } - suspend fun upload() { + private suspend fun push() { if (!dirty) { Log.i("SyncManager", "Database not dirty. Skipping upload.") return } Log.i("SyncManager", "Encrypting database...") val db = DatabaseUtils.getDatabaseFile(context) - val encryptedDB = db.encryptToString(preferences.encryptionKey) + val encryptedDB = db.encryptToString(encryptionKey) Log.i("SyncManager", "Uploading database (version ${currVersion}, ${encryptedDB.length / 1024} KB)") server.put(preferences.syncKey, SyncData(currVersion, encryptedDB)) dirty = false } - suspend fun fetchAndMerge() { + private suspend fun pull() { Log.i("SyncManager", "Fetching database from server...") val data = server.getData(preferences.syncKey) Log.i("SyncManager", "Fetched database (version ${data.version}, ${data.content.length / 1024} KB)") + if (data.version == 0L) { + Log.i("SyncManager", "Initial upload detected. Marking db as dirty.") + dirty = true + } if (data.version <= currVersion) { Log.i("SyncManager", "Local version is up-to-date. Skipping merge.") } else { Log.i("SyncManager", "Decrypting and merging with local changes...") - data.content.decryptToFile(preferences.encryptionKey, tmpFile) + data.content.decryptToFile(encryptionKey, tmpFile) taskRunner.execute(importDataTaskFactory.create(tmpFile) { tmpFile.delete() }) } currVersion = data.version + 1 diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt index eb72eac19..e77267ce4 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt @@ -17,19 +17,61 @@ * with this program. If not, see . */ +@file:Suppress("UnstableApiUsage") + package org.isoron.uhabits.utils import android.util.* +import com.google.common.io.* import java.io.* import java.nio.* -import java.nio.charset.StandardCharsets.* +import java.util.zip.* import javax.crypto.* import javax.crypto.spec.* -fun ByteArray.encrypt(key: String): ByteArray { - val keySpec = SecretKeySpec(Base64.decode(key, Base64.DEFAULT), "AES") +/** + * 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.decode(base64, Base64.DEFAULT), "AES") + return EncryptionKey(base64, keySpec) + } + + private fun fromSecretKey(spec: SecretKey): EncryptionKey { + val base64 = Base64.encodeToString(spec.encoded, Base64.DEFAULT).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, keySpec) + cipher.init(Cipher.ENCRYPT_MODE, key.secretKey) val encrypted = cipher.doFinal(this) return ByteBuffer .allocate(16 + encrypted.size) @@ -38,48 +80,50 @@ fun ByteArray.encrypt(key: String): ByteArray { .array() } -fun ByteArray.decrypt(key: String): ByteArray { +/** + * 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 keySpec = SecretKeySpec(Base64.decode(key, Base64.DEFAULT), "AES") val cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING") - cipher.init(Cipher.DECRYPT_MODE, keySpec, IvParameterSpec(iv)) + cipher.init(Cipher.DECRYPT_MODE, key.secretKey, IvParameterSpec(iv)) return cipher.doFinal(encrypted) } -fun String.encrypt(key: String): String { - return Base64.encodeToString(this.toByteArray().encrypt(key), Base64.DEFAULT) -} - -fun String.decrypt(key: String): String { - return String(Base64.decode(this, Base64.DEFAULT).decrypt(key), UTF_8) -} - -fun String.decryptToFile(key: String, output: File) -{ - val outputStream = FileOutputStream(output) - output.writeBytes(Base64.decode(this, Base64.DEFAULT).decrypt(key)) - outputStream.close() +/** + * 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 = Base64.decode(this, Base64.DEFAULT).decrypt(key) + ByteArrayInputStream(bytes).use { bytesInputStream -> + GZIPInputStream(bytesInputStream).use { gzipInputStream -> + FileOutputStream(output).use { fileOutputStream -> + ByteStreams.copy(gzipInputStream, fileOutputStream) + } + } + } } -fun File.encryptToString(key: String): String { - val bytes = ByteArray(this.length().toInt()) - val inputStream = FileInputStream(this) - inputStream.read(bytes) - inputStream.close() - return Base64.encodeToString(bytes.encrypt(key), Base64.DEFAULT) +/** + * 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) + val bytes = bytesOutputStream.toByteArray() + return Base64.encodeToString(bytes.encrypt(key), Base64.DEFAULT) + } + } + } } -fun generateEncryptionKey(): String { - return try { - val keygen = KeyGenerator.getInstance("AES") - keygen.init(256) - val key = keygen.generateKey() - Base64.encodeToString(key.encoded, Base64.DEFAULT).trim() - } catch (e: Exception) { - throw RuntimeException(e) - } -} \ No newline at end of file diff --git a/android/uhabits-android/src/main/res/xml/preferences.xml b/android/uhabits-android/src/main/res/xml/preferences.xml index 9c10105c3..0f1e2d654 100644 --- a/android/uhabits-android/src/main/res/xml/preferences.xml +++ b/android/uhabits-android/src/main/res/xml/preferences.xml @@ -123,7 +123,7 @@ diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java index dd36ae983..3f3688e06 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java @@ -319,30 +319,29 @@ public class Preferences return storage.getString("pref_sync_key", ""); } - public void setSyncKey(String key) - { - storage.putString("pref_sync_key", key); - } - public String getEncryptionKey() { return storage.getString("pref_encryption_key", ""); } - public void setEncryptionKey(String key) + public boolean isSyncEnabled() { - storage.putString("pref_encryption_key", key); + return storage.getBoolean("pref_sync_enabled", false); } - public boolean isSyncEnabled() + public void enableSync(String syncKey, String encKey) { - return storage.getBoolean("pref_sync_enabled", false); + 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 setSyncEnabled(boolean enabled) + public void disableSync() { - storage.putBoolean("pref_sync_enabled", enabled); - if(enabled) for (Listener l : listeners) l.onSyncEnabled(); + storage.putBoolean("pref_sync_enabled", false); + storage.putString("pref_sync_key", ""); + storage.putString("pref_encryption_key", ""); } public boolean areQuestionMarksEnabled() diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java index fb52b006f..719f9d895 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java @@ -165,9 +165,7 @@ public class ListHabitsBehavior return; } screen.showConfirmInstallSyncKey(() -> { - prefs.setSyncKey(syncKey); - prefs.setEncryptionKey(encryptionKey); - prefs.setSyncEnabled(true); + prefs.enableSync(syncKey, encryptionKey); screen.showMessage(Message.SYNC_ENABLED); }); } From 49faacda1c689e4e703ebdd8f1949f8bb53a0e7a Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 28 Nov 2020 09:44:45 -0600 Subject: [PATCH 20/31] Wrap base64 functions; close gzip stream before reading --- .../isoron/uhabits/utils/EncryptionExtTest.kt | 28 +++++++++++++++++++ .../org/isoron/uhabits/utils/EncryptionExt.kt | 14 ++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt index 48f37d75f..5569ef1f7 100644 --- a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt +++ b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt @@ -18,11 +18,36 @@ */ 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") @@ -30,12 +55,15 @@ class EncryptionExtTest : BaseAndroidTest() { writer.println("hello world") writer.println("encryption test") writer.close() + assertThat(original.length(), equalTo(28L)) val key = EncryptionKey.generate() val encrypted = original.encryptToString(key) + assertThat(encrypted.length, greaterThan(10)) val decrypted = File.createTempFile("file", ".txt") encrypted.decryptToFile(key, decrypted) + assertThat(decrypted.length(), equalTo(28L)) assertEquals("hello world\nencryption test\n", decrypted.readText()) } } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt index e77267ce4..86f0cce44 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt @@ -38,17 +38,17 @@ import javax.crypto.spec.* */ class EncryptionKey private constructor( val base64: String, - val secretKey: SecretKey, + val secretKey: SecretKey ) { companion object { fun fromBase64(base64: String): EncryptionKey { - val keySpec = SecretKeySpec(Base64.decode(base64, Base64.DEFAULT), "AES") + val keySpec = SecretKeySpec(base64.decodeBase64(), "AES") return EncryptionKey(base64, keySpec) } private fun fromSecretKey(spec: SecretKey): EncryptionKey { - val base64 = Base64.encodeToString(spec.encoded, Base64.DEFAULT).trim() + val base64 = spec.encoded.encodeBase64().trim() return EncryptionKey(base64, spec) } @@ -99,7 +99,7 @@ fun ByteArray.decrypt(key: EncryptionKey): ByteArray { * gzip, decrypts it with the provided key, then writes the output to the specified file. */ fun String.decryptToFile(key: EncryptionKey, output: File) { - val bytes = Base64.decode(this, Base64.DEFAULT).decrypt(key) + val bytes = this.decodeBase64().decrypt(key) ByteArrayInputStream(bytes).use { bytesInputStream -> GZIPInputStream(bytesInputStream).use { gzipInputStream -> FileOutputStream(output).use { fileOutputStream -> @@ -120,10 +120,14 @@ fun File.encryptToString(key: EncryptionKey): String { FileInputStream(this).use { inputStream -> GZIPOutputStream(bytesOutputStream).use { gzipOutputStream -> ByteStreams.copy(inputStream, gzipOutputStream) + gzipOutputStream.close() val bytes = bytesOutputStream.toByteArray() - return Base64.encodeToString(bytes.encrypt(key), Base64.DEFAULT) + return bytes.encrypt(key).encodeBase64() } } } } +fun ByteArray.encodeBase64(): String = Base64.encodeToString(this, Base64.DEFAULT) +fun String.decodeBase64(): ByteArray = Base64.decode(this, Base64.DEFAULT) + From 1787c0e74ee777b2946a6cede141cc62b0f38e8f Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 28 Nov 2020 10:06:53 -0600 Subject: [PATCH 21/31] Upgrade to Android Gradle plugin 4.1.0 --- android/android-base/build.gradle | 4 ++-- android/gradle.properties | 2 +- android/gradle/wrapper/gradle-wrapper.properties | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/android/android-base/build.gradle b/android/android-base/build.gradle index f10169233..ba3cc6c54 100644 --- a/android/android-base/build.gradle +++ b/android/android-base/build.gradle @@ -7,8 +7,8 @@ android { defaultConfig { minSdkVersion MIN_SDK_VERSION as Integer targetSdkVersion TARGET_SDK_VERSION as Integer - versionCode VERSION_CODE as Integer - versionName "$VERSION_NAME" + buildConfigField 'int', 'VERSION_CODE', "$VERSION_CODE" + buildConfigField 'String', 'VERSION_NAME', "\"$VERSION_NAME\"" } compileOptions { diff --git a/android/gradle.properties b/android/gradle.properties index 163b6096f..31aec0569 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -10,7 +10,7 @@ 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 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 84337ad35..2aa714b7c 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Sat Nov 28 09:55:24 CST 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip From 9c0951ae588748b8e8f6f2d729a741b91cd18462 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 28 Nov 2020 11:32:28 -0600 Subject: [PATCH 22/31] Minor fixes to sync protocol --- .../uhabits/sync/RemoteSyncServerTest.kt | 14 ++-- .../isoron/uhabits/sync/RemoteSyncServer.kt | 11 ++-- .../java/org/isoron/uhabits/sync/SyncData.kt | 4 ++ .../org/isoron/uhabits/sync/SyncManager.kt | 66 ++++++++++++------- .../isoron/uhabits/sync/AbstractSyncServer.kt | 26 ++++++-- .../isoron/uhabits/sync/MemorySyncServer.kt | 15 ++++- .../src/org/isoron/uhabits/sync/SyncData.kt | 9 ++- .../org/isoron/uhabits/sync/SyncException.kt | 5 +- .../uhabits/sync/app/RegistrationModule.kt | 4 +- .../isoron/uhabits/sync/app/StorageModule.kt | 9 ++- .../uhabits/sync/MemorySyncServerTest.kt | 13 ++-- .../sync/app/RegistrationModuleTest.kt | 7 +- .../uhabits/sync/app/StorageModuleTest.kt | 30 +++++---- 13 files changed, 135 insertions(+), 78 deletions(-) diff --git a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt index e9ef2c3c5..4a35ac4e7 100644 --- a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt +++ b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt @@ -19,6 +19,7 @@ package org.isoron.uhabits.sync +import androidx.test.filters.* import com.fasterxml.jackson.databind.* import io.ktor.client.* import io.ktor.client.engine.mock.* @@ -29,6 +30,7 @@ import junit.framework.Assert.* import kotlinx.coroutines.* import org.junit.* +@MediumTest class RemoteSyncServerTest { private val mapper = ObjectMapper() @@ -53,7 +55,7 @@ class RemoteSyncServerTest { @Test fun when_get_data_version_succeeds_should_return_version() = runBlocking { - server("/ABC/version") { + server("/db/ABC/version") { respondWithJson(GetDataVersionResponse(5)) }.apply { assertEquals(5, getDataVersion("ABC")) @@ -63,7 +65,7 @@ class RemoteSyncServerTest { @Test(expected = ServiceUnavailable::class) fun when_get_data_version_with_server_error_should_raise_exception() = runBlocking { - server("/ABC/version") { + server("/db/ABC/version") { respondError(HttpStatusCode.InternalServerError) }.apply { getDataVersion("ABC") @@ -73,7 +75,7 @@ class RemoteSyncServerTest { @Test(expected = KeyNotFoundException::class) fun when_get_data_version_with_invalid_key_should_raise_exception() = runBlocking { - server("/ABC/version") { + server("/db/ABC/version") { respondError(HttpStatusCode.NotFound) }.apply { getDataVersion("ABC") @@ -83,7 +85,7 @@ class RemoteSyncServerTest { @Test fun when_get_data_succeeds_should_return_data() = runBlocking { - server("/ABC") { + server("/db/ABC") { respondWithJson(data) }.apply { assertEquals(data, getData("ABC")) @@ -93,7 +95,7 @@ class RemoteSyncServerTest { @Test(expected = KeyNotFoundException::class) fun when_get_data_with_invalid_key_should_raise_exception() = runBlocking { - server("/ABC") { + server("/db/ABC") { respondError(HttpStatusCode.NotFound) }.apply { getData("ABC") @@ -103,7 +105,7 @@ class RemoteSyncServerTest { @Test fun when_put_succeeds_should_not_raise_exceptions() = runBlocking { - server("/ABC") { + server("/db/ABC") { respondOk() }.apply { put("ABC", data) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt index 1e0e16781..297bf60a4 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt @@ -19,6 +19,7 @@ package org.isoron.uhabits.sync +import android.util.* import io.ktor.client.* import io.ktor.client.engine.android.* import io.ktor.client.features.* @@ -26,9 +27,6 @@ import io.ktor.client.features.json.* import io.ktor.client.request.* import kotlinx.coroutines.* -data class RegisterReponse(val key: String) -data class GetDataVersionResponse(val version: Long) - class RemoteSyncServer( private val baseURL: String = "https://sync.loophabits.org", private val httpClient: HttpClient = HttpClient(Android) { @@ -54,7 +52,10 @@ class RemoteSyncServer( } catch (e: ServerResponseException) { throw ServiceUnavailable() } catch (e: ClientRequestException) { - throw KeyNotFoundException() + Log.w("RemoteSyncServer", "ClientRequestException", e) + if(e.message!!.contains("409")) throw EditConflictException() + if(e.message!!.contains("404")) throw KeyNotFoundException() + throw e } } @@ -65,6 +66,7 @@ class RemoteSyncServer( } catch (e: ServerResponseException) { throw ServiceUnavailable() } catch (e: ClientRequestException) { + Log.w("RemoteSyncServer", "ClientRequestException", e) throw KeyNotFoundException() } } @@ -76,6 +78,7 @@ class RemoteSyncServer( } catch(e: ServerResponseException) { throw ServiceUnavailable() } catch (e: ClientRequestException) { + Log.w("RemoteSyncServer", "ClientRequestException", e) throw KeyNotFoundException() } } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncData.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncData.kt index 5f57d3c9c..72960909b 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncData.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncData.kt @@ -23,3 +23,7 @@ data class SyncData( val version: Long, val content: String ) + +data class RegisterReponse(val key: String) + +data class GetDataVersionResponse(val version: Long) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt index de545a73f..5f70d02db 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt @@ -30,6 +30,7 @@ import org.isoron.uhabits.core.tasks.* import org.isoron.uhabits.tasks.* import org.isoron.uhabits.utils.* import java.io.* +import java.lang.RuntimeException import javax.inject.* @AppScope @@ -43,7 +44,7 @@ class SyncManager @Inject constructor( private val server = RemoteSyncServer() private val tmpFile = File.createTempFile("import", "", context.externalCacheDir) - private var currVersion = 0L + private var currVersion = 1L private var dirty = true private lateinit var encryptionKey: EncryptionKey @@ -72,35 +73,53 @@ class SyncManager @Inject constructor( } } - private suspend fun push() { - if (!dirty) { - Log.i("SyncManager", "Database not dirty. Skipping upload.") - return + private suspend fun push(depth: Int = 0) { + if(depth >= 5) { + throw RuntimeException() + } + if (dirty) { + Log.i("SyncManager", "Encrypting local database...") + val db = DatabaseUtils.getDatabaseFile(context) + val encryptedDB = db.encryptToString(encryptionKey) + val size = encryptedDB.length / 1024 + Log.i("SyncManager", "Pushing local database (version $currVersion, $size KB)") + try { + 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) + } + } else { + Log.i("SyncManager", "Local database not modified. Skipping push.") } - Log.i("SyncManager", "Encrypting database...") - val db = DatabaseUtils.getDatabaseFile(context) - val encryptedDB = db.encryptToString(encryptionKey) - Log.i("SyncManager", "Uploading database (version ${currVersion}, ${encryptedDB.length / 1024} KB)") - server.put(preferences.syncKey, SyncData(currVersion, encryptedDB)) - dirty = false } private suspend fun pull() { - Log.i("SyncManager", "Fetching database from server...") - val data = server.getData(preferences.syncKey) - Log.i("SyncManager", "Fetched database (version ${data.version}, ${data.content.length / 1024} KB)") - if (data.version == 0L) { - Log.i("SyncManager", "Initial upload detected. Marking db as dirty.") - dirty = true - } - if (data.version <= currVersion) { - Log.i("SyncManager", "Local version is up-to-date. Skipping merge.") + Log.i("SyncManager", "Querying remote database version...") + val remoteVersion = server.getDataVersion(syncKey) + Log.i("SyncManager", "Remote database has version $remoteVersion") + + if (remoteVersion <= currVersion) { + Log.i("SyncManager", "Local database is up-to-date. Skipping merge.") } else { - Log.i("SyncManager", "Decrypting and merging with local changes...") + Log.i("SyncManager", "Pulling remote database...") + 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) } - currVersion = data.version + 1 + } + + private fun setCurrentVersion(v: Long) { + currVersion = v + Log.i("SyncManager", "Setting local database version to $currVersion") } suspend fun onResume() { @@ -118,6 +137,9 @@ class SyncManager @Inject constructor( } override fun onCommandExecuted(command: Command?, refreshKey: Long?) { + if (!dirty) { + setCurrentVersion(currVersion + 1) + } dirty = true } } \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt b/server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt index efaa7ed06..d436eb635 100644 --- a/server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt +++ b/server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt @@ -24,11 +24,10 @@ interface AbstractSyncServer { * Generates and returns a new sync key, which can be used to store and retrive * data. * - * @throws RegistrationUnavailableException If key cannot be generated at this - * time, for example, due to insufficient server resources or temporary - * maintenance. + * @throws ServiceUnavailable If key cannot be generated at this time, for example, + * due to insufficient server resources, temporary server maintenance or network problems. */ - fun register(): String + suspend fun register(): String /** * Replaces data for a given sync key. @@ -36,13 +35,26 @@ interface AbstractSyncServer { * @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. */ - fun put(key: String, newData: SyncData) + 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. */ - fun get(key: String): SyncData -} \ No newline at end of file + suspend fun getData(key: String): SyncData + + /** + * Returns the current data version for the given key + * + * @throws KeyNotFoundException If key is not found + * @throws ServiceUnavailable If data cannot be retrieved at this time, for example, due + * to insufficient server resources or network problems. + */ + suspend fun getDataVersion(key: String): Long +} diff --git a/server/src/org/isoron/uhabits/sync/MemorySyncServer.kt b/server/src/org/isoron/uhabits/sync/MemorySyncServer.kt index 44798665f..3ad6defd0 100644 --- a/server/src/org/isoron/uhabits/sync/MemorySyncServer.kt +++ b/server/src/org/isoron/uhabits/sync/MemorySyncServer.kt @@ -28,7 +28,7 @@ import kotlin.streams.* class MemorySyncServer : AbstractSyncServer { private val db = mutableMapOf() - override fun register(): String { + override suspend fun register(): String { synchronized(db) { val key = generateKey() db[key] = SyncData(0, "") @@ -36,7 +36,7 @@ class MemorySyncServer : AbstractSyncServer { } } - override fun put(key: String, newData: SyncData) { + override suspend fun put(key: String, newData: SyncData) { synchronized(db) { if (!db.containsKey(key)) { throw KeyNotFoundException() @@ -49,7 +49,7 @@ class MemorySyncServer : AbstractSyncServer { } } - override fun get(key: String): SyncData { + override suspend fun getData(key: String): SyncData { synchronized(db) { if (!db.containsKey(key)) { throw KeyNotFoundException() @@ -58,6 +58,15 @@ class MemorySyncServer : AbstractSyncServer { } } + override suspend fun getDataVersion(key: String): Long { + synchronized(db) { + if (!db.containsKey(key)) { + throw KeyNotFoundException() + } + return db.getValue(key).version + } + } + private fun generateKey(): String { val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" while (true) { diff --git a/server/src/org/isoron/uhabits/sync/SyncData.kt b/server/src/org/isoron/uhabits/sync/SyncData.kt index 634982ec5..3b99535e0 100644 --- a/server/src/org/isoron/uhabits/sync/SyncData.kt +++ b/server/src/org/isoron/uhabits/sync/SyncData.kt @@ -22,9 +22,14 @@ package org.isoron.uhabits.sync import com.fasterxml.jackson.databind.* data class SyncData( - val version: Int, - val content: String, + val version: Long, + val content: String ) +data class RegisterReponse(val key: String) + +data class GetDataVersionResponse(val version: Long) + val defaultMapper = ObjectMapper() fun SyncData.toJson(): String = defaultMapper.writeValueAsString(this) +fun GetDataVersionResponse.toJson(): String = defaultMapper.writeValueAsString(this) \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/SyncException.kt b/server/src/org/isoron/uhabits/sync/SyncException.kt index 8be49b301..5c5a81403 100644 --- a/server/src/org/isoron/uhabits/sync/SyncException.kt +++ b/server/src/org/isoron/uhabits/sync/SyncException.kt @@ -19,13 +19,10 @@ package org.isoron.uhabits.sync -/** - * Generic class for all exceptions thrown by SyncServer. - */ open class SyncException: RuntimeException() class KeyNotFoundException: SyncException() -class RegistrationUnavailableException: SyncException() +class ServiceUnavailable: SyncException() class EditConflictException: SyncException() \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/app/RegistrationModule.kt b/server/src/org/isoron/uhabits/sync/app/RegistrationModule.kt index adc8f0e5d..c2a3a30e8 100644 --- a/server/src/org/isoron/uhabits/sync/app/RegistrationModule.kt +++ b/server/src/org/isoron/uhabits/sync/app/RegistrationModule.kt @@ -29,8 +29,8 @@ fun Routing.registration(app: SyncApplication) { post("/register") { try { val key = app.server.register() - call.respond(HttpStatusCode.OK, mapOf("key" to key)) - } catch (e: RegistrationUnavailableException) { + call.respond(HttpStatusCode.OK, RegisterReponse(key)) + } catch (e: ServiceUnavailable) { call.respond(HttpStatusCode.ServiceUnavailable) } } diff --git a/server/src/org/isoron/uhabits/sync/app/StorageModule.kt b/server/src/org/isoron/uhabits/sync/app/StorageModule.kt index fb85b7edf..d1f6718e9 100644 --- a/server/src/org/isoron/uhabits/sync/app/StorageModule.kt +++ b/server/src/org/isoron/uhabits/sync/app/StorageModule.kt @@ -31,7 +31,7 @@ fun Routing.storage(app: SyncApplication) { get { val key = call.parameters["key"]!! try { - val data = app.server.get(key) + val data = app.server.getData(key) call.respond(HttpStatusCode.OK, data) } catch(e: KeyNotFoundException) { call.respond(HttpStatusCode.NotFound) @@ -46,15 +46,14 @@ fun Routing.storage(app: SyncApplication) { } catch (e: KeyNotFoundException) { call.respond(HttpStatusCode.NotFound) } catch (e: EditConflictException) { - val currData = app.server.get(key) - call.respond(HttpStatusCode.Conflict, currData) + call.respond(HttpStatusCode.Conflict) } } get("version") { val key = call.parameters["key"]!! try { - val data = app.server.get(key) - call.respond(HttpStatusCode.OK, data.version) + val version = app.server.getDataVersion(key) + call.respond(HttpStatusCode.OK, GetDataVersionResponse(version)) } catch(e: KeyNotFoundException) { call.respond(HttpStatusCode.NotFound) } diff --git a/server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt b/server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt index c7ec025ee..f53fa02b9 100644 --- a/server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt +++ b/server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt @@ -19,33 +19,34 @@ package org.isoron.uhabits.sync +import kotlinx.coroutines.* import org.junit.Test import kotlin.test.* class MemorySyncServerTest { private val server = MemorySyncServer() - private val key = server.register() + private val key = runBlocking { server.register() } @Test - fun testUsage() { + fun testUsage(): Unit = runBlocking { val data0 = SyncData(0, "") - assertEquals(server.get(key), data0) + assertEquals(server.getData(key), data0) val data1 = SyncData(1, "Hello world") server.put(key, data1) - assertEquals(server.get(key), data1) + assertEquals(server.getData(key), data1) val data2 = SyncData(2, "Hello new world") server.put(key, data2) - assertEquals(server.get(key), data2) + assertEquals(server.getData(key), data2) assertFailsWith { server.put(key, data2) } assertFailsWith { - server.get("INVALID") + server.getData("INVALID") } assertFailsWith { diff --git a/server/test/org/isoron/uhabits/sync/app/RegistrationModuleTest.kt b/server/test/org/isoron/uhabits/sync/app/RegistrationModuleTest.kt index 2375fe4fb..cd58fc4a8 100644 --- a/server/test/org/isoron/uhabits/sync/app/RegistrationModuleTest.kt +++ b/server/test/org/isoron/uhabits/sync/app/RegistrationModuleTest.kt @@ -21,6 +21,7 @@ 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.* @@ -29,7 +30,7 @@ import kotlin.test.* class RegistrationModuleTest : BaseApplicationTest() { @Test - fun `when register succeeds should return generated key`() { + fun `when register succeeds should return generated key`():Unit = runBlocking { `when`(server.register()).thenReturn("ABCDEF") withTestApplication(app()) { val call = handleRequest(HttpMethod.Post, "/register") @@ -39,8 +40,8 @@ class RegistrationModuleTest : BaseApplicationTest() { } @Test - fun `when registration is unavailable should return 503`() { - `when`(server.register()).thenThrow(RegistrationUnavailableException()) + 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()) diff --git a/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt b/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt index 0ef5f014c..5e0aacc56 100644 --- a/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt +++ b/server/test/org/isoron/uhabits/sync/app/StorageModuleTest.kt @@ -21,6 +21,7 @@ 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.* @@ -31,8 +32,8 @@ class StorageModuleTest : BaseApplicationTest() { private val data2 = SyncData(2, "Hello new world") @Test - fun `when get succeeds should return data`() { - `when`(server.get("k1")).thenReturn(data1) + 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()) @@ -42,19 +43,19 @@ class StorageModuleTest : BaseApplicationTest() { } @Test - fun `when get version succeeds should return version`() { - `when`(server.get("k1")).thenReturn(data1) + 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("1", response.content) + assertEquals(GetDataVersionResponse(30).toJson(), response.content) } } } @Test - fun `when get with invalid key should return 404`() { - `when`(server.get("k1")).thenThrow(KeyNotFoundException()) + 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()) @@ -64,17 +65,19 @@ class StorageModuleTest : BaseApplicationTest() { @Test - fun `when put succeeds should return OK`() { + fun `when put succeeds should return OK`(): Unit = runBlocking { withTestApplication(app()) { handlePut("/db/k1", data1).apply { - assertEquals(HttpStatusCode.OK, response.status()) - verify(server).put("k1", data1) + runBlocking { + assertEquals(HttpStatusCode.OK, response.status()) + verify(server).put("k1", data1) + } } } } @Test - fun `when put with invalid key should return 404`() { + 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 { @@ -84,13 +87,12 @@ class StorageModuleTest : BaseApplicationTest() { } @Test - fun `when put with invalid version should return 409 and current data`() { + fun `when put with invalid version should return 409 and current data`(): Unit = runBlocking { `when`(server.put("k1", data1)).thenThrow(EditConflictException()) - `when`(server.get("k1")).thenReturn(data2) + `when`(server.getData("k1")).thenReturn(data2) withTestApplication(app()) { handlePut("/db/k1", data1).apply { assertEquals(HttpStatusCode.Conflict, response.status()) - assertEquals(data2.toJson(), response.content) } } } From 328fcd23f4fbb38d07afd63cee3b5b28daa6202f Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 28 Nov 2020 11:37:35 -0600 Subject: [PATCH 23/31] SyncManager: Run tasks in the same thread --- .../src/main/java/org/isoron/uhabits/sync/SyncManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt index 5f70d02db..302149315 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt @@ -36,7 +36,6 @@ import javax.inject.* @AppScope class SyncManager @Inject constructor( val preferences: Preferences, - val taskRunner: TaskRunner, val importDataTaskFactory: ImportDataTaskFactory, val commandRunner: CommandRunner, @AppContext val context: Context @@ -46,6 +45,7 @@ class SyncManager @Inject constructor( 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 From b0336fb495646b9aad2ff0e945f15cf00479879b Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 28 Nov 2020 16:22:33 -0600 Subject: [PATCH 24/31] SyncManager: Log sync key --- .../src/main/java/org/isoron/uhabits/sync/SyncManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt index 302149315..339394294 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt @@ -63,7 +63,7 @@ class SyncManager @Inject constructor( encryptionKey = EncryptionKey.fromBase64(preferences.encryptionKey) syncKey = preferences.syncKey try { - Log.i("SyncManager", "Starting sync (key: ${encryptionKey.base64})") + Log.i("SyncManager", "Starting sync (key: $syncKey)") pull() push() Log.i("SyncManager", "Sync finished") From 7979f74bea83d964e913215971ab925ca39e1c84 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 28 Nov 2020 16:23:21 -0600 Subject: [PATCH 25/31] Server: data persistence --- server/Dockerfile | 1 + .../uhabits/sync/app/SyncApplication.kt | 9 ++- .../uhabits/sync/repository/FileRepository.kt | 73 +++++++++++++++++++ .../uhabits/sync/repository/Repository.kt | 46 ++++++++++++ .../sync/{ => server}/AbstractSyncServer.kt | 4 +- .../RepositorySyncServer.kt} | 55 +++++++------- ...verTest.kt => RepositorySyncServerTest.kt} | 8 +- .../uhabits/sync/app/BaseApplicationTest.kt | 2 +- .../sync/repository/FileRepositoryTest.kt | 53 ++++++++++++++ 9 files changed, 216 insertions(+), 35 deletions(-) create mode 100644 server/src/org/isoron/uhabits/sync/repository/FileRepository.kt create mode 100644 server/src/org/isoron/uhabits/sync/repository/Repository.kt rename server/src/org/isoron/uhabits/sync/{ => server}/AbstractSyncServer.kt (96%) rename server/src/org/isoron/uhabits/sync/{MemorySyncServer.kt => server/RepositorySyncServer.kt} (58%) rename server/test/org/isoron/uhabits/sync/{MemorySyncServerTest.kt => RepositorySyncServerTest.kt} (85%) create mode 100644 server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt diff --git a/server/Dockerfile b/server/Dockerfile index 33977a5d5..34d77779a 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,6 +1,7 @@ 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", \ diff --git a/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt b/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt index 92798b58b..16fc71106 100644 --- a/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt +++ b/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt @@ -24,11 +24,18 @@ 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 = MemorySyncServer(), + val server: AbstractSyncServer = RepositorySyncServer( + FileRepository(REPOSITORY_PATH), + ), ) { fun Application.main() { install(DefaultHeaders) diff --git a/server/src/org/isoron/uhabits/sync/repository/FileRepository.kt b/server/src/org/isoron/uhabits/sync/repository/FileRepository.kt new file mode 100644 index 000000000..c9ea5b500 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/repository/FileRepository.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.repository + +import org.isoron.uhabits.sync.* +import java.io.* +import java.nio.file.* + +class FileRepository( + private val basepath: Path, +) : Repository { + + override suspend fun put(key: String, data: SyncData) { + // Create directory + val dataPath = key.toDataPath() + val dataDir = dataPath.toFile() + dataDir.mkdirs() + + // Create metadata + val metadataFile = dataPath.resolve("version").toFile() + metadataFile.outputStream().use { outputStream -> + PrintWriter(outputStream).use { printWriter -> + printWriter.print(data.version) + } + } + + // Create data file + val dataFile = dataPath.resolve("content").toFile() + dataFile.outputStream().use { outputStream -> + PrintWriter(outputStream).use { printWriter -> + printWriter.print(data.content) + } + } + } + + override suspend fun get(key: String): SyncData { + val dataPath = key.toDataPath() + val contentFile = dataPath.resolve("content").toFile() + val versionFile = dataPath.resolve("version").toFile() + if (!contentFile.exists() || !versionFile.exists()) { + throw KeyNotFoundException() + } + val version = versionFile.readText().trim().toLong() + return SyncData(version, contentFile.readText()) + } + + override suspend fun contains(key: String): Boolean { + val dataPath = key.toDataPath() + val versionFile = dataPath.resolve("version").toFile() + return versionFile.exists() + } + + private fun String.toDataPath(): Path { + return basepath.resolve("${this.substring(0..1)}/${this.substring(2..3)}/$this") + } +} \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/repository/Repository.kt b/server/src/org/isoron/uhabits/sync/repository/Repository.kt new file mode 100644 index 000000000..aee4ee535 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/repository/Repository.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.repository + +import com.sun.org.apache.xpath.internal.operations.* +import org.isoron.uhabits.sync.* + +/** + * A class that knows how to store and retrieve a large number of [SyncData] items. + */ +interface Repository { + /** + * Stores a data item, under the provided key. The item can be later retrieved with [get]. + * Replaces existing items silently. + */ + suspend fun put(key: String, data: SyncData) + + /** + * Retrieves a data item that was previously stored using [put]. + * @throws KeyNotFoundException If no such key exists. + */ + suspend fun get(key: String): SyncData + + /** + * Returns true if the repository contains a given key. + */ + suspend fun contains(key: String): Boolean +} + diff --git a/server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt b/server/src/org/isoron/uhabits/sync/server/AbstractSyncServer.kt similarity index 96% rename from server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt rename to server/src/org/isoron/uhabits/sync/server/AbstractSyncServer.kt index d436eb635..3aac2ee02 100644 --- a/server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt +++ b/server/src/org/isoron/uhabits/sync/server/AbstractSyncServer.kt @@ -17,7 +17,9 @@ * with this program. If not, see . */ -package org.isoron.uhabits.sync +package org.isoron.uhabits.sync.server + +import org.isoron.uhabits.sync.* interface AbstractSyncServer { /** diff --git a/server/src/org/isoron/uhabits/sync/MemorySyncServer.kt b/server/src/org/isoron/uhabits/sync/server/RepositorySyncServer.kt similarity index 58% rename from server/src/org/isoron/uhabits/sync/MemorySyncServer.kt rename to server/src/org/isoron/uhabits/sync/server/RepositorySyncServer.kt index 3ad6defd0..fa75b7d22 100644 --- a/server/src/org/isoron/uhabits/sync/MemorySyncServer.kt +++ b/server/src/org/isoron/uhabits/sync/server/RepositorySyncServer.kt @@ -17,64 +17,59 @@ * with this program. If not, see . */ -package org.isoron.uhabits.sync +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 memory. + * An AbstractSyncServer that stores all data in a [Repository]. */ -class MemorySyncServer : AbstractSyncServer { - private val db = mutableMapOf() +class RepositorySyncServer( + private val repo: Repository, +) : AbstractSyncServer { override suspend fun register(): String { - synchronized(db) { - val key = generateKey() - db[key] = SyncData(0, "") - return key - } + val key = generateKey() + repo.put(key, SyncData(0, "")) + return key } override suspend fun put(key: String, newData: SyncData) { - synchronized(db) { - if (!db.containsKey(key)) { - throw KeyNotFoundException() - } - val prevData = db.getValue(key) - if (newData.version != prevData.version + 1) { - throw EditConflictException() - } - db[key] = newData + 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 { - synchronized(db) { - if (!db.containsKey(key)) { - throw KeyNotFoundException() - } - return db.getValue(key) + if (!repo.contains(key)) { + throw KeyNotFoundException() } + return repo.get(key) } override suspend fun getDataVersion(key: String): Long { - synchronized(db) { - if (!db.containsKey(key)) { - throw KeyNotFoundException() - } - return db.getValue(key).version + if (!repo.contains(key)) { + throw KeyNotFoundException() } + return repo.get(key).version } - private fun generateKey(): String { + private suspend fun generateKey(): String { val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" while (true) { val key = Random().ints(64, 0, chars.length) .asSequence() .map(chars::get) .joinToString("") - if (!db.containsKey(key)) + if (!repo.contains(key)) return key } diff --git a/server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt b/server/test/org/isoron/uhabits/sync/RepositorySyncServerTest.kt similarity index 85% rename from server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt rename to server/test/org/isoron/uhabits/sync/RepositorySyncServerTest.kt index f53fa02b9..cae1291ee 100644 --- a/server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt +++ b/server/test/org/isoron/uhabits/sync/RepositorySyncServerTest.kt @@ -20,12 +20,16 @@ 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 MemorySyncServerTest { +class RepositorySyncServerTest { - private val server = MemorySyncServer() + private val tempdir = Files.createTempDirectory("db") + private val server = RepositorySyncServer(FileRepository(tempdir)) private val key = runBlocking { server.register() } @Test diff --git a/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt b/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt index 190fc6fcf..abcc5f0f5 100644 --- a/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt +++ b/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt @@ -20,7 +20,7 @@ package org.isoron.uhabits.sync.app import io.ktor.application.* -import org.isoron.uhabits.sync.* +import org.isoron.uhabits.sync.server.* import org.mockito.Mockito.* open class BaseApplicationTest { diff --git a/server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt b/server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt new file mode 100644 index 000000000..c9d097778 --- /dev/null +++ b/server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + + +@file:Suppress("BlockingMethodInNonBlockingContext") + +package org.isoron.uhabits.sync.repository + +import kotlinx.coroutines.* +import org.hamcrest.CoreMatchers.* +import org.isoron.uhabits.sync.* +import org.junit.* +import org.junit.Assert.* +import java.nio.file.* + +class FileRepositoryTest { + + @Test + fun testUsage() = runBlocking { + val tempdir = Files.createTempDirectory("db")!! + val repo = FileRepository(tempdir) + + val original = SyncData(10, "Hello world") + repo.put("abcdefg", original) + + val metaPath = tempdir.resolve("ab/cd/abcdefg/version") + assertTrue("$metaPath should exist", Files.exists(metaPath)) + assertEquals("10", metaPath.toFile().readText()) + + val dataPath = tempdir.resolve("ab/cd/abcdefg/content") + assertTrue("$dataPath should exist", Files.exists(dataPath)) + assertEquals("Hello world", dataPath.toFile().readText()) + + val retrieved = repo.get("abcdefg") + assertThat(retrieved, equalTo(original)) + } +} \ No newline at end of file From 4a4b3c6aeb665445a008092821d063d0e5f9cd98 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 28 Nov 2020 22:27:55 -0600 Subject: [PATCH 26/31] Server: change dir structure --- .../src/org/isoron/uhabits/sync/repository/FileRepository.kt | 2 +- .../org/isoron/uhabits/sync/repository/FileRepositoryTest.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/org/isoron/uhabits/sync/repository/FileRepository.kt b/server/src/org/isoron/uhabits/sync/repository/FileRepository.kt index c9ea5b500..07c793049 100644 --- a/server/src/org/isoron/uhabits/sync/repository/FileRepository.kt +++ b/server/src/org/isoron/uhabits/sync/repository/FileRepository.kt @@ -68,6 +68,6 @@ class FileRepository( } private fun String.toDataPath(): Path { - return basepath.resolve("${this.substring(0..1)}/${this.substring(2..3)}/$this") + return basepath.resolve("${this[0]}/${this[1]}/${this[2]}/${this[3]}/$this") } } \ No newline at end of file diff --git a/server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt b/server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt index c9d097778..047e0928e 100644 --- a/server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt +++ b/server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt @@ -39,11 +39,11 @@ class FileRepositoryTest { val original = SyncData(10, "Hello world") repo.put("abcdefg", original) - val metaPath = tempdir.resolve("ab/cd/abcdefg/version") + 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("ab/cd/abcdefg/content") + val dataPath = tempdir.resolve("a/b/c/d/abcdefg/content") assertTrue("$dataPath should exist", Files.exists(dataPath)) assertEquals("Hello world", dataPath.toFile().readText()) From 2b9fd74a1d5a14398b49ceef543a9ef011154556 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 28 Nov 2020 22:28:46 -0600 Subject: [PATCH 27/31] Close database --- .../main/java/org/isoron/uhabits/core/io/LoopDBImporter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java index 59aec5010..9cbb81f0b 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/io/LoopDBImporter.java @@ -90,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); @@ -138,5 +137,6 @@ public class LoopDBImporter extends AbstractImporter } runner.notifyListeners(null, null); + db.close(); } } From 6df5e9ebe9463d303fb91a989623a6e8c1ccb449 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 28 Nov 2020 22:28:54 -0600 Subject: [PATCH 28/31] Monitor network availability; other minor fixes --- .../uhabits/activities/sync/SyncActivity.kt | 6 +- .../isoron/uhabits/sync/RemoteSyncServer.kt | 2 +- .../org/isoron/uhabits/sync/SyncManager.kt | 116 +++++++++++------- 3 files changed, 80 insertions(+), 44 deletions(-) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt index f09dd98b8..ff005c25c 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt @@ -24,6 +24,7 @@ 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.* @@ -98,12 +99,13 @@ class SyncActivity : BaseActivity() { private var error = false override fun doInBackground() { runBlocking { - val server = RemoteSyncServer(baseURL = preferences.syncBaseURL) try { + val server = RemoteSyncServer(baseURL = preferences.syncBaseURL) syncKey = server.register() encKey = EncryptionKey.generate() preferences.enableSync(syncKey, encKey.base64) - } catch (e: ServiceUnavailable) { + } catch (e: Exception) { + Log.e("SyncActivity", "Unexpected exception", e) error = true } } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt index 297bf60a4..ec5773244 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/RemoteSyncServer.kt @@ -28,7 +28,7 @@ import io.ktor.client.request.* import kotlinx.coroutines.* class RemoteSyncServer( - private val baseURL: String = "https://sync.loophabits.org", + private val baseURL: String, private val httpClient: HttpClient = HttpClient(Android) { install(JsonFeature) } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt index 339394294..8893058b3 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt @@ -20,6 +20,7 @@ package org.isoron.uhabits.sync import android.content.* +import android.net.* import android.util.* import kotlinx.coroutines.* import org.isoron.androidbase.* @@ -30,82 +31,103 @@ import org.isoron.uhabits.core.tasks.* import org.isoron.uhabits.tasks.* import org.isoron.uhabits.utils.* import java.io.* -import java.lang.RuntimeException import javax.inject.* @AppScope class SyncManager @Inject constructor( val preferences: Preferences, - val importDataTaskFactory: ImportDataTaskFactory, + private val importDataTaskFactory: ImportDataTaskFactory, val commandRunner: CommandRunner, @AppContext val context: Context -) : Preferences.Listener, CommandRunner.Listener { +) : Preferences.Listener, CommandRunner.Listener, ConnectivityManager.NetworkCallback() { + + private var connected = false + + private val server = RemoteSyncServer(baseURL = preferences.syncBaseURL) - private val server = RemoteSyncServer() 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) } - suspend fun sync() { + fun sync() = CoroutineScope(Dispatchers.Main).launch { if (!preferences.isSyncEnabled) { Log.i("SyncManager", "Device sync is disabled. Skipping sync.") - return + return@launch } + encryptionKey = EncryptionKey.fromBase64(preferences.encryptionKey) syncKey = preferences.syncKey + Log.i("SyncManager", "Starting sync (key: $syncKey)") + try { - Log.i("SyncManager", "Starting sync (key: $syncKey)") pull() push() - Log.i("SyncManager", "Sync finished") + } 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) + Log.e("SyncManager", "Unexpected sync exception. Disabling sync.", e) preferences.disableSync() } + + Log.i("SyncManager", "Sync finished successfully.") } private suspend fun push(depth: Int = 0) { - if(depth >= 5) { + if (depth >= 5) { throw RuntimeException() } - if (dirty) { - Log.i("SyncManager", "Encrypting local database...") - val db = DatabaseUtils.getDatabaseFile(context) - val encryptedDB = db.encryptToString(encryptionKey) - val size = encryptedDB.length / 1024 - Log.i("SyncManager", "Pushing local database (version $currVersion, $size KB)") - try { - 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) - } - } else { + + 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 has version $remoteVersion") + 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)") @@ -117,29 +139,41 @@ class SyncManager @Inject constructor( } } - private fun setCurrentVersion(v: Long) { - currVersion = v - Log.i("SyncManager", "Setting local database version to $currVersion") - } + fun onResume() = sync() - suspend fun onResume() { + fun onPause() = sync() + + override fun onSyncEnabled() { + Log.i("SyncManager", "Sync enabled.") + setCurrentVersion(1) + dirty = true sync() } - suspend fun onPause() { + override fun onAvailable(network: Network) { + Log.i("SyncManager", "Network available.") + connected = true sync() } - override fun onSyncEnabled() { - CoroutineScope(Dispatchers.Main).launch { - 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) - } + if (!dirty) setCurrentVersion(currVersion + 1) dirty = true } -} \ No newline at end of file + + 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() From 0b6110f0f9ce9169f8fd0658732f1166774735da Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sat, 28 Nov 2020 22:29:24 -0600 Subject: [PATCH 29/31] Add alpha to version name --- android/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index 31aec0569..60cb81260 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,5 +1,5 @@ VERSION_CODE = 20000 -VERSION_NAME = 2.0.0 +VERSION_NAME = 2.0.0-alpha MIN_SDK_VERSION = 23 TARGET_SDK_VERSION = 29 From 872c8d9d814ff03451de8c348e9915921eb2213b Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sun, 29 Nov 2020 06:47:18 -0600 Subject: [PATCH 30/31] SyncManager: Small fix in logging --- .../src/main/java/org/isoron/uhabits/sync/SyncManager.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt index 8893058b3..e3ada333a 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt @@ -77,6 +77,7 @@ class SyncManager @Inject constructor( try { pull() push() + Log.i("SyncManager", "Sync finished successfully.") } catch (e: ConnectionLostException) { Log.i("SyncManager", "Network unavailable. Aborting sync.") } catch (e: ServiceUnavailable) { @@ -85,8 +86,6 @@ class SyncManager @Inject constructor( Log.e("SyncManager", "Unexpected sync exception. Disabling sync.", e) preferences.disableSync() } - - Log.i("SyncManager", "Sync finished successfully.") } private suspend fun push(depth: Int = 0) { From 5717ae1bf19ba1ebccd8b7cfa2cf9982b5aae3bb Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sun, 29 Nov 2020 07:11:38 -0600 Subject: [PATCH 31/31] RemoteSyncServer: Fix test --- .../java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt index 4a35ac4e7..df2e13efa 100644 --- a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt +++ b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/sync/RemoteSyncServerTest.kt @@ -126,7 +126,7 @@ class RemoteSyncServerTest { } } } - }) + }, baseURL = "") } private fun MockRequestHandleScope.respondWithJson(content: Any) =