Skip to content

Commit

Permalink
Added server time property to operations (#92)
Browse files Browse the repository at this point in the history
  • Loading branch information
kober32 authored May 18, 2023
1 parent b0f0074 commit 2106f59
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 32 deletions.
1 change: 1 addition & 0 deletions docs/Using-Operations-Service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<UserOperations>>) -> Unit)` - Retrieves pending operations from the server.
- `callback` - Called when getting list request finishes.
Expand Down
1 change: 1 addition & 0 deletions library/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion library/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
73 changes: 48 additions & 25 deletions library/src/androidTest/java/IntegrationTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<List<UserOperation>>()
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<ZonedDateTime>()
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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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<UserOperation>, status: Status): ObjectResponse<List<UserOperation>>(responseObject, status)
internal class OperationListResponse(
@SerializedName("currentTimestamp")
val currentTimestamp: ZonedDateTime?,
responseObject: List<UserOperation>,
status: Status
): ObjectResponse<List<UserOperation>>(responseObject, status)
internal class OperationHistoryResponse(responseObject: List<OperationHistoryEntry>, status: Status): ObjectResponse<List<OperationHistoryEntry>>(responseObject, status)
internal class AuthorizeRequest(requestObject: AuthorizeRequestObject): ObjectRequest<AuthorizeRequestObject>(requestObject)
internal class RejectRequest(requestObject: RejectRequestObject): ObjectRequest<RejectRequestObject>(requestObject)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -42,6 +43,20 @@ interface IOperationsService {

val lastOperationsResult: Result<List<UserOperation>>?

/**
* 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -75,6 +75,22 @@ private typealias GetOperationsCallback = (result: Result<List<UserOperation>>)
@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
Expand Down Expand Up @@ -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
*
Expand All @@ -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) {
Expand All @@ -128,8 +149,10 @@ class OperationsService: IOperationsService {
if (startLoading) {
// Notify start loading
listener?.operationsLoading(true)
val dateStarted = ZonedDateTime.now()
operationApi.list(object : IApiCallResponseListener<OperationListResponse> {
override fun onSuccess(result: OperationListResponse) {
processServerTime(result, dateStarted)
processOperationsListResult(Result.success(result.responseObject))
}
override fun onFailure(error: ApiError) {
Expand Down Expand Up @@ -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<List<OperationHistoryEntry>>) -> Unit) {
operationApi.history(authentication, object : IApiCallResponseListener<OperationHistoryResponse> {
override fun onSuccess(result: OperationHistoryResponse) {
Expand Down
3 changes: 2 additions & 1 deletion library/src/test/java/OperationJsonDeserializationTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 2106f59

Please sign in to comment.