Compare commits

..

11 Commits

60 changed files with 21680 additions and 697 deletions

View File

@@ -9,7 +9,6 @@ on:
jobs:
Test:
runs-on: self-hosted
timeout-minutes: 30
steps:
- name: Check out source code
uses: actions/checkout@v1

View File

@@ -1,9 +1,5 @@
# Changelog
## [2.1.1] -- 2022-09-24
### Fixed
- Fix Tasker plugin (@iSoron, #1503)
## [2.1.0] -- 2022-09-10
### Added
- Allow user to add notes to specific dates (@vbh, #1103)

View File

@@ -1,6 +1,6 @@
plugins {
val kotlinVersion = "1.7.21"
id("com.android.application") version ("7.3.1") apply (false)
val kotlinVersion = "1.7.10"
id("com.android.application") version ("7.3.0-rc01") apply (false)
id("org.jetbrains.kotlin.android") version kotlinVersion apply (false)
id("org.jetbrains.kotlin.kapt") version kotlinVersion apply (false)
id("org.jetbrains.kotlin.android.extensions") version kotlinVersion apply (false)

View File

@@ -2,25 +2,51 @@
Loop Habit Tracker has a fairly large number of automated tests to reduce the chance of bugs being silently introduced in our code base. The tests are divided into three categories:
- **Unit tests:** These tests run very quickly on the developer's computer, inside a JVM, and do not need an Android emulator or device. They typically test the correctness of core functions of the application, such as the computation of scores and streaks.
- **Instrumented tests:** These tests require an Android emulator or device. _Medium_ instrumented tests are still quite fast to run, since only individual classes are tested. The app itself does not need to be launched. Examples include _view tests_, which render our custom views on the device and compare them against prerendered images. _Large_ instrumented tests launch the application on an Android emulator and interact with it by touching the screen, much like a regular user.
* **Small tests:** These tests run very quickly on the developer's computer, inside a JVM, and do not need an Android emulator or device. They typically test the correctness of core functions of the application, such as the computation of scores and streaks.
* **Medium tests:** These tests require an Android emulator or device, but they are still quite fast to run, since only individual classes are tested. The app itself does not need to be launched. Examples include *view tests*, which render our custom views on the device and compare them against prerendered images.
* **Large tests:** These are end-to-end tests, which launch the application on an Android emulator and interact with it by touching the screen, much like a regular user.
## Running unit tests
## Running small tests
Unit tests can be launched by running `./gradlew test` or by right-clicking a particular class/method in Android Studio and selecting "Run testMethod()" or "Run ClassTest". An alternative way is to use `build.sh`, the script used by our continuous integration server. By running `./build.sh build`, the script will automatically build and run all small tests.
Small tests can be launched by running `./gradlew test` or by right-clicking a particular class/method in Android Studio and selecting "Run testMethod()" or "Run ClassTest". An alternative way is to use `build.sh`, the script used by our continuous integration server. By running `./build.sh build`, the script will automatically build and run all small tests.
## Running instrumented tests
## Running medium tests
To run medium tests, it is recommended to use the `build.sh` script.
To run medium tests, it is recommended to use the `build.sh` script:
1. Run `./build.sh android-setup API` to create the emulator, where `API` is the desired API level.
2. Run `./build.sh android-tests API` to run the tests on a single API.
3. Run `./build.sh android-tests-parallel API API...` to run the tests on multiple APIs in parallel.
./build.sh build
./build.sh medium-tests
Note that instrumented tests are designed to run on a clean install, inside an emulator. They will not work on actual devices. All tests are also designed for a particular screen size, namely the Nexus 4 configuration (4.7" 768x1280 xhdpi), and a particular locale, namely English (US). Furthermore:
- No additional apps should be installed on the device;
- The homescreen must look exactly like it was when the emulator was originally created, with no additional icons or widgets;
- All animations must be manually disabled.
For this script to succeed, make sure that an emulator is currently running, or that a device (with developer mode activated) is connected via USB.
If there are failing view tests (that is, if some custom views do not render exactly like the prerendered images we have), then both the actual and expected images will be automatically downloaded from the device to the folder `uhabits-android/build/outputs`. After verifying the differences, if you feel that the actual images are actually fine and should replace the prerendered ones, then run `./build.sh android-accept-images`.
**WARNING!** This script will uninstall the app prior to testing it, and therefore delete all user data!
If there are failing view tests (that is, if some custom views do not render exactly like the prerendered images we have), then the script `./build.sh fetch-images` can be used to download both the actual and the expected images from the device. The images will be downloaded from the device into the folder `tmp/`. After verifying the differences, if you feel that the actual images are actually fine and should replace the prerendered ones, then run `./build.sh accept-images`.
## Running large tests
Large tests are significantly more complicated to run. In particular, they require:
* An Android emulator; they will **not** work on actual devices;
* A vanilla x86 AOSP image; they will **not** work with Google API images;
* A particular screen size, namely the Nexus 4 configuration on Android Studio (4.7 768x1280 xhdpi);
* A particular locale, namely English (US).
Furthermore:
* No additional apps should be installed on the device;
* The homescreen must look exactly like it was when the emulator was originally created, with no additional icons or widgets;
* Developer mode must be activated, and all animations must be manually disabled.
Only the following Android versions are supported by our test suite:
* Android 7.0 (API 24)
* Android 7.1.1 (API 25)
* Android 8.0 (API 26)
* Android 8.1 (API 27)
* Android 9.0 (API 28)
* Android 10.0 (API 29)
After creating an emulator and configuring it exactly as described above, launch it, wait for it to finish booting up, then run `./build.sh large-tests`. As mentioned before, this script will uninstall the app before testing it, and therefore will delete all the user data.

View File

@@ -35,8 +35,8 @@ android {
compileSdk = 32
defaultConfig {
versionCode = 20101
versionName = "2.1.1"
versionCode = 20100
versionName = "2.1.0"
minSdk = 28
targetSdk = 31
applicationId = "org.isoron.uhabits"
@@ -80,11 +80,11 @@ android {
}
dependencies {
val daggerVersion = "2.44.2"
val kotlinVersion = "1.7.21"
val daggerVersion = "2.43.2"
val kotlinVersion = "1.7.10"
val kxCoroutinesVersion = "1.6.4"
val ktorVersion = "1.6.8"
val espressoVersion = "3.5.0"
val espressoVersion = "3.4.0"
androidTestImplementation("androidx.test.espresso:espresso-contrib:$espressoVersion")
androidTestImplementation("androidx.test.espresso:espresso-core:$espressoVersion")
@@ -92,10 +92,10 @@ dependencies {
androidTestImplementation("com.linkedin.dexmaker:dexmaker-mockito:2.28.3")
androidTestImplementation("io.ktor:ktor-client-mock:$ktorVersion")
androidTestImplementation("io.ktor:ktor-jackson:$ktorVersion")
androidTestImplementation("androidx.annotation:annotation:1.5.0")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.annotation:annotation:1.4.0")
androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0")
androidTestImplementation("androidx.test:rules:1.5.0")
androidTestImplementation("androidx.test:rules:1.4.0")
androidTestImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
compileOnly("javax.annotation:jsr250-api:1.0")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.2.2")
@@ -110,11 +110,11 @@ dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kxCoroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kxCoroutinesVersion")
implementation("androidx.appcompat:appcompat:1.5.1")
implementation("androidx.appcompat:appcompat:1.5.0")
implementation("androidx.legacy:legacy-preference-v14:1.0.0")
implementation("androidx.legacy:legacy-support-v4:1.0.0")
implementation("com.google.android.material:material:1.8.0")
implementation("com.opencsv:opencsv:5.7.1")
implementation("com.google.android.material:material:1.6.1")
implementation("com.opencsv:opencsv:5.6")
implementation(project(":uhabits-core"))
kapt("com.google.dagger:dagger-compiler:$daggerVersion")
kaptAndroidTest("com.google.dagger:dagger-compiler:$daggerVersion")

View File

@@ -79,6 +79,14 @@
android:value=".activities.habits.list.ListHabitsActivity" />
</activity>
<activity
android:name=".activities.sync.SyncActivity"
android:label="@string/device_sync">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".activities.sync.SyncActivity" />
</activity>
<activity
android:name=".activities.intro.IntroActivity"
android:label=""
@@ -270,7 +278,7 @@
<!-- Locale/Tasker -->
<receiver
android:name=".automation.FireSettingReceiver"
android:exported="true">
android:exported="false">
<intent-filter>
<action android:name="com.twofortyfouram.locale.intent.action.FIRE_SETTING" />
</intent-filter>

View File

@@ -22,7 +22,6 @@ import android.app.backup.BackupManager
import android.content.Intent
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.util.Log
@@ -44,7 +43,6 @@ import org.isoron.uhabits.core.utils.DateUtils.Companion.getLongWeekdayNames
import org.isoron.uhabits.notifications.AndroidNotificationTray.Companion.createAndroidNotificationChannel
import org.isoron.uhabits.notifications.RingtoneManager
import org.isoron.uhabits.utils.StyledResources
import org.isoron.uhabits.utils.startActivitySafely
import org.isoron.uhabits.widgets.WidgetUpdater
import java.util.Calendar
@@ -94,24 +92,16 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis
override fun onPreferenceTreeClick(preference: Preference): Boolean {
val key = preference.key ?: return false
when (key) {
"reminderSound" -> {
showRingtonePicker()
return true
}
"reminderCustomize" -> {
createAndroidNotificationChannel(requireContext())
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
intent.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationTray.REMINDERS_CHANNEL_ID)
startActivity(intent)
return true
}
"rateApp" -> {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.playStoreURL)))
activity?.startActivitySafely(intent)
return true
}
if (key == "reminderSound") {
showRingtonePicker()
return true
} else if (key == "reminderCustomize") {
createAndroidNotificationChannel(requireContext())
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
intent.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationTray.REMINDERS_CHANNEL_ID)
startActivity(intent)
return true
}
return super.onPreferenceTreeClick(preference)
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.sync
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.R
import org.isoron.uhabits.activities.AndroidThemeSwitcher
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.databinding.SyncActivityBinding
import org.isoron.uhabits.utils.setupToolbar
class SyncActivity : AppCompatActivity() {
private lateinit var binding: SyncActivityBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val component = (application as HabitsApplication).component
val themeSwitcher = AndroidThemeSwitcher(this, component.preferences)
themeSwitcher.apply()
binding = SyncActivityBinding.inflate(LayoutInflater.from(this))
binding.root.setupToolbar(
toolbar = binding.toolbar,
title = resources.getString(R.string.device_sync),
color = PaletteColor(11),
theme = themeSwitcher.currentTheme,
)
binding.generateButton.setOnClickListener { onGenerateCode() }
binding.enterButton.setOnClickListener {
val et = EditText(this)
AlertDialog.Builder(this)
.setTitle(R.string.sync_code)
.setView(et)
.setPositiveButton(R.string.save) { _, _ ->
onEnterCode(et.text.toString())
}
.show()
}
binding.disableButton.setOnClickListener {
AlertDialog.Builder(this)
.setTitle(R.string.disable_sync)
.setMessage(R.string.disable_sync_description)
.setPositiveButton(R.string.disable) { _, _ ->
onDisableSync()
}
.setNegativeButton(R.string.keep_enabled) { dialog, _ ->
dialog.dismiss()
}
.show()
}
setContentView(binding.root)
}
private fun onGenerateCode() {
showCodeScreen()
}
private fun onEnterCode(code: String) {
showCodeScreen()
}
private fun onDisableSync() {
showIntroScreen()
}
private fun showCodeScreen() {
binding.introGroup.visibility = View.GONE
binding.codeGroup.visibility = View.VISIBLE
}
private fun showIntroScreen() {
binding.introGroup.visibility = View.VISIBLE
binding.codeGroup.visibility = View.GONE
}
}

View File

@@ -33,8 +33,7 @@ import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.R
import org.isoron.uhabits.activities.AndroidThemeSwitcher
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.ui.views.DarkTheme
import org.isoron.uhabits.core.ui.views.LightTheme
import org.isoron.uhabits.core.ui.ThemeSwitcher.Companion.THEME_LIGHT
import org.isoron.uhabits.receivers.ReminderController
import org.isoron.uhabits.utils.SystemUtils
import java.util.Calendar
@@ -52,8 +51,11 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener {
val app = applicationContext as HabitsApplication
val appComponent = app.component
val themeSwitcher = AndroidThemeSwitcher(this, appComponent.preferences)
themeSwitcher.setTheme()
if (themeSwitcher.getSystemTheme() == THEME_LIGHT) {
setTheme(R.style.BaseDialog)
} else {
setTheme(R.style.BaseDialogDark)
}
val data = intent.data
if (data == null) {
finish()
@@ -73,16 +75,6 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener {
SystemUtils.unlockScreen(this)
}
private fun AndroidThemeSwitcher.setTheme() {
if (this.isNightMode) {
setTheme(R.style.BaseDialogDark)
this.currentTheme = DarkTheme()
} else {
setTheme(R.style.BaseDialog)
this.currentTheme = LightTheme()
}
}
private fun showTimePicker() {
val calendar = Calendar.getInstance()
val dialog = TimePickerDialog.newInstance(

View File

@@ -1,10 +1,6 @@
2.1.1:
* Fix Tasker plugin
2.1:
* Add notes to specific dates
* Track at-most measurable habits
* Add skips to measurable habits
* Bring back custom frequencies
* Other minor improvements and bug fixes

View File

@@ -0,0 +1,55 @@
<!--
~ Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
~
~ This file is part of Loop Habit Tracker.
~
~ Loop Habit Tracker is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by the
~ Free Software Foundation, either version 3 of the License, or (at your
~ option) any later version.
~
~ Loop Habit Tracker is distributed in the hope that it will be useful, but
~ WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
~ or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
~ more details.
~
~ You should have received a copy of the GNU General Public License along
~ with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="243dp"
android:height="85dp"
android:viewportWidth="243"
android:viewportHeight="85">
<path
android:pathData="M44.354,0H7.827C3.506,0 0,3.569 0,7.969V77.031C0,81.431 3.506,85 7.827,85H44.354C48.676,85 52.182,81.431 52.182,77.031V7.969C52.182,3.569 48.676,0 44.354,0ZM26.091,79.688C23.205,79.688 20.873,77.313 20.873,74.375C20.873,71.437 23.205,69.063 26.091,69.063C28.977,69.063 31.309,71.437 31.309,74.375C31.309,77.313 28.977,79.688 26.091,79.688ZM44.354,61.758C44.354,62.854 43.474,63.75 42.398,63.75H9.784C8.708,63.75 7.827,62.854 7.827,61.758V9.961C7.827,8.865 8.708,7.969 9.784,7.969H42.398C43.474,7.969 44.354,8.865 44.354,9.961V61.758Z"
android:fillColor="#ffffff" />
<path
android:pathData="M234.536,0H198.009C193.688,0 190.182,3.569 190.182,7.969V77.031C190.182,81.431 193.688,85 198.009,85H234.536C238.858,85 242.364,81.431 242.364,77.031V7.969C242.364,3.569 238.858,0 234.536,0ZM216.273,79.688C213.386,79.688 211.055,77.313 211.055,74.375C211.055,71.437 213.386,69.063 216.273,69.063C219.159,69.063 221.491,71.437 221.491,74.375C221.491,77.313 219.159,79.688 216.273,79.688ZM234.536,61.758C234.536,62.854 233.656,63.75 232.58,63.75H199.966C198.89,63.75 198.009,62.854 198.009,61.758V9.961C198.009,8.865 198.89,7.969 199.966,7.969H232.58C233.656,7.969 234.536,8.865 234.536,9.961V61.758Z"
android:fillColor="#ffffff" />
<path
android:pathData="M68.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M110.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M96.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M82.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M124.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M166.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M152.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M138.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
</vector>

View File

@@ -0,0 +1,36 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="243dp"
android:height="85dp"
android:viewportWidth="243"
android:viewportHeight="85">
<path
android:pathData="M44.354,0H7.827C3.506,0 0,3.569 0,7.969V77.031C0,81.431 3.506,85 7.827,85H44.354C48.676,85 52.182,81.431 52.182,77.031V7.969C52.182,3.569 48.676,0 44.354,0ZM26.091,79.688C23.205,79.688 20.873,77.313 20.873,74.375C20.873,71.437 23.205,69.063 26.091,69.063C28.977,69.063 31.309,71.437 31.309,74.375C31.309,77.313 28.977,79.688 26.091,79.688ZM44.354,61.758C44.354,62.854 43.474,63.75 42.398,63.75H9.784C8.708,63.75 7.827,62.854 7.827,61.758V9.961C7.827,8.865 8.708,7.969 9.784,7.969H42.398C43.474,7.969 44.354,8.865 44.354,9.961V61.758Z"
android:fillColor="#000000" />
<path
android:pathData="M234.536,0H198.009C193.688,0 190.182,3.569 190.182,7.969V77.031C190.182,81.431 193.688,85 198.009,85H234.536C238.858,85 242.364,81.431 242.364,77.031V7.969C242.364,3.569 238.858,0 234.536,0ZM216.273,79.688C213.386,79.688 211.055,77.313 211.055,74.375C211.055,71.437 213.386,69.063 216.273,69.063C219.159,69.063 221.491,71.437 221.491,74.375C221.491,77.313 219.159,79.688 216.273,79.688ZM234.536,61.758C234.536,62.854 233.656,63.75 232.58,63.75H199.966C198.89,63.75 198.009,62.854 198.009,61.758V9.961C198.009,8.865 198.89,7.969 199.966,7.969H232.58C233.656,7.969 234.536,8.865 234.536,9.961V61.758Z"
android:fillColor="#000000" />
<path
android:pathData="M68.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M110.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M96.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M82.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M124.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M166.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M152.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M138.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
</vector>

View File

@@ -0,0 +1,162 @@
<!--
~ Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
~
~ This file is part of Loop Habit Tracker.
~
~ Loop Habit Tracker is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by the
~ Free Software Foundation, either version 3 of the License, or (at your
~ option) any later version.
~
~ Loop Habit Tracker is distributed in the hope that it will be useful, but
~ WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
~ or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
~ more details.
~
~ You should have received a copy of the GNU General Public License along
~ with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?attr/contrast0"
android:gravity="top|center">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
app:popupTheme="?toolbarPopupTheme"
style="@style/Toolbar" />
<ScrollView
android:id="@+id/introGroup"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="top|center">
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:srcCompat="?attr/iconSync"
android:layout_margin="32dp"
android:alpha="0.25" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/device_sync_description_1"
android:layout_marginBottom="16dp"
android:layout_marginTop="0dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/device_sync_description_2"
android:layout_marginBottom="16dp"
android:layout_marginTop="0dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/generate_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/generate_new_code"
app:backgroundTint="?attr/aboutScreenColor"
android:textColor="?attr/contrast0"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp" />
<com.google.android.material.button.MaterialButton
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:id="@+id/enter_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/enter_existing_code"
android:textColor="?attr/aboutScreenColor"
app:rippleColor="?attr/aboutScreenColor"
app:strokeColor="?attr/aboutScreenColor"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp" />
</androidx.appcompat.widget.LinearLayoutCompat>
</ScrollView>
<ScrollView
android:id="@+id/codeGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sync_is_enabled"
android:layout_margin="16dp" />
<FrameLayout
style="@style/FormOuterBox"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp">
<LinearLayout style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:text="@string/sync_code" />
<TextView
style="@style/FormInput"
android:id="@+id/sync_code_tv"
android:fontFamily="monospace"
android:text="gravity trophy suspect shrimp sheriff avocado label trust tragic dove pitch title network myself task spell protect smooth diary sword brain blossom bulb under" />
</LinearLayout>
</FrameLayout>
<FrameLayout
style="@style/FormOuterBox"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp">
<LinearLayout style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:text="@string/last_sync" />
<TextView
style="@style/FormInput"
android:id="@+id/last_sync_tv"
android:text="Jan 10, 2022 4:45:00 PM" />
</LinearLayout>
</FrameLayout>
<com.google.android.material.button.MaterialButton
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:id="@+id/disable_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/disable_sync"
android:textColor="?attr/aboutScreenColor"
app:rippleColor="?attr/aboutScreenColor"
app:strokeColor="?attr/aboutScreenColor"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp" />
</LinearLayout>
</ScrollView>
</androidx.appcompat.widget.LinearLayoutCompat>

View File

@@ -43,6 +43,7 @@
<attr name="iconFilter" format="reference"/>
<attr name="iconArrowUp" format="reference"/>
<attr name="iconArrowDown" format="reference"/>
<attr name="iconSync" format="reference"/>
<attr name="dialogFormLabelColor" format="reference"/>
<attr name="toolbarPopupTheme" format="reference"/>

View File

@@ -233,4 +233,18 @@
<string name="activity_not_found">No app was found to support this action</string>
<string name="pref_midnight_delay_title">Extend day a few hours past midnight</string>
<string name="pref_midnight_delay_description">Wait until 3:00 AM to show a new day. Useful if you typically go to sleep after midnight. Requires app restart.</string>
<string name="device_sync">Device sync</string>
<string name="config_sync">Configure device sync</string>
<string name="config_sync_summary">Synchronize data across multiple devices. When enabled, an end-to-end encrypted copy of your data will be uploaded to Loop Habit Tracker servers.</string>
<string name="device_sync_description_1">Device sync allows you to keep your data synchronized across multiple devices. To get started, generate a new sync code below, install Loop Habit Tracker in another device, then type the generated code there.</string>
<string name="device_sync_description_2">When sync is enabled, an end-to-end encrypted copy of your data will be uploaded to Loop Habit Tracker servers. See privacy policy for more details.</string>
<string name="generate_new_code">Generate new code</string>
<string name="enter_existing_code">Enter existing code</string>
<string name="sync_is_enabled">Device sync is enabled. To get started, install Loop in another device, then type the following code there.</string>
<string name="sync_code">Sync code</string>
<string name="disable_sync">Disable sync</string>
<string name="disable_sync_description">Are you sure you want to disable sync on this device? This will not delete any data from any of your devices, but the current device will no longer be kept in sync with the others. If you disable sync from all devices, your data will be deleted from our servers in 30 days.</string>
<string name="disable">Disable</string>
<string name="keep_enabled">Keep enabled</string>
<string name="last_sync">Last sync</string>
</resources>

View File

@@ -45,6 +45,7 @@
<item name="iconUnarchive">@drawable/ic_action_unarchive_dark</item>
<item name="iconArrowUp">@drawable/ic_arrow_up_light</item>
<item name="iconArrowDown">@drawable/ic_arrow_down_light</item>
<item name="iconSync">@drawable/ic_sync_light</item>
<item name="contrast0">@color/white</item>
<item name="contrast20">@color/grey_300</item>
<item name="contrast40">@color/grey_350</item>
@@ -89,6 +90,7 @@
<item name="iconUnarchive">@drawable/ic_action_unarchive_dark</item>
<item name="iconArrowUp">@drawable/ic_arrow_up_dark</item>
<item name="iconArrowDown">@drawable/ic_arrow_down_dark</item>
<item name="iconSync">@drawable/ic_sync_dark</item>
<item name="contrast0">@color/grey_900</item>
<item name="contrast20">@color/grey_800</item>
<item name="contrast40">@color/grey_750</item>

View File

@@ -107,6 +107,24 @@
</PreferenceCategory>
<PreferenceCategory
android:key="syncCategory"
android:title="@string/device_sync">
<Preference
android:key="configSync"
android:summary="@string/config_sync_summary"
android:title="@string/config_sync"
app:iconSpaceReserved="false">
<intent
android:action="android.intent.action.VIEW"
android:targetClass="org.isoron.uhabits.activities.sync.SyncActivity"
android:targetPackage="org.isoron.uhabits" />
</Preference>
</PreferenceCategory>
<PreferenceCategory
android:key="databaseCategory"
android:title="@string/database">
@@ -160,9 +178,11 @@
</Preference>
<Preference
android:key="rateApp"
android:title="@string/pref_rate_this_app"
app:iconSpaceReserved="false">
<intent
android:action="android.intent.action.VIEW"
android:data="@string/playStoreURL" />
</Preference>
<Preference

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
HabitName,HabitDescription,HabitCategory,CalendarDate,Value,CommentText
Caffeine,,Coffee Consumption,2022-11-21,80,
Caffeine,,Coffee Consumption,2022-11-22,80,
1 HabitName HabitDescription HabitCategory CalendarDate Value CommentText
2 Caffeine Coffee Consumption 2022-11-21 80
3 Caffeine Coffee Consumption 2022-11-22 80

View File

@@ -43,12 +43,13 @@ kotlin {
val jvmMain by getting {
dependencies {
implementation(kotlin("stdlib-jdk8"))
compileOnly("com.google.dagger:dagger:2.44.2")
compileOnly("com.google.dagger:dagger:2.43.2")
implementation("com.google.guava:guava:31.1-android")
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.7.10")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4")
implementation("androidx.annotation:annotation:1.5.0")
implementation("androidx.annotation:annotation:1.4.0")
implementation("com.google.code.findbugs:jsr305:3.0.2")
implementation("com.opencsv:opencsv:5.7.1")
implementation("com.opencsv:opencsv:5.6")
implementation("commons-codec:commons-codec:1.15")
implementation("org.apache.commons:commons-lang3:3.12.0")
}
@@ -58,7 +59,7 @@ kotlin {
dependencies {
implementation(kotlin("test"))
implementation(kotlin("test-junit"))
implementation("org.xerial:sqlite-jdbc:3.40.0.0")
implementation("org.xerial:sqlite-jdbc:3.39.2.1")
implementation("org.hamcrest:hamcrest:2.2")
implementation("org.apache.commons:commons-io:1.3.2")
implementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")

View File

@@ -0,0 +1,76 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.crypto
class Bip39(private val wordlist: List<String>, private val crypto: Crypto) {
private fun computeChecksum(entropy: List<Boolean>): List<Boolean> {
val sha256 = crypto.sha256()
var byte = 0
entropy.forEachIndexed { i, bit ->
byte = byte shl 1
if (bit) byte += 1
if (i.rem(8) == 7) {
sha256.update(byte.toByte())
byte = 0
}
}
return sha256.finalize().toBits().subList(0, entropy.size / 32)
}
fun encode(entropy: ByteArray): List<String> {
val entropyBits = entropy.toBits()
val msg = entropyBits + computeChecksum(entropyBits)
var wordIndex = 0
val mnemonic = mutableListOf<String>()
msg.forEachIndexed { i, bit ->
wordIndex = wordIndex shl 1
if (bit) wordIndex += 1
if (i.rem(11) == 10) {
mnemonic.add(wordlist[wordIndex])
wordIndex = 0
}
}
return mnemonic
}
fun decode(mnemonic: List<String>): ByteArray {
val bits = mutableListOf<Boolean>()
mnemonic.forEach { word ->
val wordBits = mutableListOf<Boolean>()
var wordIndex = wordlist.binarySearch(word)
if (wordIndex < 0) throw InvalidWordException(word)
for (it in 0..10) {
wordBits.add(wordIndex.rem(2) == 1)
wordIndex = wordIndex shr 1
}
bits.addAll(wordBits.reversed())
}
if (bits.size.rem(33) != 0) throw InvalidMnemonicLength()
val checksumSize = bits.size / 33
val checksum = bits.subList(bits.size - checksumSize, bits.size)
val entropy = bits.subList(0, bits.size - checksumSize)
if (computeChecksum(entropy) != checksum) throw InvalidChecksumException()
return byteArray(entropy)
}
}
class InvalidChecksumException : Exception()
class InvalidWordException(word: String) : Exception(word)
class InvalidMnemonicLength : Exception()

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.crypto
class Key(val bytes: ByteArray)
interface Crypto {
fun sha256(): Sha256
fun hmacSha256(): HmacSha256
fun aesGcm(key: Key): AesGcm
fun secureRandomBytes(numBytes: Int): ByteArray
fun generateKey(): Key {
return Key(secureRandomBytes(32))
}
fun deriveKey(master: Key, subkeyName: String): Key {
val mac = hmacSha256()
mac.init(master.bytes)
mac.update(subkeyName)
return Key(mac.finalize())
}
}
interface Sha256 {
fun update(byte: Byte)
fun finalize(): ByteArray
}
interface HmacSha256 {
fun init(key: ByteArray)
fun update(byte: Byte)
fun finalize(): ByteArray
fun update(msg: String) {
for (b in msg.encodeToByteArray()) {
update(b)
}
}
}
interface AesGcm {
fun encrypt(msg: ByteArray, iv: ByteArray): ByteArray
fun decrypt(cipherText: ByteArray): ByteArray
}
fun Byte.toBits(): List<Boolean> = (7 downTo 0).map { (toInt() and (1 shl it)) != 0 }
fun ByteArray.toBits(): List<Boolean> = flatMap { it.toBits() }
fun byteArrayOfInts(vararg b: Int) = b.map { it.toByte() }.toByteArray()
fun byteArray(bits: List<Boolean>): ByteArray {
var byte = 0
val bytes = ByteArray(bits.size / 8)
bits.forEachIndexed { i, b ->
byte = byte shl 1
if (b) byte += 1
if (i.rem(8) == 7) {
bytes[i / 8] = byte.toByte()
}
}
return bytes
}

View File

@@ -17,21 +17,9 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.server
import org.isoron.uhabits.sync.*
import org.isoron.uhabits.sync.links.*
package org.isoron.uhabits.core.sync
interface AbstractSyncServer {
/**
* Generates and returns a new sync key, which can be used to store and retrive
* data.
*
* @throws ServiceUnavailable If key cannot be generated at this time, for example,
* due to insufficient server resources, temporary server maintenance or network problems.
*/
suspend fun register(): String
/**
* Replaces data for a given sync key.
*
@@ -60,22 +48,4 @@ interface AbstractSyncServer {
* to insufficient server resources or network problems.
*/
suspend fun getDataVersion(key: String): Long
/**
* Registers a new temporary link (mapping to the given sync key) and returns it.
*
* @throws ServiceUnavailable If the link cannot be generated at this time due to
* insufficient server resources.
*/
suspend fun registerLink(syncKey: String): Link
/**
* Retrieves the syncKey associated with the given link id.
*
* @throws ServiceUnavailable If the link cannot be resolved at this time due to
* insufficient server resources.
* @throws KeyNotFoundException If the link id cannot be found, or if it has
* expired.
*/
suspend fun getLink(id: String): Link
}

View File

@@ -17,9 +17,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
import com.fasterxml.jackson.databind.*
package org.isoron.uhabits.core.sync
data class SyncData(
val version: Long,
@@ -29,7 +27,3 @@ data class SyncData(
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)

View File

@@ -17,12 +17,12 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
package org.isoron.uhabits.core.sync
open class SyncException: RuntimeException()
open class SyncException : RuntimeException()
class KeyNotFoundException: SyncException()
class KeyNotFoundException : SyncException()
class ServiceUnavailable: SyncException()
class ServiceUnavailable : SyncException()
class EditConflictException: SyncException()
class EditConflictException : SyncException()

View File

@@ -0,0 +1,83 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.crypto
import java.nio.ByteBuffer
import java.security.MessageDigest
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
class JavaCrypto : Crypto {
override fun sha256() = JavaSha256()
override fun hmacSha256() = JavaHmacSha256()
override fun aesGcm(key: Key) = JavaAesGcm(key)
override fun secureRandomBytes(numBytes: Int): ByteArray {
val sr = SecureRandom()
val bytes = ByteArray(numBytes)
sr.nextBytes(bytes)
return bytes
}
}
class JavaSha256 : Sha256 {
private val md = MessageDigest.getInstance("SHA-256")
override fun update(byte: Byte) = md.update(byte)
override fun finalize(): ByteArray = md.digest()
}
class JavaHmacSha256 : HmacSha256 {
private val mac = Mac.getInstance("HmacSHA256")
override fun init(key: ByteArray) = mac.init(SecretKeySpec(key, "HmacSHA256"))
override fun update(byte: Byte) = mac.update(byte)
override fun finalize(): ByteArray = mac.doFinal()
}
class JavaAesGcm(val key: Key) : AesGcm {
override fun encrypt(msg: ByteArray, iv: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key.bytes, "AES"), GCMParameterSpec(128, iv))
val encrypted = cipher.doFinal(msg)
return ByteBuffer
.allocate(iv.size + encrypted.size)
.put(iv)
.put(encrypted)
.array()
}
override fun decrypt(cipherText: ByteArray): ByteArray {
val buffer = ByteBuffer.wrap(cipherText)
val iv = ByteArray(12)
buffer.get(iv)
val encrypted = ByteArray(buffer.remaining())
buffer.get(encrypted)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key.bytes, "AES"), GCMParameterSpec(128, iv))
return cipher.doFinal(encrypted)
}
}
fun ByteArray.toHexString(): String {
val sb = StringBuilder()
for (b in this) sb.append(String.format("%02x", b))
return sb.toString()
}

View File

@@ -86,12 +86,10 @@ class HabitBullCSVImporter
logger.info("Found a value of $value, considering this habit as numerical.")
h.type = HabitType.NUMERICAL
}
h.originalEntries.add(Entry(timestamp, value * 1000, notes))
h.originalEntries.add(Entry(timestamp, value, notes))
}
}
}
map.forEach { (_, habit) -> habit.recompute() }
}
private fun parseTimestamp(rawValue: String): Timestamp {

View File

@@ -0,0 +1,126 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.crypto
import kotlinx.coroutines.runBlocking
import org.isoron.platform.io.JavaFileOpener
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import kotlin.test.assertFailsWith
class Bip39Test {
private lateinit var bip39: Bip39
private val phrases = listOf(
listOf(
"gather",
"capable",
"since",
),
listOf(
"exit",
"churn",
"hazard",
"garage",
"hint",
"great",
),
listOf(
"exile",
"blouse",
"athlete",
"dinner",
"chef",
"home",
"destroy",
"disagree",
"select",
"eight",
"slim",
"talent",
),
)
private val entropies = listOf(
byteArrayOfInts(0x60, 0x64, 0x3f, 0x24),
byteArrayOfInts(0x4f, 0xe5, 0x19, 0xa7, 0xaf, 0xb6, 0xbc, 0xcc),
byteArrayOfInts(
0x4f,
0xa3,
0x04,
0x38,
0x9f,
0x22,
0x74,
0xda,
0x0f,
0x09,
0xf6,
0xc3,
0x48,
0xdf,
0x2f,
0x6e,
)
)
@Before
fun setUp() = runBlocking {
bip39 = Bip39(JavaFileOpener().openResourceFile("bip39/en_US.txt").lines(), JavaCrypto())
}
@Test
fun test_encode_decode() {
phrases.zip(entropies).forEach { (phrase, entropy) ->
assertEquals(phrase, bip39.encode(entropy))
assertEquals(entropy.toHexString(), bip39.decode(phrase).toHexString())
}
}
@Test
fun test_decode_invalid_checksum() {
assertFailsWith<InvalidChecksumException> {
bip39.decode(
listOf(
"lawn",
"dirt",
"work",
"mountain",
"depth",
"loyal",
"citizen",
"theory",
"cram",
"trip",
"boil",
"about",
)
)
}
}
@Test
fun test_decode_invalid_word() {
assertFailsWith<InvalidWordException> {
bip39.decode(listOf("dirt", "bee", "work"))
}
}
}

View File

@@ -0,0 +1,234 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.crypto
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
class CryptoTest {
private val crypto = JavaCrypto()
@Test
fun test_sha256() {
val sha256 = crypto.sha256()
sha256.update(0x10.toByte())
sha256.update(0x20.toByte())
sha256.update(0x30.toByte())
val digest = sha256.finalize()
assertEquals(32, digest.size)
assertEquals(0x8e.toByte(), digest[0])
assertEquals(0x13.toByte(), digest[1])
assertEquals(0x36.toByte(), digest[2])
assertEquals(0xb9.toByte(), digest[31])
}
@Test
fun test_hmacsha256() {
val hmac = crypto.hmacSha256()
hmac.init(byteArrayOfInts(0x01, 0x02, 0x03))
hmac.update(0x40.toByte())
hmac.update("AB")
val checksum = hmac.finalize()
assertEquals(32, checksum.size)
assertEquals(0x6d.toByte(), checksum[0])
assertEquals(0xc9.toByte(), checksum[1])
assertEquals(0x05.toByte(), checksum[2])
assertEquals(0xa1.toByte(), checksum[31])
}
@Test
fun test_aes_gcm() {
val msg = byteArrayOfInts(
0x2f,
0xdc,
0xaa,
0x41,
0xfa,
0xb8,
0x5e,
0xe8,
0xa3,
0x12,
0x69,
0x68,
0x14,
0x31,
0xd8,
0x59,
0x74,
0x29,
0x2e,
0xae,
0xed,
0x76,
0x0a,
0x56,
0x46,
0x90,
0xb6,
0xcb,
0x9f,
0x37,
0xbe,
0xae,
)
val key = Key(
byteArrayOfInts(
0xed,
0xa8,
0xc3,
0xc6,
0x44,
0x1e,
0xa1,
0xd5,
0x71,
0x8c,
0x71,
0x45,
0xbe,
0x2d,
0xf7,
0xa4,
0x81,
0x2e,
0x0a,
0x0b,
0xa8,
0xe4,
0x20,
0x49,
0x94,
0x8a,
0x71,
0x1a,
0x15,
0xf5,
0x29,
0x78,
)
)
val iv = byteArrayOfInts(
0xa7,
0xef,
0xe1,
0xba,
0xdf,
0x4f,
0x85,
0xca,
0xc3,
0x81,
0xc1,
0x93,
)
val expected = byteArrayOfInts(
// iv
0xa7,
0xef,
0xe1,
0xba,
0xdf,
0x4f,
0x85,
0xca,
0xc3,
0x81,
0xc1,
0x93,
// msg
0x24,
0xe7,
0x26,
0x9b,
0xb8,
0x59,
0xf0,
0xe0,
0x4f,
0xda,
0xc0,
0x85,
0xc6,
0x23,
0x21,
0x61,
0x80,
0x59,
0xd6,
0x18,
0xee,
0xa0,
0xd8,
0x00,
0xe3,
0xdf,
0x6e,
0xcf,
0x89,
0x82,
0xfd,
0x63,
// verification tag
0xe9,
0xe9,
0xac,
0x92,
0xdc,
0xb1,
0x7c,
0x2d,
0x9a,
0x73,
0xda,
0x25,
0x6d,
0xda,
0xc0,
0x83,
)
val cipher = crypto.aesGcm(key)
val actual = cipher.encrypt(msg, iv)
assertEquals(actual.toHexString(), expected.toHexString())
val recovered = cipher.decrypt(actual)
assertEquals(msg.toHexString(), recovered.toHexString())
}
@Test
fun test_rand() {
val r1 = crypto.secureRandomBytes(8)
val r2 = crypto.secureRandomBytes(8)
assertEquals(8, r1.size)
assertNotEquals(r1.toBits(), r2.toBits())
}
@Test
fun test_derive_key() {
val k1 = Key(byteArrayOfInts(0x01, 0x02, 0x03))
val k2 = crypto.deriveKey(k1, "TEST")
assertEquals(0x44.toByte(), k2.bytes[0])
assertEquals(0xd3.toByte(), k2.bytes[31])
}
}

View File

@@ -85,8 +85,8 @@ class ImportTest : BaseUnitTest() {
assertThat(habit.type, equalTo(HabitType.NUMERICAL))
assertThat(habit.description, equalTo(""))
assertThat(habit.frequency, equalTo(Frequency.DAILY))
assertThat(getValue(habit, 2021, 9, 1), equalTo(30000))
assertThat(getValue(habit, 2022, 1, 8), equalTo(100000))
assertThat(getValue(habit, 2021, 9, 1), equalTo(30))
assertThat(getValue(habit, 2022, 1, 8), equalTo(100))
val habit2 = habitList.getByPosition(1)
assertThat(habit2.name, equalTo("run"))
@@ -98,21 +98,6 @@ class ImportTest : BaseUnitTest() {
assertTrue(isChecked(habit2, 2022, 1, 19))
}
@Test
@Throws(IOException::class)
fun testHabitBullCSV4() {
importFromFile("habitbull4.csv")
assertThat(habitList.size(), equalTo(1))
val habit = habitList.getByPosition(0)
assertThat(habit.name, equalTo("Caffeine"))
assertThat(habit.type, equalTo(HabitType.NUMERICAL))
assertThat(habit.description, equalTo(""))
assertThat(habit.frequency, equalTo(Frequency.DAILY))
assertThat(getValue(habit, 2022, 11, 21), equalTo(80000))
assertThat(getValue(habit, 2022, 11, 22), equalTo(80000))
}
@Test
@Throws(IOException::class)
fun testLoopDB() {

View File

@@ -1,6 +1,6 @@
FROM openjdk:8-jre-alpine
RUN mkdir /app
COPY uhabits-server.jar /app/uhabits-server.jar
COPY build/libs/uhabits-server.jar /app/uhabits-server.jar
ENV LOOP_REPO_PATH /data/
WORKDIR /app
CMD ["java", \

View File

@@ -23,9 +23,9 @@ plugins {
application
id("kotlin")
id("com.github.johnrengelman.shadow") version "7.1.2"
id("org.jlleitschuh.gradle.ktlint")
}
application {
group = "org.isoron.uhabits"
version = "0.0.1"
@@ -34,8 +34,9 @@ application {
dependencies {
val ktorVersion = "1.6.8"
val kotlinVersion = "1.7.21"
val logbackVersion = "1.4.5"
val kotlinVersion = "1.7.10"
val logbackVersion = "1.4.0"
implementation(project(":uhabits-core"))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
implementation("io.ktor:ktor-server-netty:$ktorVersion")
implementation("ch.qos.logback:logback-classic:$logbackVersion")

View File

@@ -17,17 +17,17 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.app
import io.ktor.application.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import io.prometheus.client.*
import io.prometheus.client.exporter.common.*
import io.prometheus.client.hotspot.*
import java.io.*
package org.isoron.uhabits.server.app
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.response.respond
import io.ktor.routing.Routing
import io.ktor.routing.get
import io.prometheus.client.CollectorRegistry
import io.prometheus.client.exporter.common.TextFormat
import io.prometheus.client.hotspot.DefaultExports
import java.io.StringWriter
fun Routing.metrics(app: SyncApplication) {
// Register JVM metrics

View File

@@ -17,14 +17,20 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.app
package org.isoron.uhabits.server.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.*
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.Routing
import io.ktor.routing.get
import io.ktor.routing.put
import io.ktor.routing.route
import org.isoron.uhabits.core.sync.EditConflictException
import org.isoron.uhabits.core.sync.GetDataVersionResponse
import org.isoron.uhabits.core.sync.KeyNotFoundException
import org.isoron.uhabits.core.sync.SyncData
fun Routing.storage(app: SyncApplication) {
route("/db/{key}") {
@@ -33,7 +39,7 @@ fun Routing.storage(app: SyncApplication) {
try {
val data = app.server.getData(key)
call.respond(HttpStatusCode.OK, data)
} catch(e: KeyNotFoundException) {
} catch (e: KeyNotFoundException) {
call.respond(HttpStatusCode.NotFound)
}
}
@@ -43,8 +49,6 @@ fun Routing.storage(app: SyncApplication) {
try {
app.server.put(key, data)
call.respond(HttpStatusCode.OK)
} catch (e: KeyNotFoundException) {
call.respond(HttpStatusCode.NotFound)
} catch (e: EditConflictException) {
call.respond(HttpStatusCode.Conflict)
}
@@ -54,7 +58,7 @@ fun Routing.storage(app: SyncApplication) {
try {
val version = app.server.getDataVersion(key)
call.respond(HttpStatusCode.OK, GetDataVersionResponse(version))
} catch(e: KeyNotFoundException) {
} catch (e: KeyNotFoundException) {
call.respond(HttpStatusCode.NotFound)
}
}

View File

@@ -17,16 +17,20 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.app
package org.isoron.uhabits.server.app
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.jackson.*
import io.ktor.routing.*
import org.isoron.uhabits.sync.*
import org.isoron.uhabits.sync.repository.*
import org.isoron.uhabits.sync.server.*
import java.nio.file.*
import io.ktor.application.Application
import io.ktor.application.install
import io.ktor.features.CallLogging
import io.ktor.features.ContentNegotiation
import io.ktor.features.DefaultHeaders
import io.ktor.jackson.jackson
import io.ktor.routing.routing
import org.isoron.uhabits.core.sync.AbstractSyncServer
import org.isoron.uhabits.server.sync.Repository
import org.isoron.uhabits.server.sync.RepositorySyncServer
import java.nio.file.Path
import java.nio.file.Paths
fun Application.main() = SyncApplication().apply { main() }
@@ -34,7 +38,7 @@ val REPOSITORY_PATH: Path = Paths.get(System.getenv("LOOP_REPO_PATH")!!)
class SyncApplication(
val server: AbstractSyncServer = RepositorySyncServer(
FileRepository(REPOSITORY_PATH),
Repository(REPOSITORY_PATH),
),
) {
fun Application.main() {
@@ -44,9 +48,7 @@ class SyncApplication(
jackson { }
}
routing {
registration(this@SyncApplication)
storage(this@SyncApplication)
links(this@SyncApplication)
metrics(this@SyncApplication)
}
}

View File

@@ -17,17 +17,17 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.repository
package org.isoron.uhabits.server.sync
import org.isoron.uhabits.sync.*
import java.io.*
import java.nio.file.*
import org.isoron.uhabits.core.sync.KeyNotFoundException
import org.isoron.uhabits.core.sync.SyncData
import java.io.PrintWriter
import java.nio.file.Path
class FileRepository(
class Repository(
private val basepath: Path,
) : Repository {
override suspend fun put(key: String, data: SyncData) {
) {
fun put(key: String, data: SyncData) {
// Create directory
val dataPath = key.toDataPath()
val dataDir = dataPath.toFile()
@@ -50,7 +50,7 @@ class FileRepository(
}
}
override suspend fun get(key: String): SyncData {
fun get(key: String): SyncData {
val dataPath = key.toDataPath()
val contentFile = dataPath.resolve("content").toFile()
val versionFile = dataPath.resolve("version").toFile()
@@ -61,7 +61,7 @@ class FileRepository(
return SyncData(version, contentFile.readText())
}
override suspend fun contains(key: String): Boolean {
fun contains(key: String): Boolean {
val dataPath = key.toDataPath()
val versionFile = dataPath.resolve("version").toFile()
return versionFile.exists()

View File

@@ -17,20 +17,19 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.server
package org.isoron.uhabits.server.sync
import io.prometheus.client.*
import org.isoron.uhabits.sync.*
import org.isoron.uhabits.sync.links.*
import org.isoron.uhabits.sync.repository.*
import org.isoron.uhabits.sync.utils.*
import io.prometheus.client.Counter
import org.isoron.uhabits.core.sync.AbstractSyncServer
import org.isoron.uhabits.core.sync.EditConflictException
import org.isoron.uhabits.core.sync.KeyNotFoundException
import org.isoron.uhabits.core.sync.SyncData
/**
* An AbstractSyncServer that stores all data in a [Repository].
*/
class RepositorySyncServer(
private val repo: Repository,
private val linkManager: LinkManager = LinkManager(),
) : AbstractSyncServer {
private val requestsCounter: Counter = Counter.build()
@@ -39,21 +38,13 @@ class RepositorySyncServer(
.labelNames("method")
.register()
override suspend fun register(): String {
requestsCounter.labels("register").inc()
val key = generateKey()
repo.put(key, SyncData(0, ""))
return key
}
override suspend fun put(key: String, newData: SyncData) {
requestsCounter.labels("put").inc()
if (!repo.contains(key)) {
throw KeyNotFoundException()
}
val prevData = repo.get(key)
if (newData.version != prevData.version + 1) {
throw EditConflictException()
if (repo.contains(key)) {
val prevData = repo.get(key)
if (newData.version != prevData.version + 1) {
throw EditConflictException()
}
}
repo.put(key, newData)
}
@@ -73,22 +64,4 @@ class RepositorySyncServer(
}
return repo.get(key).version
}
override suspend fun registerLink(syncKey: String): Link {
requestsCounter.labels("registerLink").inc()
return linkManager.register(syncKey)
}
override suspend fun getLink(id: String): Link {
requestsCounter.labels("getLink").inc()
return linkManager.get(id)
}
private suspend fun generateKey(): String {
while (true) {
val key = randomString(64)
if (!repo.contains(key))
return key
}
}
}

View File

@@ -17,15 +17,12 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.utils
package org.isoron.uhabits.server.sync
import java.util.*
import kotlin.streams.*
import com.fasterxml.jackson.databind.ObjectMapper
import org.isoron.uhabits.core.sync.GetDataVersionResponse
import org.isoron.uhabits.core.sync.SyncData
fun randomString(length: Long): String {
val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
return Random().ints(length, 0, chars.length)
.asSequence()
.map(chars::get)
.joinToString("")
}
val defaultMapper = ObjectMapper()
fun SyncData.toJson(): String = defaultMapper.writeValueAsString(this)
fun GetDataVersionResponse.toJson(): String = defaultMapper.writeValueAsString(this)

View File

@@ -1,55 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.app
import io.ktor.application.*
import io.ktor.http.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.routing.*
import org.isoron.uhabits.sync.*
data class LinkRegisterRequestData(
val syncKey: String,
)
fun LinkRegisterRequestData.toJson(): String = defaultMapper.writeValueAsString(this)
fun Routing.links(app: SyncApplication) {
post("/links") {
try {
val data = call.receive<LinkRegisterRequestData>()
val link = app.server.registerLink(data.syncKey)
call.respond(HttpStatusCode.OK, link)
} catch (e: ServiceUnavailable) {
call.respond(HttpStatusCode.ServiceUnavailable)
}
}
get("/links/{id}") {
try {
val id = call.parameters["id"]!!
val link = app.server.getLink(id)
call.respond(HttpStatusCode.OK, link)
} catch (e: ServiceUnavailable) {
call.respond(HttpStatusCode.ServiceUnavailable)
} catch (e: KeyNotFoundException) {
call.respond(HttpStatusCode.NotFound)
}
}
}

View File

@@ -1,37 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.app
import io.ktor.application.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import org.isoron.uhabits.sync.*
fun Routing.registration(app: SyncApplication) {
post("/register") {
try {
val key = app.server.register()
call.respond(HttpStatusCode.OK, RegisterReponse(key))
} catch (e: ServiceUnavailable) {
call.respond(HttpStatusCode.ServiceUnavailable)
}
}
}

View File

@@ -1,36 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.links
import org.isoron.uhabits.sync.defaultMapper
/**
* A Link maps a public URL (such as https://sync.loophabits.org/links/B752A6)
* to a synchronization key. They are used to transfer sync keys between devices
* without ever exposing the original sync key. Unlike sync keys, links expire
* after a few minutes.
*/
data class Link(
val id: String,
val syncKey: String,
val createdAt: Long,
)
fun Link.toJson(): String = defaultMapper.writeValueAsString(this)

View File

@@ -1,49 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.links
import org.isoron.uhabits.sync.*
import org.isoron.uhabits.sync.utils.*
class LinkManager(
private val timeoutInMillis: Long = 900_000,
) {
private val links = HashMap<String, Link>()
fun register(syncKey: String): Link {
val link = Link(
id = randomString(64),
syncKey = syncKey,
createdAt = System.currentTimeMillis(),
)
links[link.id] = link
return link
}
fun get(id: String): Link {
val link = links[id] ?: throw KeyNotFoundException()
val ageInMillis = System.currentTimeMillis() - link.createdAt
if (ageInMillis > timeoutInMillis) {
links.remove(id)
throw KeyNotFoundException()
}
return link
}
}

View File

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

View File

@@ -4,6 +4,6 @@ ktor {
port = ${?PORT}
}
application {
modules = [ org.isoron.uhabits.sync.app.SyncApplicationKt.main ]
modules = [ org.isoron.uhabits.server.app.SyncApplicationKt.main ]
}
}

View File

@@ -17,11 +17,11 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.app
package org.isoron.uhabits.server.app
import com.nhaarman.mockitokotlin2.mock
import io.ktor.application.*
import org.isoron.uhabits.sync.server.*
import io.ktor.application.Application
import org.isoron.uhabits.core.sync.AbstractSyncServer
open class BaseApplicationTest {

View File

@@ -17,16 +17,27 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.app
package org.isoron.uhabits.server.app
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlinx.coroutines.*
import org.isoron.uhabits.sync.*
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.TestApplicationCall
import io.ktor.server.testing.TestApplicationEngine
import io.ktor.server.testing.handleRequest
import io.ktor.server.testing.setBody
import io.ktor.server.testing.withTestApplication
import kotlinx.coroutines.runBlocking
import org.isoron.uhabits.core.sync.EditConflictException
import org.isoron.uhabits.core.sync.GetDataVersionResponse
import org.isoron.uhabits.core.sync.KeyNotFoundException
import org.isoron.uhabits.core.sync.SyncData
import org.isoron.uhabits.server.sync.toJson
import org.junit.Test
import kotlin.test.*
import kotlin.test.assertEquals
class StorageModuleTest : BaseApplicationTest() {
private val data1 = SyncData(1, "Hello world")
@@ -64,7 +75,6 @@ class StorageModuleTest : BaseApplicationTest() {
}
}
@Test
fun `when put succeeds should return OK`(): Unit = runBlocking {
withTestApplication(app()) {
@@ -77,16 +87,6 @@ class StorageModuleTest : BaseApplicationTest() {
}
}
@Test
fun `when put with invalid key should return 404`(): Unit = runBlocking {
whenever(server.put("k1", data1)).thenThrow(KeyNotFoundException())
withTestApplication(app()) {
handlePut("/db/k1", data1).apply {
assertEquals(HttpStatusCode.NotFound, response.status())
}
}
}
@Test
fun `when put with invalid version should return 409 and current data`(): Unit = runBlocking {
whenever(server.put("k1", data1)).thenThrow(EditConflictException())

View File

@@ -17,26 +17,25 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.server
package org.isoron.uhabits.server.sync
import kotlinx.coroutines.*
import org.isoron.uhabits.sync.*
import org.isoron.uhabits.sync.repository.*
import kotlinx.coroutines.runBlocking
import org.isoron.uhabits.core.sync.EditConflictException
import org.isoron.uhabits.core.sync.KeyNotFoundException
import org.isoron.uhabits.core.sync.SyncData
import org.junit.Test
import java.nio.file.*
import kotlin.test.*
import java.nio.file.Files
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class RepositorySyncServerTest {
private val tempdir = Files.createTempDirectory("db")
private val server = RepositorySyncServer(FileRepository(tempdir))
private val key = runBlocking { server.register() }
private val server = RepositorySyncServer(Repository(tempdir))
private val key = "abcdefgh"
@Test
fun testUsage(): Unit = runBlocking {
val data0 = SyncData(0, "")
assertEquals(server.getData(key), data0)
val data1 = SyncData(1, "Hello world")
server.put(key, data1)
assertEquals(server.getData(key), data1)
@@ -52,9 +51,5 @@ class RepositorySyncServerTest {
assertFailsWith<KeyNotFoundException> {
server.getData("INVALID")
}
assertFailsWith<KeyNotFoundException> {
server.put("INVALID", data0)
}
}
}

View File

@@ -17,24 +17,25 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@file:Suppress("BlockingMethodInNonBlockingContext")
package org.isoron.uhabits.sync.repository
package org.isoron.uhabits.server.sync
import kotlinx.coroutines.*
import org.hamcrest.CoreMatchers.*
import org.isoron.uhabits.sync.*
import org.junit.*
import org.junit.Assert.*
import java.nio.file.*
import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers.equalTo
import org.isoron.uhabits.core.sync.SyncData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThat
import org.junit.Assert.assertTrue
import org.junit.Test
import java.nio.file.Files
class FileRepositoryTest {
class RepositoryTest {
@Test
fun testUsage() = runBlocking {
val tempdir = Files.createTempDirectory("db")!!
val repo = FileRepository(tempdir)
val repo = Repository(tempdir)
val original = SyncData(10, "Hello world")
repo.put("abcdefg", original)

View File

@@ -1,91 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.app
import com.nhaarman.mockitokotlin2.whenever
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.TestApplicationCall
import io.ktor.server.testing.TestApplicationEngine
import io.ktor.server.testing.handleRequest
import io.ktor.server.testing.setBody
import io.ktor.server.testing.withTestApplication
import kotlinx.coroutines.runBlocking
import org.isoron.uhabits.sync.KeyNotFoundException
import org.isoron.uhabits.sync.links.Link
import org.isoron.uhabits.sync.links.toJson
import org.junit.Test
import kotlin.test.assertEquals
class LinksModuleTest : BaseApplicationTest() {
private val link = Link(
id = "ABC123",
syncKey = "SECRET",
createdAt = System.currentTimeMillis(),
)
@Test
fun `when POST is successful should return link`(): Unit = runBlocking {
whenever(server.registerLink("SECRET")).thenReturn(link)
withTestApplication(app()) {
handlePost("/links", LinkRegisterRequestData(syncKey = "SECRET")).apply {
assertEquals(HttpStatusCode.OK, response.status())
assertEquals(link.toJson(), response.content)
}
}
}
@Test
fun `when GET is successful should return link`(): Unit = runBlocking {
whenever(server.getLink("ABC123")).thenReturn(link)
withTestApplication(app()) {
handleGet("/links/ABC123").apply {
assertEquals(HttpStatusCode.OK, response.status())
assertEquals(link.toJson(), response.content)
}
}
}
@Test
fun `GET with invalid link id should return 404`(): Unit = runBlocking {
whenever(server.getLink("ABC123")).thenThrow(KeyNotFoundException())
withTestApplication(app()) {
handleGet("/links/ABC123").apply {
assertEquals(HttpStatusCode.NotFound, response.status())
}
}
}
private fun TestApplicationEngine.handlePost(
url: String,
data: LinkRegisterRequestData
): TestApplicationCall {
return handleRequest(HttpMethod.Post, url) {
addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(data.toJson())
}
}
private fun TestApplicationEngine.handleGet(url: String): TestApplicationCall {
return handleRequest(HttpMethod.Get, url)
}
}

View File

@@ -1,51 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.app
import com.nhaarman.mockitokotlin2.whenever
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.handleRequest
import io.ktor.server.testing.withTestApplication
import kotlinx.coroutines.runBlocking
import org.isoron.uhabits.sync.ServiceUnavailable
import org.junit.Test
import kotlin.test.assertEquals
class RegistrationModuleTest : BaseApplicationTest() {
@Test
fun `when register succeeds should return generated key`(): Unit = runBlocking {
whenever(server.register()).thenReturn("ABCDEF")
withTestApplication(app()) {
val call = handleRequest(HttpMethod.Post, "/register")
assertEquals(HttpStatusCode.OK, call.response.status())
assertEquals("{\"key\":\"ABCDEF\"}", call.response.content)
}
}
@Test
fun `when registration is unavailable should return 503`(): Unit = runBlocking {
whenever(server.register()).thenThrow(ServiceUnavailable())
withTestApplication(app()) {
val call = handleRequest(HttpMethod.Post, "/register")
assertEquals(HttpStatusCode.ServiceUnavailable, call.response.status())
}
}
}

View File

@@ -1,44 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.links
import org.isoron.uhabits.sync.*
import org.junit.Test
import kotlin.test.*
class LinkManagerTest {
@Test
fun testUsage() {
val manager = LinkManager(timeoutInMillis = 250)
val originalLink = manager.register(syncKey = "SECRET")
val retrievedLink = manager.get(originalLink.id)
assertEquals(originalLink, retrievedLink)
Thread.sleep(260) // wait until expiration
assertFailsWith<KeyNotFoundException> {
manager.get(originalLink.id)
}
assertFailsWith<KeyNotFoundException> {
manager.get("INVALID")
}
}
}