diff --git a/docs/Using-Operations-Service.md b/docs/Using-Operations-Service.md index 3aee587..04444d6 100644 --- a/docs/Using-Operations-Service.md +++ b/docs/Using-Operations-Service.md @@ -14,6 +14,7 @@ - [UserOperation](#useroperation) - [Creating a Custom Operation](#creating-a-custom-operation) - [ProximityCheck](#proximitycheck) +- [Templates](#templates) ## Introduction @@ -553,6 +554,13 @@ class OperationUIData { * Type of PostApprovalScreen is presented with different classes (Starting with `PostApprovalScreen*`) */ val postApprovalScreen: PostApprovalScreen? + + /** + * Detailed information about displaying the operation data + * + * Contains prearranged structure of the operation attributes for the app to display + */ + val templates: Templates? } ``` @@ -664,4 +672,196 @@ data class PACData( - mentioned JWT should be in the format `{“typ”:”JWT”, “alg”:”none”}.{“oid”:”5b753d0d-d59a-49b7-bec4-eae258566dbb”, “potp”:”12345678”} ` - Accepted formats: - - notice that the totp key in JWT and in query shall be `potp`! \ No newline at end of file + - notice that the totp key in JWT and in query shall be `potp`! + +## Templates + +`Templates` are part of `OperationUIData`. +`Templates` class provides detailed information about displaying operation data within the application. + + +`typealias AttributeName = String` is used across the `Templates`. It explicitly says that the Strings that will be assigned to properties is actually `OperationAttributes.AttributeLabel.id` and its **value** shall displayed. + +Definition of the `Templates `: + +```kotlin +data class Templates( + /** How the operation should look like in the list of operations */ + val list: ListTemplate?, + + /** How the operation detail should look like when viewed individually. */ + val detail: DetailTemplate? +) +``` + +`ListTemplate` and `DetailTemplate` go as follows: + +```kotlin +data class ListTemplate( + /** Prearranged name which can be processed by the app */ + val style: String?, + + /** Attribute which will be used for the header */ + val header: AttributeFormatted?, + + /** Attribute which will be used for the title */ + val title: AttributeFormatted?, + + /** Attribute which will be used for the message */ + val message: AttributeFormatted?, + + /** Attribute which will be used for the image */ + val image: AttributeId? +) + +data class DetailTemplate( + /** Predefined style name that can be processed by the app to customize the overall look of the operation. */ + val style: String?, + + /** Indicates if the header should be created from form data (title, message) or customized for a specific operation */ + val showTitleAndMessage: Boolean?, + + /** Sections of the operation data. */ + val sections: List
? + ) { + + data class Section( + /** Prearranged name which can be processed by the app to customize the section */ + val style: String?, + + /** Attribute for section title */ + val title: AttributeId?, + + /** Each section can have multiple cells of data */ + val cells: List? + ) { + + data class Cell( + /** Which attribute shall be used */ + val name: AttributeId, + + /** Prearranged name which can be processed by the app to customize the cell */ + val style: String?, + + /** Should be the title visible or hidden */ + val visibleTitle: Boolean?, + + /** Should be the content copyable */ + val canCopy: Boolean?, + + /** Define if the cell should be collapsable */ + val collapsable: Collapsable?, + + /** If value should be centered */ + val centered: Boolean? + ) { + + enum class Collapsable { + + /** The cell should not be collapsable */ + NO, + + /** The cell should be collapsable and in collapsed state */ + COLLAPSED, + + /** The cell should be collapsable and in expanded state */ + YES + } + } + } +} + +``` + +### Template Visual Parser + +For convenience we provide a utility class responsible for preparing visual representations of `UserOperation` from received `Templates`. The parser translates `AttributeNames` from templates and returnes usable Strings values instead. Parser also walways returns the source template from which the data was created. + +```kotlin +class TemplateVisualParser { + + companion object { + + /** Prepares the visual representation for the given `UserOperation` in a list view. */ + fun prepareForList(operation: UserOperation): TemplateListVisual { + return operation.prepareVisualListDetail() + } + + /** Prepares the visual representation for a detail view of the given `UserOperation`. */ + fun prepareForDetail(operation: UserOperation): TemplateDetailVisual { + return operation.prepareVisualDetail() + } + } +} + +``` + + +#### TemplateListVisual + +`TemplateListVisual` holds the visual data for displaying a `UserOperation` in a list view (RecyclerView/ListView/LazyColumn). + +```kotlin +data class TemplateListVisual( + /** The header of the cell */ + val header: String? = null, + /** The title of the cell */ + val title: String? = null, + /** The message (subtitle) of the cell */ + val message: String? = null, + /** Predefined style of the cell on which the implementation can react */ + val style: String? = null, + /** URL of the cell thumbnail */ + val thumbnailImageURL: String? = null, + /** Complete template from which the TemplateListVisual was created */ + val template: Templates.ListTemplate? = null +) +``` + +#### TemplateDetailVisual + +`TemplateDetailVisual` holds the visual data for displaying a detailed view of a `UserOperation`. It contains style to which the app can react and adjust the operation style. It also contains list of `UserOperationVisualSection `. + +```kotlin +data class TemplateDetailVisual( + + /** Predefined style of the whole operation detail to which the app can react and adjust the operation visual */ + val style: String?, + + /** An array of `UserOperationVisualSection` defining the sections of the detailed view. */ + val sections: List +) +``` + +Sections contain style, title and cells properties. + +```kotlin +data class UserOperationVisualSection( + + /** Predefined style of the section to which the app can react and adjust the operation visual */ + val style: String? = null, + + /** The title value for the section */ + val title: String? = null, + + /** An array of cells with `FormData` header and message or visual cells based on `OperationAttributes` */ + val cells: List +) +``` + +`UserOperationVisualCell` is the basic building block of the UserOperation. We differentiate between 5 different cell types: +
    +
  1. `UserOperationHeaderVisualCell` - is a header in a user operation's detail header view.
  2. + - it is created from UserOperation FormData title +
  3. `UserOperationMessageVisualCell` - is a message cell in a user operation's header view.
  4. + - it is created from UserOperation FormData message +
  5. `UserOperationHeadingVisualCell` - is a heading ("section separator") cell in a user operation's detailed view.
  6. + - it is created from `HEADING` FormData attribute +
  7. `UserOperationImageVisualCell` - is an image cell in a user operation's detailed view.
  8. + - it is created from `IMAGE` FormData attribute +
  9. `UserOperationValueAttributeVisualCell` - is value attribute cell in a user operation's detailed view.
  10. + - it is created from the remaining (`AMOUNT`, `AMOUNT_CONVERSION `, `KEY_VALUE`, `NOTE`) FormData attribute +
+ +> [!WARNING] +> At this moment `PARTY_INFO` & `UNKNOWN` attributes are not supported \ No newline at end of file diff --git a/library/src/androidTest/java/OperationUIDataTests.kt b/library/src/androidTest/java/OperationUIDataTests.kt index 438f832..bfa1187 100644 --- a/library/src/androidTest/java/OperationUIDataTests.kt +++ b/library/src/androidTest/java/OperationUIDataTests.kt @@ -21,6 +21,7 @@ import com.wultra.android.mtokensdk.api.operation.model.* import com.wultra.android.mtokensdk.operation.JSONValue import com.wultra.android.mtokensdk.operation.OperationsUtils import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull import junit.framework.TestCase.fail import org.junit.Test @@ -208,6 +209,64 @@ class OperationUIDataTests { assertEquals(postApprovalGenericResult.payload["object"], JSONValue.JSONObject(mapOf("nestedObject" to JSONValue.JSONString("stringValue")))) } + @Test + fun testListTemplates() { + val uiResult = prepareUIData(templatesList) + if (uiResult == null) { + fail("Failed to parse JSON data") + return + } + + assertEquals("POSITIVE", uiResult.templates?.list?.style) + assertEquals("$\\{operation.request_no} Withdrawal Initiation", uiResult.templates?.list?.header) + assertNull(uiResult.templates?.list?.title) + assertNull(uiResult.templates?.list?.message) + assertNull(uiResult.templates?.list?.image) + } + + @Test + fun testTemplates() { + val uiResult = prepareUIData(uiDataWithTemplates) + if (uiResult == null) { + fail("Failed to parse JSON data") + return + } + + assertEquals("POSITIVE", uiResult.templates?.list?.style) + assertEquals("\${operation.request_no} Withdrawal Initiation", uiResult.templates?.list?.header) + assertEquals("\${operation.account} · \${operation.enterprise}", uiResult.templates?.list?.title) + assertEquals("\${operation.tx_amount}", uiResult.templates?.list?.message) + assertEquals("operation.image", uiResult.templates?.list?.image) + + assertEquals(null, uiResult.templates?.detail?.style) + assertEquals(false, uiResult.templates?.detail?.showTitleAndMessage) + + assertEquals("MONEY", uiResult.templates?.detail?.sections?.get(0)?.style) + assertEquals("operation.money.header", uiResult.templates?.detail?.sections?.get(0)?.title) + assertEquals(null, uiResult.templates?.detail?.sections?.get(0)?.cells?.get(0)?.style) + assertEquals("operation.amount", uiResult.templates?.detail?.sections?.get(0)?.cells?.get(0)?.name) + assertEquals(false, uiResult.templates?.detail?.sections?.get(0)?.cells?.get(0)?.visibleTitle) + assertEquals(true, uiResult.templates?.detail?.sections?.get(0)?.cells?.get(0)?.canCopy) + assertEquals(Templates.DetailTemplate.Section.Cell.Collapsable.NO, uiResult.templates?.detail?.sections?.get(0)?.cells?.get(0)?.collapsable) + + assertEquals("CONVERSION", uiResult.templates?.detail?.sections?.get(0)?.cells?.get(1)?.style) + assertEquals("operation.conversion", uiResult.templates?.detail?.sections?.get(0)?.cells?.get(1)?.name) + assertEquals(null, uiResult.templates?.detail?.sections?.get(0)?.cells?.get(1)?.visibleTitle) + assertEquals(true, uiResult.templates?.detail?.sections?.get(0)?.cells?.get(1)?.canCopy) + assertEquals(Templates.DetailTemplate.Section.Cell.Collapsable.NO, uiResult.templates?.detail?.sections?.get(0)?.cells?.get(1)?.collapsable) + + assertEquals(null, uiResult.templates?.detail?.sections?.get(0)?.cells?.get(2)?.style) + assertEquals("operation.conversion2", uiResult.templates?.detail?.sections?.get(0)?.cells?.get(2)?.name) + assertEquals(true, uiResult.templates?.detail?.sections?.get(0)?.cells?.get(2)?.visibleTitle) + assertEquals(false, uiResult.templates?.detail?.sections?.get(0)?.cells?.get(2)?.canCopy) + assertEquals(Templates.DetailTemplate.Section.Cell.Collapsable.COLLAPSED, uiResult.templates?.detail?.sections?.get(0)?.cells?.get(2)?.collapsable) + + assertEquals(3, uiResult.templates?.detail?.sections?.get(0)?.cells?.size) + + assertNull(uiResult.templates?.detail?.sections?.get(1)?.cells) + assertNull(uiResult.templates?.detail?.sections?.get(2)?.cells) + } + /** Helpers */ private val jsonDecoder: Gson = OperationsUtils.defaultGsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").create() @@ -220,6 +279,14 @@ class OperationUIDataTests { return result } + private fun prepareUIData(response: String): OperationUIData? { + return try { + jsonDecoder.fromJson(response, OperationUIData::class.java) + } catch (e: Exception) { + null + } + } + private val preApprovalResponse: String = """ { "id": "74654880-6db9-4b84-9174-386fc5e7d8ab", @@ -451,4 +518,88 @@ class OperationUIDataTests { } } """ + + private val templatesList: String = """ + { + "flipButtons": false, + "blockApprovalOnCall": true, + "templates": { + "list": { + "style": "POSITIVE", + "header": "${'$'}\\{operation.request_no} Withdrawal Initiation", + "message": null, + "image": true + } + } + } + """ + + private val uiDataWithTemplates: String = """ +{ + "flipButtons": false, + "blockApprovalOnCall": true, + "templates": { + "list": { + "style": "POSITIVE", + "header": "${"$"}{operation.request_no} Withdrawal Initiation", + "message": "${"$"}{operation.tx_amount}", + "title": "${"$"}{operation.account} · ${"$"}{operation.enterprise}", + "image": "operation.image" + }, + "detail": { + "style": null, + "showTitleAndMessage": false, + "sections": [ + { + "style": "MONEY", + "title": "operation.money.header", + "cells": [ + { + "name": "operation.amount", + "visibleTitle": false, + "style": null, + "canCopy": true, + "collapsable": "NO", + "centered": true + }, + { + "style": "CONVERSION", + "name": "operation.conversion", + "canCopy": true, + "collapsable": "NO" + }, + { + "name": "operation.conversion2", + "visibleTitle": true, + "style": null, + "canCopy": false, + "collapsable": "COLLAPSED" + }, + { + "visibleTitle": true + } + ] + }, + { + "style": "FOOTER", + "title": "operation.footer" + }, + { + "style": "FOOTER", + "title": "operation.footer", + "cells": + { + "name": "operation.amount", + "visibleTitle": false, + "style": null, + "canCopy": true, + "collapsable": "NO", + "centered": true + } + } + ] + } + } + } + """ } diff --git a/library/src/main/java/com/wultra/android/mtokensdk/api/operation/TemplatesDeserializer.kt b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/TemplatesDeserializer.kt new file mode 100644 index 0000000..5d4b82c --- /dev/null +++ b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/TemplatesDeserializer.kt @@ -0,0 +1,181 @@ +/* + * Copyright 2022 Wultra s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions + * and limitations under the License. + */ + +package com.wultra.android.mtokensdk.api.operation + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.reflect.TypeToken +import com.wultra.android.mtokensdk.api.operation.model.Templates +import com.wultra.android.mtokensdk.log.WMTLogger +import java.lang.reflect.Type + +/** + * Custom Templates deserializer + */ + +internal class TemplatesDeserializer : JsonDeserializer { + + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Templates { + val jsonObject = json.asJsonObject + + val listTemplate = jsonObject.get("list")?.let { listElement -> + try { + context.deserialize(listElement, Templates.ListTemplate::class.java) + } catch (e: Exception) { + WMTLogger.e("Failed to deserialize ListTemplate - ${e.message}") + null + } + } + + val detailTemplate = jsonObject.get("detail")?.let { detailElement -> + try { + context.deserialize(detailElement, Templates.DetailTemplate::class.java) + } catch (e: Exception) { + WMTLogger.e("Failed to deserialize DetailTemplate - ${e.message}") + null + } + } + + return Templates(listTemplate, detailTemplate) + } +} + +internal class ListTemplateDeserializer : JsonDeserializer { + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Templates.ListTemplate { + val jsonObject = json.asJsonObject + + val style: String? = jsonObject.get("style")?.asStringWithLogging("ListTemplate.style") + val header: String? = jsonObject.get("header")?.asStringWithLogging("ListTemplate.header") + val title: String? = jsonObject.get("title")?.asStringWithLogging("ListTemplate.title") + val message: String? = jsonObject.get("message")?.asStringWithLogging("ListTemplate.message") + val image: String? = jsonObject.get("image")?.asStringWithLogging("ListTemplate.image") + + return Templates.ListTemplate(style, header, title, message, image) + } +} + +internal class DetailTemplateDeserializer : JsonDeserializer { + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Templates.DetailTemplate { + val jsonObject = json.asJsonObject + + val style: String? = jsonObject.get("style")?.asStringWithLogging("DetailTemplate.style") + val showTitleAndMessage: Boolean? = jsonObject.get("showTitleAndMessage")?.asBooleanWithLogging("DetailTemplate.showTitleAndMessage") + + val sections: List? = try { + val sectionsElement = jsonObject.get("sections") + val sectionType = object : TypeToken>() {}.type + context.deserialize>(sectionsElement, sectionType) + } catch (e: Exception) { + WMTLogger.e("Failed to decode 'DetailTemplate.sections' - ${e.message}, setting to null") + null + } + + return Templates.DetailTemplate(style, showTitleAndMessage, sections) + } +} + +internal class SectionDeserializer : JsonDeserializer { + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Templates.DetailTemplate.Section { + val jsonObject = json.asJsonObject + + val style: String? = jsonObject.get("style")?.asStringWithLogging("ListTemplate.Section.style") + val title: String? = jsonObject.get("title")?.asStringWithLogging("ListTemplate.Section.title") + + val cells: List? = jsonObject.get("cells")?.let { cellsElement -> + try { + if (cellsElement.isJsonArray) { + val cellList = mutableListOf() + val jsonArray = cellsElement.asJsonArray + jsonArray.forEach { cellElement -> + try { + val cell = context.deserialize(cellElement, Templates.DetailTemplate.Section.Cell::class.java) + cellList.add(cell) + } catch (e: Exception) { + WMTLogger.e("Failed to decode cell in DetailTemplate.Section.cells - ${e.message}") + } + } + cellList + } else { + WMTLogger.e("Failed to decode 'DetailTemplate.Sections.cells' - Expected a JSON array, setting to null") + null + } + } catch (e: Exception) { + WMTLogger.e("Failed to decode DetailTemplate.Section.cells - ${e.message}, setting to null") + null + } + } + + return Templates.DetailTemplate.Section(style, title, cells) + } +} + +internal class CellDeserializer : JsonDeserializer { + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Templates.DetailTemplate.Section.Cell { + val jsonObject = json.asJsonObject + + val name = jsonObject.get("name").asString + + val style: String? = jsonObject.get("style")?.asStringWithLogging("DetailTemplate.Section.Cell.style") + val visibleTitle: Boolean? = jsonObject.get("visibleTitle")?.asBooleanWithLogging("DetailTemplate.Section.Cell.visibleTitle") + val canCopy: Boolean? = jsonObject.get("canCopy")?.asBooleanWithLogging("DetailTemplate.Section.Cell.canCopy") + + val collapsable = jsonObject.get("collapsable")?.asStringWithLogging("DetailTemplate.Section.Cell.collapsable")?.let { + Templates.DetailTemplate.Section.Cell.Collapsable.valueOf(it) + } + + val centered: Boolean? = jsonObject.get("centered")?.asBooleanWithLogging("DetailTemplate.Section.Cell.centered") + + return Templates.DetailTemplate.Section.Cell(name, style, visibleTitle, canCopy, collapsable, centered) + } +} + +private fun JsonElement.asStringWithLogging(fieldName: String): String? { + try { + if (this.isJsonNull) { + return null + } + + if (this.isJsonPrimitive && !this.asJsonPrimitive.isString) { + WMTLogger.e("Failed to decode '$fieldName' - $this is not a String, setting to null") + return null + } + + return this.asString + } catch (e: Exception) { + WMTLogger.e("Failed to decode '$fieldName' - ${e.message}, setting to null") + return null + } +} + +private fun JsonElement.asBooleanWithLogging(fieldName: String): Boolean? { + try { + if (this.isJsonNull) { + return null + } + + if (this.isJsonPrimitive && !this.asJsonPrimitive.isBoolean) { + WMTLogger.e("Failed to decode '$fieldName' - $this is not a Boolean, setting to null") + return null + } + + return this.asBoolean + } catch (e: Exception) { + WMTLogger.e("Failed to decode '$fieldName' - ${e.message}, setting to null") + return null + } +} diff --git a/library/src/main/java/com/wultra/android/mtokensdk/api/operation/model/Templates.kt b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/model/Templates.kt new file mode 100644 index 0000000..c2ee444 --- /dev/null +++ b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/model/Templates.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2024 Wultra s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions + * and limitations under the License. + */ + +package com.wultra.android.mtokensdk.api.operation.model + +/** + * Value of the `AttributeId` is referencing an existing `WMTOperationAttribute` by `WMTOperationAttribute.AttributeLabel.id` + */ +typealias AttributeId = String + +/** + * Value of the `AttributeFormatted` typealias contains placeholders for operation attributes, + * which are specified using the syntax `${operation.attribute}`. + * + * Example might be `"${operation.date} - ${operation.place}"` + * Placeholders in `AttributeFormatted` need to be parsed and replaced with actual attribute values. + */ +typealias AttributeFormatted = String + +/** + * Detailed information about displaying operation data + * + * Contains prearranged styles for the operation attributes for the app to display + */ +data class Templates( + + /** + * How the operation should look like in the list of operations + */ + val list: ListTemplate?, + + /** + * How the operation detail should look like when viewed individually. + */ + val detail: DetailTemplate? +) { + + /** + * ListTemplate defines how the operation should look in the list (active operations, history) + * + * List cell usually contains header, title, message(subtitle) and image + */ + data class ListTemplate( + + /** Prearranged name which can be processed by the app */ + val style: String?, + + /** Attribute which will be used for the header */ + val header: AttributeFormatted?, + + /** Attribute which will be used for the title */ + val title: AttributeFormatted?, + + /** Attribute which will be used for the message */ + val message: AttributeFormatted?, + + /** Attribute which will be used for the image */ + val image: AttributeId? + ) + + /** + * DetailTemplate defines how the operation details should appear. + * + * Each operation can be divided into sections with multiple cells. + * Attributes not mentioned in the `DetailTemplate` should be displayed without custom styling. + */ + data class DetailTemplate( + + /** Predefined style name that can be processed by the app to customize the overall look of the operation. */ + val style: String?, + + /** Indicates if the header should be created from form data (title, message, image) or customized for a specific operation */ + val showTitleAndMessage: Boolean?, + + /** Sections of the operation data. */ + val sections: List
? + ) { + + /** + * Operation data can be divided into sections + */ + data class Section( + + /** Prearranged name which can be processed by the app to customize the section */ + val style: String?, + + /** Attribute for section title */ + val title: AttributeId?, + + /** Each section can have multiple cells of data */ + val cells: List? + ) { + + /** + * Each section can have multiple cells of data + */ + data class Cell( + + /** Which attribute shall be used */ + val name: AttributeId, + + /** Prearranged name which can be processed by the app to customize the cell */ + val style: String?, + + /** Should be the title visible or hidden */ + val visibleTitle: Boolean?, + + /** Should be the content copyable */ + val canCopy: Boolean?, + + /** Define if the cell should be collapsable */ + val collapsable: Collapsable?, + + /** If value should be centered */ + val centered: Boolean? + ) { + + enum class Collapsable { + + /** The cell should not be collapsable */ + NO, + + /** The cell should be collapsable and in collapsed state */ + COLLAPSED, + + /** The cell should be collapsable and in expanded state */ + YES + } + } + } + } +} 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 b85837f..b7e8d0e 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 @@ -192,7 +192,15 @@ data class OperationUIData( * Type of PostApprovalScreen is presented with different classes (Starting with `PostApprovalScreen*`) */ @SerializedName("postApprovalScreen") - val postApprovalScreen: PostApprovalScreen? + val postApprovalScreen: PostApprovalScreen?, + + /** + * Detailed information about displaying the operation data + * + * Contains prearranged styles for the operation attributes for the app to display + */ + @SerializedName("templates") + val templates: Templates? = null ) /** diff --git a/library/src/main/java/com/wultra/android/mtokensdk/api/operation/templateparser/TemplateDetailVisual.kt b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/templateparser/TemplateDetailVisual.kt new file mode 100644 index 0000000..9616eea --- /dev/null +++ b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/templateparser/TemplateDetailVisual.kt @@ -0,0 +1,298 @@ +/* + * Copyright 2024 Wultra s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions + * and limitations under the License. + */ + +package com.wultra.android.mtokensdk.api.operation.templateparser + +import com.wultra.android.mtokensdk.api.operation.model.AmountAttribute +import com.wultra.android.mtokensdk.api.operation.model.Attribute +import com.wultra.android.mtokensdk.api.operation.model.ConversionAttribute +import com.wultra.android.mtokensdk.api.operation.model.ImageAttribute +import com.wultra.android.mtokensdk.api.operation.model.KeyValueAttribute +import com.wultra.android.mtokensdk.api.operation.model.NoteAttribute +import com.wultra.android.mtokensdk.api.operation.model.Templates +import com.wultra.android.mtokensdk.api.operation.model.UserOperation +import com.wultra.android.mtokensdk.log.WMTLogger + +/** + * `TemplateDetailVisual` holds the visual data for displaying a detailed view of a user operation. + */ +data class TemplateDetailVisual( + + /** Predefined style of the whole operation detail to which the app can react and adjust the operation visual */ + val style: String?, + + /** An array of `UserOperationVisualSection` defining the sections of the detailed view. */ + val sections: List +) + +/** + * This class defines one section in the detailed view of a user operation. + */ +data class UserOperationVisualSection( + + /** Predefined style of the section to which the app can react and adjust the operation visual */ + val style: String? = null, + + /** The title value for the section */ + val title: String? = null, + + /** An array of cells with `FormData` header and message or visual cells based on `OperationAttributes` */ + val cells: List +) + +/** + * An interface for visual cells in a user operation's detailed view. + */ +interface UserOperationVisualCell + +/** + * `UserOperationHeaderVisualCell` contains a header in a user operation's detail header view. + * + * This data class is used to distinguish between the default header section and custom `OperationAttribute` sections. + */ +data class UserOperationHeaderVisualCell( + + /** This value corresponds to `FormData.title` */ + val value: String +) : UserOperationVisualCell + +/** + * `UserOperationMessageVisualCell` is a message cell in a user operation's header view. + * + * This data class is used within default header section and is used to distinguished from custom `OperationAttribute` sections. + */ +data class UserOperationMessageVisualCell( + + /** This value corresponds to `FormData.message` */ + val value: String +) : UserOperationVisualCell + +/** + * `UserOperationHeadingVisualCell` defines a heading cell in a user operation's detailed view. + */ +data class UserOperationHeadingVisualCell( + + /** Single highlighted text used as a section heading */ + val header: String, + + /** Predefined style of the section cell, app shall react to it and should change the visual of the cell */ + val style: String? = null, + + /** The source user operation attribute. */ + val attribute: Attribute, + + /** The template the cell was made from. */ + val cellTemplate: Templates.DetailTemplate.Section.Cell? = null +) : UserOperationVisualCell + +/** + * `UserOperationValueAttributeVisualCell` defines a value attribute cell in a user operation's detailed view. + */ +data class UserOperationValueAttributeVisualCell( + + /** The header text value */ + val header: String, + + /** The text value preformatted for the cell (if the preformatted value isn't sufficient, the value from the attribute can be used) */ + val defaultFormattedStringValue: String, + + /** Predefined style of the section cell, app shall react to it and should change the visual of the cell */ + val style: String? = null, + + /** /// The source user operation attribute. */ + val attribute: Attribute, + + /** The template the cell was made from. */ + val cellTemplate: Templates.DetailTemplate.Section.Cell? = null +) : UserOperationVisualCell + +/** + * `UserOperationImageVisualCell` defines an image cell in a user operation's detailed view. + */ +data class UserOperationImageVisualCell( + + /** The URL of the thumbnail image */ + val urlThumbnail: String, + + /** The URL of the full size image */ + val urlFull: String? = null, + + /** Predefined style of the section cell, app shall react to it and should change the visual of the cell */ + val style: String? = null, + + /** The source user operation attribute. */ + val attribute: ImageAttribute, + + /** The template the cell was made from. */ + val cellTemplate: Templates.DetailTemplate.Section.Cell? = null +) : UserOperationVisualCell + +/** + * Extension function to prepare the visual representation for the given `UserOperation` in a detailed view. + */ +fun UserOperation.prepareVisualDetail(): TemplateDetailVisual { + val detailTemplate = this.ui?.templates?.detail + + return if (detailTemplate == null) { + val sections = mutableListOf(createDefaultHeaderVisual()) + if (formData.attributes.isNotEmpty()) { + sections.add(UserOperationVisualSection(cells = formData.attributes.getRemainingCells())) + } + TemplateDetailVisual(style = null, sections = sections) + } else { + createDetailVisual(detailTemplate) + } +} + +private fun UserOperation.createDefaultHeaderVisual(): UserOperationVisualSection { + val defaultHeaderCell = UserOperationHeaderVisualCell(value = this.formData.title) + val defaultMessageCell = UserOperationMessageVisualCell(value = this.formData.message) + + return UserOperationVisualSection( + style = null, + title = null, + cells = listOf(defaultHeaderCell, defaultMessageCell) + ) +} + +private fun UserOperation.createDetailVisual(detailTemplate: Templates.DetailTemplate): TemplateDetailVisual { + val attributes = this.formData.attributes.toMutableList() + + val sections = mutableListOf() + + if (detailTemplate.showTitleAndMessage == false) { + sections.addAll(attributes.popCellsFromSections(detailTemplate.sections)) + sections.add(UserOperationVisualSection(cells = attributes.getRemainingCells())) + } else { + sections.add(createDefaultHeaderVisual()) + sections.addAll(attributes.popCellsFromSections(detailTemplate.sections)) + sections.add(UserOperationVisualSection(cells = attributes.getRemainingCells())) + } + + return TemplateDetailVisual(style = detailTemplate.style, sections = sections) +} + +private fun MutableList.popCellsFromSections( + sections: List? +): List { + return sections?.map { popCellsFromSection(it) } ?: emptyList() +} + +private fun MutableList.popCellsFromSection( + section: Templates.DetailTemplate.Section +): UserOperationVisualSection { + return UserOperationVisualSection( + style = section.style, + title = popAttribute(section.title)?.label?.value, + cells = section.cells?.mapNotNull { createCellFromTemplateCell(it) } ?: emptyList() + ) +} + +private fun MutableList.popAttribute(id: String?): Attribute? { + id?.let { + val index = indexOfFirst { it.label.id == id } + return if (index != -1) removeAt(index) else null + } + return null +} + +private fun MutableList.createCellFromTemplateCell( + templateCell: Templates.DetailTemplate.Section.Cell +): UserOperationVisualCell? { + val attr = popAttribute(templateCell.name) ?: return null.also { + WMTLogger.w("Template Attribute '${templateCell.name}', not found in FormData Attributes") + } + return createCell(attr, templateCell) +} + +private fun List.getRemainingCells(): List { + return mapNotNull { createCell(it) } +} + +private fun createCell( + attr: Attribute, + templateCell: Templates.DetailTemplate.Section.Cell? = null +): UserOperationVisualCell? { + return when (attr.type) { + Attribute.Type.AMOUNT -> { + val amount = attr as? AmountAttribute ?: return null + UserOperationValueAttributeVisualCell( + header = attr.label.value, + defaultFormattedStringValue = amount.valueFormatted + ?: "${amount.amountFormatted} ${amount.currencyFormatted}", + style = templateCell?.style, + attribute = attr, + cellTemplate = templateCell + ) + } + Attribute.Type.AMOUNT_CONVERSION -> { + val conversion = attr as? ConversionAttribute ?: return null + val sourceValue = conversion.source.valueFormatted + ?: "${conversion.source.amountFormatted} ${conversion.source.currencyFormatted}" + val targetValue = conversion.target.valueFormatted + ?: "${conversion.target.amountFormatted} ${conversion.target.currencyFormatted}" + UserOperationValueAttributeVisualCell( + header = attr.label.value, + defaultFormattedStringValue = "$sourceValue → $targetValue", + style = templateCell?.style, + attribute = attr, + cellTemplate = templateCell + ) + } + Attribute.Type.KEY_VALUE -> { + val keyValue = attr as? KeyValueAttribute ?: return null + UserOperationValueAttributeVisualCell( + header = attr.label.value, + defaultFormattedStringValue = keyValue.value, + style = templateCell?.style, + attribute = attr, + cellTemplate = templateCell + ) + } + Attribute.Type.NOTE -> { + val note = attr as? NoteAttribute ?: return null + UserOperationValueAttributeVisualCell( + header = attr.label.value, + defaultFormattedStringValue = note.note, + style = templateCell?.style, + attribute = attr, + cellTemplate = templateCell + ) + } + Attribute.Type.IMAGE -> { + val image = attr as? ImageAttribute ?: return null + UserOperationImageVisualCell( + urlThumbnail = image.thumbnailUrl, + urlFull = image.originalUrl, + style = templateCell?.style, + attribute = image, + cellTemplate = templateCell + ) + } + Attribute.Type.HEADING -> { + UserOperationHeadingVisualCell( + header = attr.label.value, + style = templateCell?.style, + attribute = attr, + cellTemplate = templateCell + ) + } + else -> { + WMTLogger.w("Using unsupported Attribute in Templates") + null + } + } +} diff --git a/library/src/main/java/com/wultra/android/mtokensdk/api/operation/templateparser/TemplateListVisual.kt b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/templateparser/TemplateListVisual.kt new file mode 100644 index 0000000..c422cb0 --- /dev/null +++ b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/templateparser/TemplateListVisual.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2024 Wultra s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions + * and limitations under the License. + */ + +package com.wultra.android.mtokensdk.api.operation.templateparser + +import com.wultra.android.mtokensdk.api.operation.model.AmountAttribute +import com.wultra.android.mtokensdk.api.operation.model.Attribute +import com.wultra.android.mtokensdk.api.operation.model.ConversionAttribute +import com.wultra.android.mtokensdk.api.operation.model.HeadingAttribute +import com.wultra.android.mtokensdk.api.operation.model.ImageAttribute +import com.wultra.android.mtokensdk.api.operation.model.KeyValueAttribute +import com.wultra.android.mtokensdk.api.operation.model.NoteAttribute +import com.wultra.android.mtokensdk.api.operation.model.Templates +import com.wultra.android.mtokensdk.api.operation.model.UserOperation +import com.wultra.android.mtokensdk.log.WMTLogger + +/** + * `TemplateListVisual` holds the visual data for displaying a user operation in a list view (RecyclerView/ListView). + */ +data class TemplateListVisual( + /** The header of the cell */ + val header: String? = null, + /** The title of the cell */ + val title: String? = null, + /** The message (subtitle) of the cell */ + val message: String? = null, + /** Predefined style of the cell on which the implementation can react */ + val style: String? = null, + /** URL of the cell thumbnail */ + val thumbnailImageURL: String? = null, + /** Complete template from which the TemplateListVisual was created */ + val template: Templates.ListTemplate? = null +) + +/** + * Extension function to prepare the visual representation for the given `UserOperation` in a list view. + */ +fun UserOperation.prepareVisualListDetail(): TemplateListVisual { + val listTemplate = this.ui?.templates?.list + val attributes = this.formData.attributes + val headerAttr = listTemplate?.header?.replacePlaceholders(attributes) + + val title: String? = listTemplate?.title?.replacePlaceholders(attributes) + ?: if (this.formData.message.isNotEmpty()) this.formData.title else null + + val message: String? = listTemplate?.message?.replacePlaceholders(attributes) + ?: if (this.formData.message.isNotEmpty()) this.formData.message else null + + val imageUrl: String? = listTemplate?.image?.let { imgAttr -> + this.formData.attributes + .filterIsInstance() + .firstOrNull { it.label.id == imgAttr } + ?.thumbnailUrl + } + + return TemplateListVisual( + header = headerAttr, + title = title, + message = message, + style = this.ui?.templates?.list?.style, + thumbnailImageURL = imageUrl, + template = listTemplate + ) +} + +/** + * Extension function to replace placeholders in the template with actual values. + */ +fun String.replacePlaceholders(attributes: List): String? { + var result = this + + val placeholders = extractPlaceholders() + placeholders?.forEach { placeholder -> + val value = findAttributeValue(placeholder, attributes) + if (value != null) { + result = result.replace("\${$placeholder}", value) + } else { + WMTLogger.d("Placeholder Attribute: $placeholder not found.") + return null + } + } + return result +} + +/** + * Extracts placeholders from the string. + */ +private fun String.extractPlaceholders(): List? { + return try { + val regex = Regex("""\$\{(.*?)\}""") + regex.findAll(this).map { it.groupValues[1] }.toList() + } catch (e: Exception) { + WMTLogger.w("Error creating regex: $e in TemplatesListParser.") + null + } +} + +/** + * Finds the attribute value for a given attribute ID from the attributes list. + */ +private fun findAttributeValue(attributeId: String, attributes: List): String? { + return attributes.firstOrNull { it.label.id == attributeId }?.let { attribute -> + when (attribute.type) { + Attribute.Type.AMOUNT -> { + val attr = attribute as? AmountAttribute + attr?.valueFormatted ?: "${attr?.amountFormatted} ${attr?.currencyFormatted}" + } + Attribute.Type.AMOUNT_CONVERSION -> { + val attr = attribute as? ConversionAttribute + if (attr != null) { + val sourceValue = attr.source.valueFormatted ?: "${attr.source.amountFormatted} ${attr.source.currencyFormatted}" + val targetValue = attr.target.valueFormatted ?: "${attr.target.amountFormatted} ${attr.target.currencyFormatted}" + "$sourceValue → $targetValue" + } else { + null + } + } + Attribute.Type.KEY_VALUE -> (attribute as? KeyValueAttribute)?.value + Attribute.Type.NOTE -> (attribute as? NoteAttribute)?.note + Attribute.Type.HEADING -> (attribute as? HeadingAttribute)?.label?.value + else -> null + } + } +} diff --git a/library/src/main/java/com/wultra/android/mtokensdk/api/operation/templateparser/TemplateVisualParser.kt b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/templateparser/TemplateVisualParser.kt new file mode 100644 index 0000000..f3f556b --- /dev/null +++ b/library/src/main/java/com/wultra/android/mtokensdk/api/operation/templateparser/TemplateVisualParser.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Wultra s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions + * and limitations under the License. + */ + +package com.wultra.android.mtokensdk.api.operation.templateparser + +import com.wultra.android.mtokensdk.api.operation.model.UserOperation + +/** + * This is a utility class responsible for preparing visual representations of `UserOperation`. + * + * It generates visual data for both list and detailed views of the operations from `OperationFormData` and its `OperationAttribute`. + * The visual data are created based on the structure of the `Templates`. + */ +class TemplateVisualParser { + + companion object { + + /** + * Prepares the visual representation for the given `UserOperation` in a list view. + * @param operation The user operation to prepare the visual data for. + * @return A `TemplateListVisual` instance containing the visual data. + */ + @JvmStatic + fun prepareForList(operation: UserOperation): TemplateListVisual { + return operation.prepareVisualListDetail() + } + + /** + * Prepares the visual representation for a detail view of the given `UserOperation`. + * @param operation The user operation to prepare the visual data for. + * @return A `TemplateDetailVisual` instance containing the visual data. + */ + @JvmStatic + fun prepareForDetail(operation: UserOperation): TemplateDetailVisual { + return operation.prepareVisualDetail() + } + } +} diff --git a/library/src/main/java/com/wultra/android/mtokensdk/operation/OperationsUtils.kt b/library/src/main/java/com/wultra/android/mtokensdk/operation/OperationsUtils.kt index a7700a3..e5ee365 100644 --- a/library/src/main/java/com/wultra/android/mtokensdk/operation/OperationsUtils.kt +++ b/library/src/main/java/com/wultra/android/mtokensdk/operation/OperationsUtils.kt @@ -25,6 +25,7 @@ import com.wultra.android.mtokensdk.api.operation.model.Attribute import com.wultra.android.mtokensdk.api.operation.model.OperationHistoryEntry import com.wultra.android.mtokensdk.api.operation.model.PostApprovalScreen import com.wultra.android.mtokensdk.api.operation.model.PreApprovalScreen +import com.wultra.android.mtokensdk.api.operation.model.Templates import org.threeten.bp.ZonedDateTime class OperationsUtils { @@ -49,6 +50,11 @@ class OperationsUtils { builder.registerTypeAdapter(OperationHistoryEntry::class.java, OperationHistoryEntryDeserializer()) builder.registerTypeAdapter(PreApprovalScreen::class.java, PreApprovalScreenDeserializer()) builder.registerTypeAdapter(PostApprovalScreen::class.java, PostApprovalScreenDeserializer()) + builder.registerTypeAdapter(Templates::class.java, TemplatesDeserializer()) + builder.registerTypeAdapter(Templates.ListTemplate::class.java, ListTemplateDeserializer()) + builder.registerTypeAdapter(Templates.DetailTemplate::class.java, DetailTemplateDeserializer()) + builder.registerTypeAdapter(Templates.DetailTemplate.Section::class.java, SectionDeserializer()) + builder.registerTypeAdapter(Templates.DetailTemplate.Section.Cell::class.java, CellDeserializer()) return builder } }