mirror of
https://github.com/iSoron/uhabits.git
synced 2025-12-06 01:08:50 -06:00
Compare commits
11 Commits
897a236501
...
feature/sy
| Author | SHA1 | Date | |
|---|---|---|---|
|
b2cb54e32b
|
|||
| b7232b12cd | |||
| 8cd5b93b47 | |||
| 1b07efe291 | |||
| 232b25bed4 | |||
| 2ea6fe0570 | |||
| bb8b742dc4 | |||
| 85b52a9840 | |||
| 774412492f | |||
| 370ddfb8db | |||
| 1567e2c0ad |
@@ -79,6 +79,14 @@
|
|||||||
android:value=".activities.habits.list.ListHabitsActivity" />
|
android:value=".activities.habits.list.ListHabitsActivity" />
|
||||||
</activity>
|
</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
|
<activity
|
||||||
android:name=".activities.intro.IntroActivity"
|
android:name=".activities.intro.IntroActivity"
|
||||||
android:label=""
|
android:label=""
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
55
uhabits-android/src/main/res/drawable/ic_sync_dark.xml
Normal file
55
uhabits-android/src/main/res/drawable/ic_sync_dark.xml
Normal 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>
|
||||||
36
uhabits-android/src/main/res/drawable/ic_sync_light.xml
Normal file
36
uhabits-android/src/main/res/drawable/ic_sync_light.xml
Normal 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>
|
||||||
162
uhabits-android/src/main/res/layout/sync_activity.xml
Normal file
162
uhabits-android/src/main/res/layout/sync_activity.xml
Normal 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>
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
<attr name="iconFilter" format="reference"/>
|
<attr name="iconFilter" format="reference"/>
|
||||||
<attr name="iconArrowUp" format="reference"/>
|
<attr name="iconArrowUp" format="reference"/>
|
||||||
<attr name="iconArrowDown" format="reference"/>
|
<attr name="iconArrowDown" format="reference"/>
|
||||||
|
<attr name="iconSync" format="reference"/>
|
||||||
<attr name="dialogFormLabelColor" format="reference"/>
|
<attr name="dialogFormLabelColor" format="reference"/>
|
||||||
|
|
||||||
<attr name="toolbarPopupTheme" format="reference"/>
|
<attr name="toolbarPopupTheme" format="reference"/>
|
||||||
|
|||||||
@@ -233,4 +233,18 @@
|
|||||||
<string name="activity_not_found">No app was found to support this action</string>
|
<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_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="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>
|
</resources>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
<item name="iconUnarchive">@drawable/ic_action_unarchive_dark</item>
|
<item name="iconUnarchive">@drawable/ic_action_unarchive_dark</item>
|
||||||
<item name="iconArrowUp">@drawable/ic_arrow_up_light</item>
|
<item name="iconArrowUp">@drawable/ic_arrow_up_light</item>
|
||||||
<item name="iconArrowDown">@drawable/ic_arrow_down_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="contrast0">@color/white</item>
|
||||||
<item name="contrast20">@color/grey_300</item>
|
<item name="contrast20">@color/grey_300</item>
|
||||||
<item name="contrast40">@color/grey_350</item>
|
<item name="contrast40">@color/grey_350</item>
|
||||||
@@ -89,6 +90,7 @@
|
|||||||
<item name="iconUnarchive">@drawable/ic_action_unarchive_dark</item>
|
<item name="iconUnarchive">@drawable/ic_action_unarchive_dark</item>
|
||||||
<item name="iconArrowUp">@drawable/ic_arrow_up_dark</item>
|
<item name="iconArrowUp">@drawable/ic_arrow_up_dark</item>
|
||||||
<item name="iconArrowDown">@drawable/ic_arrow_down_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="contrast0">@color/grey_900</item>
|
||||||
<item name="contrast20">@color/grey_800</item>
|
<item name="contrast20">@color/grey_800</item>
|
||||||
<item name="contrast40">@color/grey_750</item>
|
<item name="contrast40">@color/grey_750</item>
|
||||||
|
|||||||
@@ -107,6 +107,24 @@
|
|||||||
|
|
||||||
</PreferenceCategory>
|
</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
|
<PreferenceCategory
|
||||||
android:key="databaseCategory"
|
android:key="databaseCategory"
|
||||||
android:title="@string/database">
|
android:title="@string/database">
|
||||||
|
|||||||
2048
uhabits-core/assets/main/bip39/chinese_simplified.txt
Normal file
2048
uhabits-core/assets/main/bip39/chinese_simplified.txt
Normal file
File diff suppressed because it is too large
Load Diff
2048
uhabits-core/assets/main/bip39/chinese_traditional.txt
Normal file
2048
uhabits-core/assets/main/bip39/chinese_traditional.txt
Normal file
File diff suppressed because it is too large
Load Diff
2048
uhabits-core/assets/main/bip39/czech.txt
Normal file
2048
uhabits-core/assets/main/bip39/czech.txt
Normal file
File diff suppressed because it is too large
Load Diff
2048
uhabits-core/assets/main/bip39/en_US.txt
Normal file
2048
uhabits-core/assets/main/bip39/en_US.txt
Normal file
File diff suppressed because it is too large
Load Diff
2048
uhabits-core/assets/main/bip39/french.txt
Normal file
2048
uhabits-core/assets/main/bip39/french.txt
Normal file
File diff suppressed because it is too large
Load Diff
2048
uhabits-core/assets/main/bip39/italian.txt
Normal file
2048
uhabits-core/assets/main/bip39/italian.txt
Normal file
File diff suppressed because it is too large
Load Diff
2048
uhabits-core/assets/main/bip39/japanese.txt
Normal file
2048
uhabits-core/assets/main/bip39/japanese.txt
Normal file
File diff suppressed because it is too large
Load Diff
2048
uhabits-core/assets/main/bip39/korean.txt
Normal file
2048
uhabits-core/assets/main/bip39/korean.txt
Normal file
File diff suppressed because it is too large
Load Diff
2048
uhabits-core/assets/main/bip39/portuguese.txt
Normal file
2048
uhabits-core/assets/main/bip39/portuguese.txt
Normal file
File diff suppressed because it is too large
Load Diff
2048
uhabits-core/assets/main/bip39/spanish.txt
Normal file
2048
uhabits-core/assets/main/bip39/spanish.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -17,21 +17,9 @@
|
|||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.isoron.uhabits.sync.server
|
package org.isoron.uhabits.core.sync
|
||||||
|
|
||||||
import org.isoron.uhabits.sync.*
|
|
||||||
import org.isoron.uhabits.sync.links.*
|
|
||||||
|
|
||||||
interface AbstractSyncServer {
|
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.
|
* Replaces data for a given sync key.
|
||||||
*
|
*
|
||||||
@@ -60,22 +48,4 @@ interface AbstractSyncServer {
|
|||||||
* to insufficient server resources or network problems.
|
* to insufficient server resources or network problems.
|
||||||
*/
|
*/
|
||||||
suspend fun getDataVersion(key: String): Long
|
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,9 +17,7 @@
|
|||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.isoron.uhabits.sync
|
package org.isoron.uhabits.core.sync
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.*
|
|
||||||
|
|
||||||
data class SyncData(
|
data class SyncData(
|
||||||
val version: Long,
|
val version: Long,
|
||||||
@@ -29,7 +27,3 @@ data class SyncData(
|
|||||||
data class RegisterReponse(val key: String)
|
data class RegisterReponse(val key: String)
|
||||||
|
|
||||||
data class GetDataVersionResponse(val version: Long)
|
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/>.
|
* 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()
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM openjdk:8-jre-alpine
|
FROM openjdk:8-jre-alpine
|
||||||
RUN mkdir /app
|
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/
|
ENV LOOP_REPO_PATH /data/
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
CMD ["java", \
|
CMD ["java", \
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ plugins {
|
|||||||
application
|
application
|
||||||
id("kotlin")
|
id("kotlin")
|
||||||
id("com.github.johnrengelman.shadow") version "7.1.2"
|
id("com.github.johnrengelman.shadow") version "7.1.2"
|
||||||
|
id("org.jlleitschuh.gradle.ktlint")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
application {
|
application {
|
||||||
group = "org.isoron.uhabits"
|
group = "org.isoron.uhabits"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
@@ -36,6 +36,7 @@ dependencies {
|
|||||||
val ktorVersion = "1.6.8"
|
val ktorVersion = "1.6.8"
|
||||||
val kotlinVersion = "1.7.10"
|
val kotlinVersion = "1.7.10"
|
||||||
val logbackVersion = "1.4.0"
|
val logbackVersion = "1.4.0"
|
||||||
|
implementation(project(":uhabits-core"))
|
||||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
|
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
|
||||||
implementation("io.ktor:ktor-server-netty:$ktorVersion")
|
implementation("io.ktor:ktor-server-netty:$ktorVersion")
|
||||||
implementation("ch.qos.logback:logback-classic:$logbackVersion")
|
implementation("ch.qos.logback:logback-classic:$logbackVersion")
|
||||||
|
|||||||
@@ -17,17 +17,17 @@
|
|||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* 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.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) {
|
fun Routing.metrics(app: SyncApplication) {
|
||||||
// Register JVM metrics
|
// Register JVM metrics
|
||||||
@@ -17,14 +17,20 @@
|
|||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* 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.application.call
|
||||||
import io.ktor.http.*
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.request.*
|
import io.ktor.request.receive
|
||||||
import io.ktor.response.*
|
import io.ktor.response.respond
|
||||||
import io.ktor.routing.*
|
import io.ktor.routing.Routing
|
||||||
import org.isoron.uhabits.sync.*
|
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) {
|
fun Routing.storage(app: SyncApplication) {
|
||||||
route("/db/{key}") {
|
route("/db/{key}") {
|
||||||
@@ -33,7 +39,7 @@ fun Routing.storage(app: SyncApplication) {
|
|||||||
try {
|
try {
|
||||||
val data = app.server.getData(key)
|
val data = app.server.getData(key)
|
||||||
call.respond(HttpStatusCode.OK, data)
|
call.respond(HttpStatusCode.OK, data)
|
||||||
} catch(e: KeyNotFoundException) {
|
} catch (e: KeyNotFoundException) {
|
||||||
call.respond(HttpStatusCode.NotFound)
|
call.respond(HttpStatusCode.NotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,8 +49,6 @@ fun Routing.storage(app: SyncApplication) {
|
|||||||
try {
|
try {
|
||||||
app.server.put(key, data)
|
app.server.put(key, data)
|
||||||
call.respond(HttpStatusCode.OK)
|
call.respond(HttpStatusCode.OK)
|
||||||
} catch (e: KeyNotFoundException) {
|
|
||||||
call.respond(HttpStatusCode.NotFound)
|
|
||||||
} catch (e: EditConflictException) {
|
} catch (e: EditConflictException) {
|
||||||
call.respond(HttpStatusCode.Conflict)
|
call.respond(HttpStatusCode.Conflict)
|
||||||
}
|
}
|
||||||
@@ -54,9 +58,9 @@ fun Routing.storage(app: SyncApplication) {
|
|||||||
try {
|
try {
|
||||||
val version = app.server.getDataVersion(key)
|
val version = app.server.getDataVersion(key)
|
||||||
call.respond(HttpStatusCode.OK, GetDataVersionResponse(version))
|
call.respond(HttpStatusCode.OK, GetDataVersionResponse(version))
|
||||||
} catch(e: KeyNotFoundException) {
|
} catch (e: KeyNotFoundException) {
|
||||||
call.respond(HttpStatusCode.NotFound)
|
call.respond(HttpStatusCode.NotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,16 +17,20 @@
|
|||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* 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.application.Application
|
||||||
import io.ktor.features.*
|
import io.ktor.application.install
|
||||||
import io.ktor.jackson.*
|
import io.ktor.features.CallLogging
|
||||||
import io.ktor.routing.*
|
import io.ktor.features.ContentNegotiation
|
||||||
import org.isoron.uhabits.sync.*
|
import io.ktor.features.DefaultHeaders
|
||||||
import org.isoron.uhabits.sync.repository.*
|
import io.ktor.jackson.jackson
|
||||||
import org.isoron.uhabits.sync.server.*
|
import io.ktor.routing.routing
|
||||||
import java.nio.file.*
|
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() }
|
fun Application.main() = SyncApplication().apply { main() }
|
||||||
|
|
||||||
@@ -34,7 +38,7 @@ val REPOSITORY_PATH: Path = Paths.get(System.getenv("LOOP_REPO_PATH")!!)
|
|||||||
|
|
||||||
class SyncApplication(
|
class SyncApplication(
|
||||||
val server: AbstractSyncServer = RepositorySyncServer(
|
val server: AbstractSyncServer = RepositorySyncServer(
|
||||||
FileRepository(REPOSITORY_PATH),
|
Repository(REPOSITORY_PATH),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
fun Application.main() {
|
fun Application.main() {
|
||||||
@@ -44,9 +48,7 @@ class SyncApplication(
|
|||||||
jackson { }
|
jackson { }
|
||||||
}
|
}
|
||||||
routing {
|
routing {
|
||||||
registration(this@SyncApplication)
|
|
||||||
storage(this@SyncApplication)
|
storage(this@SyncApplication)
|
||||||
links(this@SyncApplication)
|
|
||||||
metrics(this@SyncApplication)
|
metrics(this@SyncApplication)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,17 +17,17 @@
|
|||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* 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 org.isoron.uhabits.core.sync.KeyNotFoundException
|
||||||
import java.io.*
|
import org.isoron.uhabits.core.sync.SyncData
|
||||||
import java.nio.file.*
|
import java.io.PrintWriter
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
class FileRepository(
|
class Repository(
|
||||||
private val basepath: Path,
|
private val basepath: Path,
|
||||||
) : Repository {
|
) {
|
||||||
|
fun put(key: String, data: SyncData) {
|
||||||
override suspend fun put(key: String, data: SyncData) {
|
|
||||||
// Create directory
|
// Create directory
|
||||||
val dataPath = key.toDataPath()
|
val dataPath = key.toDataPath()
|
||||||
val dataDir = dataPath.toFile()
|
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 dataPath = key.toDataPath()
|
||||||
val contentFile = dataPath.resolve("content").toFile()
|
val contentFile = dataPath.resolve("content").toFile()
|
||||||
val versionFile = dataPath.resolve("version").toFile()
|
val versionFile = dataPath.resolve("version").toFile()
|
||||||
@@ -61,7 +61,7 @@ class FileRepository(
|
|||||||
return SyncData(version, contentFile.readText())
|
return SyncData(version, contentFile.readText())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun contains(key: String): Boolean {
|
fun contains(key: String): Boolean {
|
||||||
val dataPath = key.toDataPath()
|
val dataPath = key.toDataPath()
|
||||||
val versionFile = dataPath.resolve("version").toFile()
|
val versionFile = dataPath.resolve("version").toFile()
|
||||||
return versionFile.exists()
|
return versionFile.exists()
|
||||||
@@ -70,4 +70,4 @@ class FileRepository(
|
|||||||
private fun String.toDataPath(): Path {
|
private fun String.toDataPath(): Path {
|
||||||
return basepath.resolve("${this[0]}/${this[1]}/${this[2]}/${this[3]}/$this")
|
return basepath.resolve("${this[0]}/${this[1]}/${this[2]}/${this[3]}/$this")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,20 +17,19 @@
|
|||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* 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 io.prometheus.client.Counter
|
||||||
import org.isoron.uhabits.sync.*
|
import org.isoron.uhabits.core.sync.AbstractSyncServer
|
||||||
import org.isoron.uhabits.sync.links.*
|
import org.isoron.uhabits.core.sync.EditConflictException
|
||||||
import org.isoron.uhabits.sync.repository.*
|
import org.isoron.uhabits.core.sync.KeyNotFoundException
|
||||||
import org.isoron.uhabits.sync.utils.*
|
import org.isoron.uhabits.core.sync.SyncData
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An AbstractSyncServer that stores all data in a [Repository].
|
* An AbstractSyncServer that stores all data in a [Repository].
|
||||||
*/
|
*/
|
||||||
class RepositorySyncServer(
|
class RepositorySyncServer(
|
||||||
private val repo: Repository,
|
private val repo: Repository,
|
||||||
private val linkManager: LinkManager = LinkManager(),
|
|
||||||
) : AbstractSyncServer {
|
) : AbstractSyncServer {
|
||||||
|
|
||||||
private val requestsCounter: Counter = Counter.build()
|
private val requestsCounter: Counter = Counter.build()
|
||||||
@@ -39,21 +38,13 @@ class RepositorySyncServer(
|
|||||||
.labelNames("method")
|
.labelNames("method")
|
||||||
.register()
|
.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) {
|
override suspend fun put(key: String, newData: SyncData) {
|
||||||
requestsCounter.labels("put").inc()
|
requestsCounter.labels("put").inc()
|
||||||
if (!repo.contains(key)) {
|
if (repo.contains(key)) {
|
||||||
throw KeyNotFoundException()
|
val prevData = repo.get(key)
|
||||||
}
|
if (newData.version != prevData.version + 1) {
|
||||||
val prevData = repo.get(key)
|
throw EditConflictException()
|
||||||
if (newData.version != prevData.version + 1) {
|
}
|
||||||
throw EditConflictException()
|
|
||||||
}
|
}
|
||||||
repo.put(key, newData)
|
repo.put(key, newData)
|
||||||
}
|
}
|
||||||
@@ -73,22 +64,4 @@ class RepositorySyncServer(
|
|||||||
}
|
}
|
||||||
return repo.get(key).version
|
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,15 +17,12 @@
|
|||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* 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 com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import kotlin.streams.*
|
import org.isoron.uhabits.core.sync.GetDataVersionResponse
|
||||||
|
import org.isoron.uhabits.core.sync.SyncData
|
||||||
|
|
||||||
fun randomString(length: Long): String {
|
val defaultMapper = ObjectMapper()
|
||||||
val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
fun SyncData.toJson(): String = defaultMapper.writeValueAsString(this)
|
||||||
return Random().ints(length, 0, chars.length)
|
fun GetDataVersionResponse.toJson(): String = defaultMapper.writeValueAsString(this)
|
||||||
.asSequence()
|
|
||||||
.map(chars::get)
|
|
||||||
.joinToString("")
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -4,6 +4,6 @@ ktor {
|
|||||||
port = ${?PORT}
|
port = ${?PORT}
|
||||||
}
|
}
|
||||||
application {
|
application {
|
||||||
modules = [ org.isoron.uhabits.sync.app.SyncApplicationKt.main ]
|
modules = [ org.isoron.uhabits.server.app.SyncApplicationKt.main ]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* 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 com.nhaarman.mockitokotlin2.mock
|
||||||
import io.ktor.application.*
|
import io.ktor.application.Application
|
||||||
import org.isoron.uhabits.sync.server.*
|
import org.isoron.uhabits.core.sync.AbstractSyncServer
|
||||||
|
|
||||||
open class BaseApplicationTest {
|
open class BaseApplicationTest {
|
||||||
|
|
||||||
@@ -17,16 +17,27 @@
|
|||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* 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.verify
|
||||||
import com.nhaarman.mockitokotlin2.whenever
|
import com.nhaarman.mockitokotlin2.whenever
|
||||||
import io.ktor.http.*
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.server.testing.*
|
import io.ktor.http.HttpHeaders
|
||||||
import kotlinx.coroutines.*
|
import io.ktor.http.HttpMethod
|
||||||
import org.isoron.uhabits.sync.*
|
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 org.junit.Test
|
||||||
import kotlin.test.*
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
class StorageModuleTest : BaseApplicationTest() {
|
class StorageModuleTest : BaseApplicationTest() {
|
||||||
private val data1 = SyncData(1, "Hello world")
|
private val data1 = SyncData(1, "Hello world")
|
||||||
@@ -64,7 +75,6 @@ class StorageModuleTest : BaseApplicationTest() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when put succeeds should return OK`(): Unit = runBlocking {
|
fun `when put succeeds should return OK`(): Unit = runBlocking {
|
||||||
withTestApplication(app()) {
|
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
|
@Test
|
||||||
fun `when put with invalid version should return 409 and current data`(): Unit = runBlocking {
|
fun `when put with invalid version should return 409 and current data`(): Unit = runBlocking {
|
||||||
whenever(server.put("k1", data1)).thenThrow(EditConflictException())
|
whenever(server.put("k1", data1)).thenThrow(EditConflictException())
|
||||||
@@ -17,26 +17,25 @@
|
|||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* 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 kotlinx.coroutines.runBlocking
|
||||||
import org.isoron.uhabits.sync.*
|
import org.isoron.uhabits.core.sync.EditConflictException
|
||||||
import org.isoron.uhabits.sync.repository.*
|
import org.isoron.uhabits.core.sync.KeyNotFoundException
|
||||||
|
import org.isoron.uhabits.core.sync.SyncData
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.nio.file.*
|
import java.nio.file.Files
|
||||||
import kotlin.test.*
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
|
||||||
class RepositorySyncServerTest {
|
class RepositorySyncServerTest {
|
||||||
|
|
||||||
private val tempdir = Files.createTempDirectory("db")
|
private val tempdir = Files.createTempDirectory("db")
|
||||||
private val server = RepositorySyncServer(FileRepository(tempdir))
|
private val server = RepositorySyncServer(Repository(tempdir))
|
||||||
private val key = runBlocking { server.register() }
|
private val key = "abcdefgh"
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testUsage(): Unit = runBlocking {
|
fun testUsage(): Unit = runBlocking {
|
||||||
val data0 = SyncData(0, "")
|
|
||||||
assertEquals(server.getData(key), data0)
|
|
||||||
|
|
||||||
val data1 = SyncData(1, "Hello world")
|
val data1 = SyncData(1, "Hello world")
|
||||||
server.put(key, data1)
|
server.put(key, data1)
|
||||||
assertEquals(server.getData(key), data1)
|
assertEquals(server.getData(key), data1)
|
||||||
@@ -52,9 +51,5 @@ class RepositorySyncServerTest {
|
|||||||
assertFailsWith<KeyNotFoundException> {
|
assertFailsWith<KeyNotFoundException> {
|
||||||
server.getData("INVALID")
|
server.getData("INVALID")
|
||||||
}
|
}
|
||||||
|
|
||||||
assertFailsWith<KeyNotFoundException> {
|
|
||||||
server.put("INVALID", data0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,24 +17,25 @@
|
|||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
@file:Suppress("BlockingMethodInNonBlockingContext")
|
@file:Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
|
||||||
package org.isoron.uhabits.sync.repository
|
package org.isoron.uhabits.server.sync
|
||||||
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.hamcrest.CoreMatchers.*
|
import org.hamcrest.CoreMatchers.equalTo
|
||||||
import org.isoron.uhabits.sync.*
|
import org.isoron.uhabits.core.sync.SyncData
|
||||||
import org.junit.*
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.assertThat
|
||||||
import java.nio.file.*
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.nio.file.Files
|
||||||
|
|
||||||
class FileRepositoryTest {
|
class RepositoryTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testUsage() = runBlocking {
|
fun testUsage() = runBlocking {
|
||||||
val tempdir = Files.createTempDirectory("db")!!
|
val tempdir = Files.createTempDirectory("db")!!
|
||||||
val repo = FileRepository(tempdir)
|
val repo = Repository(tempdir)
|
||||||
|
|
||||||
val original = SyncData(10, "Hello world")
|
val original = SyncData(10, "Hello world")
|
||||||
repo.put("abcdefg", original)
|
repo.put("abcdefg", original)
|
||||||
@@ -50,4 +51,4 @@ class FileRepositoryTest {
|
|||||||
val retrieved = repo.get("abcdefg")
|
val retrieved = repo.get("abcdefg")
|
||||||
assertThat(retrieved, equalTo(original))
|
assertThat(retrieved, equalTo(original))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user