diff --git a/sync/build.gradle b/sync/build.gradle index 93a45bb5..1f9beef0 100644 --- a/sync/build.gradle +++ b/sync/build.gradle @@ -2,7 +2,7 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: "kotlin-kapt" -version = "1.1.6" +version = "1.1.7" android { compileSdkVersion 29 @@ -72,7 +72,7 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.preference:preference-ktx:1.1.1' - implementation 'androidx.recyclerview:recyclerview:1.1.0' + implementation 'androidx.recyclerview:recyclerview:1.2.0' implementation 'androidx.work:work-runtime-ktx:2.5.0' implementation 'com.google.android.material:material:1.4.0-alpha02' implementation 'com.google.code.gson:gson:2.8.6' diff --git a/sync/src/main/java/fr/geonature/sync/api/GeoNatureAPIClient.kt b/sync/src/main/java/fr/geonature/sync/api/GeoNatureAPIClient.kt index 8fdfd3e4..c83e116a 100644 --- a/sync/src/main/java/fr/geonature/sync/api/GeoNatureAPIClient.kt +++ b/sync/src/main/java/fr/geonature/sync/api/GeoNatureAPIClient.kt @@ -13,6 +13,12 @@ import fr.geonature.sync.api.model.User import fr.geonature.sync.auth.AuthManager import fr.geonature.sync.util.SettingsUtils.getGeoNatureServerUrl import fr.geonature.sync.util.SettingsUtils.getTaxHubServerUrl +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl import okhttp3.MediaType import okhttp3.OkHttpClient import okhttp3.RequestBody @@ -48,6 +54,35 @@ class GeoNatureAPIClient private constructor( val client = OkHttpClient .Builder() + .cookieJar(object : CookieJar { + override fun saveFromResponse( + url: HttpUrl, + cookies: MutableList + ) { + cookies + .firstOrNull() + ?.also { + authManager.setCookie(it) + } + } + + override fun loadForRequest(url: HttpUrl): MutableList { + return authManager + .getCookie() + ?.let { + if (it.expiresAt() < System.currentTimeMillis()) { + GlobalScope.launch(IO) { + authManager.logout() + } + + return@let mutableListOf() + } + + mutableListOf(it) + } + ?: mutableListOf() + } + }) .connectTimeout( 120, TimeUnit.SECONDS @@ -61,36 +96,6 @@ class GeoNatureAPIClient private constructor( TimeUnit.SECONDS ) .addInterceptor(loggingInterceptor) - // save cookie interceptor - .addInterceptor { chain -> - val originalResponse = chain.proceed(chain.request()) - - originalResponse - .headers("Set-Cookie") - .firstOrNull() - ?.also { - authManager.setCookie(it) - } - - originalResponse - } - // set cookie interceptor - .addInterceptor { chain -> - val builder = chain - .request() - .newBuilder() - - authManager - .getCookie() - ?.also { - builder.addHeader( - "Cookie", - it - ) - } - - chain.proceed(builder.build()) - } .build() geoNatureService = Retrofit diff --git a/sync/src/main/java/fr/geonature/sync/auth/AuthManager.kt b/sync/src/main/java/fr/geonature/sync/auth/AuthManager.kt index fa7dbe7f..c0e1ce80 100644 --- a/sync/src/main/java/fr/geonature/sync/auth/AuthManager.kt +++ b/sync/src/main/java/fr/geonature/sync/auth/AuthManager.kt @@ -9,11 +9,13 @@ import androidx.preference.PreferenceManager import fr.geonature.sync.api.model.AuthLogin import fr.geonature.sync.auth.io.AuthLoginJsonReader import fr.geonature.sync.auth.io.AuthLoginJsonWriter +import fr.geonature.sync.auth.io.CookieHelper import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import okhttp3.Cookie import java.util.Calendar /** @@ -23,8 +25,7 @@ import java.util.Calendar */ class AuthManager private constructor(applicationContext: Context) { - internal val preferenceManager: SharedPreferences = - PreferenceManager.getDefaultSharedPreferences(applicationContext) + internal val preferenceManager: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) private val authLoginJsonReader = AuthLoginJsonReader() private val authLoginJsonWriter = AuthLoginJsonWriter() @@ -38,75 +39,86 @@ class AuthManager private constructor(applicationContext: Context) { } } - fun setCookie(cookie: String) { - preferenceManager.edit() + fun setCookie(cookie: Cookie) { + preferenceManager + .edit() .putString( KEY_PREFERENCE_COOKIE, - cookie + CookieHelper.serialize(cookie) ) .apply() } - fun getCookie(): String? { - return preferenceManager.getString( - KEY_PREFERENCE_COOKIE, - null - ) + fun getCookie(): Cookie? { + return runCatching { + preferenceManager + .getString( + KEY_PREFERENCE_COOKIE, + null + ) + ?.let { CookieHelper.deserialize(it) } + }.getOrNull() } - suspend fun getAuthLogin(): AuthLogin? = withContext(IO) { - val authLoginAsJson = preferenceManager.getString( - KEY_PREFERENCE_AUTH_LOGIN, - null - ) - - if (authLoginAsJson.isNullOrBlank()) { - _authLogin.postValue(null) - return@withContext null - } - - authLoginJsonReader.read(authLoginAsJson) - .let { - if (it?.expires?.before(Calendar.getInstance().time) == true) { - logout() - return@let null - } + suspend fun getAuthLogin(): AuthLogin? = + withContext(IO) { + val authLoginAsJson = preferenceManager.getString( + KEY_PREFERENCE_AUTH_LOGIN, + null + ) - _authLogin.postValue(it) - it + if (authLoginAsJson.isNullOrBlank()) { + _authLogin.postValue(null) + return@withContext null } - } - suspend fun setAuthLogin(authLogin: AuthLogin): Boolean = withContext(IO) { - val authLoginAsJson = authLoginJsonWriter.write(authLogin) + authLoginJsonReader + .read(authLoginAsJson) + .let { + if (it?.expires?.before(Calendar.getInstance().time) == true) { + logout() + return@let null + } - if (authLoginAsJson.isNullOrBlank()) { - _authLogin.postValue(null) - return@withContext false + _authLogin.postValue(it) + it + } } - preferenceManager.edit() - .putString( - KEY_PREFERENCE_AUTH_LOGIN, - authLoginAsJson - ) - .commit() - .also { - _authLogin.postValue(if (it) authLogin else null) + suspend fun setAuthLogin(authLogin: AuthLogin): Boolean = + withContext(IO) { + val authLoginAsJson = authLoginJsonWriter.write(authLogin) + + if (authLoginAsJson.isNullOrBlank()) { + _authLogin.postValue(null) + return@withContext false } - } - suspend fun logout(): Boolean = withContext(IO) { - preferenceManager.edit() - .remove(KEY_PREFERENCE_COOKIE) - .remove(KEY_PREFERENCE_AUTH_LOGIN) - .commit() - .also { - if (it) { - _authLogin.postValue(null) + preferenceManager + .edit() + .putString( + KEY_PREFERENCE_AUTH_LOGIN, + authLoginAsJson + ) + .commit() + .also { + _authLogin.postValue(if (it) authLogin else null) } - } - } + } + + suspend fun logout(): Boolean = + withContext(IO) { + preferenceManager + .edit() + .remove(KEY_PREFERENCE_COOKIE) + .remove(KEY_PREFERENCE_AUTH_LOGIN) + .commit() + .also { + if (it) { + _authLogin.postValue(null) + } + } + } companion object { private const val KEY_PREFERENCE_COOKIE = "key_preference_cookie" @@ -122,8 +134,11 @@ class AuthManager private constructor(applicationContext: Context) { * @return The singleton instance of [AuthManager]. */ @Suppress("UNCHECKED_CAST") - fun getInstance(applicationContext: Context): AuthManager = INSTANCE ?: synchronized(this) { - INSTANCE ?: AuthManager(applicationContext).also { INSTANCE = it } - } + fun getInstance(applicationContext: Context): AuthManager = + INSTANCE + ?: synchronized(this) { + INSTANCE + ?: AuthManager(applicationContext).also { INSTANCE = it } + } } } diff --git a/sync/src/main/java/fr/geonature/sync/auth/io/CookieHelper.kt b/sync/src/main/java/fr/geonature/sync/auth/io/CookieHelper.kt new file mode 100644 index 00000000..09dc2c62 --- /dev/null +++ b/sync/src/main/java/fr/geonature/sync/auth/io/CookieHelper.kt @@ -0,0 +1,66 @@ +package fr.geonature.sync.auth.io + +import fr.geonature.sync.util.hexStringToByteArray +import fr.geonature.sync.util.toHex +import okhttp3.Cookie +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.ObjectInputStream +import java.io.ObjectOutputStream + +/** + * Serialize/Deserialize Cookie. + * + * @author S. Grimault + */ +object CookieHelper { + + @JvmStatic + @Throws(IOException::class) + fun serialize(cookie: Cookie): String { + return ByteArrayOutputStream() + .also { + ObjectOutputStream(it).apply { + writeObject(cookie.name()) + writeObject(cookie.value()) + writeObject(cookie.domain()) + writeObject(cookie.path()) + writeBoolean(cookie.secure()) + writeBoolean(cookie.httpOnly()) + writeBoolean(cookie.hostOnly()) + writeLong(if (cookie.persistent()) cookie.expiresAt() else -1) + close() + } + } + .toByteArray() + .toHex() + } + + @JvmStatic + @Throws(IOException::class) + fun deserialize(hexString: String): Cookie { + return ObjectInputStream(ByteArrayInputStream(hexString.hexStringToByteArray())).let { + val builder = Cookie.Builder() + builder.name(it.readObject() as String) + builder.value(it.readObject() as String) + + val domain = it.readObject() as String + + builder.path(it.readObject() as String) + + if (it.readBoolean()) builder.secure() + if (it.readBoolean()) builder.httpOnly() + if (it.readBoolean()) builder.hostOnlyDomain(domain) else builder.domain(domain) + + it + .readLong() + .takeIf { expiresAt -> expiresAt > 0 } + ?.also { expiresAt -> + builder.expiresAt(expiresAt) + } + + builder.build() + } + } +} \ No newline at end of file diff --git a/sync/src/main/java/fr/geonature/sync/sync/DataSyncViewModel.kt b/sync/src/main/java/fr/geonature/sync/sync/DataSyncViewModel.kt index b5a64e40..3ef5066e 100644 --- a/sync/src/main/java/fr/geonature/sync/sync/DataSyncViewModel.kt +++ b/sync/src/main/java/fr/geonature/sync/sync/DataSyncViewModel.kt @@ -85,6 +85,15 @@ class DataSyncViewModel(application: Application) : AndroidViewModel(application ) )] + // this work info is not scheduled or not running: the current worker is done + if (workInfo.state !in arrayListOf( + WorkInfo.State.ENQUEUED, + WorkInfo.State.RUNNING + ) + ) { + currentSyncWorkerId = null + } + DataSyncStatus( workInfo.state, workInfo.progress.getString(DataSyncWorker.KEY_SYNC_MESSAGE) diff --git a/sync/src/main/java/fr/geonature/sync/sync/worker/DataSyncWorker.kt b/sync/src/main/java/fr/geonature/sync/sync/worker/DataSyncWorker.kt index 7d549998..fca140a8 100644 --- a/sync/src/main/java/fr/geonature/sync/sync/worker/DataSyncWorker.kt +++ b/sync/src/main/java/fr/geonature/sync/sync/worker/DataSyncWorker.kt @@ -28,6 +28,7 @@ import fr.geonature.sync.MainApplication import fr.geonature.sync.R import fr.geonature.sync.api.GeoNatureAPIClient import fr.geonature.sync.api.model.User +import fr.geonature.sync.auth.AuthManager import fr.geonature.sync.data.LocalDatabase import fr.geonature.sync.settings.AppSettings import fr.geonature.sync.sync.DataSyncManager @@ -57,12 +58,23 @@ class DataSyncWorker( appContext, workerParams ) { + private val authManager: AuthManager = AuthManager.getInstance(applicationContext) private val dataSyncManager = DataSyncManager.getInstance(applicationContext) private val workManager = WorkManager.getInstance(applicationContext) override suspend fun doWork(): Result { val startTime = Date() + // not connected: abort + if (authManager.getAuthLogin() == null) { + return Result.failure( + workData( + applicationContext.getString(R.string.sync_error_server_not_connected), + ServerStatus.UNAUTHORIZED + ) + ) + } + val geoNatureAPIClient = GeoNatureAPIClient.instance(applicationContext) ?: return Result.failure( workData(applicationContext.getString(R.string.sync_error_server_url_configuration)) diff --git a/sync/src/main/java/fr/geonature/sync/ui/home/DataSyncView.kt b/sync/src/main/java/fr/geonature/sync/ui/home/DataSyncView.kt index 71de372c..1e2105c4 100644 --- a/sync/src/main/java/fr/geonature/sync/ui/home/DataSyncView.kt +++ b/sync/src/main/java/fr/geonature/sync/ui/home/DataSyncView.kt @@ -79,7 +79,10 @@ class DataSyncView : ConstraintLayout { context?.theme ) ) - iconStatus.startAnimation(stateAnimation) + + if (iconStatus.animation?.hasStarted() != true) { + iconStatus.startAnimation(stateAnimation) + } } WorkInfo.State.FAILED -> { iconStatus.setTextColor( diff --git a/sync/src/main/java/fr/geonature/sync/ui/home/HomeActivity.kt b/sync/src/main/java/fr/geonature/sync/ui/home/HomeActivity.kt index d6bd9b0b..673fd620 100644 --- a/sync/src/main/java/fr/geonature/sync/ui/home/HomeActivity.kt +++ b/sync/src/main/java/fr/geonature/sync/ui/home/HomeActivity.kt @@ -161,13 +161,13 @@ class HomeActivity : AppCompatActivity() { startSyncResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> when (result.resultCode) { RESULT_OK -> { + val appSettings = appSettings + if (appSettings == null) { packageInfoViewModel.getAvailableApplications() } else { - appSettings?.run { - dataSyncViewModel.startSync(this) - synchronizeInstalledApplications() - } + dataSyncViewModel.startSync(appSettings) + synchronizeInstalledApplications() } } } @@ -202,8 +202,7 @@ class HomeActivity : AppCompatActivity() { override fun onPrepareOptionsMenu(menu: Menu?): Boolean { menu?.run { findItem(R.id.menu_sync_data_refresh)?.also { - it.isEnabled = appSettings != null && !(dataSyncViewModel.isSyncRunning.value - ?: false) + it.isEnabled = appSettings != null && dataSyncViewModel.isSyncRunning.value != true } findItem(R.id.menu_login)?.also { it.isEnabled = appSettings != null @@ -291,7 +290,17 @@ class HomeActivity : AppCompatActivity() { "reloading settings after update..." ) - loadAppSettingsAndStartSync() + loadAppSettings { + makeSnackbar( + getString( + R.string.snackbar_settings_updated, + appSettingsViewModel.getAppSettingsFilename() + ) + )?.show() + + startFirstSync(it) + synchronizeInstalledApplications() + } } vm.packageInfos.observe(this@HomeActivity, @@ -349,12 +358,15 @@ class HomeActivity : AppCompatActivity() { } } + @ExperimentalTime private fun checkPermissions() { PermissionUtils.requestPermissions(this, listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), { result -> if (result.values.all { it }) { - packageInfoViewModel.getAvailableApplications() + loadAppSettings { + packageInfoViewModel.getAvailableApplications() + } } else { Toast .makeText( @@ -400,7 +412,7 @@ class HomeActivity : AppCompatActivity() { } @ExperimentalTime - private fun loadAppSettingsAndStartSync() { + private fun loadAppSettings(appSettingsLoaded: ((appSettings: AppSettings) -> Unit)? = null) { appSettingsViewModel .loadAppSettings() .observeOnce(this@HomeActivity) { @@ -416,32 +428,23 @@ class HomeActivity : AppCompatActivity() { if (!checkGeoNatureSettings()) { startSyncResultLauncher.launch(PreferencesActivity.newIntent(this)) - - return@observeOnce } - } else { - makeSnackbar( - getString( - R.string.snackbar_settings_updated, - appSettingsViewModel.getAppSettingsFilename() - ) - )?.show() - appSettings = it - mergeAppSettingsWithSharedPreferences(it) - invalidateOptionsMenu() - - if (!checkGeoNatureSettings()) { - startSyncResultLauncher.launch(PreferencesActivity.newIntent(this)) + return@observeOnce + } - return@observeOnce - } + appSettings = it + mergeAppSettingsWithSharedPreferences(it) + invalidateOptionsMenu() - dataSyncViewModel.configurePeriodicSync(it) + if (!checkGeoNatureSettings()) { + startSyncResultLauncher.launch(PreferencesActivity.newIntent(this)) - startFirstSync(it) - synchronizeInstalledApplications() + return@observeOnce } + + dataSyncViewModel.configurePeriodicSync(it) + appSettingsLoaded?.invoke(it) } } @@ -450,10 +453,8 @@ class HomeActivity : AppCompatActivity() { } private fun startFirstSync(appSettings: AppSettings) { - dataSyncViewModel.lastSynchronizedDate.observeOnce(this@HomeActivity) { - if (it == null) { - dataSyncViewModel.startSync(appSettings) - } + if (dataSyncViewModel.lastSynchronizedDate.value == null && dataSyncViewModel.isSyncRunning.value != true) { + dataSyncViewModel.startSync(appSettings) } } diff --git a/sync/src/main/java/fr/geonature/sync/util/ByteArrayHelper.kt b/sync/src/main/java/fr/geonature/sync/util/ByteArrayHelper.kt new file mode 100644 index 00000000..908a8df8 --- /dev/null +++ b/sync/src/main/java/fr/geonature/sync/util/ByteArrayHelper.kt @@ -0,0 +1,30 @@ +package fr.geonature.sync.util + +/** + * `ByteArray` helpers. + * + * @author S. Grimault + */ + +fun ByteArray.toHex() = + this.joinToString(separator = "") { + it + .toInt() + .and(0xff) + .toString(16) + .padStart( + 2, + '0' + ) + } + +fun String.hexStringToByteArray() = + ByteArray(this.length / 2) { + this + .substring( + it * 2, + it * 2 + 2 + ) + .toInt(16) + .toByte() + } \ No newline at end of file diff --git a/sync/src/main/res/values/colors.xml b/sync/src/main/res/values/colors.xml index 85dd9a8e..a45cf2c8 100644 --- a/sync/src/main/res/values/colors.xml +++ b/sync/src/main/res/values/colors.xml @@ -2,18 +2,18 @@ - #43a047 - #00701a - #f4511e + #43A047 + #00701A + #F4511E #40000000 @android:color/white #40FFFFFF - #64DD17 - #D50000 - #FF6D00 - #ccc + #689F38 + #D32F2F + #FFA000 + #CCC diff --git a/sync/src/test/java/fr/geonature/sync/auth/AuthManagerTest.kt b/sync/src/test/java/fr/geonature/sync/auth/AuthManagerTest.kt index a59df417..2b8469aa 100644 --- a/sync/src/test/java/fr/geonature/sync/auth/AuthManagerTest.kt +++ b/sync/src/test/java/fr/geonature/sync/auth/AuthManagerTest.kt @@ -2,9 +2,11 @@ package fr.geonature.sync.auth import android.app.Application import androidx.test.core.app.ApplicationProvider +import fr.geonature.commons.util.add import fr.geonature.sync.api.model.AuthLogin import fr.geonature.sync.api.model.AuthUser import kotlinx.coroutines.runBlocking +import okhttp3.Cookie import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -15,6 +17,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import java.util.Calendar +import java.util.Date /** * Unit tests about [AuthManager]. @@ -31,7 +34,8 @@ class AuthManagerTest { fun setUp() { val application = ApplicationProvider.getApplicationContext() authManager = AuthManager.getInstance(application) - authManager.preferenceManager.edit() + authManager.preferenceManager + .edit() .clear() .commit() } @@ -47,16 +51,30 @@ class AuthManagerTest { @Test fun testSaveAndGetCookie() { + val cookie = Cookie + .Builder() + .name("token") + .value("some_value") + .domain("demo.geonature.fr") + .path("/") + .expiresAt( + Date().add( + Calendar.HOUR, + 1 + ).time + ) + .build() + // when setting new cookie - authManager.setCookie("c_1234") + authManager.setCookie(cookie) // when reading this cookie from manager - val cookie = authManager.getCookie() + val cookieFromManager = authManager.getCookie() // then assertEquals( - "c_1234", - cookie + cookie, + cookieFromManager ) } @@ -81,16 +99,18 @@ class AuthManagerTest { 1, "admin" ), - Calendar.getInstance().apply { - add( - Calendar.DAY_OF_YEAR, - 7 - ) - set( - Calendar.MILLISECOND, - 0 - ) - }.time + Calendar + .getInstance() + .apply { + add( + Calendar.DAY_OF_YEAR, + 7 + ) + set( + Calendar.MILLISECOND, + 0 + ) + }.time ) // when saving this AuthLogin @@ -122,12 +142,14 @@ class AuthManagerTest { 1, "admin" ), - Calendar.getInstance().apply { - add( - Calendar.DAY_OF_YEAR, - -1 - ) - }.time + Calendar + .getInstance() + .apply { + add( + Calendar.DAY_OF_YEAR, + -1 + ) + }.time ) // when saving this AuthLogin diff --git a/sync/src/test/java/fr/geonature/sync/auth/io/CookieHelperTest.kt b/sync/src/test/java/fr/geonature/sync/auth/io/CookieHelperTest.kt new file mode 100644 index 00000000..d870cb95 --- /dev/null +++ b/sync/src/test/java/fr/geonature/sync/auth/io/CookieHelperTest.kt @@ -0,0 +1,74 @@ +package fr.geonature.sync.auth.io + +import fr.geonature.commons.util.add +import fr.geonature.sync.util.toHex +import okhttp3.Cookie +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.ObjectOutputStream +import java.util.Calendar +import java.util.Date + +/** + * Unit tests about [CookieHelper]. + * + * @author S. Grimault + */ +class CookieHelperTest { + + @Test + fun testSerializationDeserialization() { + // given some existing cookie + val cookie = Cookie + .Builder() + .name("token") + .value("some_value") + .domain("demo.geonature.fr") + .path("/") + .expiresAt( + Date().add( + Calendar.HOUR, + 1 + ).time + ) + .build() + + // when serialize it + val hexString = CookieHelper.serialize(cookie) + + // then + assertNotNull(hexString) + assertEquals( + cookie, + CookieHelper.deserialize(hexString) + ) + } + + @Test + fun testInvalidSerialization() { + // given an invalid serialization + val hexString = ByteArrayOutputStream() + .also { + ObjectOutputStream(it).apply { + writeObject("some_value") + writeObject("demo.geonature.fr") + writeObject("/") + writeBoolean(false) + writeBoolean(false) + writeBoolean(false) + writeLong(-1) + close() + } + } + .toByteArray() + .toHex() + + // when deserialize as cookie + assertThrows(IOException::class.java + ) { CookieHelper.deserialize(hexString) } + } +} \ No newline at end of file diff --git a/sync/version.properties b/sync/version.properties index e7d9f79c..08a817c7 100644 --- a/sync/version.properties +++ b/sync/version.properties @@ -1,2 +1,2 @@ -#Tue Apr 06 21:09:27 CEST 2021 -VERSION_CODE=2845 +#Wed Apr 14 21:15:36 CEST 2021 +VERSION_CODE=2865