diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index ee549751..5285c6f1 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -33,16 +33,7 @@
-
-
-
-
-
-
-
diff --git a/build.gradle b/build.gradle
index 57c51d68..fc474361 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.3.40'
+ ext.kotlin_version = '1.3.50'
repositories {
google()
@@ -10,7 +10,7 @@ buildscript {
}
dependencies {
- classpath 'com.android.tools.build:gradle:3.4.1'
+ classpath 'com.android.tools.build:gradle:3.5.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
diff --git a/commons/build.gradle b/commons/build.gradle
index 0cefff06..89bc0330 100644
--- a/commons/build.gradle
+++ b/commons/build.gradle
@@ -2,7 +2,7 @@ apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
-version = "0.1.2"
+version = "0.3.1"
android {
compileSdkVersion 28
@@ -42,18 +42,20 @@ dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
- implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1"
- implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.1"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0"
implementation 'com.google.android.material:material:1.0.0'
- implementation 'androidx.appcompat:appcompat:1.1.0-beta01'
- implementation 'androidx.preference:preference:1.0.0'
+ implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0-alpha04"
+ implementation 'androidx.appcompat:appcompat:1.1.0'
+ implementation 'androidx.preference:preference:1.1.0'
api 'androidx.room:room-runtime:2.1.0'
testImplementation 'junit:junit:4.12'
testImplementation 'androidx.test:core:1.2.0'
- testImplementation 'org.mockito:mockito-core:2.27.0'
- testImplementation 'org.robolectric:robolectric:4.2'
+ testImplementation 'androidx.arch.core:core-testing:2.1.0'
+ testImplementation 'org.mockito:mockito-core:3.0.0'
+ testImplementation 'org.robolectric:robolectric:4.3'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}
\ No newline at end of file
diff --git a/commons/src/main/java/fr/geonature/commons/data/AbstractTaxon.kt b/commons/src/main/java/fr/geonature/commons/data/AbstractTaxon.kt
new file mode 100644
index 00000000..903a8f3f
--- /dev/null
+++ b/commons/src/main/java/fr/geonature/commons/data/AbstractTaxon.kt
@@ -0,0 +1,105 @@
+package fr.geonature.commons.data
+
+import android.os.Parcel
+import android.os.Parcelable
+import android.provider.BaseColumns
+import androidx.room.ColumnInfo
+import androidx.room.PrimaryKey
+
+/**
+ * Base taxon.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+abstract class AbstractTaxon : Parcelable {
+
+ /**
+ * The unique ID of the taxon.
+ */
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = COLUMN_ID)
+ var id: Long
+
+ /**
+ * The default name of the taxon.
+ */
+ @ColumnInfo(name = COLUMN_NAME)
+ var name: String
+
+ /**
+ * The description of the taxon.
+ */
+ @ColumnInfo(name = COLUMN_DESCRIPTION)
+ var description: String?
+
+ /**
+ * Whether the taxon is part of the heritage.
+ */
+ @ColumnInfo(name = COLUMN_HERITAGE)
+ var heritage: Boolean = false
+
+ constructor(id: Long,
+ name: String,
+ description: String?,
+ heritage: Boolean = false) {
+ this.id = id
+ this.name = name
+ this.description = description
+ this.heritage = heritage
+ }
+
+ constructor(source: Parcel) : this(source.readLong(),
+ source.readString() ?: "",
+ source.readString(),
+ source.readByte() == 1.toByte())
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is AbstractTaxon) return false
+
+ if (id != other.id) return false
+ if (name != other.name) return false
+ if (description != other.description) return false
+ if (heritage != other.heritage) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = id.hashCode()
+ result = 31 * result + name.hashCode()
+ result = 31 * result + (description?.hashCode() ?: 0)
+ result = 31 * result + heritage.hashCode()
+
+ return result
+ }
+
+ override fun describeContents(): Int {
+ return 0
+ }
+
+ override fun writeToParcel(dest: Parcel?,
+ flags: Int) {
+ dest?.writeLong(id)
+ dest?.writeString(name)
+ dest?.writeString(description)
+ dest?.writeByte((if (heritage) 1 else 0).toByte()) // as boolean value
+ }
+
+ companion object {
+
+ /**
+ * The name of the 'ID' column.
+ */
+ const val COLUMN_ID = BaseColumns._ID
+
+ const val COLUMN_NAME = "name"
+ const val COLUMN_DESCRIPTION = "description"
+ const val COLUMN_HERITAGE = "heritage"
+
+ val DEFAULT_PROJECTION = arrayOf(COLUMN_ID,
+ COLUMN_NAME,
+ COLUMN_DESCRIPTION,
+ COLUMN_HERITAGE)
+ }
+}
diff --git a/commons/src/main/java/fr/geonature/commons/data/Converters.kt b/commons/src/main/java/fr/geonature/commons/data/Converters.kt
new file mode 100644
index 00000000..a8a107e0
--- /dev/null
+++ b/commons/src/main/java/fr/geonature/commons/data/Converters.kt
@@ -0,0 +1,30 @@
+package fr.geonature.commons.data
+
+import androidx.room.TypeConverter
+import java.util.Date
+
+/**
+ * Type converters.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+object Converters {
+
+ /**
+ * Converts timestamp to Date.
+ */
+ @TypeConverter
+ @JvmStatic
+ fun fromTimestamp(value: Long?): Date? {
+ return value?.let { Date(it) }
+ }
+
+ /**
+ * Converts Date to timestamp.
+ */
+ @TypeConverter
+ @JvmStatic
+ fun dateToTimestamp(date: Date?): Long? {
+ return date?.time
+ }
+}
\ No newline at end of file
diff --git a/commons/src/main/java/fr/geonature/commons/data/InputObserver.kt b/commons/src/main/java/fr/geonature/commons/data/InputObserver.kt
index 99852fdd..1b8f26a0 100644
--- a/commons/src/main/java/fr/geonature/commons/data/InputObserver.kt
+++ b/commons/src/main/java/fr/geonature/commons/data/InputObserver.kt
@@ -4,9 +4,11 @@ import android.database.Cursor
import android.os.Parcel
import android.os.Parcelable
import android.provider.BaseColumns
+import android.util.Log
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
+import fr.geonature.commons.util.get
/**
* Describes an input observer.
@@ -19,22 +21,22 @@ data class InputObserver(
/**
* The unique ID of the input observer.
*/
- @PrimaryKey(autoGenerate = true) @ColumnInfo(index = true,
- name = COLUMN_ID) var id: Long,
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(index = true,
+ name = COLUMN_ID)
+ var id: Long,
/**
* The last name of the input observer.
*/
- @ColumnInfo(name = COLUMN_LASTNAME) var lastname: String?,
+ @ColumnInfo(name = COLUMN_LASTNAME)
+ var lastname: String?,
/**
* The first name of the input observer.
*/
- @ColumnInfo(name = COLUMN_FIRSTNAME) var firstname: String?) : Parcelable {
-
- private constructor(builder: Builder) : this(builder.id!!,
- builder.lastname,
- builder.firstname)
+ @ColumnInfo(name = COLUMN_FIRSTNAME)
+ var firstname: String?) : Parcelable {
private constructor(source: Parcel) : this(source.readLong(),
source.readString(),
@@ -51,23 +53,10 @@ data class InputObserver(
dest?.writeString(firstname)
}
- data class Builder(var id: Long? = null,
- var lastname: String? = null,
- var firstname: String? = null) {
- fun id(id: Long) = apply { this.id = id }
- fun lastname(lastname: String?) = apply { this.lastname = lastname }
- fun firstname(firstname: String?) = apply { this.firstname = firstname }
-
- @Throws(java.lang.IllegalArgumentException::class)
- fun build() : InputObserver {
- if (id == null) throw IllegalArgumentException("InputObserver with null ID is not allowed")
-
- return InputObserver(this)
- }
- }
-
companion object {
+ private val TAG = InputObserver::class.java.name
+
/**
* The name of the 'observers' table.
*/
@@ -88,6 +77,10 @@ data class InputObserver(
*/
const val COLUMN_FIRSTNAME = "firstname"
+ val DEFAULT_PROJECTION = arrayOf(COLUMN_ID,
+ COLUMN_LASTNAME,
+ COLUMN_FIRSTNAME)
+
/**
* Create a new [InputObserver] from the specified [Cursor].
*
@@ -100,22 +93,29 @@ data class InputObserver(
return null
}
- return InputObserver(cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_ID)),
- cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_LASTNAME)),
- cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_FIRSTNAME)))
+ return try {
+ InputObserver(requireNotNull(cursor.get(COLUMN_ID)),
+ cursor.get(COLUMN_LASTNAME),
+ cursor.get(COLUMN_FIRSTNAME))
+ }
+ catch (iae: IllegalArgumentException) {
+ Log.w(TAG,
+ iae.message)
+
+ null
+ }
}
@JvmField
- val CREATOR: Parcelable.Creator =
- object : Parcelable.Creator {
+ val CREATOR: Parcelable.Creator = object : Parcelable.Creator {
- override fun createFromParcel(source: Parcel): InputObserver {
- return InputObserver(source)
- }
+ override fun createFromParcel(source: Parcel): InputObserver {
+ return InputObserver(source)
+ }
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
- }
+ override fun newArray(size: Int): Array {
+ return arrayOfNulls(size)
}
+ }
}
}
\ No newline at end of file
diff --git a/commons/src/main/java/fr/geonature/commons/data/Provider.kt b/commons/src/main/java/fr/geonature/commons/data/Provider.kt
index d2aa5dc8..7fc57e0a 100644
--- a/commons/src/main/java/fr/geonature/commons/data/Provider.kt
+++ b/commons/src/main/java/fr/geonature/commons/data/Provider.kt
@@ -2,6 +2,7 @@ package fr.geonature.commons.data
import android.content.Context
import android.net.Uri
+import android.net.Uri.withAppendedPath
import fr.geonature.commons.R
/**
@@ -24,15 +25,13 @@ object Provider {
/**
* Build resource [Uri].
*/
- fun buildUri(
- resource: String,
- path: String = ""): Uri {
+ fun buildUri(resource: String,
+ vararg path: String): Uri {
val baseUri = Uri.parse("content://$AUTHORITY/$resource")
return if (path.isEmpty()) baseUri
- else Uri.withAppendedPath(
- baseUri,
- path)
+ else withAppendedPath(baseUri,
+ path.asSequence().filter { it.isNotBlank() }.joinToString("/"))
}
}
\ No newline at end of file
diff --git a/commons/src/main/java/fr/geonature/commons/data/Taxon.kt b/commons/src/main/java/fr/geonature/commons/data/Taxon.kt
index 2d9c3ea9..4643a20c 100644
--- a/commons/src/main/java/fr/geonature/commons/data/Taxon.kt
+++ b/commons/src/main/java/fr/geonature/commons/data/Taxon.kt
@@ -3,10 +3,9 @@ package fr.geonature.commons.data
import android.database.Cursor
import android.os.Parcel
import android.os.Parcelable
-import android.provider.BaseColumns
-import androidx.room.ColumnInfo
+import android.util.Log
import androidx.room.Entity
-import androidx.room.PrimaryKey
+import fr.geonature.commons.util.get
/**
* Describes a taxon.
@@ -14,84 +13,27 @@ import androidx.room.PrimaryKey
* @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
*/
@Entity(tableName = Taxon.TABLE_NAME)
-data class Taxon(
+class Taxon : AbstractTaxon {
- /**
- * The unique ID of the taxon.
- */
- @PrimaryKey(autoGenerate = true) @ColumnInfo(index = true,
- name = COLUMN_ID) var id: Long,
+ constructor(id: Long,
+ name: String,
+ description: String?,
+ heritage: Boolean = false) : super(id,
+ name,
+ description,
+ heritage)
- /**
- * The default name of the taxon.
- */
- @ColumnInfo(name = COLUMN_NAME) var name: String?,
-
- /**
- * The info of the taxon.
- */
- @ColumnInfo(name = COLUMN_DESCRIPTION) var description: String?,
-
- /**
- * Whether the taxon is part of the heritage.
- */
- @ColumnInfo(name = COLUMN_HERITAGE) var heritage: Boolean) : Parcelable {
-
- private constructor(builder: Builder) : this(builder.id!!,
- builder.name,
- builder.description,
- builder.heritage)
-
- private constructor(source: Parcel) : this(source.readLong(),
- source.readString(),
- source.readString(),
- source.readByte() == 1.toByte())
-
- override fun describeContents(): Int {
- return 0
- }
-
- override fun writeToParcel(dest: Parcel?,
- flags: Int) {
- dest?.writeLong(id)
- dest?.writeString(name)
- dest?.writeString(description)
- dest?.writeByte((if (heritage) 1 else 0).toByte()) // as boolean value
- }
-
- data class Builder(var id: Long? = null,
- var name: String? = null,
- var description: String? = null,
- var heritage: Boolean = false) {
- fun id(id: Long) = apply { this.id = id }
- fun name(name: String?) = apply { this.name = name }
- fun description(description: String?) = apply { this.description = description }
- fun heritage(heritage: Boolean) = apply { this.heritage = heritage }
-
- @Throws(java.lang.IllegalArgumentException::class)
- fun build(): Taxon {
- if (id == null) throw IllegalArgumentException("Taxon with null ID is not allowed")
-
- return Taxon(this)
- }
- }
+ constructor(source: Parcel) : super(source)
companion object {
+ private val TAG = Taxon::class.java.name
+
/**
* The name of the 'taxa' table.
*/
const val TABLE_NAME = "taxa"
- /**
- * The name of the 'ID' column.
- */
- const val COLUMN_ID = BaseColumns._ID
-
- const val COLUMN_NAME = "name"
- const val COLUMN_DESCRIPTION = "description"
- const val COLUMN_HERITAGE = "heritage"
-
/**
* Create a new [Taxon] from the specified [Cursor].
*
@@ -104,14 +46,21 @@ data class Taxon(
return null
}
- return Taxon(cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_ID)),
- cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)),
- cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_DESCRIPTION)),
- cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_HERITAGE))?.toBoolean()
- ?: false)
+ return try {
+ Taxon(requireNotNull(cursor.get(COLUMN_ID)),
+ requireNotNull(cursor.get(COLUMN_NAME)),
+ cursor.get(COLUMN_DESCRIPTION),
+ requireNotNull(cursor.get(COLUMN_HERITAGE,
+ false)))
+ }
+ catch (iae: IllegalArgumentException) {
+ Log.w(TAG,
+ iae.message)
+
+ null
+ }
}
- @Suppress("unused")
@JvmField
val CREATOR: Parcelable.Creator = object : Parcelable.Creator {
diff --git a/commons/src/main/java/fr/geonature/commons/data/TaxonArea.kt b/commons/src/main/java/fr/geonature/commons/data/TaxonArea.kt
new file mode 100644
index 00000000..f15ee0bf
--- /dev/null
+++ b/commons/src/main/java/fr/geonature/commons/data/TaxonArea.kt
@@ -0,0 +1,129 @@
+package fr.geonature.commons.data
+
+import android.database.Cursor
+import android.os.Parcel
+import android.os.Parcelable
+import android.util.Log
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.TypeConverters
+import fr.geonature.commons.data.Converters.fromTimestamp
+import fr.geonature.commons.util.get
+import java.util.Date
+
+/**
+ * Describes a taxon data within an area.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+@Entity(tableName = TaxonArea.TABLE_NAME,
+ primaryKeys = [TaxonArea.COLUMN_TAXON_ID, TaxonArea.COLUMN_AREA_ID],
+ foreignKeys = [ForeignKey(entity = Taxon::class,
+ parentColumns = [AbstractTaxon.COLUMN_ID],
+ childColumns = [TaxonArea.COLUMN_TAXON_ID],
+ onDelete = ForeignKey.CASCADE)])
+@TypeConverters(Converters::class)
+data class TaxonArea(
+
+ /**
+ * The foreign key taxon ID of taxon.
+ */
+ @ColumnInfo(name = COLUMN_TAXON_ID)
+ var taxonId: Long,
+
+ @ColumnInfo(name = COLUMN_AREA_ID)
+ var areaId: Long,
+
+ @ColumnInfo(name = COLUMN_COLOR)
+ var color: String?,
+
+ @ColumnInfo(name = COLUMN_NUMBER_OF_OBSERVERS)
+ var numberOfObservers: Int,
+
+ @ColumnInfo(name = COLUMN_LAST_UPDATED_AT)
+ var lastUpdatedAt: Date?) : Parcelable {
+
+ private constructor(source: Parcel) : this(source.readLong(),
+ source.readLong(),
+ source.readString(),
+ source.readInt(),
+ source.readSerializable() as Date)
+
+ override fun describeContents(): Int {
+ return 0
+ }
+
+ override fun writeToParcel(dest: Parcel?,
+ flags: Int) {
+ dest?.writeLong(taxonId)
+ dest?.writeLong(areaId)
+ dest?.writeString(color)
+ dest?.writeInt(numberOfObservers)
+ dest?.writeSerializable(lastUpdatedAt)
+ }
+
+ companion object {
+
+ private val TAG = TaxonArea::class.java.name
+
+ /**
+ * The name of the 'taxa_area' table.
+ */
+ const val TABLE_NAME = "taxa_area"
+
+ const val COLUMN_TAXON_ID = "taxon_id"
+ const val COLUMN_AREA_ID = "area_id"
+ const val COLUMN_COLOR = "color"
+ const val COLUMN_NUMBER_OF_OBSERVERS = "nb_observers"
+ const val COLUMN_LAST_UPDATED_AT = "last_updated_at"
+
+ val DEFAULT_PROJECTION = arrayOf(COLUMN_TAXON_ID,
+ COLUMN_AREA_ID,
+ COLUMN_COLOR,
+ COLUMN_NUMBER_OF_OBSERVERS,
+ COLUMN_LAST_UPDATED_AT)
+
+ /**
+ * Create a new [TaxonArea] from the specified [Cursor].
+ *
+ * @param cursor A valid [Cursor]
+ *
+ * @return A newly created [TaxonArea] instance
+ */
+ fun fromCursor(cursor: Cursor): TaxonArea? {
+ if (cursor.isClosed) {
+ return null
+ }
+
+ return try {
+ TaxonArea(requireNotNull(cursor.get(COLUMN_TAXON_ID)),
+ requireNotNull(cursor.get(COLUMN_AREA_ID)),
+ cursor.get(COLUMN_COLOR,
+ "#00000000"),
+ requireNotNull(cursor.get(COLUMN_NUMBER_OF_OBSERVERS,
+ 0)),
+ cursor.get(COLUMN_LAST_UPDATED_AT,
+ 0L).run { if (this == 0L) null else fromTimestamp(this) })
+ }
+ catch (iae: IllegalArgumentException) {
+ Log.w(TAG,
+ iae.message)
+
+ null
+ }
+ }
+
+ @JvmField
+ val CREATOR: Parcelable.Creator = object : Parcelable.Creator {
+
+ override fun createFromParcel(source: Parcel): TaxonArea {
+ return TaxonArea(source)
+ }
+
+ override fun newArray(size: Int): Array {
+ return arrayOfNulls(size)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/commons/src/main/java/fr/geonature/commons/data/TaxonWithArea.kt b/commons/src/main/java/fr/geonature/commons/data/TaxonWithArea.kt
new file mode 100644
index 00000000..4e01feb0
--- /dev/null
+++ b/commons/src/main/java/fr/geonature/commons/data/TaxonWithArea.kt
@@ -0,0 +1,96 @@
+package fr.geonature.commons.data
+
+import android.database.Cursor
+import android.os.Parcel
+import android.os.Parcelable
+
+/**
+ * Describes a taxon with area.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class TaxonWithArea : AbstractTaxon {
+
+ var taxonArea: TaxonArea? = null
+
+ constructor(id: Long,
+ name: String,
+ description: String?,
+ heritage: Boolean = false,
+ taxonArea: TaxonArea?) : super(id,
+ name,
+ description,
+ heritage) {
+ this.taxonArea = taxonArea
+ }
+
+ constructor(taxon: Taxon) : super(taxon.id,
+ taxon.name,
+ taxon.description,
+ taxon.heritage)
+
+ constructor(source: Parcel) : super(source) {
+ taxonArea = source.readParcelable(TaxonArea::class.java.classLoader)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is TaxonWithArea) return false
+ if (!super.equals(other)) return false
+
+ if (taxonArea != other.taxonArea) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = super.hashCode()
+ result = 31 * result + (taxonArea?.hashCode() ?: 0)
+
+ return result
+ }
+
+ override fun writeToParcel(dest: Parcel?,
+ flags: Int) {
+ super.writeToParcel(dest,
+ flags)
+
+ dest?.writeParcelable(taxonArea,
+ flags)
+ }
+
+ companion object {
+
+ val DEFAULT_PROJECTION = arrayOf(*AbstractTaxon.DEFAULT_PROJECTION,
+ *TaxonArea.DEFAULT_PROJECTION)
+
+ /**
+ * Create a new [TaxonWithArea] from the specified [Cursor].
+ *
+ * @param cursor A valid [Cursor]
+ *
+ * @return A newly created [TaxonWithArea] instance
+ */
+ fun fromCursor(cursor: Cursor): TaxonWithArea? {
+ val taxon = Taxon.fromCursor(cursor) ?: return null
+ val taxonArea = TaxonArea.fromCursor(cursor)
+
+ return TaxonWithArea(taxon).also {
+ if (taxon.id == taxonArea?.taxonId) {
+ it.taxonArea = taxonArea
+ }
+ }
+ }
+
+ @JvmField
+ val CREATOR: Parcelable.Creator = object : Parcelable.Creator {
+ override fun createFromParcel(parcel: Parcel): TaxonWithArea {
+ return TaxonWithArea(parcel)
+ }
+
+ override fun newArray(size: Int): Array {
+ return arrayOfNulls(size)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/commons/src/main/java/fr/geonature/commons/data/Taxonomy.kt b/commons/src/main/java/fr/geonature/commons/data/Taxonomy.kt
new file mode 100644
index 00000000..5b0809ea
--- /dev/null
+++ b/commons/src/main/java/fr/geonature/commons/data/Taxonomy.kt
@@ -0,0 +1,88 @@
+package fr.geonature.commons.data
+
+import android.database.Cursor
+import android.os.Parcel
+import android.os.Parcelable
+import android.util.Log
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import fr.geonature.commons.util.get
+
+/**
+ * Describes a taxonomic rank.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+@Entity(tableName = Taxonomy.TABLE_NAME,
+ primaryKeys = [Taxonomy.COLUMN_KINGDOM, Taxonomy.COLUMN_GROUP])
+data class Taxonomy(
+ @ColumnInfo(name = COLUMN_KINGDOM)
+ var kingdom: String,
+ @ColumnInfo(name = COLUMN_GROUP)
+ var group: String) : Parcelable {
+
+ private constructor(source: Parcel) : this(source.readString()!!,
+ source.readString()!!)
+
+ override fun describeContents(): Int {
+ return 0
+ }
+
+ override fun writeToParcel(dest: Parcel?,
+ flags: Int) {
+ dest?.writeString(kingdom)
+ dest?.writeString(group)
+ }
+
+ companion object {
+
+ private val TAG = Taxonomy::class.java.name
+
+ /**
+ * The name of the 'taxonomy' table.
+ */
+ const val TABLE_NAME = "taxonomy"
+
+ const val COLUMN_KINGDOM = "kingdom"
+ const val COLUMN_GROUP = "group"
+
+ val DEFAULT_PROJECTION = arrayOf(COLUMN_KINGDOM,
+ COLUMN_GROUP)
+
+ /**
+ * Create a new [Taxonomy] from the specified [Cursor].
+ *
+ * @param cursor A valid [Cursor]
+ *
+ * @return A newly created [Taxonomy] instance
+ */
+ fun fromCursor(cursor: Cursor): Taxonomy? {
+ if (cursor.isClosed) {
+ return null
+ }
+
+ return try {
+ Taxonomy(requireNotNull(cursor.get(COLUMN_KINGDOM)),
+ requireNotNull(cursor.get(COLUMN_GROUP)))
+ }
+ catch (iae: IllegalArgumentException) {
+ Log.w(TAG,
+ iae.message)
+
+ null
+ }
+ }
+
+ @JvmField
+ val CREATOR: Parcelable.Creator = object : Parcelable.Creator {
+
+ override fun createFromParcel(source: Parcel): Taxonomy {
+ return Taxonomy(source)
+ }
+
+ override fun newArray(size: Int): Array {
+ return arrayOfNulls(size)
+ }
+ }
+ }
+}
\ 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
index a08c6735..884617f5 100644
--- a/commons/src/main/java/fr/geonature/commons/input/InputManager.kt
+++ b/commons/src/main/java/fr/geonature/commons/input/InputManager.kt
@@ -1,19 +1,19 @@
package fr.geonature.commons.input
+import android.annotation.SuppressLint
import android.app.Application
+import android.content.SharedPreferences
+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.FileUtils
import fr.geonature.commons.util.StringUtils.isEmpty
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileWriter
import java.io.IOException
-import java.io.Writer
/**
* Manage [AbstractInput]:
@@ -24,23 +24,27 @@ import java.io.Writer
*
* @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
*/
-class InputManager(private val application: Application,
- inputJsonReaderListener: InputJsonReader.OnInputJsonReaderListener,
- inputJsonWriterListener: InputJsonWriter.OnInputJsonWriterListener) {
+class InputManager private constructor(internal val application: Application,
+ inputJsonReaderListener: InputJsonReader.OnInputJsonReaderListener,
+ inputJsonWriterListener: InputJsonWriter.OnInputJsonWriterListener) {
- private val preferenceManager = PreferenceManager.getDefaultSharedPreferences(application)
- private val inputJsonReader: InputJsonReader = InputJsonReader(inputJsonReaderListener)
- private val inputJsonWriter: InputJsonWriter = InputJsonWriter(inputJsonWriterListener)
+ 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) {
+ suspend fun readInputs(): List = withContext(IO) {
preferenceManager.all.filterKeys { it.startsWith("${KEY_PREFERENCE_INPUT}_") }
.values.mapNotNull { if (it is String && !isEmpty(it)) inputJsonReader.read(it) else null }
.sortedBy { it.id }
+ .also { inputs.postValue(it) }
}
/**
@@ -50,10 +54,10 @@ class InputManager(private val application: Application,
*
* @return [AbstractInput] or `null` if not found
*/
- suspend fun readInput(id: Long? = null): T? = withContext(IO) {
- val inputPreferenceKey =
- buildInputPreferenceKey(id ?: preferenceManager.getLong(KEY_PREFERENCE_CURRENT_INPUT,
- 0))
+ 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)
@@ -62,6 +66,7 @@ class InputManager(private val application: Application,
}
inputJsonReader.read(inputAsJson)
+ .also { input.postValue(it) }
}
/**
@@ -69,16 +74,19 @@ class InputManager(private val application: Application,
*
* @return [AbstractInput] or `null` if not found
*/
- suspend fun readCurrentInput(): T? {
+ 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
*/
- suspend fun saveInput(input: T): Boolean = withContext(IO) {
+ @SuppressLint("ApplySharedPref")
+ suspend fun saveInput(input: I): Boolean = withContext(IO) {
val inputAsJson = inputJsonWriter.write(input)
if (isEmpty(inputAsJson)) return@withContext false
@@ -88,9 +96,10 @@ class InputManager(private val application: Application,
inputAsJson)
.putLong(KEY_PREFERENCE_CURRENT_INPUT,
input.id)
- .apply()
+ .commit()
preferenceManager.contains(buildInputPreferenceKey(input.id))
+ .also { readInputs() }
}
/**
@@ -100,6 +109,7 @@ class InputManager(private val application: Application,
*
* @return `true` if the given [AbstractInput] has been successfully deleted, `false` otherwise
*/
+ @SuppressLint("ApplySharedPref")
suspend fun deleteInput(id: Long): Boolean = withContext(IO) {
preferenceManager.edit()
.remove(buildInputPreferenceKey(id))
@@ -109,9 +119,12 @@ class InputManager(private val application: Application,
it.remove(KEY_PREFERENCE_CURRENT_INPUT)
}
}
- .apply()
+ .commit()
- !preferenceManager.contains(buildInputPreferenceKey(id))
+ !preferenceManager.contains(buildInputPreferenceKey(id)).also {
+ readInputs()
+ input.postValue(null)
+ }
}
/**
@@ -121,19 +134,19 @@ class InputManager(private val application: Application,
*
* @return `true` if the given [AbstractInput] has been successfully exported, `false` otherwise
*/
- suspend fun exportInput(id: Long): Boolean = coroutineScope {
- val inputToExport =
- withContext(Dispatchers.Default) { readInput(id) } ?: return@coroutineScope false
+ suspend fun exportInput(id: Long): Boolean = withContext(IO) {
+ val inputToExport = readInput(id) ?: return@withContext false
- val exported = withContext(IO) {
- inputJsonWriter.write(getInputExportWriter(inputToExport),
- inputToExport)
+ val inputExportFile = getInputExportFile(inputToExport)
+ inputJsonWriter.write(FileWriter(inputExportFile),
+ inputToExport)
- true
+ return@withContext if (inputExportFile.exists() && inputExportFile.length() > 0) {
+ deleteInput(id)
+ }
+ else {
+ false
}
- val deleted = deleteInput(id)
-
- return@coroutineScope deleted && exported
}
private fun buildInputPreferenceKey(id: Long): String {
@@ -141,19 +154,36 @@ class InputManager(private val application: Application,
}
@Throws(IOException::class)
- private fun getInputExportWriter(input: AbstractInput): Writer {
+ private fun getInputExportFile(input: AbstractInput): File {
val inputDir = FileUtils.getInputsFolder(application)
-
inputDir.mkdirs()
- val inputFile = File(inputDir,
- "input_${input.module}_${input.id}.json")
-
- return FileWriter(inputFile)
+ return File(inputDir,
+ "input_${input.module}_${input.id}.json")
}
companion object {
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/InputViewModel.kt b/commons/src/main/java/fr/geonature/commons/input/InputViewModel.kt
new file mode 100644
index 00000000..bd542207
--- /dev/null
+++ b/commons/src/main/java/fr/geonature/commons/input/InputViewModel.kt
@@ -0,0 +1,118 @@
+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
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+
+/**
+ * [AbstractInput] view model.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+open class InputViewModel(application: Application,
+ inputJsonReaderListener: InputJsonReader.OnInputJsonReaderListener,
+ inputJsonWriterListener: InputJsonWriter.OnInputJsonWriterListener) : AndroidViewModel(application) {
+
+ internal val inputManager = InputManager.getInstance(application,
+ inputJsonReaderListener,
+ inputJsonWriterListener)
+
+ private var deletedInputToRestore: I? = null
+
+ /**
+ * Reads all [AbstractInput]s.
+ */
+ fun readInputs(): LiveData> {
+ viewModelScope.launch {
+ inputManager.readInputs()
+ }
+
+ return inputManager.inputs
+ }
+
+ /**
+ * Reads [AbstractInput] from given ID.
+ *
+ * @param id The [AbstractInput] ID to read. If omitted, read the current saved [AbstractInput].
+ */
+ fun readInput(id: Long? = null): LiveData {
+ viewModelScope.launch {
+ inputManager.readInput(id)
+ }
+
+ return inputManager.input
+ }
+
+ /**
+ * Reads the current [AbstractInput].
+ */
+ fun readCurrentInput(): LiveData {
+ return readInput()
+ }
+
+ /**
+ * Saves the given [AbstractInput] and sets it as default current [AbstractInput].
+ *
+ * @param input the [AbstractInput] to save
+ */
+ fun saveInput(input: I) {
+ GlobalScope.launch(Dispatchers.Main) {
+ inputManager.saveInput(input)
+ }
+ }
+
+ /**
+ * Deletes [AbstractInput] from given ID.
+ *
+ * @param input the [AbstractInput] to delete
+ */
+ fun deleteInput(input: I) {
+ viewModelScope.launch {
+ if (inputManager.deleteInput(input.id)) {
+ deletedInputToRestore = input
+ }
+ }
+ }
+
+ /**
+ * Restores previously deleted [AbstractInput].
+ */
+ fun restoreDeletedInput() {
+ val selectedInputToRestore = deletedInputToRestore ?: return
+
+ viewModelScope.launch {
+ inputManager.saveInput(selectedInputToRestore)
+ deletedInputToRestore = null
+ }
+ }
+
+ /**
+ * Exports [AbstractInput] from given ID as `JSON` file.
+ *
+ * @param id the [AbstractInput] ID to export
+ */
+ fun exportInput(id: Long) {
+ GlobalScope.launch(Dispatchers.Main) {
+ inputManager.exportInput(id)
+ }
+ }
+
+ /**
+ * Default Factory to use for [InputViewModel].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+ class Factory, I : AbstractInput>(val creator: () -> T) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ @Suppress("UNCHECKED_CAST") return creator() as T
+ }
+ }
+}
\ No newline at end of file
diff --git a/commons/src/main/java/fr/geonature/commons/input/io/InputJsonReader.kt b/commons/src/main/java/fr/geonature/commons/input/io/InputJsonReader.kt
index b5faa0a5..2531d276 100644
--- a/commons/src/main/java/fr/geonature/commons/input/io/InputJsonReader.kt
+++ b/commons/src/main/java/fr/geonature/commons/input/io/InputJsonReader.kt
@@ -15,7 +15,7 @@ import java.io.StringReader
*
* @see InputJsonWriter
*/
-class InputJsonReader(private val onInputJsonReaderListener: OnInputJsonReaderListener) {
+class InputJsonReader(private val onInputJsonReaderListener: OnInputJsonReaderListener) {
/**
* parse a `JSON` string to convert as [AbstractInput].
@@ -24,7 +24,7 @@ class InputJsonReader(private val onInputJsonReaderListener:
* @return a [AbstractInput] instance from the `JSON` string or `null` if something goes wrong
* @see .read
*/
- fun read(json: String?): T? {
+ fun read(json: String?): I? {
if (StringUtils.isEmpty(json)) {
return null
}
@@ -48,7 +48,7 @@ class InputJsonReader(private val onInputJsonReaderListener:
* @throws IOException if something goes wrong
*/
@Throws(IOException::class)
- fun read(reader: Reader): T {
+ fun read(reader: Reader): I {
val jsonReader = JsonReader(reader)
val input = readInput(jsonReader)
jsonReader.close()
@@ -57,7 +57,7 @@ class InputJsonReader(private val onInputJsonReaderListener:
}
@Throws(IOException::class)
- private fun readInput(reader: JsonReader): T {
+ private fun readInput(reader: JsonReader): I {
val input = onInputJsonReaderListener.createInput()
reader.beginObject()
diff --git a/commons/src/main/java/fr/geonature/commons/input/io/InputJsonWriter.kt b/commons/src/main/java/fr/geonature/commons/input/io/InputJsonWriter.kt
index 1c96ff79..b1120e8e 100644
--- a/commons/src/main/java/fr/geonature/commons/input/io/InputJsonWriter.kt
+++ b/commons/src/main/java/fr/geonature/commons/input/io/InputJsonWriter.kt
@@ -13,7 +13,7 @@ import java.io.Writer
* @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
* @see InputJsonReader
*/
-class InputJsonWriter(private val onInputJsonWriterListener: OnInputJsonWriterListener) {
+class InputJsonWriter(private val onInputJsonWriterListener: OnInputJsonWriterListener) {
private var indent: String = ""
@@ -26,7 +26,7 @@ class InputJsonWriter(private val onInputJsonWriterListener:
*
* @return InputJsonWriter fluent interface
*/
- fun setIndent(indent: String): InputJsonWriter {
+ fun setIndent(indent: String): InputJsonWriter {
this.indent = indent
return this
@@ -39,7 +39,7 @@ class InputJsonWriter(private val onInputJsonWriterListener:
* @return a `JSON` string representation of the given [AbstractInput] or `null` if something goes wrong
* @see .write
*/
- fun write(input: T?): String? {
+ fun write(input: I?): String? {
if (input == null) {
return null
}
@@ -69,7 +69,7 @@ class InputJsonWriter(private val onInputJsonWriterListener:
*/
@Throws(IOException::class)
fun write(out: Writer,
- input: T) {
+ input: I) {
val writer = JsonWriter(out)
writer.setIndent(this.indent)
writeInput(writer,
@@ -80,7 +80,7 @@ class InputJsonWriter(private val onInputJsonWriterListener:
@Throws(IOException::class)
private fun writeInput(writer: JsonWriter,
- input: T) {
+ input: I) {
writer.beginObject()
writer.name("id")
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 6f3fe93b..922ffbf6 100644
--- a/commons/src/main/java/fr/geonature/commons/settings/AppSettingsManager.kt
+++ b/commons/src/main/java/fr/geonature/commons/settings/AppSettingsManager.kt
@@ -2,6 +2,7 @@ package fr.geonature.commons.settings
import android.app.Application
import android.util.Log
+import androidx.lifecycle.MutableLiveData
import fr.geonature.commons.model.MountPoint.StorageType.INTERNAL
import fr.geonature.commons.settings.io.AppSettingsJsonReader
import fr.geonature.commons.util.FileUtils.getFile
@@ -21,11 +22,12 @@ import java.io.IOException
*
* @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
*/
-class AppSettingsManager(private val application: Application,
- onAppSettingsJsonJsonReaderListener: AppSettingsJsonReader.OnAppSettingsJsonReaderListener) {
+class AppSettingsManager private constructor(internal val application: Application,
+ onAppSettingsJsonJsonReaderListener: AppSettingsJsonReader.OnAppSettingsJsonReaderListener) {
- private val appSettingsJsonReader: AppSettingsJsonReader =
- AppSettingsJsonReader(onAppSettingsJsonJsonReaderListener)
+ private val appSettingsJsonReader: AppSettingsJsonReader = AppSettingsJsonReader(onAppSettingsJsonJsonReaderListener)
+
+ val appSettings: MutableLiveData = MutableLiveData()
init {
GlobalScope.launch(Main) {
@@ -47,25 +49,40 @@ class AppSettingsManager(private val application: Application,
*
* @return [IAppSettings] or `null` if not found
*/
- suspend fun loadAppSettings(): T? = withContext(IO) {
- val settingsJsonFile = getAppSettingsAsFile()
+ suspend fun loadAppSettings(): AS? = withContext(IO) {
+ val currentLoadedAppSettings = appSettings.value
- if (!settingsJsonFile.exists()) {
- Log.w(TAG,
- "'${settingsJsonFile.absolutePath}' not found")
- null
- }
- else {
- try {
- appSettingsJsonReader.read(FileReader(settingsJsonFile))
- }
- catch (e: IOException) {
- Log.w(TAG,
- "Failed to load '${settingsJsonFile.name}'")
+ if (currentLoadedAppSettings == null) {
+ val settingsJsonFile = getAppSettingsAsFile()
+
+ Log.i(TAG,
+ "Loading settings from '${settingsJsonFile.absolutePath}'...")
+ if (!settingsJsonFile.exists()) {
+ Log.w(TAG,
+ "'${settingsJsonFile.absolutePath}' not found")
null
}
+ else {
+ 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
+ }
+ }
}
+ else {
+ currentLoadedAppSettings
+ }.also { appSettings.postValue(it) }
}
internal fun getAppSettingsAsFile(): File {
@@ -76,5 +93,23 @@ class AppSettingsManager(private val application: Application,
companion object {
private val TAG = AppSettingsManager::class.java.name
+
+ @Volatile
+ private var INSTANCE: AppSettingsManager<*>? = null
+
+ /**
+ * Gets the singleton instance of [AppSettingsManager].
+ *
+ * @param application The main application context.
+ *
+ * @return The singleton instance of [AppSettingsManager].
+ */
+ @Suppress("UNCHECKED_CAST")
+ fun getInstance(application: Application,
+ onAppSettingsJsonJsonReaderListener: AppSettingsJsonReader.OnAppSettingsJsonReaderListener): AppSettingsManager = INSTANCE as AppSettingsManager?
+ ?: synchronized(this) {
+ INSTANCE as AppSettingsManager? ?: AppSettingsManager(application,
+ onAppSettingsJsonJsonReaderListener).also { INSTANCE = it }
+ }
}
}
\ No newline at end of file
diff --git a/commons/src/main/java/fr/geonature/commons/settings/AppSettingsViewModel.kt b/commons/src/main/java/fr/geonature/commons/settings/AppSettingsViewModel.kt
new file mode 100644
index 00000000..740390e3
--- /dev/null
+++ b/commons/src/main/java/fr/geonature/commons/settings/AppSettingsViewModel.kt
@@ -0,0 +1,45 @@
+package fr.geonature.commons.settings
+
+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.settings.io.AppSettingsJsonReader
+import kotlinx.coroutines.launch
+
+/**
+ * [IAppSettings] view model.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+open class AppSettingsViewModel(application: Application,
+ onAppSettingsJsonReaderListener: AppSettingsJsonReader.OnAppSettingsJsonReaderListener) : AndroidViewModel(application) {
+
+ internal val appSettingsManager: AppSettingsManager = AppSettingsManager.getInstance(application,
+ onAppSettingsJsonReaderListener)
+
+ fun getAppSettingsFilename(): String {
+ return appSettingsManager.getAppSettingsFilename()
+ }
+
+ fun getAppSettings(): LiveData {
+ viewModelScope.launch {
+ appSettingsManager.loadAppSettings()
+ }
+
+ return appSettingsManager.appSettings
+ }
+
+ /**
+ * Default Factory to use for [AppSettingsViewModel].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+ class Factory, AS : IAppSettings>(val creator: () -> T) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ @Suppress("UNCHECKED_CAST") return creator() as T
+ }
+ }
+}
\ No newline at end of file
diff --git a/commons/src/main/java/fr/geonature/commons/settings/io/AppSettingsJsonReader.kt b/commons/src/main/java/fr/geonature/commons/settings/io/AppSettingsJsonReader.kt
index d543ab28..83461da9 100644
--- a/commons/src/main/java/fr/geonature/commons/settings/io/AppSettingsJsonReader.kt
+++ b/commons/src/main/java/fr/geonature/commons/settings/io/AppSettingsJsonReader.kt
@@ -14,7 +14,7 @@ import java.io.StringReader
*
* @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
*/
-class AppSettingsJsonReader(private val onAppSettingsJsonReaderListener: OnAppSettingsJsonReaderListener) {
+class AppSettingsJsonReader(private val onAppSettingsJsonReaderListener: OnAppSettingsJsonReaderListener) {
/**
* parse a `JSON` string to convert as [IAppSettings].
@@ -23,7 +23,7 @@ class AppSettingsJsonReader(private val onAppSettingsJsonReade
*
* @return a [IAppSettings] instance from the `JSON` string or `null` if something goes wrong
*/
- fun read(json: String?): T? {
+ fun read(json: String?): AS? {
if (TextUtils.isEmpty(json)) {
return null
}
@@ -50,7 +50,7 @@ class AppSettingsJsonReader(private val onAppSettingsJsonReade
*/
@Throws(IOException::class,
IllegalArgumentException::class)
- fun read(reader: Reader): T {
+ fun read(reader: Reader): AS {
val jsonReader = JsonReader(reader)
val appSettings = read(jsonReader)
jsonReader.close()
@@ -69,7 +69,7 @@ class AppSettingsJsonReader(private val onAppSettingsJsonReade
*/
@Throws(IOException::class,
IllegalArgumentException::class)
- private fun read(reader: JsonReader): T {
+ private fun read(reader: JsonReader): AS {
val appSettings = onAppSettingsJsonReaderListener.createAppSettings()
reader.beginObject()
diff --git a/commons/src/main/java/fr/geonature/commons/util/CursorHelper.kt b/commons/src/main/java/fr/geonature/commons/util/CursorHelper.kt
new file mode 100644
index 00000000..50f80a95
--- /dev/null
+++ b/commons/src/main/java/fr/geonature/commons/util/CursorHelper.kt
@@ -0,0 +1,32 @@
+package fr.geonature.commons.util
+
+import android.database.Cursor
+
+/**
+ * Utilities function about Cursor.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+
+/**
+ * Returns the value of the requested column name.
+ *
+ * @param columnName the name of the target column.
+ *
+ * @return the value of that column or the default value as fallback if given
+ */
+inline fun Cursor.get(columnName: String, defaultValue: T? = null): T? {
+ val columnIndex = if (defaultValue == null) getColumnIndexOrThrow(columnName) else getColumnIndex(columnName)
+
+ return when (T::class) {
+ ByteArray::class -> if (columnIndex > -1) byteArrayOf(*getBlob(columnIndex)) as T? else defaultValue
+ String::class -> if (columnIndex > -1) getString(columnIndex) as T? else defaultValue
+ Short::class -> if (columnIndex > -1) getShort(columnIndex) as T? else defaultValue
+ Int::class -> if (columnIndex > -1) getInt(columnIndex) as T? else defaultValue
+ Long::class -> if (columnIndex > -1) getLong(columnIndex) as T? else defaultValue
+ Float::class -> if (columnIndex > -1) getFloat(columnIndex) as T? else defaultValue
+ Double::class -> if (columnIndex > -1) getDouble(columnIndex) as T? else defaultValue
+ Boolean::class -> if (columnIndex > -1) getString(columnIndex).run { this?.toBoolean() } as T? else defaultValue
+ else -> throw IllegalArgumentException("Unsupported type ${T::class.java}")
+ }.run { this ?: defaultValue }
+}
diff --git a/commons/src/main/java/fr/geonature/commons/util/FileUtils.kt b/commons/src/main/java/fr/geonature/commons/util/FileUtils.kt
index f4898ec7..54f1cf00 100644
--- a/commons/src/main/java/fr/geonature/commons/util/FileUtils.kt
+++ b/commons/src/main/java/fr/geonature/commons/util/FileUtils.kt
@@ -108,8 +108,7 @@ object FileUtils {
getRootFolder(
context,
MountPoint.StorageType.INTERNAL),
- "inputs",
- context.packageName)
+ "inputs")
}
/**
diff --git a/commons/src/main/java/fr/geonature/commons/util/PermissionUtils.kt b/commons/src/main/java/fr/geonature/commons/util/PermissionUtils.kt
index 186307ef..3f0db98f 100644
--- a/commons/src/main/java/fr/geonature/commons/util/PermissionUtils.kt
+++ b/commons/src/main/java/fr/geonature/commons/util/PermissionUtils.kt
@@ -41,13 +41,11 @@ object PermissionUtils {
/**
* Determines whether the user have been granted a set of permissions.
*
- * @param context the current `Context`.
- * @param onCheckSelfPermissionListener the callback to use to notify if these permissions was
- * granted or not
- * @param permissions a set of permissions being checked
+ * @param context the current `Context`.
+ * @param permissions a set of permissions being checked
*/
fun checkSelfPermissions(context: Context,
- onCheckSelfPermissionListener: OnCheckSelfPermissionListener, @NonNull vararg permissions: String) {
+ vararg permissions: String): Boolean {
var granted = true
val iterator = permissions.iterator()
@@ -56,7 +54,23 @@ object PermissionUtils {
iterator.next()) == PackageManager.PERMISSION_GRANTED
}
- if (granted) {
+ return granted
+ }
+
+ /**
+ * Determines whether the user have been granted a set of permissions.
+ *
+ * @param context the current `Context`.
+ * @param onCheckSelfPermissionListener the callback to use to notify if these permissions was
+ * granted or not
+ * @param permissions a set of permissions being checked
+ */
+ fun checkSelfPermissions(context: Context,
+ onCheckSelfPermissionListener: OnCheckSelfPermissionListener,
+ @NonNull
+ vararg permissions: String) {
+ if (checkSelfPermissions(context,
+ *permissions)) {
onCheckSelfPermissionListener.onPermissionsGranted()
}
else {
@@ -86,9 +100,8 @@ object PermissionUtils {
val iterator = permissions.iterator()
while (iterator.hasNext() && !shouldShowRequestPermissions) {
- shouldShowRequestPermissions =
- ActivityCompat.shouldShowRequestPermissionRationale(activity,
- iterator.next())
+ shouldShowRequestPermissions = ActivityCompat.shouldShowRequestPermissionRationale(activity,
+ iterator.next())
}
if (shouldShowRequestPermissions) {
@@ -131,8 +144,7 @@ object PermissionUtils {
val iterator = permissions.iterator()
while (iterator.hasNext() && !shouldShowRequestPermissions) {
- shouldShowRequestPermissions =
- fragment.shouldShowRequestPermissionRationale(iterator.next())
+ shouldShowRequestPermissions = fragment.shouldShowRequestPermissionRationale(iterator.next())
}
if (shouldShowRequestPermissions) {
diff --git a/commons/src/test/java/fr/geonature/commons/OneTimeObserver.kt b/commons/src/test/java/fr/geonature/commons/OneTimeObserver.kt
new file mode 100644
index 00000000..54465b31
--- /dev/null
+++ b/commons/src/test/java/fr/geonature/commons/OneTimeObserver.kt
@@ -0,0 +1,36 @@
+package fr.geonature.commons
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
+
+/**
+ * Observer implementation that owns its lifecycle and achieves a one-time only observation
+ * by marking it as destroyed once the `onChange` handler is executed.
+ *
+ * @param handler the handler to execute on change.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class OneTimeObserver(private val handler: (T?) -> Unit) : Observer,
+ LifecycleOwner {
+ private val lifecycle = LifecycleRegistry(this)
+
+ init {
+ lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
+ }
+
+ override fun getLifecycle(): Lifecycle = lifecycle
+
+ override fun onChanged(t: T?) {
+ handler(t)
+ lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+ }
+}
+
+fun LiveData.observeOnce(onChangeHandler: (T?) -> Unit) {
+ val observer = OneTimeObserver(handler = onChangeHandler)
+ observe(observer, observer)
+}
\ No newline at end of file
diff --git a/commons/src/test/java/fr/geonature/commons/data/ConvertersTest.kt b/commons/src/test/java/fr/geonature/commons/data/ConvertersTest.kt
new file mode 100644
index 00000000..608031e1
--- /dev/null
+++ b/commons/src/test/java/fr/geonature/commons/data/ConvertersTest.kt
@@ -0,0 +1,31 @@
+package fr.geonature.commons.data
+
+import fr.geonature.commons.data.Converters.dateToTimestamp
+import fr.geonature.commons.data.Converters.fromTimestamp
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import java.time.Instant
+import java.util.Date
+
+/**
+ * Unit tests about [Converters].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class ConvertersTest {
+
+ @Test
+ fun testFromTimestamp() {
+ assertNull(fromTimestamp(null))
+ assertEquals(Date.from(Instant.parse("2016-10-28T08:15:00Z")),
+ fromTimestamp(1477642500000))
+ }
+
+ @Test
+ fun testDateToTimestamp() {
+ assertNull(dateToTimestamp(null))
+ assertEquals(1477642500000,
+ dateToTimestamp(Date.from(Instant.parse("2016-10-28T08:15:00Z"))))
+ }
+}
\ No newline at end of file
diff --git a/commons/src/test/java/fr/geonature/commons/data/InputObserverTest.kt b/commons/src/test/java/fr/geonature/commons/data/InputObserverTest.kt
index 8fa96476..61f04d71 100644
--- a/commons/src/test/java/fr/geonature/commons/data/InputObserverTest.kt
+++ b/commons/src/test/java/fr/geonature/commons/data/InputObserverTest.kt
@@ -2,8 +2,10 @@ package fr.geonature.commons.data
import android.database.Cursor
import android.os.Parcel
+import fr.geonature.commons.data.InputObserver.Companion.fromCursor
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.`when`
@@ -29,13 +31,18 @@ class InputObserverTest {
}
@Test
- fun testBuilder() {
- // given an InputObserver instance from its builder
- val inputObserver = InputObserver.Builder()
- .id(1234)
- .lastname("lastname")
- .firstname("firstname")
- .build()
+ fun testCreateFromCompleteCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.getColumnIndexOrThrow(InputObserver.COLUMN_ID)).thenReturn(0)
+ `when`(cursor.getColumnIndexOrThrow(InputObserver.COLUMN_LASTNAME)).thenReturn(1)
+ `when`(cursor.getColumnIndexOrThrow(InputObserver.COLUMN_FIRSTNAME)).thenReturn(2)
+ `when`(cursor.getLong(0)).thenReturn(1234)
+ `when`(cursor.getString(1)).thenReturn("lastname")
+ `when`(cursor.getString(2)).thenReturn("firstname")
+
+ // when getting InputObserver instance from Cursor
+ val inputObserver = fromCursor(cursor)
// then
assertNotNull(inputObserver)
@@ -46,27 +53,58 @@ class InputObserverTest {
}
@Test
- fun testCreateFromCursor() {
+ fun testCreateFromPartialCursor() {
// given a mocked Cursor
val cursor = mock(Cursor::class.java)
`when`(cursor.getColumnIndexOrThrow(InputObserver.COLUMN_ID)).thenReturn(0)
- `when`(cursor.getColumnIndexOrThrow(InputObserver.COLUMN_LASTNAME)).thenReturn(1)
- `when`(cursor.getColumnIndexOrThrow(InputObserver.COLUMN_FIRSTNAME)).thenReturn(2)
+ `when`(cursor.getColumnIndexOrThrow(InputObserver.COLUMN_LASTNAME)).thenReturn(-1)
+ `when`(cursor.getColumnIndexOrThrow(InputObserver.COLUMN_FIRSTNAME)).thenReturn(-1)
`when`(cursor.getLong(0)).thenReturn(1234)
- `when`(cursor.getString(1)).thenReturn("lastname")
- `when`(cursor.getString(2)).thenReturn("firstname")
+ `when`(cursor.getString(1)).thenReturn(null)
+ `when`(cursor.getString(2)).thenReturn(null)
// when getting InputObserver instance from Cursor
- val inputObserver = InputObserver.fromCursor(cursor)
+ val inputObserver = fromCursor(cursor)
// then
assertNotNull(inputObserver)
assertEquals(InputObserver(1234,
- "lastname",
- "firstname"),
+ null,
+ null),
inputObserver)
}
+ @Test
+ fun testCreateFromClosedCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.isClosed).thenReturn(true)
+
+ // when getting InputObserver instance from Cursor
+ val inputObserver = fromCursor(cursor)
+
+ // then
+ assertNull(inputObserver)
+ }
+
+ @Test
+ fun testCreateFromInvalidCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.getColumnIndexOrThrow(InputObserver.COLUMN_ID)).thenThrow(IllegalArgumentException::class.java)
+ `when`(cursor.getColumnIndexOrThrow(InputObserver.COLUMN_LASTNAME)).thenReturn(-1)
+ `when`(cursor.getColumnIndexOrThrow(InputObserver.COLUMN_FIRSTNAME)).thenReturn(-1)
+ `when`(cursor.getLong(0)).thenReturn(0)
+ `when`(cursor.getString(1)).thenReturn(null)
+ `when`(cursor.getString(2)).thenReturn(null)
+
+ // when getting InputObserver instance from Cursor
+ val inputObserver = fromCursor(cursor)
+
+ // then
+ assertNull(inputObserver)
+ }
+
@Test
fun testParcelable() {
// given InputObserver
diff --git a/commons/src/test/java/fr/geonature/commons/data/ProviderTest.kt b/commons/src/test/java/fr/geonature/commons/data/ProviderTest.kt
new file mode 100644
index 00000000..a0fe262d
--- /dev/null
+++ b/commons/src/test/java/fr/geonature/commons/data/ProviderTest.kt
@@ -0,0 +1,47 @@
+package fr.geonature.commons.data
+
+import fr.geonature.commons.data.Provider.buildUri
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * Unit tests about [Provider].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+@RunWith(RobolectricTestRunner::class)
+class ProviderTest {
+
+ @Test
+ fun testBuildUri() {
+ assertEquals("content://${Provider.AUTHORITY}/taxa",
+ buildUri("taxa").toString())
+
+ assertEquals("content://${Provider.AUTHORITY}/taxa/123",
+ buildUri("taxa",
+ 123.toString()).toString())
+
+ assertEquals("content://${Provider.AUTHORITY}/taxa/area/123",
+ buildUri("taxa",
+ "area/${123}").toString())
+
+ assertEquals("content://${Provider.AUTHORITY}/taxa/area/123",
+ buildUri("taxa",
+ "area",
+ 123.toString()).toString())
+
+ assertEquals("content://${Provider.AUTHORITY}/taxa/area/123",
+ buildUri("taxa",
+ "area",
+ "",
+ 123.toString()).toString())
+
+ assertEquals("content://${Provider.AUTHORITY}/taxa/area/123",
+ buildUri("taxa",
+ "area",
+ " ",
+ 123.toString()).toString())
+ }
+}
\ No newline at end of file
diff --git a/commons/src/test/java/fr/geonature/commons/data/TaxonAreaTest.kt b/commons/src/test/java/fr/geonature/commons/data/TaxonAreaTest.kt
new file mode 100644
index 00000000..c13d31e6
--- /dev/null
+++ b/commons/src/test/java/fr/geonature/commons/data/TaxonAreaTest.kt
@@ -0,0 +1,164 @@
+package fr.geonature.commons.data
+
+import android.database.Cursor
+import android.os.Parcel
+import fr.geonature.commons.data.TaxonArea.Companion.fromCursor
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.`when`
+import org.mockito.Mockito.mock
+import org.robolectric.RobolectricTestRunner
+import java.time.Instant
+import java.util.Date
+
+/**
+ * Unit tests about [TaxonArea].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+@RunWith(RobolectricTestRunner::class)
+class TaxonAreaTest {
+
+ @Test
+ fun testEquals() {
+ val now = Date.from(Instant.now())
+
+ assertEquals(TaxonArea(1234,
+ 10,
+ "red",
+ 3,
+ now),
+ TaxonArea(1234,
+ 10,
+ "red",
+ 3,
+ now))
+
+ assertEquals(TaxonArea(1234,
+ 10,
+ null,
+ 3,
+ now),
+ TaxonArea(1234,
+ 10,
+ null,
+ 3,
+ now))
+ }
+
+ @Test
+ fun testCreateFromCompleteCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_TAXON_ID)).thenReturn(0)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_AREA_ID)).thenReturn(1)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_COLOR)).thenReturn(2)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_NUMBER_OF_OBSERVERS)).thenReturn(3)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_LAST_UPDATED_AT)).thenReturn(4)
+ `when`(cursor.getLong(0)).thenReturn(1234)
+ `when`(cursor.getLong(1)).thenReturn(10)
+ `when`(cursor.getString(2)).thenReturn("red")
+ `when`(cursor.getInt(3)).thenReturn(3)
+ `when`(cursor.getLong(4)).thenReturn(1477642500000)
+
+ // when getting a TaxonArea instance from Cursor
+ val taxonArea = fromCursor(cursor)
+
+ // then
+ assertNotNull(taxonArea)
+ assertEquals(TaxonArea(1234,
+ 10,
+ "red",
+ 3,
+ Date.from(Instant.parse("2016-10-28T08:15:00Z"))),
+ taxonArea)
+ }
+
+ @Test
+ fun testCreateFromPartialCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_TAXON_ID)).thenReturn(0)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_AREA_ID)).thenReturn(1)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_COLOR)).thenReturn(-1)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_NUMBER_OF_OBSERVERS)).thenReturn(-1)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_LAST_UPDATED_AT)).thenReturn(-1)
+ `when`(cursor.getLong(0)).thenReturn(1234)
+ `when`(cursor.getLong(1)).thenReturn(10)
+ `when`(cursor.getString(2)).thenReturn(null)
+ `when`(cursor.getInt(3)).thenReturn(0)
+ `when`(cursor.getLong(4)).thenReturn(0)
+
+ // when getting a TaxonArea instance from Cursor
+ val taxonArea = fromCursor(cursor)
+
+ // then
+ assertNotNull(taxonArea)
+ assertEquals(TaxonArea(1234,
+ 10,
+ "#00000000",
+ 0,
+ null),
+ taxonArea)
+ }
+
+ @Test
+ fun testCreateFromClosedCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.isClosed).thenReturn(true)
+
+ // when getting a TaxonArea instance from Cursor
+ val taxonArea = fromCursor(cursor)
+
+ // then
+ assertNull(taxonArea)
+ }
+
+ @Test()
+ fun testCreateFromInvalidCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_TAXON_ID)).thenThrow(IllegalArgumentException::class.java)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_AREA_ID)).thenThrow(IllegalArgumentException::class.java)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_COLOR)).thenReturn(-1)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_NUMBER_OF_OBSERVERS)).thenReturn(-1)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_LAST_UPDATED_AT)).thenReturn(-1)
+ `when`(cursor.getLong(0)).thenReturn(0)
+ `when`(cursor.getLong(1)).thenReturn(0)
+ `when`(cursor.getString(2)).thenReturn(null)
+ `when`(cursor.getInt(3)).thenReturn(0)
+ `when`(cursor.getLong(4)).thenReturn(0)
+
+ // when getting a TaxonArea instance from Cursor
+ val taxonArea = fromCursor(cursor)
+
+ // then
+ assertNull(taxonArea)
+ }
+
+ @Test
+ fun testParcelable() {
+ // given a TaxonArea
+ val taxonArea = TaxonArea(1234,
+ 10,
+ "red",
+ 3,
+ Date.from(Instant.now()))
+
+ // when we obtain a Parcel object to write the TaxonWithArea instance to it
+ val parcel = Parcel.obtain()
+ taxonArea.writeToParcel(parcel,
+ 0)
+
+ // reset the parcel for reading
+ parcel.setDataPosition(0)
+
+ // then
+ assertEquals(taxonArea,
+ TaxonArea.CREATOR.createFromParcel(parcel))
+ }
+}
\ No newline at end of file
diff --git a/commons/src/test/java/fr/geonature/commons/data/TaxonTest.kt b/commons/src/test/java/fr/geonature/commons/data/TaxonTest.kt
index 28fb17b6..b5017d10 100644
--- a/commons/src/test/java/fr/geonature/commons/data/TaxonTest.kt
+++ b/commons/src/test/java/fr/geonature/commons/data/TaxonTest.kt
@@ -2,7 +2,10 @@ package fr.geonature.commons.data
import android.database.Cursor
import android.os.Parcel
-import org.junit.Assert
+import fr.geonature.commons.data.Taxon.Companion.fromCursor
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.`when`
@@ -17,61 +20,113 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class TaxonTest {
- fun testBuilder() {
- // given a taxon instance from its builder
- val taxon1 = Taxon.Builder()
- .id(1234)
- .name("taxon_01")
- .description("desc")
- .heritage(true)
- .build()
+ @Test
+ fun testEquals() {
+ assertEquals(Taxon(1234,
+ "taxon_01",
+ "desc",
+ true),
+ Taxon(1234,
+ "taxon_01",
+ "desc",
+ true))
- // then
- Assert.assertNotNull(taxon1)
- Assert.assertEquals(Taxon(1234,
- "taxon_01",
- "desc",
- true),
- taxon1)
-
- // given a taxon instance with default values from its builder
- val taxon2 = Taxon.Builder()
- .id(1235)
- .name("taxon_02")
- .build()
+ assertEquals(Taxon(1234,
+ "taxon_01",
+ "desc"),
+ Taxon(1234,
+ "taxon_01",
+ "desc"))
- // then
- Assert.assertNotNull(taxon2)
- Assert.assertEquals(Taxon(1235,
- "taxon_02",
- null,
- false),
- taxon2)
+ assertEquals(Taxon(1234,
+ "taxon_01",
+ null),
+ Taxon(1234,
+ "taxon_01",
+ null))
}
@Test
- fun testCreateFromCursor() {
+ fun testCreateFromCompleteCursor() {
// given a mocked Cursor
val cursor = mock(Cursor::class.java)
- `when`(cursor.getColumnIndexOrThrow(Taxon.COLUMN_ID)).thenReturn(0)
- `when`(cursor.getColumnIndexOrThrow(Taxon.COLUMN_NAME)).thenReturn(1)
- `when`(cursor.getColumnIndexOrThrow(Taxon.COLUMN_DESCRIPTION)).thenReturn(2)
- `when`(cursor.getColumnIndexOrThrow(Taxon.COLUMN_HERITAGE)).thenReturn(3)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_ID)).thenReturn(0)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_NAME)).thenReturn(1)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_DESCRIPTION)).thenReturn(2)
+ `when`(cursor.getColumnIndex(AbstractTaxon.COLUMN_HERITAGE)).thenReturn(3)
`when`(cursor.getLong(0)).thenReturn(1234)
`when`(cursor.getString(1)).thenReturn("taxon_01")
`when`(cursor.getString(2)).thenReturn("desc")
`when`(cursor.getString(3)).thenReturn("True")
- // when getting Taxon instance from Cursor
- val taxon = Taxon.fromCursor(cursor)
+ // when getting a Taxon instance from Cursor
+ val taxon = fromCursor(cursor)
+
+ // then
+ assertNotNull(taxon)
+ assertEquals(Taxon(1234,
+ "taxon_01",
+ "desc",
+ true),
+ taxon)
+ }
+
+ @Test
+ fun testCreateFromPartialCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_ID)).thenReturn(0)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_NAME)).thenReturn(1)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_DESCRIPTION)).thenReturn(-1)
+ `when`(cursor.getColumnIndex(AbstractTaxon.COLUMN_HERITAGE)).thenReturn(-1)
+ `when`(cursor.getLong(0)).thenReturn(1234)
+ `when`(cursor.getString(1)).thenReturn("taxon_01")
+ `when`(cursor.getString(2)).thenReturn(null)
+ `when`(cursor.getString(3)).thenReturn(null)
+
+ // when getting a Taxon instance from Cursor
+ val taxon = fromCursor(cursor)
+
+ // then
+ assertNotNull(taxon)
+ assertEquals(Taxon(1234,
+ "taxon_01",
+ null,
+ false),
+ taxon)
+ }
+
+ @Test
+ fun testCreateFromClosedCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.isClosed).thenReturn(true)
+
+ // when getting a Taxon instance from Cursor
+ val taxon = fromCursor(cursor)
+
+ // then
+ assertNull(taxon)
+ }
+
+ @Test
+ fun testCreateFromInvalidCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_ID)).thenThrow(IllegalArgumentException::class.java)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_NAME)).thenThrow(IllegalArgumentException::class.java)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_DESCRIPTION)).thenReturn(-1)
+ `when`(cursor.getColumnIndex(AbstractTaxon.COLUMN_HERITAGE)).thenReturn(-1)
+ `when`(cursor.getLong(0)).thenReturn(0)
+ `when`(cursor.getString(1)).thenReturn(null)
+ `when`(cursor.getString(2)).thenReturn(null)
+ `when`(cursor.getString(3)).thenReturn(null)
+
+ // when getting a Taxon instance from Cursor
+ val taxon = fromCursor(cursor)
// then
- Assert.assertNotNull(taxon)
- Assert.assertEquals(Taxon(1234,
- "taxon_01",
- "desc",
- true),
- taxon)
+ assertNull(taxon)
}
@Test
@@ -91,7 +146,7 @@ class TaxonTest {
parcel.setDataPosition(0)
// then
- Assert.assertEquals(taxon,
- Taxon.CREATOR.createFromParcel(parcel))
+ assertEquals(taxon,
+ Taxon.CREATOR.createFromParcel(parcel))
}
}
\ No newline at end of file
diff --git a/commons/src/test/java/fr/geonature/commons/data/TaxonWithAreaTest.kt b/commons/src/test/java/fr/geonature/commons/data/TaxonWithAreaTest.kt
new file mode 100644
index 00000000..2d63c576
--- /dev/null
+++ b/commons/src/test/java/fr/geonature/commons/data/TaxonWithAreaTest.kt
@@ -0,0 +1,262 @@
+package fr.geonature.commons.data
+
+import android.database.Cursor
+import android.os.Parcel
+import fr.geonature.commons.data.TaxonWithArea.Companion.fromCursor
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.`when`
+import org.mockito.Mockito.mock
+import org.robolectric.RobolectricTestRunner
+import java.time.Instant
+import java.util.Date
+
+/**
+ * Unit tests about [TaxonWithArea].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+@RunWith(RobolectricTestRunner::class)
+class TaxonWithAreaTest {
+
+ @Test
+ fun testEquals() {
+ val now = Date.from(Instant.now())
+
+ assertEquals(TaxonWithArea(1234,
+ "taxon_01",
+ "desc",
+ true,
+ null),
+ TaxonWithArea(1234,
+ "taxon_01",
+ "desc",
+ true,
+ null))
+
+ assertEquals(TaxonWithArea(1234,
+ "taxon_01",
+ "desc",
+ true,
+ TaxonArea(1234,
+ 10,
+ "red",
+ 3,
+ now)),
+ TaxonWithArea(1234,
+ "taxon_01",
+ "desc",
+ true,
+ TaxonArea(1234,
+ 10,
+ "red",
+ 3,
+ now)))
+
+ assertEquals(TaxonWithArea(Taxon(1234,
+ "taxon_01",
+ "desc")),
+ TaxonWithArea(Taxon(1234,
+ "taxon_01",
+ "desc")))
+ }
+
+ @Test
+ fun testCreateFromCompleteCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_ID)).thenReturn(0)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_NAME)).thenReturn(1)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_DESCRIPTION)).thenReturn(2)
+ `when`(cursor.getColumnIndex(AbstractTaxon.COLUMN_HERITAGE)).thenReturn(3)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_TAXON_ID)).thenReturn(4)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_AREA_ID)).thenReturn(5)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_COLOR)).thenReturn(6)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_NUMBER_OF_OBSERVERS)).thenReturn(7)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_LAST_UPDATED_AT)).thenReturn(8)
+ `when`(cursor.getLong(0)).thenReturn(1234)
+ `when`(cursor.getString(1)).thenReturn("taxon_01")
+ `when`(cursor.getString(2)).thenReturn("desc")
+ `when`(cursor.getString(3)).thenReturn("True")
+ `when`(cursor.getLong(4)).thenReturn(1234)
+ `when`(cursor.getLong(5)).thenReturn(10)
+ `when`(cursor.getString(6)).thenReturn("red")
+ `when`(cursor.getInt(7)).thenReturn(3)
+ `when`(cursor.getLong(8)).thenReturn(1477642500000)
+
+ // when getting a TaxonWithArea instance from Cursor
+ val taxonWithArea = fromCursor(cursor)
+
+ // then
+ assertNotNull(taxonWithArea)
+ assertEquals(TaxonWithArea(1234,
+ "taxon_01",
+ "desc",
+ true,
+ TaxonArea(1234,
+ 10,
+ "red",
+ 3,
+ Date.from(Instant.parse("2016-10-28T08:15:00Z")))),
+ taxonWithArea)
+ }
+
+ @Test
+ fun testCreateFromPartialCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_ID)).thenReturn(0)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_NAME)).thenReturn(1)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_DESCRIPTION)).thenReturn(2)
+ `when`(cursor.getColumnIndex(AbstractTaxon.COLUMN_HERITAGE)).thenReturn(3)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_TAXON_ID)).thenThrow(IllegalArgumentException::class.java)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_AREA_ID)).thenThrow(IllegalArgumentException::class.java)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_COLOR)).thenReturn(-1)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_NUMBER_OF_OBSERVERS)).thenReturn(-1)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_LAST_UPDATED_AT)).thenReturn(-1)
+ `when`(cursor.getLong(0)).thenReturn(1234)
+ `when`(cursor.getString(1)).thenReturn("taxon_01")
+ `when`(cursor.getString(2)).thenReturn("desc")
+ `when`(cursor.getString(3)).thenReturn("True")
+ `when`(cursor.getLong(4)).thenReturn(0)
+ `when`(cursor.getLong(5)).thenReturn(0)
+ `when`(cursor.getString(6)).thenReturn(null)
+ `when`(cursor.getInt(7)).thenReturn(0)
+ `when`(cursor.getLong(8)).thenReturn(0)
+
+ // when getting a TaxonWithArea instance from Cursor
+ val taxonWithArea = fromCursor(cursor)
+
+ // then
+ assertNotNull(taxonWithArea)
+ assertEquals(TaxonWithArea(1234,
+ "taxon_01",
+ "desc",
+ true,
+ null),
+ taxonWithArea)
+ }
+
+ @Test
+ fun testCreateFromIncompleteCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_ID)).thenReturn(0)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_NAME)).thenReturn(1)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_DESCRIPTION)).thenReturn(2)
+ `when`(cursor.getColumnIndex(AbstractTaxon.COLUMN_HERITAGE)).thenReturn(3)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_TAXON_ID)).thenReturn(4)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_AREA_ID)).thenReturn(5)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_COLOR)).thenReturn(-1)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_NUMBER_OF_OBSERVERS)).thenReturn(-1)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_LAST_UPDATED_AT)).thenReturn(-1)
+ `when`(cursor.getLong(0)).thenReturn(1234)
+ `when`(cursor.getString(1)).thenReturn("taxon_01")
+ `when`(cursor.getString(2)).thenReturn("desc")
+ `when`(cursor.getString(3)).thenReturn("True")
+ `when`(cursor.getLong(4)).thenReturn(0)
+ `when`(cursor.getLong(5)).thenReturn(0)
+ `when`(cursor.getString(6)).thenReturn(null)
+ `when`(cursor.getInt(7)).thenReturn(0)
+ `when`(cursor.getLong(8)).thenReturn(0)
+
+ // when getting a TaxonWithArea instance from Cursor
+ val taxonWithArea = fromCursor(cursor)
+
+ // then
+ assertNotNull(taxonWithArea)
+ assertEquals(TaxonWithArea(1234,
+ "taxon_01",
+ "desc",
+ true,
+ null),
+ taxonWithArea)
+ }
+
+ @Test
+ fun testCreateFromClosedCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.isClosed).thenReturn(true)
+
+ // when getting a TaxonWithArea instance from Cursor
+ val taxonWithArea = fromCursor(cursor)
+
+ // then
+ assertNull(taxonWithArea)
+ }
+
+ @Test
+ fun testCreateFromInvalidCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_ID)).thenThrow(IllegalArgumentException::class.java)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_NAME)).thenThrow(IllegalArgumentException::class.java)
+ `when`(cursor.getColumnIndexOrThrow(AbstractTaxon.COLUMN_DESCRIPTION)).thenReturn(-1)
+ `when`(cursor.getColumnIndex(AbstractTaxon.COLUMN_HERITAGE)).thenReturn(-1)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_TAXON_ID)).thenThrow(IllegalArgumentException::class.java)
+ `when`(cursor.getColumnIndexOrThrow(TaxonArea.COLUMN_AREA_ID)).thenThrow(IllegalArgumentException::class.java)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_COLOR)).thenReturn(-1)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_NUMBER_OF_OBSERVERS)).thenReturn(-1)
+ `when`(cursor.getColumnIndex(TaxonArea.COLUMN_LAST_UPDATED_AT)).thenReturn(-1)
+ `when`(cursor.getLong(0)).thenReturn(0)
+ `when`(cursor.getString(1)).thenReturn(null)
+ `when`(cursor.getString(2)).thenReturn(null)
+ `when`(cursor.getString(3)).thenReturn(null)
+ `when`(cursor.getLong(4)).thenReturn(0)
+ `when`(cursor.getLong(5)).thenReturn(0)
+ `when`(cursor.getString(6)).thenReturn(null)
+ `when`(cursor.getInt(7)).thenReturn(0)
+ `when`(cursor.getLong(8)).thenReturn(0)
+
+ // when getting a TaxonWithArea instance from Cursor
+ val taxonWithArea = fromCursor(cursor)
+
+ // then
+ assertNull(taxonWithArea)
+ }
+
+ @Test
+ fun testParcelable() {
+ // given a TaxonWithArea
+ val taxonWithArea = TaxonWithArea(1234,
+ "taxon_01",
+ "desc",
+ true,
+ TaxonArea(1234,
+ 10,
+ "red",
+ 3,
+ Date.from(Instant.now())))
+
+ // when we obtain a Parcel object to write the TaxonWithArea instance to it
+ val parcel = Parcel.obtain()
+ taxonWithArea.writeToParcel(parcel,
+ 0)
+
+ // reset the parcel for reading
+ parcel.setDataPosition(0)
+
+ // then
+ assertEquals(taxonWithArea,
+ TaxonWithArea.CREATOR.createFromParcel(parcel))
+ }
+
+ @Test
+ fun testDefaultProjection() {
+ assertArrayEquals(arrayOf(AbstractTaxon.COLUMN_ID,
+ AbstractTaxon.COLUMN_NAME,
+ AbstractTaxon.COLUMN_DESCRIPTION,
+ AbstractTaxon.COLUMN_HERITAGE,
+ TaxonArea.COLUMN_TAXON_ID,
+ TaxonArea.COLUMN_AREA_ID,
+ TaxonArea.COLUMN_COLOR,
+ TaxonArea.COLUMN_NUMBER_OF_OBSERVERS,
+ TaxonArea.COLUMN_LAST_UPDATED_AT),
+ TaxonWithArea.DEFAULT_PROJECTION)
+ }
+}
\ No newline at end of file
diff --git a/commons/src/test/java/fr/geonature/commons/data/TaxonomyTest.kt b/commons/src/test/java/fr/geonature/commons/data/TaxonomyTest.kt
new file mode 100644
index 00000000..d420ffff
--- /dev/null
+++ b/commons/src/test/java/fr/geonature/commons/data/TaxonomyTest.kt
@@ -0,0 +1,67 @@
+package fr.geonature.commons.data
+
+import android.database.Cursor
+import android.os.Parcel
+import fr.geonature.commons.data.Taxonomy.Companion.fromCursor
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.`when`
+import org.mockito.Mockito.mock
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * Unit tests about [Taxonomy].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+@RunWith(RobolectricTestRunner::class)
+class TaxonomyTest {
+
+ @Test
+ fun testEquals() {
+ assertEquals(Taxonomy("Animalia",
+ "Ascidies"),
+ Taxonomy("Animalia",
+ "Ascidies"))
+ }
+
+ @Test
+ fun testCreateFromCursor() {
+ // given a mocked Cursor
+ val cursor = mock(Cursor::class.java)
+ `when`(cursor.getColumnIndexOrThrow(Taxonomy.COLUMN_KINGDOM)).thenReturn(0)
+ `when`(cursor.getColumnIndexOrThrow(Taxonomy.COLUMN_GROUP)).thenReturn(1)
+ `when`(cursor.getString(0)).thenReturn("Animalia")
+ `when`(cursor.getString(1)).thenReturn("Ascidies")
+
+ // when getting a Taxonomy instance from Cursor
+ val taxonomy = fromCursor(cursor)
+
+ // then
+ assertNotNull(taxonomy)
+ assertEquals(Taxonomy("Animalia",
+ "Ascidies"),
+ taxonomy)
+ }
+
+ @Test
+ fun testParcelable() {
+ // given a Taxonomy instance
+ val taxonomy = Taxonomy("Animalia",
+ "Ascidies")
+
+ // when we obtain a Parcel object to write the Taxonomy instance to it
+ val parcel = Parcel.obtain()
+ taxonomy.writeToParcel(parcel,
+ 0)
+
+ // reset the parcel for reading
+ parcel.setDataPosition(0)
+
+ // then
+ assertEquals(taxonomy,
+ Taxonomy.CREATOR.createFromParcel(parcel))
+ }
+}
\ 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 b8de093c..a6dbd288 100644
--- a/commons/src/test/java/fr/geonature/commons/input/InputManagerTest.kt
+++ b/commons/src/test/java/fr/geonature/commons/input/InputManagerTest.kt
@@ -2,6 +2,8 @@ package fr.geonature.commons.input
import android.app.Application
import android.util.JsonReader
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.lifecycle.Observer
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import fr.geonature.commons.input.io.InputJsonReader
import fr.geonature.commons.input.io.InputJsonWriter
@@ -13,9 +15,12 @@ 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.atMost
+import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations.initMocks
import org.robolectric.RobolectricTestRunner
@@ -27,6 +32,9 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class InputManagerTest {
+ @get:Rule
+ val rule = InstantTaskExecutorRule()
+
private lateinit var inputManager: InputManager
@Mock
@@ -34,6 +42,12 @@ class InputManagerTest {
private lateinit var onInputJsonReaderListener: InputJsonReader.OnInputJsonReaderListener
+ @Mock
+ private lateinit var observerForListOfInputs: Observer>
+
+ @Mock
+ private lateinit var observerForInput: Observer
+
@Before
fun setUp() {
initMocks(this)
@@ -50,9 +64,15 @@ class InputManagerTest {
}
val application = getApplicationContext()
- inputManager = InputManager(application,
- onInputJsonReaderListener,
- onInputJsonWriterListener)
+ inputManager = InputManager.getInstance(application,
+ onInputJsonReaderListener,
+ onInputJsonWriterListener)
+ inputManager.inputs.observeForever(observerForListOfInputs)
+ inputManager.input.observeForever(observerForInput)
+
+ inputManager.preferenceManager.edit()
+ .clear()
+ .commit()
}
@Test
@@ -90,6 +110,8 @@ class InputManagerTest {
input2.id,
input3.id),
inputs.map { it.id }.toTypedArray())
+
+ verify(observerForListOfInputs).onChanged(inputs)
}
@Test
@@ -141,6 +163,8 @@ class InputManagerTest {
currentInput!!.id)
assertEquals(input.module,
currentInput.module)
+
+ verify(observerForInput).onChanged(readInput)
}
@Test
@@ -162,6 +186,9 @@ class InputManagerTest {
val noSuchInput = runBlocking { inputManager.readInput(input.id) }
assertNull(noSuchInput)
+
+ verify(observerForInput,
+ atMost(2)).onChanged(null)
}
@Test
@@ -186,5 +213,11 @@ class InputManagerTest {
// then
assertTrue(saved)
assertTrue(exported)
+
+ val noSuchInput = runBlocking { inputManager.readInput(input.id) }
+ assertNull(noSuchInput)
+
+ verify(observerForListOfInputs).onChanged(emptyList())
+ verify(observerForInput).onChanged(null)
}
}
\ No newline at end of file
diff --git a/commons/src/test/java/fr/geonature/commons/input/InputViewModelTest.kt b/commons/src/test/java/fr/geonature/commons/input/InputViewModelTest.kt
new file mode 100644
index 00000000..b5df21e3
--- /dev/null
+++ b/commons/src/test/java/fr/geonature/commons/input/InputViewModelTest.kt
@@ -0,0 +1,71 @@
+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
+
+/**
+ * Unit tests about [InputViewModel].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+@RunWith(RobolectricTestRunner::class)
+class InputViewModelTest {
+
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @Mock
+ private lateinit var onInputJsonReaderListener: InputJsonReader.OnInputJsonReaderListener
+
+ @Mock
+ private lateinit var onInputJsonWriterListener: InputJsonWriter.OnInputJsonWriterListener
+
+ private lateinit var inputViewModel: DummyInputViewModel
+
+ @Before
+ fun setUp() {
+ initMocks(this)
+
+ doReturn(DummyInput()).`when`(onInputJsonReaderListener)
+ .createInput()
+
+ inputViewModel = spy(DummyInputViewModel(ApplicationProvider.getApplicationContext(),
+ onInputJsonReaderListener,
+ onInputJsonWriterListener))
+ }
+
+ @Test
+ fun testCreateFromFactory() {
+ // given Factory
+ val factory = InputViewModel.Factory {
+ DummyInputViewModel(ApplicationProvider.getApplicationContext(),
+ onInputJsonReaderListener,
+ onInputJsonWriterListener)
+ }
+
+ // when create InputViewModel instance from this factory
+ val viewModelFromFactory = factory.create(DummyInputViewModel::class.java)
+
+ // then
+ assertNotNull(viewModelFromFactory)
+ }
+
+ class DummyInputViewModel(application: Application,
+ inputJsonReaderListener: InputJsonReader.OnInputJsonReaderListener,
+ inputJsonWriterListener: InputJsonWriter.OnInputJsonWriterListener) : InputViewModel(application,
+ inputJsonReaderListener,
+ inputJsonWriterListener)
+}
\ No newline at end of file
diff --git a/commons/src/test/java/fr/geonature/commons/settings/AppSettingsManagerTest.kt b/commons/src/test/java/fr/geonature/commons/settings/AppSettingsManagerTest.kt
index ec495e8d..15fb045c 100644
--- a/commons/src/test/java/fr/geonature/commons/settings/AppSettingsManagerTest.kt
+++ b/commons/src/test/java/fr/geonature/commons/settings/AppSettingsManagerTest.kt
@@ -3,8 +3,10 @@ package fr.geonature.commons.settings
import android.app.Application
import android.util.JsonReader
import androidx.test.core.app.ApplicationProvider.getApplicationContext
-import fr.geonature.commons.FixtureHelper
-import fr.geonature.commons.MockitoKotlinHelper
+import fr.geonature.commons.FixtureHelper.getFixtureAsFile
+import fr.geonature.commons.MockitoKotlinHelper.any
+import fr.geonature.commons.MockitoKotlinHelper.eq
+import fr.geonature.commons.observeOnce
import fr.geonature.commons.settings.io.AppSettingsJsonReader
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
@@ -13,9 +15,7 @@ import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
-import org.mockito.Mock
-import org.mockito.Mockito.`when`
-import org.mockito.Mockito.atMost
+import org.mockito.Mockito.atMostOnce
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.spy
import org.mockito.Mockito.verify
@@ -32,20 +32,30 @@ import java.io.File
class AppSettingsManagerTest {
private lateinit var appSettingsManager: AppSettingsManager
-
- @Mock
private lateinit var onAppSettingsJsonJsonReaderListener: AppSettingsJsonReader.OnAppSettingsJsonReaderListener
@Before
fun setUp() {
initMocks(this)
- doReturn(DummyAppSettings()).`when`(onAppSettingsJsonJsonReaderListener)
- .createAppSettings()
+ onAppSettingsJsonJsonReaderListener = spy(object : AppSettingsJsonReader.OnAppSettingsJsonReaderListener {
+ override fun createAppSettings(): DummyAppSettings {
+ return DummyAppSettings()
+ }
+
+ override fun readAdditionalAppSettingsData(reader: JsonReader,
+ keyName: String,
+ appSettings: DummyAppSettings) {
+ when (keyName) {
+ "attribute" -> appSettings.attribute = reader.nextString()
+ else -> reader.skipValue()
+ }
+ }
+ })
val application = getApplicationContext()
- appSettingsManager = spy(AppSettingsManager(application,
- onAppSettingsJsonJsonReaderListener))
+ appSettingsManager = spy(AppSettingsManager.getInstance(application,
+ onAppSettingsJsonJsonReaderListener))
}
@Test
@@ -82,25 +92,25 @@ class AppSettingsManagerTest {
@Test
fun testReadAppSettings() {
// given app settings to read
- doReturn(FixtureHelper.getFixtureAsFile("settings_dummy.json")).`when`(appSettingsManager)
+ doReturn(getFixtureAsFile("settings_dummy.json")).`when`(appSettingsManager)
.getAppSettingsAsFile()
- `when`(onAppSettingsJsonJsonReaderListener.readAdditionalAppSettingsData(MockitoKotlinHelper.any(JsonReader::class.java),
- MockitoKotlinHelper.eq("attribute"),
- MockitoKotlinHelper.any(DummyAppSettings::class.java))).then {
- assertEquals("value",
- (it.getArgument(0) as JsonReader).nextString())
- }
-
// when reading this file
val appSettings = runBlocking { appSettingsManager.loadAppSettings() }
// then
verify(onAppSettingsJsonJsonReaderListener,
- atMost(1)).readAdditionalAppSettingsData(MockitoKotlinHelper.any(JsonReader::class.java),
- MockitoKotlinHelper.eq("attribute"),
- MockitoKotlinHelper.any(DummyAppSettings::class.java))
+ atMostOnce()).readAdditionalAppSettingsData(any(JsonReader::class.java),
+ eq("attribute"),
+ any(DummyAppSettings::class.java))
assertNotNull(appSettings)
+ assertEquals(DummyAppSettings("value"),
+ appSettings)
+ appSettingsManager.appSettings.observeOnce {
+ assertNotNull(it)
+ assertEquals(appSettings,
+ it)
+ }
}
}
\ No newline at end of file
diff --git a/commons/src/test/java/fr/geonature/commons/settings/AppSettingsViewModelTest.kt b/commons/src/test/java/fr/geonature/commons/settings/AppSettingsViewModelTest.kt
new file mode 100644
index 00000000..be51f7f4
--- /dev/null
+++ b/commons/src/test/java/fr/geonature/commons/settings/AppSettingsViewModelTest.kt
@@ -0,0 +1,72 @@
+package fr.geonature.commons.settings
+
+import android.app.Application
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.lifecycle.Observer
+import androidx.test.core.app.ApplicationProvider
+import fr.geonature.commons.settings.io.AppSettingsJsonReader
+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
+
+/**
+ * Unit tests about [AppSettingsViewModel].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+@RunWith(RobolectricTestRunner::class)
+class AppSettingsViewModelTest {
+
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @Mock
+ private lateinit var onAppSettingsJsonJsonReaderListener: AppSettingsJsonReader.OnAppSettingsJsonReaderListener
+
+ @Mock
+ private lateinit var observer: Observer
+
+ private lateinit var application: Application
+ private lateinit var appSettingsViewModel: DummyAppSettingsViewModel
+ private lateinit var appSettingsManager: AppSettingsManager
+
+ @Before
+ fun setUp() {
+ initMocks(this)
+
+ application = spy(ApplicationProvider.getApplicationContext())
+ doReturn("fr.geonature.commons").`when`(application)
+ .packageName
+
+ appSettingsViewModel = spy(DummyAppSettingsViewModel(application,
+ onAppSettingsJsonJsonReaderListener))
+ appSettingsManager = spy(appSettingsViewModel.appSettingsManager)
+ appSettingsManager.appSettings.observeForever(observer)
+ }
+
+ @Test
+ fun testCreateFromFactory() {
+ // given Factory
+ val factory = AppSettingsViewModel.Factory {
+ DummyAppSettingsViewModel(application,
+ onAppSettingsJsonJsonReaderListener)
+ }
+
+ // when create AppSettingsViewModel instance from this factory
+ val appSettingsViewModelFromFactory = factory.create(DummyAppSettingsViewModel::class.java)
+
+ // then
+ assertNotNull(appSettingsViewModelFromFactory)
+ }
+
+ class DummyAppSettingsViewModel(application: Application,
+ onAppSettingsJsonJsonReaderListener: AppSettingsJsonReader.OnAppSettingsJsonReaderListener) : AppSettingsViewModel(application,
+ onAppSettingsJsonJsonReaderListener)
+}
\ No newline at end of file
diff --git a/commons/src/test/java/fr/geonature/commons/settings/io/AppSettingsJsonReaderTest.kt b/commons/src/test/java/fr/geonature/commons/settings/io/AppSettingsJsonReaderTest.kt
index 061956b6..5463a478 100644
--- a/commons/src/test/java/fr/geonature/commons/settings/io/AppSettingsJsonReaderTest.kt
+++ b/commons/src/test/java/fr/geonature/commons/settings/io/AppSettingsJsonReaderTest.kt
@@ -2,7 +2,8 @@ package fr.geonature.commons.settings.io
import android.util.JsonReader
import fr.geonature.commons.FixtureHelper.getFixture
-import fr.geonature.commons.MockitoKotlinHelper
+import fr.geonature.commons.MockitoKotlinHelper.any
+import fr.geonature.commons.MockitoKotlinHelper.eq
import fr.geonature.commons.settings.DummyAppSettings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -53,9 +54,9 @@ class AppSettingsJsonReaderTest {
@Test
fun testReadAppSettingsFromJsonString() {
- `when`(onAppSettingsJsonJsonReaderListener.readAdditionalAppSettingsData(MockitoKotlinHelper.any(JsonReader::class.java),
- MockitoKotlinHelper.eq("attribute"),
- MockitoKotlinHelper.any(DummyAppSettings::class.java))).then {
+ `when`(onAppSettingsJsonJsonReaderListener.readAdditionalAppSettingsData(any(JsonReader::class.java),
+ eq("attribute"),
+ any(DummyAppSettings::class.java))).then {
assertEquals("value",
(it.getArgument(0) as JsonReader).nextString())
}
@@ -68,9 +69,9 @@ class AppSettingsJsonReaderTest {
// then
verify(onAppSettingsJsonJsonReaderListener,
- atMost(1)).readAdditionalAppSettingsData(MockitoKotlinHelper.any(JsonReader::class.java),
- MockitoKotlinHelper.eq("attribute"),
- MockitoKotlinHelper.any(DummyAppSettings::class.java))
+ atMost(1)).readAdditionalAppSettingsData(any(JsonReader::class.java),
+ eq("attribute"),
+ any(DummyAppSettings::class.java))
assertNotNull(appSettings)
}
diff --git a/commons/version.properties b/commons/version.properties
index b8f2fe3a..7f13f984 100644
--- a/commons/version.properties
+++ b/commons/version.properties
@@ -1,2 +1,2 @@
-#Sat Jun 22 17:31:13 CEST 2019
-VERSION_CODE=750
+#Thu Sep 05 23:16:51 CEST 2019
+VERSION_CODE=1140
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 88e8e9d6..e2b22a5d 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
-#Thu Apr 18 20:54:41 CEST 2019
+#Wed Aug 21 20:44:10 CEST 2019
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip
diff --git a/settings.gradle b/settings.gradle
index e0a835b3..154c54a7 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,3 +1,3 @@
-include ':viewpager', ':commons'
+include ':viewpager', ':commons', ':sync'
diff --git a/sync/.gitignore b/sync/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/sync/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/sync/README.md b/sync/README.md
new file mode 100644
index 00000000..de909e3c
--- /dev/null
+++ b/sync/README.md
@@ -0,0 +1,29 @@
+# Sync
+
+Synchronize local database through GeoNature API:
+
+- Users (e.g. Observers)
+- taxa (with "color" by areas and taxonomy)
+
+## Content Provider
+
+This app expose synchronised data from GeoNature through a content provider.
+The authority of this content provider is `fr.geonature.sync.provider`.
+
+### Exposed content URIs
+
+Base URI: `content://fr.geonature.sync.provider`
+
+| URI | Parameters | Description |
+| ------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------- |
+| **\**/app_sync/\* | String | Fetch synchronization status by application package ID (e.g. `fr.geonature.occtax`) |
+| **\**/observers | n/a | Fetch all registered observers |
+| **\**/observers/\* | String (list of comma separated values) | Fetch all registered observers matching a list of IDs |
+| **\**/observers/# | Number | Fetch an observer by ID |
+| **\**/taxa | n/a | Fetch all taxa |
+| **\**/taxa/area/# | Number | Fetch all taxa matching a given area ID |
+| **\**/taxa/# | Number | Fetch a taxon by ID |
+| **\**/taxa/#/area/# | Number, Number | Fetch a taxon by ID matching a given area ID |
+| **\**/taxonomy | n/a | Fetch taxonomy |
+| **\**/taxonomy/\* | String | Fetch taxonomy matching a given kingdom |
+| **\**/taxonomy/\*/\* | String, String | Fetch taxonomy matching a given kingdom and group |
diff --git a/sync/build.gradle b/sync/build.gradle
new file mode 100644
index 00000000..d693d6a4
--- /dev/null
+++ b/sync/build.gradle
@@ -0,0 +1,74 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-android-extensions'
+apply plugin: "kotlin-kapt"
+
+version = "0.0.9"
+
+android {
+ compileSdkVersion 28
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ defaultConfig {
+ applicationId "fr.geonature.sync"
+ minSdkVersion 21
+ targetSdkVersion 28
+ versionCode updateVersionCode(module.name)
+ versionName version
+ buildConfigField "String", "BUILD_DATE", "\"" + new Date().getTime() + "\""
+ testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+ archivesBaseName = module.name + "-" + versionName
+ }
+
+ buildTypes {
+ debug {
+ versionNameSuffix "." + getVersionCode(module.name) + "-DEV"
+ }
+
+ release {
+ versionNameSuffix "." + getVersionCode(module.name)
+ debuggable false
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
+ }
+}
+
+dependencies {
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
+
+ implementation project(':commons')
+
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
+
+ implementation 'androidx.core:core-ktx:1.2.0-alpha04'
+ implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0-alpha04"
+ implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta2'
+ implementation 'androidx.work:work-runtime:2.2.0'
+ implementation 'androidx.recyclerview:recyclerview:1.1.0-beta04'
+ implementation 'androidx.preference:preference:1.1.0'
+ implementation 'com.google.android.material:material:1.1.0-alpha10'
+ implementation 'com.google.code.gson:gson:2.8.5'
+ implementation 'com.l4digital.fastscroll:fastscroll:2.0.1'
+ implementation 'com.squareup.retrofit2:retrofit:2.6.0'
+ implementation 'com.squareup.retrofit2:converter-gson:2.6.0'
+ implementation 'com.squareup.okhttp3:logging-interceptor:3.12.0'
+ kapt 'androidx.room:room-compiler:2.1.0'
+
+ testImplementation 'junit:junit:4.12'
+ testImplementation 'androidx.test:core:1.2.0'
+ testImplementation 'org.mockito:mockito-core:3.0.0'
+ testImplementation 'org.robolectric:robolectric:4.3'
+ androidTestImplementation 'androidx.test:runner:1.3.0-alpha02'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0-alpha02'
+}
diff --git a/sync/proguard-rules.pro b/sync/proguard-rules.pro
new file mode 100644
index 00000000..f1b42451
--- /dev/null
+++ b/sync/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/sync/src/debug/AndroidManifest.xml b/sync/src/debug/AndroidManifest.xml
new file mode 100644
index 00000000..adb711d7
--- /dev/null
+++ b/sync/src/debug/AndroidManifest.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sync/src/debug/res/xml/network_security_config.xml b/sync/src/debug/res/xml/network_security_config.xml
new file mode 100644
index 00000000..de662ce5
--- /dev/null
+++ b/sync/src/debug/res/xml/network_security_config.xml
@@ -0,0 +1,7 @@
+
+
+
+
+ geonature.fr
+
+
\ No newline at end of file
diff --git a/sync/src/main/AndroidManifest.xml b/sync/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..3457ea5c
--- /dev/null
+++ b/sync/src/main/AndroidManifest.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/MainApplication.kt b/sync/src/main/java/fr/geonature/sync/MainApplication.kt
new file mode 100644
index 00000000..b1967f7e
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/MainApplication.kt
@@ -0,0 +1,27 @@
+package fr.geonature.sync
+
+import android.app.Application
+import android.util.Log
+
+import fr.geonature.commons.util.MountPointUtils
+
+/**
+ * Base class to maintain global application state.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class MainApplication : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+
+ Log.i(TAG,
+ "internal storage: " + MountPointUtils.getInternalStorage())
+ Log.i(TAG,
+ "external storage: " + MountPointUtils.getExternalStorage(this))
+ }
+
+ companion object {
+ private val TAG = MainApplication::class.java.name
+ }
+}
diff --git a/sync/src/main/java/fr/geonature/sync/api/GeoNatureAPIClient.kt b/sync/src/main/java/fr/geonature/sync/api/GeoNatureAPIClient.kt
new file mode 100644
index 00000000..c248b04e
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/api/GeoNatureAPIClient.kt
@@ -0,0 +1,62 @@
+package fr.geonature.sync.api
+
+import com.google.gson.GsonBuilder
+import fr.geonature.sync.api.model.Taxref
+import fr.geonature.sync.api.model.TaxrefArea
+import fr.geonature.sync.api.model.User
+import okhttp3.OkHttpClient
+import okhttp3.ResponseBody
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Call
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+
+/**
+ * GeoNature API client.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class GeoNatureAPIClient private constructor(baseUrl: String) {
+ private val geoNatureService: GeoNatureService
+
+ init {
+ val loggingInterceptor = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BASIC
+ redactHeader("Authorization")
+ redactHeader("Cookie")
+ }
+
+ val client = OkHttpClient.Builder()
+ .addInterceptor(loggingInterceptor)
+ .build()
+
+ val retrofit = Retrofit.Builder()
+ .baseUrl("$baseUrl/")
+ .client(client)
+ .addConverterFactory(GsonConverterFactory.create(GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create()))
+ .build()
+
+ geoNatureService = retrofit.create(GeoNatureService::class.java)
+ }
+
+ fun getUsers(): Call> {
+ return geoNatureService.getUsers()
+ }
+
+ fun getTaxref(): Call> {
+ return geoNatureService.getTaxref()
+ }
+
+ fun getTaxrefAreas(): Call> {
+ return geoNatureService.getTaxrefAreas()
+ }
+
+ fun getTaxonomyRanks(): Call {
+ return geoNatureService.getTaxonomyRanks()
+ }
+
+ companion object {
+
+ fun instance(baseUrl: String): Lazy = lazy { GeoNatureAPIClient(baseUrl.also { if (it.endsWith('/')) it.dropLast(1) }) }
+ }
+}
\ 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/GeoNatureService.kt
new file mode 100644
index 00000000..bdd812a1
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/api/GeoNatureService.kt
@@ -0,0 +1,28 @@
+package fr.geonature.sync.api
+
+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 retrofit2.Call
+import retrofit2.http.GET
+
+/**
+ * GeoNature API interface definition.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+interface GeoNatureService {
+
+ @GET("geonature/api/users/menu/1")
+ fun getUsers(): Call>
+
+ @GET("taxhub/api/taxref/allnamebylist/100")
+ fun getTaxref(): Call>
+
+ @GET("geonature/api/synthese/color_taxon")
+ fun getTaxrefAreas(): Call>
+
+ @GET("taxhub/api/taxref/regnewithgroupe2")
+ fun getTaxonomyRanks(): Call
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/api/model/Taxref.kt b/sync/src/main/java/fr/geonature/sync/api/model/Taxref.kt
new file mode 100644
index 00000000..c38f940b
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/api/model/Taxref.kt
@@ -0,0 +1,16 @@
+package fr.geonature.sync.api.model
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * GeoNature Taxref definition.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+data class Taxref(
+ @SerializedName("cd_nom")
+ val id: Long,
+ @SerializedName("cd_ref")
+ val ref: Long,
+ @SerializedName("lb_nom")
+ val name: String, val records: List = emptyList())
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/api/model/TaxrefArea.kt b/sync/src/main/java/fr/geonature/sync/api/model/TaxrefArea.kt
new file mode 100644
index 00000000..fdabf023
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/api/model/TaxrefArea.kt
@@ -0,0 +1,21 @@
+package fr.geonature.sync.api.model
+
+import com.google.gson.annotations.SerializedName
+import java.util.Date
+
+/**
+ * GeoNature Taxref with area definition.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+data class TaxrefArea(
+ @SerializedName("cd_nom")
+ val taxrefId: Long,
+ @SerializedName("id_area")
+ val areaId: Long,
+ @SerializedName("color")
+ val color: String,
+ @SerializedName("nb_obs")
+ val numberOfObservers: Int,
+ @SerializedName("last_date")
+ val lastUpdatedAt: Date)
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/api/model/User.kt b/sync/src/main/java/fr/geonature/sync/api/model/User.kt
new file mode 100644
index 00000000..6bd9a4a9
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/api/model/User.kt
@@ -0,0 +1,16 @@
+package fr.geonature.sync.api.model
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * GeoNature User definition.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+data class User(
+ @SerializedName("id_role")
+ val id: Long,
+ @SerializedName("nom_role")
+ val lastname: String,
+ @SerializedName("prenom_role")
+ val firstname: String)
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/data/AppSyncDao.kt b/sync/src/main/java/fr/geonature/sync/data/AppSyncDao.kt
new file mode 100644
index 00000000..52990b02
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/data/AppSyncDao.kt
@@ -0,0 +1,47 @@
+package fr.geonature.sync.data
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.preference.PreferenceManager
+import fr.geonature.commons.data.AppSync
+import fr.geonature.commons.util.StringUtils
+
+/**
+ * Data access object for [AppSync].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class AppSyncDao(context: Context) {
+
+ private val sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
+
+ fun findByPackageId(packageId: String?): Cursor {
+ val columns = arrayOf(AppSync.COLUMN_ID,
+ AppSync.COLUMN_LAST_SYNC,
+ AppSync.COLUMN_INPUTS_TO_SYNCHRONIZE)
+ val cursor = MatrixCursor(columns)
+
+ if (StringUtils.isEmpty(packageId)) return cursor
+
+ val lastSync = this.sharedPreferences.getString(buildPreferenceKeyFromPackageId(packageId!!,
+ AppSync.COLUMN_LAST_SYNC),
+ null)
+ val inputsToSynchronize = this.sharedPreferences.getLong(buildPreferenceKeyFromPackageId(packageId,
+ AppSync.COLUMN_INPUTS_TO_SYNCHRONIZE),
+ 0)
+
+ val values = arrayOf(packageId,
+ lastSync,
+ inputsToSynchronize)
+ cursor.addRow(values)
+
+ return cursor
+ }
+
+ private fun buildPreferenceKeyFromPackageId(packageId: String,
+ key: String): String {
+ return "sync.$packageId.$key"
+ }
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/data/InputObserverDao.kt b/sync/src/main/java/fr/geonature/sync/data/InputObserverDao.kt
new file mode 100644
index 00000000..bad4e1c3
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/data/InputObserverDao.kt
@@ -0,0 +1,29 @@
+package fr.geonature.sync.data
+
+import android.database.Cursor
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.RawQuery
+import androidx.sqlite.db.SupportSQLiteQuery
+import fr.geonature.commons.data.InputObserver
+
+/**
+ * Data access object for [InputObserver].
+ */
+@Dao
+interface InputObserverDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun insert(vararg inputObservers: InputObserver)
+
+ /**
+ * Select observers from given query.
+ *
+ * @param query the query
+ *
+ * @return A [Cursor] of observers
+ */
+ @RawQuery
+ fun select(query: SupportSQLiteQuery): Cursor
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/data/LocalDatabase.kt b/sync/src/main/java/fr/geonature/sync/data/LocalDatabase.kt
new file mode 100644
index 00000000..95fdd9db
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/data/LocalDatabase.kt
@@ -0,0 +1,85 @@
+package fr.geonature.sync.data
+
+import android.content.Context
+import android.util.Log
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import fr.geonature.commons.data.InputObserver
+import fr.geonature.commons.data.Taxon
+import fr.geonature.commons.data.TaxonArea
+import fr.geonature.commons.data.Taxonomy
+import fr.geonature.commons.model.MountPoint
+import fr.geonature.sync.BuildConfig
+import fr.geonature.sync.util.FileUtils.getDatabaseFolder
+import fr.geonature.sync.util.FileUtils.getFile
+
+/**
+ * The Room database.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+@Database(entities = [InputObserver::class, Taxonomy::class, Taxon::class, TaxonArea::class],
+ version = 5,
+ exportSchema = false)
+abstract class LocalDatabase : RoomDatabase() {
+
+ /**
+ * @return The DAO for the 'observers' table.
+ */
+ abstract fun inputObserverDao(): InputObserverDao
+
+ /**
+ * @return The DAO for the 'Taxonomy' table.
+ */
+ abstract fun taxonomyDao(): TaxonomyDao
+
+ /**
+ * @return The DAO for the 'taxa' table.
+ */
+ abstract fun taxonDao(): TaxonDao
+
+ /**
+ * @return The DAO for the 'taxa_area' table.
+ */
+ abstract fun taxonAreaDao(): TaxonAreaDao
+
+ companion object {
+
+ private val TAG = LocalDatabase::class.java.name
+
+ /**
+ * The only instance
+ */
+ @Volatile
+ private var INSTANCE: LocalDatabase? = null
+
+ /**
+ * Gets the singleton instance of [LocalDatabase].
+ *
+ * @param context The context.
+ *
+ * @return The singleton instance of [LocalDatabase].
+ */
+ fun getInstance(context: Context): LocalDatabase = INSTANCE ?: synchronized(this) {
+ INSTANCE ?: buildDatabase(context).also { INSTANCE = it }
+ }
+
+ private fun buildDatabase(context: Context): LocalDatabase {
+ val localDatabase = getFile(getDatabaseFolder(context,
+ MountPoint.StorageType.INTERNAL),
+ "data.db")
+
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG,
+ "Loading local database '" + localDatabase.absolutePath + "'...")
+ }
+
+ return Room.databaseBuilder(context.applicationContext,
+ LocalDatabase::class.java,
+ localDatabase.absolutePath)
+ .fallbackToDestructiveMigration()
+ .build()
+ }
+ }
+}
\ 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
new file mode 100644
index 00000000..ba21230a
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/data/MainContentProvider.kt
@@ -0,0 +1,373 @@
+package fr.geonature.sync.data
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.content.UriMatcher
+import android.database.Cursor
+import android.net.Uri
+import androidx.sqlite.db.SimpleSQLiteQuery
+import androidx.sqlite.db.SupportSQLiteQueryBuilder
+import fr.geonature.commons.data.AbstractTaxon
+import fr.geonature.commons.data.AppSync
+import fr.geonature.commons.data.InputObserver
+import fr.geonature.commons.data.Provider.AUTHORITY
+import fr.geonature.commons.data.Provider.checkReadPermission
+import fr.geonature.commons.data.Taxon
+import fr.geonature.commons.data.TaxonArea
+import fr.geonature.commons.data.TaxonWithArea
+import fr.geonature.commons.data.Taxonomy
+
+/**
+ * Default ContentProvider implementation.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class MainContentProvider : ContentProvider() {
+
+ override fun onCreate(): Boolean {
+ return true
+ }
+
+ override fun getType(uri: Uri): String? {
+ return when (MATCHER.match(uri)) {
+ APP_SYNC_ID -> "$VND_TYPE_ITEM_PREFIX/$AUTHORITY.${AppSync.TABLE_NAME}"
+ INPUT_OBSERVERS, INPUT_OBSERVERS_IDS -> "$VND_TYPE_DIR_PREFIX/$AUTHORITY.${InputObserver.TABLE_NAME}"
+ INPUT_OBSERVER_ID -> "$VND_TYPE_ITEM_PREFIX/$AUTHORITY.${InputObserver.TABLE_NAME}"
+ TAXA -> "$VND_TYPE_DIR_PREFIX/$AUTHORITY.${Taxon.TABLE_NAME}"
+ TAXON_ID, TAXON_AREA_ID -> "$VND_TYPE_ITEM_PREFIX/$AUTHORITY.${Taxon.TABLE_NAME}"
+ TAXONOMY, TAXONOMY_KINGDOM -> "$VND_TYPE_DIR_PREFIX/$AUTHORITY.${Taxonomy.TABLE_NAME}"
+ TAXONOMY_KINGDOM_GROUP -> "$VND_TYPE_ITEM_PREFIX/$AUTHORITY.${Taxonomy.TABLE_NAME}"
+ else -> throw IllegalArgumentException("Unknown URI: $uri")
+ }
+ }
+
+ override fun query(uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?): Cursor? {
+ val context = context ?: return null
+
+ if (!checkReadPermission(context,
+ readPermission)) {
+ throw SecurityException("Permission denial: require READ permission")
+ }
+
+ return when (MATCHER.match(uri)) {
+ APP_SYNC_ID -> appSyncIdQuery(context,
+ uri)
+ INPUT_OBSERVERS -> inputObserversQuery(context,
+ projection,
+ selection,
+ selectionArgs,
+ sortOrder)
+ INPUT_OBSERVERS_IDS -> inputObserversIdsQuery(context,
+ uri,
+ projection,
+ sortOrder)
+ INPUT_OBSERVER_ID -> inputObserverIdQuery(context,
+ uri,
+ projection)
+ TAXA -> taxaQuery(context,
+ projection,
+ selection,
+ selectionArgs,
+ sortOrder)
+ TAXA_AREA -> taxaAreaQuery(context,
+ uri,
+ projection,
+ selection,
+ selectionArgs,
+ sortOrder)
+ TAXON_ID -> taxonIdQuery(context,
+ uri,
+ projection)
+ TAXON_AREA_ID -> taxonAreaIdQuery(context,
+ uri,
+ projection)
+ TAXONOMY, TAXONOMY_KINGDOM, TAXONOMY_KINGDOM_GROUP -> taxonomyQuery(context,
+ uri,
+ projection)
+ else -> throw IllegalArgumentException("Unknown URI: $uri")
+ }
+ }
+
+ override fun insert(uri: Uri,
+ values: ContentValues?): Uri? {
+ throw NotImplementedError("'insert' operation not implemented")
+ }
+
+ 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")
+ }
+
+ private fun appSyncIdQuery(context: Context,
+ uri: Uri): Cursor {
+
+ val appSyncDao = AppSyncDao(context)
+ val packageId = uri.lastPathSegment
+
+ return appSyncDao.findByPackageId(packageId)
+ }
+
+ private fun inputObserversQuery(context: Context,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?): Cursor {
+
+ val queryBuilder = SupportSQLiteQueryBuilder.builder(InputObserver.TABLE_NAME)
+ .columns(projection ?: InputObserver.DEFAULT_PROJECTION)
+ .selection(selection,
+ selectionArgs)
+ .orderBy(sortOrder ?: "${InputObserver.COLUMN_LASTNAME} COLLATE NOCASE ASC")
+
+ return LocalDatabase.getInstance(context)
+ .inputObserverDao()
+ .select(queryBuilder.create())
+ }
+
+ private fun inputObserversIdsQuery(context: Context,
+ uri: Uri,
+ projection: Array?,
+ sortOrder: String?): Cursor {
+
+ val selectedObserverIds = uri.lastPathSegment?.split(",")?.mapNotNull { it.toLongOrNull() }?.distinct()
+ ?: listOf()
+
+ val queryBuilder = SupportSQLiteQueryBuilder.builder(InputObserver.TABLE_NAME)
+ .columns(projection ?: InputObserver.DEFAULT_PROJECTION)
+ .selection("${InputObserver.COLUMN_ID} IN (${selectedObserverIds.joinToString(",")})",
+ null)
+ .orderBy(sortOrder ?: "${InputObserver.COLUMN_LASTNAME} COLLATE NOCASE ASC")
+
+ return LocalDatabase.getInstance(context)
+ .inputObserverDao()
+ .select(queryBuilder.create())
+ }
+
+ private fun inputObserverIdQuery(context: Context,
+ uri: Uri,
+ projection: Array?): Cursor {
+
+ val queryBuilder = SupportSQLiteQueryBuilder.builder(InputObserver.TABLE_NAME)
+ .columns(projection ?: InputObserver.DEFAULT_PROJECTION)
+ .selection("${InputObserver.COLUMN_ID} = ?",
+ arrayOf(uri.lastPathSegment?.toLongOrNull()))
+
+ return LocalDatabase.getInstance(context)
+ .inputObserverDao()
+ .select(queryBuilder.create())
+ }
+
+ private fun taxaQuery(context: Context,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?): Cursor {
+
+ val queryBuilder = SupportSQLiteQueryBuilder.builder(Taxon.TABLE_NAME)
+ .columns(projection ?: AbstractTaxon.DEFAULT_PROJECTION)
+ .selection(selection,
+ selectionArgs)
+ .orderBy(sortOrder ?: "${AbstractTaxon.COLUMN_NAME} COLLATE NOCASE ASC")
+
+ return LocalDatabase.getInstance(context)
+ .taxonDao()
+ .select(queryBuilder.create())
+ }
+
+ private fun taxaAreaQuery(context: Context,
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?): Cursor {
+
+ val bindArgs = mutableListOf()
+
+ val defaultProjection = projection
+ ?: TaxonWithArea.DEFAULT_PROJECTION.asSequence().filter { column -> TaxonWithArea.DEFAULT_PROJECTION.any { it === column } }.map {
+ when (it) {
+ AbstractTaxon.COLUMN_ID, AbstractTaxon.COLUMN_NAME, AbstractTaxon.COLUMN_DESCRIPTION, AbstractTaxon.COLUMN_HERITAGE -> "t.$it"
+ TaxonArea.COLUMN_TAXON_ID, TaxonArea.COLUMN_AREA_ID, TaxonArea.COLUMN_COLOR, TaxonArea.COLUMN_NUMBER_OF_OBSERVERS, TaxonArea.COLUMN_LAST_UPDATED_AT -> "ta.$it"
+ else -> it
+ }
+ }.joinToString(", ")
+
+ val filterOnArea = uri.lastPathSegment?.toLongOrNull()
+ val joinFilterOnAreaClause = if (filterOnArea == null) {
+ ""
+ }
+ else {
+ bindArgs.add(filterOnArea)
+ "LEFT JOIN ${TaxonArea.TABLE_NAME} ta ON ta.${TaxonArea.COLUMN_TAXON_ID} = t.${AbstractTaxon.COLUMN_ID} AND ta.${TaxonArea.COLUMN_AREA_ID} = ?"
+ }
+
+ val whereClause = if (selection == null) "" else "WHERE $selection"
+ val orderBy = sortOrder ?: "t.${AbstractTaxon.COLUMN_NAME} COLLATE NOCASE ASC"
+
+ val sql = """
+ SELECT $defaultProjection
+ FROM ${Taxon.TABLE_NAME} t
+ $joinFilterOnAreaClause
+ $whereClause
+ ORDER BY $orderBy
+ """
+
+ return LocalDatabase.getInstance(context)
+ .taxonAreaDao()
+ .select(SimpleSQLiteQuery(sql,
+ bindArgs.also {
+ it.addAll(selectionArgs?.asList() ?: emptyList())
+ }.toTypedArray()))
+ }
+
+ private fun taxonIdQuery(context: Context,
+ uri: Uri,
+ projection: Array?): Cursor {
+
+ val queryBuilder = SupportSQLiteQueryBuilder.builder(Taxon.TABLE_NAME)
+ .columns(projection ?: AbstractTaxon.DEFAULT_PROJECTION)
+ .selection("${AbstractTaxon.COLUMN_ID} = ?",
+ arrayOf(uri.lastPathSegment?.toLongOrNull()))
+
+ return LocalDatabase.getInstance(context)
+ .taxonDao()
+ .select(queryBuilder.create())
+ }
+
+ private fun taxonAreaIdQuery(context: Context,
+ uri: Uri,
+ projection: Array?): Cursor {
+ val bindArgs = mutableListOf()
+
+ val defaultProjection = projection
+ ?: TaxonWithArea.DEFAULT_PROJECTION.asSequence().filter { column -> TaxonWithArea.DEFAULT_PROJECTION.any { it === column } }.map {
+ when (it) {
+ AbstractTaxon.COLUMN_ID, AbstractTaxon.COLUMN_NAME, AbstractTaxon.COLUMN_DESCRIPTION, AbstractTaxon.COLUMN_HERITAGE -> "t.$it"
+ TaxonArea.COLUMN_TAXON_ID, TaxonArea.COLUMN_AREA_ID, TaxonArea.COLUMN_COLOR, TaxonArea.COLUMN_NUMBER_OF_OBSERVERS, TaxonArea.COLUMN_LAST_UPDATED_AT -> "ta.$it"
+ else -> it
+ }
+ }.joinToString(", ")
+
+ val filterOnArea = uri.lastPathSegment?.toLongOrNull()
+ val joinFilterOnAreaClause = if (filterOnArea == null) {
+ ""
+ }
+ else {
+ bindArgs.add(filterOnArea)
+ "LEFT JOIN ${TaxonArea.TABLE_NAME} ta ON ta.${TaxonArea.COLUMN_TAXON_ID} = t.${AbstractTaxon.COLUMN_ID} AND ta.${TaxonArea.COLUMN_AREA_ID} = ?"
+ }
+
+ val taxonId = uri.pathSegments.asSequence()
+ .map { it.toLongOrNull() }
+ .filterNotNull()
+ .firstOrNull()
+ bindArgs.add(taxonId)
+
+ val whereClause = "WHERE ${AbstractTaxon.COLUMN_ID} = ?"
+
+ val sql = """
+ SELECT $defaultProjection
+ FROM ${Taxon.TABLE_NAME} t
+ $joinFilterOnAreaClause
+ $whereClause
+ """
+
+ return LocalDatabase.getInstance(context)
+ .taxonAreaDao()
+ .select(SimpleSQLiteQuery(sql,
+ bindArgs.toTypedArray()))
+ }
+
+ private fun taxonomyQuery(context: Context,
+ uri: Uri,
+ projection: Array?): Cursor {
+
+ val lastPathSegments = uri.pathSegments.drop(uri.pathSegments.indexOf(Taxonomy.TABLE_NAME) + 1)
+ .take(2)
+ val selection = if (lastPathSegments.isEmpty()) "" else if (lastPathSegments.size == 1) "${Taxonomy.COLUMN_KINGDOM} LIKE ?" else "${Taxonomy.COLUMN_KINGDOM} = ? AND ${Taxonomy.COLUMN_GROUP} LIKE ?"
+
+ val queryBuilder = SupportSQLiteQueryBuilder.builder(Taxonomy.TABLE_NAME)
+ .columns(projection ?: Taxonomy.DEFAULT_PROJECTION)
+
+ if (selection.isNotEmpty()) {
+ queryBuilder.selection(selection,
+ lastPathSegments.toTypedArray())
+ }
+
+ return LocalDatabase.getInstance(context)
+ .taxonomyDao()
+ .select(queryBuilder.create())
+ }
+
+ companion object {
+
+ // used for the UriMatcher
+ const val APP_SYNC_ID = 1
+ const val INPUT_OBSERVERS = 10
+ const val INPUT_OBSERVERS_IDS = 11
+ const val INPUT_OBSERVER_ID = 12
+ const val TAXA = 20
+ const val TAXA_AREA = 21
+ const val TAXON_ID = 22
+ const val TAXON_AREA_ID = 23
+ const val TAXONOMY = 30
+ const val TAXONOMY_KINGDOM = 31
+ const val TAXONOMY_KINGDOM_GROUP = 32
+
+ const val VND_TYPE_DIR_PREFIX = "vnd.android.cursor.dir"
+ const val VND_TYPE_ITEM_PREFIX = "vnd.android.cursor.item"
+
+ /**
+ * The URI matcher.
+ */
+ @JvmStatic
+ private val MATCHER = UriMatcher(UriMatcher.NO_MATCH).apply {
+ addURI(AUTHORITY,
+ "${AppSync.TABLE_NAME}/*",
+ APP_SYNC_ID)
+ addURI(AUTHORITY,
+ InputObserver.TABLE_NAME,
+ INPUT_OBSERVERS)
+ addURI(AUTHORITY,
+ "${InputObserver.TABLE_NAME}/*",
+ INPUT_OBSERVERS_IDS)
+ addURI(AUTHORITY,
+ "${InputObserver.TABLE_NAME}/#",
+ INPUT_OBSERVER_ID)
+ addURI(AUTHORITY,
+ Taxon.TABLE_NAME,
+ TAXA)
+ addURI(AUTHORITY,
+ "${Taxon.TABLE_NAME}/area/#",
+ TAXA_AREA)
+ addURI(AUTHORITY,
+ "${Taxon.TABLE_NAME}/#",
+ TAXON_ID)
+ addURI(AUTHORITY,
+ "${Taxon.TABLE_NAME}/#/area/#",
+ TAXON_AREA_ID)
+ addURI(AUTHORITY,
+ Taxonomy.TABLE_NAME,
+ TAXONOMY)
+ addURI(AUTHORITY,
+ "${Taxonomy.TABLE_NAME}/*",
+ TAXONOMY_KINGDOM)
+ addURI(AUTHORITY,
+ "${Taxonomy.TABLE_NAME}/*/*",
+ TAXONOMY_KINGDOM_GROUP)
+ }
+ }
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/data/TaxonAreaDao.kt b/sync/src/main/java/fr/geonature/sync/data/TaxonAreaDao.kt
new file mode 100644
index 00000000..287a8677
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/data/TaxonAreaDao.kt
@@ -0,0 +1,31 @@
+package fr.geonature.sync.data
+
+import android.database.Cursor
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.RawQuery
+import androidx.sqlite.db.SupportSQLiteQuery
+import fr.geonature.commons.data.TaxonArea
+
+/**
+ * Data access object for [TaxonArea].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+@Dao
+interface TaxonAreaDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun insert(vararg taxa: TaxonArea)
+
+ /**
+ * Select taxa with area from given query.
+ *
+ * @param query the query
+ *
+ * @return A [Cursor] of taxa with areas
+ */
+ @RawQuery
+ fun select(query: SupportSQLiteQuery): Cursor
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/data/TaxonDao.kt b/sync/src/main/java/fr/geonature/sync/data/TaxonDao.kt
new file mode 100644
index 00000000..3aec95a0
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/data/TaxonDao.kt
@@ -0,0 +1,31 @@
+package fr.geonature.sync.data
+
+import android.database.Cursor
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.RawQuery
+import androidx.sqlite.db.SupportSQLiteQuery
+import fr.geonature.commons.data.Taxon
+
+/**
+ * Data access object for [Taxon].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+@Dao
+interface TaxonDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun insert(vararg taxa: Taxon)
+
+ /**
+ * Select taxa from given query.
+ *
+ * @param query the query
+ *
+ * @return A [Cursor] of taxa
+ */
+ @RawQuery
+ fun select(query: SupportSQLiteQuery): Cursor
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/data/TaxonomyDao.kt b/sync/src/main/java/fr/geonature/sync/data/TaxonomyDao.kt
new file mode 100644
index 00000000..df2e92ac
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/data/TaxonomyDao.kt
@@ -0,0 +1,31 @@
+package fr.geonature.sync.data
+
+import android.database.Cursor
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.RawQuery
+import androidx.sqlite.db.SupportSQLiteQuery
+import fr.geonature.commons.data.Taxonomy
+
+/**
+ * Data access object for [Taxonomy].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+@Dao
+interface TaxonomyDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun insert(vararg taxonomy: Taxonomy)
+
+ /**
+ * Select taxonomy ranks from given query.
+ *
+ * @param query the query
+ *
+ * @return A [Cursor] of taxa
+ */
+ @RawQuery
+ fun select(query: SupportSQLiteQuery): Cursor
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/ui/MainActivity.kt b/sync/src/main/java/fr/geonature/sync/ui/MainActivity.kt
new file mode 100644
index 00000000..7f40aec3
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/ui/MainActivity.kt
@@ -0,0 +1,72 @@
+package fr.geonature.sync.ui
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.util.Pair
+import androidx.lifecycle.ViewModelProvider
+import fr.geonature.sync.R
+import fr.geonature.sync.ui.home.HomeFragment
+import fr.geonature.sync.ui.settings.PreferencesActivity
+import fr.geonature.sync.viewmodel.DataSyncViewModel
+
+class MainActivity : AppCompatActivity(),
+ HomeFragment.OnHomeFragmentListener {
+
+ private lateinit var dataSyncViewModel: DataSyncViewModel
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ dataSyncViewModel = ViewModelProvider(this,
+ DataSyncViewModel.Factory { DataSyncViewModel(this.application) }).get(DataSyncViewModel::class.java)
+
+ // Display the fragment as the main content.
+ supportFragmentManager.beginTransaction()
+ .replace(android.R.id.content,
+ HomeFragment.newInstance())
+ .commit()
+
+ startSync()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ menuInflater.inflate(R.menu.settings,
+ menu)
+
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem?): Boolean {
+ return when (item?.itemId) {
+ R.id.menu_settings -> {
+ startActivity(PreferencesActivity.newIntent(this))
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ override fun onItemClicked(itemIntent: Pair) {
+ Log.i(TAG,
+ "onItemClicked: ${itemIntent.first}")
+
+ val intent = itemIntent.second ?: return
+
+ startActivity(intent.apply {
+ putExtra("title",
+ itemIntent.first)
+ })
+ }
+
+ private fun startSync() {
+ dataSyncViewModel.startSync()
+ }
+
+ companion object {
+ private val TAG = MainActivity::class.java.name
+ }
+}
diff --git a/sync/src/main/java/fr/geonature/sync/ui/home/HomeFragment.kt b/sync/src/main/java/fr/geonature/sync/ui/home/HomeFragment.kt
new file mode 100644
index 00000000..abbfa5be
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/ui/home/HomeFragment.kt
@@ -0,0 +1,107 @@
+package fr.geonature.sync.ui.home
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.util.Pair
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import fr.geonature.sync.R
+import fr.geonature.sync.ui.observers.InputObserverListActivity
+import fr.geonature.sync.ui.taxa.TaxaActivity
+
+/**
+ * A fragment representing a list of Items.
+ * Activities containing this fragment MUST implement the
+ * [HomeFragment.OnHomeFragmentListener] interface.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class HomeFragment : Fragment() {
+
+ private var listener: OnHomeFragmentListener? = null
+ private lateinit var adapter: HomeRecyclerViewAdapter
+
+ override fun onCreateView(inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?): View? {
+ return inflater.inflate(R.layout.fragment_home,
+ container,
+ false)
+ }
+
+ override fun onViewCreated(view: View,
+ savedInstanceState: Bundle?) {
+ super.onViewCreated(view,
+ savedInstanceState)
+
+ adapter =
+ HomeRecyclerViewAdapter(object : HomeRecyclerViewAdapter.OnHomeRecyclerViewAdapterListener {
+ override fun onItemClicked(itemIntent: Pair) {
+ Log.i(TAG,
+ "onItemClicked: ${itemIntent.first}")
+
+ listener?.onItemClicked(itemIntent)
+ }
+ })
+
+ // Set the adapter
+ if (view is RecyclerView) {
+ with(view) {
+ layoutManager = LinearLayoutManager(context)
+ adapter = this@HomeFragment.adapter
+ }
+
+ val dividerItemDecoration = DividerItemDecoration(view.context,
+ (view.layoutManager as LinearLayoutManager).orientation)
+ view.addItemDecoration(dividerItemDecoration)
+ }
+
+ adapter.setItems(listOf(Pair.create("Observers",
+ InputObserverListActivity.newIntent(requireContext())),
+ Pair.create("Taxa",
+ TaxaActivity.newIntent(requireContext()))))
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+
+ if (context is OnHomeFragmentListener) {
+ listener = context
+ }
+ else {
+ throw RuntimeException("$context must implement OnHomeFragmentListener")
+ }
+ }
+
+ override fun onDetach() {
+ super.onDetach()
+ listener = null
+ }
+
+ /**
+ * Callback used by [HomeFragment].
+ */
+ interface OnHomeFragmentListener {
+ fun onItemClicked(itemIntent: Pair)
+ }
+
+ companion object {
+
+ private val TAG = HomeFragment::class.java.name
+
+ /**
+ * Use this factory method to create a new instance of [HomeFragment].
+ *
+ * @return A new instance of [HomeFragment]
+ */
+ @JvmStatic
+ fun newInstance() = HomeFragment()
+ }
+}
diff --git a/sync/src/main/java/fr/geonature/sync/ui/home/HomeRecyclerViewAdapter.kt b/sync/src/main/java/fr/geonature/sync/ui/home/HomeRecyclerViewAdapter.kt
new file mode 100644
index 00000000..bb397d68
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/ui/home/HomeRecyclerViewAdapter.kt
@@ -0,0 +1,65 @@
+package fr.geonature.sync.ui.home
+
+import android.content.Intent
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.core.util.Pair
+import androidx.recyclerview.widget.RecyclerView
+import fr.geonature.sync.ui.home.HomeRecyclerViewAdapter.OnHomeRecyclerViewAdapterListener
+
+/**
+ * [RecyclerView.Adapter] that can display item and makes a call to the
+ * specified [OnHomeRecyclerViewAdapterListener].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class HomeRecyclerViewAdapter(private val listener: OnHomeRecyclerViewAdapterListener) : RecyclerView.Adapter() {
+
+ private val itemIntents: MutableList> = mutableListOf()
+
+ override fun onCreateViewHolder(parent: ViewGroup,
+ viewType: Int): ViewHolder {
+ return ViewHolder(parent)
+
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder,
+ position: Int) {
+ holder.bind(itemIntents[position])
+ }
+
+ override fun getItemCount(): Int = itemIntents.size
+
+ fun setItems(items: List>) {
+ this.itemIntents.clear()
+ this.itemIntents.addAll(items)
+
+ notifyDataSetChanged()
+ }
+
+ inner class ViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(LayoutInflater.from(parent.context).inflate(android.R.layout.simple_list_item_1,
+ parent,
+ false)) {
+
+ private val text1: TextView = itemView.findViewById(android.R.id.text1)
+
+ fun bind(itemIntent: Pair) {
+ text1.text = itemIntent.first
+ itemView.setOnClickListener { listener.onItemClicked(itemIntent) }
+ }
+ }
+
+ /**
+ * Callback used by [HomeRecyclerViewAdapter].
+ */
+ interface OnHomeRecyclerViewAdapterListener {
+
+ /**
+ * Called when an item has been clicked.
+ *
+ * @param itemIntent the selected item
+ */
+ fun onItemClicked(itemIntent: Pair)
+ }
+}
diff --git a/sync/src/main/java/fr/geonature/sync/ui/observers/InputObserverListActivity.kt b/sync/src/main/java/fr/geonature/sync/ui/observers/InputObserverListActivity.kt
new file mode 100644
index 00000000..c0944895
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/ui/observers/InputObserverListActivity.kt
@@ -0,0 +1,57 @@
+package fr.geonature.sync.ui.observers
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.MenuItem
+import androidx.appcompat.app.AppCompatActivity
+import fr.geonature.commons.data.InputObserver
+import fr.geonature.sync.R
+
+/**
+ * Let the user to choose an [InputObserver] from the list.
+ *
+ * @see InputObserverListFragment
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class InputObserverListActivity : AppCompatActivity(),
+ InputObserverListFragment.OnInputObserverListFragmentListener {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(R.layout.activity_toolbar)
+
+ setSupportActionBar(findViewById(R.id.toolbar))
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ supportActionBar?.title = intent.getStringExtra("title")
+
+ // Display the fragment as the main content.
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.container,
+ InputObserverListFragment.newInstance())
+ .commit()
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem?): Boolean {
+ return when (item?.itemId ?: return super.onOptionsItemSelected(item)) {
+ android.R.id.home -> {
+ finish()
+ return true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ override fun onSelectedInputObservers(inputObservers: List) {
+ }
+
+ companion object {
+
+ fun newIntent(context: Context): Intent {
+ return Intent(context,
+ InputObserverListActivity::class.java)
+ }
+ }
+}
diff --git a/sync/src/main/java/fr/geonature/sync/ui/observers/InputObserverListFragment.kt b/sync/src/main/java/fr/geonature/sync/ui/observers/InputObserverListFragment.kt
new file mode 100644
index 00000000..d8ec7569
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/ui/observers/InputObserverListFragment.kt
@@ -0,0 +1,337 @@
+package fr.geonature.sync.ui.observers
+
+import android.content.Context
+import android.database.Cursor
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.AnimationUtils
+import android.widget.ListView
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.view.ActionMode
+import androidx.appcompat.widget.SearchView
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import androidx.loader.app.LoaderManager
+import androidx.loader.content.CursorLoader
+import androidx.loader.content.Loader
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import fr.geonature.commons.data.InputObserver
+import fr.geonature.commons.data.Provider.buildUri
+import fr.geonature.sync.R
+import kotlinx.android.synthetic.main.fast_scroll_recycler_view.*
+
+/**
+ * [Fragment] to let the user to choose an [InputObserver] from the list.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class InputObserverListFragment : Fragment() {
+
+ private var listener: OnInputObserverListFragmentListener? = null
+ private lateinit var adapter: InputObserverRecyclerViewAdapter
+ private var progressBar: ProgressBar? = null
+ private var emptyTextView: TextView? = null
+
+ private val loaderCallbacks = object : LoaderManager.LoaderCallbacks {
+ override fun onCreateLoader(id: Int,
+ args: Bundle?): Loader {
+
+ when (id) {
+ LOADER_OBSERVERS -> {
+ val selections = if (args?.getString(KEY_FILTER,
+ null) == null) Pair(null,
+ null)
+ else {
+ val filter = "%${args.getString(KEY_FILTER)}%"
+ Pair("(${InputObserver.COLUMN_LASTNAME} LIKE ? OR ${InputObserver.COLUMN_FIRSTNAME} LIKE ?)",
+ arrayOf(filter,
+ filter))
+ }
+
+ return CursorLoader(requireContext(),
+ buildUri(InputObserver.TABLE_NAME),
+ null,
+ selections.first,
+ selections.second,
+ null)
+ }
+
+ else -> throw IllegalArgumentException()
+ }
+ }
+
+ override fun onLoadFinished(loader: Loader,
+ data: Cursor?) {
+
+ showView(progressBar,
+ false)
+
+ if (data == null) {
+ Log.w(TAG,
+ "Failed to load data from '${(loader as CursorLoader).uri}'")
+
+ return
+ }
+
+ when (loader.id) {
+ LOADER_OBSERVERS -> adapter.bind(data)
+ }
+ }
+
+ override fun onLoaderReset(loader: Loader) {
+ when (loader.id) {
+ LOADER_OBSERVERS -> adapter.bind(null)
+ }
+ }
+ }
+
+ private var actionMode: ActionMode? = null
+ private val actionModeCallback = object : ActionMode.Callback {
+ override fun onCreateActionMode(mode: ActionMode?,
+ menu: Menu?): Boolean {
+ return true
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode?,
+ menu: Menu?): Boolean {
+ return false
+ }
+
+ override fun onActionItemClicked(mode: ActionMode?,
+ item: MenuItem?): Boolean {
+ return false
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode?) {
+ actionMode = null
+ listener?.onSelectedInputObservers(adapter.getSelectedInputObservers())
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?): View? {
+ val view = inflater.inflate(R.layout.fast_scroll_recycler_view,
+ container,
+ false)
+
+ progressBar = view.findViewById(R.id.progressBar)
+ emptyTextView = view.findViewById(R.id.emptyTextView)
+
+ showView(progressBar,
+ true)
+
+ return view
+ }
+
+ override fun onViewCreated(view: View,
+ savedInstanceState: Bundle?) {
+ super.onViewCreated(view,
+ savedInstanceState)
+
+ // we have a menu item to show in action bar
+ setHasOptionsMenu(true)
+
+ adapter = InputObserverRecyclerViewAdapter(object : InputObserverRecyclerViewAdapter.OnInputObserverRecyclerViewAdapterListener {
+ override fun onSelectedInputObservers(inputObservers: List) {
+ if (adapter.isSingleChoice()) {
+ listener?.onSelectedInputObservers(inputObservers)
+ return
+ }
+
+ updateActionMode(inputObservers)
+ }
+
+ override fun scrollToFirstSelectedItemPosition(position: Int) {
+ recyclerView.smoothScrollToPosition(position)
+ }
+ })
+ adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
+ override fun onChanged() {
+ super.onChanged()
+
+ showView(emptyTextView,
+ adapter.itemCount == 0)
+ }
+
+ override fun onItemRangeChanged(positionStart: Int,
+ itemCount: Int) {
+ super.onItemRangeChanged(positionStart,
+ itemCount)
+
+ showView(emptyTextView,
+ adapter.itemCount == 0)
+ }
+
+ override fun onItemRangeInserted(positionStart: Int,
+ itemCount: Int) {
+ super.onItemRangeInserted(positionStart,
+ itemCount)
+
+ showView(emptyTextView,
+ false)
+ }
+ })
+ adapter.setChoiceMode(arguments?.getInt(ARG_CHOICE_MODE) ?: ListView.CHOICE_MODE_SINGLE)
+ adapter.setSelectedInputObservers(arguments?.getParcelableArrayList(ARG_SELECTED_INPUT_OBSERVERS)
+ ?: listOf())
+ .also { updateActionMode(adapter.getSelectedInputObservers()) }
+
+ with(recyclerView) {
+ layoutManager = LinearLayoutManager(context)
+ adapter = this@InputObserverListFragment.adapter
+ }
+
+ val dividerItemDecoration = DividerItemDecoration(recyclerView.context,
+ (recyclerView.layoutManager as LinearLayoutManager).orientation)
+ recyclerView.addItemDecoration(dividerItemDecoration)
+
+ LoaderManager.getInstance(this)
+ .initLoader(LOADER_OBSERVERS,
+ null,
+ loaderCallbacks)
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu,
+ inflater: MenuInflater) {
+
+ super.onCreateOptionsMenu(menu,
+ inflater)
+
+ inflater.inflate(R.menu.search,
+ menu)
+
+ val searchItem = menu.findItem(R.id.action_search)
+ val searchView = searchItem.actionView as SearchView
+ searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+ override fun onQueryTextSubmit(query: String): Boolean {
+ return true
+ }
+
+ override fun onQueryTextChange(newText: String): Boolean {
+ LoaderManager.getInstance(this@InputObserverListFragment)
+ .restartLoader(LOADER_OBSERVERS,
+ bundleOf(Pair(KEY_FILTER,
+ newText)),
+ loaderCallbacks)
+
+ return true
+ }
+ })
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ listener?.onSelectedInputObservers(emptyList())
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+
+ if (context !is OnInputObserverListFragmentListener) {
+ throw RuntimeException("$context must implement OnInputObserverListFragmentListener")
+ }
+
+ listener = context
+ }
+
+ override fun onDetach() {
+ super.onDetach()
+
+ listener = null
+ }
+
+ private fun showView(view: View?,
+ show: Boolean) {
+ if (view == null) return
+
+ if (view.visibility == View.VISIBLE == show) {
+ return
+ }
+
+ if (show) {
+ view.startAnimation(AnimationUtils.loadAnimation(context,
+ android.R.anim.fade_in))
+ view.visibility = View.VISIBLE
+
+ }
+ else {
+ view.postDelayed({
+ view.startAnimation(AnimationUtils.loadAnimation(context,
+ android.R.anim.fade_out))
+ view.visibility = View.GONE
+ },
+ 500)
+ }
+ }
+
+ private fun updateActionMode(inputObservers: List) {
+ if (inputObservers.isEmpty()) {
+ actionMode?.finish()
+ return
+ }
+
+ if (actionMode == null) {
+ actionMode = (activity as AppCompatActivity?)?.startSupportActionMode(actionModeCallback)
+ actionMode?.setTitle(R.string.activity_observers_title)
+ }
+
+ actionMode?.subtitle = resources.getQuantityString(R.plurals.action_title_item_count_selected,
+ inputObservers.size,
+ inputObservers.size)
+ }
+
+ /**
+ * Callback used by [InputObserverListFragment].
+ */
+ interface OnInputObserverListFragmentListener {
+
+ /**
+ * Called when [InputObserver]s were been selected.
+ *
+ * @param inputObservers the selected [InputObserver]s
+ */
+ fun onSelectedInputObservers(inputObservers: List)
+ }
+
+ companion object {
+
+ private val TAG = InputObserverListFragment::class.java.name
+ private const val ARG_CHOICE_MODE = "arg_choice_mode"
+ private const val ARG_SELECTED_INPUT_OBSERVERS = "arg_selected_input_observers"
+ private const val LOADER_OBSERVERS = 1
+ private const val KEY_FILTER = "filter"
+
+ /**
+ * Use this factory method to create a new instance of [InputObserverListFragment].
+ *
+ * @return A new instance of [InputObserverListFragment]
+ */
+ @JvmStatic
+ fun newInstance(choiceMode: Int = ListView.CHOICE_MODE_SINGLE,
+ selectedObservers: List = listOf()) = InputObserverListFragment().apply {
+ arguments = Bundle().apply {
+ putInt(ARG_CHOICE_MODE,
+ choiceMode)
+ putParcelableArrayList(ARG_SELECTED_INPUT_OBSERVERS,
+ ArrayList(selectedObservers))
+ }
+ }
+ }
+}
diff --git a/sync/src/main/java/fr/geonature/sync/ui/observers/InputObserverRecyclerViewAdapter.kt b/sync/src/main/java/fr/geonature/sync/ui/observers/InputObserverRecyclerViewAdapter.kt
new file mode 100644
index 00000000..abcdd23b
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/ui/observers/InputObserverRecyclerViewAdapter.kt
@@ -0,0 +1,213 @@
+package fr.geonature.sync.ui.observers
+
+import android.database.Cursor
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.CheckBox
+import android.widget.ListView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.l4digital.fastscroll.FastScroller
+import fr.geonature.commons.data.InputObserver
+import fr.geonature.sync.R
+
+/**
+ * Default RecyclerView Adapter used by [InputObserverListFragment].
+ *
+ * @see InputObserverListFragment
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class InputObserverRecyclerViewAdapter(private val listener: OnInputObserverRecyclerViewAdapterListener) : RecyclerView.Adapter(),
+ FastScroller.SectionIndexer {
+ private var cursor: Cursor? = null
+ private var choiceMode: Int = ListView.CHOICE_MODE_SINGLE
+ private val selectedInputObservers: MutableList = ArrayList()
+ private val onClickListener: View.OnClickListener
+
+ init {
+ onClickListener = View.OnClickListener { v ->
+ val checkbox: CheckBox = v.findViewById(android.R.id.checkbox)
+
+ val inputObserver = v.tag as InputObserver
+ val isSelected = selectedInputObservers.contains(inputObserver)
+
+ if (isSingleChoice()) {
+ val selectedItemsPositions = selectedInputObservers.map { getItemPosition(it) }
+ selectedInputObservers.clear()
+ selectedItemsPositions.forEach { notifyItemChanged(it) }
+ }
+
+ if (isSelected) {
+ selectedInputObservers.remove(inputObserver)
+ checkbox.isChecked = false
+ }
+ else {
+ selectedInputObservers.add(inputObserver)
+ checkbox.isChecked = true
+ }
+
+ listener.onSelectedInputObservers(selectedInputObservers)
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup,
+ viewType: Int): ViewHolder {
+ return ViewHolder(parent)
+ }
+
+ override fun getItemCount(): Int {
+ return cursor?.count ?: 0
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder,
+ position: Int) {
+
+ holder.bind(position)
+ }
+
+ override fun getSectionText(position: Int): CharSequence {
+ val cursor = cursor ?: return ""
+ cursor.moveToPosition(position)
+ val inputObserver = InputObserver.fromCursor(cursor) ?: return ""
+ val lastname = inputObserver.lastname ?: return ""
+
+ return lastname.elementAt(0)
+ .toString()
+ }
+
+ fun setChoiceMode(choiceMode: Int = ListView.CHOICE_MODE_SINGLE) {
+ this.choiceMode = choiceMode
+ }
+
+ fun isSingleChoice(): Boolean {
+ return choiceMode == ListView.CHOICE_MODE_SINGLE
+ }
+
+ fun setSelectedInputObservers(selectedInputObservers: List) {
+ this.selectedInputObservers.clear()
+ this.selectedInputObservers.addAll(selectedInputObservers)
+
+ if (cursor != null) {
+ notifyDataSetChanged()
+ }
+ }
+
+ fun getSelectedInputObservers(): List {
+ return this.selectedInputObservers
+ }
+
+ fun bind(cursor: Cursor?) {
+ this.cursor = cursor
+ scrollToFirstItemSelected()
+ notifyDataSetChanged()
+ }
+
+ private fun getItemPosition(inputObserver: InputObserver?): Int {
+ var itemPosition = -1
+ val cursor = cursor ?: return itemPosition
+ if (inputObserver == null) return itemPosition
+
+ cursor.moveToFirst()
+
+ while (!cursor.isAfterLast && itemPosition < 0) {
+ val currentInputObserver = InputObserver.fromCursor(cursor)
+
+ if (inputObserver.id == currentInputObserver?.id) {
+ itemPosition = cursor.position
+ }
+
+ cursor.moveToNext()
+ }
+
+ cursor.moveToFirst()
+
+ return itemPosition
+ }
+
+ private fun scrollToFirstItemSelected() {
+ val cursor = cursor ?: return
+
+ // try to find the first selected item position
+ if (selectedInputObservers.size > 0) {
+ cursor.moveToFirst()
+ var foundFirstItemSelected = false
+
+ while (!cursor.isAfterLast && !foundFirstItemSelected) {
+ val currentInputObserver = InputObserver.fromCursor(cursor)
+
+ if (selectedInputObservers.contains(currentInputObserver)) {
+ foundFirstItemSelected = true
+ listener.scrollToFirstSelectedItemPosition(cursor.position)
+ }
+
+ cursor.moveToNext()
+ }
+
+ cursor.moveToFirst()
+ }
+ }
+
+ inner class ViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.list_title_item_2,
+ parent,
+ false)) {
+
+ private val title: TextView = itemView.findViewById(android.R.id.title)
+ private val text1: TextView = itemView.findViewById(android.R.id.text1)
+ private val text2: TextView = itemView.findViewById(android.R.id.text2)
+ private val checkbox: CheckBox = itemView.findViewById(android.R.id.checkbox)
+
+ fun bind(position: Int) {
+ val cursor = cursor ?: return
+
+ cursor.moveToPosition(position)
+
+ val inputObserver = InputObserver.fromCursor(cursor)
+
+ val previousTitle = if (position > 0) {
+ cursor.moveToPosition(position - 1)
+ InputObserver.fromCursor(cursor)
+ ?.lastname?.elementAt(0)
+ .toString()
+ }
+ else {
+ ""
+ }
+
+ if (inputObserver != null) {
+ val currentTitle = inputObserver.lastname?.elementAt(0)
+ .toString()
+ title.text = if (previousTitle == currentTitle) "" else currentTitle
+ text1.text = inputObserver.lastname?.toUpperCase()
+ text2.text = inputObserver.firstname
+ checkbox.isChecked = selectedInputObservers.contains(inputObserver)
+
+ with(itemView) {
+ tag = inputObserver
+ setOnClickListener(onClickListener)
+ }
+ }
+ }
+ }
+
+ /**
+ * Callback used by [InputObserverRecyclerViewAdapter].
+ */
+ interface OnInputObserverRecyclerViewAdapterListener {
+
+ /**
+ * Called when [InputObserver]s were been selected.
+ *
+ * @param inputObservers the selected [InputObserver]s
+ */
+ fun onSelectedInputObservers(inputObservers: List)
+
+ /**
+ * Called if we want to scroll to the first selected item
+ *
+ * @param position the current position of the first selected item
+ */
+ fun scrollToFirstSelectedItemPosition(position: Int)
+ }
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/ui/settings/PreferencesActivity.kt b/sync/src/main/java/fr/geonature/sync/ui/settings/PreferencesActivity.kt
new file mode 100644
index 00000000..8591753d
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/ui/settings/PreferencesActivity.kt
@@ -0,0 +1,47 @@
+package fr.geonature.sync.ui.settings
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.MenuItem
+import androidx.appcompat.app.AppCompatActivity
+
+/**
+ * Global settings.
+ *
+ * @see PreferencesFragment
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class PreferencesActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ // Display the fragment as the main content.
+ supportFragmentManager.beginTransaction()
+ .replace(android.R.id.content,
+ PreferencesFragment.newInstance())
+ .commit()
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem?): Boolean {
+ return when (item?.itemId) {
+ android.R.id.home -> {
+ finish()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ companion object {
+
+ fun newIntent(context: Context): Intent {
+ return Intent(context,
+ PreferencesActivity::class.java)
+ }
+ }
+}
diff --git a/sync/src/main/java/fr/geonature/sync/ui/settings/PreferencesFragment.kt b/sync/src/main/java/fr/geonature/sync/ui/settings/PreferencesFragment.kt
new file mode 100644
index 00000000..7893b438
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/ui/settings/PreferencesFragment.kt
@@ -0,0 +1,36 @@
+package fr.geonature.sync.ui.settings
+
+import android.os.Bundle
+import androidx.preference.PreferenceFragmentCompat
+import fr.geonature.sync.R
+import fr.geonature.sync.util.SettingsUtils.updatePreferences
+
+/**
+ * Global settings.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class PreferencesFragment : PreferenceFragmentCompat() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ updatePreferences(preferenceScreen)
+ }
+
+ override fun onCreatePreferences(savedInstanceState: Bundle?,
+ rootKey: String?) {
+ addPreferencesFromResource(R.xml.preferences)
+ }
+
+ companion object {
+
+ /**
+ * Use this factory method to create a new instance of [PreferencesFragment].
+ *
+ * @return A new instance of [PreferencesFragment]
+ */
+ @JvmStatic
+ fun newInstance() = PreferencesFragment()
+ }
+}
diff --git a/sync/src/main/java/fr/geonature/sync/ui/taxa/TaxaActivity.kt b/sync/src/main/java/fr/geonature/sync/ui/taxa/TaxaActivity.kt
new file mode 100644
index 00000000..8fbdb56f
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/ui/taxa/TaxaActivity.kt
@@ -0,0 +1,58 @@
+package fr.geonature.sync.ui.taxa
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.MenuItem
+import androidx.appcompat.app.AppCompatActivity
+import fr.geonature.commons.data.Taxon
+import fr.geonature.sync.R
+
+/**
+ * Let the user to choose an [Taxon] from the list.
+ *
+ * @see TaxaFragment
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class TaxaActivity : AppCompatActivity(),
+ TaxaFragment.OnTaxaFragmentListener {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(R.layout.activity_toolbar)
+
+ setSupportActionBar(findViewById(R.id.toolbar))
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ supportActionBar?.title = intent.getStringExtra("title")
+
+ // Display the fragment as the main content.
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.container,
+ TaxaFragment.newInstance())
+ .commit()
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem?): Boolean {
+ return when (item?.itemId ?: return super.onOptionsItemSelected(item)) {
+ android.R.id.home -> {
+ finish()
+ return true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ override fun onSelectedTaxon(taxon: Taxon) {
+ }
+
+ companion object {
+
+ fun newIntent(context: Context): Intent {
+ return Intent(context,
+ TaxaActivity::class.java)
+ }
+ }
+}
+
diff --git a/sync/src/main/java/fr/geonature/sync/ui/taxa/TaxaFragment.kt b/sync/src/main/java/fr/geonature/sync/ui/taxa/TaxaFragment.kt
new file mode 100644
index 00000000..d50141fe
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/ui/taxa/TaxaFragment.kt
@@ -0,0 +1,295 @@
+package fr.geonature.sync.ui.taxa
+
+import android.content.Context
+import android.database.Cursor
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.AnimationUtils
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.appcompat.widget.SearchView
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import androidx.loader.app.LoaderManager
+import androidx.loader.content.CursorLoader
+import androidx.loader.content.Loader
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import fr.geonature.commons.data.AbstractTaxon
+import fr.geonature.commons.data.InputObserver
+import fr.geonature.commons.data.Provider.buildUri
+import fr.geonature.commons.data.Taxon
+import fr.geonature.sync.R
+import kotlinx.android.synthetic.main.fast_scroll_recycler_view.*
+
+/**
+ * [Fragment] to let the user to choose an [InputObserver] from the list.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class TaxaFragment : Fragment() {
+
+ private var listener: OnTaxaFragmentListener? = null
+ private lateinit var adapter: TaxaRecyclerViewAdapter
+ private var progressBar: ProgressBar? = null
+ private var emptyTextView: TextView? = null
+
+ private val loaderCallbacks = object : LoaderManager.LoaderCallbacks {
+ override fun onCreateLoader(id: Int,
+ args: Bundle?): Loader {
+
+ when (id) {
+ LOADER_TAXA -> {
+ val selections = if (args?.getString(KEY_FILTER,
+ null) == null) Pair(null,
+ null)
+ else {
+ val filter = "%${args.getString(KEY_FILTER)}%"
+ Pair("(${AbstractTaxon.COLUMN_NAME} LIKE ?)",
+ arrayOf(filter))
+ }
+
+ return CursorLoader(requireContext(),
+ buildUri(Taxon.TABLE_NAME,
+ "area/123"),
+ null,
+ selections.first,
+ selections.second,
+ null)
+ }
+
+ else -> throw IllegalArgumentException()
+ }
+ }
+
+ override fun onLoadFinished(loader: Loader,
+ data: Cursor?) {
+
+ showView(progressBar,
+ false)
+
+ if (data == null) {
+ Log.w(TAG,
+ "Failed to load data from '${(loader as CursorLoader).uri}'")
+
+ return
+ }
+
+ when (loader.id) {
+ LOADER_TAXA -> adapter.bind(data)
+ }
+ }
+
+ override fun onLoaderReset(loader: Loader) {
+ when (loader.id) {
+ LOADER_TAXA -> adapter.bind(null)
+ }
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?): View? {
+ val view = inflater.inflate(R.layout.fast_scroll_recycler_view,
+ container,
+ false)
+
+ progressBar = view.findViewById(R.id.progressBar)
+ emptyTextView = view.findViewById(R.id.emptyTextView)
+
+ showView(progressBar,
+ true)
+
+ return view
+ }
+
+ override fun onViewCreated(view: View,
+ savedInstanceState: Bundle?) {
+ super.onViewCreated(view,
+ savedInstanceState)
+
+ // we have a menu item to show in action bar
+ setHasOptionsMenu(true)
+
+ adapter = TaxaRecyclerViewAdapter(object : TaxaRecyclerViewAdapter.OnTaxaRecyclerViewAdapterListener {
+ override fun onSelectedTaxon(taxon: Taxon) {
+ listener?.onSelectedTaxon(taxon)
+ }
+
+ override fun scrollToFirstSelectedItemPosition(position: Int) {
+ recyclerView.smoothScrollToPosition(position)
+ }
+ })
+ adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
+ override fun onChanged() {
+ super.onChanged()
+
+ showView(emptyTextView,
+ adapter.itemCount == 0)
+ }
+
+ override fun onItemRangeChanged(positionStart: Int,
+ itemCount: Int) {
+ super.onItemRangeChanged(positionStart,
+ itemCount)
+
+ showView(emptyTextView,
+ adapter.itemCount == 0)
+ }
+
+ override fun onItemRangeInserted(positionStart: Int,
+ itemCount: Int) {
+ super.onItemRangeInserted(positionStart,
+ itemCount)
+
+ showView(emptyTextView,
+ false)
+ }
+ })
+
+ val selectedTaxon: Taxon? = arguments?.getParcelable(ARG_SELECTED_TAXON)
+
+ if (selectedTaxon != null) {
+ adapter.setSelectedTaxon(selectedTaxon)
+ }
+
+ with(recyclerView) {
+ layoutManager = LinearLayoutManager(context)
+ adapter = this@TaxaFragment.adapter
+ }
+
+ val dividerItemDecoration = DividerItemDecoration(recyclerView.context,
+ (recyclerView.layoutManager as LinearLayoutManager).orientation)
+ recyclerView.addItemDecoration(dividerItemDecoration)
+
+ LoaderManager.getInstance(this)
+ .initLoader(LOADER_TAXA,
+ null,
+ loaderCallbacks)
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu,
+ inflater: MenuInflater) {
+
+ super.onCreateOptionsMenu(menu,
+ inflater)
+
+ inflater.inflate(R.menu.search,
+ menu)
+
+ val searchItem = menu.findItem(R.id.action_search)
+ val searchView = searchItem.actionView as SearchView
+ searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+ override fun onQueryTextSubmit(query: String): Boolean {
+ return true
+ }
+
+ override fun onQueryTextChange(newText: String): Boolean {
+ LoaderManager.getInstance(this@TaxaFragment)
+ .restartLoader(LOADER_TAXA,
+ bundleOf(Pair(KEY_FILTER,
+ newText)),
+ loaderCallbacks)
+
+ return true
+ }
+ })
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ val selectedTaxon = adapter.getSelectedTaxon()
+
+ if (selectedTaxon != null) {
+ listener?.onSelectedTaxon(selectedTaxon)
+ }
+
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+
+ if (context !is OnTaxaFragmentListener) {
+ throw RuntimeException("$context must implement OnTaxaFragmentListener")
+ }
+
+ listener = context
+ }
+
+ override fun onDetach() {
+ super.onDetach()
+
+ listener = null
+ }
+
+ private fun showView(view: View?,
+ show: Boolean) {
+ if (view == null) return
+
+ if (view.visibility == View.VISIBLE == show) {
+ return
+ }
+
+ if (show) {
+ view.startAnimation(AnimationUtils.loadAnimation(context,
+ android.R.anim.fade_in))
+ view.visibility = View.VISIBLE
+
+ }
+ else {
+ view.postDelayed({
+ view.startAnimation(AnimationUtils.loadAnimation(context,
+ android.R.anim.fade_out))
+ view.visibility = View.GONE
+ },
+ 500)
+ }
+ }
+
+ /**
+ * Callback used by [TaxaFragment].
+ */
+ interface OnTaxaFragmentListener {
+
+ /**
+ * Called when a [Taxon] has been selected.
+ *
+ * @param taxon the selected [Taxon]
+ */
+ fun onSelectedTaxon(taxon: Taxon)
+ }
+
+ companion object {
+
+ private val TAG = TaxaFragment::class.java.name
+ private const val ARG_SELECTED_TAXON = "arg_selected_taxon"
+ private const val LOADER_TAXA = 1
+ private const val KEY_FILTER = "filter"
+
+ /**
+ * Use this factory method to create a new instance of [TaxaFragment].
+ *
+ * @return A new instance of [TaxaFragment]
+ */
+ @JvmStatic
+ fun newInstance(selectedTaxon: Taxon? = null) = TaxaFragment().apply {
+ arguments = Bundle().apply {
+ putParcelable(ARG_SELECTED_TAXON,
+ selectedTaxon)
+ }
+ }
+ }
+}
diff --git a/sync/src/main/java/fr/geonature/sync/ui/taxa/TaxaRecyclerViewAdapter.kt b/sync/src/main/java/fr/geonature/sync/ui/taxa/TaxaRecyclerViewAdapter.kt
new file mode 100644
index 00000000..71f9a6e2
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/ui/taxa/TaxaRecyclerViewAdapter.kt
@@ -0,0 +1,179 @@
+package fr.geonature.sync.ui.taxa
+
+import android.database.Cursor
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.CheckBox
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.l4digital.fastscroll.FastScroller
+import fr.geonature.commons.data.Taxon
+import fr.geonature.commons.data.TaxonWithArea
+import fr.geonature.sync.R
+
+/**
+ * Default RecyclerView Adapter used by [TaxaFragment].
+ *
+ * @see TaxaFragment
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class TaxaRecyclerViewAdapter(private val listener: OnTaxaRecyclerViewAdapterListener) : RecyclerView.Adapter(),
+ FastScroller.SectionIndexer {
+ private var cursor: Cursor? = null
+ private var selectedTaxon: Taxon? = null
+ private val onClickListener: View.OnClickListener
+
+ init {
+ onClickListener = View.OnClickListener { v ->
+ val previousSelectedItemPosition = getItemPosition(selectedTaxon)
+
+ val checkbox: CheckBox = v.findViewById(android.R.id.checkbox)
+ checkbox.isChecked = true
+
+ val taxon = v.tag as Taxon
+ selectedTaxon = taxon
+
+ listener.onSelectedTaxon(taxon)
+
+ if (previousSelectedItemPosition >= 0) {
+ notifyItemChanged(previousSelectedItemPosition)
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup,
+ viewType: Int): ViewHolder {
+ return ViewHolder(parent)
+ }
+
+ override fun getItemCount(): Int {
+ return cursor?.count ?: 0
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder,
+ position: Int) {
+
+ holder.bind(position)
+ }
+
+ override fun getSectionText(position: Int): CharSequence {
+ val cursor = cursor ?: return ""
+ cursor.moveToPosition(position)
+ val taxon = Taxon.fromCursor(cursor) ?: return ""
+ val name = taxon.name ?: return ""
+
+ return name.elementAt(0)
+ .toString()
+ }
+
+ fun setSelectedTaxon(selectedTaxon: Taxon) {
+ this.selectedTaxon = selectedTaxon
+ notifyDataSetChanged()
+ }
+
+ fun getSelectedTaxon(): Taxon? {
+ return this.selectedTaxon
+ }
+
+ fun bind(cursor: Cursor?) {
+ this.cursor = cursor
+ scrollToFirstItemSelected()
+ notifyDataSetChanged()
+ }
+
+ private fun getItemPosition(taxon: Taxon?): Int {
+ var itemPosition = -1
+ val cursor = cursor ?: return itemPosition
+ if (taxon == null) return itemPosition
+
+ cursor.moveToFirst()
+
+ while (!cursor.isAfterLast && itemPosition < 0) {
+ val currentTaxon = Taxon.fromCursor(cursor)
+
+ if (taxon.id == currentTaxon?.id) {
+ itemPosition = cursor.position
+ }
+
+ cursor.moveToNext()
+ }
+
+ cursor.moveToFirst()
+
+ return itemPosition
+ }
+
+ private fun scrollToFirstItemSelected() {
+ val selectedTaxon = selectedTaxon ?: return
+ val selectedItemPosition = getItemPosition(selectedTaxon)
+
+ if (selectedItemPosition >= 0) {
+ listener.scrollToFirstSelectedItemPosition(selectedItemPosition)
+ }
+ }
+
+ inner class ViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.list_title_item_2,
+ parent,
+ false)) {
+
+ private val title: TextView = itemView.findViewById(android.R.id.title)
+ private val text1: TextView = itemView.findViewById(android.R.id.text1)
+ private val text2: TextView = itemView.findViewById(android.R.id.text2)
+ private val checkbox: CheckBox = itemView.findViewById(android.R.id.checkbox)
+
+ fun bind(position: Int) {
+ val cursor = cursor ?: return
+
+ cursor.moveToPosition(position)
+
+ val taxon = TaxonWithArea.fromCursor(cursor)
+
+ val previousTitle = if (position > 0) {
+ cursor.moveToPosition(position - 1)
+ TaxonWithArea.fromCursor(cursor)
+ ?.name?.elementAt(0)
+ .toString()
+ }
+ else {
+ ""
+ }
+
+ if (taxon != null) {
+ val currentTitle = taxon.name?.elementAt(0)
+ .toString()
+ title.text = if (previousTitle == currentTitle) "" else currentTitle
+ text1.text = taxon.name
+ text2.text = taxon.heritage.toString()
+ //text2.text = taxon.description
+ checkbox.isChecked = selectedTaxon?.id == taxon.id
+
+ with(itemView) {
+ tag = taxon
+ setOnClickListener(onClickListener)
+ }
+ }
+ }
+ }
+
+ /**
+ * Callback used by [TaxaRecyclerViewAdapter].
+ */
+ interface OnTaxaRecyclerViewAdapterListener {
+
+ /**
+ * Called when a [Taxon] has been selected.
+ *
+ * @param taxon the selected [Taxon]
+ */
+ fun onSelectedTaxon(taxon: Taxon)
+
+ /**
+ * Called if we want to scroll to the first selected item
+ *
+ * @param position the current position of the first selected item
+ */
+ fun scrollToFirstSelectedItemPosition(position: Int)
+ }
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/util/FileUtils.kt b/sync/src/main/java/fr/geonature/sync/util/FileUtils.kt
new file mode 100644
index 00000000..4a6cdc0e
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/util/FileUtils.kt
@@ -0,0 +1,134 @@
+package fr.geonature.sync.util
+
+import android.content.Context
+import android.os.Environment
+import android.util.Log
+import fr.geonature.commons.model.MountPoint
+import fr.geonature.commons.util.MountPointUtils
+import java.io.File
+
+/**
+ * Helpers for [File] utilities.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+object FileUtils {
+
+ private val TAG = FileUtils::class.java.name
+
+ /**
+ * Construct a file from the set of name elements.
+ *
+ * @param directory the parent directory
+ * @param names the name elements
+ *
+ * @return the corresponding file
+ */
+ fun getFile(
+ directory: File,
+ vararg names: String): File {
+
+ var file = directory
+
+ for (name in names) {
+ file = File(
+ file,
+ name)
+ }
+
+ return file
+ }
+
+ /**
+ * Gets the relative path used by this context.
+ *
+ * @param context the current `Context`
+ *
+ * @return the relative path
+ */
+ fun getRelativeSharedPath(context: Context): String {
+
+ return "Android" + File.separator + "data" + File.separator + context.packageName + File.separator
+ }
+
+ /**
+ * Tries to find the mount point used by external storage as `File`.
+ * If not, returns the default `Environment.getExternalStorageDirectory()`.
+ *
+ * @param context the current `Context`
+ *
+ * @return the mount point as `File` used by external storage if available
+ */
+ fun getExternalStorageDirectory(context: Context): File {
+
+ val externalMountPoint = MountPointUtils.getExternalStorage(
+ context,
+ Environment.MEDIA_MOUNTED,
+ Environment.MEDIA_MOUNTED_READ_ONLY)
+
+ if (externalMountPoint == null) {
+ Log.w(
+ TAG,
+ "getExternalStorageDirectory: external mount point is not available. Use default: " + MountPointUtils.getInternalStorage())
+
+ return MountPointUtils.getInternalStorage().mountPath
+ }
+
+ return externalMountPoint.mountPath
+ }
+
+ /**
+ * Gets the root folder as `File` used by this context.
+ *
+ * @param context the current `Context`
+ * @param storageType the [MountPoint.StorageType] to use
+ *
+ * @return the root folder as `File`
+ */
+ fun getRootFolder(
+ context: Context,
+ storageType: MountPoint.StorageType): File {
+
+ return getFile(
+ if (storageType === MountPoint.StorageType.EXTERNAL) getExternalStorageDirectory(context)
+ else MountPointUtils.getInternalStorage().mountPath,
+ getRelativeSharedPath(context))
+ }
+
+ /**
+ * Gets the `inputs/` folder as `File` used by this context.
+ * The relative path used is `inputs/`
+ *
+ * @param context the current `Context`
+ *
+ * @return the `inputs/` folder as `File`
+ */
+ fun getInputsFolder(context: Context): File {
+
+ return getFile(
+ getRootFolder(
+ context,
+ MountPoint.StorageType.INTERNAL),
+ "inputs",
+ context.packageName)
+ }
+
+ /**
+ * Gets the `databases/` folder as `File` used by this context.
+ *
+ * @param context the current `Context`
+ * @param storageType the [MountPoint.StorageType] to use
+ *
+ * @return the `databases/` folder
+ */
+ fun getDatabaseFolder(
+ context: Context,
+ storageType: MountPoint.StorageType): File {
+
+ return getFile(
+ getRootFolder(
+ context,
+ storageType),
+ "databases")
+ }
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/util/SettingsUtils.kt b/sync/src/main/java/fr/geonature/sync/util/SettingsUtils.kt
new file mode 100644
index 00000000..8c61c5cd
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/util/SettingsUtils.kt
@@ -0,0 +1,52 @@
+package fr.geonature.sync.util
+
+import android.content.Context
+import androidx.preference.EditTextPreference
+import androidx.preference.Preference
+import androidx.preference.PreferenceManager
+import androidx.preference.PreferenceScreen
+import fr.geonature.sync.BuildConfig
+import fr.geonature.sync.R
+import java.text.DateFormat
+import java.util.Date
+
+/**
+ * Helper about application settings through [Preference].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+object SettingsUtils {
+
+ /**
+ * Gets the current GeoNature server url to use.
+ *
+ * @param context the current context
+ *
+ * @return the GeoNature server url to use or `null` if not defined
+ */
+ fun getGeoNatureServerUrl(context: Context): String? {
+ return PreferenceManager.getDefaultSharedPreferences(context)
+ .getString(context.getString(R.string.preference_category_server_url_key),
+ null)
+ }
+
+ fun updatePreferences(preferenceScreen: PreferenceScreen) {
+ val context = preferenceScreen.context
+ val onPreferenceChangeListener = Preference.OnPreferenceChangeListener { preference, newValue ->
+ preference.summary = newValue.toString()
+ true
+ }
+
+ preferenceScreen.findPreference(context.getString(R.string.preference_category_server_url_key))
+ ?.apply {
+ summary = getGeoNatureServerUrl(preferenceScreen.context)
+ setOnPreferenceChangeListener(onPreferenceChangeListener)
+ }
+
+ preferenceScreen.findPreference(context.getString(R.string.preference_category_about_app_version_key))
+ ?.summary = context.getString(R.string.app_version,
+ BuildConfig.VERSION_NAME,
+ BuildConfig.VERSION_CODE,
+ DateFormat.getDateTimeInstance().format(Date(BuildConfig.BUILD_DATE.toLong())))
+ }
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/viewmodel/DataSyncViewModel.kt b/sync/src/main/java/fr/geonature/sync/viewmodel/DataSyncViewModel.kt
new file mode 100644
index 00000000..12c7ae05
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/viewmodel/DataSyncViewModel.kt
@@ -0,0 +1,45 @@
+package fr.geonature.sync.viewmodel
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import fr.geonature.sync.worker.Constants
+import fr.geonature.sync.worker.DataSyncWorker
+
+/**
+ * Keeps track of sync operations.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class DataSyncViewModel(application: Application) : AndroidViewModel(application) {
+ private val workManager: WorkManager = WorkManager.getInstance(getApplication())
+
+ internal val syncOutputStatus: LiveData>
+ get() = workManager.getWorkInfosByTagLiveData(Constants.TAG_DATA_SYNC_OUTPUT)
+
+ fun startSync() {
+ val continuation = workManager.beginUniqueWork(Constants.DATA_SYNC_WORK_NAME,
+ ExistingWorkPolicy.REPLACE,
+ OneTimeWorkRequest.from(DataSyncWorker::class.java))
+
+ // start the work
+ continuation.enqueue()
+ }
+
+ /**
+ * Default Factory to use for [DataSyncViewModel].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+ class Factory(val creator: () -> DataSyncViewModel) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ @Suppress("UNCHECKED_CAST") return creator() as T
+ }
+ }
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/worker/Constants.kt b/sync/src/main/java/fr/geonature/sync/worker/Constants.kt
new file mode 100644
index 00000000..e3548a12
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/worker/Constants.kt
@@ -0,0 +1,14 @@
+package fr.geonature.sync.worker
+
+/**
+ * Defines a list of constants used for [androidx.work.Worker] names, inputs & outputs.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+object Constants {
+
+ // The name of the synchronisation work
+ const val DATA_SYNC_WORK_NAME = "data_sync_work_name"
+
+ const val TAG_DATA_SYNC_OUTPUT = "tag_data_sync_output"
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/worker/DataSyncWorker.kt b/sync/src/main/java/fr/geonature/sync/worker/DataSyncWorker.kt
new file mode 100644
index 00000000..26d96d3f
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/worker/DataSyncWorker.kt
@@ -0,0 +1,153 @@
+package fr.geonature.sync.worker
+
+import android.content.Context
+import android.text.TextUtils
+import android.util.Log
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import fr.geonature.commons.data.InputObserver
+import fr.geonature.commons.data.Taxon
+import fr.geonature.commons.data.TaxonArea
+import fr.geonature.sync.api.GeoNatureAPIClient
+import fr.geonature.sync.data.LocalDatabase
+import fr.geonature.sync.util.SettingsUtils
+
+/**
+ * Local data synchronisation worker.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class DataSyncWorker(appContext: Context,
+ workerParams: WorkerParameters) : Worker(appContext,
+ workerParams) {
+
+ override fun doWork(): Result {
+ val geoNatureServerUrl = SettingsUtils.getGeoNatureServerUrl(applicationContext)
+
+ if (TextUtils.isEmpty(geoNatureServerUrl)) {
+ return Result.failure()
+ }
+
+ val geoNatureServiceClient = GeoNatureAPIClient.instance(geoNatureServerUrl!!)
+ .value
+
+ val syncInputObserversResult = syncInputObservers(geoNatureServiceClient)
+
+ if (syncInputObserversResult is Result.Failure) {
+ return syncInputObserversResult
+ }
+
+ val syncTaxonomyRanksResult = syncTaxonomyRanks(geoNatureServiceClient)
+
+ if (syncTaxonomyRanksResult is Result.Failure) {
+ return syncTaxonomyRanksResult
+ }
+
+ return syncTaxa(geoNatureServiceClient)
+ }
+
+ private fun syncInputObservers(geoNatureServiceClient: GeoNatureAPIClient): Result {
+ val response = geoNatureServiceClient.getUsers()
+ .execute()
+
+ if (!response.isSuccessful) {
+ return Result.failure()
+ }
+
+ val users = response.body() ?: return Result.failure()
+ val inputObservers = users.map {
+ InputObserver(it.id,
+ it.lastname,
+ it.firstname)
+ }
+ .toTypedArray()
+
+ Log.i(TAG,
+ "users to update: ${users.size}")
+
+ LocalDatabase.getInstance(applicationContext)
+ .inputObserverDao()
+ .insert(*inputObservers)
+
+ return Result.success()
+ }
+
+ private fun syncTaxonomyRanks(geoNatureServiceClient: GeoNatureAPIClient): Result {
+ val taxonomyRanksResponse = geoNatureServiceClient.getTaxonomyRanks()
+ .execute()
+
+ if (!taxonomyRanksResponse.isSuccessful) {
+ return Result.failure()
+ }
+
+ val jsonString = taxonomyRanksResponse.body()?.string() ?: return Result.failure()
+ val taxonomy = TaxonomyJsonReader().read(jsonString)
+
+ Log.i(TAG,
+ "taxonomy to update: ${taxonomy.size}")
+
+ LocalDatabase.getInstance(applicationContext)
+ .taxonomyDao()
+ .insert(*taxonomy.toTypedArray())
+
+ return Result.success()
+ }
+
+ private fun syncTaxa(geoNatureServiceClient: GeoNatureAPIClient): Result {
+ val taxrefResponse = geoNatureServiceClient.getTaxref()
+ .execute()
+
+ if (!taxrefResponse.isSuccessful) {
+ return Result.failure()
+ }
+
+ val taxrefAreasResponse = geoNatureServiceClient.getTaxrefAreas()
+ .execute()
+
+ if (!taxrefAreasResponse.isSuccessful) {
+ return Result.failure()
+ }
+
+ val taxref = taxrefResponse.body() ?: return Result.failure()
+ val taxrefAreas = taxrefAreasResponse.body() ?: return Result.failure()
+
+ val taxa = taxref.map {
+ Taxon(it.id,
+ it.name,
+ null)
+ }
+ .toTypedArray()
+
+ Log.i(TAG,
+ "taxa to update: ${taxa.size}")
+
+ LocalDatabase.getInstance(applicationContext)
+ .taxonDao()
+ .insert(*taxa)
+
+ val taxonAreas = taxrefAreas.asSequence()
+ .filter { taxrefArea -> taxa.any { it.id == taxrefArea.taxrefId } }
+ .map {
+ TaxonArea(it.taxrefId,
+ it.areaId,
+ it.color,
+ it.numberOfObservers,
+ it.lastUpdatedAt)
+ }
+ .toList()
+ .toTypedArray()
+
+ Log.i(TAG,
+ "taxa with areas to update: ${taxonAreas.size}")
+
+ LocalDatabase.getInstance(applicationContext)
+ .taxonAreaDao()
+ .insert(*taxonAreas)
+
+ return Result.success()
+ }
+
+ companion object {
+ private val TAG = DataSyncWorker::class.java.name
+ }
+}
\ No newline at end of file
diff --git a/sync/src/main/java/fr/geonature/sync/worker/TaxonomyJsonReader.kt b/sync/src/main/java/fr/geonature/sync/worker/TaxonomyJsonReader.kt
new file mode 100644
index 00000000..d7ec4c69
--- /dev/null
+++ b/sync/src/main/java/fr/geonature/sync/worker/TaxonomyJsonReader.kt
@@ -0,0 +1,133 @@
+package fr.geonature.sync.worker
+
+import android.text.TextUtils
+import android.util.JsonReader
+import android.util.JsonToken
+import android.util.JsonToken.NAME
+import android.util.Log
+import fr.geonature.commons.data.Taxonomy
+import fr.geonature.commons.util.StringUtils
+import java.io.IOException
+import java.io.Reader
+import java.io.StringReader
+
+/**
+ * Default `JsonReader` about reading a `JSON` stream and build the corresponding [Taxonomy].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+class TaxonomyJsonReader {
+
+ /**
+ * parse a `JSON` string to convert as list of [Taxonomy].
+ *
+ * @param json the `JSON` string to parse
+ * @return a list of [Taxonomy] instances from the `JSON` string or empty if something goes wrong
+ * @see .read
+ */
+ fun read(json: String?): List {
+ if (StringUtils.isEmpty(json)) {
+ return emptyList()
+ }
+
+ try {
+ return read(StringReader(json))
+ }
+ catch (ioe: IOException) {
+ Log.w(TAG,
+ ioe.message)
+ }
+
+ return emptyList()
+ }
+
+ /**
+ * parse a `JSON` reader to convert as list of [Taxonomy].
+ *
+ * @param reader the `Reader` to parse
+ * @return a list of [Taxonomy] instance from the `JSON` reader
+ * @throws IOException if something goes wrong
+ */
+ @Throws(IOException::class)
+ fun read(reader: Reader): List {
+ val jsonReader = JsonReader(reader)
+ val input = readTaxonomyAsMap(jsonReader)
+ jsonReader.close()
+
+ return input
+ }
+
+ @Throws(IOException::class)
+ private fun readTaxonomyAsMap(reader: JsonReader): List {
+ val taxonomyAsMap = HashMap>()
+
+ reader.beginObject()
+
+ var kingdom: String? = null
+
+ while (reader.hasNext()) {
+ when (val jsonToken = reader.peek()) {
+ NAME -> {
+ val key = reader.nextName()
+
+ if (TextUtils.isEmpty(key)) {
+ kingdom = null
+ }
+ else {
+ kingdom = key
+ taxonomyAsMap[kingdom!!] = mutableSetOf()
+ }
+ }
+ JsonToken.BEGIN_ARRAY -> {
+ if (TextUtils.isEmpty(kingdom)) {
+ reader.skipValue()
+ }
+ else {
+ reader.beginArray()
+ while (reader.hasNext()) {
+ val group = reader.nextString()
+
+ if (!TextUtils.isEmpty(group)) {
+ taxonomyAsMap[kingdom!!]?.add(group)
+ }
+ }
+
+ reader.endArray()
+ }
+ }
+ else -> {
+ reader.skipValue()
+ Log.w(TAG,
+ "Invalid object properties JSON token $jsonToken while reading taxonomy")
+ }
+ }
+ }
+
+ reader.endObject()
+
+ return taxonomyAsMap.keys.asSequence()
+ .map { kingdomAsKey ->
+ taxonomyAsMap[kingdomAsKey]?.map { group ->
+ Taxonomy(kingdomAsKey,
+ group)
+ } ?: emptyList()
+ }
+ .filter { it.isNotEmpty() }
+ .flatMap { it.asSequence() }
+ .sortedWith(Comparator { o1, o2 ->
+ val kingdomCompare = o1.kingdom.compareTo(o2.kingdom)
+
+ if (kingdomCompare != 0) {
+ kingdomCompare
+ }
+ else {
+ o1.group.compareTo(o2.group)
+ }
+ })
+ .toList()
+ }
+
+ companion object {
+ private val TAG = TaxonomyJsonReader::class.java.name
+ }
+}
\ No newline at end of file
diff --git a/sync/src/main/res/animator/list_item_section_header_elevation.xml b/sync/src/main/res/animator/list_item_section_header_elevation.xml
new file mode 100644
index 00000000..972206ec
--- /dev/null
+++ b/sync/src/main/res/animator/list_item_section_header_elevation.xml
@@ -0,0 +1,20 @@
+
+
+
+ -
+
+
+
+ -
+
+
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/color/actionbar_icon_tint.xml b/sync/src/main/res/color/actionbar_icon_tint.xml
new file mode 100644
index 00000000..2f04f4ae
--- /dev/null
+++ b/sync/src/main/res/color/actionbar_icon_tint.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/drawable-v24/ic_launcher_foreground.xml b/sync/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 00000000..c3903edf
--- /dev/null
+++ b/sync/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sync/src/main/res/drawable/ic_action_settings.xml b/sync/src/main/res/drawable/ic_action_settings.xml
new file mode 100644
index 00000000..4077ce97
--- /dev/null
+++ b/sync/src/main/res/drawable/ic_action_settings.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/sync/src/main/res/drawable/ic_launcher_background.xml b/sync/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..e80b3ee9
--- /dev/null
+++ b/sync/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sync/src/main/res/layout/activity_toolbar.xml b/sync/src/main/res/layout/activity_toolbar.xml
new file mode 100644
index 00000000..5279aea4
--- /dev/null
+++ b/sync/src/main/res/layout/activity_toolbar.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/layout/fast_scroll_recycler_view.xml b/sync/src/main/res/layout/fast_scroll_recycler_view.xml
new file mode 100644
index 00000000..c89c5997
--- /dev/null
+++ b/sync/src/main/res/layout/fast_scroll_recycler_view.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/sync/src/main/res/layout/fragment_home.xml b/sync/src/main/res/layout/fragment_home.xml
new file mode 100644
index 00000000..c042915b
--- /dev/null
+++ b/sync/src/main/res/layout/fragment_home.xml
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/layout/list_title_item_2.xml b/sync/src/main/res/layout/list_title_item_2.xml
new file mode 100644
index 00000000..0f227995
--- /dev/null
+++ b/sync/src/main/res/layout/list_title_item_2.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/menu/search.xml b/sync/src/main/res/menu/search.xml
new file mode 100644
index 00000000..0c008f19
--- /dev/null
+++ b/sync/src/main/res/menu/search.xml
@@ -0,0 +1,11 @@
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/menu/settings.xml b/sync/src/main/res/menu/settings.xml
new file mode 100644
index 00000000..9ea4b2a3
--- /dev/null
+++ b/sync/src/main/res/menu/settings.xml
@@ -0,0 +1,11 @@
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sync/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..bbd3e021
--- /dev/null
+++ b/sync/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sync/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..bbd3e021
--- /dev/null
+++ b/sync/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/mipmap-hdpi/ic_launcher.png b/sync/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..898f3ed5
Binary files /dev/null and b/sync/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/sync/src/main/res/mipmap-hdpi/ic_launcher_round.png b/sync/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..dffca360
Binary files /dev/null and b/sync/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/sync/src/main/res/mipmap-mdpi/ic_launcher.png b/sync/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..64ba76f7
Binary files /dev/null and b/sync/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/sync/src/main/res/mipmap-mdpi/ic_launcher_round.png b/sync/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..dae5e082
Binary files /dev/null and b/sync/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/sync/src/main/res/mipmap-xhdpi/ic_launcher.png b/sync/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..e5ed4659
Binary files /dev/null and b/sync/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/sync/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/sync/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..14ed0af3
Binary files /dev/null and b/sync/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/sync/src/main/res/mipmap-xxhdpi/ic_launcher.png b/sync/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..b0907cac
Binary files /dev/null and b/sync/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/sync/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/sync/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..d8ae0315
Binary files /dev/null and b/sync/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/sync/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/sync/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..2c18de9e
Binary files /dev/null and b/sync/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/sync/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/sync/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..beed3cdd
Binary files /dev/null and b/sync/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/sync/src/main/res/values-fr/prefs.xml b/sync/src/main/res/values-fr/prefs.xml
new file mode 100644
index 00000000..6a7ff209
--- /dev/null
+++ b/sync/src/main/res/values-fr/prefs.xml
@@ -0,0 +1,9 @@
+
+
+
+ Serveur GeoNature
+ URL
+ A propos
+ Version de l\'application
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/values-fr/strings.xml b/sync/src/main/res/values-fr/strings.xml
new file mode 100644
index 00000000..f26699e9
--- /dev/null
+++ b/sync/src/main/res/values-fr/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ Paramètres
+ Observateurs
+
+ Aucune donnée
+
+ Paramètres
+ Recherche
+
+ - %d sélectionné
+ - %d sélectionnés
+
+
+
diff --git a/sync/src/main/res/values/colors.xml b/sync/src/main/res/values/colors.xml
new file mode 100644
index 00000000..810d7e05
--- /dev/null
+++ b/sync/src/main/res/values/colors.xml
@@ -0,0 +1,11 @@
+
+
+
+ #008577
+ #00574B
+ #D81B60
+
+ @android:color/white
+ #80ffffff
+
+
diff --git a/sync/src/main/res/values/dimens.xml b/sync/src/main/res/values/dimens.xml
new file mode 100644
index 00000000..5aa6709d
--- /dev/null
+++ b/sync/src/main/res/values/dimens.xml
@@ -0,0 +1,5 @@
+
+
+ 16dp
+ 8dp
+
diff --git a/sync/src/main/res/values/prefs.xml b/sync/src/main/res/values/prefs.xml
new file mode 100644
index 00000000..81555b24
--- /dev/null
+++ b/sync/src/main/res/values/prefs.xml
@@ -0,0 +1,11 @@
+
+
+
+ GeoNature Server
+ URL
+ server_url
+ About
+ app_version
+ App version
+
+
\ No newline at end of file
diff --git a/sync/src/main/res/values/strings.xml b/sync/src/main/res/values/strings.xml
new file mode 100644
index 00000000..0e56394c
--- /dev/null
+++ b/sync/src/main/res/values/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Sync
+ %1$s (%2$d)\n%3$s
+
+ Settings
+ Observers
+
+ No data
+
+ Settings
+ Search
+
+ - %d selected
+ - %d selected
+
+
+
diff --git a/sync/src/main/res/values/styles.xml b/sync/src/main/res/values/styles.xml
new file mode 100644
index 00000000..f2f02d91
--- /dev/null
+++ b/sync/src/main/res/values/styles.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sync/src/main/res/xml/preferences.xml b/sync/src/main/res/xml/preferences.xml
new file mode 100644
index 00000000..4c5d9f1c
--- /dev/null
+++ b/sync/src/main/res/xml/preferences.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sync/src/test/java/fr/geonature/sync/FixtureHelper.kt b/sync/src/test/java/fr/geonature/sync/FixtureHelper.kt
new file mode 100644
index 00000000..35c4fc18
--- /dev/null
+++ b/sync/src/test/java/fr/geonature/sync/FixtureHelper.kt
@@ -0,0 +1,79 @@
+package fr.geonature.sync
+
+import java.io.BufferedReader
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.io.InputStream
+import java.io.InputStreamReader
+
+/**
+ * Helper functions about loading fixtures files from resources `fixtures/` folder.
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+object FixtureHelper {
+
+ /**
+ * Reads the contents of a file as `File`.
+ *
+ * @param name the file to read (e.g. XML, JSON or any text file), must not be `null`
+ *
+ * @return the contents as `File`
+ */
+ @Throws(FileNotFoundException::class)
+ fun getFixtureAsFile(name: String): File {
+ val resource = FixtureHelper::class.java.classLoader!!.getResource("fixtures/$name")
+ ?: throw FileNotFoundException("file not found $name")
+
+ return File(resource.file)
+ }
+
+ /**
+ * Reads the contents of a file as string.
+ * The file is always closed.
+ *
+ * @param name the file to read (e.g. XML, JSON or any text file), must not be `null`
+ *
+ * @return the file contents, never `null`
+ */
+ fun getFixture(name: String): String {
+ val stringBuilder = StringBuilder()
+
+ val inputStream = getFixtureAsStream(name) ?: return stringBuilder.toString()
+
+ val bufferedReader = BufferedReader(InputStreamReader(inputStream))
+
+ try {
+ var line: String? = bufferedReader.readLine()
+
+ while (line != null) {
+ stringBuilder.append(line)
+ stringBuilder.append("\n")
+ line = bufferedReader.readLine()
+ }
+ }
+ catch (ignored: IOException) {
+ }
+ finally {
+ try {
+ bufferedReader.close()
+ }
+ catch (ignored: IOException) {
+ }
+ }
+
+ return stringBuilder.toString().trim { it <= ' ' }
+ }
+
+ /**
+ * Reads the contents of a file as [InputStream].
+ *
+ * @param name the file to read (e.g. XML, JSON or any text file), must not be `null`
+ *
+ * @return the file contents as [InputStream]
+ */
+ private fun getFixtureAsStream(name: String): InputStream? {
+ return FixtureHelper::class.java.classLoader!!.getResourceAsStream("fixtures/$name")
+ }
+}
diff --git a/sync/src/test/java/fr/geonature/sync/worker/TaxonomyJsonReaderTest.kt b/sync/src/test/java/fr/geonature/sync/worker/TaxonomyJsonReaderTest.kt
new file mode 100644
index 00000000..02d6dce9
--- /dev/null
+++ b/sync/src/test/java/fr/geonature/sync/worker/TaxonomyJsonReaderTest.kt
@@ -0,0 +1,71 @@
+package fr.geonature.sync.worker
+
+import android.app.Application
+import fr.geonature.sync.FixtureHelper.getFixture
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Unit tests about [TaxonomyJsonReader].
+ *
+ * @author [S. Grimault](mailto:sebastien.grimault@gmail.com)
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class)
+class TaxonomyJsonReaderTest {
+
+ private lateinit var taxonomyJsonReader: TaxonomyJsonReader
+
+ @Before
+ fun setUp() {
+ taxonomyJsonReader = TaxonomyJsonReader()
+ }
+
+ @Test
+ fun testReadFromInvalidJsonString() {
+ // when read an invalid JSON
+ val taxonomy = taxonomyJsonReader.read("")
+
+ // then
+ assertNotNull(taxonomy)
+ assertTrue(taxonomy.isEmpty())
+ }
+
+ @Test
+ fun testReadEmptyTaxonomy() {
+ // when read an empty JSON
+ val taxonomy = taxonomyJsonReader.read("{}")
+
+ // then
+ assertNotNull(taxonomy)
+ assertTrue(taxonomy.isEmpty())
+ }
+
+ @Test
+ fun testRead() {
+ // given an input file to read
+ val json = getFixture("taxonomy_geonature.json")
+
+ // when parsing this file
+ val taxonomy = taxonomyJsonReader.read(json)
+
+ // then
+ assertNotNull(taxonomy)
+ assertArrayEquals(arrayOf("Animalia",
+ "Bacteria",
+ "Chromista",
+ "Fungi",
+ "Plantae",
+ "Protozoa"),
+ taxonomy.map { it.kingdom }.distinct().toTypedArray())
+ assertArrayEquals(arrayOf("Autres",
+ "Lichens"),
+ taxonomy.asSequence().filter { it.kingdom == "Fungi" }.map { it.group }.toList().toTypedArray())
+ }
+}
\ No newline at end of file
diff --git a/sync/src/test/resources/fixtures/taxonomy_geonature.json b/sync/src/test/resources/fixtures/taxonomy_geonature.json
new file mode 100644
index 00000000..84230c4f
--- /dev/null
+++ b/sync/src/test/resources/fixtures/taxonomy_geonature.json
@@ -0,0 +1,62 @@
+{
+ "": [
+ ""
+ ],
+ "Plantae": [
+ "",
+ "Algues rouges",
+ "Algues vertes",
+ "Angiospermes",
+ "Autres",
+ "Fougères",
+ "Gymnospermes",
+ "Hépatiques et Anthocérotes",
+ "Mousses"
+ ],
+ "Bacteria": [
+ "",
+ "Autres"
+ ],
+ "Fungi": [
+ "",
+ "Autres",
+ "Lichens"
+ ],
+ "Animalia": [
+ "",
+ "Acanthocéphales",
+ "Amphibiens",
+ "Annélides",
+ "Arachnides",
+ "Ascidies",
+ "Autres",
+ "Bivalves",
+ "Céphalopodes",
+ "Crustacés",
+ "Entognathes",
+ "Gastéropodes",
+ "Hydrozoaires",
+ "Insectes",
+ "Mammifères",
+ "Myriapodes",
+ "Nématodes",
+ "Némertes",
+ "Octocoralliaires",
+ "Oiseaux",
+ "Plathelminthes",
+ "Poissons",
+ "Pycnogonides",
+ "Reptiles",
+ "Scléractiniaires"
+ ],
+ "Chromista": [
+ "",
+ "Algues brunes",
+ "Autres",
+ "Diatomées"
+ ],
+ "Protozoa": [
+ "",
+ "Autres"
+ ]
+}
\ No newline at end of file
diff --git a/sync/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/sync/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 00000000..ca6ee9ce
--- /dev/null
+++ b/sync/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
\ No newline at end of file
diff --git a/sync/version.properties b/sync/version.properties
new file mode 100644
index 00000000..27abff9c
--- /dev/null
+++ b/sync/version.properties
@@ -0,0 +1,2 @@
+#Sat Sep 14 15:38:33 CEST 2019
+VERSION_CODE=800
diff --git a/viewpager/build.gradle b/viewpager/build.gradle
index be7fd59f..70094350 100644
--- a/viewpager/build.gradle
+++ b/viewpager/build.gradle
@@ -2,7 +2,7 @@ apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
-version = "0.0.3"
+version = "0.0.5"
android {
compileSdkVersion 28
@@ -42,16 +42,16 @@ dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
- implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1"
- implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.1"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0"
- implementation 'androidx.appcompat:appcompat:1.1.0-beta01'
- implementation 'androidx.preference:preference:1.0.0'
+ implementation 'androidx.appcompat:appcompat:1.1.0'
+ implementation 'androidx.preference:preference:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
testImplementation 'junit:junit:4.12'
testImplementation 'androidx.test:core:1.2.0'
- testImplementation 'org.robolectric:robolectric:4.2'
+ testImplementation 'org.robolectric:robolectric:4.3'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}
diff --git a/viewpager/src/main/java/fr/geonature/viewpager/pager/Pager.kt b/viewpager/src/main/java/fr/geonature/viewpager/pager/Pager.kt
index ee896729..dde9ee8f 100644
--- a/viewpager/src/main/java/fr/geonature/viewpager/pager/Pager.kt
+++ b/viewpager/src/main/java/fr/geonature/viewpager/pager/Pager.kt
@@ -4,7 +4,6 @@ import android.os.Parcel
import android.os.Parcelable
import java.util.ArrayDeque
import java.util.ArrayList
-import java.util.Arrays
import java.util.Deque
/**
@@ -79,8 +78,7 @@ class Pager : Parcelable {
return if (position != pager.position) {
false
}
- else Arrays.equals(history.toTypedArray(),
- pager.history.toTypedArray())
+ else history.toTypedArray().contentEquals(pager.history.toTypedArray())
}
override fun hashCode(): Int {
diff --git a/viewpager/version.properties b/viewpager/version.properties
index cce68031..f3d6f471 100644
--- a/viewpager/version.properties
+++ b/viewpager/version.properties
@@ -1,2 +1,2 @@
-#Sat Jun 08 15:25:09 CEST 2019
-VERSION_CODE=298
+#Wed Aug 28 22:52:52 CEST 2019
+VERSION_CODE=580