Make registration functional

pull/699/head
Alinson S. Xavier 5 years ago
parent 5376f4bff8
commit a2400172e2

@ -10,6 +10,7 @@ KOTLIN_VERSION = 1.3.61
SUPPORT_LIBRARY_VERSION = 28.0.0
AUTO_FACTORY_VERSION = 1.0-beta6
BUILD_TOOLS_VERSION = 4.0.0
KTOR_VERSION=1.4.2
org.gradle.parallel=false
org.gradle.daemon=true

@ -94,6 +94,10 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$KOTLIN_VERSION"
implementation "androidx.constraintlayout:constraintlayout:2.0.0-beta4"
implementation 'com.google.zxing:core:3.4.1'
implementation "io.ktor:ktor-client-core:$KTOR_VERSION"
implementation "io.ktor:ktor-client-android:$KTOR_VERSION"
implementation "io.ktor:ktor-client-json:$KTOR_VERSION"
implementation "io.ktor:ktor-client-jackson:$KTOR_VERSION"
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
compileOnly "javax.annotation:jsr250-api:1.0"
@ -113,6 +117,8 @@ dependencies {
androidTestImplementation 'androidx.test:rules:1.1.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation "com.google.guava:guava:24.1-android"
androidTestImplementation "io.ktor:ktor-client-mock:$KTOR_VERSION"
androidTestImplementation "io.ktor:ktor-jackson:$KTOR_VERSION"
androidTestImplementation project(":uhabits-core")
kaptAndroidTest "com.google.dagger:dagger-compiler:$DAGGER_VERSION"

@ -0,0 +1,133 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
import com.fasterxml.jackson.databind.*
import io.ktor.client.*
import io.ktor.client.engine.mock.*
import io.ktor.client.features.json.*
import io.ktor.client.request.*
import io.ktor.http.*
import junit.framework.Assert.*
import kotlinx.coroutines.*
import org.junit.*
class RemoteSyncServerTest {
private val mapper = ObjectMapper()
val data = SyncData(1, "Hello world")
@Test
fun when_register_succeeds_should_return_key() = runBlocking {
val server = server("/register") {
respondWithJson(RegisterReponse("ABCDEF"))
}
assertEquals("ABCDEF", server.register())
}
@Test(expected = ServiceUnavailable::class)
fun when_register_fails_should_raise_correct_exception() = runBlocking {
val server = server("/register") {
respondError(HttpStatusCode.ServiceUnavailable)
}
server.register()
return@runBlocking
}
@Test
fun when_get_data_version_succeeds_should_return_version() = runBlocking {
server("/ABC/version") {
respondWithJson(GetDataVersionResponse(5))
}.apply {
assertEquals(5, getDataVersion("ABC"))
}
return@runBlocking
}
@Test(expected = ServiceUnavailable::class)
fun when_get_data_version_with_server_error_should_raise_exception() = runBlocking {
server("/ABC/version") {
respondError(HttpStatusCode.InternalServerError)
}.apply {
getDataVersion("ABC")
}
return@runBlocking
}
@Test(expected = KeyNotFoundException::class)
fun when_get_data_version_with_invalid_key_should_raise_exception() = runBlocking {
server("/ABC/version") {
respondError(HttpStatusCode.NotFound)
}.apply {
getDataVersion("ABC")
}
return@runBlocking
}
@Test
fun when_get_data_succeeds_should_return_data() = runBlocking {
server("/ABC") {
respondWithJson(data)
}.apply {
assertEquals(data, getData("ABC"))
}
return@runBlocking
}
@Test(expected = KeyNotFoundException::class)
fun when_get_data_with_invalid_key_should_raise_exception() = runBlocking {
server("/ABC") {
respondError(HttpStatusCode.NotFound)
}.apply {
getData("ABC")
}
return@runBlocking
}
@Test
fun when_put_succeeds_should_not_raise_exceptions() = runBlocking {
server("/ABC") {
respondOk()
}.apply {
put("ABC", data)
}
return@runBlocking
}
private fun server(expectedPath: String,
action: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData
): AbstractSyncServer {
return RemoteSyncServer(httpClient = HttpClient(MockEngine) {
install(JsonFeature)
engine {
addHandler { request ->
when (request.url.fullPath) {
expectedPath -> action(request)
else -> error("unexpected call: ${request.url.fullPath}")
}
}
}
})
}
private fun MockRequestHandleScope.respondWithJson(content: Any) =
respond(mapper.writeValueAsBytes(content),
headers = headersOf("Content-Type" to listOf("application/json")))
}

@ -23,6 +23,8 @@
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".HabitsApplication"

@ -24,17 +24,23 @@ import android.content.ClipboardManager
import android.graphics.*
import android.os.*
import android.text.*
import android.view.*
import com.google.zxing.*
import com.google.zxing.qrcode.*
import kotlinx.coroutines.*
import org.isoron.androidbase.activities.*
import org.isoron.androidbase.utils.*
import org.isoron.androidbase.utils.InterfaceUtils.getFontAwesome
import org.isoron.uhabits.*
import org.isoron.uhabits.activities.*
import org.isoron.uhabits.core.tasks.*
import org.isoron.uhabits.databinding.*
import org.isoron.uhabits.sync.*
class SyncActivity : BaseActivity() {
private lateinit var taskRunner: TaskRunner
private lateinit var baseScreen: BaseScreen
private lateinit var themeSwitcher: AndroidThemeSwitcher
private lateinit var binding: ActivitySyncBinding
@ -47,10 +53,13 @@ class SyncActivity : BaseActivity() {
val component = (application as HabitsApplication).component
themeSwitcher = AndroidThemeSwitcher(this, component.preferences)
themeSwitcher.apply()
taskRunner = component.taskRunner
binding = ActivitySyncBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.errorIcon.typeface = getFontAwesome(this)
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowHomeEnabled(true)
@ -58,14 +67,50 @@ class SyncActivity : BaseActivity() {
binding.instructions.setText(Html.fromHtml(resources.getString(R.string.sync_instructions)))
displayLink("https://loophabits.org/sync/KA9GvblSWrcLk9iwJrplHvWiWdE6opAokdf2qqRl6n6ECX8IUhvcksqlfkQACoMM")
displayPassword("6B2W9F5X")
binding.syncLink.setOnClickListener {
copyToClipboard()
}
}
override fun onResume() {
super.onResume()
displayLoading()
taskRunner.execute(object : Task {
private var key = ""
private var error = false
override fun doInBackground() {
runBlocking {
val server = RemoteSyncServer()
try {
key = server.register()
} catch(e: ServiceUnavailable) {
error = true
}
}
}
override fun onPostExecute() {
if(error) {
displayError()
} else {
displayLink("https://loophabits.org/sync/$key")
displayPassword("6B2W9F5X")
}
}
})
}
private fun displayLoading() {
binding.qrCode.visibility = View.GONE
binding.progress.visibility = View.VISIBLE
binding.errorPanel.visibility = View.GONE
}
private fun displayError() {
binding.qrCode.visibility = View.GONE
binding.progress.visibility = View.GONE
binding.errorPanel.visibility = View.VISIBLE
}
private fun copyToClipboard() {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Loop Sync Link", binding.syncLink.text))
@ -77,6 +122,9 @@ class SyncActivity : BaseActivity() {
}
private fun displayLink(link: String) {
binding.qrCode.visibility = View.VISIBLE
binding.progress.visibility = View.GONE
binding.errorPanel.visibility = View.GONE
binding.syncLink.text = link
displayQR(link)
}

@ -0,0 +1,60 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
interface AbstractSyncServer {
/**
* Generates and returns a new sync key, which can be used to store and retrive
* data.
*
* @throws ServiceUnavailable If key cannot be generated at this time, for example,
* due to insufficient server resources, temporary server maintenance or network problems.
*/
suspend fun register(): String
/**
* Replaces data for a given sync key.
*
* @throws KeyNotFoundException If key is not found
* @throws EditConflictException If the version of the data provided is not
* exactly the current data version plus one.
* @throws ServiceUnavailable If data cannot be put at this time, for example, due
* to insufficient server resources or network problems.
*/
suspend fun put(key: String, newData: SyncData)
/**
* Returns data for a given sync key.
*
* @throws KeyNotFoundException If key is not found
* @throws ServiceUnavailable If data cannot be retrieved at this time, for example, due
* to insufficient server resources or network problems.
*/
suspend fun getData(key: String): SyncData
/**
* Returns the current data version for the given key
*
* @throws KeyNotFoundException If key is not found
* @throws ServiceUnavailable If data cannot be retrieved at this time, for example, due
* to insufficient server resources or network problems.
*/
suspend fun getDataVersion(key: String): Long
}

@ -0,0 +1,80 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync
import io.ktor.client.*
import io.ktor.client.engine.android.*
import io.ktor.client.features.*
import io.ktor.client.features.json.*
import io.ktor.client.request.*
data class RegisterReponse(val key: String)
data class GetDataVersionResponse(val version: Long)
class RemoteSyncServer(
private val baseURL: String = "https://sync.loophabits.org",
private val httpClient: HttpClient = HttpClient(Android) {
install(JsonFeature)
}
) : AbstractSyncServer {
override suspend fun register(): String {
try {
val response: RegisterReponse = httpClient.post("$baseURL/register")
return response.key
} catch(e: ServerResponseException) {
throw ServiceUnavailable()
}
}
override suspend fun put(key: String, newData: SyncData) {
try {
val response: String = httpClient.put("$baseURL/$key") {
header("Content-Type", "application/json")
body = newData
}
} catch (e: ServerResponseException) {
throw ServiceUnavailable()
} catch (e: ClientRequestException) {
throw KeyNotFoundException()
}
}
override suspend fun getData(key: String): SyncData {
try {
return httpClient.get("$baseURL/$key")
} catch (e: ServerResponseException) {
throw ServiceUnavailable()
} catch (e: ClientRequestException) {
throw KeyNotFoundException()
}
}
override suspend fun getDataVersion(key: String): Long {
try {
val response: GetDataVersionResponse = httpClient.get("$baseURL/$key/version")
return response.version
} catch(e: ServerResponseException) {
throw ServiceUnavailable()
} catch (e: ClientRequestException) {
throw KeyNotFoundException()
}
}
}

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

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

@ -52,6 +52,55 @@
android:id="@+id/instructions"
/>
<!-- Sync Link (QR) -->
<FrameLayout style="@style/FormOuterBox">
<LinearLayout
style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:translationZ="0.01dp"
android:text="@string/sync_link_qr" />
<LinearLayout
android:id="@+id/errorPanel"
android:layout_width="match_parent"
android:layout_height="200dp"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/errorIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="40dp"
android:layout_margin="40dp"
android:text="@string/fa_exclamation_circle" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Error generating code. Please try again later."
/>
</LinearLayout>
<ProgressBar
android:id="@+id/progress"
android:layout_width="50dp"
android:layout_height="200dp"
android:layout_gravity="center"
android:indeterminate="true"
/>
<ImageView
android:id="@+id/qrCode"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_gravity="center"
/>
</LinearLayout>
</FrameLayout>
<!-- Sync Link -->
<FrameLayout style="@style/FormOuterBox">
@ -73,22 +122,7 @@
</LinearLayout>
</FrameLayout>
<!-- Sync Link -->
<FrameLayout style="@style/FormOuterBox">
<LinearLayout style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:text="@string/sync_link_qr" />
<ImageView
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_marginTop="8dp"
android:layout_gravity="center"
android:id="@+id/qrCode" />
</LinearLayout>
</FrameLayout>
<!-- Password -->
<FrameLayout style="@style/FormOuterBox">
<LinearLayout style="@style/FormInnerBox">

@ -26,6 +26,7 @@
<string translatable="false" name="fa_skipped">&#xf068;</string>
<string translatable="false" name="fa_bell_o">&#xf0a2;</string>
<string translatable="false" name="fa_calendar">&#xf073;</string>
<string translatable="false" name="fa_exclamation_circle">&#xf06a;</string>
<!--<string translatable="false" name="fa_glass">&#xf000;</string>-->
<!--<string translatable="false" name="fa_music">&#xf001;</string>-->
@ -123,7 +124,6 @@
<!--<string translatable="false" name="fa_plus">&#xf067;</string>-->
<!--<string translatable="false" name="fa_minus">&#xf068;</string>-->
<!--<string translatable="false" name="fa_asterisk">&#xf069;</string>-->
<!--<string translatable="false" name="fa_exclamation_circle">&#xf06a;</string>-->
<!--<string translatable="false" name="fa_gift">&#xf06b;</string>-->
<!--<string translatable="false" name="fa_leaf">&#xf06c;</string>-->
<!--<string translatable="false" name="fa_fire">&#xf06d;</string>-->

Loading…
Cancel
Save