Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ the `context` param takes android `Context` object as described below:

```kotlin
val collectElementInput = Skyflow.CollectElementInput(
table = "string", //the table this data belongs to
tableName = "string", //the table this data belongs to
column = "string", //the column into which this data should be inserted
type = Skyflow.ElementType.CARD_NUMBER, //Skyflow.ElementType enum
inputStyles = Skyflow.Styles(), /*optional styles that should be applied to the form element*/
Expand Down Expand Up @@ -683,12 +683,12 @@ val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT)

// Create a CollectElementInput
val cardNumberInput = Skyflow.CollectElementInput(
table = "cards",
tableName = "cards",
column = "cardNumber",
type = Skyflow.ElementType.CARD_NUMBER,
)
val cardHolderNameInput = Skyflow.CollectElementInput(
table = "cards",
tableName = "cards",
column = "cardHolderName",
type = Skyflow.ElementType.CARDHOLDER_NAME,
)
Expand Down Expand Up @@ -763,7 +763,7 @@ val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT)

// Create a CollectElementInput
val cardNumberInput = Skyflow.CollectElementInput(
table = "cards",
tableName = "cards",
column = "cardNumber",
type = Skyflow.ElementType.CARD_NUMBER,
)
Expand Down Expand Up @@ -801,7 +801,7 @@ val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT)

// Create a CollectElementInput
val cardNumberInput = Skyflow.CollectElementInput(
table = "cards",
tableName = "cards",
column = "cardNumber",
type = Skyflow.ElementType.CARD_NUMBER,
)
Expand Down Expand Up @@ -1272,13 +1272,13 @@ val container = skyflowClient.container(type: Skyflow.ContainerType.COMPOSABLE,

// Create a CollectElementInput
val cardNumberInput = Skyflow.CollectElementInput(
table = "cards",
tableName = "cards",
column = "cardNumber",
type = Skyflow.ElementType.CARD_NUMBER,
)

val cardHolderNameInput = Skyflow.CollectElementInput(
table = "cards",
tableName = "cards",
column = "cardHolderName",
type = Skyflow.ElementType.CARDHOLDER_NAME,
)
Expand Down Expand Up @@ -1391,13 +1391,13 @@ val container = skyflowClient.container(type: Skyflow.ContainerType.COMPOSABLE,

// Create a CollectElementInput
val cardNumberInput = Skyflow.CollectElementInput(
table = "cards",
tableName = "cards",
column = "cardNumber",
type = Skyflow.ElementType.CARD_NUMBER,
)

val cardHolderNameInput = Skyflow.CollectElementInput(
table = "cards",
tableName = "cards",
column = "cardHolderName",
type = Skyflow.ElementType.CARDHOLDER_NAME,
)
Expand All @@ -1414,7 +1414,7 @@ try {

// Update table, column, inputStyles properties on cardNumber.
cardNumber.update(update = CollectElementInput(
table = "cards",
tableName = "cards",
column = "cardHolderName",
inputStyles = Skyflow.Styles(base: Style(borderColor: UIColor.red))
))
Expand Down Expand Up @@ -1458,7 +1458,7 @@ val container = skyflowClient.container(type: Skyflow.ContainerType.COMPOSABLE,

// Create a CollectElementInput
val cardNumberInput = Skyflow.CollectElementInput(
table = "cards",
tableName = "cards",
column = "cardNumber",
type = Skyflow.ElementType.CARD_NUMBER,
)
Expand Down
12 changes: 7 additions & 5 deletions Skyflow/src/main/kotlin/Skyflow/CollectContainer.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package Skyflow

import Skyflow.collect.client.CVVMap
import Skyflow.collect.client.FlowDBCollectRequestBody
import Skyflow.collect.client.FlowDBMixedAPICallback
import org.json.JSONObject
Expand Down Expand Up @@ -72,13 +73,13 @@ internal fun Container<CollectContainer>.validateElement(element: TextField, err
throw SkyflowInternalError(SkyflowErrorCode.ELEMENT_NOT_MOUNTED, tag, configuration.options.logLevel, arrayOf(element.columnName))
}
when {
element.collectInput.table.equals(null) -> {
element.collectInput.tableName.equals(null) -> {
throw SkyflowInternalError(SkyflowErrorCode.MISSING_TABLE_IN_ELEMENT, tag, configuration.options.logLevel, arrayOf(element.fieldType.toString()))
}
element.collectInput.column.equals(null) -> {
throw SkyflowInternalError(SkyflowErrorCode.MISSING_COLUMN, tag, configuration.options.logLevel, arrayOf(element.fieldType.toString()))
}
element.collectInput.table!!.isEmpty() -> {
element.collectInput.tableName!!.isEmpty() -> {
throw SkyflowInternalError(SkyflowErrorCode.ELEMENT_EMPTY_TABLE_NAME, tag, configuration.options.logLevel, arrayOf(element.fieldType.toString()))
}
element.collectInput.column!!.isEmpty() -> {
Expand Down Expand Up @@ -150,7 +151,7 @@ internal fun Container<CollectContainer>.post(callback: Callback, options: Colle

val mixedCallback = FlowDBMixedAPICallback(
client.apiClient, combinedUpdateBody, insertBody, callback, collectOptions,
configuration.options.logLevel
configuration.options.logLevel, CVVMap.capture(collectElements)
)
client.apiClient.getAccessToken(mixedCallback)
return
Expand All @@ -166,7 +167,7 @@ internal fun Container<CollectContainer>.post(callback: Callback, options: Colle
insertOptions,
configuration.options.logLevel
)
this.client.apiClient.post(requestBody, callback, collectOptions)
this.client.apiClient.post(requestBody, callback, collectOptions, cvvMap = CVVMap.capture(this.collectElements))
}

fun Container<CollectContainer>.collect(callback: CollectCallback, options: CollectOptions? = CollectOptions()) {
Expand All @@ -191,7 +192,8 @@ fun Container<CollectContainer>.update(tableName: String, skyflowID: String, cal
skyflowID,
configuration.options.logLevel
)
this.client.apiClient.post(requestBody, callback, options, "update")
this.client.apiClient.post(requestBody, callback, options, "update",
CVVMap.captureForUpdate(this.collectElements, skyflowID))
} catch (e: Exception) {
callback.onFailure(Utils.constructErrorResponse(e))
}
Expand Down
6 changes: 3 additions & 3 deletions Skyflow/src/main/kotlin/Skyflow/CollectElementInput.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package Skyflow
import com.Skyflow.collect.elements.validations.ValidationSet

class CollectElementInput(
internal var table: String? = null,
internal var tableName: String? = null,
internal var column: String? = null,
internal var inputStyles: Styles = Styles(),
internal var labelStyles: Styles = Styles(),
Expand All @@ -23,7 +23,7 @@ class CollectElementInput(
internal lateinit var altText: String

constructor(
table: String? = null,
tableName: String? = null,
column: String? = null,
type: SkyflowElementType,
inputStyles: Styles = Styles(),
Expand All @@ -35,7 +35,7 @@ class CollectElementInput(
validations: ValidationSet = ValidationSet(),
skyflowId: String? = null
) : this(
table,
tableName,
column,
inputStyles,
labelStyles,
Expand Down
4 changes: 2 additions & 2 deletions Skyflow/src/main/kotlin/Skyflow/Element.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ open class Element @JvmOverloads constructor(
this.collectInput = collectInput
this.options = options
this.fieldType = this.collectInput.type
if(!this.collectInput.table.equals(null))
tableName = this.collectInput.table!!
if(!this.collectInput.tableName.equals(null))
tableName = this.collectInput.tableName!!
if(!this.collectInput.column.equals(null))
columnName = this.collectInput.column!!
isRequired = this.options.required
Expand Down
2 changes: 1 addition & 1 deletion Skyflow/src/main/kotlin/Skyflow/TextField.kt
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ class TextField @JvmOverloads constructor(
}

fun update(updateCollectInput: CollectElementInput) {
this.collectInput.table = updateCollectInput.table
this.collectInput.tableName = updateCollectInput.tableName
this.collectInput.column = updateCollectInput.column
this.collectInput.label = updateCollectInput.label
this.collectInput.placeholder = updateCollectInput.placeholder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ internal class FlowDBCollectAPICallback(
val callback: Skyflow.Callback,
private val options: CollectOptions,
val logLevel: LogLevel,
private val endpoint: String = "insert"
private val endpoint: String = "insert",
private val cvvMap: CVVMap = CVVMap.EMPTY
) : Skyflow.Callback {
private val okHttpClient = apiClient.okHttpClient
private val tag = FlowDBCollectAPICallback::class.qualifiedName
Expand Down Expand Up @@ -128,6 +129,10 @@ internal class FlowDBCollectAPICallback(
}
}

// Swap real CVV tokens for mock placeholders before returning to the app. The entered
// value still went to the vault unchanged; only the token in the response is replaced.
replaceCVVTokensInRecord(fieldsObject, tableName, skyflowId, cvvMap)

val resultRecord = JSONObject()
.put("tableName", tableName)
.put("skyflowId", skyflowId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ internal class FlowDBCollectRequestBody {
// Merge additionalFields insert records into tableMap
options.additionalFields?.records?.forEach { rec ->
val existing = tableMap.getOrPut(rec.tableName) { mutableListOf() }
rec.data.forEach { (k, v) -> existing.add(CollectRequestRecord(k, v.toString())) }
rec.data.forEach { (k, v) -> existing.add(CollectRequestRecord(k, anyToJsonValue(v))) }
}

val recordsArray = JSONArray()
Expand Down Expand Up @@ -109,6 +109,15 @@ internal class FlowDBCollectRequestBody {
return tableMap
}

private fun anyToJsonValue(v: Any?): Any {
if (v is Map<*, *>) {
val obj = JSONObject()
v.forEach { (mk, mv) -> obj.put(mk.toString(), anyToJsonValue(mv)) }
return obj
}
return v ?: JSONObject.NULL
}

private fun createJSONKey(obj: JSONObject, columnName: String, value: Any) {
val keys = columnName.split(".").toTypedArray()
if (obj.has(keys[0])) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ internal class FlowDBMixedAPICallback(
private val insertBody: JSONObject?,
private val finalCallback: Callback,
private val options: CollectOptions,
val logLevel: LogLevel
val logLevel: LogLevel,
private val cvvMap: CVVMap = CVVMap.EMPTY
) : Callback {

private val totalCalls = (if (updateBody != null) 1 else 0) + (if (insertBody != null) 1 else 0)
Expand All @@ -23,11 +24,11 @@ internal class FlowDBMixedAPICallback(
override fun onSuccess(responseBody: Any) {
val token = responseBody.toString()
updateBody?.let { body ->
FlowDBCollectAPICallback(apiClient, body, makeSubCallback(), options, logLevel, "update")
FlowDBCollectAPICallback(apiClient, body, makeSubCallback(), options, logLevel, "update", cvvMap)
.onSuccess(token)
}
insertBody?.let { body ->
FlowDBCollectAPICallback(apiClient, body, makeSubCallback(), options, logLevel, "insert")
FlowDBCollectAPICallback(apiClient, body, makeSubCallback(), options, logLevel, "insert", cvvMap)
.onSuccess(token)
}
}
Expand Down
126 changes: 126 additions & 0 deletions Skyflow/src/main/kotlin/Skyflow/collect/client/MockCVV.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package Skyflow.collect.client

import Skyflow.SkyflowElementType
import Skyflow.TextField
import org.json.JSONObject
import java.security.SecureRandom

/**
* Captures the actual value entered into each CVV collect element so its token can be swapped
* for a mock placeholder in the response, without the real value ever reaching the app.
*
* Because Android collect elements are in-process objects that directly know their own element
* type and entered value, no identifier plumbing is needed: we read [TextField.fieldType] and
* [TextField.getValue] at request-assembly time and key the entered value by table name (inserts)
* or record id / skyflowID (updates), mirroring how the vault echoes records back.
*/
internal class CVVMap(
val byTable: Map<String, Map<String, String>>,
val byRecordId: Map<String, Map<String, String>>
) {
fun isEmpty(): Boolean = byTable.isEmpty() && byRecordId.isEmpty()

companion object {
val EMPTY = CVVMap(emptyMap(), emptyMap())

/**
* Builds the map from a set of collect elements. Update elements carry their own skyflowID
* (they are the ones filtered by a non-empty skyflowId) and are keyed by record id; insert
* elements are keyed by table name.
*/
internal fun capture(elements: List<TextField>): CVVMap {
val byTable = LinkedHashMap<String, MutableMap<String, String>>()
val byRecordId = LinkedHashMap<String, MutableMap<String, String>>()
for (element in elements) {
if (element.fieldType != SkyflowElementType.CVV) continue
val value = element.getValue()
val skyflowId = element.skyflowId
if (!skyflowId.isNullOrEmpty()) {
byRecordId.getOrPut(skyflowId) { LinkedHashMap() }[element.columnName] = value
} else {
byTable.getOrPut(element.tableName) { LinkedHashMap() }[element.columnName] = value
}
}
return CVVMap(byTable, byRecordId)
}

/**
* Builds the map for the standalone update flow, where the skyflowID is supplied by the
* caller rather than carried on the elements. All CVV elements are keyed by that record id.
*/
internal fun captureForUpdate(elements: List<TextField>, skyflowId: String): CVVMap {
val columns = LinkedHashMap<String, String>()
for (element in elements) {
if (element.fieldType != SkyflowElementType.CVV) continue
columns[element.columnName] = element.getValue()
}
return if (columns.isEmpty()) EMPTY else CVVMap(emptyMap(), mapOf(skyflowId to columns))
}
}
}

private val secureRandom = SecureRandom()

/**
* Generates a numeric mock CVV placeholder of [length] digits that is guaranteed to differ from
* [actualValue]. Leading zeros are allowed because this is a display string, not a number. Each
* attempt is a single secure-random draw (digit = nextInt(10) per position); it regenerates on the
* rare collision. It runs a handful of times per submit, so speed is not a concern.
*/
internal fun generateMockCVV(length: Int, actualValue: String): String {
if (length <= 0) return ""
while (true) {
val builder = StringBuilder(length)
for (i in 0 until length) {
builder.append(secureRandom.nextInt(10))
}
val candidate = builder.toString()
if (candidate != actualValue) return candidate
}
}

/**
* Replaces the token value of every captured CVV column in [tokens] with a freshly generated mock
* placeholder that matches the entered length and never equals that element's own entered value.
*
* tokens is keyed only by the TOP-LEVEL column name. Nested sub-fields appear as separate entries
* in that column's list, each carrying a dotted "path" field. The replacement rule:
* - Flat column (no dot in column name): replace entries that have NO "path" field.
* - Nested column (e.g. "address.city.street"): split at first dot → topKey="address",
* nestedPath="city.street"; replace ONLY the entry whose "path" is EXACTLY "city.street".
* Exact equality prevents "city" from matching "city.street" or "city.ward".
*
* One mock is generated per column (same value applied to all matching entries). Updates are matched
* by record id first, then inserts by table name. Non-CVV columns and hashed data are untouched.
*
* Cross-element collision is intentionally ignored: a mock may coincidentally equal a *different*
* element's entered value, but entered values never leave the device to the app, so there is no
* observable leak. Only the per-element guarantee (mock != that element's own entered value) matters.
*/
internal fun replaceCVVTokensInRecord(
tokens: JSONObject,
tableName: String,
skyflowId: String,
cvvMap: CVVMap
) {
if (cvvMap.isEmpty()) return
val columns = cvvMap.byRecordId[skyflowId]
?: (if (tableName.isNotEmpty()) cvvMap.byTable[tableName] else null)
?: return
for ((column, enteredValue) in columns) {
val dotIndex = column.indexOf('.')
val topKey = if (dotIndex == -1) column else column.substring(0, dotIndex)
val nestedPath = if (dotIndex == -1) null else column.substring(dotIndex + 1)

val entries = tokens.optJSONArray(topKey) ?: continue
val mock = if (enteredValue.isEmpty()) "" else generateMockCVV(enteredValue.length, enteredValue)
for (i in 0 until entries.length()) {
val entry = entries.optJSONObject(i) ?: continue
val entryPath = if (entry.has("path")) entry.optString("path") else null
val matches = if (nestedPath == null) entryPath == null else entryPath == nestedPath
if (matches && entry.has("token")) {
entry.put("token", mock)
}
}
}
}
Loading
Loading