mirror of
https://github.com/iSoron/uhabits.git
synced 2025-12-06 01:08:50 -06:00
Compare commits
13 Commits
feature/sy
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
145179bba3
|
|||
|
|
c7d1e92cae | ||
|
|
d24dcbf2ca | ||
|
|
579b33cc78 | ||
|
|
1a3e6315a1 | ||
|
|
7f4d06d15d | ||
|
|
b8033a6012 | ||
|
404671546c
|
|||
|
a94c6e8b9f
|
|||
|
da09df2dd1
|
|||
|
40a4d254f5
|
|||
|
177d01edd9
|
|||
|
9f5da7b4fe
|
@@ -1,5 +1,9 @@
|
||||
# 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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
val kotlinVersion = "1.7.10"
|
||||
id("com.android.application") version ("7.3.0-rc01") apply (false)
|
||||
val kotlinVersion = "1.7.21"
|
||||
id("com.android.application") version ("7.3.1") 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)
|
||||
|
||||
54
docs/TEST.md
54
docs/TEST.md
@@ -2,51 +2,25 @@
|
||||
|
||||
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:
|
||||
|
||||
* **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.
|
||||
- **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.
|
||||
|
||||
## Running small tests
|
||||
## Running unit 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.
|
||||
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.
|
||||
|
||||
## Running medium tests
|
||||
## Running instrumented 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.
|
||||
|
||||
./build.sh build
|
||||
./build.sh medium-tests
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
**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.
|
||||
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`.
|
||||
|
||||
@@ -35,8 +35,8 @@ android {
|
||||
compileSdk = 32
|
||||
|
||||
defaultConfig {
|
||||
versionCode = 20100
|
||||
versionName = "2.1.0"
|
||||
versionCode = 20101
|
||||
versionName = "2.1.1"
|
||||
minSdk = 28
|
||||
targetSdk = 31
|
||||
applicationId = "org.isoron.uhabits"
|
||||
@@ -81,7 +81,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
val daggerVersion = "2.43.2"
|
||||
val kotlinVersion = "1.7.10"
|
||||
val kotlinVersion = "1.7.21"
|
||||
val kxCoroutinesVersion = "1.6.4"
|
||||
val ktorVersion = "1.6.8"
|
||||
val espressoVersion = "3.4.0"
|
||||
@@ -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.4.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.3")
|
||||
androidTestImplementation("androidx.annotation:annotation:1.5.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0")
|
||||
androidTestImplementation("androidx.test:rules:1.4.0")
|
||||
androidTestImplementation("androidx.test:rules:1.5.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.0")
|
||||
implementation("androidx.appcompat:appcompat:1.5.1")
|
||||
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.6.1")
|
||||
implementation("com.opencsv:opencsv:5.6")
|
||||
implementation("com.google.android.material:material:1.7.0")
|
||||
implementation("com.opencsv:opencsv:5.7.1")
|
||||
implementation(project(":uhabits-core"))
|
||||
kapt("com.google.dagger:dagger-compiler:$daggerVersion")
|
||||
kaptAndroidTest("com.google.dagger:dagger-compiler:$daggerVersion")
|
||||
|
||||
@@ -79,14 +79,6 @@
|
||||
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=""
|
||||
@@ -278,7 +270,7 @@
|
||||
<!-- Locale/Tasker -->
|
||||
<receiver
|
||||
android:name=".automation.FireSettingReceiver"
|
||||
android:exported="false">
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.twofortyfouram.locale.intent.action.FIRE_SETTING" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -22,6 +22,7 @@ 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
|
||||
@@ -43,6 +44,7 @@ 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
|
||||
|
||||
@@ -92,16 +94,24 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
val key = preference.key ?: return false
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
return super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
|
||||
@@ -1,100 +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.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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
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
|
||||
* Other minor improvements and bug fixes
|
||||
|
||||
|
||||
@@ -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/>.
|
||||
-->
|
||||
|
||||
<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>
|
||||
@@ -1,36 +0,0 @@
|
||||
<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>
|
||||
@@ -1,162 +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/>.
|
||||
-->
|
||||
|
||||
<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>
|
||||
@@ -43,7 +43,6 @@
|
||||
<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"/>
|
||||
|
||||
@@ -233,18 +233,4 @@
|
||||
<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>
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
<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>
|
||||
@@ -90,7 +89,6 @@
|
||||
<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>
|
||||
|
||||
@@ -107,24 +107,6 @@
|
||||
|
||||
</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">
|
||||
@@ -178,11 +160,9 @@
|
||||
</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
@@ -45,11 +45,11 @@ kotlin {
|
||||
implementation(kotlin("stdlib-jdk8"))
|
||||
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.kotlin:kotlin-stdlib:1.7.21")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4")
|
||||
implementation("androidx.annotation:annotation:1.4.0")
|
||||
implementation("androidx.annotation:annotation:1.5.0")
|
||||
implementation("com.google.code.findbugs:jsr305:3.0.2")
|
||||
implementation("com.opencsv:opencsv:5.6")
|
||||
implementation("com.opencsv:opencsv:5.7.1")
|
||||
implementation("commons-codec:commons-codec:1.15")
|
||||
implementation("org.apache.commons:commons-lang3:3.12.0")
|
||||
}
|
||||
@@ -59,7 +59,7 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(kotlin("test-junit"))
|
||||
implementation("org.xerial:sqlite-jdbc:3.39.2.1")
|
||||
implementation("org.xerial:sqlite-jdbc:3.40.0.0")
|
||||
implementation("org.hamcrest:hamcrest:2.2")
|
||||
implementation("org.apache.commons:commons-io:1.3.2")
|
||||
implementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
|
||||
|
||||
@@ -1,76 +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.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()
|
||||
@@ -1,78 +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.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
|
||||
}
|
||||
@@ -1,83 +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.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()
|
||||
}
|
||||
@@ -1,126 +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.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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,234 +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.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])
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM openjdk:8-jre-alpine
|
||||
RUN mkdir /app
|
||||
COPY build/libs/uhabits-server.jar /app/uhabits-server.jar
|
||||
COPY uhabits-server.jar /app/uhabits-server.jar
|
||||
ENV LOOP_REPO_PATH /data/
|
||||
WORKDIR /app
|
||||
CMD ["java", \
|
||||
|
||||
@@ -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,9 +34,8 @@ application {
|
||||
|
||||
dependencies {
|
||||
val ktorVersion = "1.6.8"
|
||||
val kotlinVersion = "1.7.10"
|
||||
val logbackVersion = "1.4.0"
|
||||
implementation(project(":uhabits-core"))
|
||||
val kotlinVersion = "1.7.21"
|
||||
val logbackVersion = "1.4.4"
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
|
||||
implementation("io.ktor:ktor-server-netty:$ktorVersion")
|
||||
implementation("ch.qos.logback:logback-classic:$logbackVersion")
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.core.sync
|
||||
package org.isoron.uhabits.sync
|
||||
|
||||
import com.fasterxml.jackson.databind.*
|
||||
|
||||
data class SyncData(
|
||||
val version: Long,
|
||||
@@ -27,3 +29,7 @@ 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)
|
||||
@@ -17,12 +17,12 @@
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.core.sync
|
||||
package org.isoron.uhabits.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()
|
||||
@@ -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/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,17 +17,17 @@
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.server.app
|
||||
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.*
|
||||
|
||||
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
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,20 +17,14 @@
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.server.app
|
||||
package org.isoron.uhabits.sync.app
|
||||
|
||||
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
|
||||
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}") {
|
||||
@@ -39,7 +33,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)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +43,8 @@ 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)
|
||||
}
|
||||
@@ -58,9 +54,9 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,20 +17,16 @@
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.server.app
|
||||
package org.isoron.uhabits.sync.app
|
||||
|
||||
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
|
||||
import io.ktor.application.*
|
||||
import io.ktor.features.*
|
||||
import io.ktor.jackson.*
|
||||
import io.ktor.routing.*
|
||||
import org.isoron.uhabits.sync.*
|
||||
import org.isoron.uhabits.sync.repository.*
|
||||
import org.isoron.uhabits.sync.server.*
|
||||
import java.nio.file.*
|
||||
|
||||
fun Application.main() = SyncApplication().apply { main() }
|
||||
|
||||
@@ -38,7 +34,7 @@ val REPOSITORY_PATH: Path = Paths.get(System.getenv("LOOP_REPO_PATH")!!)
|
||||
|
||||
class SyncApplication(
|
||||
val server: AbstractSyncServer = RepositorySyncServer(
|
||||
Repository(REPOSITORY_PATH),
|
||||
FileRepository(REPOSITORY_PATH),
|
||||
),
|
||||
) {
|
||||
fun Application.main() {
|
||||
@@ -48,7 +44,9 @@ class SyncApplication(
|
||||
jackson { }
|
||||
}
|
||||
routing {
|
||||
registration(this@SyncApplication)
|
||||
storage(this@SyncApplication)
|
||||
links(this@SyncApplication)
|
||||
metrics(this@SyncApplication)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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)
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -17,17 +17,17 @@
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.server.sync
|
||||
package org.isoron.uhabits.sync.repository
|
||||
|
||||
import org.isoron.uhabits.core.sync.KeyNotFoundException
|
||||
import org.isoron.uhabits.core.sync.SyncData
|
||||
import java.io.PrintWriter
|
||||
import java.nio.file.Path
|
||||
import org.isoron.uhabits.sync.*
|
||||
import java.io.*
|
||||
import java.nio.file.*
|
||||
|
||||
class Repository(
|
||||
class FileRepository(
|
||||
private val basepath: Path,
|
||||
) {
|
||||
fun put(key: String, data: SyncData) {
|
||||
) : Repository {
|
||||
|
||||
override suspend fun put(key: String, data: SyncData) {
|
||||
// Create directory
|
||||
val dataPath = key.toDataPath()
|
||||
val dataDir = dataPath.toFile()
|
||||
@@ -50,7 +50,7 @@ class Repository(
|
||||
}
|
||||
}
|
||||
|
||||
fun get(key: String): SyncData {
|
||||
override suspend 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 Repository(
|
||||
return SyncData(version, contentFile.readText())
|
||||
}
|
||||
|
||||
fun contains(key: String): Boolean {
|
||||
override suspend fun contains(key: String): Boolean {
|
||||
val dataPath = key.toDataPath()
|
||||
val versionFile = dataPath.resolve("version").toFile()
|
||||
return versionFile.exists()
|
||||
@@ -70,4 +70,4 @@ class Repository(
|
||||
private fun String.toDataPath(): Path {
|
||||
return basepath.resolve("${this[0]}/${this[1]}/${this[2]}/${this[3]}/$this")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
|
||||
@@ -17,9 +17,21 @@
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.core.sync
|
||||
package org.isoron.uhabits.sync.server
|
||||
|
||||
import org.isoron.uhabits.sync.*
|
||||
import org.isoron.uhabits.sync.links.*
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -48,4 +60,22 @@ 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
|
||||
}
|
||||
@@ -17,19 +17,20 @@
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.server.sync
|
||||
package org.isoron.uhabits.sync.server
|
||||
|
||||
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
|
||||
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.*
|
||||
|
||||
/**
|
||||
* 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()
|
||||
@@ -38,13 +39,21 @@ 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)) {
|
||||
val prevData = repo.get(key)
|
||||
if (newData.version != prevData.version + 1) {
|
||||
throw EditConflictException()
|
||||
}
|
||||
if (!repo.contains(key)) {
|
||||
throw KeyNotFoundException()
|
||||
}
|
||||
val prevData = repo.get(key)
|
||||
if (newData.version != prevData.version + 1) {
|
||||
throw EditConflictException()
|
||||
}
|
||||
repo.put(key, newData)
|
||||
}
|
||||
@@ -64,4 +73,22 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,15 @@
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.server.sync
|
||||
package org.isoron.uhabits.sync.utils
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.isoron.uhabits.core.sync.GetDataVersionResponse
|
||||
import org.isoron.uhabits.core.sync.SyncData
|
||||
import java.util.*
|
||||
import kotlin.streams.*
|
||||
|
||||
val defaultMapper = ObjectMapper()
|
||||
fun SyncData.toJson(): String = defaultMapper.writeValueAsString(this)
|
||||
fun GetDataVersionResponse.toJson(): String = defaultMapper.writeValueAsString(this)
|
||||
fun randomString(length: Long): String {
|
||||
val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
return Random().ints(length, 0, chars.length)
|
||||
.asSequence()
|
||||
.map(chars::get)
|
||||
.joinToString("")
|
||||
}
|
||||
@@ -4,6 +4,6 @@ ktor {
|
||||
port = ${?PORT}
|
||||
}
|
||||
application {
|
||||
modules = [ org.isoron.uhabits.server.app.SyncApplicationKt.main ]
|
||||
modules = [ org.isoron.uhabits.sync.app.SyncApplicationKt.main ]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.server.app
|
||||
package org.isoron.uhabits.sync.app
|
||||
|
||||
import com.nhaarman.mockitokotlin2.mock
|
||||
import io.ktor.application.Application
|
||||
import org.isoron.uhabits.core.sync.AbstractSyncServer
|
||||
import io.ktor.application.*
|
||||
import org.isoron.uhabits.sync.server.*
|
||||
|
||||
open class BaseApplicationTest {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,27 +17,16 @@
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.server.app
|
||||
package org.isoron.uhabits.sync.app
|
||||
|
||||
import com.nhaarman.mockitokotlin2.verify
|
||||
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.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 io.ktor.http.*
|
||||
import io.ktor.server.testing.*
|
||||
import kotlinx.coroutines.*
|
||||
import org.isoron.uhabits.sync.*
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.*
|
||||
|
||||
class StorageModuleTest : BaseApplicationTest() {
|
||||
private val data1 = SyncData(1, "Hello world")
|
||||
@@ -75,6 +64,7 @@ class StorageModuleTest : BaseApplicationTest() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun `when put succeeds should return OK`(): Unit = runBlocking {
|
||||
withTestApplication(app()) {
|
||||
@@ -87,6 +77,16 @@ 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())
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,25 +17,24 @@
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
@file:Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
||||
package org.isoron.uhabits.server.sync
|
||||
package org.isoron.uhabits.sync.repository
|
||||
|
||||
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
|
||||
import kotlinx.coroutines.*
|
||||
import org.hamcrest.CoreMatchers.*
|
||||
import org.isoron.uhabits.sync.*
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import java.nio.file.*
|
||||
|
||||
class RepositoryTest {
|
||||
class FileRepositoryTest {
|
||||
|
||||
@Test
|
||||
fun testUsage() = runBlocking {
|
||||
val tempdir = Files.createTempDirectory("db")!!
|
||||
val repo = Repository(tempdir)
|
||||
val repo = FileRepository(tempdir)
|
||||
|
||||
val original = SyncData(10, "Hello world")
|
||||
repo.put("abcdefg", original)
|
||||
@@ -51,4 +50,4 @@ class RepositoryTest {
|
||||
val retrieved = repo.get("abcdefg")
|
||||
assertThat(retrieved, equalTo(original))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,25 +17,26 @@
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.server.sync
|
||||
package org.isoron.uhabits.sync.server
|
||||
|
||||
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 kotlinx.coroutines.*
|
||||
import org.isoron.uhabits.sync.*
|
||||
import org.isoron.uhabits.sync.repository.*
|
||||
import org.junit.Test
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import java.nio.file.*
|
||||
import kotlin.test.*
|
||||
|
||||
class RepositorySyncServerTest {
|
||||
|
||||
private val tempdir = Files.createTempDirectory("db")
|
||||
private val server = RepositorySyncServer(Repository(tempdir))
|
||||
private val key = "abcdefgh"
|
||||
private val server = RepositorySyncServer(FileRepository(tempdir))
|
||||
private val key = runBlocking { server.register() }
|
||||
|
||||
@Test
|
||||
fun testUsage(): Unit = runBlocking {
|
||||
val data0 = SyncData(0, "")
|
||||
assertEquals(server.getData(key), data0)
|
||||
|
||||
val data1 = SyncData(1, "Hello world")
|
||||
server.put(key, data1)
|
||||
assertEquals(server.getData(key), data1)
|
||||
@@ -51,5 +52,9 @@ class RepositorySyncServerTest {
|
||||
assertFailsWith<KeyNotFoundException> {
|
||||
server.getData("INVALID")
|
||||
}
|
||||
|
||||
assertFailsWith<KeyNotFoundException> {
|
||||
server.put("INVALID", data0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user