diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 43602fdf..144d718b 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -152,7 +152,6 @@
-
@@ -161,7 +160,6 @@
-
diff --git a/build.gradle b/build.gradle
index 78dd4fe4..23fd1de1 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
- ext.kotlin_version = '1.5.10'
+ ext.kotlin_version = '1.5.21'
repositories {
google()
@@ -12,7 +12,7 @@ buildscript {
}
dependencies {
- classpath 'com.android.tools.build:gradle:4.2.1'
+ classpath 'com.android.tools.build:gradle:4.2.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jlleitschuh.gradle:ktlint-gradle:9.4.1"
// NOTE: Do not place any application dependencies here; they belong
diff --git a/commons/build.gradle b/commons/build.gradle
index b7a31fe3..33300636 100644
--- a/commons/build.gradle
+++ b/commons/build.gradle
@@ -1,7 +1,7 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
-version = "0.8.4"
+version = "0.8.8"
android {
compileSdkVersion 29
@@ -54,7 +54,7 @@ dependencies {
api 'androidx.room:room-runtime:2.3.0'
testImplementation 'androidx.arch.core:core-testing:2.1.0'
- testImplementation 'androidx.test:core:1.3.0'
+ testImplementation 'androidx.test:core:1.4.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:3.0.0'
testImplementation 'org.robolectric:robolectric:4.5.1'
diff --git a/commons/src/main/AndroidManifest.xml b/commons/src/main/AndroidManifest.xml
index d5caad8e..9d34477d 100644
--- a/commons/src/main/AndroidManifest.xml
+++ b/commons/src/main/AndroidManifest.xml
@@ -1,8 +1,17 @@
-
+
-
-
+
+
+
+
+
+
diff --git a/commons/src/main/java/fr/geonature/commons/data/helper/Provider.kt b/commons/src/main/java/fr/geonature/commons/data/helper/Provider.kt
index b35f1cd8..5bb8a446 100644
--- a/commons/src/main/java/fr/geonature/commons/data/helper/Provider.kt
+++ b/commons/src/main/java/fr/geonature/commons/data/helper/Provider.kt
@@ -1,9 +1,7 @@
package fr.geonature.commons.data.helper
-import android.content.Context
import android.net.Uri
import android.net.Uri.withAppendedPath
-import fr.geonature.commons.R
/**
* Base content provider.
@@ -17,12 +15,6 @@ object Provider {
*/
const val AUTHORITY = "fr.geonature.sync.provider"
- /**
- * Check if 'READ' permission is granted for content provider.
- */
- val checkReadPermission: (Context, String?) -> Boolean =
- { context, permission -> context.getString(R.string.permission_read) == permission }
-
/**
* Build resource [Uri].
*/
@@ -34,9 +26,10 @@ object Provider {
val baseUri = Uri.parse("content://$AUTHORITY/$resource")
return if (path.isEmpty()) baseUri
- else withAppendedPath(
- baseUri,
- path.asSequence().filter { it.isNotBlank() }.joinToString("/")
- )
+ else withAppendedPath(baseUri,
+ path
+ .asSequence()
+ .filter { it.isNotBlank() }
+ .joinToString("/"))
}
}
diff --git a/commons/src/main/java/fr/geonature/commons/fp/Either.kt b/commons/src/main/java/fr/geonature/commons/fp/Either.kt
new file mode 100644
index 00000000..0198a544
--- /dev/null
+++ b/commons/src/main/java/fr/geonature/commons/fp/Either.kt
@@ -0,0 +1,120 @@
+package fr.geonature.commons.fp
+
+/**
+ * Represents a value of one of two possible types (a disjoint union).
+ * Instances of [Either] are either an instance of [Left] or [Right].
+ * FP Convention dictates that [Left] is used for "failure"
+ * and [Right] is used for "success".
+ *
+ * @see Left
+ * @see Right
+ */
+sealed class Either {
+
+ /**
+ * Represents the left side of [Either] class which by convention is a "Failure".
+ */
+ data class Left(val a: L) : Either()
+
+ /**
+ * Represents the right side of [Either] class which by convention is a "Success".
+ */
+ data class Right(val b: R) : Either()
+
+ /**
+ * Returns true if this is a [Right], false otherwise.
+ * @see Right
+ */
+ val isRight get() = this is Right
+
+ /**
+ * Returns true if this is a [Left], false otherwise.
+ * @see Left
+ */
+ val isLeft get() = this is Left
+
+ /**
+ * Creates a [Left] type.
+ * @see Left
+ */
+ fun left(a: L) =
+ Left(a)
+
+ /**
+ * Creates a [Right] type.
+ * @see Right
+ */
+ fun right(b: R) =
+ Right(b)
+
+ /**
+ * Applies fnL if this is a [Left] or fnR if this is a [Right].
+ * @see Left
+ * @see Right
+ */
+ fun fold(
+ fnL: (L) -> Any,
+ fnR: (R) -> Any
+ ): Any =
+ when (this) {
+ is Left -> fnL(a)
+ is Right -> fnR(b)
+ }
+}
+
+/**
+ * Composes 2 functions.
+ *
+ * See [credits to Alex Hart.](https://proandroiddev.com/kotlins-nothing-type-946de7d464fb)
+ */
+fun ((A) -> B).c(f: (B) -> C): (A) -> C =
+ {
+ f(this(it))
+ }
+
+/**
+ * Right-biased flatMap() FP convention which means that [Either.Right] is assumed to be the default
+ * case to operate on.
+ * If it is [Either.Left], operations like map, flatMap, ... return the [Either.Left] value unchanged.
+ */
+fun Either.flatMap(fn: (R) -> Either): Either =
+ when (this) {
+ is Either.Left -> Either.Left(a)
+ is Either.Right -> fn(b)
+ }
+
+/**
+ * Right-biased map() FP convention which means that [Either.Right] is assumed to be the default case
+ * to operate on.
+ * If it is [Either.Left], operations like map, flatMap, ... return the [Either.Left] value unchanged.
+ */
+fun Either.map(fn: (R) -> (T)): Either =
+ this.flatMap(fn.c(::right))
+
+/**
+ * Returns the value from this [Either.Right] or the given argument if this is a [Either.Left].
+ * Examples:
+ * * `Right(12).getOrElse(17)` returns 12
+ * * `Left(12).getOrElse(17)` returns 17
+ */
+fun Either.getOrElse(value: R): R =
+ when (this) {
+ is Either.Left -> value
+ is Either.Right -> b
+ }
+
+/**
+ * Left-biased onFailure() FP convention dictates that when this class is [Either.Left], it'll perform
+ * the onFailure functionality passed as a parameter, but, overall will still return an either
+ * object so you chain calls.
+ */
+fun Either.onFailure(fn: (failure: L) -> Unit): Either =
+ this.apply { if (this is Either.Left) fn(a) }
+
+/**
+ * Right-biased onSuccess() FP convention dictates that when this class is [Either.Right], it'll perform
+ * the onSuccess functionality passed as a parameter, but, overall will still return an either
+ * object so you chain calls.
+ */
+fun Either.onSuccess(fn: (success: R) -> Unit): Either =
+ this.apply { if (this is Either.Right) fn(b) }
\ No newline at end of file
diff --git a/commons/src/main/java/fr/geonature/commons/fp/Failure.kt b/commons/src/main/java/fr/geonature/commons/fp/Failure.kt
new file mode 100644
index 00000000..0512d641
--- /dev/null
+++ b/commons/src/main/java/fr/geonature/commons/fp/Failure.kt
@@ -0,0 +1,12 @@
+package fr.geonature.commons.fp
+
+/**
+ * Base class for handling errors/failures/exceptions.
+ * Every feature specific failure should extend [FeatureFailure] class.
+ */
+sealed class Failure {
+ object NetworkFailure : Failure()
+ object ServerFailure : Failure()
+
+ abstract class FeatureFailure : Failure()
+}
\ No newline at end of file
diff --git a/commons/src/main/java/fr/geonature/commons/input/AbstractInput.kt b/commons/src/main/java/fr/geonature/commons/input/AbstractInput.kt
index 04ac6b8d..69b40441 100644
--- a/commons/src/main/java/fr/geonature/commons/input/AbstractInput.kt
+++ b/commons/src/main/java/fr/geonature/commons/input/AbstractInput.kt
@@ -23,6 +23,7 @@ abstract class AbstractInput(
var id: Long = generateId()
var date: Date = Date()
+ var status: Status = Status.DRAFT
var datasetId: Long? = null
private val inputObserverIds: MutableSet = mutableSetOf()
private val inputTaxa: MutableMap = LinkedHashMap()
@@ -31,7 +32,16 @@ abstract class AbstractInput(
constructor(source: Parcel) : this(source.readString()!!) {
this.id = source.readLong()
this.date = source.readSerializable() as Date
- this.datasetId = source.readLong()
+ this.status = source
+ .readString()
+ .let { statusAsString ->
+ Status
+ .values()
+ .firstOrNull { it.name == statusAsString }
+ ?: Status.DRAFT
+ }
+ this.datasetId = source
+ .readLong()
.takeIf { it != -1L }
val inputObserverId = source.readLong()
@@ -61,9 +71,17 @@ abstract class AbstractInput(
it.writeString(module)
it.writeLong(this.id)
it.writeSerializable(this.date)
- it.writeLong(this.datasetId ?: -1L)
+ it.writeString(this.status.name)
+ it.writeLong(
+ this.datasetId
+ ?: -1L
+ )
it.writeLong(if (inputObserverIds.isEmpty()) -1 else inputObserverIds.first())
- it.writeLongArray(inputObserverIds.drop(1).toLongArray())
+ it.writeLongArray(
+ inputObserverIds
+ .drop(1)
+ .toLongArray()
+ )
it.writeTypedList(getInputTaxa())
}
}
@@ -77,6 +95,7 @@ abstract class AbstractInput(
if (module != other.module) return false
if (id != other.id) return false
if (date != other.date) return false
+ if (status != other.status) return false
if (datasetId != other.datasetId) return false
if (inputObserverIds != other.inputObserverIds) return false
if (inputTaxa != other.inputTaxa) return false
@@ -88,6 +107,7 @@ abstract class AbstractInput(
var result = module.hashCode()
result = 31 * result + id.hashCode()
result = 31 * result + date.hashCode()
+ result = 31 * result + status.hashCode()
result = 31 * result + datasetId.hashCode()
result = 31 * result + inputObserverIds.hashCode()
result = 31 * result + inputTaxa.hashCode()
@@ -96,7 +116,8 @@ abstract class AbstractInput(
}
fun setDate(isoDate: String?) {
- this.date = toDate(isoDate) ?: Date()
+ this.date = toDate(isoDate)
+ ?: Date()
}
/**
@@ -117,7 +138,8 @@ abstract class AbstractInput(
* Gets only selected input observers without the primary input observer.
*/
fun getInputObserverIds(): Set {
- return this.inputObserverIds.drop(1)
+ return this.inputObserverIds
+ .drop(1)
.toSet()
}
@@ -126,7 +148,8 @@ abstract class AbstractInput(
}
fun setPrimaryInputObserverId(id: Long) {
- val inputObservers = this.inputObserverIds.toMutableList()
+ val inputObservers = this.inputObserverIds
+ .toMutableList()
.apply {
add(
0,
@@ -204,6 +227,11 @@ abstract class AbstractInput(
abstract fun getTaxaFromParcel(source: Parcel): List
+ enum class Status {
+ DRAFT,
+ TO_SYNC
+ }
+
/**
* Generates a pseudo unique ID. The value is the number of seconds since Jan. 1, 2016, midnight.
*
diff --git a/commons/src/main/java/fr/geonature/commons/input/IInputManager.kt b/commons/src/main/java/fr/geonature/commons/input/IInputManager.kt
new file mode 100644
index 00000000..7287ab6a
--- /dev/null
+++ b/commons/src/main/java/fr/geonature/commons/input/IInputManager.kt
@@ -0,0 +1,84 @@
+package fr.geonature.commons.input
+
+import androidx.lifecycle.LiveData
+
+/**
+ * Manage [AbstractInput]:
+ * - Create a new [AbstractInput]
+ * - Read the current [AbstractInput]
+ * - Save the current [AbstractInput]
+ * - Export the current [AbstractInput] as `JSON` file
+ *
+ * @author S. Grimault
+ */
+interface IInputManager {
+
+ /***
+ * All loaded [AbstractInput]s as `List`.
+ */
+ val inputs: LiveData>
+
+ /**
+ * Current loaded [AbstractInput] to edit.
+ */
+ val input: LiveData
+
+ /**
+ * Reads all [AbstractInput]s.
+ *
+ * @return A list of [AbstractInput]s
+ */
+ suspend fun readInputs(): List
+
+ /**
+ * Reads [AbstractInput] from given ID.
+ *
+ * @param id The [AbstractInput] ID to read. If omitted, read the current saved [AbstractInput].
+ *
+ * @return [AbstractInput] or `null` if not found
+ */
+ suspend fun readInput(id: Long? = null): I?
+
+ /**
+ * Reads the current [AbstractInput].
+ *
+ * @return [AbstractInput] or `null` if not found
+ */
+ suspend fun readCurrentInput(): I?
+
+ /**
+ * Saves the given [AbstractInput] and sets it as default current [AbstractInput].
+ *
+ * @param input the [AbstractInput] to save
+ *
+ * @return `true` if the given [AbstractInput] has been successfully saved, `false` otherwise
+ */
+ suspend fun saveInput(input: I): Boolean
+
+ /**
+ * Deletes [AbstractInput] from given ID.
+ *
+ * @param id the [AbstractInput] ID to delete
+ *
+ * @return `true` if the given [AbstractInput] has been successfully deleted, `false` otherwise
+ */
+ suspend fun deleteInput(id: Long): Boolean
+
+ /**
+ * Exports [AbstractInput] from given ID as `JSON` file.
+ *
+ * @param id the [AbstractInput] ID to export
+ *
+ * @return `true` if the given [AbstractInput] has been successfully exported, `false` otherwise
+ */
+ suspend fun exportInput(id: Long): Boolean
+
+ /**
+ * Exports [AbstractInput] as `JSON` file.
+ *
+ * @param input the [AbstractInput] to save
+ *
+ * @return `true` if the given [AbstractInput] has been successfully exported, `false` otherwise
+ */
+ suspend fun exportInput(input: I): Boolean
+}
\ No newline at end of file
diff --git a/commons/src/main/java/fr/geonature/commons/input/InputManager.kt b/commons/src/main/java/fr/geonature/commons/input/InputManager.kt
deleted file mode 100644
index f410fc9a..00000000
--- a/commons/src/main/java/fr/geonature/commons/input/InputManager.kt
+++ /dev/null
@@ -1,246 +0,0 @@
-package fr.geonature.commons.input
-
-import android.annotation.SuppressLint
-import android.app.Application
-import android.content.SharedPreferences
-import android.util.Log
-import androidx.lifecycle.MutableLiveData
-import androidx.preference.PreferenceManager
-import fr.geonature.commons.input.io.InputJsonReader
-import fr.geonature.commons.input.io.InputJsonWriter
-import fr.geonature.commons.util.getInputsFolder
-import fr.geonature.mountpoint.util.FileUtils
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.withContext
-import java.io.File
-import java.io.FileWriter
-
-/**
- * Manage [AbstractInput]:
- * - Create a new [AbstractInput]
- * - Read the current [AbstractInput]
- * - Save the current [AbstractInput]
- * - Export the current [AbstractInput] as `JSON` file
- *
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
- */
-class InputManager private constructor(
- internal val application: Application,
- inputJsonReaderListener: InputJsonReader.OnInputJsonReaderListener,
- inputJsonWriterListener: InputJsonWriter.OnInputJsonWriterListener
-) {
-
- internal val preferenceManager: SharedPreferences =
- PreferenceManager.getDefaultSharedPreferences(application)
- private val inputJsonReader: InputJsonReader = InputJsonReader(inputJsonReaderListener)
- private val inputJsonWriter: InputJsonWriter = InputJsonWriter(inputJsonWriterListener)
-
- val inputs: MutableLiveData> = MutableLiveData()
- val input: MutableLiveData = MutableLiveData()
-
- /**
- * Reads all [AbstractInput]s.
- *
- * @return A list of [AbstractInput]s
- */
- suspend fun readInputs(): List = withContext(IO) {
- preferenceManager.all.filterKeys { it.startsWith("${KEY_PREFERENCE_INPUT}_") }
- .values.mapNotNull { if (it is String && it.isNotBlank()) inputJsonReader.read(it) else null }
- .sortedBy { it.id }
- .also { inputs.postValue(it) }
- }
-
- /**
- * Reads [AbstractInput] from given ID.
- *
- * @param id The [AbstractInput] ID to read. If omitted, read the current saved [AbstractInput].
- *
- * @return [AbstractInput] or `null` if not found
- */
- suspend fun readInput(id: Long? = null): I? = withContext(IO) {
- val inputPreferenceKey = buildInputPreferenceKey(
- id
- ?: preferenceManager.getLong(
- KEY_PREFERENCE_CURRENT_INPUT,
- 0
- )
- )
- val inputAsJson = preferenceManager.getString(
- inputPreferenceKey,
- null
- )
-
- if (inputAsJson.isNullOrBlank()) {
- return@withContext null
- }
-
- inputJsonReader.read(inputAsJson)
- .also { input.postValue(it) }
- }
-
- /**
- * Reads the current [AbstractInput].
- *
- * @return [AbstractInput] or `null` if not found
- */
- suspend fun readCurrentInput(): I? {
- return readInput()
- }
-
- /**
- * Saves the given [AbstractInput] and sets it as default current [AbstractInput].
- *
- * @param input the [AbstractInput] to save
- *
- * @return `true` if the given [AbstractInput] has been successfully saved, `false` otherwise
- */
- @SuppressLint("ApplySharedPref")
- suspend fun saveInput(input: I): Boolean {
- val saved = withContext(IO) {
- val inputAsJson = inputJsonWriter.write(input)
- if (inputAsJson.isNullOrBlank()) return@withContext false
-
- preferenceManager.edit()
- .putString(
- buildInputPreferenceKey(input.id),
- inputAsJson
- )
- .putLong(
- KEY_PREFERENCE_CURRENT_INPUT,
- input.id
- )
- .commit()
- }
-
- return saved && preferenceManager.contains(buildInputPreferenceKey(input.id))
- .also { readInputs() }
- }
-
- /**
- * Deletes [AbstractInput] from given ID.
- *
- * @param id the [AbstractInput] ID to delete
- *
- * @return `true` if the given [AbstractInput] has been successfully deleted, `false` otherwise
- */
- @SuppressLint("ApplySharedPref")
- suspend fun deleteInput(id: Long): Boolean {
- val deleted = withContext(IO) {
- preferenceManager.edit()
- .remove(buildInputPreferenceKey(id))
- .also {
- if (preferenceManager.getLong(
- KEY_PREFERENCE_CURRENT_INPUT,
- 0
- ) == id
- ) {
- it.remove(KEY_PREFERENCE_CURRENT_INPUT)
- }
- }
- .commit()
- }
-
- Log.i(
- TAG,
- "input '$id' deleted: $deleted"
- )
-
- return deleted && !preferenceManager.contains(buildInputPreferenceKey(id))
- .also {
- readInputs()
- input.postValue(null)
- }
- }
-
- /**
- * Exports [AbstractInput] from given ID as `JSON` file.
- *
- * @param id the [AbstractInput] ID to export
- *
- * @return `true` if the given [AbstractInput] has been successfully exported, `false` otherwise
- */
- suspend fun exportInput(id: Long): Boolean {
- val inputToExport = readInput(id) ?: return false
-
- return exportInput(inputToExport)
- }
-
- /**
- * Exports [AbstractInput] as `JSON` file.
- *
- * @param input the [AbstractInput] to save
- *
- * @return `true` if the given [AbstractInput] has been successfully exported, `false` otherwise
- */
- suspend fun exportInput(input: I): Boolean {
- val inputExportFile = getInputExportFile(input)
-
- @Suppress("BlockingMethodInNonBlockingContext")
- withContext(IO) {
- inputJsonWriter.write(
- FileWriter(inputExportFile),
- input
- )
- }
-
- Log.i(
- TAG,
- "export input '${input.id}' to JSON file '${inputExportFile.absolutePath}'"
- )
- Log.d(
- TAG,
- "'${inputExportFile.absolutePath}' exists? ${inputExportFile.exists()}"
- )
-
- return if (inputExportFile.exists()) {
- deleteInput(input.id)
- } else {
- false
- }
- }
-
- private fun buildInputPreferenceKey(id: Long): String {
- return "${KEY_PREFERENCE_INPUT}_$id"
- }
-
- private suspend fun getInputExportFile(input: AbstractInput): File = withContext(IO) {
- val inputDir = FileUtils.getInputsFolder(application)
- inputDir.mkdirs()
-
- return@withContext File(
- inputDir,
- "input_${input.module}_${input.id}.json"
- )
- }
-
- companion object {
- private val TAG = InputManager::class.java.name
-
- private const val KEY_PREFERENCE_INPUT = "key_preference_input"
- private const val KEY_PREFERENCE_CURRENT_INPUT = "key_preference_current_input"
-
- @Volatile
- private var INSTANCE: InputManager<*>? = null
-
- /**
- * Gets the singleton instance of [InputManager].
- *
- * @param application The main application context.
- *
- * @return The singleton instance of [InputManager].
- */
- @Suppress("UNCHECKED_CAST")
- fun getInstance(
- application: Application,
- inputJsonReaderListener: InputJsonReader.OnInputJsonReaderListener,
- inputJsonWriterListener: InputJsonWriter.OnInputJsonWriterListener
- ): InputManager = INSTANCE as InputManager?
- ?: synchronized(this) {
- INSTANCE as InputManager? ?: InputManager(
- application,
- inputJsonReaderListener,
- inputJsonWriterListener
- ).also { INSTANCE = it }
- }
- }
-}
diff --git a/commons/src/main/java/fr/geonature/commons/input/InputManagerImpl.kt b/commons/src/main/java/fr/geonature/commons/input/InputManagerImpl.kt
new file mode 100644
index 00000000..2133f0ec
--- /dev/null
+++ b/commons/src/main/java/fr/geonature/commons/input/InputManagerImpl.kt
@@ -0,0 +1,200 @@
+package fr.geonature.commons.input
+
+import android.content.ContentValues
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Log
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.preference.PreferenceManager
+import fr.geonature.commons.data.helper.Provider
+import fr.geonature.commons.input.io.InputJsonReader
+import fr.geonature.commons.input.io.InputJsonWriter
+import kotlinx.coroutines.Dispatchers.Default
+import kotlinx.coroutines.withContext
+
+/**
+ * Default implementation of [IInputManager].
+ *
+ * @author S. Grimault
+ */
+class InputManagerImpl(
+ private val context: Context,
+ inputJsonReaderListener: InputJsonReader.OnInputJsonReaderListener,
+ inputJsonWriterListener: InputJsonWriter.OnInputJsonWriterListener
+) : IInputManager {
+
+ private val preferenceManager: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
+ private val inputJsonReader: InputJsonReader = InputJsonReader(inputJsonReaderListener)
+ private val inputJsonWriter: InputJsonWriter = InputJsonWriter(inputJsonWriterListener)
+
+ private val _inputs: MutableLiveData> = MutableLiveData()
+ override val inputs: LiveData> = _inputs
+
+ private val _input: MutableLiveData = MutableLiveData()
+ override val input: LiveData = _input
+
+ override suspend fun readInputs(): List =
+ withContext(Default) {
+ preferenceManager.all.filterKeys { it.startsWith("${KEY_PREFERENCE_INPUT}_") }.values
+ .mapNotNull { if (it is String && it.isNotBlank()) inputJsonReader.read(it) else null }
+ .sortedBy { it.id }
+ .also { _inputs.postValue(it) }
+ }
+
+ override suspend fun readInput(id: Long?): I? =
+ withContext(Default) {
+ val inputPreferenceKey = buildInputPreferenceKey(
+ id
+ ?: preferenceManager.getLong(
+ KEY_PREFERENCE_CURRENT_INPUT,
+ 0
+ )
+ )
+ val inputAsJson = preferenceManager.getString(
+ inputPreferenceKey,
+ null
+ )
+
+ if (inputAsJson.isNullOrBlank()) {
+ return@withContext null
+ }
+
+ inputJsonReader
+ .read(inputAsJson)
+ .also { _input.postValue(it) }
+ }
+
+ override suspend fun readCurrentInput(): I? {
+ return readInput()
+ }
+
+ override suspend fun saveInput(input: I): Boolean {
+ val saved = withContext(Default) {
+ val inputAsJson = inputJsonWriter.write(input)
+ if (inputAsJson.isNullOrBlank()) return@withContext false
+
+ preferenceManager
+ .edit()
+ .putString(
+ buildInputPreferenceKey(input.id),
+ inputAsJson
+ )
+ .putLong(
+ KEY_PREFERENCE_CURRENT_INPUT,
+ input.id
+ )
+ .commit()
+ }
+
+ return saved && preferenceManager
+ .contains(buildInputPreferenceKey(input.id))
+ .also { readInputs() }
+ }
+
+ override suspend fun deleteInput(id: Long): Boolean {
+ val deleted = withContext(Default) {
+ preferenceManager
+ .edit()
+ .remove(buildInputPreferenceKey(id))
+ .also {
+ if (preferenceManager.getLong(
+ KEY_PREFERENCE_CURRENT_INPUT,
+ 0
+ ) == id
+ ) {
+ it.remove(KEY_PREFERENCE_CURRENT_INPUT)
+ }
+ }
+ .commit()
+ }
+
+ Log.i(
+ TAG,
+ "input '$id' deleted: $deleted"
+ )
+
+ _input.postValue(null)
+ readInputs()
+
+ return deleted && !preferenceManager.contains(buildInputPreferenceKey(id))
+ }
+
+ override suspend fun exportInput(id: Long): Boolean {
+ val inputToExport = readInput(id)
+ ?: return false
+
+ return exportInput(inputToExport)
+ }
+
+ override suspend fun exportInput(input: I): Boolean {
+ input.status = AbstractInput.Status.TO_SYNC
+
+ val inputExportUri = Provider.buildUri(
+ "inputs",
+ "export"
+ )
+
+ val inputUri = kotlin
+ .runCatching {
+ context.contentResolver
+ .acquireContentProviderClient(inputExportUri)
+ ?.let {
+ val uri = it.insert(
+ inputExportUri,
+ toContentValues(input)
+ )
+
+ it.close()
+ uri
+ }
+ }
+ .getOrNull()
+
+ if (inputUri == null) {
+ input.status = AbstractInput.Status.DRAFT
+
+ Log.w(
+ TAG,
+ "failed to export input '${input.id}'"
+ )
+
+ return false
+ }
+
+ Log.i(
+ TAG,
+ "input '${input.id}' exported (URI: $inputUri)"
+ )
+
+ return deleteInput(input.id)
+ }
+
+ private fun toContentValues(input: I): ContentValues {
+ return ContentValues().apply {
+ put(
+ "id",
+ input.id
+ )
+ put(
+ "packageName",
+ context.packageName
+ )
+ put(
+ "data",
+ inputJsonWriter.write(input)
+ )
+ }
+ }
+
+ private fun buildInputPreferenceKey(id: Long): String {
+ return "${KEY_PREFERENCE_INPUT}_$id"
+ }
+
+ companion object {
+ private val TAG = InputManagerImpl::class.java.name
+
+ private const val KEY_PREFERENCE_INPUT = "key_preference_input"
+ private const val KEY_PREFERENCE_CURRENT_INPUT = "key_preference_current_input"
+ }
+}
diff --git a/commons/src/main/java/fr/geonature/commons/input/InputViewModel.kt b/commons/src/main/java/fr/geonature/commons/input/InputViewModel.kt
index a681f473..5b1e5ad3 100644
--- a/commons/src/main/java/fr/geonature/commons/input/InputViewModel.kt
+++ b/commons/src/main/java/fr/geonature/commons/input/InputViewModel.kt
@@ -1,13 +1,9 @@
package fr.geonature.commons.input
-import android.app.Application
-import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
-import fr.geonature.commons.input.io.InputJsonReader
-import fr.geonature.commons.input.io.InputJsonWriter
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@@ -17,17 +13,8 @@ import kotlinx.coroutines.launch
*
* @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
*/
-open class InputViewModel(
- application: Application,
- inputJsonReaderListener: InputJsonReader.OnInputJsonReaderListener,
- inputJsonWriterListener: InputJsonWriter.OnInputJsonWriterListener
-) : AndroidViewModel(application) {
-
- private val inputManager = InputManager.getInstance(
- application,
- inputJsonReaderListener,
- inputJsonWriterListener
- )
+open class InputViewModel(private val inputManager: IInputManager) :
+ ViewModel() {
private var deletedInputToRestore: I? = null
@@ -90,7 +77,8 @@ open class InputViewModel(
* Restores previously deleted [AbstractInput].
*/
fun restoreDeletedInput() {
- val selectedInputToRestore = deletedInputToRestore ?: return
+ val selectedInputToRestore = deletedInputToRestore
+ ?: return
viewModelScope.launch {
inputManager.saveInput(selectedInputToRestore)
@@ -103,20 +91,38 @@ open class InputViewModel(
*
* @param id the [AbstractInput] ID to export
*/
- fun exportInput(id: Long) {
+ fun exportInput(
+ id: Long,
+ exported: () -> Unit = {}
+ ) {
GlobalScope.launch(Main) {
- inputManager.exportInput(id)
+ inputManager
+ .exportInput(id)
+ .also {
+ if (it) {
+ exported()
+ }
+ }
}
}
/**
- * Exports [AbstractInput] from given ID as `JSON` file.
+ * Exports given [AbstractInput] as `JSON` file.
*
* @param input the [AbstractInput] to export
*/
- fun exportInput(input: I) {
+ fun exportInput(
+ input: I,
+ exported: () -> Unit = {}
+ ) {
GlobalScope.launch(Main) {
- inputManager.exportInput(input)
+ inputManager
+ .exportInput(input)
+ .also {
+ if (it) {
+ exported()
+ }
+ }
}
}
diff --git a/commons/src/main/java/fr/geonature/commons/settings/AppSettingsManager.kt b/commons/src/main/java/fr/geonature/commons/settings/AppSettingsManager.kt
index 32a63b00..b48c0dad 100644
--- a/commons/src/main/java/fr/geonature/commons/settings/AppSettingsManager.kt
+++ b/commons/src/main/java/fr/geonature/commons/settings/AppSettingsManager.kt
@@ -1,44 +1,30 @@
package fr.geonature.commons.settings
+import android.annotation.SuppressLint
import android.app.Application
import android.util.Log
+import fr.geonature.commons.data.helper.Provider
import fr.geonature.commons.settings.io.AppSettingsJsonReader
import fr.geonature.mountpoint.model.MountPoint.StorageType.INTERNAL
import fr.geonature.mountpoint.util.FileUtils.getFile
import fr.geonature.mountpoint.util.FileUtils.getRootFolder
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Dispatchers.Main
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileReader
-import java.io.IOException
/**
* Manage [IAppSettings].
- * - Read [IAppSettings] from `JSON` file
+ * - Read [IAppSettings] from URI
+ * - Read [IAppSettings] from `JSON` file as fallback
*
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ * @author S. Grimault
*/
class AppSettingsManager private constructor(
internal val application: Application,
onAppSettingsJsonJsonReaderListener: AppSettingsJsonReader.OnAppSettingsJsonReaderListener
) {
- private val appSettingsJsonReader: AppSettingsJsonReader =
- AppSettingsJsonReader(onAppSettingsJsonJsonReaderListener)
-
- init {
- GlobalScope.launch(Main) {
- withContext(IO) {
- getRootFolder(
- application,
- INTERNAL
- )
- .mkdirs()
- }
- }
- }
+ private val appSettingsJsonReader: AppSettingsJsonReader = AppSettingsJsonReader(onAppSettingsJsonJsonReaderListener)
fun getAppSettingsFilename(): String {
val packageName = application.packageName
@@ -47,47 +33,24 @@ class AppSettingsManager private constructor(
}
/**
- * Loads [IAppSettings] from `JSON` file.
+ * Loads [IAppSettings] from URI or `JSON` file as fallback.
*
* @return [IAppSettings] or `null` if not found
*/
suspend fun loadAppSettings(): AS? {
- val settingsJsonFile = getAppSettingsAsFile()
-
- Log.i(
- TAG,
- "Loading settings from '${settingsJsonFile.absolutePath}'..."
- )
+ val appSettings = withContext(Dispatchers.Default) {
+ loadAppSettingsFromUri()
+ ?: loadAppSettingsFromFile()
+ }
- if (!settingsJsonFile.exists()) {
+ if (appSettings == null) {
Log.w(
TAG,
- "'${settingsJsonFile.absolutePath}' not found"
+ "Failed to load '${getAppSettingsFilename()}'"
)
-
- return null
}
- @Suppress("BlockingMethodInNonBlockingContext")
- return withContext(IO) {
- try {
- val appSettings = appSettingsJsonReader.read(FileReader(settingsJsonFile))
-
- Log.i(
- TAG,
- "Settings loaded"
- )
-
- appSettings
- } catch (e: IOException) {
- Log.w(
- TAG,
- "Failed to load '${settingsJsonFile.name}'"
- )
-
- null
- }
- }
+ return appSettings
}
internal fun getAppSettingsAsFile(): File {
@@ -100,6 +63,75 @@ class AppSettingsManager private constructor(
)
}
+ @SuppressLint("Recycle")
+ private fun loadAppSettingsFromUri(): AS? {
+ val appSettingsUri = Provider.buildUri(
+ "settings",
+ getAppSettingsFilename()
+ )
+
+ Log.i(
+ TAG,
+ "Loading settings from URI '${appSettingsUri}'..."
+ )
+
+ return kotlin
+ .runCatching {
+ application.contentResolver
+ .acquireContentProviderClient(appSettingsUri)
+ ?.let {
+ val appSettings = it
+ .openFile(
+ appSettingsUri,
+ "r"
+ )
+ ?.let { pfd ->
+ val appSettings = kotlin
+ .runCatching { appSettingsJsonReader.read(FileReader(pfd.fileDescriptor)) }
+ .getOrNull()
+
+ if (appSettings == null) {
+ Log.w(
+ TAG,
+ "failed to load settings from URI '${appSettingsUri}'"
+ )
+ }
+
+ pfd.close()
+
+ appSettings
+ }
+
+ it.close()
+
+ appSettings
+ }
+ }
+ .getOrNull()
+ }
+
+ private fun loadAppSettingsFromFile(): AS? {
+ val appSettingsJsonFile = getAppSettingsAsFile()
+
+ Log.i(
+ TAG,
+ "Loading settings from '${appSettingsJsonFile.absolutePath}'..."
+ )
+
+ if (!appSettingsJsonFile.exists()) {
+ Log.w(
+ TAG,
+ "'${appSettingsJsonFile.absolutePath}' not found"
+ )
+
+ return null
+ }
+
+ return kotlin
+ .runCatching { appSettingsJsonReader.read(FileReader(appSettingsJsonFile)) }
+ .getOrNull()
+ }
+
companion object {
private val TAG = AppSettingsManager::class.java.name
@@ -117,12 +149,14 @@ class AppSettingsManager private constructor(
fun getInstance(
application: Application,
onAppSettingsJsonJsonReaderListener: AppSettingsJsonReader.OnAppSettingsJsonReaderListener
- ): AppSettingsManager = INSTANCE as AppSettingsManager?
- ?: synchronized(this) {
- INSTANCE as AppSettingsManager? ?: AppSettingsManager(
- application,
- onAppSettingsJsonJsonReaderListener
- ).also { INSTANCE = it }
- }
+ ): AppSettingsManager =
+ INSTANCE as AppSettingsManager?
+ ?: synchronized(this) {
+ INSTANCE as AppSettingsManager?
+ ?: AppSettingsManager(
+ application,
+ onAppSettingsJsonJsonReaderListener
+ ).also { INSTANCE = it }
+ }
}
}
diff --git a/commons/src/main/java/fr/geonature/commons/util/NetworkHandler.kt b/commons/src/main/java/fr/geonature/commons/util/NetworkHandler.kt
new file mode 100644
index 00000000..21db6c86
--- /dev/null
+++ b/commons/src/main/java/fr/geonature/commons/util/NetworkHandler.kt
@@ -0,0 +1,33 @@
+package fr.geonature.commons.util
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.os.Build
+
+/**
+ * Returns information about the current network connection state.
+ */
+class NetworkHandler(private val applicationContext: Context) {
+ fun isNetworkAvailable(): Boolean {
+ val connectivityManager = applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ val network = connectivityManager.activeNetwork
+ ?: return false
+ val activeNetwork = connectivityManager.getNetworkCapabilities(network)
+ ?: return false
+
+ return when {
+ activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
+ activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
+ activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true
+ else -> false
+ }
+ } else {
+ @Suppress("DEPRECATION") val networkInfo = connectivityManager.activeNetworkInfo
+ ?: return false
+ @Suppress("DEPRECATION") return networkInfo.isConnected
+ }
+ }
+}
\ No newline at end of file
diff --git a/commons/src/main/res/values/strings.xml b/commons/src/main/res/values/strings.xml
index f59f976c..357a837e 100644
--- a/commons/src/main/res/values/strings.xml
+++ b/commons/src/main/res/values/strings.xml
@@ -5,6 +5,7 @@
fr.geonature
fr.geonature.sync.permission.READ
+ fr.geonature.sync.permission.WRITE
mounted (rw)
mounted (ro)
diff --git a/commons/src/main/res/values/styles.xml b/commons/src/main/res/values/styles.xml
deleted file mode 100644
index c291b9d4..00000000
--- a/commons/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
diff --git a/commons/src/test/java/fr/geonature/commons/data/DummyContentProvider.kt b/commons/src/test/java/fr/geonature/commons/data/DummyContentProvider.kt
new file mode 100644
index 00000000..87fb64eb
--- /dev/null
+++ b/commons/src/test/java/fr/geonature/commons/data/DummyContentProvider.kt
@@ -0,0 +1,55 @@
+package fr.geonature.commons.data
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.net.Uri
+import fr.geonature.commons.data.helper.Provider
+
+class DummyContentProvider : ContentProvider() {
+ override fun onCreate(): Boolean {
+ return true
+ }
+
+ override fun getType(uri: Uri): String {
+ return "$VND_TYPE_ITEM_PREFIX/${Provider.AUTHORITY}.DummyTable"
+ }
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?
+ ): Cursor? {
+ return null
+ }
+
+ override fun insert(
+ uri: Uri,
+ values: ContentValues?
+ ): Uri {
+ return uri
+ }
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?
+ ): Int {
+ throw NotImplementedError("'update' operation not implemented")
+ }
+
+ override fun delete(
+ uri: Uri,
+ selection: String?,
+ selectionArgs: Array?
+ ): Int {
+ throw NotImplementedError("'delete' operation not implemented")
+ }
+
+ companion object {
+ const val VND_TYPE_ITEM_PREFIX = "vnd.android.cursor.item"
+ }
+}
\ No newline at end of file
diff --git a/commons/src/test/java/fr/geonature/commons/fp/EitherTest.kt b/commons/src/test/java/fr/geonature/commons/fp/EitherTest.kt
new file mode 100644
index 00000000..e3b8c8ee
--- /dev/null
+++ b/commons/src/test/java/fr/geonature/commons/fp/EitherTest.kt
@@ -0,0 +1,196 @@
+package fr.geonature.commons.fp
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+
+/**
+ * Unit tests about [Either].
+ */
+class EitherTest {
+ @Test
+ fun `Either Right should return correct type`() {
+ val result = Either.Right("right_value")
+
+ assertTrue(result.isRight)
+ assertFalse(result.isLeft)
+
+ result.fold({},
+ { right ->
+ assertEquals(
+ "right_value",
+ right
+ )
+ })
+ }
+
+ @Test
+ fun `Either Left should return correct type`() {
+ val result = Either.Left("left_value")
+
+ assertFalse(result.isRight)
+ assertTrue(result.isLeft)
+
+ result.fold({ left ->
+ assertEquals(
+ "left_value",
+ left
+ )
+ },
+ {})
+ }
+
+ @Test
+ fun `Either getOrElse should ignore default value if it is Right type`() {
+ val success = "Success"
+ val result = Either
+ .Right(success)
+ .getOrElse("Default")
+
+ assertEquals(
+ success,
+ result
+ )
+ }
+
+ @Test
+ fun `Either getOrElse should return default value if it is Left type`() {
+ val other = "Default"
+ val result = Either
+ .Left("Failure")
+ .getOrElse(other)
+
+ assertEquals(
+ other,
+ result
+ )
+ }
+
+ @Test
+ fun `Given fold is called, when Either is Right, applies fnR and returns its result`() {
+ val either = Either.Right("Success")
+ val result = either.fold({ fail("Shouldn't be executed") }) { 5 }
+
+ assertEquals(
+ 5,
+ result
+ )
+ }
+
+ @Test
+ fun `Given fold is called, when Either is Left, applies fnL and returns its result`() {
+ val either = Either.Left(12)
+ val foldResult = "fold_result"
+ val result = either.fold({ foldResult }) { fail("Shouldn't be executed") }
+
+ assertEquals(
+ foldResult,
+ result
+ )
+ }
+
+ @Test
+ fun `Given flatMap is called, when Either is Right, applies function and returns new Either`() {
+ val either = Either.Right("Success")
+ val result = either.flatMap {
+ assertEquals(
+ "Success",
+ it
+ )
+ Either.Left("Error")
+ }
+
+ assertEquals(
+ Either.Left("Error"),
+ result
+ )
+ assertTrue(result.isLeft)
+ }
+
+ @Test
+ fun `Given flatMap is called, when Either is Left, doesn't invoke function and returns original Either`() {
+ val either = Either.Left(12)
+ val result = either.flatMap { Either.Right(20) }
+
+ assertTrue(result.isLeft)
+ assertEquals(
+ either,
+ result
+ )
+ }
+
+ @Test
+ fun `Given onFailure is called, when Either is Right, doesn't invoke function and returns original Either`() {
+ val success = "Success"
+ val either = Either.Right(success)
+ val result = either.onFailure { fail("Shouldn't be executed") }
+
+ assertEquals(
+ either,
+ result
+ )
+ assertEquals(
+ success,
+ either.getOrElse("Failure")
+ )
+ }
+
+ @Test
+ fun `Given onFailure is called, when Either is Left, invokes function with left value and returns original Either`() {
+ val either = Either.Left(12)
+ var onFailureCalled = false
+ val result = either.onFailure {
+ assertEquals(12, it)
+ onFailureCalled = true
+ }
+
+ assertEquals(either, result)
+ assertTrue(onFailureCalled)
+ }
+
+ @Test
+ fun `Given onSuccess is called, when Either is Right, invokes function with right value and returns original Either`() {
+ val success = "Success"
+ val either = Either.Right(success)
+ var onSuccessCalled = false
+ val result = either.onSuccess {
+ assertEquals(success, it)
+ onSuccessCalled = true
+ }
+
+ assertEquals(either, result)
+ assertTrue(onSuccessCalled)
+ }
+
+ @Test
+ fun `Given onSuccess is called, when Either is Left, doesn't invoke function and returns original Either`() {
+ val either = Either.Left(12)
+ val result = either.onSuccess {fail("Shouldn't be executed") }
+
+ assertEquals(either, result)
+ }
+
+ @Test
+ fun `Given map is called, when Either is Right, invokes function with right value and returns a new Either`() {
+ val success = "Success"
+ val resultValue = "Result"
+ val either = Either.Right(success)
+ val result = either.map {
+ assertEquals(success, it)
+ resultValue
+ }
+
+ assertEquals(Either.Right(resultValue), result)
+ }
+
+ @Test
+ fun `Given map is called, when Either is Left, doesn't invoke function and returns original Either`() {
+ val either = Either.Left(12)
+ val result = either.map { Either.Right(20) }
+
+ assertTrue(result.isLeft)
+ assertEquals(either, result)
+ }
+}
\ No newline at end of file
diff --git a/commons/src/test/java/fr/geonature/commons/input/InputManagerTest.kt b/commons/src/test/java/fr/geonature/commons/input/InputManagerTest.kt
index c1c8fe1d..21c9d03b 100644
--- a/commons/src/test/java/fr/geonature/commons/input/InputManagerTest.kt
+++ b/commons/src/test/java/fr/geonature/commons/input/InputManagerTest.kt
@@ -1,10 +1,14 @@
package fr.geonature.commons.input
import android.app.Application
+import android.content.pm.ProviderInfo
import android.util.JsonReader
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.Observer
+import androidx.preference.PreferenceManager
import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import fr.geonature.commons.data.DummyContentProvider
+import fr.geonature.commons.data.helper.Provider
import fr.geonature.commons.input.io.InputJsonReader
import fr.geonature.commons.input.io.InputJsonWriter
import kotlinx.coroutines.runBlocking
@@ -20,13 +24,13 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.atMost
-import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations.initMocks
+import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
/**
- * Unit tests about [InputManager].
+ * Unit tests about [InputManagerImpl].
*
* @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
*/
@@ -36,7 +40,7 @@ class InputManagerTest {
@get:Rule
val rule = InstantTaskExecutorRule()
- private lateinit var inputManager: InputManager
+ private lateinit var inputManager: InputManagerImpl
@Mock
private lateinit var onInputJsonWriterListener: InputJsonWriter.OnInputJsonWriterListener
@@ -67,7 +71,15 @@ class InputManagerTest {
}
val application = getApplicationContext()
- inputManager = InputManager.getInstance(
+
+ val info = ProviderInfo()
+ info.authority = Provider.AUTHORITY
+ info.grantUriPermissions = true
+ Robolectric
+ .buildContentProvider(DummyContentProvider::class.java)
+ .create(info)
+
+ inputManager = InputManagerImpl(
application,
onInputJsonReaderListener,
onInputJsonWriterListener
@@ -75,14 +87,15 @@ class InputManagerTest {
inputManager.inputs.observeForever(observerForListOfInputs)
inputManager.input.observeForever(observerForInput)
- inputManager.preferenceManager.edit()
+ PreferenceManager
+ .getDefaultSharedPreferences(application)
+ .edit()
.clear()
.commit()
}
@Test
- fun testReadUndefinedInputs() {
- // when reading non existing inputs
+ fun testReadUndefinedInputs() { // when reading non existing inputs
val noSuchInputs = runBlocking { inputManager.readInputs() }
// then
@@ -90,8 +103,7 @@ class InputManagerTest {
}
@Test
- fun testSaveAndReadInputs() {
- // given some inputs to save and read
+ fun testSaveAndReadInputs() { // given some inputs to save and read
val input1 = DummyInput().apply { id = 1234 }
val input2 = DummyInput().apply { id = 1235 }
val input3 = DummyInput().apply { id = 1236 }
@@ -111,21 +123,20 @@ class InputManagerTest {
val inputs = runBlocking { inputManager.readInputs() }
// then
- assertArrayEquals(
- arrayOf(
- input1.id,
- input2.id,
- input3.id
- ),
- inputs.map { it.id }.toTypedArray()
- )
+ assertArrayEquals(arrayOf(
+ input1.id,
+ input2.id,
+ input3.id
+ ),
+ inputs
+ .map { it.id }
+ .toTypedArray())
verify(observerForListOfInputs).onChanged(inputs)
}
@Test
- fun testReadUndefinedInput() {
- // when reading non existing Input
+ fun testReadUndefinedInput() { // when reading non existing Input
val noSuchInput = runBlocking { inputManager.readInput(1234) }
// then
@@ -133,8 +144,7 @@ class InputManagerTest {
}
@Test
- fun testSaveAndReadInput() {
- // given an Input to save and read
+ fun testSaveAndReadInput() { // given an Input to save and read
val input = DummyInput().apply { id = 1234 }
// when saving this Input
@@ -185,12 +195,14 @@ class InputManagerTest {
currentInput.module
)
- verify(observerForInput).onChanged(readInput)
+ verify(
+ observerForInput,
+ atMost(2)
+ ).onChanged(readInput)
}
@Test
- fun testSaveAndDeleteInput() {
- // given an Input to save and delete
+ fun testSaveAndDeleteInput() { // given an Input to save and delete
val input = DummyInput().apply { id = 1234 }
// when saving this Input
@@ -215,8 +227,7 @@ class InputManagerTest {
}
@Test
- fun testExportUndefinedInput() {
- // when exporting non existing Input
+ fun testExportUndefinedInput() { // when exporting non existing Input
val exported = runBlocking { inputManager.exportInput(1234) }
// then
@@ -224,13 +235,11 @@ class InputManagerTest {
}
@Test
- fun testSaveAndExportExistingInput() {
- // given an Input to save and export
+ fun testSaveAndExportExistingInput() { // given an Input to save and export
val input = DummyInput().apply { id = 1234 }
// when saving this Input
- val saved = runBlocking { inputManager.saveInput(input) }
- // and exporting this Input
+ val saved = runBlocking { inputManager.saveInput(input) } // and exporting this Input
val exported = runBlocking { inputManager.exportInput(input.id) }
// then
@@ -242,17 +251,16 @@ class InputManagerTest {
verify(
observerForListOfInputs,
- times(2)
+ atMost(2)
).onChanged(emptyList())
verify(
observerForInput,
- times(2)
+ atMost(2)
).onChanged(null)
}
@Test
- fun testSaveAndExportInput() {
- // given an Input to save and export
+ fun testSaveAndExportInput() { // given an Input to save and export
val input = DummyInput().apply { id = 1234 }
// when exporting this Input
diff --git a/commons/src/test/java/fr/geonature/commons/input/InputTest.kt b/commons/src/test/java/fr/geonature/commons/input/InputTest.kt
index 05ec729f..ca51e625 100644
--- a/commons/src/test/java/fr/geonature/commons/input/InputTest.kt
+++ b/commons/src/test/java/fr/geonature/commons/input/InputTest.kt
@@ -31,6 +31,15 @@ class InputTest {
assertTrue(input.id > 0)
}
+ @Test
+ fun testHasDefaultStatus() {
+ // given an empty Input
+ val input = DummyInput()
+
+ // then
+ assertTrue(input.status == AbstractInput.Status.DRAFT)
+ }
+
@Test
fun testSetDate() {
// given an empty Input
@@ -90,11 +99,13 @@ class InputTest {
// then
assertNull(input.getPrimaryObserverId())
assertTrue(
- input.getAllInputObserverIds()
+ input
+ .getAllInputObserverIds()
.isEmpty()
)
assertTrue(
- input.getInputObserverIds()
+ input
+ .getInputObserverIds()
.isEmpty()
)
@@ -114,11 +125,13 @@ class InputTest {
)
assertArrayEquals(
longArrayOf(6),
- input.getAllInputObserverIds()
+ input
+ .getAllInputObserverIds()
.toLongArray()
)
assertTrue(
- input.getInputObserverIds()
+ input
+ .getInputObserverIds()
.isEmpty()
)
@@ -139,7 +152,8 @@ class InputTest {
3,
4
),
- input.getAllInputObserverIds()
+ input
+ .getAllInputObserverIds()
.toLongArray()
)
assertArrayEquals(
@@ -147,7 +161,8 @@ class InputTest {
3,
4
),
- input.getInputObserverIds()
+ input
+ .getInputObserverIds()
.toLongArray()
)
}
@@ -169,7 +184,8 @@ class InputTest {
// then
assertArrayEquals(
longArrayOf(1),
- input.getAllInputObserverIds()
+ input
+ .getAllInputObserverIds()
.toLongArray()
)
@@ -190,7 +206,8 @@ class InputTest {
1,
2
),
- input.getAllInputObserverIds()
+ input
+ .getAllInputObserverIds()
.toLongArray()
)
@@ -213,7 +230,8 @@ class InputTest {
5,
6
),
- input.getAllInputObserverIds()
+ input
+ .getAllInputObserverIds()
.toLongArray()
)
}
@@ -259,7 +277,8 @@ class InputTest {
3,
5
),
- input.getAllInputObserverIds()
+ input
+ .getAllInputObserverIds()
.toLongArray()
)
@@ -291,7 +310,8 @@ class InputTest {
8,
6
),
- input.getAllInputObserverIds()
+ input
+ .getAllInputObserverIds()
.toLongArray()
)
}
@@ -330,7 +350,8 @@ class InputTest {
3,
5
),
- input.getAllInputObserverIds()
+ input
+ .getAllInputObserverIds()
.toLongArray()
)
@@ -345,7 +366,8 @@ class InputTest {
5,
7
),
- input.getAllInputObserverIds()
+ input
+ .getAllInputObserverIds()
.toLongArray()
)
}
@@ -383,7 +405,8 @@ class InputTest {
)
)
),
- input.getInputTaxa()
+ input
+ .getInputTaxa()
.toTypedArray()
)
@@ -415,7 +438,8 @@ class InputTest {
)
)
),
- input.getInputTaxa()
+ input
+ .getInputTaxa()
.toTypedArray()
)
}
@@ -500,7 +524,8 @@ class InputTest {
)
)
),
- input.getInputTaxa()
+ input
+ .getInputTaxa()
.toTypedArray()
)
@@ -522,7 +547,8 @@ class InputTest {
)
)
),
- input.getInputTaxa()
+ input
+ .getInputTaxa()
.toTypedArray()
)
}
@@ -745,8 +771,7 @@ class InputTest {
// given an Input
val input = DummyInput().apply {
id = 1234
- date = Calendar.getInstance()
- .time
+ date = Calendar.getInstance().time
datasetId = 17
setAllInputObservers(
listOf(
diff --git a/commons/src/test/java/fr/geonature/commons/input/InputViewModelTest.kt b/commons/src/test/java/fr/geonature/commons/input/InputViewModelTest.kt
index 1fe95f47..318a54c5 100644
--- a/commons/src/test/java/fr/geonature/commons/input/InputViewModelTest.kt
+++ b/commons/src/test/java/fr/geonature/commons/input/InputViewModelTest.kt
@@ -1,17 +1,12 @@
package fr.geonature.commons.input
-import android.app.Application
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
-import androidx.test.core.app.ApplicationProvider
-import fr.geonature.commons.input.io.InputJsonReader
-import fr.geonature.commons.input.io.InputJsonWriter
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
-import org.mockito.Mockito.doReturn
import org.mockito.Mockito.spy
import org.mockito.MockitoAnnotations.initMocks
import org.robolectric.RobolectricTestRunner
@@ -28,54 +23,27 @@ class InputViewModelTest {
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Mock
- private lateinit var onInputJsonReaderListener: InputJsonReader.OnInputJsonReaderListener
+ private lateinit var inputManager: IInputManager
- @Mock
- private lateinit var onInputJsonWriterListener: InputJsonWriter.OnInputJsonWriterListener
-
- private lateinit var inputViewModel: DummyInputViewModel
+ private lateinit var inputViewModel: InputViewModel
@Before
fun setUp() {
initMocks(this)
- doReturn(DummyInput()).`when`(onInputJsonReaderListener)
- .createInput()
-
- inputViewModel = spy(
- DummyInputViewModel(
- ApplicationProvider.getApplicationContext(),
- onInputJsonReaderListener,
- onInputJsonWriterListener
- )
- )
+ inputViewModel = spy(InputViewModel(inputManager))
}
@Test
- fun testCreateFromFactory() {
- // given Factory
+ fun testCreateFromFactory() { // given Factory
val factory = InputViewModel.Factory {
- DummyInputViewModel(
- ApplicationProvider.getApplicationContext(),
- onInputJsonReaderListener,
- onInputJsonWriterListener
- )
+ InputViewModel(inputManager)
}
// when create InputViewModel instance from this factory
- val viewModelFromFactory = factory.create(DummyInputViewModel::class.java)
+ val viewModelFromFactory = factory.create(InputViewModel::class.java)
// then
assertNotNull(viewModelFromFactory)
}
-
- class DummyInputViewModel(
- application: Application,
- inputJsonReaderListener: InputJsonReader.OnInputJsonReaderListener,
- inputJsonWriterListener: InputJsonWriter.OnInputJsonWriterListener
- ) : InputViewModel(
- application,
- inputJsonReaderListener,
- inputJsonWriterListener
- )
}
diff --git a/commons/version.properties b/commons/version.properties
index 0595f464..e35f6bfc 100644
--- a/commons/version.properties
+++ b/commons/version.properties
@@ -1,2 +1,2 @@
-#Sat Jun 12 15:17:29 CEST 2021
-VERSION_CODE=2890
+#Tue Jul 27 17:28:59 CEST 2021
+VERSION_CODE=3270
diff --git a/gradle.properties b/gradle.properties
index eeed7cd8..f4f49faf 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,3 +1,4 @@
android.enableJetifier=true
android.useAndroidX=true
-kotlin.code.style=official
\ No newline at end of file
+kotlin.code.style=official
+org.gradle.jvmargs=-Xmx2048M
\ No newline at end of file
diff --git a/sync/README.md b/sync/README.md
index 3dac9950..2e4cde23 100644
--- a/sync/README.md
+++ b/sync/README.md
@@ -11,13 +11,14 @@ Synchronize observers inputs from synchronized apps (e.g. "Occtax").
## Launcher icons
-| Name | Flavor | Launcher icon |
-| -------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------- |
-| Default | _generic_ | ![PNX](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/main/res/mipmap-xhdpi/ic_launcher.png) |
-| [Parc National des Cévennes](http://www.cevennes-parcnational.fr) | _pnc_ | ![PNC](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pnc/res/mipmap-xhdpi/ic_launcher.png) |
-| [Parc National des Écrins](http://www.ecrins-parcnational.fr) | _pne_ | ![PNE](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pne/res/mipmap-xhdpi/ic_launcher.png) |
-| [Parc National du Mercantour](http://www.mercantour-parcnational.fr) | _pnm_ | ![PNE](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pnm/res/mipmap-xhdpi/ic_launcher.png) |
-| [Parc National de la Vanoise](http://www.vanoise-parcnational.fr) | _pnv_ | ![PNE](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pnv/res/mipmap-xhdpi/ic_launcher.png) |
+| Name | Flavor | Launcher icon |
+| -------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Default | _generic_ | ![PNX](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/main/res/mipmap-xhdpi/ic_launcher.png) ![PNX_debug](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/debug/res/mipmap-xhdpi/ic_launcher.png) |
+| [Parc amazonien de Guyane](https://www.parc-amazonien-guyane.fr) | _pag_ | ![PAG](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pag/res/mipmap-xhdpi/ic_launcher.png) ![PAG_debug](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pagDebug/res/mipmap-xhdpi/ic_launcher.png) |
+| [Parc national des Cévennes](http://www.cevennes-parcnational.fr) | _pnc_ | ![PNC](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pnc/res/mipmap-xhdpi/ic_launcher.png) ![PNC_debug](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pncDebug/res/mipmap-xhdpi/ic_launcher.png) |
+| [Parc national des Écrins](http://www.ecrins-parcnational.fr) | _pne_ | ![PNE](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pne/res/mipmap-xhdpi/ic_launcher.png) ![PNE_debug](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pneDebug/res/mipmap-xhdpi/ic_launcher.png) |
+| [Parc national du Mercantour](http://www.mercantour-parcnational.fr) | _pnm_ | ![PNM](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pnm/res/mipmap-xhdpi/ic_launcher.png) ![PNM_debug](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pnmDebug/res/mipmap-xhdpi/ic_launcher.png) |
+| [Parc national de la Vanoise](http://www.vanoise-parcnational.fr) | _pnv_ | ![PNV](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pnv/res/mipmap-xhdpi/ic_launcher.png) ![PNV_debug](https://raw.githubusercontent.com/PnX-SI/gn_mobile_core/develop/sync/src/pnvDebug/res/mipmap-xhdpi/ic_launcher.png) |
## Settings
@@ -37,17 +38,17 @@ Example:
### Parameters description
-| Parameter | UI | Description | Default value |
-| --------------------------------- | ------- | ------------------------------------------------------ | ------------- |
-| `geonature_url` | ☑ | GeoNature URL | |
-| `taxhub_url` | ☑ | TaxHub URL | |
-| `uh_application_id` | ☐ | GeoNature application ID in UsersHub | |
-| `observers_list_id` | ☐ | GeoNature selected observer list ID in UsersHub | |
-| `taxa_list_id` | ☐ | GeoNature selected taxa list ID | |
-| `code_area_type` | ☐ | GeoNature selected area type | |
-| `page_size` | ☐ | Default page size while fetching paginated values | 10000 |
-| `sync_periodicity_data_essential` | ☐ | Configure essential data synchronization periodicity | |
-| `sync_periodicity_data` | ☐ | Configure all data synchronization periodicity | |
+| Parameter | UI | Description | Default value |
+| --------------------------------- | ------- | ---------------------------------------------------- | ------------- |
+| `geonature_url` | ☑ | GeoNature URL | |
+| `taxhub_url` | ☑ | TaxHub URL | |
+| `uh_application_id` | ☐ | GeoNature application ID in UsersHub | |
+| `observers_list_id` | ☐ | GeoNature selected observer list ID in UsersHub | |
+| `taxa_list_id` | ☐ | GeoNature selected taxa list ID | |
+| `code_area_type` | ☐ | GeoNature selected area type | |
+| `page_size` | ☐ | Default page size while fetching paginated values | 10000 |
+| `sync_periodicity_data_essential` | ☐ | Configure essential data synchronization periodicity | |
+| `sync_periodicity_data` | ☐ | Configure all data synchronization periodicity | |
### Data synchronization periodicity
@@ -102,6 +103,9 @@ The authority of this content provider is `fr.geonature.sync.provider`.
| **\**/nomenclature_types/\*/default | String | Fetch all default nomenclature definitions from given application package ID (e.g. `fr.geonature.occtax`) |
| **\**/nomenclature_types/\*/items/\* | String, String | Fetch all nomenclature definitions from given type, matching a given kingdom |
| **\**/nomenclature_types/\*/items/\*/\* | String, String, String | Fetch all nomenclature definitions from given type, matching a given kingdom and group |
+| **\**/settings/\* | String | Fetch app settings JSON file |
+| **\**/inputs/\*/# | String, Number | Get input as JSON file from given package ID (e.g. `fr.geonature.occtax`) |
+| **\**/inputs/export | ContentValues | Export input data to JSON file |
## Full Build
diff --git a/sync/build.gradle b/sync/build.gradle
index 0c6993c7..58f1f2b7 100644
--- a/sync/build.gradle
+++ b/sync/build.gradle
@@ -2,10 +2,10 @@ apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: "kotlin-kapt"
-version = "1.2.1"
+version = "1.3.0"
android {
- compileSdkVersion 29
+ compileSdkVersion 30
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
@@ -19,7 +19,7 @@ android {
defaultConfig {
applicationId "fr.geonature.sync"
minSdkVersion 21
- targetSdkVersion 29
+ targetSdkVersion 30
versionCode updateVersionCode(module.name)
versionName version
buildConfigField "String", "BUILD_DATE", "\"" + new Date().getTime() + "\""
@@ -50,6 +50,7 @@ android {
flavorDimensions "version"
productFlavors {
generic {}
+ pag {}
pnc {}
pne {}
pnm {}
@@ -82,7 +83,8 @@ dependencies {
kapt 'androidx.room:room-compiler:2.3.0'
- testImplementation 'androidx.test:core:1.3.0'
+ testImplementation 'androidx.arch.core:core-testing:2.1.0'
+ testImplementation 'androidx.test:core:1.4.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:3.0.0'
testImplementation 'org.robolectric:robolectric:4.5.1'
diff --git a/sync/src/main/AndroidManifest.xml b/sync/src/main/AndroidManifest.xml
index 32f0907e..6714524a 100644
--- a/sync/src/main/AndroidManifest.xml
+++ b/sync/src/main/AndroidManifest.xml
@@ -9,7 +9,6 @@
-
+ android:readPermission="@string/permission_read"
+ android:writePermission="@string/permission_write" />
- ) {
- cookies
- .firstOrNull()
- ?.also {
- authManager.setCookie(it)
- }
- }
-
- override fun loadForRequest(url: HttpUrl): MutableList {
- return authManager
- .getCookie()
- ?.let {
- if (it.expiresAt() < System.currentTimeMillis()) {
- Log.i(
- TAG,
- "cookie expiry date ${it.expiresAt()} reached: perform logout"
- )
-
- GlobalScope.launch(Default) {
- authManager.logout()
- }
-
- return@let mutableListOf()
- }
-
- mutableListOf(it)
- }
- ?: mutableListOf()
- }
- })
- .connectTimeout(
- 120,
- TimeUnit.SECONDS
- )
- .readTimeout(
- 120,
- TimeUnit.SECONDS
- )
- .writeTimeout(
- 120,
- TimeUnit.SECONDS
- )
- .addInterceptor(loggingInterceptor)
- .build()
-
- geoNatureService = Retrofit
- .Builder()
- .baseUrl("$geoNatureBaseUrl/")
- .client(client)
- .addConverterFactory(
- GsonConverterFactory.create(
- GsonBuilder()
- .setDateFormat("yyyy-MM-dd HH:mm:ss")
- .create()
- )
- )
- .build()
- .create(GeoNatureService::class.java)
-
- taxHubService = Retrofit
- .Builder()
- .baseUrl("$taxHubBaseUrl/")
- .client(client)
- .addConverterFactory(
- GsonConverterFactory.create(
- GsonBuilder()
- .setDateFormat("yyyy-MM-dd HH:mm:ss")
- .create()
- )
- )
- .build()
- .create(TaxHubService::class.java)
- }
-
- suspend fun authLogin(authCredentials: AuthCredentials): Response {
- return geoNatureService.authLogin(authCredentials)
- }
-
- fun sendInput(
- module: String,
- input: JSONObject
- ): Call {
- return geoNatureService.sendInput(
- module,
- RequestBody.create(
- MediaType.parse("application/json; charset=utf-8"),
- input.toString()
- )
- )
- }
-
- fun getMetaDatasets(): Call {
- return geoNatureService.getMetaDatasets()
- }
-
- fun getUsers(menuId: Int): Call> {
- return geoNatureService.getUsers(menuId)
- }
-
- fun getTaxonomyRanks(): Call {
- return taxHubService.getTaxonomyRanks()
- }
-
- fun getTaxref(
- listId: Int,
- limit: Int? = null,
- offset: Int? = null
- ): Call> {
- return taxHubService.getTaxref(
- listId,
- limit,
- offset
- )
- }
-
- fun getTaxrefAreas(
- codeAreaType: String? = null,
- limit: Int? = null,
- offset: Int? = null
- ): Call> {
- return geoNatureService.getTaxrefAreas(
- codeAreaType,
- limit,
- offset
- )
- }
-
- fun getNomenclatures(): Call> {
- return geoNatureService.getNomenclatures()
- }
-
- fun getDefaultNomenclaturesValues(module: String): Call {
- return geoNatureService.getDefaultNomenclaturesValues(module)
- }
-
- fun getApplications(): Call> {
- return geoNatureService.getApplications()
- }
-
- fun downloadPackage(url: String): Call {
- return geoNatureService.downloadPackage(url)
- }
-
- companion object {
- private val TAG = GeoNatureAPIClient::class.java.name
-
- private val baseUrl: (String?) -> String? = { url ->
- url?.also { if (it.endsWith('/')) it.dropLast(1) }
- }
-
- fun instance(
- context: Context,
- geoNatureBaseUrl: String? = null,
- taxHubBaseUrl: String? = null
- ): GeoNatureAPIClient? {
- val sanitizeGeoNatureBaseUrl = baseUrl(if (geoNatureBaseUrl.isNullOrBlank()) getGeoNatureServerUrl(context) else geoNatureBaseUrl)
- val sanitizeTaxHubBaseUrl = baseUrl(if (taxHubBaseUrl.isNullOrBlank()) getTaxHubServerUrl(context) else taxHubBaseUrl)
-
- if (sanitizeGeoNatureBaseUrl.isNullOrBlank()) {
- Log.w(
- TAG,
- "No GeoNature server configured"
- )
-
- return null
- }
-
- if (sanitizeTaxHubBaseUrl.isNullOrBlank()) {
- Log.w(
- TAG,
- "No TaxHub server configured"
- )
-
- return null
- }
-
- return GeoNatureAPIClient(
- context,
- sanitizeGeoNatureBaseUrl,
- sanitizeTaxHubBaseUrl
- )
- }
- }
-}
diff --git a/sync/src/main/java/fr/geonature/sync/api/GeoNatureAPIClientImpl.kt b/sync/src/main/java/fr/geonature/sync/api/GeoNatureAPIClientImpl.kt
new file mode 100644
index 00000000..64cba606
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/api/GeoNatureAPIClientImpl.kt
@@ -0,0 +1,203 @@
+package fr.geonature.sync.api
+
+import android.content.Context
+import com.google.gson.GsonBuilder
+import fr.geonature.sync.api.model.AppPackage
+import fr.geonature.sync.api.model.AuthCredentials
+import fr.geonature.sync.api.model.AuthLogin
+import fr.geonature.sync.api.model.NomenclatureType
+import fr.geonature.sync.api.model.Taxref
+import fr.geonature.sync.api.model.TaxrefArea
+import fr.geonature.sync.api.model.User
+import fr.geonature.sync.auth.ICookieManager
+import fr.geonature.sync.util.SettingsUtils.getGeoNatureServerUrl
+import fr.geonature.sync.util.SettingsUtils.getTaxHubServerUrl
+import okhttp3.Cookie
+import okhttp3.CookieJar
+import okhttp3.HttpUrl
+import okhttp3.MediaType
+import okhttp3.OkHttpClient
+import okhttp3.RequestBody
+import okhttp3.ResponseBody
+import okhttp3.logging.HttpLoggingInterceptor
+import org.json.JSONObject
+import retrofit2.Call
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.concurrent.TimeUnit
+
+/**
+ * GeoNature API client.
+ *
+ * @author S. Grimault
+ */
+class GeoNatureAPIClientImpl(
+ private val applicationContext: Context,
+ private val cookieManager: ICookieManager
+) : IGeoNatureAPIClient {
+
+ private val client = OkHttpClient
+ .Builder()
+ .cookieJar(object : CookieJar {
+ override fun saveFromResponse(
+ url: HttpUrl,
+ cookies: MutableList
+ ) {
+ cookies
+ .firstOrNull()
+ ?.also {
+ cookieManager.cookie = it
+ }
+ }
+
+ override fun loadForRequest(url: HttpUrl): MutableList {
+ return cookieManager.cookie?.let {
+ mutableListOf(it)
+ }
+ ?: mutableListOf()
+ }
+ })
+ .connectTimeout(
+ 120,
+ TimeUnit.SECONDS
+ )
+ .readTimeout(
+ 120,
+ TimeUnit.SECONDS
+ )
+ .writeTimeout(
+ 120,
+ TimeUnit.SECONDS
+ )
+ .addInterceptor(HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BASIC
+ redactHeader("Authorization")
+ redactHeader("Cookie")
+ })
+ .build()
+
+ private val geoNatureService = {
+ geoNatureBaseUrl?.let {
+ Retrofit
+ .Builder()
+ .baseUrl("$it/")
+ .client(client)
+ .addConverterFactory(
+ GsonConverterFactory.create(
+ GsonBuilder()
+ .setDateFormat("yyyy-MM-dd HH:mm:ss")
+ .create()
+ )
+ )
+ .build()
+ .create(IGeoNatureService::class.java)
+ }
+ }
+
+ private var taxHubService = {
+ taxHubBaseUrl?.let {
+ Retrofit
+ .Builder()
+ .baseUrl("$it/")
+ .client(client)
+ .addConverterFactory(
+ GsonConverterFactory.create(
+ GsonBuilder()
+ .setDateFormat("yyyy-MM-dd HH:mm:ss")
+ .create()
+ )
+ )
+ .build()
+ .create(ITaxHubService::class.java)
+ }
+ }
+
+ override var geoNatureBaseUrl: String? = null
+ get() = baseUrl(if (field.isNullOrBlank()) getGeoNatureServerUrl(applicationContext) else field)
+
+ override var taxHubBaseUrl: String? = null
+ get() = baseUrl(if (field.isNullOrBlank()) getTaxHubServerUrl(applicationContext) else field)
+
+ override fun authLogin(authCredentials: AuthCredentials): Call? {
+ return geoNatureService()?.authLogin(authCredentials)
+ }
+
+ override fun logout() {
+ cookieManager.clearCookie()
+ }
+
+ override fun sendInput(
+ module: String,
+ input: JSONObject
+ ): Call? {
+ return geoNatureService()?.sendInput(
+ module,
+ RequestBody.create(
+ MediaType.parse("application/json; charset=utf-8"),
+ input.toString()
+ )
+ )
+ }
+
+ override fun getMetaDatasets(): Call? {
+ return geoNatureService()?.getMetaDatasets()
+ }
+
+ override fun getUsers(menuId: Int): Call>? {
+ return geoNatureService()?.getUsers(menuId)
+ }
+
+ override fun getTaxonomyRanks(): Call? {
+ return taxHubService()?.getTaxonomyRanks()
+ }
+
+ override fun getTaxref(
+ listId: Int,
+ limit: Int?,
+ offset: Int?
+ ): Call>? {
+ return taxHubService()?.getTaxref(
+ listId,
+ limit,
+ offset
+ )
+ }
+
+ override fun getTaxrefAreas(
+ codeAreaType: String?,
+ limit: Int?,
+ offset: Int?
+ ): Call>? {
+ return geoNatureService()?.getTaxrefAreas(
+ codeAreaType,
+ limit,
+ offset
+ )
+ }
+
+ override fun getNomenclatures(): Call>? {
+ return geoNatureService()?.getNomenclatures()
+ }
+
+ override fun getDefaultNomenclaturesValues(module: String): Call? {
+ return geoNatureService()?.getDefaultNomenclaturesValues(module)
+ }
+
+ override fun getApplications(): Call>? {
+ return geoNatureService()?.getApplications()
+ }
+
+ override fun downloadPackage(url: String): Call? {
+ return geoNatureService()?.downloadPackage(url)
+ }
+
+ override fun checkSettings(): Boolean {
+ return !(geoNatureBaseUrl.isNullOrBlank() || taxHubBaseUrl.isNullOrBlank())
+ }
+
+ companion object {
+ private val baseUrl: (String?) -> String? = { url ->
+ url?.also { if (it.endsWith('/')) it.dropLast(1) }
+ }
+ }
+}
diff --git a/sync/src/main/java/fr/geonature/sync/api/IGeoNatureAPIClient.kt b/sync/src/main/java/fr/geonature/sync/api/IGeoNatureAPIClient.kt
new file mode 100644
index 00000000..3ad456c6
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/api/IGeoNatureAPIClient.kt
@@ -0,0 +1,76 @@
+package fr.geonature.sync.api
+
+import fr.geonature.sync.api.model.AppPackage
+import fr.geonature.sync.api.model.AuthCredentials
+import fr.geonature.sync.api.model.AuthLogin
+import fr.geonature.sync.api.model.NomenclatureType
+import fr.geonature.sync.api.model.Taxref
+import fr.geonature.sync.api.model.TaxrefArea
+import fr.geonature.sync.api.model.User
+import okhttp3.ResponseBody
+import org.json.JSONObject
+import retrofit2.Call
+
+/**
+ * GeoNature API client.
+ *
+ * @author S. Grimault
+ */
+interface IGeoNatureAPIClient {
+
+ /**
+ * Base URL for GeoNature.
+ */
+ var geoNatureBaseUrl: String?
+
+ /**
+ * Base URL for TaxHub.
+ */
+ var taxHubBaseUrl: String?
+
+ fun authLogin(authCredentials: AuthCredentials): Call?
+
+ /**
+ * Performs logout.
+ */
+ fun logout()
+
+ fun sendInput(
+ module: String,
+ input: JSONObject
+ ): Call?
+
+ fun getMetaDatasets(): Call?
+
+ fun getUsers(menuId: Int): Call>?
+
+ fun getTaxonomyRanks(): Call?
+
+ fun getTaxref(
+ listId: Int,
+ limit: Int? = null,
+ offset: Int? = null
+ ): Call>?
+
+ fun getTaxrefAreas(
+ codeAreaType: String? = null,
+ limit: Int? = null,
+ offset: Int? = null
+ ): Call>?
+
+ fun getNomenclatures(): Call>?
+
+ fun getDefaultNomenclaturesValues(module: String): Call?
+
+ /**
+ * Gets all available applications from GeoNature.
+ */
+ fun getApplications(): Call>?
+
+ /**
+ * Downloads application package (APK) from GeoNature.
+ */
+ fun downloadPackage(url: String): Call?
+
+ fun checkSettings(): Boolean
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/api/GeoNatureService.kt b/sync/src/main/java/fr/geonature/sync/api/IGeoNatureService.kt
similarity index 79%
rename from sync/src/main/java/fr/geonature/sync/api/GeoNatureService.kt
rename to sync/src/main/java/fr/geonature/sync/api/IGeoNatureService.kt
index 77850f7c..eb24416f 100644
--- a/sync/src/main/java/fr/geonature/sync/api/GeoNatureService.kt
+++ b/sync/src/main/java/fr/geonature/sync/api/IGeoNatureService.kt
@@ -9,7 +9,6 @@ import fr.geonature.sync.api.model.User
import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.Call
-import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Headers
@@ -22,19 +21,18 @@ import retrofit2.http.Url
/**
* GeoNature API interface definition.
*
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ * @author S. Grimault
*/
-interface GeoNatureService {
+interface IGeoNatureService {
@Headers(
"Accept: application/json",
"Content-Type: application/json;charset=UTF-8"
)
@POST("api/auth/login")
- suspend fun authLogin(
- @Body
- authCredentials: AuthCredentials
- ): Response
+ fun authLogin(
+ @Body authCredentials: AuthCredentials
+ ): Call
@Headers(
"Accept: application/json",
@@ -42,21 +40,18 @@ interface GeoNatureService {
)
@POST("api/{module}/releve")
fun sendInput(
- @Path("module")
- module: String,
- @Body
- input: RequestBody
+ @Path("module") module: String,
+ @Body input: RequestBody
): Call
@Headers("Accept: application/json")
- @GET("api/meta/datasets")
+ @GET("api/meta/datasets?fields=modules")
fun getMetaDatasets(): Call
@Headers("Accept: application/json")
@GET("api/users/menu/{id}")
fun getUsers(
- @Path("id")
- menuId: Int
+ @Path("id") menuId: Int
): Call>
@Headers("Accept: application/json")
@@ -74,8 +69,7 @@ interface GeoNatureService {
@Headers("Accept: application/json")
@GET("api/{module}/defaultNomenclatures")
fun getDefaultNomenclaturesValues(
- @Path("module")
- module: String
+ @Path("module") module: String
): Call
@Headers("Accept: application/json")
@@ -84,5 +78,7 @@ interface GeoNatureService {
@Streaming
@GET
- fun downloadPackage(@Url url: String): Call
+ fun downloadPackage(
+ @Url url: String
+ ): Call
}
diff --git a/sync/src/main/java/fr/geonature/sync/api/TaxHubService.kt b/sync/src/main/java/fr/geonature/sync/api/ITaxHubService.kt
similarity index 88%
rename from sync/src/main/java/fr/geonature/sync/api/TaxHubService.kt
rename to sync/src/main/java/fr/geonature/sync/api/ITaxHubService.kt
index 5ef5248d..57dd1586 100644
--- a/sync/src/main/java/fr/geonature/sync/api/TaxHubService.kt
+++ b/sync/src/main/java/fr/geonature/sync/api/ITaxHubService.kt
@@ -11,9 +11,9 @@ import retrofit2.http.Query
/**
* TaxHub API interface definition.
*
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ * @author S. Grimault
*/
-interface TaxHubService {
+interface ITaxHubService {
@Headers("Accept: application/json")
@GET("api/taxref/regnewithgroupe2")
diff --git a/sync/src/main/java/fr/geonature/sync/auth/AuthFailure.kt b/sync/src/main/java/fr/geonature/sync/auth/AuthFailure.kt
new file mode 100644
index 00000000..a6bec878
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/auth/AuthFailure.kt
@@ -0,0 +1,11 @@
+package fr.geonature.sync.auth
+
+import fr.geonature.commons.fp.Failure
+import fr.geonature.sync.api.model.AuthLoginError
+
+/**
+ * Authentication failure.
+ *
+ * @author S. Grimault
+ */
+data class AuthFailure(val authLoginError: AuthLoginError) : Failure.FeatureFailure()
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/auth/AuthLoginViewModel.kt b/sync/src/main/java/fr/geonature/sync/auth/AuthLoginViewModel.kt
index 3d6aa8cc..56f4879b 100644
--- a/sync/src/main/java/fr/geonature/sync/auth/AuthLoginViewModel.kt
+++ b/sync/src/main/java/fr/geonature/sync/auth/AuthLoginViewModel.kt
@@ -1,8 +1,6 @@
package fr.geonature.sync.auth
import android.app.Application
-import android.content.Context
-import android.net.ConnectivityManager
import android.text.TextUtils
import androidx.annotation.StringRes
import androidx.lifecycle.AndroidViewModel
@@ -11,26 +9,22 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
-import com.google.gson.Gson
-import com.google.gson.reflect.TypeToken
+import fr.geonature.commons.fp.Failure
import fr.geonature.sync.R
-import fr.geonature.sync.api.GeoNatureAPIClient
-import fr.geonature.sync.api.model.AuthCredentials
+import fr.geonature.sync.api.IGeoNatureAPIClient
import fr.geonature.sync.api.model.AuthLogin
-import fr.geonature.sync.api.model.AuthLoginError
import kotlinx.coroutines.launch
-import retrofit2.Response
/**
* Login view model.
*
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ * @author S. Grimault
*/
-class AuthLoginViewModel(application: Application) : AndroidViewModel(application) {
-
- private val authManager: AuthManager = AuthManager.getInstance(application)
- private val geoNatureAPIClient: GeoNatureAPIClient? = GeoNatureAPIClient.instance(application)
- private val connectivityManager = application.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+class AuthLoginViewModel(
+ application: Application,
+ private val authManager: IAuthManager,
+ private val geoNatureAPIClient: IGeoNatureAPIClient
+) : AndroidViewModel(application) {
private val _loginFormState = MutableLiveData()
val loginFormState: LiveData = _loginFormState
@@ -41,7 +35,7 @@ class AuthLoginViewModel(application: Application) : AndroidViewModel(applicatio
val isLoggedIn: LiveData = authManager.isLoggedIn
init {
- if (geoNatureAPIClient == null) {
+ if (!geoNatureAPIClient.checkSettings()) {
_loginResult.value = LoginResult(error = R.string.login_failed_server_url_configuration)
_loginFormState.value = LoginFormState(
isValid = false,
@@ -55,7 +49,8 @@ class AuthLoginViewModel(application: Application) : AndroidViewModel(applicatio
val authLoginLiveData = MutableLiveData()
viewModelScope.launch {
- authLoginLiveData.postValue(authManager.getAuthLogin())
+ val authLogin = authManager.getAuthLogin()
+ authLoginLiveData.postValue(authLogin)
}
return authLoginLiveData
@@ -66,52 +61,36 @@ class AuthLoginViewModel(application: Application) : AndroidViewModel(applicatio
password: String,
applicationId: Int
) {
- if (geoNatureAPIClient == null) {
+ if (!geoNatureAPIClient.checkSettings()) {
_loginResult.value = LoginResult(error = R.string.login_failed_server_url_configuration)
return
}
viewModelScope.launch {
- try {
- val authLoginResponse = geoNatureAPIClient.authLogin(
- AuthCredentials(
- username,
- password,
- applicationId
- )
- )
-
- if (!authLoginResponse.isSuccessful) {
- val authLoginError = buildErrorResponse(authLoginResponse)
-
- _loginResult.value = if (authLoginError != null) {
- when (authLoginError.type) {
+ val authLogin = authManager.login(
+ username,
+ password,
+ applicationId
+ )
+
+ _loginResult.value = authLogin.fold({
+ when (it) {
+ is AuthFailure -> {
+ when (it.authLoginError.type) {
"login" -> LoginResult(error = R.string.login_failed_login)
"password" -> LoginResult(error = R.string.login_failed_password)
else -> LoginResult(error = R.string.login_failed)
}
- } else {
- LoginResult(error = R.string.login_failed)
}
-
- return@launch
- }
-
- val authLogin = authLoginResponse.body()
-
- if (authLogin == null) {
- _loginResult.value = LoginResult(error = R.string.login_failed)
- return@launch
- }
-
- authManager
- .setAuthLogin(authLogin)
- .also {
- _loginResult.value = LoginResult(success = authLogin)
+ is Failure.NetworkFailure -> {
+ LoginResult(error = R.string.snackbar_network_lost)
}
- } catch (e: Exception) {
- _loginResult.value = LoginResult(error = if (connectivityManager.allNetworks.isEmpty()) R.string.snackbar_network_lost else R.string.login_failed)
- }
+ else -> LoginResult(error = R.string.login_failed)
+ }
+ },
+ {
+ LoginResult(success = it)
+ }) as LoginResult
}
}
@@ -137,13 +116,14 @@ class AuthLoginViewModel(application: Application) : AndroidViewModel(applicatio
}
fun logout(): LiveData {
- val disconnected = MutableLiveData()
+ val disconnectedLiveData = MutableLiveData()
viewModelScope.launch {
- disconnected.value = authManager.logout()
+ val disconnected = authManager.logout()
+ disconnectedLiveData.value = disconnected
}
- return disconnected
+ return disconnectedLiveData
}
// A placeholder username validation check
@@ -156,17 +136,6 @@ class AuthLoginViewModel(application: Application) : AndroidViewModel(applicatio
return !TextUtils.isEmpty(password)
}
- private fun buildErrorResponse(response: Response): AuthLoginError? {
- val type = object : TypeToken() {}.type
-
- return Gson().fromJson(
- response
- .errorBody()!!
- .charStream(),
- type
- )
- }
-
/**
* Data validation state of the login form.
*
diff --git a/sync/src/main/java/fr/geonature/sync/auth/AuthManager.kt b/sync/src/main/java/fr/geonature/sync/auth/AuthManager.kt
deleted file mode 100644
index 986c64cc..00000000
--- a/sync/src/main/java/fr/geonature/sync/auth/AuthManager.kt
+++ /dev/null
@@ -1,170 +0,0 @@
-package fr.geonature.sync.auth
-
-import android.content.Context
-import android.content.SharedPreferences
-import android.util.Log
-import androidx.core.app.NotificationManagerCompat
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.Transformations
-import androidx.preference.PreferenceManager
-import fr.geonature.sync.api.model.AuthLogin
-import fr.geonature.sync.auth.io.AuthLoginJsonReader
-import fr.geonature.sync.auth.io.AuthLoginJsonWriter
-import fr.geonature.sync.auth.io.CookieHelper
-import fr.geonature.sync.sync.worker.CheckAuthLoginWorker
-import kotlinx.coroutines.Dispatchers.Default
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import okhttp3.Cookie
-import java.util.Calendar
-
-/**
- * [AuthLogin] manager.
- *
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
- */
-class AuthManager private constructor(applicationContext: Context) {
-
- internal val preferenceManager: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
- private val notificationManager = NotificationManagerCompat.from(applicationContext)
- private val authLoginJsonReader = AuthLoginJsonReader()
- private val authLoginJsonWriter = AuthLoginJsonWriter()
-
- private val _authLogin = MutableLiveData()
- private var cookie: Cookie? = null
-
- val isLoggedIn: LiveData = Transformations.map(_authLogin) { it != null }
-
- init {
- GlobalScope.launch(Default) {
- getAuthLogin()
- }
- }
-
- fun setCookie(cookie: Cookie) {
- this.cookie = cookie
-
- preferenceManager
- .edit()
- .putString(
- KEY_PREFERENCE_COOKIE,
- CookieHelper.serialize(cookie)
- )
- .apply()
- }
-
- fun getCookie(): Cookie? {
- return cookie
- ?: runCatching {
- preferenceManager
- .getString(
- KEY_PREFERENCE_COOKIE,
- null
- )
- ?.let { CookieHelper.deserialize(it) }
- }.getOrNull()
- }
-
- suspend fun getAuthLogin(): AuthLogin? =
- withContext(Default) {
- val authLogin = _authLogin.value
-
- if (authLogin != null) return@withContext authLogin
-
- val authLoginAsJson = preferenceManager.getString(
- KEY_PREFERENCE_AUTH_LOGIN,
- null
- )
-
- if (authLoginAsJson.isNullOrBlank()) {
- _authLogin.postValue(null)
- return@withContext null
- }
-
- authLoginJsonReader
- .read(authLoginAsJson)
- .let {
- if (it?.expires?.before(Calendar.getInstance().time) == true) {
- Log.i(
- TAG,
- "auth login expiry date ${it.expires} reached: perform logout"
- )
-
- logout()
- return@let null
- }
-
- _authLogin.postValue(it)
- it
- }
- }
-
- suspend fun setAuthLogin(authLogin: AuthLogin): Boolean {
- Log.i(
- TAG,
- "successfully authenticated, login expiration date: ${authLogin.expires}"
- )
-
- _authLogin.value = authLogin
-
- return withContext(Default) {
-
- val authLoginAsJson = authLoginJsonWriter.write(authLogin)
-
- if (authLoginAsJson.isNullOrBlank()) {
- _authLogin.postValue(null)
- return@withContext false
- }
-
- notificationManager.cancel(CheckAuthLoginWorker.NOTIFICATION_ID)
-
- preferenceManager
- .edit()
- .putString(
- KEY_PREFERENCE_AUTH_LOGIN,
- authLoginAsJson
- )
- .commit()
- }
- }
-
- suspend fun logout(): Boolean =
- withContext(Default) {
- preferenceManager
- .edit()
- .remove(KEY_PREFERENCE_COOKIE)
- .remove(KEY_PREFERENCE_AUTH_LOGIN)
- .commit()
- .also {
- if (it) {
- _authLogin.postValue(null)
- }
- }
- }
-
- companion object {
- private val TAG = AuthManager::class.java.name
-
- private const val KEY_PREFERENCE_COOKIE = "key_preference_cookie"
- private const val KEY_PREFERENCE_AUTH_LOGIN = "key_preference_auth_login"
-
- @Volatile
- private var INSTANCE: AuthManager? = null
-
- /** Gets the singleton instance of [AuthManager].
- *
- * @param applicationContext The main application context.
- *
- * @return The singleton instance of [AuthManager].
- */
- @Suppress("UNCHECKED_CAST")
- fun getInstance(applicationContext: Context): AuthManager =
- INSTANCE
- ?: synchronized(this) {
- INSTANCE
- ?: AuthManager(applicationContext).also { INSTANCE = it }
- }
- }
-}
diff --git a/sync/src/main/java/fr/geonature/sync/auth/AuthManagerImpl.kt b/sync/src/main/java/fr/geonature/sync/auth/AuthManagerImpl.kt
new file mode 100644
index 00000000..2a107284
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/auth/AuthManagerImpl.kt
@@ -0,0 +1,214 @@
+package fr.geonature.sync.auth
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Log
+import androidx.core.app.NotificationManagerCompat
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.preference.PreferenceManager
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import fr.geonature.commons.fp.Either
+import fr.geonature.commons.fp.Failure
+import fr.geonature.commons.fp.getOrElse
+import fr.geonature.commons.util.NetworkHandler
+import fr.geonature.sync.api.IGeoNatureAPIClient
+import fr.geonature.sync.api.model.AuthCredentials
+import fr.geonature.sync.api.model.AuthLogin
+import fr.geonature.sync.api.model.AuthLoginError
+import fr.geonature.sync.auth.io.AuthLoginJsonReader
+import fr.geonature.sync.auth.io.AuthLoginJsonWriter
+import fr.geonature.sync.sync.worker.CheckAuthLoginWorker
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import retrofit2.Response
+import java.util.Calendar
+
+/**
+ * Default implementation of [IAuthManager].
+ *
+ * @author S. Grimault
+ */
+class AuthManagerImpl(
+ applicationContext: Context,
+ private val geoNatureAPIClient: IGeoNatureAPIClient,
+ private val networkHandler: NetworkHandler
+) : IAuthManager {
+
+ private val preferenceManager: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
+ private val notificationManager = NotificationManagerCompat.from(applicationContext)
+ private val authLoginJsonReader = AuthLoginJsonReader()
+ private val authLoginJsonWriter = AuthLoginJsonWriter()
+
+ private var authLogin: AuthLogin? = null
+ set(value) {
+ field = value
+ _isLoggedIn.postValue(value != null)
+ }
+
+ private val _isLoggedIn: MutableLiveData = MutableLiveData(false)
+ override val isLoggedIn: LiveData = _isLoggedIn
+
+ override suspend fun getAuthLogin(): AuthLogin? =
+ withContext(Dispatchers.Default) {
+ val authLogin = this@AuthManagerImpl.authLogin
+
+ if (authLogin != null) {
+ if (!checkSessionValidity(authLogin)) {
+ this@AuthManagerImpl.authLogin = null
+ return@withContext null
+ }
+
+ return@withContext authLogin
+ }
+
+ val authLoginAsJson = preferenceManager.getString(
+ KEY_PREFERENCE_AUTH_LOGIN,
+ null
+ )
+
+ if (authLoginAsJson.isNullOrBlank()) {
+ this@AuthManagerImpl.authLogin = null
+ return@withContext null
+ }
+
+ authLoginJsonReader
+ .read(authLoginAsJson)
+ .let {
+ if (it == null) {
+ this@AuthManagerImpl.authLogin = null
+ return@let it
+ }
+
+ if (!checkSessionValidity(it)) {
+ this@AuthManagerImpl.authLogin = null
+ return@let null
+ }
+
+ this@AuthManagerImpl.authLogin = it
+ it
+ }
+ }
+
+ override suspend fun login(
+ username: String,
+ password: String,
+ applicationId: Int
+ ): Either {
+ if (!networkHandler.isNetworkAvailable()) {
+ return Either.Left(Failure.NetworkFailure)
+ }
+
+ // perform login from backend
+ val authLoginResponse = withContext(IO) {
+ val authLoginResponse = runCatching {
+ geoNatureAPIClient
+ .authLogin(
+ AuthCredentials(
+ username,
+ password,
+ applicationId
+ )
+ )
+ ?.execute()
+ }
+ .map { response ->
+ if (response?.isSuccessful == true) response
+ .body()
+ ?.let { Either.Right(it) }
+ else response?.let { buildAuthLoginErrorResponse(it)?.let { authLoginError -> Either.Left(AuthFailure(authLoginError)) } }
+ }
+ .getOrNull()
+ ?: return@withContext Either.Left(Failure.ServerFailure)
+
+ authLoginResponse
+ }
+
+ val authLogin = authLoginResponse.getOrElse(null)
+ ?: return authLoginResponse
+
+ val authLoginAsJson = withContext(Dispatchers.Default) { authLoginJsonWriter.write(authLogin) }
+
+ if (authLoginAsJson.isNullOrBlank()) {
+ this@AuthManagerImpl.authLogin = null
+ return Either.Left(Failure.ServerFailure)
+ }
+
+ Log.i(
+ TAG,
+ "successfully authenticated, login expiration date: ${authLogin.expires}"
+ )
+
+ this.authLogin = authLogin
+
+ notificationManager.cancel(CheckAuthLoginWorker.NOTIFICATION_ID)
+
+ return withContext(Dispatchers.Default) {
+ preferenceManager
+ .edit()
+ .putString(
+ KEY_PREFERENCE_AUTH_LOGIN,
+ authLoginAsJson
+ )
+ .commit()
+ .let {
+ authLoginResponse
+ }
+ }
+ }
+
+ override suspend fun logout() =
+ withContext(Dispatchers.Default) {
+ geoNatureAPIClient.logout()
+ preferenceManager
+ .edit()
+ .remove(KEY_PREFERENCE_AUTH_LOGIN)
+ .commit()
+ .also {
+ if (it) {
+ authLogin = null
+ }
+ }
+ }
+
+ private fun checkSessionValidity(authLogin: AuthLogin): Boolean {
+ if (authLogin.expires.before(Calendar.getInstance().time)) {
+ Log.i(
+ TAG,
+ "auth login expiry date ${authLogin.expires} reached: perform logout"
+ )
+
+ GlobalScope.launch(Dispatchers.Default) {
+ logout()
+ }
+
+ return false
+ }
+
+ return true
+ }
+
+ private fun buildAuthLoginErrorResponse(response: Response): AuthLoginError? {
+ val responseErrorBody = response.errorBody()
+ ?: return null
+
+ val type = object : TypeToken() {}.type
+
+ return runCatching {
+ Gson().fromJson(
+ responseErrorBody.charStream(),
+ type
+ )
+ }.getOrNull()
+ }
+
+ companion object {
+ private val TAG = AuthManagerImpl::class.java.name
+
+ private const val KEY_PREFERENCE_AUTH_LOGIN = "key_preference_auth_login"
+ }
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/auth/CookieManagerImpl.kt b/sync/src/main/java/fr/geonature/sync/auth/CookieManagerImpl.kt
new file mode 100644
index 00000000..06e44f61
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/auth/CookieManagerImpl.kt
@@ -0,0 +1,79 @@
+package fr.geonature.sync.auth
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Log
+import androidx.preference.PreferenceManager
+import fr.geonature.sync.auth.io.CookieHelper
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import okhttp3.Cookie
+
+/**
+ * Default implementation of [ICookieManager].
+ *
+ * @author S. Grimault
+ */
+class CookieManagerImpl(applicationContext: Context) : ICookieManager {
+ private val preferenceManager: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
+
+ override var cookie: Cookie? = null
+ get() {
+ val cookie = field
+
+ if (cookie != null) {
+ if (cookie.expiresAt() < System.currentTimeMillis()) {
+ Log.i(
+ TAG,
+ "cookie expiry date ${cookie.expiresAt()} reached: perform logout"
+ )
+
+ GlobalScope.launch(Dispatchers.Default) {
+ clearCookie()
+ }
+
+ return null
+ }
+
+ return cookie
+ }
+
+ return preferenceManager
+ .getString(
+ KEY_PREFERENCE_COOKIE,
+ null
+ )
+ ?.let { CookieHelper.deserialize(it) }
+ }
+ set(value) {
+ field = value
+
+ if (value == null) {
+ preferenceManager
+ .edit()
+ .remove(KEY_PREFERENCE_COOKIE)
+ .apply()
+
+ return
+ }
+
+ preferenceManager
+ .edit()
+ .putString(
+ KEY_PREFERENCE_COOKIE,
+ CookieHelper.serialize(value)
+ )
+ .apply()
+ }
+
+ override fun clearCookie() {
+ cookie = null
+ }
+
+ companion object {
+ private val TAG = CookieManagerImpl::class.java.name
+
+ private const val KEY_PREFERENCE_COOKIE = "key_preference_cookie"
+ }
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/auth/IAuthManager.kt b/sync/src/main/java/fr/geonature/sync/auth/IAuthManager.kt
new file mode 100644
index 00000000..c48ce088
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/auth/IAuthManager.kt
@@ -0,0 +1,38 @@
+package fr.geonature.sync.auth
+
+import androidx.lifecycle.LiveData
+import fr.geonature.commons.fp.Either
+import fr.geonature.commons.fp.Failure
+import fr.geonature.sync.api.model.AuthLogin
+
+/**
+ * [AuthLogin] manager.
+ *
+ * @author S. Grimault
+ */
+interface IAuthManager {
+
+ /**
+ * Check if the current session is still valid.
+ */
+ val isLoggedIn: LiveData
+
+ /**
+ * Gets the logged in user.
+ */
+ suspend fun getAuthLogin(): AuthLogin?
+
+ /**
+ * Performs authentication.
+ */
+ suspend fun login(
+ username: String,
+ password: String,
+ applicationId: Int
+ ): Either
+
+ /**
+ * Clears the current session.
+ */
+ suspend fun logout(): Boolean
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/auth/ICookieManager.kt b/sync/src/main/java/fr/geonature/sync/auth/ICookieManager.kt
new file mode 100644
index 00000000..538043a6
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/auth/ICookieManager.kt
@@ -0,0 +1,14 @@
+package fr.geonature.sync.auth
+
+import okhttp3.Cookie
+
+/**
+ * Manages cookie issued by GeoNature.
+ *
+ * @author S. Grimault
+ */
+interface ICookieManager {
+ var cookie: Cookie?
+
+ fun clearCookie()
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/data/MainContentProvider.kt b/sync/src/main/java/fr/geonature/sync/data/MainContentProvider.kt
index 9a4cd1be..67ae7e57 100644
--- a/sync/src/main/java/fr/geonature/sync/data/MainContentProvider.kt
+++ b/sync/src/main/java/fr/geonature/sync/data/MainContentProvider.kt
@@ -6,6 +6,7 @@ import android.content.Context
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
+import android.os.ParcelFileDescriptor
import fr.geonature.commons.data.AppSync
import fr.geonature.commons.data.Dataset
import fr.geonature.commons.data.DefaultNomenclature
@@ -15,9 +16,13 @@ import fr.geonature.commons.data.NomenclatureType
import fr.geonature.commons.data.Taxon
import fr.geonature.commons.data.Taxonomy
import fr.geonature.commons.data.helper.Provider.AUTHORITY
-import fr.geonature.commons.data.helper.Provider.checkReadPermission
+import fr.geonature.mountpoint.model.MountPoint
+import fr.geonature.mountpoint.util.FileUtils
import fr.geonature.sync.data.dao.AppSyncDao
+import fr.geonature.sync.data.dao.InputDao
import fr.geonature.sync.data.dao.TaxonDao
+import java.io.File
+import java.io.FileNotFoundException
/**
* Default ContentProvider implementation.
@@ -55,15 +60,8 @@ class MainContentProvider : ContentProvider() {
selectionArgs: Array?,
sortOrder: String?
): Cursor? {
- val context = context ?: return null
-
- if (!checkReadPermission(
- context,
- readPermission
- )
- ) {
- throw SecurityException("Permission denial: require READ permission")
- }
+ val context = context
+ ?: return null
return when (MATCHER.match(uri)) {
APP_SYNC_ID -> appSyncByPackageIdQuery(
@@ -125,7 +123,70 @@ class MainContentProvider : ContentProvider() {
context,
uri
)
- else -> throw IllegalArgumentException("Unknown URI: $uri")
+ else -> throw IllegalArgumentException("Unknown URI (query): $uri")
+ }
+ }
+
+ override fun openFile(
+ uri: Uri,
+ mode: String
+ ): ParcelFileDescriptor? {
+ val context = context
+ ?: return null
+
+ return when (MATCHER.match(uri)) {
+ SETTINGS -> {
+ val filename = uri.lastPathSegment
+
+ if (filename.isNullOrEmpty()) {
+ throw IllegalArgumentException("Missing filename")
+ }
+
+ val file = File(
+ FileUtils.getRootFolder(
+ context,
+ MountPoint.StorageType.INTERNAL
+ ),
+ filename
+ )
+
+ if (!file.exists()) {
+ throw FileNotFoundException("No file found at $uri")
+ }
+
+ ParcelFileDescriptor.open(
+ file,
+ ParcelFileDescriptor.MODE_READ_ONLY
+ )
+ }
+ INPUT_ID -> {
+ val packageId = uri.pathSegments
+ .drop(uri.pathSegments.indexOf("inputs") + 1)
+ .take(1)
+ .firstOrNull()
+
+ if (packageId.isNullOrEmpty()) {
+ throw IllegalArgumentException("Missing package ID from URI '$uri'")
+ }
+
+ val inputId = uri.lastPathSegment?.toLongOrNull()
+ ?: throw IllegalArgumentException("Missing input ID from URI '$uri'")
+
+ val file = InputDao(context).getExportedInput(
+ packageId,
+ inputId
+ )
+
+ if (!file.exists()) {
+ throw FileNotFoundException("No input file found at $uri")
+ }
+
+ ParcelFileDescriptor.open(
+ file,
+ ParcelFileDescriptor.MODE_READ_ONLY
+ )
+ }
+ else -> throw IllegalArgumentException("Unknown URI (openFile): $uri")
}
}
@@ -133,7 +194,19 @@ class MainContentProvider : ContentProvider() {
uri: Uri,
values: ContentValues?
): Uri? {
- throw NotImplementedError("'insert' operation not implemented")
+ val context = context
+ ?: return null
+
+ return when (MATCHER.match(uri)) {
+ INPUTS_EXPORT -> {
+ if (values == null) {
+ throw IllegalArgumentException("Missing ContentValues")
+ }
+
+ InputDao(context).exportInput(values)
+ }
+ else -> throw IllegalArgumentException("Unknown URI (insert): $uri")
+ }
}
override fun update(
@@ -167,16 +240,16 @@ class MainContentProvider : ContentProvider() {
context: Context,
uri: Uri
): Cursor {
- val module =
- uri.pathSegments
- .drop(uri.pathSegments.indexOf(Dataset.TABLE_NAME) + 1)
- .take(1)
- .firstOrNull()
- ?.substringAfterLast(".")
+ val module = uri.pathSegments
+ .drop(uri.pathSegments.indexOf(Dataset.TABLE_NAME) + 1)
+ .take(1)
+ .firstOrNull()
+ ?.substringAfterLast(".")
val onlyActive = uri.lastPathSegment == "active"
- return LocalDatabase.getInstance(context)
+ return LocalDatabase
+ .getInstance(context)
.datasetDao()
.QB()
.whereModule(module)
@@ -192,14 +265,14 @@ class MainContentProvider : ContentProvider() {
context: Context,
uri: Uri
): Cursor {
- val module =
- uri.pathSegments
- .drop(uri.pathSegments.indexOf(Dataset.TABLE_NAME) + 1)
- .take(1)
- .firstOrNull()
- ?.substringAfterLast(".")
-
- return LocalDatabase.getInstance(context)
+ val module = uri.pathSegments
+ .drop(uri.pathSegments.indexOf(Dataset.TABLE_NAME) + 1)
+ .take(1)
+ .firstOrNull()
+ ?.substringAfterLast(".")
+
+ return LocalDatabase
+ .getInstance(context)
.datasetDao()
.QB()
.whereModule(module)
@@ -212,12 +285,16 @@ class MainContentProvider : ContentProvider() {
selection: String?,
selectionArgs: Array?
): Cursor {
- return LocalDatabase.getInstance(context)
+ return LocalDatabase
+ .getInstance(context)
.inputObserverDao()
.QB()
.whereSelection(
selection,
- arrayOf(*selectionArgs ?: emptyArray())
+ arrayOf(
+ *selectionArgs
+ ?: emptyArray()
+ )
)
.cursor()
}
@@ -226,12 +303,12 @@ class MainContentProvider : ContentProvider() {
context: Context,
uri: Uri
): Cursor {
- val selectedObserverIds =
- uri.lastPathSegment?.split(",")
- ?.mapNotNull { it.toLongOrNull() }
- ?.distinct()
- ?.toLongArray()
- ?: longArrayOf()
+ val selectedObserverIds = uri.lastPathSegment
+ ?.split(",")
+ ?.mapNotNull { it.toLongOrNull() }
+ ?.distinct()
+ ?.toLongArray()
+ ?: longArrayOf()
if (selectedObserverIds.size == 1) {
return inputObserverByIdQuery(
@@ -240,7 +317,8 @@ class MainContentProvider : ContentProvider() {
)
}
- return LocalDatabase.getInstance(context)
+ return LocalDatabase
+ .getInstance(context)
.inputObserverDao()
.QB()
.whereIdsIn(*selectedObserverIds)
@@ -251,7 +329,8 @@ class MainContentProvider : ContentProvider() {
context: Context,
uri: Uri
): Cursor {
- return LocalDatabase.getInstance(context)
+ return LocalDatabase
+ .getInstance(context)
.inputObserverDao()
.QB()
.whereId(uri.lastPathSegment?.toLongOrNull())
@@ -262,12 +341,12 @@ class MainContentProvider : ContentProvider() {
context: Context,
uri: Uri
): Cursor {
- val lastPathSegments =
- uri.pathSegments
- .drop(uri.pathSegments.indexOf(Taxonomy.TABLE_NAME) + 1)
- .take(2)
+ val lastPathSegments = uri.pathSegments
+ .drop(uri.pathSegments.indexOf(Taxonomy.TABLE_NAME) + 1)
+ .take(2)
- return LocalDatabase.getInstance(context)
+ return LocalDatabase
+ .getInstance(context)
.taxonomyDao()
.QB()
.also {
@@ -289,12 +368,16 @@ class MainContentProvider : ContentProvider() {
selectionArgs: Array?,
sortOrder: String?
): Cursor {
- return LocalDatabase.getInstance(context)
+ return LocalDatabase
+ .getInstance(context)
.taxonDao()
.QB()
.whereSelection(
selection,
- arrayOf(*selectionArgs ?: emptyArray())
+ arrayOf(
+ *selectionArgs
+ ?: emptyArray()
+ )
)
.also {
if (sortOrder.isNullOrEmpty()) {
@@ -310,7 +393,8 @@ class MainContentProvider : ContentProvider() {
context: Context,
uri: Uri
): Cursor {
- return LocalDatabase.getInstance(context)
+ return LocalDatabase
+ .getInstance(context)
.taxonDao()
.QB()
.whereId(uri.lastPathSegment?.toLongOrNull())
@@ -326,13 +410,17 @@ class MainContentProvider : ContentProvider() {
): Cursor {
val filterOnArea = uri.lastPathSegment?.toLongOrNull()
- return LocalDatabase.getInstance(context)
+ return LocalDatabase
+ .getInstance(context)
.taxonDao()
.QB()
.withArea(filterOnArea)
.whereSelection(
selection,
- arrayOf(*selectionArgs ?: emptyArray())
+ arrayOf(
+ *selectionArgs
+ ?: emptyArray()
+ )
)
.also {
if (sortOrder.isNullOrEmpty()) {
@@ -355,7 +443,8 @@ class MainContentProvider : ContentProvider() {
.filterNotNull()
.firstOrNull()
- return LocalDatabase.getInstance(context)
+ return LocalDatabase
+ .getInstance(context)
.taxonDao()
.QB()
.withArea(filterOnArea)
@@ -364,7 +453,8 @@ class MainContentProvider : ContentProvider() {
}
private fun nomenclatureTypesQuery(context: Context): Cursor {
- return LocalDatabase.getInstance(context)
+ return LocalDatabase
+ .getInstance(context)
.nomenclatureTypeDao()
.QB()
.cursor()
@@ -374,14 +464,14 @@ class MainContentProvider : ContentProvider() {
context: Context,
uri: Uri
): Cursor {
- val module =
- uri.pathSegments
- .drop(uri.pathSegments.indexOf(NomenclatureType.TABLE_NAME) + 1)
- .take(1)
- .firstOrNull()
- ?.substringAfterLast(".")
-
- return LocalDatabase.getInstance(context)
+ val module = uri.pathSegments
+ .drop(uri.pathSegments.indexOf(NomenclatureType.TABLE_NAME) + 1)
+ .take(1)
+ .firstOrNull()
+ ?.substringAfterLast(".")
+
+ return LocalDatabase
+ .getInstance(context)
.nomenclatureDao()
.QB()
.withNomenclatureType()
@@ -393,16 +483,16 @@ class MainContentProvider : ContentProvider() {
context: Context,
uri: Uri
): Cursor {
- val mnemonic =
- uri.pathSegments
- .drop(uri.pathSegments.indexOf(NomenclatureType.TABLE_NAME) + 1)
- .take(1)
- .firstOrNull()
+ val mnemonic = uri.pathSegments
+ .drop(uri.pathSegments.indexOf(NomenclatureType.TABLE_NAME) + 1)
+ .take(1)
+ .firstOrNull()
val lastPathSegments = uri.pathSegments
.drop(uri.pathSegments.indexOf("items") + 1)
.take(2)
- return LocalDatabase.getInstance(context)
+ return LocalDatabase
+ .getInstance(context)
.nomenclatureDao()
.QB()
.withNomenclatureType(mnemonic)
@@ -434,6 +524,9 @@ class MainContentProvider : ContentProvider() {
const val NOMENCLATURE_TYPES_DEFAULT = 51
const val NOMENCLATURE_ITEMS_TAXONOMY_KINGDOM = 52
const val NOMENCLATURE_ITEMS_TAXONOMY_KINGDOM_GROUP = 53
+ const val SETTINGS = 60
+ const val INPUTS_EXPORT = 70
+ const val INPUT_ID = 71
const val VND_TYPE_DIR_PREFIX = "vnd.android.cursor.dir"
const val VND_TYPE_ITEM_PREFIX = "vnd.android.cursor.item"
@@ -533,6 +626,21 @@ class MainContentProvider : ContentProvider() {
"${NomenclatureType.TABLE_NAME}/*/items/*/*",
NOMENCLATURE_ITEMS_TAXONOMY_KINGDOM_GROUP
)
+ addURI(
+ AUTHORITY,
+ "settings/*",
+ SETTINGS
+ )
+ addURI(
+ AUTHORITY,
+ "inputs/export",
+ INPUTS_EXPORT
+ )
+ addURI(
+ AUTHORITY,
+ "inputs/*/#",
+ INPUT_ID
+ )
}
}
}
diff --git a/sync/src/main/java/fr/geonature/sync/data/dao/AppSyncDao.kt b/sync/src/main/java/fr/geonature/sync/data/dao/AppSyncDao.kt
index 8a2df848..0e06f1b5 100644
--- a/sync/src/main/java/fr/geonature/sync/data/dao/AppSyncDao.kt
+++ b/sync/src/main/java/fr/geonature/sync/data/dao/AppSyncDao.kt
@@ -8,8 +8,6 @@ import androidx.preference.PreferenceManager
import fr.geonature.commons.data.AppSync
import fr.geonature.commons.data.helper.Converters.dateToTimestamp
import fr.geonature.commons.data.helper.Converters.fromTimestamp
-import fr.geonature.commons.util.getInputsFolder
-import fr.geonature.mountpoint.util.FileUtils
import java.util.Date
/**
@@ -17,9 +15,10 @@ import java.util.Date
*
* @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
*/
-class AppSyncDao(private val context: Context) {
+class AppSyncDao(context: Context) {
private val sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
+ private val inputDao: InputDao = InputDao(context)
fun findByPackageId(packageId: String?): Cursor {
val cursor = MatrixCursor(AppSync
@@ -33,7 +32,7 @@ class AppSyncDao(private val context: Context) {
packageId,
dateToTimestamp(getLastSynchronizedDate()),
dateToTimestamp(getLastEssentialSynchronizedDate()),
- countInputsToSynchronize(packageId)
+ inputDao.countInputsToSynchronize(packageId)
)
cursor.addRow(values)
@@ -76,17 +75,6 @@ class AppSyncDao(private val context: Context) {
.run { fromTimestamp(this) }
}
- private fun countInputsToSynchronize(packageId: String): Number {
- return FileUtils
- .getInputsFolder(
- context,
- packageId
- )
- .walkTopDown()
- .filter { it.isFile && it.extension == "json" && it.canRead() }
- .count()
- }
-
private fun buildLastSynchronizedDatePreferenceKey(complete: Boolean = true): String {
return "sync.${if (complete) AppSync.COLUMN_LAST_SYNC else AppSync.COLUMN_LAST_SYNC_ESSENTIAL}"
}
diff --git a/sync/src/main/java/fr/geonature/sync/data/dao/InputDao.kt b/sync/src/main/java/fr/geonature/sync/data/dao/InputDao.kt
new file mode 100644
index 00000000..68ff44a3
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/data/dao/InputDao.kt
@@ -0,0 +1,87 @@
+package fr.geonature.sync.data.dao
+
+import android.content.ContentValues
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import fr.geonature.commons.data.helper.Provider.buildUri
+import fr.geonature.commons.input.AbstractInput
+import fr.geonature.commons.util.getInputsFolder
+import fr.geonature.mountpoint.util.FileUtils
+import org.json.JSONObject
+import java.io.BufferedWriter
+import java.io.File
+import java.io.FileWriter
+
+/**
+ * Data access object for [AbstractInput].
+ *
+ * @author S. Grimault
+ */
+class InputDao(private val context: Context) {
+
+ /**
+ * Exports `ContentValues` as [AbstractInput] to `JSON` file.
+ *
+ * @param values a set of column_name/value pairs as [AbstractInput] to save
+ *
+ * @return The URI to the `JSON` file
+ */
+ fun exportInput(values: ContentValues): Uri {
+ val file = getExportedInput(
+ values.getAsString("packageName"),
+ values.getAsLong("id")
+ )
+
+ val asJson = kotlin
+ .runCatching { JSONObject(values.getAsString("data")) }
+ .getOrNull()
+ ?: throw IllegalArgumentException("Invalid ContentValues $values")
+
+ BufferedWriter(FileWriter(file)).run {
+ write(asJson.toString())
+ flush()
+ close()
+ }
+
+ val exportedInputUri = buildUri(
+ "inputs",
+ values.getAsString("packageName"),
+ values
+ .getAsLong("id")
+ .toString()
+ )
+
+ Log.i(
+ TAG,
+ "input '${values.getAsLong("id")}' exported (URI: $exportedInputUri)"
+ )
+
+ return exportedInputUri
+ }
+
+ fun getExportedInput(
+ packageId: String,
+ inputId: Long
+ ): File {
+ return File(
+ FileUtils.getInputsFolder(context).also { it.mkdirs() },
+ "input_${packageId.substringAfterLast(".")}_${inputId}.json"
+ )
+ }
+
+ fun countInputsToSynchronize(packageId: String): Number {
+ return FileUtils
+ .getInputsFolder(context)
+ .walkTopDown()
+ .filter { it.isFile && it.extension == "json" }
+ .filter { it.nameWithoutExtension.startsWith("input") }
+ .filter { it.nameWithoutExtension.contains(packageId.substringAfterLast(".")) }
+ .filter { it.canRead() }
+ .count()
+ }
+
+ companion object {
+ private val TAG = InputDao::class.java.name
+ }
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/di/ServiceLocator.kt b/sync/src/main/java/fr/geonature/sync/di/ServiceLocator.kt
new file mode 100644
index 00000000..adfd9d3d
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/di/ServiceLocator.kt
@@ -0,0 +1,45 @@
+package fr.geonature.sync.di
+
+import android.app.Application
+import fr.geonature.commons.util.NetworkHandler
+import fr.geonature.sync.api.GeoNatureAPIClientImpl
+import fr.geonature.sync.api.IGeoNatureAPIClient
+import fr.geonature.sync.auth.AuthManagerImpl
+import fr.geonature.sync.auth.CookieManagerImpl
+import fr.geonature.sync.auth.IAuthManager
+import fr.geonature.sync.sync.IPackageInfoManager
+import fr.geonature.sync.sync.PackageInfoManagerImpl
+
+/**
+ * Service Locator
+ *
+ * @author S. Grimault
+ */
+class ServiceLocator(private val application: Application) {
+
+ private val networkHandler: NetworkHandler by lazy {
+ NetworkHandler(application)
+ }
+
+ val authManager: IAuthManager by lazy {
+ AuthManagerImpl(
+ application,
+ geoNatureAPIClient,
+ networkHandler
+ )
+ }
+
+ val geoNatureAPIClient: IGeoNatureAPIClient by lazy {
+ GeoNatureAPIClientImpl(
+ application,
+ CookieManagerImpl(application)
+ )
+ }
+
+ val packageInfoManager: IPackageInfoManager by lazy {
+ PackageInfoManagerImpl(
+ application,
+ geoNatureAPIClient
+ )
+ }
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/sync/AppPackageDownloadStatus.kt b/sync/src/main/java/fr/geonature/sync/sync/AppPackageDownloadStatus.kt
index 229071f4..fc472f8d 100644
--- a/sync/src/main/java/fr/geonature/sync/sync/AppPackageDownloadStatus.kt
+++ b/sync/src/main/java/fr/geonature/sync/sync/AppPackageDownloadStatus.kt
@@ -1,16 +1,15 @@
package fr.geonature.sync.sync
import androidx.work.WorkInfo
-import fr.geonature.sync.api.model.AppPackage
/**
* Describes [AppPackage] download status.
*
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ * @author S. Grimault
*/
data class AppPackageDownloadStatus(
- val state: WorkInfo.State,
val packageName: String,
+ val state: WorkInfo.State,
val progress: Int = -1,
val apkFilePath: String? = null
)
diff --git a/sync/src/main/java/fr/geonature/sync/sync/AppPackageInputsStatus.kt b/sync/src/main/java/fr/geonature/sync/sync/AppPackageInputsStatus.kt
new file mode 100644
index 00000000..8f230281
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/sync/AppPackageInputsStatus.kt
@@ -0,0 +1,15 @@
+package fr.geonature.sync.sync
+
+import androidx.work.WorkInfo
+import fr.geonature.sync.api.model.AppPackage
+
+/**
+ * Describes [AppPackage] inputs status.
+ *
+ * @author S. Grimault
+ */
+data class AppPackageInputsStatus(
+ val packageName: String,
+ val state: WorkInfo.State = WorkInfo.State.ENQUEUED,
+ val inputs: Int = 0
+)
diff --git a/sync/src/main/java/fr/geonature/sync/sync/IPackageInfoManager.kt b/sync/src/main/java/fr/geonature/sync/sync/IPackageInfoManager.kt
new file mode 100644
index 00000000..02fc0347
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/sync/IPackageInfoManager.kt
@@ -0,0 +1,39 @@
+package fr.geonature.sync.sync
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * [PackageInfo] manager.
+ *
+ * Retrieves various kinds of information related to the application packages that are currently
+ * installed on the device.
+ *
+ * @author S. Grimault
+ */
+interface IPackageInfoManager {
+
+ /**
+ * Gets all available applications.
+ */
+ fun getAllApplications(): Flow>
+
+ /**
+ * Gets all compatible installed applications.
+ */
+ fun getInstalledApplications(): Flow>
+
+ /**
+ * Gets related info from package name.
+ */
+ suspend fun getPackageInfo(packageName: String): PackageInfo?
+
+ /**
+ * Fetch all available inputs to synchronize from given [PackageInfo].
+ */
+ suspend fun getInputsToSynchronize(packageInfo: PackageInfo): List
+
+ /**
+ * Updates local settings from given [PackageInfo].
+ */
+ suspend fun updateAppSettings(packageInfo: PackageInfo)
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/sync/PackageInfo.kt b/sync/src/main/java/fr/geonature/sync/sync/PackageInfo.kt
index 3da0d94e..9c016e47 100644
--- a/sync/src/main/java/fr/geonature/sync/sync/PackageInfo.kt
+++ b/sync/src/main/java/fr/geonature/sync/sync/PackageInfo.kt
@@ -2,7 +2,6 @@ package fr.geonature.sync.sync
import android.content.Intent
import android.graphics.drawable.Drawable
-import androidx.work.WorkInfo
/**
* Describes the contents of a package.
@@ -19,9 +18,9 @@ data class PackageInfo(
val icon: Drawable? = null,
val launchIntent: Intent? = null
) : Comparable {
- var inputs: Int = 0
- var state: WorkInfo.State = WorkInfo.State.ENQUEUED
var settings: Any? = null
+ var inputsStatus: AppPackageInputsStatus? = null
+ var downloadStatus: AppPackageDownloadStatus? = null
override fun compareTo(other: PackageInfo): Int {
return packageName.compareTo(other.packageName)
diff --git a/sync/src/main/java/fr/geonature/sync/sync/PackageInfoManager.kt b/sync/src/main/java/fr/geonature/sync/sync/PackageInfoManager.kt
deleted file mode 100644
index fdf19e02..00000000
--- a/sync/src/main/java/fr/geonature/sync/sync/PackageInfoManager.kt
+++ /dev/null
@@ -1,205 +0,0 @@
-package fr.geonature.sync.sync
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.content.pm.PackageManager
-import android.util.Log
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
-import fr.geonature.commons.util.getInputsFolder
-import fr.geonature.mountpoint.util.FileUtils
-import fr.geonature.sync.api.GeoNatureAPIClient
-import fr.geonature.sync.sync.io.AppSettingsJsonWriter
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.flow.asFlow
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.toList
-import kotlinx.coroutines.withContext
-import org.json.JSONObject
-import retrofit2.awaitResponse
-import java.util.Locale
-
-/**
- * [PackageInfo] manager.
- *
- * Retrieves various kinds of information related to the application packages that are currently
- * installed on the device.
- *
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
- */
-class PackageInfoManager private constructor(private val applicationContext: Context) {
-
- private val pm = applicationContext.packageManager
- private val sharedUserId = pm.getPackageInfo(
- applicationContext.packageName,
- PackageManager.GET_META_DATA
- ).sharedUserId
-
- private val availablePackageInfos = mutableMapOf()
- private val packageInfos: MutableLiveData> = MutableLiveData()
-
- val observePackageInfos: LiveData> = packageInfos
-
- /**
- * Finds all available applications from GeoNature.
- */
- @SuppressLint("DefaultLocale")
- suspend fun getAvailableApplications(): List =
- withContext(IO) {
- availablePackageInfos.clear()
-
- val availableAppPackages = try {
- val geoNatureAPIClient = GeoNatureAPIClient.instance(applicationContext)
- val response = geoNatureAPIClient
- ?.getApplications()
- ?.awaitResponse()
-
- if (response?.isSuccessful == true) {
- response.body()
- ?: emptyList()
- } else {
- emptyList()
- }
- } catch (e: Exception) {
- emptyList()
- }
-
- availableAppPackages
- .asSequence()
- .map {
- PackageInfo(
- it.packageName,
- it.code
- .lowercase(Locale.ROOT)
- .replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase(Locale.ROOT) else c.toString() },
- it.versionCode.toLong(),
- 0,
- null,
- it.apkUrl
- ).apply {
- settings = it.settings
- }
- }
- .onEach {
- availablePackageInfos[it.packageName] = it
- }
- .toList()
- .also {
- getInstalledApplications()
- }
- }
-
- /**
- * Finds all compatible installed applications.
- */
- suspend fun getInstalledApplications(): List =
- withContext(IO) {
- val allPackageInfos = mutableMapOf()
- allPackageInfos.putAll(availablePackageInfos)
-
- pm
- .getInstalledApplications(PackageManager.GET_META_DATA)
- .asFlow()
- .filter { it.packageName.startsWith(sharedUserId) }
- .map {
- val packageInfoFromPackageManager = pm.getPackageInfo(
- it.packageName,
- PackageManager.GET_META_DATA
- )
-
- val existingPackageInfo = availablePackageInfos[it.packageName]
-
- @Suppress("DEPRECATION") PackageInfo(
- it.packageName,
- pm
- .getApplicationLabel(it)
- .toString(),
- existingPackageInfo?.versionCode
- ?: 0,
- if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) packageInfoFromPackageManager.longVersionCode
- else packageInfoFromPackageManager.versionCode.toLong(),
- packageInfoFromPackageManager.versionName,
- existingPackageInfo?.apkUrl,
- pm.getApplicationIcon(it.packageName),
- pm.getLaunchIntentForPackage(it.packageName)
- ).apply {
- inputs = getInputsToSynchronize(this).size
- settings = existingPackageInfo?.settings
- }
- }
- .onEach {
- allPackageInfos[it.packageName] = it
- }
- .toList()
- .also {
- packageInfos.postValue(allPackageInfos.values.toList())
- }
- }
-
- /**
- * Gets related info from package name.
- */
- fun getPackageInfo(packageName: String): PackageInfo? {
- return availablePackageInfos[packageName]
- }
-
- /**
- * Fetch all available inputs to synchronize from given [PackageInfo].
- */
- suspend fun getInputsToSynchronize(packageInfo: PackageInfo): List =
- withContext(IO) {
- FileUtils
- .getInputsFolder(
- applicationContext,
- packageInfo.packageName
- )
- .walkTopDown()
- .filter { f -> f.isFile && f.extension == "json" && f.canRead() }
- .map {
- val rawString = it.readText()
- val toJson = JSONObject(rawString)
- SyncInput(
- packageInfo,
- it.absolutePath,
- toJson.getString("module"),
- toJson
- )
- }
- .toList()
- }
-
- suspend fun updateAppSettings(packageInfo: PackageInfo) =
- withContext(IO) {
- val result = runCatching { AppSettingsJsonWriter(applicationContext).write(packageInfo) }
-
- if (result.isFailure) {
- Log.w(
- TAG,
- "failed to update settings for '${packageInfo.packageName}'"
- )
- }
- }
-
- companion object {
- private val TAG = PackageInfoManager::class.java.name
-
- @Volatile
- private var INSTANCE: PackageInfoManager? = null
-
- /**
- * Gets the singleton instance of [PackageInfoManager].
- *
- * @param applicationContext The main application context.
- *
- * @return The singleton instance of [PackageInfoManager].
- */
- fun getInstance(applicationContext: Context): PackageInfoManager =
- INSTANCE
- ?: synchronized(this) {
- INSTANCE
- ?: PackageInfoManager(applicationContext).also { INSTANCE = it }
- }
- }
-}
diff --git a/sync/src/main/java/fr/geonature/sync/sync/PackageInfoManagerImpl.kt b/sync/src/main/java/fr/geonature/sync/sync/PackageInfoManagerImpl.kt
new file mode 100644
index 00000000..9bfadff7
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/sync/PackageInfoManagerImpl.kt
@@ -0,0 +1,218 @@
+package fr.geonature.sync.sync
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.PackageManager
+import android.util.Log
+import androidx.work.WorkInfo
+import fr.geonature.commons.util.getInputsFolder
+import fr.geonature.mountpoint.util.FileUtils
+import fr.geonature.sync.api.IGeoNatureAPIClient
+import fr.geonature.sync.sync.io.AppSettingsJsonWriter
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import retrofit2.awaitResponse
+import java.util.Locale
+
+/**
+ * Default implementation of [IPackageInfoManager].
+ *
+ * @author S. Grimault
+ */
+class PackageInfoManagerImpl(
+ private val applicationContext: Context,
+ private val geoNatureAPIClient: IGeoNatureAPIClient
+) : IPackageInfoManager {
+
+ private val pm = applicationContext.packageManager
+ private val sharedUserId = pm.getPackageInfo(
+ applicationContext.packageName,
+ PackageManager.GET_META_DATA
+ ).sharedUserId
+
+ private val allPackageInfos = mutableMapOf()
+
+ override fun getAllApplications(): Flow> =
+ flow {
+ val installedApplications = (getInstalledApplications().firstOrNull()
+ ?: emptyList())
+
+ emit(installedApplications)
+
+ allPackageInfos.clear()
+ allPackageInfos.putAll((installedApplications
+ .associateBy { it.packageName }
+ .asSequence() + getAvailableApplications()
+ .associateBy { it.packageName }
+ .asSequence())
+ .distinct()
+ .groupBy({ it.key },
+ { it.value })
+ .mapValues {
+ when (it.value.size) {
+ 2 -> it.value[0]
+ .copy(
+ versionCode = it.value[1].versionCode,
+ apkUrl = it.value[1].apkUrl
+ )
+ .apply {
+ settings = it.value[1].settings
+ }
+ else -> it.value[0]
+ }
+ })
+
+ emit(allPackageInfos.values.toList())
+ }
+
+ @SuppressLint("QueryPermissionsNeeded")
+ override fun getInstalledApplications(): Flow> =
+ flow {
+ emit(withContext(IO) {
+ pm
+ .getInstalledApplications(PackageManager.GET_META_DATA)
+ .asFlow()
+ .filter { it.packageName.startsWith(sharedUserId) }
+ .map {
+ val packageInfoFromPackageManager = pm.getPackageInfo(
+ it.packageName,
+ PackageManager.GET_META_DATA
+ )
+
+ @Suppress("DEPRECATION") PackageInfo(
+ it.packageName,
+ pm
+ .getApplicationLabel(it)
+ .toString(),
+ 0,
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) packageInfoFromPackageManager.longVersionCode
+ else packageInfoFromPackageManager.versionCode.toLong(),
+ packageInfoFromPackageManager.versionName,
+ null,
+ pm.getApplicationIcon(it.packageName),
+ pm.getLaunchIntentForPackage(it.packageName)
+ ).apply {
+ inputsStatus = AppPackageInputsStatus(
+ it.packageName,
+ WorkInfo.State.ENQUEUED,
+ getInputsToSynchronize(this).size
+ )
+ }
+ }
+ .toList()
+ })
+ }
+
+ override suspend fun getPackageInfo(packageName: String): PackageInfo? {
+ return allPackageInfos[packageName]
+ }
+
+ override suspend fun getInputsToSynchronize(packageInfo: PackageInfo): List =
+ withContext(IO) {
+ FileUtils
+ .getInputsFolder(applicationContext)
+ .walkTopDown()
+ .filter { it.isFile && it.extension == "json" }
+ .filter { it.nameWithoutExtension.startsWith("input") }
+ .filter { it.nameWithoutExtension.contains(packageInfo.packageName.substringAfterLast(".")) }
+ .filter { it.canRead() }
+ .map {
+ val toJson = kotlin
+ .runCatching { JSONObject(it.readText()) }
+ .getOrNull()
+
+ if (toJson == null) {
+ Log.w(
+ TAG,
+ "invalid input file found '${it.name}'"
+ )
+
+ it.delete()
+
+ return@map null
+ }
+
+ val module = kotlin
+ .runCatching { toJson.getString("module") }
+ .getOrNull()
+
+ if (module.isNullOrBlank()) {
+ Log.w(
+ TAG,
+ "invalid input file found '${it.name}': missing 'module' attribute"
+ )
+
+ return@map null
+ }
+
+ SyncInput(
+ packageInfo,
+ it.absolutePath,
+ module,
+ toJson
+ )
+ }
+ .filterNotNull()
+ .toList()
+ }
+
+ override suspend fun updateAppSettings(packageInfo: PackageInfo) =
+ withContext(IO) {
+ val result = runCatching { AppSettingsJsonWriter(applicationContext).write(packageInfo) }
+
+ if (result.isFailure) {
+ Log.w(
+ TAG,
+ "failed to update settings for '${packageInfo.packageName}'"
+ )
+ }
+ }
+
+ /**
+ * Finds all available applications from GeoNature.
+ */
+ private suspend fun getAvailableApplications(): List =
+ withContext(IO) {
+ runCatching {
+ geoNatureAPIClient
+ .getApplications()
+ ?.awaitResponse()
+ }
+ .map {
+ if (it?.isSuccessful == true) it.body()
+ ?: emptyList() else emptyList()
+ }
+ .map { appPackages ->
+ appPackages
+ .asSequence()
+ .map {
+ PackageInfo(
+ it.packageName,
+ it.code
+ .lowercase(Locale.ROOT)
+ .replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase(Locale.ROOT) else c.toString() },
+ it.versionCode.toLong(),
+ 0,
+ null,
+ it.apkUrl
+ ).apply {
+ settings = it.settings
+ }
+ }
+ .toList()
+ }
+ .getOrElse { emptyList() }
+ }
+
+ companion object {
+ private val TAG = PackageInfoManagerImpl::class.java.name
+ }
+}
diff --git a/sync/src/main/java/fr/geonature/sync/sync/PackageInfoViewModel.kt b/sync/src/main/java/fr/geonature/sync/sync/PackageInfoViewModel.kt
index 1117c385..01670fa1 100644
--- a/sync/src/main/java/fr/geonature/sync/sync/PackageInfoViewModel.kt
+++ b/sync/src/main/java/fr/geonature/sync/sync/PackageInfoViewModel.kt
@@ -6,7 +6,6 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations.map
-import androidx.lifecycle.Transformations.switchMap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@@ -21,6 +20,7 @@ import fr.geonature.sync.BuildConfig
import fr.geonature.sync.sync.worker.DownloadPackageWorker
import fr.geonature.sync.sync.worker.InputsSyncWorker
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/**
@@ -28,56 +28,76 @@ import kotlinx.coroutines.launch
*
* @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
*/
-class PackageInfoViewModel(application: Application) : AndroidViewModel(application) {
+class PackageInfoViewModel(
+ application: Application,
+ private val packageInfoManager: IPackageInfoManager
+) : AndroidViewModel(application) {
private val workManager: WorkManager = WorkManager.getInstance(getApplication())
- private val packageInfoManager: PackageInfoManager = PackageInfoManager.getInstance(getApplication())
-
private val _appSettingsUpdated: MutableLiveData = MutableLiveData()
+ private val _allPackageInfos: MutableLiveData> = MutableLiveData()
- val packageInfos: LiveData> = switchMap(packageInfoManager.observePackageInfos) { packageInfos ->
- viewModelScope.launch {
- packageInfos
- .asSequence()
- .filter { it.launchIntent != null && it.packageName != BuildConfig.APPLICATION_ID }
- .forEach {
- packageInfoManager.updateAppSettings(it)
+ private val _synchronizeInputsFromPackageInfo = map(workManager.getWorkInfosByTagLiveData(InputsSyncWorker.INPUT_SYNC_WORKER_TAG)) { workInfos ->
+ val workInfoData = workInfos
+ .firstOrNull()
+ ?.let { workInfo ->
+ workInfo.progress
+ .getString(InputsSyncWorker.KEY_PACKAGE_NAME)
+ ?.let { workInfo.progress }
+ ?: workInfo.outputData
+ .getString(InputsSyncWorker.KEY_PACKAGE_NAME)
+ ?.let { workInfo.outputData }
+ }
+ ?: return@map null
+
+ val packageName = workInfoData.getString(InputsSyncWorker.KEY_PACKAGE_NAME)
+ ?: return@map null
+
+ AppPackageInputsStatus(
+ packageName,
+ WorkInfo.State.values()[workInfoData.getInt(
+ InputsSyncWorker.KEY_PACKAGE_STATUS,
+ WorkInfo.State.ENQUEUED.ordinal
+ )],
+ workInfoData.getInt(
+ InputsSyncWorker.KEY_PACKAGE_INPUTS,
+ 0
+ )
+ )
+ }
+ private val _downloadPackageInfo = MutableLiveData()
+
+ /**
+ * Observe all [PackageInfo] managed by this application.
+ */
+ val packageInfos: LiveData> = MediatorLiveData>().apply {
+ postValue(emptyList())
+ addSource(_allPackageInfos) { packageInfos ->
+ value = packageInfos.filter { it.packageName != BuildConfig.APPLICATION_ID }
+ }
+ addSource(_synchronizeInputsFromPackageInfo) { inputsStatus ->
+ value = value?.map { packageInfo ->
+ inputsStatus?.let {
+ if (it.packageName == inputsStatus.packageName) {
+ packageInfo.copy().apply {
+ this.inputsStatus = inputsStatus
+ }
+ } else packageInfo
}
+ ?: packageInfo
+ }
}
-
- map(workManager.getWorkInfosByTagLiveData(InputsSyncWorker.INPUT_SYNC_WORKER_TAG)) { workInfos ->
- packageInfos
- .asSequence()
- .filter { it.packageName != BuildConfig.APPLICATION_ID }
- .map { packageInfo ->
- packageInfo
- .copy()
- .apply {
- inputs = packageInfo.inputs
- settings = packageInfo.settings
-
- val workInfo = workInfos.firstOrNull { workInfo -> workInfo.progress.getString(InputsSyncWorker.KEY_PACKAGE_NAME) == packageName }
- ?: workInfos.firstOrNull { workInfo -> workInfo.outputData.getString(InputsSyncWorker.KEY_PACKAGE_NAME) == packageName }
-
- if (workInfo != null) {
- state = WorkInfo.State.values()[workInfo.progress.getInt(
- InputsSyncWorker.KEY_PACKAGE_STATUS,
- workInfo.outputData.getInt(
- InputsSyncWorker.KEY_PACKAGE_STATUS,
- WorkInfo.State.ENQUEUED.ordinal
- )
- )]
- inputs = workInfo.progress.getInt(
- InputsSyncWorker.KEY_PACKAGE_INPUTS,
- workInfo.outputData.getInt(
- InputsSyncWorker.KEY_PACKAGE_INPUTS,
- 0
- )
- )
- }
+ addSource(_downloadPackageInfo) { downloadStatus ->
+ value = value?.map { packageInfo ->
+ downloadStatus?.let {
+ if (it.packageName == downloadStatus.packageName) {
+ packageInfo.copy().apply {
+ this.downloadStatus = downloadStatus
}
+ } else packageInfo
}
- .toList()
+ ?: packageInfo
+ }
}
}
@@ -85,9 +105,9 @@ class PackageInfoViewModel(application: Application) : AndroidViewModel(applicat
* Checks if the current app can be updated or not.
*/
val updateAvailable: LiveData = MediatorLiveData().apply {
- addSource(packageInfoManager.observePackageInfos) { availablePackageInfos: List ->
+ addSource(_allPackageInfos) { packageInfos: List ->
viewModelScope.launch {
- availablePackageInfos
+ packageInfos
.find { it.packageName == BuildConfig.APPLICATION_ID }
?.also {
if (it.settings != null) {
@@ -107,11 +127,15 @@ class PackageInfoViewModel(application: Application) : AndroidViewModel(applicat
val appSettingsUpdated: LiveData = _appSettingsUpdated
/**
- * Checks if we can perform an update of existing apps.
+ * Finds all available applications from GeoNature and these installed locally.
*/
- fun getAvailableApplications() {
+ fun getAllApplications() {
viewModelScope.launch {
- packageInfoManager.getAvailableApplications()
+ packageInfoManager
+ .getAllApplications()
+ .collect {
+ _allPackageInfos.postValue(it)
+ }
}
}
@@ -120,11 +144,13 @@ class PackageInfoViewModel(application: Application) : AndroidViewModel(applicat
*/
fun synchronizeInstalledApplications() {
viewModelScope.launch {
- packageInfoManager
- .getInstalledApplications()
- .asSequence()
- .filter { it.packageName != BuildConfig.APPLICATION_ID }
- .forEach { startSyncInputs(it) }
+ _allPackageInfos.value
+ ?.asSequence()
+ ?.filter { it.packageName != BuildConfig.APPLICATION_ID }
+ ?.forEach {
+ packageInfoManager.updateAppSettings(it)
+ startSyncInputs(it)
+ }
}
}
@@ -164,8 +190,8 @@ class PackageInfoViewModel(application: Application) : AndroidViewModel(applicat
?: it.outputData.getString(DownloadPackageWorker.KEY_PACKAGE_NAME)
?: return@map null
- AppPackageDownloadStatus(it.state,
- packageNameToUpgrade,
+ val downloadStatus = AppPackageDownloadStatus(packageNameToUpgrade,
+ it.state,
it.outputData
.getInt(
DownloadPackageWorker.KEY_PROGRESS,
@@ -177,6 +203,10 @@ class PackageInfoViewModel(application: Application) : AndroidViewModel(applicat
-1
),
it.outputData.getString(DownloadPackageWorker.KEY_APK_FILE_PATH))
+
+ _downloadPackageInfo.postValue(downloadStatus)
+
+ downloadStatus
}.also {
// start the work
continuation.enqueue()
diff --git a/sync/src/main/java/fr/geonature/sync/sync/io/AppSettingsJsonWriter.kt b/sync/src/main/java/fr/geonature/sync/sync/io/AppSettingsJsonWriter.kt
index 63a19a68..ca9fdbdc 100644
--- a/sync/src/main/java/fr/geonature/sync/sync/io/AppSettingsJsonWriter.kt
+++ b/sync/src/main/java/fr/geonature/sync/sync/io/AppSettingsJsonWriter.kt
@@ -31,8 +31,7 @@ class AppSettingsJsonWriter(private val context: Context) {
val appRootFolder = getRootFolder(
context,
- MountPoint.StorageType.INTERNAL,
- packageInfo.packageName
+ MountPoint.StorageType.INTERNAL
).also { it.mkdirs() }
val appSettingsFile = getFile(
diff --git a/sync/src/main/java/fr/geonature/sync/sync/worker/CheckAuthLoginWorker.kt b/sync/src/main/java/fr/geonature/sync/sync/worker/CheckAuthLoginWorker.kt
index 75572747..7a0d5e88 100644
--- a/sync/src/main/java/fr/geonature/sync/sync/worker/CheckAuthLoginWorker.kt
+++ b/sync/src/main/java/fr/geonature/sync/sync/worker/CheckAuthLoginWorker.kt
@@ -9,7 +9,6 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import fr.geonature.sync.MainApplication
import fr.geonature.sync.R
-import fr.geonature.sync.auth.AuthManager
import fr.geonature.sync.ui.login.LoginActivity
/**
@@ -24,9 +23,9 @@ class CheckAuthLoginWorker(
appContext,
workerParams
) {
- private val authManager: AuthManager = AuthManager.getInstance(applicationContext)
-
override suspend fun doWork(): Result {
+ val authManager = (applicationContext as MainApplication).sl.authManager
+
// not connected: notify user
if (authManager.getAuthLogin() == null) {
with(NotificationManagerCompat.from(applicationContext)) {
diff --git a/sync/src/main/java/fr/geonature/sync/sync/worker/CheckInputsToSynchronizeWorker.kt b/sync/src/main/java/fr/geonature/sync/sync/worker/CheckInputsToSynchronizeWorker.kt
index 2a6fb0dc..853bb2dd 100644
--- a/sync/src/main/java/fr/geonature/sync/sync/worker/CheckInputsToSynchronizeWorker.kt
+++ b/sync/src/main/java/fr/geonature/sync/sync/worker/CheckInputsToSynchronizeWorker.kt
@@ -10,13 +10,13 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import fr.geonature.sync.MainApplication
import fr.geonature.sync.R
-import fr.geonature.sync.sync.PackageInfoManager
import fr.geonature.sync.ui.home.HomeActivity
+import kotlinx.coroutines.flow.firstOrNull
/**
* Checks Inputs to synchronize.
*
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ * @author S. Grimault
*/
class CheckInputsToSynchronizeWorker(
appContext: Context,
@@ -25,11 +25,13 @@ class CheckInputsToSynchronizeWorker(
appContext,
workerParams
) {
- private val packageInfoManager = PackageInfoManager.getInstance(applicationContext)
-
override suspend fun doWork(): Result {
- val availablePackageInfos = packageInfoManager.getInstalledApplications()
- val availableInputs = availablePackageInfos
+ val packageInfoManager = (applicationContext as MainApplication).sl.packageInfoManager
+
+ val availableInputs = (packageInfoManager
+ .getInstalledApplications()
+ .firstOrNull()
+ ?: emptyList())
.map { packageInfo -> packageInfoManager.getInputsToSynchronize(packageInfo) }
.flatten()
diff --git a/sync/src/main/java/fr/geonature/sync/sync/worker/DataSyncWorker.kt b/sync/src/main/java/fr/geonature/sync/sync/worker/DataSyncWorker.kt
index 59bc037f..9b310799 100644
--- a/sync/src/main/java/fr/geonature/sync/sync/worker/DataSyncWorker.kt
+++ b/sync/src/main/java/fr/geonature/sync/sync/worker/DataSyncWorker.kt
@@ -26,9 +26,8 @@ import fr.geonature.commons.data.TaxonArea
import fr.geonature.commons.data.Taxonomy
import fr.geonature.sync.MainApplication
import fr.geonature.sync.R
-import fr.geonature.sync.api.GeoNatureAPIClient
+import fr.geonature.sync.api.IGeoNatureAPIClient
import fr.geonature.sync.api.model.User
-import fr.geonature.sync.auth.AuthManager
import fr.geonature.sync.data.LocalDatabase
import fr.geonature.sync.settings.AppSettings
import fr.geonature.sync.sync.DataSyncManager
@@ -49,7 +48,7 @@ import java.util.Locale
/**
* Local data synchronization worker.
*
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ * @author S. Grimault
*/
class DataSyncWorker(
appContext: Context,
@@ -58,11 +57,12 @@ class DataSyncWorker(
appContext,
workerParams
) {
- private val authManager: AuthManager = AuthManager.getInstance(applicationContext)
private val dataSyncManager = DataSyncManager.getInstance(applicationContext)
private val workManager = WorkManager.getInstance(applicationContext)
override suspend fun doWork(): Result {
+ val authManager = (applicationContext as MainApplication).sl.authManager
+
val startTime = Date()
// not connected: abort
@@ -92,10 +92,7 @@ class DataSyncWorker(
)
}
- val geoNatureAPIClient = GeoNatureAPIClient.instance(applicationContext)
- ?: return Result.failure(
- workData(applicationContext.getString(R.string.sync_error_server_url_configuration))
- )
+ val geoNatureAPIClient = (applicationContext as MainApplication).sl.geoNatureAPIClient
val alreadyRunning = workManager
.getWorkInfosByTag(DATA_SYNC_WORKER_TAG)
@@ -214,7 +211,7 @@ class DataSyncWorker(
return syncTaxaResult
}
- private suspend fun syncDataset(geoNatureServiceClient: GeoNatureAPIClient): Result {
+ private suspend fun syncDataset(geoNatureServiceClient: IGeoNatureAPIClient): Result {
Log.i(
TAG,
"synchronize dataset..."
@@ -223,12 +220,12 @@ class DataSyncWorker(
val result = runCatching {
geoNatureServiceClient
.getMetaDatasets()
- .awaitResponse()
+ ?.awaitResponse()
}
.map {
checkResponse(it).run {
if (this.state == WorkInfo.State.FAILED) this else it
- .body()
+ ?.body()
?.byteStream()
?: DataSyncStatus(
WorkInfo.State.FAILED,
@@ -302,7 +299,7 @@ class DataSyncWorker(
}
private suspend fun syncInputObservers(
- geoNatureServiceClient: GeoNatureAPIClient,
+ geoNatureServiceClient: IGeoNatureAPIClient,
menuId: Int
): Result {
Log.i(
@@ -313,11 +310,11 @@ class DataSyncWorker(
val result = runCatching {
geoNatureServiceClient
.getUsers(menuId)
- .awaitResponse()
+ ?.awaitResponse()
}
.map {
checkResponse(it).run {
- if (this.state == WorkInfo.State.FAILED) this else runCatching { it.body() }.getOrNull()
+ if (this.state == WorkInfo.State.FAILED) this else runCatching { it?.body() }.getOrNull()
?: emptyList>()
}
}
@@ -392,7 +389,7 @@ class DataSyncWorker(
return Result.success()
}
- private suspend fun syncTaxonomyRanks(geoNatureServiceClient: GeoNatureAPIClient): Result {
+ private suspend fun syncTaxonomyRanks(geoNatureServiceClient: IGeoNatureAPIClient): Result {
Log.i(
TAG,
"synchronize taxonomy ranks..."
@@ -401,12 +398,12 @@ class DataSyncWorker(
val result = runCatching {
geoNatureServiceClient
.getTaxonomyRanks()
- .awaitResponse()
+ ?.awaitResponse()
}
.map {
checkResponse(it).run {
if (this.state == WorkInfo.State.FAILED) this else it
- .body()
+ ?.body()
?.byteStream()
?: DataSyncStatus(
WorkInfo.State.FAILED,
@@ -479,7 +476,7 @@ class DataSyncWorker(
return Result.success()
}
- private suspend fun syncNomenclature(geoNatureServiceClient: GeoNatureAPIClient): Result {
+ private suspend fun syncNomenclature(geoNatureServiceClient: IGeoNatureAPIClient): Result {
Log.i(
TAG,
"synchronize nomenclature types..."
@@ -488,11 +485,11 @@ class DataSyncWorker(
val nomenclaturesResult = runCatching {
geoNatureServiceClient
.getNomenclatures()
- .awaitResponse()
+ ?.awaitResponse()
}
.map {
checkResponse(it).run {
- if (this.state == WorkInfo.State.FAILED) this else runCatching { it.body() }.getOrNull()
+ if (this.state == WorkInfo.State.FAILED) this else runCatching { it?.body() }.getOrNull()
?: emptyList()
}
}
@@ -649,12 +646,12 @@ class DataSyncWorker(
val defaultNomenclatureResult = runCatching {
geoNatureServiceClient
.getDefaultNomenclaturesValues("occtax")
- .awaitResponse()
+ ?.awaitResponse()
}
.map {
checkResponse(it).run {
if (this.state == WorkInfo.State.FAILED) this else it
- .body()
+ ?.body()
?.byteStream()
?: DataSyncStatus(
WorkInfo.State.FAILED,
@@ -750,7 +747,7 @@ class DataSyncWorker(
}
private suspend fun syncTaxa(
- geoNatureServiceClient: GeoNatureAPIClient,
+ geoNatureServiceClient: IGeoNatureAPIClient,
listId: Int,
codeAreaType: String?,
pageSize: Int,
@@ -775,7 +772,7 @@ class DataSyncWorker(
pageSize,
offset
)
- .awaitResponse()
+ ?.awaitResponse()
}.getOrNull()
@@ -902,7 +899,7 @@ class DataSyncWorker(
pageSize,
offset
)
- .awaitResponse()
+ ?.awaitResponse()
}.getOrNull()
if (taxrefAreasResponse == null || checkResponse(taxrefAreasResponse).state == WorkInfo.State.FAILED) {
@@ -1019,9 +1016,9 @@ class DataSyncWorker(
)
}
- private suspend fun checkResponse(response: Response<*>): DataSyncStatus {
+ private suspend fun checkResponse(response: Response<*>?): DataSyncStatus {
// not connected
- if (response.code() == ServerStatus.UNAUTHORIZED.httpStatus) {
+ if (response?.code() == ServerStatus.UNAUTHORIZED.httpStatus) {
setForeground(
createForegroundInfo(
createNotification(
@@ -1038,7 +1035,7 @@ class DataSyncWorker(
)
}
- if (!response.isSuccessful) {
+ if (response?.isSuccessful == false) {
return DataSyncStatus(
WorkInfo.State.FAILED,
applicationContext.getString(R.string.sync_error_server_error),
diff --git a/sync/src/main/java/fr/geonature/sync/sync/worker/DownloadPackageWorker.kt b/sync/src/main/java/fr/geonature/sync/sync/worker/DownloadPackageWorker.kt
index 0b7ad4da..95e4307d 100644
--- a/sync/src/main/java/fr/geonature/sync/sync/worker/DownloadPackageWorker.kt
+++ b/sync/src/main/java/fr/geonature/sync/sync/worker/DownloadPackageWorker.kt
@@ -6,9 +6,8 @@ import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.WorkerParameters
import androidx.work.workDataOf
-import fr.geonature.sync.api.GeoNatureAPIClient
+import fr.geonature.sync.MainApplication
import fr.geonature.sync.sync.PackageInfo
-import fr.geonature.sync.sync.PackageInfoManager
import okhttp3.ResponseBody
import okhttp3.internal.Util
import okio.Buffer
@@ -19,7 +18,7 @@ import java.io.File
/**
* Download given application package.
*
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ * @author S. Grimault
*/
class DownloadPackageWorker(
appContext: Context,
@@ -28,9 +27,6 @@ class DownloadPackageWorker(
appContext,
workerParams
) {
- private val packageInfoManager =
- PackageInfoManager.getInstance(applicationContext)
-
override suspend fun doWork(): Result {
val packageName = inputData.getString(KEY_PACKAGE_NAME)
@@ -38,14 +34,13 @@ class DownloadPackageWorker(
return Result.failure()
}
- val packageInfoToUpdate =
- packageInfoManager.getPackageInfo(packageName)
- ?: return Result.failure()
- val apkUrl = packageInfoToUpdate.apkUrl ?: return Result.failure()
-
- val geoNatureAPIClient = GeoNatureAPIClient.instance(applicationContext)
+ val packageInfoToUpdate = (applicationContext as MainApplication).sl.packageInfoManager.getPackageInfo(packageName)
+ ?: return Result.failure()
+ val apkUrl = packageInfoToUpdate.apkUrl
?: return Result.failure()
+ val geoNatureAPIClient = (applicationContext as MainApplication).sl.geoNatureAPIClient
+
Log.i(
TAG,
"updating '$packageName'..."
@@ -54,10 +49,11 @@ class DownloadPackageWorker(
setProgress(workData(packageInfoToUpdate.packageName))
return try {
- val response = geoNatureAPIClient.downloadPackage(apkUrl)
- .awaitResponse()
+ val response = geoNatureAPIClient
+ .downloadPackage(apkUrl)
+ ?.awaitResponse()
- if (!response.isSuccessful) {
+ if (response?.isSuccessful == false) {
return Result.failure(
workData(
packageInfoToUpdate.packageName,
@@ -66,12 +62,13 @@ class DownloadPackageWorker(
)
}
- val responseBody = response.body() ?: return Result.failure(
- workData(
- packageInfoToUpdate.packageName,
- 100
+ val responseBody = response?.body()
+ ?: return Result.failure(
+ workData(
+ packageInfoToUpdate.packageName,
+ 100
+ )
)
- )
downloadAsFile(
responseBody,
@@ -99,19 +96,20 @@ class DownloadPackageWorker(
val source = responseBody.source()
val contentLength = responseBody.contentLength()
- val apkFilePath =
- "${applicationContext.getExternalFilesDir(null)?.absolutePath}/${packageInfo.packageName}_${packageInfo.versionCode}.apk"
+ val apkFilePath = "${applicationContext.getExternalFilesDir(null)?.absolutePath}/${packageInfo.packageName}_${packageInfo.versionCode}.apk"
val buffer = Buffer()
val bufferedSink = Okio.buffer(Okio.sink(File(apkFilePath)))
var total = 0L
while (true) {
- val read: Long = source.read(
- buffer,
- 4096
- )
- .takeUnless { it == -1L } ?: break
+ val read: Long = source
+ .read(
+ buffer,
+ 4096
+ )
+ .takeUnless { it == -1L }
+ ?: break
bufferedSink.write(
buffer,
@@ -135,6 +133,13 @@ class DownloadPackageWorker(
Util.closeQuietly(bufferedSink)
Util.closeQuietly(responseBody)
+ setProgressAsync(
+ workData(
+ packageInfo.packageName,
+ 100
+ )
+ )
+
return Result.success(
workData(
packageInfo.packageName,
diff --git a/sync/src/main/java/fr/geonature/sync/sync/worker/InputsSyncWorker.kt b/sync/src/main/java/fr/geonature/sync/sync/worker/InputsSyncWorker.kt
index 704be931..776e71ad 100644
--- a/sync/src/main/java/fr/geonature/sync/sync/worker/InputsSyncWorker.kt
+++ b/sync/src/main/java/fr/geonature/sync/sync/worker/InputsSyncWorker.kt
@@ -8,9 +8,8 @@ import androidx.work.Data
import androidx.work.WorkInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
-import fr.geonature.sync.api.GeoNatureAPIClient
+import fr.geonature.sync.MainApplication
import fr.geonature.sync.sync.PackageInfo
-import fr.geonature.sync.sync.PackageInfoManager
import fr.geonature.sync.sync.SyncInput
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -21,7 +20,7 @@ import java.io.File
/**
* Inputs synchronization worker from given [PackageInfo].
*
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ * @author S. Grimault
*/
class InputsSyncWorker(
appContext: Context,
@@ -30,8 +29,6 @@ class InputsSyncWorker(
appContext,
workerParams
) {
- private val packageInfoManager = PackageInfoManager.getInstance(applicationContext)
-
override suspend fun doWork(): Result {
val packageName = inputData.getString(KEY_PACKAGE_NAME)
@@ -39,11 +36,12 @@ class InputsSyncWorker(
return Result.failure()
}
+ val packageInfoManager = (applicationContext as MainApplication).sl.packageInfoManager
+
val packageInfo: PackageInfo = packageInfoManager.getPackageInfo(packageName)
?: return Result.failure()
- val geoNatureAPIClient = GeoNatureAPIClient.instance(applicationContext)
- ?: return Result.failure()
+ val geoNatureAPIClient = (applicationContext as MainApplication).sl.geoNatureAPIClient
NotificationManagerCompat
.from(applicationContext)
@@ -95,9 +93,9 @@ class InputsSyncWorker(
syncInput.module,
syncInput.payload
)
- .awaitResponse()
+ ?.awaitResponse()
- if (!response.isSuccessful) {
+ if (response?.isSuccessful == false) {
setProgress(
workData(
packageInfo.packageName,
@@ -164,18 +162,12 @@ class InputsSyncWorker(
}
private suspend fun deleteSynchronizedInput(syncInput: SyncInput): Boolean {
- val deleted = withContext(Dispatchers.IO) {
+ return withContext(Dispatchers.IO) {
File(syncInput.filePath)
.takeIf { it.exists() && it.isFile && it.parentFile?.canWrite() ?: false }
?.delete()
?: false
}
-
- if (deleted) {
- packageInfoManager.getInputsToSynchronize(syncInput.packageInfo)
- }
-
- return deleted
}
private fun workData(
diff --git a/sync/src/main/java/fr/geonature/sync/ui/home/HomeActivity.kt b/sync/src/main/java/fr/geonature/sync/ui/home/HomeActivity.kt
index 6dac79f2..c454dd57 100644
--- a/sync/src/main/java/fr/geonature/sync/ui/home/HomeActivity.kt
+++ b/sync/src/main/java/fr/geonature/sync/ui/home/HomeActivity.kt
@@ -2,7 +2,6 @@ package fr.geonature.sync.ui.home
import android.Manifest
import android.annotation.SuppressLint
-import android.app.ProgressDialog
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
@@ -13,6 +12,7 @@ import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
+import android.view.ViewGroup
import android.view.animation.AnimationUtils.loadAnimation
import android.widget.ProgressBar
import android.widget.TextView
@@ -30,14 +30,14 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkInfo
+import com.google.android.material.progressindicator.CircularProgressIndicator
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
-import fr.geonature.commons.util.PermissionUtils
import fr.geonature.commons.util.observeOnce
import fr.geonature.commons.util.observeUntil
import fr.geonature.sync.BuildConfig
+import fr.geonature.sync.MainApplication
import fr.geonature.sync.R
-import fr.geonature.sync.api.GeoNatureAPIClient
import fr.geonature.sync.auth.AuthLoginViewModel
import fr.geonature.sync.settings.AppSettings
import fr.geonature.sync.settings.AppSettingsViewModel
@@ -61,7 +61,7 @@ import kotlin.time.ExperimentalTime
/**
* Home screen Activity.
*
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ * @author S. Grimault
*/
class HomeActivity : AppCompatActivity() {
@@ -77,8 +77,7 @@ class HomeActivity : AppCompatActivity() {
private var progressBar: ProgressBar? = null
private var dataSyncView: DataSyncView? = null
- @Suppress("DEPRECATION")
- private var progressDialog: ProgressDialog? = null
+ private var progressSnackbar: Pair? = null
private var appSettings: AppSettings? = null
private var isLoggedIn: Boolean = false
@@ -164,7 +163,7 @@ class HomeActivity : AppCompatActivity() {
val appSettings = appSettings
if (appSettings == null) {
- packageInfoViewModel.getAvailableApplications()
+ packageInfoViewModel.getAllApplications()
} else {
dataSyncViewModel.startSync(appSettings)
synchronizeInstalledApplications()
@@ -174,14 +173,16 @@ class HomeActivity : AppCompatActivity() {
}
checkNetwork()
- checkPermissions()
+ loadAppSettings {
+ packageInfoViewModel.getAllApplications()
+ }
}
override fun onResume() {
super.onResume()
if (appSettings != null) {
- packageInfoViewModel.synchronizeInstalledApplications()
+ synchronizeInstalledApplications()
}
}
@@ -261,19 +262,27 @@ class HomeActivity : AppCompatActivity() {
private fun configureAuthLoginViewModel(): AuthLoginViewModel {
return ViewModelProvider(this,
- AuthLoginViewModel.Factory { AuthLoginViewModel(application) })
+ AuthLoginViewModel.Factory {
+ AuthLoginViewModel(
+ application,
+ (application as MainApplication).sl.authManager,
+ (application as MainApplication).sl.geoNatureAPIClient
+ )
+ })
.get(AuthLoginViewModel::class.java)
.also { vm ->
- vm.checkAuthLogin().observeOnce(this@HomeActivity) {
- if (checkGeoNatureSettings() && it == null) {
- Log.i(
- TAG,
- "not connected, redirect to LoginActivity"
- )
+ vm
+ .checkAuthLogin()
+ .observeOnce(this@HomeActivity) {
+ if (checkGeoNatureSettings() && it == null) {
+ Log.i(
+ TAG,
+ "not connected, redirect to LoginActivity"
+ )
- startSyncResultLauncher.launch(LoginActivity.newIntent(this@HomeActivity))
+ startSyncResultLauncher.launch(LoginActivity.newIntent(this@HomeActivity))
+ }
}
- }
vm.isLoggedIn.observe(this@HomeActivity,
{
this@HomeActivity.isLoggedIn = it
@@ -285,7 +294,12 @@ class HomeActivity : AppCompatActivity() {
@ExperimentalTime
private fun configurePackageInfoViewModel(): PackageInfoViewModel {
return ViewModelProvider(this,
- PackageInfoViewModel.Factory { PackageInfoViewModel(application) })
+ PackageInfoViewModel.Factory {
+ PackageInfoViewModel(
+ application,
+ (application as MainApplication).sl.packageInfoManager
+ )
+ })
.get(
PackageInfoViewModel::class.java
)
@@ -368,35 +382,6 @@ class HomeActivity : AppCompatActivity() {
}
}
- @ExperimentalTime
- private fun checkPermissions() {
- PermissionUtils.requestPermissions(this,
- listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
- { result ->
- if (result.values.all { it }) {
- loadAppSettings {
- packageInfoViewModel.getAvailableApplications()
- }
- } else {
- Toast
- .makeText(
- this,
- R.string.snackbar_permissions_not_granted,
- Toast.LENGTH_LONG
- )
- .show()
- }
- },
- { callback ->
- makeSnackbar(
- getString(R.string.snackbar_permission_external_storage_rationale),
- BaseTransientBottomBar.LENGTH_INDEFINITE
- )
- ?.setAction(android.R.string.ok) { callback() }
- ?.show()
- })
- }
-
@RequiresPermission(Manifest.permission.CHANGE_NETWORK_STATE)
private fun checkNetwork() {
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -459,11 +444,11 @@ class HomeActivity : AppCompatActivity() {
}
private fun checkGeoNatureSettings(): Boolean {
- return GeoNatureAPIClient.instance(this) != null
+ return (application as MainApplication).sl.geoNatureAPIClient.checkSettings()
}
private fun startFirstSync(appSettings: AppSettings) {
- if (dataSyncViewModel.lastSynchronizedDate.value == null && dataSyncViewModel.isSyncRunning.value != true) {
+ if (dataSyncViewModel.lastSynchronizedDate.value?.second == null && dataSyncViewModel.isSyncRunning.value != true) {
dataSyncViewModel.startSync(appSettings)
}
}
@@ -492,6 +477,37 @@ class HomeActivity : AppCompatActivity() {
)
}
+ private fun makeProgressSnackbar(text: CharSequence): Pair? {
+ val view = homeContent
+ ?: return null
+
+ return Snackbar
+ .make(
+ view,
+ text,
+ Snackbar.LENGTH_INDEFINITE
+ )
+ .let { snackbar ->
+ val circularProgressIndicator = CircularProgressIndicator(this).also {
+ it.isIndeterminate = true
+ it.layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ }
+
+ (snackbar.view.findViewById(com.google.android.material.R.id.snackbar_text).parent as ViewGroup).addView(
+ circularProgressIndicator,
+ 0
+ )
+
+ Pair(
+ snackbar,
+ circularProgressIndicator
+ )
+ }
+ }
+
private fun mergeAppSettingsWithSharedPreferences(appSettings: AppSettings) {
val geoNatureServerUrl = appSettings.geoNatureServerUrl
val taxHubServerUrl = appSettings.taxHubServerUrl
@@ -531,23 +547,11 @@ class HomeActivity : AppCompatActivity() {
.show()
}
- @Suppress("DEPRECATION")
- private fun showProgressDialog(progress: Int) {
- if (progressDialog == null) {
- progressDialog = ProgressDialog(this).apply {
- setCancelable(false)
- setIcon(R.drawable.ic_upgrade)
- setTitle(R.string.alert_new_app_version_available_title)
- setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
- setProgressNumberFormat(null)
- }
- progressDialog?.show()
+ private fun downloadApk(packageName: String) {
+ if (packageName == BuildConfig.APPLICATION_ID) {
+ progressSnackbar = makeProgressSnackbar(getString(R.string.snackbar_upgrading_app))?.also { it.first.show() }
}
- progressDialog?.progress = progress
- }
-
- private fun downloadApk(packageName: String) {
packageInfoViewModel
.downloadAppPackage(packageName)
.observeUntil(this@HomeActivity,
@@ -560,14 +564,23 @@ class HomeActivity : AppCompatActivity() {
}) {
it?.run {
when (state) {
- WorkInfo.State.FAILED -> progressDialog?.dismiss()
+ WorkInfo.State.FAILED -> {
+ if (packageName == BuildConfig.APPLICATION_ID) progressSnackbar?.first?.dismiss()
+ }
WorkInfo.State.SUCCEEDED -> {
- progressDialog?.dismiss()
+ if (packageName == BuildConfig.APPLICATION_ID) progressSnackbar?.first?.dismiss()
apkFilePath?.run {
installApk(this)
}
}
- else -> showProgressDialog(progress)
+ else -> {
+ if (packageName == BuildConfig.APPLICATION_ID) {
+ progressSnackbar?.second?.also { circularProgressIndicator ->
+ circularProgressIndicator.isIndeterminate = false
+ circularProgressIndicator.progress = progress
+ }
+ }
+ }
}
}
}
diff --git a/sync/src/main/java/fr/geonature/sync/ui/home/PackageInfoRecyclerViewAdapter.kt b/sync/src/main/java/fr/geonature/sync/ui/home/PackageInfoRecyclerViewAdapter.kt
index 20a936df..45599636 100644
--- a/sync/src/main/java/fr/geonature/sync/ui/home/PackageInfoRecyclerViewAdapter.kt
+++ b/sync/src/main/java/fr/geonature/sync/ui/home/PackageInfoRecyclerViewAdapter.kt
@@ -3,15 +3,17 @@ package fr.geonature.sync.ui.home
import android.view.View
import android.widget.Button
import android.widget.ImageView
-import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.work.WorkInfo
+import com.google.android.material.progressindicator.CircularProgressIndicator
import fr.geonature.commons.ui.adapter.AbstractListItemRecyclerViewAdapter
import fr.geonature.commons.util.ThemeUtils
import fr.geonature.sync.R
+import fr.geonature.sync.sync.AppPackageDownloadStatus
+import fr.geonature.sync.sync.AppPackageInputsStatus
import fr.geonature.sync.sync.PackageInfo
/**
@@ -53,9 +55,10 @@ class PackageInfoRecyclerViewAdapter(private val listener: OnPackageInfoRecycler
newItemPosition: Int
): Boolean {
return oldItems[oldItemPosition] == newItems[newItemPosition] &&
+ oldItems[oldItemPosition].packageName == newItems[newItemPosition].packageName &&
oldItems[oldItemPosition].apkUrl == newItems[newItemPosition].apkUrl &&
- oldItems[oldItemPosition].state == newItems[newItemPosition].state &&
- oldItems[oldItemPosition].inputs == newItems[newItemPosition].inputs
+ oldItems[oldItemPosition].inputsStatus == newItems[newItemPosition].inputsStatus &&
+ oldItems[oldItemPosition].downloadStatus == newItems[newItemPosition].downloadStatus
}
inner class ViewHolder(itemView: View) :
@@ -64,27 +67,24 @@ class PackageInfoRecyclerViewAdapter(private val listener: OnPackageInfoRecycler
private val icon: ImageView = itemView.findViewById(android.R.id.icon1)
private val iconStatus: TextView = itemView.findViewById(android.R.id.icon2)
private val button: Button = itemView.findViewById(android.R.id.button1)
- private val progressBar: ProgressBar = itemView.findViewById(R.id.progressBar)
+ private val progressBar: CircularProgressIndicator = itemView.findViewById(android.R.id.progress)
private val text1: TextView = itemView.findViewById(android.R.id.text1)
private val text2: TextView = itemView.findViewById(android.R.id.text2)
override fun onBind(item: PackageInfo) {
with(button) {
- visibility =
- if (item.hasNewVersionAvailable()) View.VISIBLE
- else View.GONE
- text =
- if (item.isAvailableForInstall()) itemView.context.getString(R.string.home_app_install)
- else itemView.context.getString(R.string.home_app_upgrade)
- contentDescription =
- if (item.isAvailableForInstall()) itemView.context.getString(
- R.string.home_app_install_desc,
- item.label
- )
- else itemView.context.getString(
- R.string.home_app_upgrade_desc,
- item.label
- )
+ visibility = if (item.hasNewVersionAvailable()) View.VISIBLE
+ else View.GONE
+ text = if (item.isAvailableForInstall()) itemView.context.getString(R.string.home_app_install)
+ else itemView.context.getString(R.string.home_app_upgrade)
+ contentDescription = if (item.isAvailableForInstall()) itemView.context.getString(
+ R.string.home_app_install_desc,
+ item.label
+ )
+ else itemView.context.getString(
+ R.string.home_app_upgrade_desc,
+ item.label
+ )
setOnClickListener {
listener.onUpgrade(item)
}
@@ -92,10 +92,11 @@ class PackageInfoRecyclerViewAdapter(private val listener: OnPackageInfoRecycler
with(icon) {
setImageDrawable(
- item.icon ?: ContextCompat.getDrawable(
- itemView.context,
- R.drawable.ic_upgrade
- )
+ item.icon
+ ?: ContextCompat.getDrawable(
+ itemView.context,
+ R.drawable.ic_upgrade
+ )
)
if (item.icon == null) {
@@ -106,31 +107,39 @@ class PackageInfoRecyclerViewAdapter(private val listener: OnPackageInfoRecycler
}
}
- text1.text =
- if (item.versionName.isNullOrEmpty()) item.label
- else itemView.context.getString(
- R.string.home_app_version_full,
- item.label,
- item.versionName
- )
+ text1.text = if (item.versionName.isNullOrEmpty()) item.label
+ else itemView.context.getString(
+ R.string.home_app_version_full,
+ item.label,
+ item.versionName
+ )
with(text2) {
- visibility =
- if (item.isAvailableForInstall()) View.GONE
- else View.VISIBLE
+ visibility = if (item.isAvailableForInstall()) View.GONE
+ else View.VISIBLE
text = itemView.resources.getQuantityString(
R.plurals.home_app_inputs,
- item.inputs,
- item.inputs
+ item.inputsStatus?.inputs
+ ?: 0,
+ item.inputsStatus?.inputs
+ ?: 0
)
}
- setState(item.state)
+ setInputsStatusState(item.inputsStatus)
+ setDownloadStatusState(item.downloadStatus)
}
- private fun setState(state: WorkInfo.State) {
- when (state) {
+ private fun setInputsStatusState(inputsStatus: AppPackageInputsStatus?) {
+ if (inputsStatus == null) {
+ iconStatus.visibility = View.GONE
+ progressBar.visibility = View.INVISIBLE
+ return
+ }
+
+ when (inputsStatus.state) {
WorkInfo.State.RUNNING -> {
+ progressBar.isIndeterminate = true
progressBar.visibility = View.VISIBLE
}
WorkInfo.State.FAILED -> {
@@ -142,7 +151,7 @@ class PackageInfoRecyclerViewAdapter(private val listener: OnPackageInfoRecycler
itemView.context?.theme
)
)
- progressBar.visibility = View.GONE
+ progressBar.visibility = View.INVISIBLE
}
WorkInfo.State.SUCCEEDED -> {
iconStatus.visibility = View.VISIBLE
@@ -153,11 +162,32 @@ class PackageInfoRecyclerViewAdapter(private val listener: OnPackageInfoRecycler
itemView.context?.theme
)
)
- progressBar.visibility = View.GONE
+ progressBar.visibility = View.INVISIBLE
}
else -> {
iconStatus.visibility = View.GONE
- progressBar.visibility = View.GONE
+ progressBar.visibility = View.INVISIBLE
+ }
+ }
+ }
+
+ private fun setDownloadStatusState(downloadStatus: AppPackageDownloadStatus?) {
+ if (downloadStatus == null) {
+ progressBar.visibility = View.INVISIBLE
+ return
+ }
+
+ when (downloadStatus.state) {
+ WorkInfo.State.RUNNING -> {
+ progressBar.visibility = View.VISIBLE
+ progressBar.isIndeterminate = false
+ progressBar.progress = downloadStatus.progress
+ }
+ WorkInfo.State.SUCCEEDED -> {
+ progressBar.visibility = View.INVISIBLE
+ }
+ else -> {
+ progressBar.visibility = View.INVISIBLE
}
}
}
diff --git a/sync/src/main/java/fr/geonature/sync/ui/login/LoginActivity.kt b/sync/src/main/java/fr/geonature/sync/ui/login/LoginActivity.kt
index 42ac29a9..537a7f7a 100644
--- a/sync/src/main/java/fr/geonature/sync/ui/login/LoginActivity.kt
+++ b/sync/src/main/java/fr/geonature/sync/ui/login/LoginActivity.kt
@@ -6,24 +6,29 @@ import android.os.Bundle
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.Button
-import android.widget.EditText
import android.widget.ProgressBar
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
-import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
+import com.google.android.material.textfield.TextInputLayout
import fr.geonature.commons.util.KeyboardUtils.hideSoftKeyboard
import fr.geonature.commons.util.afterTextChanged
import fr.geonature.commons.util.observeOnce
+import fr.geonature.sync.MainApplication
import fr.geonature.sync.R
import fr.geonature.sync.auth.AuthLoginViewModel
import fr.geonature.sync.settings.AppSettings
import fr.geonature.sync.settings.AppSettingsViewModel
+/**
+ * Login Activity.
+ *
+ * @author S. Grimault
+ */
class LoginActivity : AppCompatActivity() {
private lateinit var authLoginViewModel: AuthLoginViewModel
@@ -31,8 +36,8 @@ class LoginActivity : AppCompatActivity() {
private var appSettings: AppSettings? = null
private var content: ConstraintLayout? = null
- private var editTextUsername: EditText? = null
- private var editTextPassword: EditText? = null
+ private var editTextUsername: TextInputLayout? = null
+ private var editTextPassword: TextInputLayout? = null
private var buttonLogin: Button? = null
private var progress: ProgressBar? = null
@@ -42,13 +47,19 @@ class LoginActivity : AppCompatActivity() {
setContentView(R.layout.activity_login)
authLoginViewModel = ViewModelProvider(this,
- AuthLoginViewModel.Factory { AuthLoginViewModel(application) })
+ AuthLoginViewModel.Factory {
+ AuthLoginViewModel(
+ application,
+ (application as MainApplication).sl.authManager,
+ (application as MainApplication).sl.geoNatureAPIClient
+ )
+ })
.get(AuthLoginViewModel::class.java)
.apply {
loginFormState.observe(this@LoginActivity,
- Observer {
+ {
val loginState = it
- ?: return@Observer
+ ?: return@observe
// disable login button unless both username / password is valid
buttonLogin?.isEnabled = loginState.isValid && appSettings != null
@@ -58,9 +69,9 @@ class LoginActivity : AppCompatActivity() {
})
loginResult.observe(this@LoginActivity,
- Observer {
+ {
val loginResult = it
- ?: return@Observer
+ ?: return@observe
progress?.visibility = View.GONE
@@ -70,7 +81,7 @@ class LoginActivity : AppCompatActivity() {
?: R.string.login_failed
)
- return@Observer
+ return@observe
}
showToast(R.string.login_success)
@@ -86,17 +97,17 @@ class LoginActivity : AppCompatActivity() {
editTextUsername = findViewById(R.id.edit_text_username)
editTextUsername?.apply {
- afterTextChanged {
+ editText?.afterTextChanged {
authLoginViewModel.loginDataChanged(
- editTextUsername?.text.toString(),
- editTextPassword?.text.toString()
+ editTextUsername?.editText?.text.toString(),
+ editTextPassword?.editText?.text.toString()
)
}
setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
authLoginViewModel.loginDataChanged(
- editTextUsername?.text.toString(),
- editTextPassword?.text.toString()
+ editTextUsername?.editText?.text.toString(),
+ editTextPassword?.editText?.text.toString()
)
}
}
@@ -104,25 +115,25 @@ class LoginActivity : AppCompatActivity() {
editTextPassword = findViewById(R.id.edit_text_password)
editTextPassword?.apply {
- afterTextChanged {
+ editText?.afterTextChanged {
authLoginViewModel.loginDataChanged(
- editTextUsername?.text.toString(),
- editTextPassword?.text.toString()
+ editTextUsername?.editText?.text.toString(),
+ editTextPassword?.editText?.text.toString()
)
}
setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
authLoginViewModel.loginDataChanged(
- editTextUsername?.text.toString(),
- editTextPassword?.text.toString()
+ editTextUsername?.editText?.text.toString(),
+ editTextPassword?.editText?.text.toString()
)
}
}
- setOnEditorActionListener { _, actionId, _ ->
+ editText?.setOnEditorActionListener { _, actionId, _ ->
when (actionId) {
EditorInfo.IME_ACTION_DONE -> performLogin(
- editTextUsername?.text.toString(),
- editTextPassword?.text.toString()
+ editTextUsername?.editText?.text.toString(),
+ editTextPassword?.editText?.text.toString()
)
}
@@ -133,8 +144,8 @@ class LoginActivity : AppCompatActivity() {
buttonLogin = findViewById(R.id.button_login)
buttonLogin?.setOnClickListener {
performLogin(
- editTextUsername?.text.toString(),
- editTextPassword?.text.toString()
+ editTextUsername?.editText?.text.toString(),
+ editTextPassword?.editText?.text.toString()
)
}
diff --git a/sync/src/main/res/color-night/action_drawable_color.xml b/sync/src/main/res/color-night/action_drawable_color.xml
new file mode 100644
index 00000000..b7973d84
--- /dev/null
+++ b/sync/src/main/res/color-night/action_drawable_color.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/color/action_drawable_color.xml b/sync/src/main/res/color/action_drawable_color.xml
new file mode 100644
index 00000000..2561bbf5
--- /dev/null
+++ b/sync/src/main/res/color/action_drawable_color.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/drawable/background_login.xml b/sync/src/main/res/drawable/background_login.xml
index 37dc295a..5cb2ce92 100644
--- a/sync/src/main/res/drawable/background_login.xml
+++ b/sync/src/main/res/drawable/background_login.xml
@@ -2,7 +2,7 @@
\ No newline at end of file
diff --git a/sync/src/main/res/drawable/ic_action_login.xml b/sync/src/main/res/drawable/ic_action_login.xml
index eb30e9b0..c6e20695 100644
--- a/sync/src/main/res/drawable/ic_action_login.xml
+++ b/sync/src/main/res/drawable/ic_action_login.xml
@@ -2,11 +2,11 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="@color/actionbar_icon_tint"
+ android:tint="?attr/colorControlNormal"
android:tintMode="multiply"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
diff --git a/sync/src/main/res/drawable/ic_action_logout.xml b/sync/src/main/res/drawable/ic_action_logout.xml
index 8e748fa7..6e309efd 100644
--- a/sync/src/main/res/drawable/ic_action_logout.xml
+++ b/sync/src/main/res/drawable/ic_action_logout.xml
@@ -2,11 +2,11 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="@color/actionbar_icon_tint"
+ android:tint="?attr/colorControlNormal"
android:tintMode="multiply"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
diff --git a/sync/src/main/res/drawable/ic_action_refresh.xml b/sync/src/main/res/drawable/ic_action_refresh.xml
index 5dc5ea09..bb3c018e 100644
--- a/sync/src/main/res/drawable/ic_action_refresh.xml
+++ b/sync/src/main/res/drawable/ic_action_refresh.xml
@@ -2,11 +2,11 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="@color/actionbar_icon_tint"
+ android:tint="?attr/colorControlNormal"
android:tintMode="multiply"
android:viewportWidth="24"
android:viewportHeight="24">
diff --git a/sync/src/main/res/drawable/ic_action_settings.xml b/sync/src/main/res/drawable/ic_action_settings.xml
index da1994d1..8f251866 100644
--- a/sync/src/main/res/drawable/ic_action_settings.xml
+++ b/sync/src/main/res/drawable/ic_action_settings.xml
@@ -2,11 +2,11 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="@color/actionbar_icon_tint"
+ android:tint="?attr/colorControlNormal"
android:tintMode="multiply"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
diff --git a/sync/src/main/res/layout/activity_login.xml b/sync/src/main/res/layout/activity_login.xml
index 5edb385d..5697d836 100644
--- a/sync/src/main/res/layout/activity_login.xml
+++ b/sync/src/main/res/layout/activity_login.xml
@@ -22,43 +22,50 @@
app:tint="@android:color/background_light"
tools:ignore="ContentDescription" />
-
+ app:layout_constraintTop_toBottomOf="@android:id/icon">
-
+
+
+
+
+ app:layout_constraintTop_toBottomOf="@+id/edit_text_username">
+
+
+
+
Erreur lors du chargement des paramètres \'%1$s\'
Paramètres \'%1$s\' à jour
+ Téléchargement de la mise à jour…
Les permissions n\'ont pas été accordées
L\'application requiert la permission d\'accéder au contenu de la mémoire de stockage
diff --git a/sync/src/main/res/values-night/themes.xml b/sync/src/main/res/values-night/themes.xml
new file mode 100644
index 00000000..2fe72598
--- /dev/null
+++ b/sync/src/main/res/values-night/themes.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/values/strings.xml b/sync/src/main/res/values/strings.xml
index 49c13ee7..d16bcfbb 100644
--- a/sync/src/main/res/values/strings.xml
+++ b/sync/src/main/res/values/strings.xml
@@ -82,6 +82,7 @@
You are not connected to the Internet.\nSynchronization will resume automatically.
Unable to load settings \'%1$s\'
Settings \'%1$s\' updated
+ Upgrading…
Permissions were not granted
External storage permission is needed
diff --git a/sync/src/main/res/values/styles.xml b/sync/src/main/res/values/styles.xml
deleted file mode 100644
index 0b87d6a9..00000000
--- a/sync/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/sync/src/main/res/values/themes.xml b/sync/src/main/res/values/themes.xml
new file mode 100644
index 00000000..f93b5e70
--- /dev/null
+++ b/sync/src/main/res/values/themes.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sync/src/pag/res/mipmap-hdpi/ic_launcher.png b/sync/src/pag/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..26945527
Binary files /dev/null and b/sync/src/pag/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/sync/src/pag/res/mipmap-hdpi/ic_launcher_round.png b/sync/src/pag/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..6c182605
Binary files /dev/null and b/sync/src/pag/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/sync/src/pag/res/mipmap-mdpi/ic_launcher.png b/sync/src/pag/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..5c77da2e
Binary files /dev/null and b/sync/src/pag/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/sync/src/pag/res/mipmap-mdpi/ic_launcher_round.png b/sync/src/pag/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..8818e418
Binary files /dev/null and b/sync/src/pag/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/sync/src/pag/res/mipmap-xhdpi/ic_launcher.png b/sync/src/pag/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..bf01cbe4
Binary files /dev/null and b/sync/src/pag/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/sync/src/pag/res/mipmap-xhdpi/ic_launcher_round.png b/sync/src/pag/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..dc9c04c1
Binary files /dev/null and b/sync/src/pag/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/sync/src/pag/res/mipmap-xxhdpi/ic_launcher.png b/sync/src/pag/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..2868ad76
Binary files /dev/null and b/sync/src/pag/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/sync/src/pag/res/mipmap-xxhdpi/ic_launcher_round.png b/sync/src/pag/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..88aaeece
Binary files /dev/null and b/sync/src/pag/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/sync/src/pag/res/mipmap-xxxhdpi/ic_launcher.png b/sync/src/pag/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..038bccfb
Binary files /dev/null and b/sync/src/pag/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/sync/src/pag/res/mipmap-xxxhdpi/ic_launcher_round.png b/sync/src/pag/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..ac704dba
Binary files /dev/null and b/sync/src/pag/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/sync/src/pag/res/values/colors.xml b/sync/src/pag/res/values/colors.xml
new file mode 100644
index 00000000..aaac31bd
--- /dev/null
+++ b/sync/src/pag/res/values/colors.xml
@@ -0,0 +1,9 @@
+
+
+
+
+ #af272f
+ #790008
+ #eaaa00
+
+
diff --git a/sync/src/pagDebug/res/mipmap-hdpi/ic_launcher.png b/sync/src/pagDebug/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..8a9a75e6
Binary files /dev/null and b/sync/src/pagDebug/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/sync/src/pagDebug/res/mipmap-hdpi/ic_launcher_round.png b/sync/src/pagDebug/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..68666dbf
Binary files /dev/null and b/sync/src/pagDebug/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/sync/src/pagDebug/res/mipmap-mdpi/ic_launcher.png b/sync/src/pagDebug/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..f2ec14ee
Binary files /dev/null and b/sync/src/pagDebug/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/sync/src/pagDebug/res/mipmap-mdpi/ic_launcher_round.png b/sync/src/pagDebug/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..816f561f
Binary files /dev/null and b/sync/src/pagDebug/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/sync/src/pagDebug/res/mipmap-xhdpi/ic_launcher.png b/sync/src/pagDebug/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..752e17f0
Binary files /dev/null and b/sync/src/pagDebug/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/sync/src/pagDebug/res/mipmap-xhdpi/ic_launcher_round.png b/sync/src/pagDebug/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..e5c496aa
Binary files /dev/null and b/sync/src/pagDebug/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/sync/src/pagDebug/res/mipmap-xxhdpi/ic_launcher.png b/sync/src/pagDebug/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..af154104
Binary files /dev/null and b/sync/src/pagDebug/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/sync/src/pagDebug/res/mipmap-xxhdpi/ic_launcher_round.png b/sync/src/pagDebug/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..c8f2d0e5
Binary files /dev/null and b/sync/src/pagDebug/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/sync/src/pagDebug/res/mipmap-xxxhdpi/ic_launcher.png b/sync/src/pagDebug/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..7072f024
Binary files /dev/null and b/sync/src/pagDebug/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/sync/src/pagDebug/res/mipmap-xxxhdpi/ic_launcher_round.png b/sync/src/pagDebug/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..bb996579
Binary files /dev/null and b/sync/src/pagDebug/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/sync/src/test/java/fr/geonature/sync/MockitoKotlinHelper.kt b/sync/src/test/java/fr/geonature/sync/MockitoKotlinHelper.kt
new file mode 100644
index 00000000..75e1183a
--- /dev/null
+++ b/sync/src/test/java/fr/geonature/sync/MockitoKotlinHelper.kt
@@ -0,0 +1,35 @@
+package fr.geonature.sync
+
+import org.mockito.ArgumentCaptor
+import org.mockito.Mockito
+
+/**
+ * Helper functions that are workarounds to kotlin Runtime Exceptions when using kotlin.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+object MockitoKotlinHelper {
+
+ /**
+ * Returns Mockito.eq() as nullable type to avoid [IllegalStateException] when null is returned.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ */
+ fun eq(obj: T): T = Mockito.eq(obj)
+
+ /**
+ * Returns Mockito.any() as nullable type to avoid [IllegalStateException] when null is returned.
+ */
+ fun any(type: Class): T = Mockito.any(type)
+
+ /**
+ * Returns ArgumentCaptor.capture() as nullable type to avoid [IllegalStateException] when null is returned.
+ */
+ fun capture(argumentCaptor: ArgumentCaptor): T = argumentCaptor.capture()
+
+ /**
+ * Helper function for creating an argumentCaptor in kotlin.
+ */
+ inline fun argumentCaptor(): ArgumentCaptor =
+ ArgumentCaptor.forClass(T::class.java)
+}
diff --git a/sync/src/test/java/fr/geonature/sync/auth/AuthManagerTest.kt b/sync/src/test/java/fr/geonature/sync/auth/AuthManagerTest.kt
index 2b8469aa..41ffa57f 100644
--- a/sync/src/test/java/fr/geonature/sync/auth/AuthManagerTest.kt
+++ b/sync/src/test/java/fr/geonature/sync/auth/AuthManagerTest.kt
@@ -1,167 +1,355 @@
package fr.geonature.sync.auth
import android.app.Application
-import androidx.test.core.app.ApplicationProvider
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.lifecycle.Observer
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import fr.geonature.commons.fp.Failure
+import fr.geonature.commons.fp.getOrElse
+import fr.geonature.commons.util.NetworkHandler
import fr.geonature.commons.util.add
+import fr.geonature.commons.util.toIsoDateString
+import fr.geonature.sync.MockitoKotlinHelper.any
+import fr.geonature.sync.api.IGeoNatureAPIClient
+import fr.geonature.sync.api.model.AuthCredentials
import fr.geonature.sync.api.model.AuthLogin
+import fr.geonature.sync.api.model.AuthLoginError
import fr.geonature.sync.api.model.AuthUser
import kotlinx.coroutines.runBlocking
-import okhttp3.Cookie
+import okhttp3.ResponseBody
import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.Mockito.atLeastOnce
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations.initMocks
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
+import retrofit2.Call
+import retrofit2.Response
+import java.io.StringReader
import java.util.Calendar
import java.util.Date
/**
- * Unit tests about [AuthManager].
+ * Unit tests about [IAuthManager].
*
- * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ * @author S. Grimault
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class AuthManagerTest {
- private lateinit var authManager: AuthManager
+ @get:Rule
+ val rule = InstantTaskExecutorRule()
+
+ private lateinit var authManager: IAuthManager
+
+ @Mock
+ private lateinit var geoNatureAPIClient: IGeoNatureAPIClient
+
+ @Mock
+ private lateinit var networkHandler: NetworkHandler
+
+ @Mock
+ private lateinit var authResponseCall: Call
+
+ @Mock
+ private lateinit var authLoginResponse: Response
+
+ @Mock
+ private lateinit var authLoginErrorResponse: ResponseBody
+
+ @Mock
+ private lateinit var isLoggedInObserver: Observer
@Before
fun setUp() {
- val application = ApplicationProvider.getApplicationContext()
- authManager = AuthManager.getInstance(application)
- authManager.preferenceManager
- .edit()
- .clear()
- .commit()
+ initMocks(this)
+
+ val application = getApplicationContext()
+ authManager = AuthManagerImpl(
+ application,
+ geoNatureAPIClient,
+ networkHandler
+ )
+ authManager.isLoggedIn.observeForever(isLoggedInObserver)
}
@Test
- fun testGetUndefinedCookie() {
- // when reading non existing cookie
- val noSuchCookie = authManager.getCookie()
+ fun `Should return undefined AuthLogin at startup`() {
+ // when reading non existing AuthLogin instance
+ val noSuchAuthLogin = runBlocking { authManager.getAuthLogin() }
// then
- assertNull(noSuchCookie)
+ assertNull(noSuchAuthLogin)
+ verify(
+ isLoggedInObserver,
+ atLeastOnce()
+ ).onChanged(false)
}
@Test
- fun testSaveAndGetCookie() {
- val cookie = Cookie
- .Builder()
- .name("token")
- .value("some_value")
- .domain("demo.geonature.fr")
- .path("/")
- .expiresAt(
- Date().add(
- Calendar.HOUR,
- 1
- ).time
+ fun `Should perform a successful login`() {
+ // given a successful login from backend
+ val authLogin = AuthLogin(
+ AuthUser(
+ 1234,
+ "Grimault",
+ "Sebastien",
+ 2,
+ 8,
+ "sgr"
+ ),
+ Date().add(
+ Calendar.HOUR,
+ 1
)
- .build()
+ )
- // when setting new cookie
- authManager.setCookie(cookie)
+ `when`(networkHandler.isNetworkAvailable()).thenReturn(true)
+ `when`(geoNatureAPIClient.authLogin(any(AuthCredentials::class.java))).thenReturn(authResponseCall)
+ `when`(authResponseCall.execute()).thenReturn(authLoginResponse)
+ `when`(authLoginResponse.isSuccessful).thenReturn(true)
+ `when`(authLoginResponse.body()).thenReturn(authLogin)
- // when reading this cookie from manager
- val cookieFromManager = authManager.getCookie()
+ verify(
+ isLoggedInObserver,
+ atLeastOnce()
+ ).onChanged(false)
+
+ // when perform authentication
+ val auth = runBlocking {
+ authManager.login(
+ "sgr",
+ "pass",
+ 2
+ )
+ }
// then
+ assertTrue(auth.isRight)
+ assertEquals(
+ authLogin,
+ auth.getOrElse(null)
+ )
+ verify(
+ isLoggedInObserver,
+ atLeastOnce()
+ ).onChanged(true)
+
+ val authLoginFromManager = runBlocking { authManager.getAuthLogin() }
+
+ assertEquals(
+ authLogin.user,
+ authLoginFromManager?.user
+ )
assertEquals(
- cookie,
- cookieFromManager
+ authLogin.expires.toIsoDateString(),
+ authLoginFromManager?.expires?.toIsoDateString()
)
+ assertTrue(authManager.isLoggedIn.value == true)
+ verify(
+ isLoggedInObserver,
+ atLeastOnce()
+ ).onChanged(true)
}
@Test
- fun testGetUndefinedAuthLogin() {
- // when reading non existing AuthLogin instance
- val noSuchAuthLogin = runBlocking { authManager.getAuthLogin() }
+ fun `Should return a network failure when no connection while trying to login`() {
+ `when`(networkHandler.isNetworkAvailable()).thenReturn(false)
+
+ // when perform authentication
+ val auth = runBlocking {
+ authManager.login(
+ "sgr",
+ "pass",
+ 2
+ )
+ }
// then
- assertNull(noSuchAuthLogin)
+ assertTrue(auth.isLeft)
+ auth.fold({ assertTrue(it is Failure.NetworkFailure) },
+ {})
+ verify(
+ geoNatureAPIClient,
+ never()
+ ).authLogin(any(AuthCredentials::class.java))
+ }
+
+ @Test
+ fun `Should return AuthFailure if no successful response while trying to login`() {
+ `when`(networkHandler.isNetworkAvailable()).thenReturn(true)
+ `when`(geoNatureAPIClient.authLogin(any(AuthCredentials::class.java))).thenReturn(authResponseCall)
+ `when`(authResponseCall.execute()).thenReturn(authLoginResponse)
+ `when`(authLoginResponse.isSuccessful).thenReturn(false)
+ `when`(authLoginResponse.errorBody()).thenReturn(authLoginErrorResponse)
+ `when`(authLoginErrorResponse.charStream()).thenReturn(StringReader("{\"type\": \"login\", \"msg\": \"No user found with the username 'sgr' for the application with id 2\"}"))
+
+ // when perform authentication
+ val auth = runBlocking {
+ authManager.login(
+ "sgr",
+ "pass",
+ 2
+ )
+ }
+
+ // then
+ assertTrue(auth.isLeft)
+ auth.fold({
+ assertEquals(
+ AuthFailure(
+ AuthLoginError(
+ "login",
+ "No user found with the username 'sgr' for the application with id 2"
+ )
+ ),
+ it
+ )
+ },
+ {})
}
@Test
- fun testSaveAndGetAuthLogin() {
- // given an AuthLogin instance to save and read
+ fun `Should return ServerFailure if no successful response with no body while trying to login`() {
+ `when`(networkHandler.isNetworkAvailable()).thenReturn(true)
+ `when`(geoNatureAPIClient.authLogin(any(AuthCredentials::class.java))).thenReturn(authResponseCall)
+ `when`(authResponseCall.execute()).thenReturn(authLoginResponse)
+ `when`(authLoginResponse.isSuccessful).thenReturn(false)
+ `when`(authLoginResponse.errorBody()).thenReturn(authLoginErrorResponse)
+
+ // when perform authentication
+ val auth = runBlocking {
+ authManager.login(
+ "sgr",
+ "pass",
+ 2
+ )
+ }
+
+ // then
+ assertTrue(auth.isLeft)
+ auth.fold({
+ assertTrue(it is Failure.ServerFailure)
+ },
+ {})
+ }
+
+ @Test
+ fun `Should perform logout`() {
+ // given a successful login from backend
val authLogin = AuthLogin(
AuthUser(
- 1234L,
- "Admin",
- "Test",
- 3,
- 1,
- "admin"
+ 1234,
+ "Grimault",
+ "Sebastien",
+ 2,
+ 8,
+ "sgr"
),
- Calendar
- .getInstance()
- .apply {
- add(
- Calendar.DAY_OF_YEAR,
- 7
- )
- set(
- Calendar.MILLISECOND,
- 0
- )
- }.time
+ Date().add(
+ Calendar.HOUR,
+ 1
+ )
)
- // when saving this AuthLogin
- val saved = runBlocking { authManager.setAuthLogin(authLogin) }
+ `when`(networkHandler.isNetworkAvailable()).thenReturn(true)
+ `when`(geoNatureAPIClient.authLogin(any(AuthCredentials::class.java))).thenReturn(authResponseCall)
+ `when`(authResponseCall.execute()).thenReturn(authLoginResponse)
+ `when`(authLoginResponse.isSuccessful).thenReturn(true)
+ `when`(authLoginResponse.body()).thenReturn(authLogin)
- // then
- assertTrue(saved)
-
- // when reading this AuthLogin from manager
- val authLoginFromManager = runBlocking { authManager.getAuthLogin() }
+ // when perform authentication
+ val auth = runBlocking {
+ authManager.login(
+ "sgr",
+ "pass",
+ 2
+ )
+ }
// then
- assertNotNull(authLoginFromManager)
+ assertTrue(auth.isRight)
assertEquals(
authLogin,
- authLoginFromManager
+ auth.getOrElse(null)
)
+
+ // when perform logout
+ val disconnected = runBlocking { authManager.logout() }
+ val authLoginFromManager = runBlocking { authManager.getAuthLogin() }
+
+ // then
+ verify(
+ geoNatureAPIClient,
+ atLeastOnce()
+ ).logout()
+ assertTrue(disconnected)
+ assertNull(authLoginFromManager)
+ verify(
+ isLoggedInObserver,
+ atLeastOnce()
+ ).onChanged(false)
}
@Test
- fun testSaveAndGetExpiredAuthLogin() {
- // given an expired AuthLogin instance to save and read
+ fun `Should return no AuthLogin if the current session is expired`() {
+ // given a successful login from backend
val authLogin = AuthLogin(
AuthUser(
- 1234L,
- "Admin",
- "Test",
- 3,
- 1,
- "admin"
+ 1234,
+ "Grimault",
+ "Sebastien",
+ 2,
+ 8,
+ "sgr"
),
- Calendar
- .getInstance()
- .apply {
- add(
- Calendar.DAY_OF_YEAR,
- -1
- )
- }.time
+ // with an expiration date somewhere in the past
+ Date().add(
+ Calendar.HOUR,
+ -1
+ )
)
- // when saving this AuthLogin
- val saved = runBlocking { authManager.setAuthLogin(authLogin) }
+ `when`(networkHandler.isNetworkAvailable()).thenReturn(true)
+ `when`(geoNatureAPIClient.authLogin(any(AuthCredentials::class.java))).thenReturn(authResponseCall)
+ `when`(authResponseCall.execute()).thenReturn(authLoginResponse)
+ `when`(authLoginResponse.isSuccessful).thenReturn(true)
+ `when`(authLoginResponse.body()).thenReturn(authLogin)
+
+ // when perform authentication
+ val auth = runBlocking {
+ authManager.login(
+ "sgr",
+ "pass",
+ 2
+ )
+ }
// then
- assertTrue(saved)
+ assertTrue(auth.isRight)
+ assertEquals(
+ authLogin,
+ auth.getOrElse(null)
+ )
- // when reading this AuthLogin from manager
+ // when checking AuthLogin from manager
val authLoginFromManager = runBlocking { authManager.getAuthLogin() }
// then
assertNull(authLoginFromManager)
+ verify(
+ isLoggedInObserver,
+ atLeastOnce()
+ ).onChanged(false)
}
}
diff --git a/sync/src/test/java/fr/geonature/sync/auth/CookieManagerTest.kt b/sync/src/test/java/fr/geonature/sync/auth/CookieManagerTest.kt
new file mode 100644
index 00000000..a053d9c4
--- /dev/null
+++ b/sync/src/test/java/fr/geonature/sync/auth/CookieManagerTest.kt
@@ -0,0 +1,129 @@
+package fr.geonature.sync.auth
+
+import android.app.Application
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import fr.geonature.commons.util.add
+import okhttp3.Cookie
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.util.Calendar
+import java.util.Date
+
+/**
+ * Unit tests about [ICookieManager].
+ *
+ * @author S. Grimault
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class)
+class CookieManagerTest {
+
+ private lateinit var cookieManager: ICookieManager
+
+ @Before
+ fun setUp() {
+ val application = getApplicationContext()
+ cookieManager = CookieManagerImpl(application)
+ }
+
+ @Test
+ fun `Should return undefined cookie at startup`() {
+ // when reading non existing cookie
+ val noSuchCookie = cookieManager.cookie
+
+ // then
+ assertNull(noSuchCookie)
+ }
+
+ @Test
+ fun `Should save and return cookie`() {
+ // given a valid Cookie to save and read
+ val cookie = Cookie
+ .Builder()
+ .name("token")
+ .value("some_value")
+ .domain("demo.geonature.fr")
+ .path("/")
+ .expiresAt(
+ Date().add(
+ Calendar.HOUR,
+ 1
+ ).time
+ )
+ .build()
+
+ // when setting new cookie
+ cookieManager.cookie = cookie
+
+ // when reading this cookie from manager
+ val cookieFromManager = cookieManager.cookie
+
+ // then
+ assertEquals(
+ cookie,
+ cookieFromManager
+ )
+ }
+
+ @Test
+ fun `Should return nothing if the current cookie is expired`() {
+ // given an expired Cookie to save and read
+ val cookie = Cookie
+ .Builder()
+ .name("token")
+ .value("some_value")
+ .domain("demo.geonature.fr")
+ .path("/")
+ .expiresAt(
+ Date().add(
+ Calendar.HOUR,
+ -1
+ ).time
+ )
+ .build()
+
+ // when setting new cookie
+ cookieManager.cookie = cookie
+
+ // when reading this cookie from manager
+ val cookieFromManager = cookieManager.cookie
+
+ // then
+ assertNull(cookieFromManager)
+ }
+
+ @Test
+ fun `Should return nothing if the current cookie was cleared`() {
+ // given a valid Cookie to save and read
+ val cookie = Cookie
+ .Builder()
+ .name("token")
+ .value("some_value")
+ .domain("demo.geonature.fr")
+ .path("/")
+ .expiresAt(
+ Date().add(
+ Calendar.HOUR,
+ 1
+ ).time
+ )
+ .build()
+
+ // when setting new cookie
+ cookieManager.cookie = cookie
+
+ // when clear cookie from manager
+ cookieManager.clearCookie()
+
+ // and reading this cookie from manager
+ val cookieFromManager = cookieManager.cookie
+
+ // then
+ assertNull(cookieFromManager)
+ }
+}
\ No newline at end of file
diff --git a/sync/version.properties b/sync/version.properties
index 84c1501b..ba5ce6da 100644
--- a/sync/version.properties
+++ b/sync/version.properties
@@ -1,2 +1,2 @@
-#Wed Jun 23 22:07:11 CEST 2021
-VERSION_CODE=2980
+#Thu Jul 29 10:14:58 CEST 2021
+VERSION_CODE=3150