diff --git a/docs/Using-Operations-Service.md b/docs/Using-Operations-Service.md index 22d2150..0a3330c 100644 --- a/docs/Using-Operations-Service.md +++ b/docs/Using-Operations-Service.md @@ -330,6 +330,7 @@ All available methods and attributes of `IOperationsService` API are: - `listener` - Listener object that receives info about operation loading. - `acceptLanguage` - Language settings, that will be sent along with each request. The server will return properly localized content based on this value. Value follows standard RFC [Accept-Language](https://tools.ietf.org/html/rfc7231#section-5.3.5) - `lastOperationsResult` - Cached last operations result. +- `currentServerDate()` - Current server date. This is a calculated property based on the difference between the phone date and the date on the server. Value is available after the first successful operation list request. It might be nil if the server doesn't provide such a feature. - `isLoadingOperations()` - Indicates if the service is loading operations. - `getOperations(callback: (result: Result>) -> Unit)` - Retrieves pending operations from the server. - `callback` - Called when getting list request finishes. diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 10c1a58..eee94d7 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { testImplementation("junit:junit:4.13.2") // Android tests + androidTestImplementation("com.jakewharton.threetenabp:threetenabp:1.1.1") androidTestImplementation("com.wultra.android.powerauth:powerauth-sdk:1.7.6") androidTestImplementation("com.wultra.android.powerauth:powerauth-networking:1.1.3") androidTestImplementation("androidx.test:runner:1.5.1") diff --git a/library/gradle.properties b/library/gradle.properties index cd1e661..13a4e05 100644 --- a/library/gradle.properties +++ b/library/gradle.properties @@ -14,6 +14,6 @@ # and limitations under the License. # -VERSION_NAME=1.5.0 +VERSION_NAME=1.6.0-SNAPSHOT GROUP_ID=com.wultra.android.mtokensdk ARTIFACT_ID=wultra-mtoken-sdk diff --git a/library/src/androidTest/java/IntegrationTests.kt b/library/src/androidTest/java/IntegrationTests.kt index 6535da4..daa1c2c 100644 --- a/library/src/androidTest/java/IntegrationTests.kt +++ b/library/src/androidTest/java/IntegrationTests.kt @@ -22,11 +22,12 @@ import com.wultra.android.mtokensdk.api.operation.model.QROperationParser import com.wultra.android.mtokensdk.api.operation.model.UserOperation import com.wultra.android.mtokensdk.operation.* import com.wultra.android.powerauth.networking.error.ApiError -import io.getlime.security.powerauth.networking.response.IActivationRemoveListener import io.getlime.security.powerauth.sdk.PowerAuthAuthentication import io.getlime.security.powerauth.sdk.PowerAuthSDK import org.junit.* +import org.threeten.bp.ZonedDateTime import java.lang.Exception +import java.lang.Math.abs import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit @@ -35,44 +36,66 @@ import java.util.concurrent.TimeUnit */ class IntegrationTests { - companion object { - - lateinit var ops: IOperationsService - private lateinit var pa: PowerAuthSDK - const val pin = "1234" - - @BeforeClass - @JvmStatic - fun setup() { - try { - val result = IntegrationUtils.prepareActivation(pin) - pa = result.first - ops = result.second - } catch (e: Throwable) { - Assert.fail("Activation preparation failed: $e") - } - } + private lateinit var ops: IOperationsService + private lateinit var pa: PowerAuthSDK + private val pin = "1234" - @AfterClass - @JvmStatic - fun tearDown() { - IntegrationUtils.removeRegistration(pa.activationIdentifier) - pa.removeActivationLocal(IntegrationUtils.context) + @Before + fun setup() { + try { + val result = IntegrationUtils.prepareActivation(pin) + pa = result.first + ops = result.second + } catch (e: Throwable) { + Assert.fail("Activation preparation failed: $e") } } + @After + fun tearDown() { + IntegrationUtils.removeRegistration(pa.activationIdentifier) + pa.removeActivationLocal(IntegrationUtils.context) + } + + init { + initThreeTen() + } + @Test fun testList() { val future = CompletableFuture>() ops.getOperations { result -> - result.onSuccess { future.complete(it) } + result + .onSuccess { future.complete(it) } .onFailure { future.completeExceptionally(it) } } val oplist = future.get(20, TimeUnit.SECONDS) Assert.assertNotNull(oplist) } - // 1FA test are temporalily disabled + @Test + fun testServerTime() { + val future = CompletableFuture() + Assert.assertNull(ops.currentServerDate()) + ops.getOperations { result -> + result + .onSuccess { + future.complete(ops.currentServerDate()) + } + .onFailure { + future.completeExceptionally(it) + } + } + val date = future.get(20, TimeUnit.SECONDS) + Assert.assertNotNull(date) + + val secDiff = kotlin.math.abs(date.toEpochSecond() - ZonedDateTime.now().toEpochSecond()) + // if the difference between the server and the device is more than 20 seconds, there is something wrong with the server + // or there is a bug. Both cases needs a fix + Assert.assertTrue(secDiff < 20) + } + + // 1FA test are temporally disabled // @Test // fun testApproveLogin() { diff --git a/library/src/main/java/com/wultra/android/mtokensdk/api/operation/OperationApi.kt b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/OperationApi.kt index e010594..3ef3564 100644 --- a/library/src/main/java/com/wultra/android/mtokensdk/api/operation/OperationApi.kt +++ b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/OperationApi.kt @@ -18,6 +18,7 @@ package com.wultra.android.mtokensdk.api.operation import android.content.Context import com.google.gson.GsonBuilder +import com.google.gson.annotations.SerializedName import com.wultra.android.mtokensdk.api.operation.model.* import com.wultra.android.mtokensdk.operation.OperationsUtils import com.wultra.android.powerauth.networking.* @@ -26,8 +27,14 @@ import com.wultra.android.powerauth.networking.tokens.IPowerAuthTokenProvider import io.getlime.security.powerauth.sdk.PowerAuthAuthentication import io.getlime.security.powerauth.sdk.PowerAuthSDK import okhttp3.OkHttpClient +import org.threeten.bp.ZonedDateTime -internal class OperationListResponse(responseObject: List, status: Status): ObjectResponse>(responseObject, status) +internal class OperationListResponse( + @SerializedName("currentTimestamp") + val currentTimestamp: ZonedDateTime?, + responseObject: List, + status: Status +): ObjectResponse>(responseObject, status) internal class OperationHistoryResponse(responseObject: List, status: Status): ObjectResponse>(responseObject, status) internal class AuthorizeRequest(requestObject: AuthorizeRequestObject): ObjectRequest(requestObject) internal class RejectRequest(requestObject: RejectRequestObject): ObjectRequest(requestObject) diff --git a/library/src/main/java/com/wultra/android/mtokensdk/api/operation/model/UserOperation.kt b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/model/UserOperation.kt index 19adb95..52ef2be 100644 --- a/library/src/main/java/com/wultra/android/mtokensdk/api/operation/model/UserOperation.kt +++ b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/model/UserOperation.kt @@ -16,7 +16,6 @@ package com.wultra.android.mtokensdk.api.operation.model -import com.google.gson.annotations.Expose import com.google.gson.annotations.SerializedName import com.wultra.android.mtokensdk.operation.expiration.ExpirableOperation import org.threeten.bp.ZonedDateTime diff --git a/library/src/main/java/com/wultra/android/mtokensdk/operation/IOperationsService.kt b/library/src/main/java/com/wultra/android/mtokensdk/operation/IOperationsService.kt index 3b6ebb2..38bb21c 100644 --- a/library/src/main/java/com/wultra/android/mtokensdk/operation/IOperationsService.kt +++ b/library/src/main/java/com/wultra/android/mtokensdk/operation/IOperationsService.kt @@ -23,6 +23,7 @@ import com.wultra.android.mtokensdk.api.operation.model.UserOperation import com.wultra.android.mtokensdk.api.operation.model.QROperation import com.wultra.android.powerauth.networking.error.ApiError import io.getlime.security.powerauth.sdk.PowerAuthAuthentication +import org.threeten.bp.ZonedDateTime /** * Service for operations handling. @@ -42,6 +43,20 @@ interface IOperationsService { val lastOperationsResult: Result>? + /** + * Current server date + * + * This is calculated property based on the difference between phone date + * and date on the server. + * + * This property is available after the first successful operation list request. + * It might be nil if the server doesn't provide such a feature. + * + * Note that this value might be incorrect when the user decide to + * change the system time during the runtime of the application. + */ + fun currentServerDate(): ZonedDateTime? + /** * If operations are loading. */ diff --git a/library/src/main/java/com/wultra/android/mtokensdk/operation/OperationsService.kt b/library/src/main/java/com/wultra/android/mtokensdk/operation/OperationsService.kt index 9dfeb59..0bb5091 100644 --- a/library/src/main/java/com/wultra/android/mtokensdk/operation/OperationsService.kt +++ b/library/src/main/java/com/wultra/android/mtokensdk/operation/OperationsService.kt @@ -20,9 +20,6 @@ import android.content.Context import com.google.gson.GsonBuilder import com.wultra.android.mtokensdk.api.apiErrorForListener import com.wultra.android.mtokensdk.api.operation.* -import com.wultra.android.mtokensdk.api.operation.AuthorizeRequest -import com.wultra.android.mtokensdk.api.operation.OperationApi -import com.wultra.android.mtokensdk.api.operation.RejectRequest import com.wultra.android.mtokensdk.api.operation.model.* import com.wultra.android.mtokensdk.common.Logger import com.wultra.android.powerauth.networking.IApiCallResponseListener @@ -35,7 +32,10 @@ import com.wultra.android.powerauth.networking.tokens.IPowerAuthTokenProvider import io.getlime.security.powerauth.sdk.PowerAuthAuthentication import io.getlime.security.powerauth.sdk.PowerAuthSDK import okhttp3.OkHttpClient +import org.threeten.bp.ZonedDateTime +import org.threeten.bp.temporal.ChronoUnit import java.util.* +import kotlin.math.abs /** * Convenience factory method to create an IOperationsService instance @@ -75,6 +75,22 @@ private typealias GetOperationsCallback = (result: Result>) @Suppress("EXPERIMENTAL_API_USAGE", "ConvertSecondaryConstructorToPrimary") class OperationsService: IOperationsService { + companion object { + /** + * Maximal duration in milliseconds of the request that can affect server time. + * If request takes longer than this value, the value won't update server time + */ + private const val SERVER_TIME_DELAY_THRESHOLD_MS = 1_000 + /** + * Minimal delta change in server time to accept it as a change. + */ + private const val MIN_SERVER_TIME_CHANGE_MS = 300 + /** + * Delta change which is forced to be accepted even when the network conditions are not ideal + */ + private const val FORCED_SERVER_TIME_CHANGE_MS = 20_000 + } + override var listener: IOperationsServiceListener? = null override var acceptLanguage: String @@ -103,6 +119,9 @@ class OperationsService: IOperationsService { // Mutex private val mutex = Object() + // Difference in milliseconds between server and phone time + private var serverDateShiftInMilliSeconds: Long? = null + /** * Constructs OperationService * @@ -119,6 +138,8 @@ class OperationsService: IOperationsService { this.operationApi = OperationApi(httpClient, baseURL, appContext, powerAuthSDK, tokenProvider, userAgent, gsonBuilder) } + override fun currentServerDate() = serverDateShiftInMilliSeconds?.let { ZonedDateTime.now().plus(it, ChronoUnit.MILLIS) } + override fun isLoadingOperations() = synchronized(mutex) { tasks.isNotEmpty() } override fun getOperations(callback: GetOperationsCallback) { @@ -128,8 +149,10 @@ class OperationsService: IOperationsService { if (startLoading) { // Notify start loading listener?.operationsLoading(true) + val dateStarted = ZonedDateTime.now() operationApi.list(object : IApiCallResponseListener { override fun onSuccess(result: OperationListResponse) { + processServerTime(result, dateStarted) processOperationsListResult(Result.success(result.responseObject)) } override fun onFailure(error: ApiError) { @@ -163,6 +186,43 @@ class OperationsService: IOperationsService { } } + private fun processServerTime(response: OperationListResponse, requestStarted: ZonedDateTime) { + + // server does not support this feature + if (response.currentTimestamp == null) { + return + } + + val now = ZonedDateTime.now() + val requestDelayMilliseconds = now.toInstant().toEpochMilli() - requestStarted.toInstant().toEpochMilli() + + // We're adding half of the time that the request took to compensate for the network delay + val serverTime = response.currentTimestamp.plus((requestDelayMilliseconds/2), ChronoUnit.MILLIS) + + // Already calculated server time + val currentServerDate = currentServerDate() + + // If this is not a first calculation, do some adjustments + if (currentServerDate != null) { + + // Difference between already calculated server time and the new server time + val timeChangeMilliseconds = abs((currentServerDate.toInstant().toEpochMilli() - serverTime.toInstant().toEpochMilli())) + + // If the change is under the limit, we ignore the new value to avoid unnecessary changes that might be due to network delay. + if (timeChangeMilliseconds < MIN_SERVER_TIME_CHANGE_MS) { + return + } + + // Reject small change if the network connection took long time + // This is to avoid volatility of the value + if (requestDelayMilliseconds > SERVER_TIME_DELAY_THRESHOLD_MS && timeChangeMilliseconds < FORCED_SERVER_TIME_CHANGE_MS) { + return + } + } + + serverDateShiftInMilliSeconds = serverTime.toInstant().toEpochMilli() - now.toInstant().toEpochMilli() + } + override fun getHistory(authentication: PowerAuthAuthentication, callback: (result: Result>) -> Unit) { operationApi.history(authentication, object : IApiCallResponseListener { override fun onSuccess(result: OperationHistoryResponse) { diff --git a/library/src/test/java/OperationJsonDeserializationTests.kt b/library/src/test/java/OperationJsonDeserializationTests.kt index 24d898a..0f78c77 100644 --- a/library/src/test/java/OperationJsonDeserializationTests.kt +++ b/library/src/test/java/OperationJsonDeserializationTests.kt @@ -170,10 +170,11 @@ class OperationJsonDeserializationTests { @Test fun `test real data 2`() { val json = """ - {"status":"OK","responseObject":[{"id":"930febe7-f350-419a-8bc0-c8883e7f71e3","name":"authorize_payment","data":"A1*A100CZK*Q238400856/0300**D20170629*NUtility Bill Payment - 05/2017","operationCreated":"2018-08-08T12:30:42+0000","operationExpires":"2018-08-08T12:35:43+0000","allowedSignatureType":{"type":"2FA","variants":["possession_knowledge", "possession_biometry"]},"formData":{"title":"Potvrzení platby","message":"Dobrý den,prosíme o potvrzení následující platby:","attributes":[{"type":"AMOUNT","id":"operation.amount","label":"Částka","amount":965165234082.23,"currency":"CZK"},{"type":"KEY_VALUE","id":"operation.account","label":"Na účet","value":"238400856/0300"},{"type":"KEY_VALUE","id":"operation.dueDate","label":"Datum splatnosti","value":"29.6.2017"},{"type":"NOTE","id":"operation.note","label":"Poznámka","note":"Utility Bill Payment - 05/2017"},{"type":"PARTY_INFO","id":"operation.partyInfo","label":"Application","partyInfo":{"logoUrl":"http://whywander.com/wp-content/uploads/2017/05/prague_hero-100x100.jpg","name":"Tesco","description":"Objevte více příběhů psaných s chutí","websiteUrl":"https://itesco.cz/hello/vse-o-jidle/pribehy-psane-s-chuti/clanek/tomovy-burgery-pro-zapalene-fanousky/15012"}},{ "type": "AMOUNT_CONVERSION", "id": "operation.conversion", "label": "Conversion", "dynamic": true, "sourceAmount": 1.26, "sourceCurrency": "ETC", "sourceAmountFormatted": "1.26", "sourceCurrencyFormatted": "ETC", "targetAmount": 1710.98, "targetCurrency": "USD", "targetAmountFormatted": "1,710.98", "targetCurrencyFormatted": "USD"},{ "type": "IMAGE", "id": "operation.image", "label": "Image", "thumbnailUrl": "https://example.com/123_thumb.jpeg", "originalUrl": "https://example.com/123.jpeg" },{ "type": "IMAGE", "id": "operation.image", "label": "Image", "thumbnailUrl": "https://example.com/123_thumb.jpeg" },{ "type": "IMAGE", "id": "operation.image", "label": "Image", "thumbnailUrl": "https://example.com/123_thumb.jpeg", "originalUrl": 12345 }]}},{"id":"930febe7-f350-419a-8bc0-c8883e7f71e3","name":"authorize_payment","data":"A1*A100CZK*Q238400856/0300**D20170629*NUtility Bill Payment - 05/2017","operationCreated":"2018-08-08T12:30:42+0000","operationExpires":"2018-08-08T12:35:43+0000","allowedSignatureType":{"type":"1FA","variants":["possession_knowledge"]},"formData":{"title":"Potvrzení platby","message":"Dobrý den,prosíme o potvrzení následující platby:","attributes":[{"type":"AMOUNT","id":"operation.amount","label":"Částka","amount":100,"currency":"CZK"},{"type":"KEY_VALUE","id":"operation.account","label":"Na účet","value":"238400856/0300"},{"type":"KEY_VALUE","id":"operation.dueDate","label":"Datum splatnosti","value":"29.6.2017"},{"type":"NOTE","id":"operation.note","label":"Poznámka","note":"Utility Bill Payment - 05/2017"}]}}]} + {"status":"OK","currentTimestamp":"2023-02-10T12:30:42+0000","responseObject":[{"id":"930febe7-f350-419a-8bc0-c8883e7f71e3","name":"authorize_payment","data":"A1*A100CZK*Q238400856/0300**D20170629*NUtility Bill Payment - 05/2017","operationCreated":"2018-08-08T12:30:42+0000","operationExpires":"2018-08-08T12:35:43+0000","allowedSignatureType":{"type":"2FA","variants":["possession_knowledge", "possession_biometry"]},"formData":{"title":"Potvrzení platby","message":"Dobrý den,prosíme o potvrzení následující platby:","attributes":[{"type":"AMOUNT","id":"operation.amount","label":"Částka","amount":965165234082.23,"currency":"CZK"},{"type":"KEY_VALUE","id":"operation.account","label":"Na účet","value":"238400856/0300"},{"type":"KEY_VALUE","id":"operation.dueDate","label":"Datum splatnosti","value":"29.6.2017"},{"type":"NOTE","id":"operation.note","label":"Poznámka","note":"Utility Bill Payment - 05/2017"},{"type":"PARTY_INFO","id":"operation.partyInfo","label":"Application","partyInfo":{"logoUrl":"http://whywander.com/wp-content/uploads/2017/05/prague_hero-100x100.jpg","name":"Tesco","description":"Objevte více příběhů psaných s chutí","websiteUrl":"https://itesco.cz/hello/vse-o-jidle/pribehy-psane-s-chuti/clanek/tomovy-burgery-pro-zapalene-fanousky/15012"}},{ "type": "AMOUNT_CONVERSION", "id": "operation.conversion", "label": "Conversion", "dynamic": true, "sourceAmount": 1.26, "sourceCurrency": "ETC", "sourceAmountFormatted": "1.26", "sourceCurrencyFormatted": "ETC", "targetAmount": 1710.98, "targetCurrency": "USD", "targetAmountFormatted": "1,710.98", "targetCurrencyFormatted": "USD"},{ "type": "IMAGE", "id": "operation.image", "label": "Image", "thumbnailUrl": "https://example.com/123_thumb.jpeg", "originalUrl": "https://example.com/123.jpeg" },{ "type": "IMAGE", "id": "operation.image", "label": "Image", "thumbnailUrl": "https://example.com/123_thumb.jpeg" },{ "type": "IMAGE", "id": "operation.image", "label": "Image", "thumbnailUrl": "https://example.com/123_thumb.jpeg", "originalUrl": 12345 }]}},{"id":"930febe7-f350-419a-8bc0-c8883e7f71e3","name":"authorize_payment","data":"A1*A100CZK*Q238400856/0300**D20170629*NUtility Bill Payment - 05/2017","operationCreated":"2018-08-08T12:30:42+0000","operationExpires":"2018-08-08T12:35:43+0000","allowedSignatureType":{"type":"1FA","variants":["possession_knowledge"]},"formData":{"title":"Potvrzení platby","message":"Dobrý den,prosíme o potvrzení následující platby:","attributes":[{"type":"AMOUNT","id":"operation.amount","label":"Částka","amount":100,"currency":"CZK"},{"type":"KEY_VALUE","id":"operation.account","label":"Na účet","value":"238400856/0300"},{"type":"KEY_VALUE","id":"operation.dueDate","label":"Datum splatnosti","value":"29.6.2017"},{"type":"NOTE","id":"operation.note","label":"Poznámka","note":"Utility Bill Payment - 05/2017"}]}}]} """.trimIndent() val response = typeAdapter.fromJson(json) Assert.assertNotNull(response) + Assert.assertEquals(1676032242000, response.currentTimestamp?.toInstant()?.toEpochMilli()) Assert.assertEquals(2, response.responseObject.size) val operation = response.responseObject[0] Assert.assertEquals(9, operation.formData.attributes.size)