diff --git a/.github/workflows/beta_release.yml b/.github/workflows/beta_release.yml index cc40ca6b..a4fe3848 100644 --- a/.github/workflows/beta_release.yml +++ b/.github/workflows/beta_release.yml @@ -1,8 +1,13 @@ name: Public Beta Release +# One workflow for both SDKs' beta releases. The tag namespace decides the product: +# x.y.z-beta.n -> legacy (skyvault) +# flowvault/x.y.z-beta.n -> flowvault on: push: - tags: "*.*.*-beta.*" + tags: + - '*.*.*-beta.*' + - 'flowvault/*-beta.*' jobs: build-sdk: @@ -24,9 +29,8 @@ jobs: run: | raw=$(git branch -r --contains ${{ github.ref }}) branch=${raw##*/} - beta_branch="beta-release/$branch" - echo "::set-output name=branch::$beta_branch" - echo "Branch is $beta_branch." + echo "branch=beta-release/$branch" >> "$GITHUB_OUTPUT" + echo "Branch is beta-release/$branch." - name: Create local properties file run: | @@ -34,25 +38,37 @@ jobs: echo gpr.user=${{ github.actor }} >> local.properties echo gpr.key=${{ secrets.PAT_ACTIONS }} >> local.properties - - name: Get Previous tag - id: previoustag - uses: WyriHaximus/github-action-get-previous-tag@v1 - with: - fallback: 1.0.0 + - name: Determine product from tag + id: prod + run: | + ref="${GITHUB_REF_NAME}" + if [[ "$ref" == flowvault/* ]]; then + product=flowvault; module=flowvault; version="${ref#flowvault/}"; gradle_file=flowvault/build.gradle; label="FlowVault Public Beta" + else + product=legacy; module=skyvault; version="$ref"; gradle_file=skyvault/build.gradle; label="Public Beta" + fi + { + echo "product=$product" + echo "module=$module" + echo "version=$version" + echo "gradle_file=$gradle_file" + echo "label=$label" + } >> "$GITHUB_OUTPUT" + echo "Beta releasing tag '$ref' as product=$product version=$version" - name: Bump Version run: | chmod +x ./scripts/bump_version.sh - ./scripts/bump_version.sh "${{ steps.previoustag.outputs.tag }}" + ./scripts/bump_version.sh ${{ steps.prod.outputs.product }} "${{ steps.prod.outputs.version }}" - name: Commit changes run: | git config user.name ${{ github.actor }} git config user.email ${{ github.actor }}@users.noreply.gihub.com git branch -f ${{ steps.check_step.outputs.branch }} origin/${{ steps.check_step.outputs.branch }} - git checkout ${{ steps.check_step.outputs.branch }} - git add Skyflow/build.gradle - git commit -m "[AUTOMATED] Public Beta Release ${{ steps.previoustag.outputs.tag }}" + git checkout ${{ steps.check_step.outputs.branch }} + git add ${{ steps.prod.outputs.gradle_file }} + git commit -m "[AUTOMATED] ${{ steps.prod.outputs.label }} Release ${{ steps.prod.outputs.version }}" git push origin - name: Validate Gradle wrapper @@ -60,7 +76,7 @@ jobs: - name: Publish package run: | chmod +x gradlew - ./gradlew publish -Pbeta=true + ./gradlew :${{ steps.prod.outputs.module }}:publish -Pbeta=true env: USERNAME: ${{ github.actor }} TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/internal_release.yml b/.github/workflows/internal_release.yml index 658c2916..dafcbbcf 100644 --- a/.github/workflows/internal_release.yml +++ b/.github/workflows/internal_release.yml @@ -1,19 +1,33 @@ name: Internal Release + +# ONE internal (dev) release for both SDKs from a single `release/*` branch — no separate +# flowvault-release branch, no two PRs. What gets published is decided by the SCOPE of changes +# (relative to main), mirroring the skyflow-js internal workflow: +# common/ or a shared root changed -> publish BOTH skyvault + flowvault +# only skyvault/ changed -> publish skyvault +# only flowvault/ changed -> publish flowvault +# (Public/beta releases stay per-product via their tag namespaces — this scope logic is internal-only.) on: push: tags-ignore: - "*" paths-ignore: - - "Skyflow/build.gradle" + - "skyvault/build.gradle" + - "flowvault/build.gradle" - "*.md" + # Sample apps are not shipped artifacts — a samples-only change must not trigger an internal + # (dev) release. (The scope step also treats samples/ as out-of-scope, so nothing would publish + # even if triggered; this stops the workflow from running at all for samples-only pushes.) + - "samples/**" branches: - release/* jobs: - publish: + internal-release: name: Internal Release runs-on: ubuntu-latest - + # Skip the automated version-bump commit this workflow pushes (belt-and-suspenders with paths-ignore). + if: "!contains(github.event.head_commit.message, '[AUTOMATED] Internal Release')" steps: - uses: actions/checkout@v4 with: @@ -26,13 +40,38 @@ jobs: distribution: 'temurin' java-version: '17' - - name: Get Previous tag - id: previoustag + - name: Detect changed scope + base versions + id: scope run: | - git fetch --tags - tag=$(git describe --tags --abbrev=0 origin/main) - echo "::set-output name=tag::$tag" - echo "latest stable tag is $tag" + # Update origin/main's tracking ref explicitly, then base the merge-base on it. + # Do NOT use FETCH_HEAD here: the `git fetch --tags` below re-points FETCH_HEAD at + # the release branch, so `merge-base FETCH_HEAD HEAD` collapsed to HEAD -> empty diff + # -> both SDKs read as out-of-scope -> publish skipped. + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + git fetch --tags --quiet + # Diff vs where this release branch diverged from main (idempotent across re-pushes). + BASE=$(git merge-base origin/main HEAD) + CHANGED=$(git diff --name-only "$BASE" HEAD) + echo "Merge-base with main: $BASE" + echo "Changed files:"; echo "$CHANGED" + + # Shared roots feed BOTH SDK builds -> treat like common. + SHARED='^(common/|scripts/|build\.gradle|settings\.gradle|gradle/|gradle\.properties|gradlew|\.github/workflows/)' + sky=false; fv=false + if echo "$CHANGED" | grep -qE "$SHARED"; then sky=true; fv=true; fi + if echo "$CHANGED" | grep -qE '^skyvault/'; then sky=true; fi + if echo "$CHANGED" | grep -qE '^flowvault/'; then fv=true; fi + + # Base versions for the -dev suffix (skyvault: latest plain-semver tag on main; flowvault: latest flowvault/x.y.z). + skytag=$(git describe --tags --abbrev=0 --match '[0-9]*.[0-9]*.[0-9]*' origin/main 2>/dev/null || echo "1.27.0") + rawfv=$(git tag --list 'flowvault/[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1) + fvtag="${rawfv#flowvault/}"; [ -z "$fvtag" ] && fvtag="1.0.0" + + { + echo "sky=$sky"; echo "fv=$fv" + echo "skytag=$skytag"; echo "fvtag=$fvtag" + } >> "$GITHUB_OUTPUT" + echo "Resolved -> skyvault=$sky (base $skytag) | flowvault=$fv (base $fvtag)" - name: Cache Gradle and wrapper uses: actions/cache@v4 @@ -42,25 +81,41 @@ jobs: ~/.gradle/wrapper key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} - - name: Bump Version + - name: Bump versions (in scope) + if: steps.scope.outputs.sky == 'true' || steps.scope.outputs.fv == 'true' run: | chmod +x ./scripts/bump_version.sh - ./scripts/bump_version.sh "${{ steps.previoustag.outputs.tag }}" "$(git rev-parse --short "$GITHUB_SHA")" + sha="$(git rev-parse --short "$GITHUB_SHA")" + if [ "${{ steps.scope.outputs.sky }}" = "true" ]; then + ./scripts/bump_version.sh legacy "${{ steps.scope.outputs.skytag }}" "$sha" + fi + if [ "${{ steps.scope.outputs.fv }}" = "true" ]; then + ./scripts/bump_version.sh flowvault "${{ steps.scope.outputs.fvtag }}" "$sha" + fi - - name: Commit changes + - name: Commit version bump(s) + if: steps.scope.outputs.sky == 'true' || steps.scope.outputs.fv == 'true' run: | git config user.name ${{ github.actor }} git config user.email ${{ github.actor }}@users.noreply.gihub.com - git add Skyflow/build.gradle - git commit -m "[AUTOMATED] Private Release ${{ steps.previoustag.outputs.tag }}-dev.$(git rev-parse --short $GITHUB_SHA)" + sha="$(git rev-parse --short "$GITHUB_SHA")" + [ "${{ steps.scope.outputs.sky }}" = "true" ] && git add skyvault/build.gradle + [ "${{ steps.scope.outputs.fv }}" = "true" ] && git add flowvault/build.gradle + git commit -m "[AUTOMATED] Internal Release (skyvault=${{ steps.scope.outputs.sky }}, flowvault=${{ steps.scope.outputs.fv }}) dev.$sha" git push origin - name: Validate Gradle wrapper uses: gradle/wrapper-validation-action@v3 - - name: Publish package + + - name: Publish (in scope) + if: steps.scope.outputs.sky == 'true' || steps.scope.outputs.fv == 'true' run: | chmod +x gradlew - ./gradlew publish -Pdev=true + MODULES="" + [ "${{ steps.scope.outputs.sky }}" = "true" ] && MODULES="$MODULES :skyvault:publish" + [ "${{ steps.scope.outputs.fv }}" = "true" ] && MODULES="$MODULES :flowvault:publish" + echo "Publishing:$MODULES" + ./gradlew $MODULES -Pdev=true env: USERNAME: ${{ github.actor }} TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 849ad2c6..7206b6f0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -27,8 +27,8 @@ jobs: java-version: '17' - name: Grant permissions to gradlew run: chmod +x gradlew - - name: Run Android Linter - run: ./gradlew :Skyflow:lint + - name: Run Android Linter (both SDKs) + run: ./gradlew :skyvault:lint :flowvault:lint build: runs-on: ubuntu-latest @@ -42,8 +42,8 @@ jobs: java-version: '17' - name: Grant permissions to gradlew run: chmod +x gradlew - - name: Build SDK - run: ./gradlew :Skyflow:build - - name: Run Tests + - name: Build both SDKs + run: ./gradlew :skyvault:build :flowvault:build + - name: Run Tests (both SDKs via runOnGitHub aggregate) id: tests - run: ./gradlew :Skyflow:test \ No newline at end of file + run: ./gradlew runOnGitHub \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e47c3f75..7d196b18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,14 @@ name: Public Release +# One workflow for both SDKs. The tag namespace decides which one is released: +# x.y.z -> legacy (skyvault / skyflow-android-sdk) +# flowvault/x.y.z -> flowvault (skyflow-flowvault-android-sdk) on: push: - tags: '[0-9]+.[0-9]+.[0-9]+' - + tags: + - '[0-9]+.[0-9]+.[0-9]+' + - 'flowvault/[0-9]+.[0-9]+.[0-9]+' + jobs: build-sdk: runs-on: ubuntu-latest @@ -20,31 +25,43 @@ jobs: distribution: 'temurin' java-version: '17' - - name: Get Previous tag - id: previoustag - uses: WyriHaximus/github-action-get-previous-tag@v1 - with: - fallback: 1.0.0 + - name: Determine product from tag + id: prod + run: | + ref="${GITHUB_REF_NAME}" + if [[ "$ref" == flowvault/* ]]; then + product=flowvault; module=flowvault; version="${ref#flowvault/}"; gradle_file=flowvault/build.gradle; label="FlowVault Public" + else + product=legacy; module=skyvault; version="$ref"; gradle_file=skyvault/build.gradle; label="Public" + fi + { + echo "product=$product" + echo "module=$module" + echo "version=$version" + echo "gradle_file=$gradle_file" + echo "label=$label" + } >> "$GITHUB_OUTPUT" + echo "Releasing tag '$ref' as product=$product version=$version" - name: Bump Version run: | - chmod +x ./scripts/bump_version.sh - ./scripts/bump_version.sh "${{ steps.previoustag.outputs.tag }}" + chmod +x ./scripts/bump_version.sh + ./scripts/bump_version.sh ${{ steps.prod.outputs.product }} "${{ steps.prod.outputs.version }}" - name: Commit changes run: | - git config user.name ${{ github.actor }} - git config user.email ${{ github.actor }}@users.noreply.gihub.com - git add Skyflow/build.gradle - git commit -m "[AUTOMATED] Public Release ${{ steps.previoustag.outputs.tag }}" - git push origin + git config user.name ${{ github.actor }} + git config user.email ${{ github.actor }}@users.noreply.gihub.com + git add ${{ steps.prod.outputs.gradle_file }} + git commit -m "[AUTOMATED] ${{ steps.prod.outputs.label }} Release ${{ steps.prod.outputs.version }}" + git push origin - name: Validate Gradle wrapper uses: gradle/wrapper-validation-action@v3 - name: Publish package run: | - chmod +x gradlew - ./gradlew publish + chmod +x gradlew + ./gradlew :${{ steps.prod.outputs.module }}:publish env: USERNAME: ${{ github.actor }} TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 8ccc4f86..12ec7937 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ .externalNativeBuild .cxx local.properties + +flowvault-samples/ diff --git a/README.md b/README.md index 8282a994..328b02ee 100644 --- a/README.md +++ b/README.md @@ -4,2722 +4,51 @@ [![GitHub release](https://img.shields.io/github/v/release/skyflowapi/skyflow-android.svg)](https://github.com/skyflowapi/skyflow-android/releases) [![License](https://img.shields.io/github/license/skyflowapi/skyflow-android)](https://github.com/skyflowapi/skyflow-android/blob/main/LICENSE) -Skyflow’s android SDK can be used to securely collect, tokenize, and display sensitive data in the mobile without exposing your front-end infrastructure to sensitive data. +Skyflow's Android SDKs let you securely collect, tokenize, and reveal sensitive data in your Android app without that data touching your front-end infrastructure. This repository publishes **two SDKs from a shared codebase**. Pick the one that matches your vault type: -# Table of Contents -* [Installation](#installation) - * [Requirements](#requirements) - * [Configuration](#configuration) -* [Initializing Skyflow-android](#initializing-skyflow-android) -* [Securely collecting data client-side](#securely-collecting-data-client-side) -* [Securely collecting data client-side using composable elements](#securely-collecting-data-client-side-using-composable-elements) -* [Securely revealing data client-side](#securely-revealing-data-client-side) +| SDK | Vault type | Install (Gradle) | Guide | +|-----|------------|------------------|-------| +| **skyflow-android-sdk** | PDB vault (v1 API) | `com.skyflowapi.android:skyflow-android-sdk:1.27.0` | [skyvault/README.md](skyvault/README.md) | +| **skyflow-flowvault-android-sdk** | Flow vault (v2 API) | `com.skyflowapi.android:skyflow-flowvault-android-sdk:1.0.0` | [flowvault/README.md](flowvault/README.md) | -# Installation +If you are an existing Skyflow Android customer, stay on **skyflow-android-sdk** — it is fully backward compatible. **skyflow-flowvault-android-sdk** is a separate SDK (versioned from `1.0.0`) for Flow vaults, built on the v2 API. Both share the `Skyflow` package, so add whichever matches your vault. ## Requirements + - Android 5.0 (API level 21) and above - compileSdk 35 and above - Android Gradle Plugin 8.6.0 and above -## Configuration -### Step 1: Generate a Personal Access Token for GitHub -- Inside you GitHub account: -- Settings -> Developer Settings -> Personal Access Tokens -> Generate new token -- Make sure you select the following scopes (“read:packages”) and Generate a token -- After Generating make sure to copy your new personal access token. You cannot see it again! The only option is to generate a new key. - -### Step 2: Store your GitHub — Personal Access Token details -- Create a github.properties file within your root Android project -- In case of a public repository make sure you add this file to .gitignore for keep the token private -- Add properties gpr.usr=GITHUB_USER_NAME and gpr.key=PERSONAL_ACCESS_TOKEN -- Replace GITHUB_USER_NAME with personal / organisation Github user NAME and PERSONAL_ACCESS_TOKEN with the token generated in [Step 1](#step-1-generate-a-personal-access-token-for-github) - -Alternatively you can also add the GPR_USER_NAME and GPR_PAT values to your environment variables on you local machine or build server to avoid creating a github properties file - -### Step 3: Adding the dependency to the project - -#### Using gradle - -- Add the Github package registry to your root project build.gradle file - - ```java - def githubProperties = new Properties() - githubProperties.load(new FileInputStream(file(“github.properties”))) - allprojects { - repositories { - ... - maven { - url "https://maven.pkg.github.com/skyflowapi/skyflow-android-sdk" - credentials { - username = githubProperties['gpr.usr'] ?: System.getenv("GPR_USER_NAME") - password = githubProperties['gpr.key'] ?: System.getenv("GPR_PAT") - } - } - } - ... - } - ``` - -- Add the dependency to your application's build.gradle file - - ```java - implementation 'com.skyflowapi.android:skyflow-android-sdk:1.27.0' - ``` - -#### Using maven -- Add the Github package registry in the repositories tag and the GITHUB_USER_NAME, PERSONAL_ACCESS_TOKEN collected from [Step1](#step-1-generate-a-personal-access-token-for-github) in the server tag to your project's settings.xml file. Make sure that the id's for both these tags are the same. - -```xml - - - github - https://maven.pkg.github.com/skyflowapi/skyflow-android-sdk - - - - - - github - GITHUB_USER_NAME - PERSONAL_ACCESS_TOKEN - - - ``` - -- Add the package dependencies to the dependencies element of your project pom.xml file -```xml - - com.skyflowapi.android - skyflow-android-sdk - 1.27.0 - -``` - - -# Initializing skyflow-android ----- -Use the ```init()``` method to initialize a Skyflow client as shown below. -```kt -val demoTokenProvider = DemoTokenProvider() /*DemoTokenProvider is an implementation of -the Skyflow.TokenProvider interface*/ - -val config = Skyflow.Configuration( - vaultID = , - vaultURL = , - tokenProvider = demoTokenProvider, - options: Skyflow.Options( - logLevel : Skyflow.LogLevel, // optional, if not specified loglevel is ERROR. - env: SKyflow.Env //optiuona, if not specified env is PROD. - ) -) - -val skyflowClient = Skyflow.init(config) -``` -For the tokenProvider parameter, pass in an implementation of the Skyflow.TokenProvider interface that declares a getAccessToken method which retrieves a Skyflow bearer token from your backend. This function will be invoked when the SDK needs to insert or retrieve data from the vault. - -For example, if the response of the consumer tokenAPI is in the below format - -``` -{ - "accessToken": string, - "tokenType": string -} -``` - -then, your Skyflow.TokenProvider Implementation should be as below - - -```kt -class DemoTokenProvider: Skyflow.TokenProvider { - override fun getBearerToken(callback: Callback) { - val url = "http://10.0.2.2:8000/js/analystToken" - val request = okhttp3.Request.Builder().url(url).build() - val okHttpClient = OkHttpClient() - try { - val thread = Thread { - run { - okHttpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) - throw IOException("Unexpected code $response") - val accessTokenObject = JSONObject( - response.body()!!.string().toString() - ) - val accessToken = accessTokenObject["accessToken"] - callback.onSuccess("$accessToken") - } - } - } - thread.start() - }catch (exception:Exception){ - callback.onFailure(exception) - } - } -} -``` - -NOTE: You should pass access token as `String` value in the success callback of getBearerToken. - -For `logLevel` parameter, there are 4 accepted values in Skyflow.LogLevel - -- `DEBUG` - - When `Skyflow.LogLevel.DEBUG` is passed, all level of logs will be printed(DEBUG, INFO, WARN, ERROR). - -- `INFO` - - When `Skyflow.LogLevel.INFO` is passed, INFO logs for every event that has occurred during the SDK flow execution will be printed along with WARN and ERROR logs. - - -- `WARN` - - When `Skyflow.LogLevel.WARN` is passed, WARN and ERROR logs will be printed. - -- `ERROR` - - When `Skyflow.LogLevel.ERROR` is passed, only ERROR logs will be printed. - -`Note`: - - The ranking of logging levels is as follows : DEBUG < INFO < WARN < ERROR - - since `logLevel` is optional, by default the logLevel will be `ERROR`. - - - -For `env` parameter, there are 2 accepted values in Skyflow.Env - -- `PROD` -- `DEV` - - In [Event Listeners](#event-listener-on-collect-elements), actual value of element can only be accessed inside the handler when the `env` is set to `DEV`. - -`Note`: - - since `env` is optional, by default the env will be `PROD`. - - Use `env` option with caution, make sure the env is set to `PROD` when using `skyflow-android` in production. - - - ---- -# Securely collecting data client-side -- [**Inserting data into the vault**](#inserting-data-into-the-vault) -- [**Using Skyflow Elements to collect data**](#using-skyflow-elements-to-collect-data) -- [**Using Skyflow Elements to update data**](#using-skyflow-elements-to-update-data) -- [**Event Listener on Collect Elements**](#event-listener-on-collect-elements) -- [**UI Error for Collect Elements**](#ui-error-for-collect-elements) -- [**Set and Clear value for Collect Elements (DEV ENV ONLY)**](#set-and-clear-value-for-collect-elements-dev-env-only) - - -## Inserting data into the vault - -To insert data into the vault from the integrated application, use the ```insert(records: JSONObject, options: InsertOptions?= InsertOptions() , callback: Skyflow.Callback)``` method of the Skyflow client. The records parameter takes a JSON object of the records to be inserted in the below format. The options parameter takes a object of optional parameters for the insertion. `insert` method also support upsert operations. See below: - -```json5 -{ - "records": [ - { - table: "string", //table into which record should be inserted - fields: { - column1: "value", //column names should match vault column names - ///... additional fields - } - }, - ///...additional records - ] -} -``` - -An example of an insert call is given below: - -```kt -//Upsert options -val upsertArray = JSONArray() -val upsertColumn = JSONObject() -upsertColumn.put("table", "cards") -upsertColumn.put("column", "card_number") -upsertArray.put(upsertColumn) -val insertOptions = Skyflow.InsertOptions(tokens= false,upsert= upsertArray) /*indicates whether or not tokens should be returned for the inserted data. Defaults to 'true'*/ -val insertCallback = InsertCallback() //Custom callback - implementation of Skyflow.Callback -val records = JSONObject() -val recordsArray = JSONArray() -val record = JSONObject() -record.put("table", "cards") -val fields = JSONObject() -fields.put("expiry_date", "12/2028") -fields.put("cardNumber", "41111111111") -record.put("fields", fields) -recordsArray.put(record) -records.put("records", recordsArray) -skyflowClient.insert(records = records, options = insertOptions, callback = insertCallback); -``` - -**Response :** -```json -{ - "records": [ - { - "table": "cards", - "fields":{ - "cardNumber": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", - "expiry_date": "1989cb56-63da-4482-a2df-1f74cd0dd1a5" - } - } - ] -} -``` - -## Using Skyflow Elements to collect data - -**Skyflow Elements** provide developers with pre-built form elements to securely collect sensitive data client-side. This reduces your PCI compliance scope by not exposing your front-end application to sensitive data. Follow the steps below to securely collect data with Skyflow Elements in your application. - -### Step 1: Create a container - -First create a **container** for the form elements using the ```skyflowClient.container(type: Skyflow.ContainerType)``` method as show below - -```kt -val container = skyflowClient.container(Skyflow.ContainerType.COLLECT) -``` - -### Step 2: Create a collect Element - -To create a collect element, we must first construct `Skyflow.CollectElementInput` object defined as shown below: - -```kt -Skyflow.CollectElementInput( - table : String, //the table this data belongs to - column : String, //the column into which this data should be inserted - type: Skyflow.ElementType //Skyflow.ElementType enum - inputStyles: Skyflow.Styles, //optional styles that should be applied to the form element - labelStyles: Skyflow.Styles, //optional styles that will be applied to the label of the collect element - errorTextStyles: Skyflow.Styles, //optional styles that will be applied to the errorText of the collect element - label: String, //optional label for the form element - placeholder: String, //optional placeholder for the form element - altText: String, //(DEPRECATED) optional string that acts as an initial value for the collect element - validations: ValidationSet // optional set of validations for collect element -) -``` -The `table` and `column` parameters indicate which table and column in the vault the Element corresponds to. -Note: Use dot delimited strings to specify columns nested inside JSON fields (e.g. address.street.line1). - -The `inputStyles` field accepts a Skyflow.Styles object which consists of multiple `Skyflow.Style` objects which should be applied to the form element in the following states: - -- `base`: all other variants inherit from these styles -- `complete`: applied when the Element has valid input -- `empty`: applied when the Element has no input -- `focus`: applied when the Element has focus -- `invalid`: applied when the Element has invalid input - -Each Style object accepts the following properties, please note that each property is optional: - -```kotlin -Skyflow.Style( - borderColor: Int // optional - cornerRadius: Float // optional - padding: Skyflow.Padding // optional - borderWidth: Int // optional - font: Int // optional - textAlignment: Int // optional - textColor: Int // optional - placeholderColor: Int // optional - width: Int // optional - height: Int // optional - margin: Skyflow.Margin // optional - backgroundColor: Int // optional - minWidth: Int // optional - maxWidth: Int // optional - minHeight: Int // optional - maxHeight: Int // optional -) -``` -Here `Skyflow.Padding` and `Skyflow.Margin` are classes which can be used to set the padding and margin respectively for the composable element which takes all the left, top, right, bottom values. - -```kt -Skyflow.Padding(left: Int, top: Int, right: Int, bottom: Int) - -Skyflow.Margin(left: Int, top: Int, right: Int, bottom: Int) -``` - -An example Skyflow.Styles object -```kotlin -val inputStyles = Skyflow.Styles( - base = Skyflow.Style(), // optional - complete = Skyflow.Style(), // optional - empty = Skyflow.Style(), // optional - focus = Skyflow.Style(), // optional - invalid = Skyflow.Style(), // optional - requiredAsterisk = Skyflow.Style() // optional -) -``` - -The `labelStyles` and `errorTextStyles` fields accept the above mentioned `Skyflow.Styles` object which are applied to the `label` and `errorText` text views respectively. - -The states that are available for `labelStyles` are `base`, `focus` and `requiredAsterisk`. - -`requiredAsterisk`: Styles applied for the Asterisk symbol in the label. Defaults to `red`. - -The state that is available for `errorTextStyles` is only the `base` state, it shows up when there is some error in the collect element. - -The parameters in `Skyflow.Style` object that are respected for `label` and `errorText` text views are -- padding -- font -- textColor -- textAlignment -- width -- height -- margin -- minWidth -- maxWidth -- minHeight -- maxHeight - -Other parameters in the `Skyflow.Style` object are ignored for `label` and `errorText` text views. - -Finally, the `type` field takes a Skyflow ElementType. Each type applies the appropriate regex and validations to the form element. There are currently 5 types: -- `INPUT_FIELD` -- `CARDHOLDER_NAME` -- `CARD_NUMBER` -- `EXPIRATION_DATE` -- `EXPIRATION_MONTH` -- `EXPIRATION_YEAR` -- `CVV` -- `PIN` - -The `INPUT_FIELD` type is a custom UI element without any built-in validations. See the section on [`validations`](#validations) for more information on validations. - -Along with `CollectElementInput` you can define other options in the `CollectElementOptions` object which is described below. - -```kotlin -Skyflow.CollectElementOptions( - required: Boolean, // Indicates whether the field is marked as required. Defaults to 'false' - enableCardIcon: Boolean, // Indicates whether card icon should be enabled (only for CARD_NUMBER inputs) - format: String, // Format for the element (currently applicable for "EXPIRATION_DATE", "CARD_NUMBER", "EXPIRATION_YEAR" and "INPUT_FIELD") - translation: HashMap // Indicates the allowed data type value for format. - enableCopy: Boolean, // Indicates whether to enable the copy icon in collect elements to copy text to clipboard. Defaults to 'false' - cardMetadata: Skyflow.CardMetadata, // Optional, metadata to control card number element behavior. (only applicable for CARD_NUMBER ElementType). -) -``` - -- `required`: Indicates whether the field is marked as required or not. Default is `false`. - -- `enableCardIcon`: Indicates whether the icon is visible for the CARD_NUMBER element. Default is `true`. - -- `format`: A string value that indicates the format pattern applicable to the element type. Only applicable to `EXPIRATION_DATE`, `CARD_NUMBER`, `EXPIRATION_YEAR` and `INPUT_FIELD` elements. - - For `INPUT_FIELD` elements, - - the length of `format` determines the expected length of the user input. - - if `translation` isn't specified, the `format` value is considered a string literal. - -- `translation`: A hashmap of key/value pairs, where the key is a character that appears in `format` and the value is a regex pattern of acceptable inputs for that character. Each key can only appear once. Only applicable for `INPUT_FIELD` elements. - -- `enableCopy`: Indicates whether to enable the copy icon in collect elements to copy text to clipboard. - -- `cardMetadata`: An object of metadata keys to control card number element behavior. It supports an optional key called `scheme`, which accepts an array of Skyflow-supported card types and determines which brands display in the card number element's card brand choice dropdown. `Skyflow.CardType` is an enum with all Skyflow-supported card schemes. - -```kotlin -class CardMetadata(var scheme: Array) {} -``` - -#### Supported card types by Skyflow.CardType : -- `VISA` -- `MASTERCARD` -- `AMEX` -- `DINERS_CLUB` -- `DISCOVER` -- `JCB` -- `MAESTRO` -- `UNIONPAY` -- `HIPERCARD` -- `CARTES_BANCAIRES` - -Accepted values by element type: - -| Element type | `format` | `translation` | Examples | -| --------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| EXPIRATION_DATE |
  • `mm/yy`(default)
  • `mm/yyyy`
  • `yy/mm`
  • `yyyy/mm`
| N/A |
  • 12/27
  • 12/2027
  • 27/12
  • 2027/12
| -| EXPIRATION_YEAR |
  • `yy`(default)
  • `yyyy`
  • | N/A |
    • 27
    • 2027
    | -| CARD_NUMBER |
    • `XXXX XXXX XXXX XXXX` (default)
    • `XXXX-XXXX-XXXX-XXXX`
    | N/A |
    • 1234 5678 9012 3456
    • 1234-5678-9012-3456
    | -| INPUT_FIELD | A string that matches the desired output, with placeholder characters of your choice. | A hashmap of key/value pairs. Defaults to `hashmapOf('X' to "[0-9]")` | With `format: +91 XXXX-XX-XXXX` and `translation: hashmapOf('X' to "[0-9]")`, user input of "1234121234" displays as "+91 1234-12-1234". | - -Collect Element Options examples for INPUT_FIELD - -Example 1 -```kotlin -Skyflow.CollectElementOptions( - required: true, - enableCardIcon: true, - format: "+91 XXXX-XX-XXXX", - translation: hashmapOf('X' to "[0-9]") -) -``` -User input: "1234121234" - -Value displayed in INPUT_FIELD: "+91 1234-12-1234" - -Example 2 -```kotlin -Skyflow.CollectElementOptions( - required: true, - enableCardIcon: true, - format: "AY XX-XXX-XXXX", - translation: hashmapOf('X' to "[0-9]", 'Y' to "[A-Z]") -) -``` -User input: "B1234121234" - -Value displayed in INPUT_FIELD: "AB 12-341-2123" - -Once the `Skyflow.CollectElementInput` and `Skyflow.CollectElementOptions` objects are defined, add to the container using the ```create(context:Context,input: CollectElementInput, options: CollectElementOptions)``` method as shown below. The `input` param takes a `Skyflow.CollectElementInput` object as defined above and the `options` parameter takes a `Skyflow.CollectElementOptions`, -the `context` param takes android `Context` object as described below: - -```kotlin -val collectElementInput = Skyflow.CollectElementInput( - table = "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*/ - labelStyles = Skyflow.Styles(), //optional styles that will be applied to the label of the collect element - errorTextStyles = Skyflow.Styles(), //optional styles that will be applied to the errorText of the collect element - label = "string", //optional label for the form element - placeholder = "string", //optional placeholder for the form element - altText: String, //(DEPRECATED) optional string that acts as an initial value for the collect element - validations = ValidationSet() // optional set of validations for the input element -) - -val collectElementOptions = Skyflow.CollectElementOptions( - required = false, //indicates whether the field is marked as required. Defaults to 'false' - enableCardIcon = true //indicates whether card icon should be enabled (only for CARD_NUMBER inputs) - format = "mm/yy" //Format for the element (only applies currently for EXPIRATION_DATE element type) -) - -const element = container.create(context = Context, collectElementInput, collectElementOptions) -``` - - - -### Step 3: Add Elements to the layout - -To specify where the Elements will be rendered on the screen, set layout params to the view and add it to a layout in your app programmatically. - -```kt -val layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT -) -element.layoutParams = layoutParams -existingLayout.addView(element) -``` - -The Skyflow Element is an implementation of native android View so it can be used/mounted similarly.Alternatively, you can use the `unmount` method to reset any element to it's initial state. - -```kt -fun clearFields(elements: List) { - - //resets all elements to initial value - for element in elements { - element.unmount() - } -} -``` - - -### Step 4 : Collect data from Elements -When the form is ready to be submitted, call the collect(options: Skyflow.CollectOptions? = nil, callback: Skyflow.Callback) method on the container object. The options parameter takes `Skyflow.CollectOptions` object. - -`Skyflow.CollectOptions` takes two optional fields -- `tokens`: indicates whether tokens for the collected data should be returned or not. Defaults to 'true' -- `additionalFields`: Non-PCI elements data to be inserted into the vault which should be in the `records` object format as described in the above [Inserting data into vault](#Inserting-data-into-the-vault) section. - -```kt -// NON-PCI fields object creation -val nonPCIRecords = JSONObject() -val recordsArray = JSONArray() -val record = JSONObject() -record.put("table", "persons") -val fields = JSONObject() -fields.put("gender", "MALE") -record.put("fields", fields) -recordsArray.put(record) -nonPCIRecords.put("records", recordsArray) - -val options = Skyflow.CollectOptions(tokens = true, additonalFields = nonPCIRecords) -val insertCallback = InsertCallback() //Custom callback - implementation of Skyflow.callback -container.collect(options, insertCallback) -``` -### End to end example of collecting data with Skyflow Elements - -#### [Sample Code](https://github.com/skyflowapi/skyflow-android/blob/main/samples/src/main/java/com/Skyflow/CollectActivity.kt): -```kt -//Initialize skyflow configuration -val config = Skyflow.Configuration(vaultId = VAULT_ID, vaultURL = VAULT_URL, tokenProvider = demoTokenProvider) - -//Initialize skyflow client -val skyflowClient = Skyflow.initialize(config) - -//Create a CollectContainer -val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT) - -//Initialize and set required options -val options = Skyflow.CollectElementOptions(required = true) - -//Create Skyflow.Styles with individual Skyflow.Style variants -val baseStyle = Skyflow.Style(borderColor = Color.BLUE) -val baseTextStyle = Skyflow.Style(textColor = Color.BLACK) -val completedStyle = Skyflow.Style(textColor = Color.GREEN) -val focusTextStyle = Skyflow.Style(textColor = Color.RED) -val inputStyles = Skyflow.Styles(base = baseStyle, complete = completedStyle) -val labelStyles = Skyflow.Styles(base = baseTextStyle, focus = focusTextStyle) -val errorTextStyles = Skyflow.Styles(base = baseTextStyle) - -//Create a CollectElementInput -val input = Skyflow.CollectElementInput( - table = "cards", - column = "cardNumber", - type = Skyflow.ElementType.CARD_NUMBER - inputStyles = inputStyles, - labelStyles = labelStyles, - errorTextStyles = errorTextStyles, - label = "card number", - placeholder = "card number", -) - -//Create a CollectElementOptions instance -val options = Skyflow.CollectElementOptions(required = true) - -//Create a Collect Element from the Collect Container -val skyflowElement = container.create(context = Context,input, options) - -//Can interact with this object as a normal UIView Object and add to View - -// Non-PCI fields data -val nonPCIRecords = JSONObject() -val recordsArray = JSONArray() -val record = JSONObject() -record.put("table", "persons") -val fields = JSONObject() -fields.put("gender", "MALE") -record.put("fields", fields) -recordsArray.put(record) -nonPCIRecords.put("records", recordsArray) - -//Initialize and set required options for insertion -val collectOptions = Skyflow.CollectOptions(tokens = true, additionalFields = nonPCIRecords) - -//Implement a custom Skyflow.Callback to be called on Insertion success/failure -public class InsertCallback: Skyflow.Callback { - override fun onSuccess(responseBody: Any) { - print(responseBody) - } - override fun onFailure(_ error: Error) { - print(error) - } -} - -//Initialize InsertCallback which is an implementation of Skyflow.Callback interface -val insertCallback = InsertCallback() - -//Call collect method on CollectContainer -container.collect(options = collectOptions, callback = insertCallback) - -``` -#### Sample Response : -``` -{ - "records": [ - { - "table": "cards", - "fields": { - "cardNumber": "f3907186-e7e2-466f-91e5-48e12c2bcbc1" - } - }, - { - "table": "persons", - "fields": { - "gender": "12f670af-6c7d-4837-83fb-30365fbc0b1e", - } - } - ] -} - -``` - -### End to end example of upsert support with Skyflow Elements - -#### [Sample Code](https://github.com/skyflowapi/skyflow-android/blob/main/samples/src/main/java/com/Skyflow/UpsertFeature.kt): -```kt -val config = Skyflow.Configuration(vaultId = VAULT_ID, vaultURL = VAULT_URL, tokenProvider = demoTokenProvider) -val skyflowClient = Skyflow.initialize(config) -val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT) -val options = Skyflow.CollectElementOptions(required = true) -val baseStyle = Skyflow.Style(borderColor = Color.BLUE) -val baseTextStyle = Skyflow.Style(textColor = Color.BLACK) -val completedStyle = Skyflow.Style(textColor = Color.GREEN) -val focusTextStyle = Skyflow.Style(textColor = Color.RED) -val inputStyles = Skyflow.Styles(base = baseStyle, complete = completedStyle) -val labelStyles = Skyflow.Styles(base = baseTextStyle, focus = focusTextStyle) -val errorTextStyles = Skyflow.Styles(base = baseTextStyle) - -val cardNumberInput = Skyflow.CollectElementInput( - table = "cards", - column = "card_number", - type = Skyflow.ElementType.CARD_NUMBER - inputStyles = inputStyles, - labelStyles = labelStyles, - errorTextStyles = errorTextStyles, - label = "Card number", - placeholder = "enter your card number", -) - -val nameInput = Skyflow.CollectElementInput( - table = "cards", - column = "full_name", - type = Skyflow.ElementType.CARD_NUMBER - inputStyles = inputStyles, - labelStyles = labelStyles, - errorTextStyles = errorTextStyles, - label = "Full name", - placeholder = "enter your name", -) -val cardNumberElement = container.create(context = Context,cardNumberInput, options) -val nameElement = container.create(context = Context,namerInput, options) - -//Upsert options -val upsertArray = JSONArray() -val upsertColumn = JSONObject() -upsertColumn.put("table", "cards") -upsertColumn.put("column", "card_number") -upsertArray.put(upsertColumn) - -val collectOptions = Skyflow.CollectOptions(tokens = true,upsert = upsertArray) - -public class InsertCallback: Skyflow.Callback { - override fun onSuccess(responseBody: Any) { - print(responseBody) - } - override fun onFailure(_ error: Error) { - print(error) - } -} - -val insertCallback = InsertCallback() -container.collect(options = collectOptions, callback = insertCallback) - -``` -#### Sample Response : -``` -{ - "records": [ - { - "table": "cards", - "fields": { - "card_number": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", - "name": "f3907186-e7e2-464f-91e5-48e12c2bfsi9" - } - } - ] -} - -``` - -## Using Skyflow Elements to update data - -You can update data in a vault using Skyflow Elements. Use the following steps to securely update data. - -### Step 1: Create a container - -First create a **container** for the form elements using the ```skyflowClient.container(type: Skyflow.ContainerType)``` method as shown below: - -```kt -val container = skyflowClient.container(Skyflow.ContainerType.COLLECT) -``` - -### Step 2: Create a collect Element - -To create a collect Element, construct a `Skyflow.CollectElementInput` object as shown below: - -```kt -val collectElementInput = Skyflow.CollectElementInput( - table: String, // optional, the table this data belongs to - column: String, // optional, the column into which this data should be inserted - type: Skyflow.ElementType, // Skyflow.ElementType enum - inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element - labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element - errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element - label: String, // optional label for the form element - placeholder: String, // optional placeholder for the form element - altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element - validations: ValidationSet, // optional set of validations for the input element - skyflowID: String // The skyflow_id of the record to be updated -) -``` - -The `table` and `column` fields indicate which table and column in the vault the Element corresponds to. - -**Note:** -- Use dot delimited strings to specify columns nested inside JSON fields (e.g. `address.street.line1`) -- `table` and `column` are optional only if the element is being used in invokeConnection() - -Along with `CollectElementInput`, you can define other options in the `CollectElementOptions` object as described in the [collect section](#step-2-create-a-collect-element). - -### Step 3: Mount Elements to the Screen - -To specify where the Elements will be rendered on the screen, create a parent UIView (like LinearLayout, etc.) and add it programmatically. - -```kt -val parent = findViewById(R.id.parent) -val lp = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT -) -parent.addView(element) -``` - -The Skyflow Element is an implementation of the View so it can be used/mounted similarly. Alternatively, you can use the `unmount` method to reset any collect element to its initial state. - -```kt -fun clearFieldsOnSubmit(elements: List) { - // resets all elements in the array - for (element in elements) { - element.unmount() - } -} -``` - -### Step 4: Update data from Elements - -When the form is ready to submit, call the `collect(options?)` method on the container object. The `options` parameter takes an object of optional parameters as shown below: - -- `tokens`: indicates whether tokens for the collected data should be returned or not. Defaults to 'true' -- `additionalFields`: Non-PCI elements data to update or insert into the vault which should be in the records object format. -- `upsert`: To support upsert operations while collecting data from Skyflow elements, pass the table and column marked as unique in the table. - -```kt -// Non-PCI records with skyflowID for update -val nonPCIRecords = JSONObject().apply { - val recordsArray = JSONArray() - val record = JSONObject().apply { - put("table", "persons") - val fields = JSONObject().apply { - put("gender", "MALE") - put("skyflow_id", "") // skyflowID for update - } - put("fields", fields) - } - recordsArray.put(record) - put("records", recordsArray) -} - -// Upsert options -val upsertArray = JSONArray() -val upsertColumn = JSONObject().apply { - put("table", "cards") - put("column", "card_number") -} -upsertArray.put(upsertColumn) - -// Send the non-PCI records as additionalFields of CollectOptions (optional) -// and apply upsert using `upsert` field of CollectOptions (optional) -val options = CollectOptions(tokens = true, additionalFields = nonPCIRecords, upsert = upsertArray) - -// Custom callback - implementation of Skyflow.Callback -val insertCallback = InsertCallback() -container.collect(callback = insertCallback, options = options) -``` - -**Note:** `skyflowID` is required if you want to update the data. If `skyflowID` isn't specified, the `collect(options?)` method creates a new record in the vault. - -### End to end example of updating data with Skyflow Elements - -```kt -// Initialize skyflow configuration -val tokenProvider = DemoTokenProvider() -val config = Configuration( - vaultID = "", - vaultURL = "", - tokenProvider = tokenProvider -) - -// Initialize skyflow client -val skyflowClient = init(config) +## Installation -// Create a CollectContainer -val container = skyflowClient.container(ContainerType.COLLECT) +Both SDKs are published to GitHub Packages. First configure your GitHub Personal Access Token as described in the [skyflow-android-sdk](skyvault/README.md#configuration) or [skyflow-flowvault-android-sdk](flowvault/README.md#configuration) guide, then add the dependency for your vault type: -// Create Skyflow.Styles with individual Skyflow.Style variants -val padding = Padding(8, 8, 8, 8) -val baseStyle = Style(borderColor = Color.BLUE) -val baseTextStyle = Style(textColor = Color.BLACK) -val completeStyle = Style(borderColor = Color.GREEN) -val focusTextStyle = Style(textColor = Color.RED) -val inputStyles = Styles(base = baseStyle, complete = completeStyle) -val labelStyles = Styles(base = baseTextStyle, focus = focusTextStyle) -val errorTextStyles = Styles(base = baseTextStyle) +**PDB vault (v1 API)** -// Create a CollectElementInput with skyflowID for update -val input = CollectElementInput( - table = "cards", - column = "card_number", - type = SkyflowElementType.CARD_NUMBER, - inputStyles = inputStyles, - labelStyles = labelStyles, - errorTextStyles = errorTextStyles, - label = "Card Number", - placeholder = "XXXX XXXX XXXX XXXX", - skyflowID = "431eaa6c-5c15-4513-aa15-29f50babe882" // skyflowID for update -) - -// Create an option to require the element -val requiredOption = CollectElementOptions(required = true, enableCopy = true) - -// Create a Collect Element from the Collect Container -val skyflowElement = container.create(context = this, input = input, options = requiredOption) - -// Can interact with this object as a normal View Object and add to View -val parent = findViewById(R.id.parent) -parent.addView(skyflowElement) - -// Non-PCI records with skyflowID for update -val nonPCIRecords = JSONObject().apply { - val recordsArray = JSONArray() - // Update existing person record - val personRecord = JSONObject().apply { - put("table", "persons") - val fields = JSONObject().apply { - put("gender", "MALE") - put("skyflowID", "77dc3caf-c452-49e1-8625-07219d7567bf") // skyflowID for update - } - put("fields", fields) - } - // Update existing card record with additional fields - val cardRecord = JSONObject().apply { - put("table", "cards") - val fields = JSONObject().apply { - put("first_name", "Joe") - put("skyflowID", "431eaa6c-5c15-4513-aa15-29f50babe882") // same skyflowID as collect element - } - put("fields", fields) - } - recordsArray.put(personRecord) - recordsArray.put(cardRecord) - put("records", recordsArray) -} - -// Upsert options -val upsertOptions = JSONArray().apply { - val upsertColumn = JSONObject().apply { - put("table", "cards") - put("column", "card_number") - } - put(upsertColumn) -} - -// Send the Non-PCI records as additionalFields of CollectOptions (optional) -// and apply upsert using optional field `upsert` of CollectOptions -val collectOptions = CollectOptions(tokens = true, additionalFields = nonPCIRecords, upsert = upsertOptions) - -// Implement a custom Skyflow.Callback to call on update success/failure -class InsertCallback : Callback { - override fun onSuccess(responseBody: Any) { - Log.d(TAG, "Update successful: $responseBody") - } - - override fun onFailure(exception: Any) { - Log.e(TAG, "Update failed: ${(exception as Exception).message}") - } -} - -// Initialize custom Skyflow.Callback -val insertCallback = InsertCallback() - -// Call collect method on CollectContainer -container.collect(callback = insertCallback, options = collectOptions) -``` - -#### Skyflow returns tokens for the record you just updated: - -```json -{ - "records": [ - { - "table": "persons", - "fields": { - "gender": "12f670af-6c7d-4837-83fb-30365fbc0b1e", - "skyflow_id": "77dc3caf-c452-49e1-8625-07219d7567bf" - } - }, - { - "table": "cards", - "fields": { - "skyflow_id": "431eaa6c-5c15-4513-aa15-29f50babe882", - "card_number": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", - "first_name": "131e70dc-6f76-4319-bdd3-96281e051051" - } - } - ] -} -``` - -### Validations - -skyflow-android provides two types of validations on Collect Elements - -#### 1. Default Validations: -Every Collect Element except of type `INPUT_FIELD` has a set of default validations listed below: -- `CARD_NUMBER`: Card number validation with checkSum algorithm(Luhn algorithm), available card lengths for defined card types -- `CARD_HOLDER_NAME`: Name, should be 2 or more symbols, valid characters shold match pattern `^([a-zA-Z\\ \\,\\.\\-\\']{2,})$` -- `CVV`: Card CVV can have 3-4 digits -- `EXPIRATION_DATE`: Any date starting from current month. By default valid expiration date should be in short year format - `MM/YY` -- `PIN`: Can have 4-12 digits - -#### 2. Custom Validations: -Custom validations can be added to any element which will be checked after the default validations have passed. The following Custom validation rules are currently supported: -- `RegexMatchRule`: You can use this rule to specify any Regular Expression to be matched with the text field value -- `LengthMatchRule`: You can use this rule to set the minimum and maximum permissible length of the textfield value -- `ElementValueMatchRule`: You can use this rule to match the value of one element with another - -The Sample code below illustrates the usage of custom validations: - -```kt -/* - Reset Password - A simple example that illustrates custom validations. The below code shows two input fields with custom validations, one to enter a Password and the second to confirm the same Password. -*/ - -var myRuleset = ValidationSet() -val strongPasswordRule = RegexMatchRule(regex= "^^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]*$", error = "At least one letter and one number") // This rule enforces a strong password -val lengthRule = LengthMatchRule(minLength = 8, maxLength = 16, error = "Must be between 8 and 16 digits") // this rule allows input length between 8 and 16 characters - -// for the Password element -myRuleset.add(rule = strongPasswordRule) -myRuleset.add(rule = lengthRule) - -val passwordInput = CollectElementInput(inputStyles = styles, label = "Password", placeholder = "****", type = ElementType.INPUT_FIELD, validations = myRuleset) - -val Password = container.create(passwordInput) - -// For confirm Password element - shows error when the PINs don't match -val elementMatchRule = ElementMatchRule(element = Password, error = "PINs don't match") - -val confirmPasswordinput = CollectElementInput(inputStyles = styles, label = "Confirm Password", placeholder = "****", type = ElementType.INPUT_FIELD, validations = ValidationSet(rules = mutableListOf(strongPasswordRule, lengthRule, elementMatchRule))) -val confirmPassword = container.create(input = confirmPasswordinput) - -//mount elements to the screen -addView(Password) -addView(confirmPassword) - -``` - -### Event Listener on Collect Elements - - -Helps to communicate with skyflow elements by listening to an event - -```kt -element.on(eventName: Skyflow.EventName) { state -> - //handle function -} +```groovy +implementation 'com.skyflowapi.android:skyflow-android-sdk:1.27.0' ``` -There are 4 events in `Skyflow.EventName` -- `CHANGE` - Change event is triggered when the Element's value changes. -- `READY` - Ready event is triggered when the Element is fully rendered -- `FOCUS` - Focus event is triggered when the Element gains focus -- `BLUR` - Blur event is triggered when the Element loses focus. -The handler ```(state: JSONObject) -> Unit``` is a callback function you provide, that will be called when the event is fired with the state object as shown below. +**Flow vault (v2 API)** -```kt -val state = { - "elementType": Skyflow.ElementType, - "isEmpty": Boolean, - "isRequired": Boolean, - "isFocused": Boolean, - "isValid": Boolean, - "value": String, - "selectedCardScheme": Skyflow.CardType, -} +```groovy +implementation 'com.skyflowapi.android:skyflow-flowvault-android-sdk:1.0.0' ``` -`Notes:` -- values of SkyflowElements will be returned in element state object only when `env` is `DEV`, else it is empty string i.e, '', but in case of CARD_NUMBER type element when the `env` is `PROD` for all the card types except AMEX, it will return first eight digits, for AMEX it will return first six digits and rest all digits in masked format. -- `selectedCardScheme` is only populated for the `CARD_NUMBER` element states when a user chooses a card brand. By default, `selectedCardScheme` is an empty string. -##### Sample code snippet for using listeners -```kt -//create skyflow client with loglevel:"DEBUG" -val config = Skyflow.Configuration(vaultID = VAULT_ID, vaultURL = VAULT_URL, tokenProvider = demoTokenProvider, options = Skyflow.Options(logLevel = Skyflow.LogLevel.DEBUG)) +See each guide for the latest published version. -val skyflowClient = Skyflow.initialize(config) +## Documentation -val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT) - -// Create a CollectElementInput -val cardNumberInput = Skyflow.CollectElementInput( - table = "cards", - column = "cardNumber", - type = Skyflow.ElementType.CARD_NUMBER, -) -val cardHolderNameInput = Skyflow.CollectElementInput( - table = "cards", - column = "cardHolderName", - type = Skyflow.ElementType.CARDHOLDER_NAME, -) +- **skyflow-android-sdk** — PDB vault (v1 API): [skyvault/README.md](skyvault/README.md) +- **skyflow-flowvault-android-sdk** — Flow vault (v2 API): [flowvault/README.md](flowvault/README.md) -val cardNumber = container.create(context = Context, input = cardNumberInput) -val cardHolderName = container.create(context = Context, input = cardHolderNameInput) - -//subscribing to CHANGE event, which gets triggered when element changes -cardNumber.on(eventName = Skyflow.EventName.CHANGE) { state -> - // Your implementation when Change event occurs - log.info("on change", state) -} -cardHolderName.on(eventName = Skyflow.EventName.CHANGE) { state -> - // Your implementation when Change event occurs - log.info("on change", state) -} -``` -##### Sample Element state object when `Env` is `DEV` -```kt -{ - "elementType": Skyflow.ElementType.CARD_NUMBER, - "isEmpty": false, - "isRequired": false, - "isFocused": true, - "isValid": true, - "value": "4111111111111111" -} -{ - "elementType": Skyflow.ElementType.CARDHOLDER_NAME, - "isEmpty": false, - "isRequired": false, - "isFocused": true, - "isValid": true, - "value": "John" -} -``` -##### Sample Element state object when `Env` is `PROD` -```kt -{ - "elementType": Skyflow.ElementType.CARD_NUMBER, - "isEmpty": false, - "isFocused": true, - "isValid": true, - "value": "41111111XXXXXXXX" -} -{ - "elementType": Skyflow.ElementType.CARDHOLDER_NAME, - "isEmpty": false, - "isFocused": true, - "isValid": true, - "value": "" -} -``` - -### UI Error for Collect Elements - -Helps to display custom error messages on the Skyflow Elements through the methods `setError` and `resetError` on the elements. - -`setError(error : String)` method is used to set the error text for the element, when this method is trigerred, all the current errors present on the element will be overridden with the custom error message passed. This error will be displayed on the element until `resetError()` is trigerred on the same element. - -`resetError()` method is used to clear the custom error message that is set using `setError`. - -##### Sample code snippet for setError and resetError - -```kt -//create skyflow client with loglevel:"DEBUG" -val config = Skyflow.Configuration(vaultID = VAULT_ID, vaultURL = VAULT_URL, tokenProvider = demoTokenProvider, options = Skyflow.Options(logLevel = Skyflow.LogLevel.DEBUG)) - -val skyflowClient = Skyflow.initialize(config) - -val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT) - -// Create a CollectElementInput -val cardNumberInput = Skyflow.CollectElementInput( - table = "cards", - column = "cardNumber", - type = Skyflow.ElementType.CARD_NUMBER, -) - -val cardNumber = container.create(input = cardNumberInput) - -//Set custom error -cardNumber.setError("custom error") - -//reset custom error -cardNumber.resetError() -``` - - -### Set and Clear value for Collect Elements (DEV ENV ONLY) - -`setValue(value: String)` method is used to set the value of the element. This method will override any previous value present in the element. - -`clearValue()` method is used to reset the value of the element. - -`Note:` This methods are only available in DEV env for testing/developmental purposes and MUST NOT be used in PROD env. - -##### Sample code snippet for setValue and clearValue - -```kotlin -//create skyflow client with env DEV -val config = Skyflow.Configuration( - vaultID = VAULT_ID, - vaultURL = VAULT_URL, - tokenProvider = demoTokenProvider, - options = Skyflow.Options(env = Skyflow.Env.DEV) -) -val skyflowClient = Skyflow.initialize(config) -val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT) - -// Create a CollectElementInput -val cardNumberInput = Skyflow.CollectElementInput( - table = "cards", - column = "cardNumber", - type = Skyflow.ElementType.CARD_NUMBER, -) -val cardNumber = container.create(input = cardNumberInput) -//Set a value programatically -cardNumber.setValue("4111111111111111") -//Clear the value -cardNumber.clearValue() -``` - ---- - -# Securely collecting data client-side using composable elements -Composable Elements combine multiple Skyflow Elements in a single row. The following steps create a composable element and securely collect data through it. - -- [**Using Skyflow Composable Elements to collect data**](#using-skyflow-composable-elements-to-collect-data) -- [**Using Skyflow Composable Elements to update data**](#using-skyflow-composable-elements-to-update-data) -- [**Event Listeners on Composable Elements**](#event-listeners-on-composable-elements) -- [**Update Composable Elements**](#update-composable-elements) -- [**Event Listeners on Composable Container**](#event-listeners-on-composable-container) - -## Using Skyflow Composable Elements to collect data -### Step 1: Create a composable container - -First create a **container** for the form elements using the `skyflowClient.container(type: Skyflow.ContainerType, options: Skyflow.ContainerOptions)` method as show below - -```kotlin -val container = skyflowClient.container(type = ContainerType.COMPOSABLE, options = ContainerOptions(layout = arrayOf(2, 1))) -``` +## Samples -The container requires an options object that contains the following keys: - -- `layout`: An array that indicates the number of rows in the container and the number of elements in each row. The index value of the array defines the number of rows, and each value in the array represents the number of elements in that row, in order. - - For example: `arrayOf(2, 1)` means the container has two rows, with two elements in the first row and one element in the second row. - - `Note`: The sum of values in the layout array should be equal to the number of elements created - -- `styles`: styles to apply to each composable row. - -- `errorTextStyles`: styles to apply if an error is encountered. - -```kotlin -val containerOptions = ContainerOptions( - layout: [1, 1, 2], // required - styles: Skyflow.Styles, // optional - errorTextStyles: Skyflow.Styles // optional -) -``` -### Step 2: Create Composable Elements -Composable Elements use the following schema: - -```kotlin -val composableElementInput = Skyflow.CollectElementInput( - table: String, // optional, the table this data belongs to - column: String, // optional, the column into which this data should be inserted - inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element - labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element - errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element - label: String, // optional label for the form element - placeholder: String, // optional placeholder for the form element - altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element - validations: ValidationSet, // optional set of validations for the input element - type: Skyflow.ElementType, // Skyflow.ElementType enum -) -``` -The `table` and `column` fields indicate which table and column in the vault the Element correspond to. - -**Note**: -- Use dot delimited strings to specify columns nested inside JSON fields (e.g. `address.street.line1`) - -The `inputStyles` parameter accepts a `Skyflow.Styles` object which consists of multiple `Skyflow.Style` objects which should be applied to the form element in the following states: - -- `base`: all other variants inherit from these styles -- `complete`: applied when the Element has valid input -- `empty`: applied when the Element has no input -- `focus`: applied when the Element has focus -- `invalid`: applied when the Element has invalid input - -Each Style object accepts the following properties, please note that each property is optional: - -```kotlin -Skyflow.Style( - borderColor: Int // optional - cornerRadius: Float // optional - padding: Skyflow.Padding // optional - borderWidth: Int // optional - font: Int // optional - textAlignment: Int // optional - textColor: Int // optional - placeholderColor: Int // optional - width: Int // optional - height: Int // optional - margin: Skyflow.Margin // optional - backgroundColor: Int // optional - minWidth: Int // optional - maxWidth: Int // optional - minHeight: Int // optional - maxHeight: Int // optional -) -``` - -Here `Skyflow.Padding` and `Skyflow.Margin` are classes which can be used to set the padding and margin respectively for the composable element which takes all the left, top, right, bottom values. - -```kt -Skyflow.Padding(left: Int, top: Int, right: Int, bottom: Int) - -Skyflow.Margin(left: Int, top: Int, right: Int, bottom: Int) -``` - -An example Skyflow.Styles object -```kotlin -val styles = Skyflow.Styles( - base: Style, // optional - complete: Style, // optional - empty: Style, // optional - focus: Style, // optional - invalid: Style // optional -) -``` - -**Notes**: -- The `labelStyles` and `errorTextStyles` fields accept the above mentioned `Skyflow.Styles` object which are applied to the `label` and `errorText` text views respectively. - -- The states that are available for `labelStyles` are `base` and `focus`. - -- The `errorTextStyles` will be ignored for composable element passed in `CollectElementInput` and `errorTextStyles` passed in `ContainerOptions` will be used instead. - -- The state that is available for `errorTextStyles` is only the base state, it shows up when there is some error in the composable element. - -- The parameters in `Skyflow.Style` object that are respected for `label` and `errorText` text views are - - padding - - font - - textColor - - textAlignment - - width - - height - - margin - - minWidth - - maxWidth - - minHeight - - maxHeight - -Other parameters in the `Skyflow.Style` object are ignored for `label` and `errorText` text views. - -Finally, the `type` parameter takes a Skyflow.ElementType. Each type applies the appropriate regex and validations to the form element. - -The Android SDK supports the following composable elements: - -- `INPUT_FIELD` -- `CARDHOLDER_NAME` -- `CARD_NUMBER` -- `EXPIRATION_DATE` -- `CVV` -- `PIN` -- `EXPIRATION_YEAR` -- `EXPIRATION_MONTH` - -**Note**: Only when the entered value in the below composable elements is valid, the focus shifts automatically. The element types are: - -- `CARD_NUMBER` -- `EXPIRATION_DATE` -- `EXPIRATION_MONTH` -- `EXPIRATION_YEAR` - -The `INPUT_FIELD` type is a custom UI element without any built-in validations. See the section on [validations](#validations) for more information on validations. - -Along with `CollectElementInput`, you can define other options in the `CollectElementOptions` object which is described below. - -```kotlin -Skyflow.CollectElementOptions( - required: Boolean, // Indicates whether the field is marked as required. Defaults to 'false' - enableCardIcon: Boolean, // Indicates whether card icon should be enabled (only for CARD_NUMBER inputs) - format: String, // Format for the element - translation: HashMap // Indicates the allowed data type value for format. -) -``` -- `required`: Indicates whether the field is marked as required or not. Default is `false`. -- `enableCardIcon`: Indicates whether the icon is visible for the CARD_NUMBER element. Default is `true`. -- `format`: A string value that indicates the format pattern applicable to the element type. Only applicable to `EXPIRATION_DATE`, `CARD_NUMBER`, `EXPIRATION_YEAR`, and `INPUT_FIELD` elements. - - For INPUT_FIELD elements, - - the length of `format` determines the expected length of the user input. - - if `translation` isn't specified, the `format` value is considered a string literal. -- `translation`: A dictionary of key/value pairs, where the key is a character that appears in `format` and the value is a regex pattern of acceptable inputs for that character. Each key can only appear once. Only applicable for INPUT_FIELD elements. - -Accepted values by element type: - -| Element type | `format` | `translation` | Examples | -| --------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| EXPIRATION_DATE |
    • `mm/yy`(default)
    • `mm/yyyy`
    • `yy/mm`
    • `yyyy/mm`
    | N/A |
    • 12/27
    • 12/2027
    • 27/12
    • 2027/12
| -| EXPIRATION_YEAR |
  • `yy`(default)
  • `yyyy`
  • | N/A |
    • 27
    • 2027
    | -| CARD_NUMBER |
    • `XXXX XXXX XXXX XXXX` (default)
    • `XXXX-XXXX-XXXX-XXXX`
    | N/A |
    • 1234 5678 9012 3456
    • 1234-5678-9012-3456
    | -| INPUT_FIELD | A string that matches the desired output, with placeholder characters of your choice. | A hashmap of key/value pairs. Defaults to `hashmapOf('X' to "[0-9]")` | With `format: +91 XXXX-XX-XXXX` and `translation: hashmapOf('X' to "[0-9]")`, user input of "1234121234" displays as "+91 1234-12-1234". | - -Collect Element Options examples for INPUT_FIELD - -Example 1 -```kotlin -Skyflow.CollectElementOptions( - required: true, - enableCardIcon: true, - format: "+91 XXXX-XX-XXXX", - translation: hashmapOf('X' to "[0-9]") -) -``` -User input: "1234121234" - -Value displayed in INPUT_FIELD: "+91 1234-12-1234" - -Example 2 -```kotlin -Skyflow.CollectElementOptions( - required: true, - enableCardIcon: true, - format: "AY XX-XXX-XXXX", - translation: hashmapOf('X' to "[0-9]", 'Y' to "[A-Z]") -) -``` -User input: "B1234121234" - -Value displayed in INPUT_FIELD: "AB 12-341-2123" - -Once the `Skyflow.CollectElementInput` and `Skyflow.CollectElementOptions` objects are defined, add to the container using the `create(context: Context, input: CollectElementInput, options: CollectElementOptions)` method as shown below. The `input` param takes a `Skyflow.CollectElementInput` object as defined above and the `options` parameter takes an `Skyflow.CollectElementOptions` object as described below: - -```kotlin -val composableElementInput = Skyflow.CollectElementInput( - table: String, // the table this data belongs to - column: String, // the column into which this data should be inserted - inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element - labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element - errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element - label: String, // optional label for the form element - placeholder: String, // optional placeholder for the form element - altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element - validations: ValidationSet, // optional set of validations for the input element - type: Skyflow.ElementType, // Skyflow.ElementType enum -) - -val collectElementOptions = Skyflow.CollectElementOptions( - required: false, // indicates whether the field is marked as required. Defaults to 'false', - enableCardIcon: true, // indicates whether card icon should be enabled (only for CARD_NUMBER inputs) - format: "mm/yy" // Format for the element -) - -val element = container.create(context = Context, input: composableElementInput, options: collectElementOptions) -``` -### Step 3: Mount Elements to the Screen - -To specify where the Elements will be rendered on the screen, fetch composable layout using `container.getComposableLayout()` and add it to a layout in your app programmatically. - -```kotlin -try { - val composableLayout = container.getComposableLayout() - existingLayout.addView(composableLayout) -} catch(error: Exception) { - println(error) -} -``` - -The Skyflow Element is an implementation of native android View so it can be used/mounted similarly.Alternatively, you can use the unmount method to reset any element to it's initial state. - -```kotlin -fun clearFieldsOnSubmit(elements: List) { - // resets all elements in the array - for element in elements { - element.unmount() - } -} -``` -### Step 4: Collect data from elements - -When the form is ready to be submitted, call the `collect(options: Skyflow.CollectOptions? = CollectOptions(), callback: Skyflow.Callback)` method on the container object. The options parameter takes `Skyflow.CollectOptions` object. - -`Skyflow.CollectOptions` takes three optional fields -- `tokens`: indicates whether tokens for the collected data should be returned or not. Defaults to 'true' -- `additionalFields`: Non-PCI elements data to be inserted into the vault which should be in the `records` object format as described in the above [Inserting data into vault](#Inserting-data-into-the-vault) section. -- `upsert`: To support upsert operations, the table containing the data and a column marked as unique in that table. - -```kotlin -// NON-PCI fields object creation -val nonPCIRecords = JSONObject() -val recordsArray = JSONArray() - -val record = JSONObject() -record.put("table", "persons") - -val fields = JSONObject() -fields.put("gender", "MALE") - -record.put("fields", fields) -recordsArray.put(record) - -nonPCIRecords.put("records", recordsArray) - -//Upsert options -val upsertArray = JSONArray() - -val upsertColumn = JSONObject() -upsertColumn.put("table", "cards") -upsertColumn.put("column", "card_number") - -upsertArray.put(upsertColumn) - -val options = Skyflow.CollectOptions(tokens = true, additonalFields = nonPCIRecords, upsert = upsertArray) -val insertCallback = InsertCallback() //Custom callback - implementation of Skyflow.callback -container.collect(options, insertCallback) -``` -#### End to end example of collecting data with Composable Elements - -##### [Sample Code](https://github.com/skyflowapi/skyflow-android/blob/main/samples/src/main/java/com/Skyflow/ComposableActivity.kt): -```kotlin -//Initialize skyflow configuration -val config = Skyflow.Configuration(vaultID = VAULT_ID, vaultURL = VAULT_URL, tokenProvider = demoTokenProvider) - -//Initialize skyflow client -val skyflowClient = Skyflow.init(config) - -//Create a ComposableContainer -val container = skyflowClient.container( - type = Skyflow.ContainerType.COMPOSABLE, - options = ContainerOptions(layout = arrayOf(1, 2)) -) - -//Initialize and set required options -val options = Skyflow.CollectElementOptions(required = true) - -//Create Skyflow.Styles with individual Skyflow.Style variants -val baseCardStyle = Skyflow.Style(borderColor = Color.TRANSPARENT) -val baseDateStyle = Skyflow.Style(borderColor = Color.TRANSPARENT, width = 300) -val baseCvvStyle = Skyflow.Style(borderColor = Color.TRANSPARENT, width = 200) -val completedStyle = Skyflow.Style(textColor = Color.TRANSPARENT) -val baseTextStyle = Skyflow.Style(textColor = Color.BLACK) -val focusTextStyle = Skyflow.Style(textColor = Color.RED) -val cardStyles = Skyflow.Styles(base = baseCardStyle, complete = completedStyle) -val dateStyles = Skyflow.Styles(base = baseDateStyle, complete = completedStyle) -val cvvStyles = Skyflow.Styles(base = baseCvvStyle, complete = completedStyle) -val labelStyles = Skyflow.Styles(base = baseTextStyle, focus = focusTextStyle) -val errorTextStyles = Skyflow.Styles(base = baseTextStyle) - -//Create a CollectElementInput -val cardNumber = Skyflow.CollectElementInput( - table = "cards", - column = "cardNumber", - type = Skyflow.ElementType.CARD_NUMBER - inputStyles = cardStyles, - labelStyles = labelStyles, - errorTextStyles = errorTextStyles, - label = "card number", - placeholder = "card number", -) - -val expDate = Skyflow.CollectElementInput( - table = "cards", - column = "expiryDate", - type = Skyflow.ElementType.EXPIRATION_DATE - inputStyles = dateStyles, - labelStyles = labelStyles, - errorTextStyles = errorTextStyles, - label = "Expiry Date", - placeholder = "mm/yy", -) - -val cvv = Skyflow.CollectElementInput( - table = "cards", - column = "cvv", - type = Skyflow.ElementType.CVV - inputStyles = cvvStyles, - labelStyles = labelStyles, - errorTextStyles = errorTextStyles, - label = "CVV", - placeholder = "***", -) - -//Create a CollectElementOptions instance -val options = Skyflow.CollectElementOptions(required = true) - -//Create a Composable Element from the Composable Container -val cardNumberElement = container.create(context = Context, cardNumber, options) -val expDateElement = container.create(context = Context, expDate, options) -val cvvElement = container.create(context = Context, cvv, options) - -//Fetch composable layout and add to main view -try { - val composableLayout = container.getComposableLayout() - parent.addView(composableLayout) -} catch (error: Exception) { - println(error) -} - -// Non-PCI fields data -val nonPCIRecords = JSONObject() -val recordsArray = JSONArray() - -val record = JSONObject() -record.put("table", "persons") - -val fields = JSONObject() -fields.put("gender", "MALE") - -record.put("fields", fields) -recordsArray.put(record) - -nonPCIRecords.put("records", recordsArray) - -//Upsert options -val upsertArray = JSONArray() - -val upsertColumn = JSONObject() -upsertColumn.put("table", "cards") -upsertColumn.put("column", "card_number") - -upsertArray.put(upsertColumn) - -//Initialize and set required options for insertion -val collectOptions = Skyflow.CollectOptions(tokens = true, additionalFields = nonPCIRecords, upsert = upsertArray) - -//Implement a custom Skyflow.Callback to be called on Insertion success/failure -class InsertCallback: Skyflow.Callback { - override fun onSuccess(responseBody: Any) { - print(responseBody) - } - override fun onFailure(_ error: Error) { - print(error) - } -} - -//Initialize InsertCallback which is an implementation of Skyflow.Callback interface -val insertCallback = InsertCallback() - -//Call collect method on CollectContainer -container.collect(options = collectOptions, callback = insertCallback) -``` -##### Sample Response : -```json -{ - "records": [ - { - "table": "cards", - "fields": { - "cardNumber": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", - "expiryDate": "d0369871-91e5-466f-e7e2-48e12c2bcbc2", - "cvv": "c7093186-466f-e7e2-91e5-48e12c2bcbc3", - } - }, - { - "table": "persons", - "fields": { - "gender": "12f670af-6c7d-4837-83fb-30365fbc0b1e", - } - } - ] -} -``` - -[For information on validations, see validations.](#validations) - -## Using Skyflow Composable Elements to update data - -Composable Elements combine multiple Skyflow Elements in a single row. The following steps create a composable element and securely update data through it. - -### Step 1: Create a composable container - -First create a **container** for the form elements using the `skyflowClient.container(type: Skyflow.ContainerType, options: Skyflow.ContainerOptions)` method as shown below: - -```kt -val containerOptions = ContainerOptions( - layout = arrayOf(1, 1, 2), // required - styles = Skyflow.Styles, // optional - errorTextStyles = Skyflow.Styles // optional -) -val container = skyflowClient.container( - type = ContainerType.COMPOSABLE, - options = containerOptions -) -``` - -### Step 2: Create Composable Elements - -Composable Elements use the following schema: - -```kt -val composableElementInput = Skyflow.CollectElementInput( - table: String, // optional, the table this data belongs to - column: String, // optional, the column into which this data should be updated - type: Skyflow.ElementType, // Skyflow.ElementType enum - inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element - labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element - errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element - label: String, // optional label for the form element - placeholder: String, // optional placeholder for the form element - altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element - validations: ValidationSet, // optional set of validations for the input element - skyflowID: String // The skyflow_id of the record to be updated -) -``` - -The `table` and `column` fields indicate which table and column in the vault the Element corresponds to. - -**Note:** -- Use dot delimited strings to specify columns nested inside JSON fields (e.g. `address.street.line1`) - -Along with `CollectElementInput`, you can define other options in the `CollectElementOptions` object as described below: - -```kt -Skyflow.CollectElementOptions( - required: Boolean, // Indicates whether the field is marked as required. Defaults to 'false' - enableCardIcon: Boolean, // Indicates whether card icon should be enabled (only for CARD_NUMBER inputs) - format: String, // Format for the element - translation: HashMap // Indicates the allowed data type value for format. -) -``` - -Once the `Skyflow.CollectElementInput` and `Skyflow.CollectElementOptions` objects are defined, add to the container using the `create(context: Context, input: CollectElementInput, options: CollectElementOptions)` method as shown below: - -```kt -val element = container.create(context = this, input = composableElementInput, options = collectElementOptions) -``` - -### Step 3: Mount Elements to the Screen - -To specify where the Elements will be rendered on the screen, fetch composable layout using `container.getComposableLayout()` and add it to a layout in your app programmatically. - -```kt -try { - val composableLayout = container.getComposableLayout() - existingLayout.addView(composableLayout) -} catch (error: Exception) { - println(error) -} -``` - -The Skyflow Element is an implementation of the View so it can be used/mounted similarly. Alternatively, you can use the `unmount` method to reset any collect element to its initial state. - -```kt -fun clearFieldsOnSubmit(elements: List) { - // resets all elements in the array - for (element in elements) { - element.unmount() - } -} -``` - -### Step 4: Update data from Elements - -When you submit the form, call the `collect(options: Skyflow.CollectOptions? = null, callback: Skyflow.Callback)` method on the container object. - -The options parameter takes a `Skyflow.CollectOptions` object as shown below: - -- `tokens`: Whether or not tokens for the collected data are returned. Defaults to 'true' -- `additionalFields`: Non-PCI elements data to insert into the vault, specified in the records object format. -- `upsert`: To support upsert operations, the table containing the data and a column marked as unique in that table. - -```kt -// Non-PCI records with skyflowID for update -val nonPCIRecords = JSONObject().apply { - val recordsArray = JSONArray() - val record = JSONObject().apply { - put("table", "persons") - val fields = JSONObject().apply { - put("gender", "MALE") - put("skyflowID", "") // skyflowID for update - } - put("fields", fields) - } - recordsArray.put(record) - put("records", recordsArray) -} - -// Upsert options -val upsertOptions = JSONArray().apply { - val upsertColumn = JSONObject().apply { - put("table", "cards") - put("column", "card_number") - } - put(upsertColumn) -} - -// Send the non-PCI records as additionalFields of CollectOptions (optional) -// and apply upsert using `upsert` field of CollectOptions (optional) -val options = CollectOptions(tokens = true, additionalFields = nonPCIRecords, upsert = upsertOptions) - -// Custom callback - implementation of Skyflow.Callback -val insertCallback = InsertCallback() -container.collect(callback = insertCallback, options = options) -``` - -### End to end example of updating data with Composable Elements - -```kt -// Initialize skyflow configuration -val config = Configuration( - vaultID = "", - vaultURL = "", - tokenProvider = demoTokenProvider -) - -// Initialize skyflow client -val skyflowClient = init(config) - -// Create container options with layout -val containerOptions = ContainerOptions( - layout = arrayOf(1, 2), - styles = Styles(base = Style(borderColor = Color.GRAY)), - errorTextStyles = Styles(base = Style(textColor = Color.RED)) -) - -// Create a Composable Container -val container = skyflowClient.container( - type = ContainerType.COMPOSABLE, - options = containerOptions -) - -// Create Skyflow.Styles with individual Skyflow.Style variants -val padding = Padding(8, 8, 8, 8) -val baseStyle = Style(borderColor = Color.BLUE) -val baseTextStyle = Style(textColor = Color.BLACK) -val completeStyle = Style(borderColor = Color.GREEN) -val focusTextStyle = Style(textColor = Color.RED) -val inputStyles = Styles(base = baseStyle, complete = completeStyle) -val labelStyles = Styles(base = baseTextStyle, focus = focusTextStyle) -val errorTextStyles = Styles(base = baseTextStyle) - -// Create Composable Elements with skyflowID for update -val cardHolderNameElementInput = CollectElementInput( - table = "cards", - column = "cardholder_name", - type = SkyflowElementType.CARDHOLDER_NAME, - inputStyles = inputStyles, - labelStyles = labelStyles, - errorTextStyles = errorTextStyles, - label = "Cardholder Name", - placeholder = "John Doe", - skyflowID = "431eaa6c-5c15-4513-aa15-29f50babe882" // skyflowID for update -) - -// Create an option to require the element -val requiredOption = CollectElementOptions(required = true) - -// Create a Composable Element from the Composable Container -val cardHolderNameElement = container.create( - context = this, - input = cardHolderNameElementInput, - options = requiredOption -) - -val cardNumberElementInput = CollectElementInput( - table = "cards", - column = "card_number", - type = SkyflowElementType.CARD_NUMBER, - inputStyles = inputStyles, - labelStyles = labelStyles, - errorTextStyles = errorTextStyles, - label = "Card Number", - placeholder = "XXXX XXXX XXXX XXXX", - skyflowID = "431eaa6c-5c15-4513-aa15-29f50babe882" // same skyflowID - will be merged in single update -) - -val cardNumberElement = container.create( - context = this, - input = cardNumberElementInput, - options = requiredOption -) - -val cvvElementInput = CollectElementInput( - table = "cards", - column = "cvv", - type = SkyflowElementType.CVV, - inputStyles = inputStyles, - labelStyles = labelStyles, - errorTextStyles = errorTextStyles, - label = "CVV", - placeholder = "CVV", - skyflowID = "431eaa6c-5c15-4513-aa15-29f50babe882" // same skyflowID - will be merged in single update -) - -val cvvElement = container.create( - context = this, - input = cvvElementInput, - options = requiredOption -) - -// Add composable layout to screen -val parent = findViewById(R.id.parent) -try { - val composableLayout = container.getComposableLayout() - parent.addView(composableLayout) -} catch (error: Exception) { - println(error) -} - -// Non-PCI records with skyflowID for update -val nonPCIRecords = JSONObject().apply { - val recordsArray = JSONArray() - val record = JSONObject().apply { - put("table", "persons") - val fields = JSONObject().apply { - put("gender", "MALE") - put("skyflowID", "77dc3caf-c452-49e1-8625-07219d7567bf") // skyflowID for update - } - put("fields", fields) - } - recordsArray.put(record) - put("records", recordsArray) -} - -// Upsert options -val upsertOptions = JSONArray().apply { - val upsertColumn = JSONObject().apply { - put("table", "cards") - put("column", "card_number") - } - put(upsertColumn) -} - -// Send the Non-PCI records as additionalFields of CollectOptions (optional) -// and apply upsert using optional field `upsert` of CollectOptions -val collectOptions = CollectOptions( - tokens = true, - additionalFields = nonPCIRecords, - upsert = upsertOptions -) - -// Implement a custom Skyflow.Callback to call on update success/failure -class InsertCallback : Callback { - override fun onSuccess(responseBody: Any) { - Log.d(TAG, "Update successful: $responseBody") - } - - override fun onFailure(exception: Any) { - Log.e(TAG, "Update failed: ${(exception as Exception).message}") - } -} - -// Initialize custom Skyflow.Callback -val insertCallback = InsertCallback() - -// Call collect method on CollectContainer -container.collect(callback = insertCallback, options = collectOptions) -``` - -### Sample Success Response: - -```json -{ - "records": [ - { - "table": "persons", - "fields": { - "gender": "12f670af-6c7d-4837-83fb-30365fbc0b1e", - "skyflow_id": "77dc3caf-c452-49e1-8625-07219d7567bf" - } - }, - { - "table": "cards", - "fields": { - "skyflow_id": "431eaa6c-5c15-4513-aa15-29f50babe882", - "card_number": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", - "cardholder_name": "131e70dc-6f76-4319-bdd3-96281e051051", - "cvv": "098834fe-de99-4fc8-abdf-88c18a28a2cf" - } - } - ] -} -``` - -### Sample Partial Error Response: - -```json -{ - "records": [ - { - "table": "cards", - "fields": { - "skyflow_id": "431eaa6c-5c15-4513-aa15-29f50babe882", - "card_number": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", - "cardholder_name": "131e70dc-6f76-4319-bdd3-96281e051051", - "cvv": "098834fe-de99-4fc8-abdf-88c18a28a2cf" - } - } - ], - "errors": [ - { - "error": { - "code": 400, - "description": "Update failed. skyflow_ids [77dc3caf-c452-49e1-8625-07219d7567bf] are invalid. Specify valid Skyflow IDs. - request-id: cb397-8521-42c2-870c-92dbeec", - "type": 400 - } - } - ] -} -``` - -## Event Listeners on Composable Elements -You can communicate with Skyflow Elements by listening to element events: - -```kotlin -element.on(eventName: Skyflow.EventName) { state -> - // handle function -} -``` - -The SDK supports four events: - -- `CHANGE`: Triggered when the Element's value changes. -- `READY`: Triggered when the Element is fully rendered. -- `FOCUS`: Triggered when the Element gains focus. -- `BLUR`: Triggered when the Element loses focus. - -The handler `(state: JSONObject) -> Unit` is a callback function you provide, that will be called when the event is fired with the state object as shown below. - -```kotlin -val state = { - "elementType": Skyflow.ElementType, - "isEmpty": Bool , - "isRequired": Bool, - "isFocused": Bool, - "isValid": Bool, - "value": String -} -``` - -`Note`: -values of SkyflowElements will be returned in element state object only when `env` is `DEV`, else it is empty string i.e, '', but in case of CARD_NUMBER type element when the `env` is `PROD` for all the card types except AMEX, it will return first eight digits, for AMEX it will return first six digits and rest all digits in masked format. - -#### Example Usage of Event Listener on Composable Elements - -```kotlin -// create skyflow client with loglevel:"DEBUG" -val config = Skyflow.Configuration( - vaultID = VAULT_ID, - vaultURL = VAULT_URL, - tokenProvider = demoTokenProvider, - options = Skyflow.Options(logLevel: Skyflow.LogLevel.DEBUG) -) - -val skyflowClient = Skyflow.init(config) - -val containerOptions = ContainerOptions( - layout = arrayOf(1, 1), - styles = Styles(base: Style(borderColor: UIColor.gray)), - errorTextStyles = Styles(base: Style(textColor: UIColor.red)) -) - -//Create a Composable Container. -val container = skyflowClient.container(type: Skyflow.ContainerType.COMPOSABLE, options: containerOptions) - -// Create a CollectElementInput -val cardNumberInput = Skyflow.CollectElementInput( - table = "cards", - column = "cardNumber", - type = Skyflow.ElementType.CARD_NUMBER, -) - -val cardHolderNameInput = Skyflow.CollectElementInput( - table = "cards", - column = "cardHolderName", - type = Skyflow.ElementType.CARDHOLDER_NAME, -) - -val cardNumber = container.create(context = Context, input = cardNumberInput) -val cardHolderName = container.create(context = Context, input = cardHolderNameInput) - -try { - val composableLayout = container.getComposableLayout() - parent.addView(composableLayout) -} catch (error: Exception) { - println(error) -} - -// subscribing to CHANGE event, which gets triggered when element changes -cardNumber.on(eventName: Skyflow.EventName.CHANGE) { state -> - // Your implementation when Change event occurs - log.info("on change", state) -} - -cardHolderName.on(eventName: Skyflow.EventName.CHANGE) { state -> - // Your implementation when Change event occurs - log.info("on change", state) -} -``` - -#### Sample Element state object when `env` is `DEV` -```kotlin -{ - "elementType": Skyflow.ElementType.CARD_NUMBER, - "isEmpty": false, - "isRequired": false, - "isFocused": true, - "isValid": true, - "value": "4111111111111111" -} -{ - "elementType": Skyflow.ElementType.CARDHOLDER_NAME, - "isEmpty": false, - "isRequired": false, - "isFocused": true, - "isValid": true, - "value": "John" -} -``` -#### Sample Element state object when `env` is `PROD` -```kotlin -{ - "elementType": Skyflow.ElementType.CARD_NUMBER, - "isEmpty": false, - "isRequired": false, - "isFocused": true, - "isValid": true, - "value": "41111111XXXXXXXX" -} -{ - "elementType": Skyflow.ElementType.CARDHOLDER_NAME, - "isEmpty": false, - "isRequired": false, - "isFocused": true, - "isValid": true, - "value": "" -} -``` -## Update Composable Elements -You can update composable element properties with the `update` interface. - -The `update` interface takes the below object: -```kotlin -val updateElement = Skyflow.CollectElementInput( - table: String, // optional the table this data belongs to - column: String, // optional the column into which this data should be inserted - inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element - labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element - errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element - label: String, // optional label for the form element - placeholder: String, // optional placeholder for the form element - altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element - validations: ValidationSet, // optional set of validations for the input element -) -``` -Only include the properties that you want to update for the specified composable element. - -Properties your provided when you created the element remain the same until you explicitly update them. - -`Notes`: -- You can't update the type property of an element. -- Upon calling the update method, if not passed, all Styles i.e. `inputStyles`, `labelStyles` and `errorTextStyles` will be overridden by default Styles. - -#### End to end example -```kotlin -// create skyflow client with loglevel:"DEBUG" -val config = Skyflow.Configuration( - vaultID = VAULT_ID, - vaultURL = VAULT_URL, - tokenProvider = demoTokenProvider, - options = Skyflow.Options(logLevel: Skyflow.LogLevel.DEBUG) -) - -val skyflowClient = Skyflow.init(config) - -val containerOptions = ContainerOptions( - layout = arrayOf(1, 1), - styles = Styles(base: Style(borderColor: UIColor.gray)), - errorTextStyles = Styles(base: Style(textColor: UIColor.red)) -) - -//Create a Composable Container. -val container = skyflowClient.container(type: Skyflow.ContainerType.COMPOSABLE, options: containerOptions) - -// Create a CollectElementInput -val cardNumberInput = Skyflow.CollectElementInput( - table = "cards", - column = "cardNumber", - type = Skyflow.ElementType.CARD_NUMBER, -) - -val cardHolderNameInput = Skyflow.CollectElementInput( - table = "cards", - column = "cardHolderName", - type = Skyflow.ElementType.CARDHOLDER_NAME, -) - -val cardNumber = container.create(context = Context, input = cardNumberInput) -val cardHolderName = container.create(context = Context, input = cardHolderNameInput) - -try { - val composableLayout = container.getComposableLayout() - parent.addView(composableLayout) -} catch (error: Exception) { - println(error) -} - -// Update table, column, inputStyles properties on cardNumber. -cardNumber.update(update = CollectElementInput( - table = "cards", - column = "cardHolderName", - inputStyles = Skyflow.Styles(base: Style(borderColor: UIColor.red)) -)) - -val lengthRule = LengthMatchRule(minLength = 5, maxLength = 16, error = "Must be between 5 and 16 digits") - -// Update validations and placeholder property on cardHolderName. -cardHolderName.update(update = CollectElementInput( - placeholder = "cardHolderName", - validations = ValidationSet(rules = mutableListOf(lengthRule))) -) -``` - -## Event Listeners on Composable Container - -Currently, the SDK supports one event: -- `SUBMIT`: Triggered when the Enter key is pressed in any container element. - -The handler function `() -> Unit` is a callback function you provide that's called when the `SUBMIT` event fires. - -#### Example -```kotlin -// create skyflow client with loglevel:"DEBUG" -val config = Skyflow.Configuration( - vaultID = VAULT_ID, - vaultURL = VAULT_URL, - tokenProvider = demoTokenProvider, - options = Skyflow.Options(logLevel: Skyflow.LogLevel.DEBUG) -) - -val skyflowClient = Skyflow.init(config) - -val containerOptions = ContainerOptions( - layout = arrayOf(1), - styles = Styles(base: Style(borderColor: UIColor.gray)), - errorTextStyles = Styles(base: Style(textColor: UIColor.red)) -) - -//Create a Composable Container. -val container = skyflowClient.container(type: Skyflow.ContainerType.COMPOSABLE, options: containerOptions) - -// Create a CollectElementInput -val cardNumberInput = Skyflow.CollectElementInput( - table = "cards", - column = "cardNumber", - type = Skyflow.ElementType.CARD_NUMBER, -) - -val cardNumber = container.create(context = Context, input = cardNumberInput) - -try { - val composableLayout = container.getComposableLayout() - parent.addView(composableLayout) -} catch (error: Exception) { - println(error) -} - -//Call Submit event listener on container -container.on(EventName.SUBMIT) { - // Your implementation when Submit (enter) event occurs - log.info("on submit", "submit event triggerred") -} -``` - ---- -# Securely revealing data client-side -- [**Retrieving data from the vault**](#retrieving-data-from-the-vault) -- [**Using Skyflow Elements to reveal data**](#using-skyflow-elements-to-reveal-data) -- [**UI Error for Reveal Elements**](#ui-error-for-reveal-elements) -- [**Set token for Reveal Elements**](#set-token-for-reveal-elements) -- [**Set and clear altText for Reveal Elements**](#set-and-clear-alttext-for-reveal-elements) - -## Retrieving data from the vault -For non-PCI use-cases, retrieving data from the vault and revealing it in the mobile can be done either using the SkyflowID's or tokens as described below - -- ### Using tokens - To retrieve record data using tokens, use the `detokenize(records)` method. The `records` parameter takes a JSON object that contains tokens for record values to fetch: - - ```json5 - { - "records":[ - { - "token": "string", // token for the record to be fetched - "redaction": Skyflow.RedactionType // Optional. Redaction to apply for retrieved data. E.g. RedactionType.MASKED - } - ] - } - ``` - - Note: `redaction` defaults to [`RedactionType.PLAIN_TEXT`](#redaction-types). - - The following example code makes a detokenize call to reveal the masked value of a token: - ```kt - val getCallback = GetCallback() //Custom callback - implementation of Skyflow.Callback - - val records = JSONObject() - val recordsArray = JSONArray() - val recordObj = JSONObject() - recordObj.put("token", "45012507-f72b-4f5c-9bf9-86b133bae719") - recordObj.put("redaction", RedactionType.MASKED) - recordsArray.put(recordObj) - records.put("records", recordsArray) - - skyflowClient.detokenize(records = records, callback = getCallback) - ``` - The sample response: - ```json - { - "records": [ - { - "token": "131e70dc-6f76-4319-bdd3-96281e051051", - "value": "j***oe" - } - ] - } - ``` - -- ### Using Skyflow ID's or Unique Column Values - For retrieving data from the vault, use the `get(records: JSONObject, options: GetOptions? = GetOptions(), callback: Skyflow.Callback)` method. - - The `records` parameter takes a JSON object that contains an array of the records to fetch. Each object inside array should contain: - - - Either an array of Skyflow IDs to fetch - - Or a column name and an array of column values - - The second parameter, `options`, is a `GetOptions` object that retrieves tokens of Skyflow IDs. - - Notes: - - You can use either Skyflow IDs or unique values to retrieve records. You can't use both at the same time. - - GetOptions parameter is applicable only for retrieving tokens using Skyflow ID. - - You can't pass GetOptions along with the redaction type. - - `tokens` defaults to false. - - ```json5 - { - "records":[ - { - "ids": JSONArray(), // Array of SkyflowID's of the records to be fetched - "table": String, // name of table holding the above skyflow_id's - "redaction": Skyflow.RedactionType // redaction to be applied to retrieved data - }, - { - "table": String, // name of table from where records are to be fetched - "redaction": Skyflow.RedactionType, // redaction to be applied to retrieved data - "columnName": String, // a unique column name - "colunmnValues": JSONArray() // Array of Column Values of the records to be fetched - } - ] - } - ``` - - An example of get call to fetch records: - ```kotlin - val getCallback = GetCallback() //Custom callback - implementation of Skyflow.Callback - - val recordsArray = JSONArray() - - val record = JSONObject() - val skyflowIDs = JSONArray() - skyflowIDs.put("f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9") - skyflowIDs.put("da26de53-95d5-4bdb-99db-8d8c66a35ff9") - - record.put("ids", skyflowIDs) - record.put("table", "cards") - record.put("redaction", RedactionType.PLAIN_TEXT) - - val record1 = JSONObject() - val recordSkyflowIDs = JSONArray() - recordSkyflowIDs.put("invalid skyflow id") // invalid skyflow ID - - record1.put("ids", recordSkyflowIDs) - record1.put("table", "cards") - record1.put("redaction", RedactionType.PLAIN_TEXT) - - val record2 = JSONObject() - val columnValues = JSONArray() - columnValues.put("john.doe@gmail.com") - columnValues.put("jane.doe@gmail.com") - - record2.put("table", "customers") - record2.put("redaction", RedactionType.PLAIN_TEXT) - record2.put("columnName", "email") - record2.put("columnValues", columnValues) - - val record3 = JSONObject() - val columnValues1 = JSONArray() - columnValues1.put("invalid column value") // invalid column value - - record3.put("table", "customers") - record3.put("redaction", RedactionType.PLAIN_TEXT) - record3.put("columnName", "email") - record3.put("columnValues", columnValues1) - - recordsArray.put(record) - recordsArray.put(record1) - recordsArray.put(record2) - recordsArray.put(record3) - - val records = JSONObject() - records.put("records", recordsArray) - - skyflowClient.getById(records = records, GetOptions(), callback = getCallback) - ``` - - The sample response: - ```json - { - "records": [ - { - "fields": { - "card_number": "4111111111111111", - "expiry_date": "11/35", - "fullname": "myname", - "id": "f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9" - }, - "table": "cards" - }, - { - "fields": { - "card_number": "4111111111111111", - "expiry_date": "10/23", - "fullname": "sam", - "id": "da26de53-95d5-4bdb-99db-8d8c66a35ff9" - }, - "table": "cards" - }, - { - "fields": { - "card_number": "4111111111111111", - "email": "john@doe@gmail.com", - "name": "john", - "id": "f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9" - }, - "table": "customers" - }, - { - "fields": { - "card_number": "4111111111111111", - "email": "jane@doe@gmail.com", - "name": "jane", - "id": "f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9" - }, - "table": "customers" - } - ], - "errors": [ - { - "error": { - "code": "404", - "description": "No Records Found" - }, - "ids": ["invalid skyflow id"] - }, - { - "error": { - "code": "404", - "description": "No Records Found" - }, - "columnName": "customers", - "columnValues": ["invalid column value"] - } - ] - } - ``` - - An example of get call to fetch tokens: - ```kotlin - val getCallback = GetCallback() //Custom callback - implementation of Skyflow.Callback - - val recordsArray = JSONArray() - - val validRecord = JSONObject() - val validSkyflowIDs = JSONArray() - validSkyflowIDs.put("f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9") - validSkyflowIDs.put("da26de53-95d5-4bdb-99db-8d8c66a35ff9") - - validRecord.put("ids", validSkyflowIDs) - validRecord.put("table", "cards") - - val invalidRecord = JSONObject() - val invalidRecordSkyflowIDs = JSONArray() - invalidRecordSkyflowIDs.put("invalid skyflow id") // invalid skyflow ID - - invalidRecord.put("ids", invalidRecordSkyflowIDs) - invalidRecord.put("table", "cards") - - recordsArray.put(validRecord) - recordsArray.put(invalidRecord) - - val records = JSONObject() - records.put("records", recordsArray) - - skyflowClient.getById(records = records, GetOptions(true), callback = getCallback) - ``` - - The sample Response: - ```json - { - "records": [ - { - "fields": { - "card_number": "9802-3257-3113-0294", - "expiry_date": "45012507-f72b-4f5c-9bf9-86b133bae719", - "fullname": "131e2507-f72b-4f5c-9bf9-86b133bae719", - }, - "table": "cards" - }, - { - "fields": { - "card_number": "0294-3213-3157-9802", - "expiry_date": "131e2507-f72b-4f5c-9bf9-86b133bae719", - "fullname": "45012507-f72b-4f5c-9bf9-86b133bae719", - }, - "table": "cards" - } - ], - "errors": [ - { - "error": { - "code": "404", - "description": "No Records Found" - }, - "ids": ["invalid skyflow id"] - } - ] - } - ``` -### Redaction types - There are four enum values in Skyflow.RedactionType: - - `PLAIN_TEXT` - - `MASKED` - - `REDACTED` - - `DEFAULT` - - -## Using Skyflow Elements to reveal data -Skyflow Elements can be used to securely reveal data in an application without exposing your front end to the sensitive data. This is great for use-cases like card issuance where you may want to reveal the card number to a user without increasing your PCI compliance scope. -### Step 1: Create a container -To start, create a container using the `skyflowClient.container(Skyflow.ContainerType.REVEAL)` method as shown below. -```kt -val container = skyflowClient.container(type = Skyflow.ContainerType.REVEAL) -``` - -### Step 2: Create a reveal Element -Next, define a Skyflow Element to reveal data as shown below: -```kt -val revealElementInput = Skyflow.RevealElementInput( - token = "string", - redaction = Skyflow.RedactionType, // optional. Redaction to apply for retrieved data. E.g. RedactionType.MASKED - inputStyles = Skyflow.Styles(), //optional, styles to be applied to the element - labelStyles = Skyflow.Styles(), //optional, styles to be applied to the label of the reveal element - errorTextStyles = Skyflow.Styles(), //optional styles that will be applied to the errorText of the reveal element - label = "cardNumber" //optional, label for the element, - altText = "XXXX XXXX XXXX XXXX" //optional, string that is shown before reveal, will show token if altText is not provided - ) - -``` - -`Notes`: -- `token` is optional only if it is being used in invokeConnection() -- `redaction` defaults to [`RedactionType.PLAIN_TEXT`](#redaction-types) - -The `inputStyles` parameter accepts a styles object as described in the [previous section](#step-2-create-a-collect-element) for collecting data but the only state available for a reveal element is the base state. - -The `labelStyles` and `errorTextStyles` fields accept the above mentioned `Skyflow.Styles` object as described in the [previous section](#step-2-create-a-collect-element), the only state available for a reveal element is the base state. - -The `inputStyles`, `labelStyles` and `errorTextStyles` parameters accepts a styles object as described in the [previous section](#step-2-create-a-collect-element) for collecting data but only a single variant is available i.e. base. - -An example of a inputStyles object: - -```kt -var inputStyles = Skyflow.Styles(base = Skyflow.Style( - borderColor = Color.BLUE)) -``` - -An example of a labelStyles object: - -```kt -var labelStyles = Skyflow.Styles(base = - Skyflow.Style(font = 12)) -``` - -An example of a errorTextStyles object: - -```kt -var labelStyles = Skyflow.Styles(base = - Skyflow.Style(textColor = COLOR.RED)) -``` - -Along with `RevealElementInput`, you can define other options in the `RevealElementOptions` object as described below: -```kotlin -Skyflow.RevealElementOptions( - format: String, // Format for the element. - translation: HashMap // Indicates the allowed data type value for format - enableCopy: Boolean, // Indicates whether to enable the copy icon in reveal elements to copy text to clipboard. Defaults to 'false' -) -``` -- `format`: A string value that indicates how the element should display the value, including placeholder characters that map to keys `translation` If `translation` isn't specified, the `format` value is considered a string literal. - -- `translation`: A hashmap of key/value pairs, where the key is a character that appears in `format` and the value is a regex pattern of acceptable inputs for that character. Each key can only appear once. Defaults to `hashmapOf('X' to "[0-9]")`. - -`enableCopy`: Indicates whether to enable the copy icon in reveal elements to copy text to clipboard. - -Reveal Element Options examples: - -Example 1: -```kotlin -let element = container.create(input: revealElementInput) -Skyflow.RevealElementOptions( - format: "(XXX) XXX-XXXX", - translation: hashmapOf('X' to "[0-9]") -) -``` -Value from vault: "1234121234" - -Value displayed in element: "(123) 412-1234" - -Example 2: -```kotlin -Skyflow.RevealElementOptions( - format: "XXXX-XXXXXX-XXXXX", - translation: hashmapOf('X' to "[0-9]") -) -``` -Value from vault: "374200000000004" - -Value displayed in element: "3742-000000-00004" - -Once you've defined a `Skyflow.RevealElementInput` object and `Skyflow.RevealElementOptions`, you can use the `create()` method of the container to create the Element as shown below: - -```kotlin -let element = container.create(input: revealElementInput, options: Skyflow.RevealElementOptions(format: "XXXX-XXXXXX-XXXXX", -translation: hashmapOf('X' to "[0-9]") -)) -``` - -### Step 3: Mount Elements to the Screen - -Elements used for revealing data are mounted to the screen the same way as Elements used for collecting data. Refer to Step 3 of the [section above](#step-3-mount-elements-to-the-screen). - -### Step 4: Reveal data -When the sensitive data is ready to be retrieved and revealed, call the `reveal()` method on the container as shown below: -```kt -val revealCallback = RevealCallback() //Custom callback - implementation of Skyflow.Callback -container.reveal(callback = revealCallback) -``` - -### UI Error for Reveal Elements - -Helps to display custom error messages on the Skyflow Elements through the methods `setError` and `resetError` on the elements. - -`setError(error : String)` method is used to set the error text for the element, when this method is trigerred, all the current errors present on the element will be overridden with the custom error message passed. This error will be displayed on the element until `resetError()` is trigerred on the same element. - -`resetError()` method is used to clear the custom error message that is set using `setError`. - - -### Set token for Reveal Elements -The `setToken(value: String)` method can be used to set the token of the Reveal Element. If no altText is set, the set token will be displayed on the UI as well. If altText is set, then there will be no change in the UI but the token of the element will be internally updated. -### Set and Clear altText for Reveal Elements -The `setAltText(value: String)` method can be used to set the altText of the Reveal Element. This will cause the altText to be displayed in the UI regardless of whether the token or value is currently being displayed. -`clearAltText()` method can be used to clear the altText, this will cause the element to display the token or actual value of the element. If the element has no token, the element will be empty. - - -### End to end example of revealing data with Skyflow Elements -#### [Sample Code](https://github.com/skyflowapi/skyflow-android/blob/main/samples/src/main/java/com/Skyflow/RevealActivity.kt): -```kt -//Initialize skyflow configuration -val config = Skyflow.Configuration(vaultId = , vaultURL = , tokenProvider = demoTokenProvider) - -//Initialize skyflow client -val skyflowClient = Skyflow.initialize(config) - -//Create a Reveal Container -val container = skyflowClient.container(type = Skyflow.ContainerType.REVEAL) - - -//Create Skyflow.Styles with individual Skyflow.Style variants -val baseStyle = Skyflow.Style(borderColor = Color.BLUE) -val baseTextStyle = Skyflow.Style(textColor = Color.BLACK) -val inputStyles = Skyflow.Styles(base = baseStyle) -val labelStyles = Skyflow.Styles(base = baseTextStyle) -val errorTextStyles = Skyflow.Styles(base = baseTextStyle) - -//Create Reveal Elements -val cardNumberInput = Skyflow.RevealElementInput( - token = "b63ec4e0-bbad-4e43-96e6-6bd50f483f75", - redaction = RedactionType.MASKED, - inputStyles = inputStyles, - labelStyles = labelStyles, - errorTextStyles = errorTextStyles, - label = "cardnumber", - altText = "XXXX XXXX XXXX XXXX" -) - -val cardNumberElement = container.create(context = Context, input = cardNumberInput) - -val nameInput = Skyflow.RevealElementInput( - token = "89024714-6a26-4256-b9d4-55ad69aa4047", - redaction = RedactionType.DEFAULT, - inputStyles = inputStyles, - labelStyles = labelStyles, - errorTextStyles = errorTextStyles, - label = "fullname", - altText = "XXX" -) - -val nameElement = container.create(context = Context,input = nameInput) - -//set error to the element -nameElement.setError("custom error") - -//reset error to the element -nameElement.resetError() - -//Can interact with these objects as a normal UIView Object and add to View - - -//Implement a custom Skyflow.Callback to be called on Reveal success/failure -public class RevealCallback: Skyflow.Callback { - override fun onSuccess(responseBody: Any) { - print(responseBody) - } - override fun onFailure(exception: Exception) { - print(exception) - } -} - -//Initialize custom Skyflow.Callback -val revealCallback = RevealCallback() - -//Call reveal method on RevealContainer -container.reveal(callback = revealCallback) - -``` -The response below shows that some tokens assigned to the reveal elements get revealed successfully, while others fail and remain unrevealed. - - -#### Sample Response:Callback -```json -{ - "success": [ - { - "token": "b63ec4e0-bbad-4e43-96e6-6bd50f483f75" - } - ], - "errors": [ - { - "id": "89024714-6a26-4256-b9d4-55ad69aa4047", - "error": { - "code": 404, - "description": "Tokens not found for 89024714-6a26-4256-b9d4-55ad69aa4047" - } - } - ] -} -``` -## Limitation -Currently the skyflow collect elements and reveal elements can't be used in the XML layout definition, we have to add them to the views programatically. +Runnable reference apps for each SDK live under [`samples/`](samples/README.md): +- [`samples/skyvault/`](samples/skyvault/) — skyflow-android-sdk (PDB vault) sample +- [`samples/flowvault/`](samples/flowvault/) — skyflow-flowvault-android-sdk (Flow vault) sample +## License +See [LICENSE](LICENSE). diff --git a/Skyflow/src/main/kotlin/Skyflow/CollectContainer.kt b/Skyflow/src/main/kotlin/Skyflow/CollectContainer.kt deleted file mode 100644 index a93115fb..00000000 --- a/Skyflow/src/main/kotlin/Skyflow/CollectContainer.kt +++ /dev/null @@ -1,151 +0,0 @@ -package Skyflow - -import Skyflow.collect.client.CollectRequestBody -import Skyflow.core.Logger -import Skyflow.core.Messages -import Skyflow.core.getMessage -import Skyflow.utils.Utils -import android.content.Context -import com.Skyflow.core.container.ContainerProtocol -import org.json.JSONObject -import java.util.* - -open class CollectContainer : ContainerProtocol { -} - -private val tag = CollectContainer::class.qualifiedName - -fun Container.create( - context: Context, - input: CollectElementInput, - options: CollectElementOptions = CollectElementOptions() -): TextField { - Utils.checkInputFormatOptions(input.type, options, configuration.options.logLevel) - Logger.info( - tag, - Messages.VALIDATE_INPUT_FORMAT_OPTIONS.getMessage(input.label), - configuration.options.logLevel - ) - Logger.info( - tag, - Messages.CREATED_COLLECT_ELEMENT.getMessage(input.label), - configuration.options.logLevel - ) - val collectElement = TextField(context, configuration.options, collectElements.size) - collectElement.setupField(input, options) - collectElements.add(collectElement) - val uuid = UUID.randomUUID().toString() - client.elementMap[uuid] = collectElement - collectElement.uuid = uuid - return collectElement -} - -fun Container.collect(callback: Callback, options: CollectOptions? = CollectOptions()){ - try { - Utils.checkVaultDetails(client.configuration) - Logger.info(tag, Messages.VALIDATE_COLLECT_RECORDS.getMessage(), configuration.options.logLevel) - validateElements() - post(callback,options) - } - catch (e:Exception) - { - callback.onFailure(Utils.constructErrorResponse(e)) - } -} -internal fun Container.validateElements() { - var errors = "" - for (element in this.collectElements) { - errors = validateElement(element,errors) - } - if (errors != "") { - throw SkyflowError(SkyflowErrorCode.INVALID_INPUT, tag, configuration.options.logLevel, arrayOf(errors)) - } -} - -internal fun Container.validateElement(element: TextField,err:String) : String -{ - var errorOnElement = err - if (!element.isAttachedToWindow()) { - throw SkyflowError(SkyflowErrorCode.ELEMENT_NOT_MOUNTED, - tag, - configuration.options.logLevel, - arrayOf(element.columnName)) - } - when { - element.collectInput.table.equals(null) -> { - throw SkyflowError(SkyflowErrorCode.MISSING_TABLE_IN_ELEMENT, - tag, - configuration.options.logLevel, - arrayOf(element.fieldType.toString())) - } - element.collectInput.column.equals(null) -> { - throw SkyflowError(SkyflowErrorCode.MISSING_COLUMN, - tag, - configuration.options.logLevel, - arrayOf(element.fieldType.toString())) - } - element.collectInput.table!!.isEmpty() -> { - throw SkyflowError(SkyflowErrorCode.ELEMENT_EMPTY_TABLE_NAME, - tag, - configuration.options.logLevel, - arrayOf(element.fieldType.toString())) - } - element.collectInput.column!!.isEmpty() -> { - throw SkyflowError(SkyflowErrorCode.EMPTY_COLUMN_NAME, - tag, - configuration.options.logLevel, - arrayOf(element.fieldType.toString())) - } - else -> { - val state = element.getState() - val error = state["validationError"] - if (!(state["isValid"] as Boolean)) { - element.invalidTextField() - errorOnElement += "for " + element.columnName + " " + (error as String) + "\n" - } - } - } - return errorOnElement -} -internal fun Container.post(callback:Callback,options: CollectOptions?) -{ - // Separate insert and update elements/records - val (insertElements, insertAdditionalFields, updateRecords) = Skyflow.collect.client.CollectRequestBody.separateInsertAndUpdateRecords( - this.collectElements, - options?.additionalFields, - configuration.options.logLevel - ) - - val hasInsertData = insertElements.isNotEmpty() || insertAdditionalFields != null - val hasUpdateRecords = updateRecords.isNotEmpty() - - if (hasInsertData && hasUpdateRecords) { - // Mixed case: both insert and update - val insertRecordsJson = if (insertElements.isNotEmpty()) { - JSONObject(Skyflow.collect.client.CollectRequestBody.createRequestBody( - insertElements, - insertAdditionalFields, - configuration.options.logLevel - )) - } else { - insertAdditionalFields - } - - val insertOptions = InsertOptions(options?.token ?: true, options?.upsert) - this.client.apiClient.postWithUpdate(insertRecordsJson, updateRecords, callback, insertOptions) - } else if (hasUpdateRecords) { - // Only update records - val insertOptions = InsertOptions(options?.token ?: true, options?.upsert) - this.client.apiClient.postWithUpdate(null, updateRecords, callback, insertOptions) - } else { - // Only insert records - val records = Skyflow.collect.client.CollectRequestBody.createRequestBody( - this.collectElements, - insertAdditionalFields, - configuration.options.logLevel - ) - val insertOptions = InsertOptions(options?.token ?: true, options?.upsert) - this.client.apiClient.post(JSONObject(records), callback, insertOptions) - } -} - diff --git a/Skyflow/src/main/kotlin/Skyflow/CollectElementInput.kt b/Skyflow/src/main/kotlin/Skyflow/CollectElementInput.kt deleted file mode 100644 index e2eef7de..00000000 --- a/Skyflow/src/main/kotlin/Skyflow/CollectElementInput.kt +++ /dev/null @@ -1,51 +0,0 @@ -package Skyflow - -import com.Skyflow.collect.elements.validations.ValidationSet - -class CollectElementInput( - internal var table: String? = null, - internal var column: String? = null, - internal var inputStyles: Styles = Styles(), - internal var labelStyles: Styles = Styles(), - internal var errorTextStyles: Styles = Styles(), - internal var label: String = "", - internal var placeholder: String = "", - internal var validations: ValidationSet = ValidationSet(), - internal var skyflowID: String? = null -) { - - internal lateinit var type: SkyflowElementType - - @Deprecated( - "altText parameter is deprecated", - level = DeprecationLevel.WARNING - ) - internal lateinit var altText: String - - constructor( - table: String? = null, - column: String? = null, - type: SkyflowElementType, - inputStyles: Styles = Styles(), - labelStyles: Styles = Styles(), - errorTextStyles: Styles = Styles(), - label: String = "", - placeholder: String = "", - altText: String = "", - validations: ValidationSet = ValidationSet(), - skyflowID: String? = null - ) : this( - table, - column, - inputStyles, - labelStyles, - errorTextStyles, - label, - placeholder, - validations, - skyflowID - ) { - this.type = type - this.altText = altText - } -} \ No newline at end of file diff --git a/Skyflow/src/main/kotlin/Skyflow/Configuration.kt b/Skyflow/src/main/kotlin/Skyflow/Configuration.kt deleted file mode 100644 index 9fb525ac..00000000 --- a/Skyflow/src/main/kotlin/Skyflow/Configuration.kt +++ /dev/null @@ -1,18 +0,0 @@ -package Skyflow - - - -class Configuration( - val vaultID: String = "", - var vaultURL: String = "", - val tokenProvider: TokenProvider, - val options: Options = Options(), -){ - init { - if( vaultURL.endsWith("/")){ - vaultURL += "v1/vaults/" - } else{ - vaultURL += "/v1/vaults/" - } - } -} \ No newline at end of file diff --git a/Skyflow/src/main/kotlin/Skyflow/Init.kt b/Skyflow/src/main/kotlin/Skyflow/Init.kt deleted file mode 100644 index 04ed7b37..00000000 --- a/Skyflow/src/main/kotlin/Skyflow/Init.kt +++ /dev/null @@ -1,12 +0,0 @@ -package Skyflow - -import Skyflow.core.Logger -import Skyflow.core.Messages -import Skyflow.core.getMessage - - -fun init(configuration: Configuration) : Client{ - val tag = Client::class.qualifiedName - Logger.info(tag, Messages.CLIENT_INITIALIZED.getMessage(), configuration.options.logLevel) - return Client(configuration) -} \ No newline at end of file diff --git a/Skyflow/src/main/kotlin/Skyflow/RevealContainer.kt b/Skyflow/src/main/kotlin/Skyflow/RevealContainer.kt deleted file mode 100644 index 5efb4e89..00000000 --- a/Skyflow/src/main/kotlin/Skyflow/RevealContainer.kt +++ /dev/null @@ -1,96 +0,0 @@ -package Skyflow - -import Skyflow.core.Logger -import Skyflow.core.Messages -import Skyflow.core.getMessage -import android.content.Context -import com.Skyflow.core.container.ContainerProtocol -import Skyflow.reveal.RevealRequestBody -import Skyflow.reveal.RevealValueCallback -import Skyflow.utils.Utils -import Skyflow.utils.Utils.Companion.checkIfElementsMounted -import java.lang.Exception -import java.util.* - -class RevealContainer : ContainerProtocol { - private val tag = RevealContainer::class.qualifiedName -} - -private val tag = RevealContainer::class.qualifiedName - -fun Container.create( - context: Context, - input: RevealElementInput, - options: RevealElementOptions = RevealElementOptions() -): Label { - Logger.info( - tag, - Messages.CREATED_REVEAL_ELEMENT.getMessage(input.label), - configuration.options.logLevel - ) - - val revealElement = Label(context) - revealElement.setupField(input, options) - revealElements.add(revealElement) - - val uuid = UUID.randomUUID().toString() - client.elementMap.put(uuid, revealElement) - revealElement.uuid = uuid - - return revealElement -} - -fun Container.reveal( - callback: Callback, - options: RevealOptions? = RevealOptions() -) { - try { - Utils.checkVaultDetails(client.configuration) - validateElements() - Logger.info( - tag, - Messages.VALIDATE_REVEAL_RECORDS.getMessage(), - configuration.options.logLevel - ) - get(callback, options) - } catch (e: Exception) { - callback.onFailure(Utils.constructError(e)) - } -} - -internal fun Container.validateElements() { - for (element in this.revealElements) { - val token = element.revealInput.token - if (!checkIfElementsMounted(element)) { - throw SkyflowError( - SkyflowErrorCode.ELEMENT_NOT_MOUNTED_REVEAL, tag, configuration.options.logLevel, - arrayOf(element.revealInput.label) - ) - } - - if (element.isTokenNull) { - throw SkyflowError( - SkyflowErrorCode.TOKEN_KEY_NOT_FOUND_REVEAL, tag, configuration.options.logLevel, - ) - } else if (token!!.isEmpty()) { - throw SkyflowError( - SkyflowErrorCode.EMPTY_TOKEN_REVEAL, tag, configuration.options.logLevel - ) - } else if (element.isError) { - throw SkyflowError( - SkyflowErrorCode.ERROR_STATE_REVEAL, tag, configuration.options.logLevel, - arrayOf("${element.error.text}") - ) - } - } -} - -internal fun Container.get(callback: Callback, options: RevealOptions?) { - val revealValueCallback = RevealValueCallback( - callback, - this.revealElements, - configuration.options.logLevel - ) - val records = RevealRequestBody.createRequestBody(this.revealElements) - this.client.apiClient.get(records, revealValueCallback) -} \ No newline at end of file diff --git a/Skyflow/src/main/kotlin/Skyflow/RevealElementInput.kt b/Skyflow/src/main/kotlin/Skyflow/RevealElementInput.kt deleted file mode 100644 index 1e132a16..00000000 --- a/Skyflow/src/main/kotlin/Skyflow/RevealElementInput.kt +++ /dev/null @@ -1,11 +0,0 @@ -package Skyflow - -class RevealElementInput( - internal var token: String? = null, - internal var redaction: RedactionType? = RedactionType.PLAIN_TEXT, - internal var inputStyles: Styles = Styles(), - internal var labelStyles: Styles = Styles(), - internal var errorTextStyles: Styles = Styles(), - internal var label: String = "", - internal var altText: String = "" -) {} \ No newline at end of file diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowInternalValidationProtocol.kt b/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowInternalValidationProtocol.kt deleted file mode 100644 index 8cd56d42..00000000 --- a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowInternalValidationProtocol.kt +++ /dev/null @@ -1,5 +0,0 @@ -package Skyflow.collect.elements.validations - -internal interface SkyflowInternalValidationProtocol { - fun validate(text: String?) : Boolean -} \ No newline at end of file diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/ValidationRule.kt b/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/ValidationRule.kt deleted file mode 100644 index dc4c13ea..00000000 --- a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/ValidationRule.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.Skyflow.collect.elements.validations - -interface ValidationRule { - var error: SkyflowValidationError -} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 09ed153b..8375f294 100644 --- a/build.gradle +++ b/build.gradle @@ -9,6 +9,10 @@ task clean(type: Delete) { } tasks.register("runOnGitHub") { // 1 - dependsOn(":Skyflow:lint", ":Skyflow:test") // 2 ==> CUSTOMIZE THIS LINE + // PR CI aggregate: lint + test BOTH published modules (legacy skyvault + flowvault) in one task. + dependsOn( + ":skyvault:lint", ":skyvault:test", + ":flowvault:lint", ":flowvault:test" + ) group = "custom" // 3 } \ No newline at end of file diff --git a/Skyflow/src/main/kotlin/Skyflow/Callback.kt b/common/src/main/kotlin/Skyflow/auth/Callback.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/Callback.kt rename to common/src/main/kotlin/Skyflow/auth/Callback.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/TokenProvider.kt b/common/src/main/kotlin/Skyflow/auth/TokenProvider.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/TokenProvider.kt rename to common/src/main/kotlin/Skyflow/auth/TokenProvider.kt diff --git a/common/src/main/kotlin/Skyflow/client/BaseInit.kt b/common/src/main/kotlin/Skyflow/client/BaseInit.kt new file mode 100644 index 00000000..b5d56dae --- /dev/null +++ b/common/src/main/kotlin/Skyflow/client/BaseInit.kt @@ -0,0 +1,17 @@ +package Skyflow + +import Skyflow.core.Logger +import Skyflow.core.Messages +import Skyflow.core.getMessage + +internal fun baseInit( + sdkVersion: String, + logLevel: LogLevel, + factory: () -> T +): T { + // Stamp this product's version into core so error messages self-report it. + SdkInfo.version = sdkVersion + val client = factory() + Logger.info(client::class.qualifiedName, Messages.CLIENT_INITIALIZED.getMessage(), logLevel) + return client +} diff --git a/common/src/main/kotlin/Skyflow/client/BaseSkyflowClient.kt b/common/src/main/kotlin/Skyflow/client/BaseSkyflowClient.kt new file mode 100644 index 00000000..e23ff38d --- /dev/null +++ b/common/src/main/kotlin/Skyflow/client/BaseSkyflowClient.kt @@ -0,0 +1,57 @@ +package Skyflow + +import Skyflow.core.Logger +import Skyflow.core.Messages +import Skyflow.core.getMessage +import android.content.Context +import com.Skyflow.core.container.ContainerProtocol +import kotlin.reflect.KClass + +/** + * Contract-agnostic base client shared by both SDKs (see docs/sdk-split-plan.md). + * + * It owns only what is common to every backend contract: the [configuration], the element + * registry ([elementMap]), and the `container(...)` factories. Each product's concrete + * `Client` extends this: + * - the legacy (`skyflow-android-sdk`) `Client` adds the v1 client methods + * (`insert` / `get` / `getById` / `detokenize` / connection calls) and the v1 api client; + * - the FlowVault (`skyflow-flowvault-android-sdk`) `Client` adds only its v2 api client and + * has no standalone client methods. + * + * `container()` lives here because it is identical for both products. Contract-specific work + * (the api client, request building) is reached from the per-product container extensions via + * the concrete `Client`. + */ +abstract class BaseSkyflowClient internal constructor( + // `open` so the legacy Client can covariantly narrow the return type to v1 `Configuration`, + // preserving the 1.27.0 binary signature `getConfiguration()LSkyflow/Configuration;`. + open val configuration: BaseConfiguration, +) : ISkyflowClient { + internal val tag = this::class.qualifiedName + internal val elementMap = HashMap() + + override fun container(type: KClass): Container { + if (type == ContainerType.COLLECT) { + Logger.info(tag, Messages.COLLECT_CONTAINER_CREATED.getMessage(), configuration.options.logLevel) + } else if (type == ContainerType.REVEAL) { + Logger.info(tag, Messages.REVEAL_CONTAINER_CREATED.getMessage(), configuration.options.logLevel) + } + return Container(configuration, this) + } + + override fun container( + type: KClass, + context: Context, + options: ContainerOptions + ): Container { + when (type) { + ContainerType.COMPOSABLE -> Logger.info( + tag, + Messages.COMPOSABLE_CONTAINER_CREATED.getMessage(), + configuration.options.logLevel + ) + else -> container(type) + } + return Container(configuration, this, context, options) + } +} diff --git a/common/src/main/kotlin/Skyflow/client/ISkyflowClient.kt b/common/src/main/kotlin/Skyflow/client/ISkyflowClient.kt new file mode 100644 index 00000000..668b599b --- /dev/null +++ b/common/src/main/kotlin/Skyflow/client/ISkyflowClient.kt @@ -0,0 +1,16 @@ +package Skyflow + +import android.content.Context +import com.Skyflow.core.container.ContainerProtocol +import kotlin.reflect.KClass + + +interface ISkyflowClient { + fun container(type: KClass): Container + + fun container( + type: KClass, + context: Context, + options: ContainerOptions + ): Container +} diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/client/CollectRequestRecord.kt b/common/src/main/kotlin/Skyflow/collect/client/CollectRequestRecord.kt similarity index 98% rename from Skyflow/src/main/kotlin/Skyflow/collect/client/CollectRequestRecord.kt rename to common/src/main/kotlin/Skyflow/collect/client/CollectRequestRecord.kt index 722bb2c3..aa4931c7 100644 --- a/Skyflow/src/main/kotlin/Skyflow/collect/client/CollectRequestRecord.kt +++ b/common/src/main/kotlin/Skyflow/collect/client/CollectRequestRecord.kt @@ -1,4 +1,4 @@ package Skyflow.collect.client internal data class CollectRequestRecord(val columnName:String,val value:Any) { -} \ No newline at end of file +} diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/utils/CardType.kt b/common/src/main/kotlin/Skyflow/collect/elements/utils/CardType.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/utils/CardType.kt rename to common/src/main/kotlin/Skyflow/collect/elements/utils/CardType.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/utils/DateValidator.kt b/common/src/main/kotlin/Skyflow/collect/elements/utils/DateValidator.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/utils/DateValidator.kt rename to common/src/main/kotlin/Skyflow/collect/elements/utils/DateValidator.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/utils/Spacespan.kt b/common/src/main/kotlin/Skyflow/collect/elements/utils/Spacespan.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/utils/Spacespan.kt rename to common/src/main/kotlin/Skyflow/collect/elements/utils/Spacespan.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/utils/VibratorHelper.kt b/common/src/main/kotlin/Skyflow/collect/elements/utils/VibratorHelper.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/utils/VibratorHelper.kt rename to common/src/main/kotlin/Skyflow/collect/elements/utils/VibratorHelper.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/ElementValueMatchRule.kt b/common/src/main/kotlin/Skyflow/collect/elements/validations/ElementValueMatchRule.kt similarity index 91% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/ElementValueMatchRule.kt rename to common/src/main/kotlin/Skyflow/collect/elements/validations/ElementValueMatchRule.kt index b1374173..e4e160c8 100644 --- a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/ElementValueMatchRule.kt +++ b/common/src/main/kotlin/Skyflow/collect/elements/validations/ElementValueMatchRule.kt @@ -4,7 +4,7 @@ import Skyflow.Element import com.Skyflow.collect.elements.validations.SkyflowValidationError import com.Skyflow.collect.elements.validations.ValidationRule -class ElementValueMatchRule(var element:Element, override var error: SkyflowValidationError = "validation failed"): ValidationRule,SkyflowInternalValidationProtocol { +class ElementValueMatchRule(var element:Element, override var error: SkyflowValidationError = "validation failed"): ValidationRule { override fun validate(text: String?): Boolean { return element.getValue().equals(text) } } \ No newline at end of file diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/LengthMatchRule.kt b/common/src/main/kotlin/Skyflow/collect/elements/validations/LengthMatchRule.kt similarity index 73% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/LengthMatchRule.kt rename to common/src/main/kotlin/Skyflow/collect/elements/validations/LengthMatchRule.kt index bf80c9d6..9aa5370c 100644 --- a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/LengthMatchRule.kt +++ b/common/src/main/kotlin/Skyflow/collect/elements/validations/LengthMatchRule.kt @@ -1,12 +1,10 @@ package com.Skyflow.collect.elements.validations -import Skyflow.collect.elements.validations.SkyflowInternalValidationProtocol - /** Validate input in scope of length. */ class LengthMatchRule(val minLength: Int, val maxLength: Int, - override var error: SkyflowValidationError = "validation failed") : ValidationRule,SkyflowInternalValidationProtocol { + override var error: SkyflowValidationError = "validation failed") : ValidationRule { /// validate length of text @@ -20,4 +18,4 @@ class LengthMatchRule(val minLength: Int, val maxLength: Int, } -} \ No newline at end of file +} diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/RegexMatchRule.kt b/common/src/main/kotlin/Skyflow/collect/elements/validations/RegexMatchRule.kt similarity index 72% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/RegexMatchRule.kt rename to common/src/main/kotlin/Skyflow/collect/elements/validations/RegexMatchRule.kt index 971a50f9..241e3449 100644 --- a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/RegexMatchRule.kt +++ b/common/src/main/kotlin/Skyflow/collect/elements/validations/RegexMatchRule.kt @@ -1,12 +1,11 @@ package com.Skyflow.collect.elements.validations -import Skyflow.collect.elements.validations.SkyflowInternalValidationProtocol import java.util.regex.Pattern /** Validate input in scope of length. */ -class RegexMatchRule(var regex:String, override var error: SkyflowValidationError = "validation failed") : ValidationRule,SkyflowInternalValidationProtocol { +class RegexMatchRule(var regex:String, override var error: SkyflowValidationError = "validation failed") : ValidationRule { /// validate length of text @@ -20,4 +19,4 @@ class RegexMatchRule(var regex:String, override var error: SkyflowValidationErro return pattern.matcher(text).matches() } -} \ No newline at end of file +} diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateCardNumber.kt b/common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateCardNumber.kt similarity index 90% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateCardNumber.kt rename to common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateCardNumber.kt index a9910370..6936dfc6 100644 --- a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateCardNumber.kt +++ b/common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateCardNumber.kt @@ -1,13 +1,12 @@ package com.Skyflow.collect.elements.validations import Skyflow.collect.elements.utils.CardType -import Skyflow.collect.elements.validations.SkyflowInternalValidationProtocol import java.util.regex.Pattern /** Validate input in the scope of matching supported cards. */ -internal class SkyflowValidateCardNumber(override var error: SkyflowValidationError = "") : ValidationRule,SkyflowInternalValidationProtocol { +internal class SkyflowValidateCardNumber(override var error: SkyflowValidationError = "") : ValidationRule { override fun validate(text: String?) : Boolean { val cardNumber = text!!.replace(" ", "").replace("-", "") @@ -42,5 +41,3 @@ internal class SkyflowValidateCardNumber(override var error: SkyflowValidationEr return (oddSum + evenSum) % 10 == 0 } } - - diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateExpireDate.kt b/common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateExpireDate.kt similarity index 94% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateExpireDate.kt rename to common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateExpireDate.kt index 45b769cd..89c80c8d 100644 --- a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateExpireDate.kt +++ b/common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateExpireDate.kt @@ -1,6 +1,5 @@ package com.Skyflow.collect.elements.validations -import Skyflow.collect.elements.validations.SkyflowInternalValidationProtocol import Skyflow.utils.Utils import android.text.TextUtils import java.util.* @@ -8,7 +7,7 @@ import java.util.* /** Validate input in scope of length. */ -internal class SkyflowValidateExpireDate(var format:String ="mm/yy", override var error: SkyflowValidationError = "INVALID_EXPIRE_DATE") : ValidationRule,SkyflowInternalValidationProtocol { +internal class SkyflowValidateExpireDate(var format:String ="mm/yy", override var error: SkyflowValidationError = "INVALID_EXPIRE_DATE") : ValidationRule { /// validate length of text @@ -88,4 +87,4 @@ internal enum class SkyflowExpireDateFormat(var yearCharacters:Int,var monthChar LONGYEAR(4,2,"yyyy") -} \ No newline at end of file +} diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateLengthMatch.kt b/common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateLengthMatch.kt similarity index 78% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateLengthMatch.kt rename to common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateLengthMatch.kt index 47b30414..7d281113 100644 --- a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateLengthMatch.kt +++ b/common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateLengthMatch.kt @@ -1,11 +1,9 @@ package com.Skyflow.collect.elements.validations -import Skyflow.collect.elements.validations.SkyflowInternalValidationProtocol - /** Validate input in scope of length. */ -internal class SkyflowValidateLengthMatch( val lengths:IntArray,override var error: SkyflowValidationError = "") : ValidationRule,SkyflowInternalValidationProtocol { +internal class SkyflowValidateLengthMatch( val lengths:IntArray,override var error: SkyflowValidationError = "") : ValidationRule { /// validate length of text @@ -18,4 +16,4 @@ internal class SkyflowValidateLengthMatch( val lengths:IntArray,override var err } -} \ No newline at end of file +} diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateMonth.kt b/common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateMonth.kt similarity index 87% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateMonth.kt rename to common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateMonth.kt index 9a3dda6d..12bf5fda 100644 --- a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateMonth.kt +++ b/common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateMonth.kt @@ -4,7 +4,7 @@ import android.text.TextUtils import com.Skyflow.collect.elements.validations.SkyflowValidationError import com.Skyflow.collect.elements.validations.ValidationRule -class SkyflowValidateMonth(override var error: SkyflowValidationError = "INVALID_EXPIRE_MONTH") : ValidationRule,SkyflowInternalValidationProtocol { +class SkyflowValidateMonth(override var error: SkyflowValidationError = "INVALID_EXPIRE_MONTH") : ValidationRule { override fun validate(text: String?): Boolean { if(text!!.isEmpty()) { return true diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateYear.kt b/common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateYear.kt similarity index 96% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateYear.kt rename to common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateYear.kt index d5ed5f30..febddd88 100644 --- a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateYear.kt +++ b/common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidateYear.kt @@ -6,7 +6,7 @@ import android.util.Log import com.Skyflow.collect.elements.validations.SkyflowValidationError import com.Skyflow.collect.elements.validations.ValidationRule -class SkyflowValidateYear(override var error: SkyflowValidationError = "INVALID_EXPIRE_YEAR",var format:String="yy") : ValidationRule,SkyflowInternalValidationProtocol { +class SkyflowValidateYear(override var error: SkyflowValidationError = "INVALID_EXPIRE_YEAR",var format:String="yy") : ValidationRule { override fun validate(text: String?): Boolean { if(text!!.isEmpty()) { return true diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidationError.kt b/common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidationError.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidationError.kt rename to common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidationError.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidator.kt b/common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidator.kt similarity index 79% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidator.kt rename to common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidator.kt index 3728229a..6af07997 100644 --- a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidator.kt +++ b/common/src/main/kotlin/Skyflow/collect/elements/validations/SkyflowValidator.kt @@ -1,7 +1,5 @@ package com.Skyflow.collect.elements.validations -import Skyflow.collect.elements.validations.SkyflowInternalValidationProtocol - internal class SkyflowValidator { companion object { @@ -12,7 +10,6 @@ internal class SkyflowValidator { while (iterator.hasNext()) { val value = iterator.next() - value as SkyflowInternalValidationProtocol if(!value.validate(input)) return value.error } diff --git a/common/src/main/kotlin/Skyflow/collect/elements/validations/ValidationRule.kt b/common/src/main/kotlin/Skyflow/collect/elements/validations/ValidationRule.kt new file mode 100644 index 00000000..e5d02829 --- /dev/null +++ b/common/src/main/kotlin/Skyflow/collect/elements/validations/ValidationRule.kt @@ -0,0 +1,17 @@ +package com.Skyflow.collect.elements.validations + +/** + * Contract for a custom validation rule attached to a collect element via [ValidationSet]. + * Implement this interface to plug in your own validation logic. + */ +interface ValidationRule { + var error: SkyflowValidationError + + /** + * Return `true` when [text] (the element's current value) is valid, `false` otherwise. + * When it returns `false`, [error] is surfaced on the element. Follow the built-in rules' + * convention of returning `true` for empty input, so emptiness is governed by the element's + * `required` flag rather than by custom rules. + */ + fun validate(text: String?): Boolean +} \ No newline at end of file diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/ValidationSet.kt b/common/src/main/kotlin/Skyflow/collect/elements/validations/ValidationSet.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/collect/elements/validations/ValidationSet.kt rename to common/src/main/kotlin/Skyflow/collect/elements/validations/ValidationSet.kt diff --git a/common/src/main/kotlin/Skyflow/composable/ComposableContainerClass.kt b/common/src/main/kotlin/Skyflow/composable/ComposableContainerClass.kt new file mode 100644 index 00000000..ed232016 --- /dev/null +++ b/common/src/main/kotlin/Skyflow/composable/ComposableContainerClass.kt @@ -0,0 +1,10 @@ +package Skyflow.composable + +import com.Skyflow.core.container.ContainerProtocol + +/** + * Neutral composable-container marker type, shared by both SDKs and referenced by + * `ContainerType`. The contract-specific operations are extension functions defined in each + * product's module (see docs/sdk-split-plan.md). + */ +class ComposableContainer : ContainerProtocol diff --git a/Skyflow/src/main/kotlin/Skyflow/composable/ComposableErrorsList.kt b/common/src/main/kotlin/Skyflow/composable/ComposableErrorsList.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/composable/ComposableErrorsList.kt rename to common/src/main/kotlin/Skyflow/composable/ComposableErrorsList.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/composable/ComposableEvents.kt b/common/src/main/kotlin/Skyflow/composable/ComposableEvents.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/composable/ComposableEvents.kt rename to common/src/main/kotlin/Skyflow/composable/ComposableEvents.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/composable/ComposableStyles.kt b/common/src/main/kotlin/Skyflow/composable/ComposableStyles.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/composable/ComposableStyles.kt rename to common/src/main/kotlin/Skyflow/composable/ComposableStyles.kt diff --git a/common/src/main/kotlin/Skyflow/config/BaseConfiguration.kt b/common/src/main/kotlin/Skyflow/config/BaseConfiguration.kt new file mode 100644 index 00000000..ec03b5d2 --- /dev/null +++ b/common/src/main/kotlin/Skyflow/config/BaseConfiguration.kt @@ -0,0 +1,18 @@ +package Skyflow + +/** + * Contract-agnostic configuration shared by both SDKs (see docs/sdk-split-plan.md). + * + * Holds only the neutral fields. Any contract-specific transform of these values (e.g. the + * legacy v1 `.../v1/vaults/` URL suffix) belongs in that product's `Configuration` subclass, + * NOT here — core must stay neutral. Core reads configuration through this base type. + */ +open class BaseConfiguration( + val vaultID: String = "", + var vaultURL: String = "", + val tokenProvider: TokenProvider, + val options: Options = Options(), + // Neutral superset field: the FlowVault (v2) API client injects a custom OkHttpClient; the + // legacy (v1) client ignores it and uses the default. Kept here so core stays contract-free. + val okHttpClient: okhttp3.OkHttpClient = okhttp3.OkHttpClient(), +) diff --git a/Skyflow/src/main/kotlin/Skyflow/Env.kt b/common/src/main/kotlin/Skyflow/config/Env.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/Env.kt rename to common/src/main/kotlin/Skyflow/config/Env.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/Options.kt b/common/src/main/kotlin/Skyflow/config/Options.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/Options.kt rename to common/src/main/kotlin/Skyflow/config/Options.kt diff --git a/common/src/main/kotlin/Skyflow/config/SdkInfo.kt b/common/src/main/kotlin/Skyflow/config/SdkInfo.kt new file mode 100644 index 00000000..03838a65 --- /dev/null +++ b/common/src/main/kotlin/Skyflow/config/SdkInfo.kt @@ -0,0 +1,14 @@ +package Skyflow + +/** + * Per-product SDK version that core reads for error-message rendering (see docs/sdk-split-plan.md). + * Each SDK stamps it from its own `BuildConfig.SDK_VERSION` inside `init()`; the default matches the + * legacy product so a message rendered before `init()` runs is still well-formed. + * + * The SDK *name* is intentionally NOT held here: it must never appear in error messages (which stay + * byte-identical to 1.27.0's "Android SDK v"), and telemetry reads `BuildConfig.SDK_NAME` + * directly (see Utils.fetchMetrics). + */ +internal object SdkInfo { + var version: String = "1.27.0" +} diff --git a/common/src/main/kotlin/Skyflow/container/BaseCollectContainer.kt b/common/src/main/kotlin/Skyflow/container/BaseCollectContainer.kt new file mode 100644 index 00000000..5b08a75f --- /dev/null +++ b/common/src/main/kotlin/Skyflow/container/BaseCollectContainer.kt @@ -0,0 +1,67 @@ +package Skyflow + +import Skyflow.core.Logger +import Skyflow.core.Messages +import Skyflow.core.getMessage +import Skyflow.utils.Utils +import android.content.Context +import java.util.* + +private val tag = CollectContainer::class.qualifiedName + +internal fun Container.createElement( + context: Context, + input: BaseCollectElementInput, + options: CollectElementOptions +): TextField { + Utils.checkInputFormatOptions(input.type, options, configuration.options.logLevel) + Logger.info(tag, Messages.VALIDATE_INPUT_FORMAT_OPTIONS.getMessage(input.label), configuration.options.logLevel) + Logger.info(tag, Messages.CREATED_COLLECT_ELEMENT.getMessage(input.label), configuration.options.logLevel) + val collectElement = TextField(context, configuration.options, collectElements.size) + collectElement.setupField(input, options) + collectElements.add(collectElement) + val uuid = UUID.randomUUID().toString() + client.elementMap[uuid] = collectElement + collectElement.uuid = uuid + return collectElement +} + +internal fun Container.validateElements() { + var errors = "" + for (element in this.collectElements) { + errors = validateElement(element, errors) + } + if (errors != "") { + throw SkyflowInternalError(SkyflowErrorCode.INVALID_INPUT, tag, configuration.options.logLevel, arrayOf(errors)) + } +} + +internal fun Container.validateElement(element: TextField, err: String): String { + var errorOnElement = err + if (!element.isAttachedToWindow()) { + throw SkyflowInternalError(SkyflowErrorCode.ELEMENT_NOT_MOUNTED, tag, configuration.options.logLevel, arrayOf(element.columnName)) + } + when { + 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.tableName!!.isEmpty() -> { + throw SkyflowInternalError(SkyflowErrorCode.ELEMENT_EMPTY_TABLE_NAME, tag, configuration.options.logLevel, arrayOf(element.fieldType.toString())) + } + element.collectInput.column!!.isEmpty() -> { + throw SkyflowInternalError(SkyflowErrorCode.EMPTY_COLUMN_NAME, tag, configuration.options.logLevel, arrayOf(element.fieldType.toString())) + } + else -> { + val state = element.getState() + val error = state["validationError"] + if (!(state["isValid"] as Boolean)) { + element.invalidTextField() + errorOnElement += "for " + element.columnName + " " + (error as String) + "\n" + } + } + } + return errorOnElement +} diff --git a/common/src/main/kotlin/Skyflow/container/BaseRevealContainer.kt b/common/src/main/kotlin/Skyflow/container/BaseRevealContainer.kt new file mode 100644 index 00000000..c91b2e7e --- /dev/null +++ b/common/src/main/kotlin/Skyflow/container/BaseRevealContainer.kt @@ -0,0 +1,52 @@ +package Skyflow + +import Skyflow.core.Logger +import Skyflow.core.Messages +import Skyflow.core.getMessage +import Skyflow.utils.Utils.Companion.checkIfElementsMounted +import android.content.Context +import java.util.* + +private val tag = RevealContainer::class.qualifiedName + +internal fun Container.createLabel( + context: Context, + input: BaseRevealElementInput, + options: RevealElementOptions +): Label { + Logger.info(tag, Messages.CREATED_REVEAL_ELEMENT.getMessage(input.label), configuration.options.logLevel) + val revealElement = Label(context) + revealElement.setupField(input, options) + revealElements.add(revealElement) + val uuid = UUID.randomUUID().toString() + client.elementMap.put(uuid, revealElement) + revealElement.uuid = uuid + return revealElement +} + +internal fun Container.validateElements() { + for (element in this.revealElements) { + val token = element.revealInput.token + if (!checkIfElementsMounted(element)) { + throw SkyflowInternalError( + SkyflowErrorCode.ELEMENT_NOT_MOUNTED_REVEAL, tag, configuration.options.logLevel, + arrayOf(element.revealInput.label) + ) + } + + if (element.isTokenNull) { + throw SkyflowInternalError( + SkyflowErrorCode.TOKEN_KEY_NOT_FOUND_REVEAL, tag, configuration.options.logLevel, + ) + } else if (token!!.isEmpty()) { + throw SkyflowInternalError( + SkyflowErrorCode.EMPTY_TOKEN_REVEAL, tag, configuration.options.logLevel + ) + } else if (element.isError) { + throw SkyflowInternalError( + SkyflowErrorCode.ERROR_STATE_REVEAL, tag, configuration.options.logLevel, + arrayOf("${element.error.text}") + ) + } + } +} diff --git a/Skyflow/src/main/kotlin/Skyflow/Container.kt b/common/src/main/kotlin/Skyflow/container/Container.kt similarity index 89% rename from Skyflow/src/main/kotlin/Skyflow/Container.kt rename to common/src/main/kotlin/Skyflow/container/Container.kt index fae390db..5dac141c 100644 --- a/Skyflow/src/main/kotlin/Skyflow/Container.kt +++ b/common/src/main/kotlin/Skyflow/container/Container.kt @@ -5,12 +5,12 @@ import android.widget.LinearLayout import com.Skyflow.core.container.ContainerProtocol class Container internal constructor( - internal val configuration: Configuration, - internal val client: Client, + internal val configuration: BaseConfiguration, + internal val client: BaseSkyflowClient, ) { internal constructor( - configuration: Configuration, - client: Client, + configuration: BaseConfiguration, + client: BaseSkyflowClient, context: Context, options: ContainerOptions ) : this(configuration, client) { diff --git a/common/src/main/kotlin/Skyflow/container/ContainerClasses.kt b/common/src/main/kotlin/Skyflow/container/ContainerClasses.kt new file mode 100644 index 00000000..5375c2c2 --- /dev/null +++ b/common/src/main/kotlin/Skyflow/container/ContainerClasses.kt @@ -0,0 +1,12 @@ +package Skyflow + +import com.Skyflow.core.container.ContainerProtocol + +/** + * Neutral container marker types, shared by both SDKs and referenced by [ContainerType]. + * The contract-specific container operations (`create` / `collect` / `reveal` / `post`) are + * extension functions defined in each product's module (see docs/sdk-split-plan.md). + */ +open class CollectContainer : ContainerProtocol + +class RevealContainer : ContainerProtocol diff --git a/Skyflow/src/main/kotlin/Skyflow/ContainerOptions.kt b/common/src/main/kotlin/Skyflow/container/ContainerOptions.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/ContainerOptions.kt rename to common/src/main/kotlin/Skyflow/container/ContainerOptions.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/ContainerType.kt b/common/src/main/kotlin/Skyflow/container/ContainerType.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/ContainerType.kt rename to common/src/main/kotlin/Skyflow/container/ContainerType.kt diff --git a/common/src/main/kotlin/Skyflow/core/BaseApiClient.kt b/common/src/main/kotlin/Skyflow/core/BaseApiClient.kt new file mode 100644 index 00000000..25c4ad06 --- /dev/null +++ b/common/src/main/kotlin/Skyflow/core/BaseApiClient.kt @@ -0,0 +1,57 @@ +package Skyflow.core + +import Skyflow.* + +/** + * Shared bearer-token lifecycle for both products' API clients — legacy `APIClient` and FlowVault + * `FlowDBAPIClient` (see docs/sdk-split-plan.md). + * + * Fetching, validating (via [JWTUtils]), caching, and refreshing the bearer token is entirely + * contract-agnostic, so it lives here once. Each product's api client extends this and adds only + * its own request methods and endpoints (v1 `/v1/vaults/…` vs v2 `/v2/…`). Both the class and its + * subclasses are `internal`, so this is purely an implementation detail — no public API impact. + */ +internal abstract class BaseApiClient( + val vaultId: String, + val vaultURL: String, + private val tokenProvider: TokenProvider, + val logLevel: LogLevel, + private var token: String = "", +) { + protected val tag: String? = this::class.qualifiedName + + // `protected open` so a product can harden it (see FlowDBAPIClient). The legacy client does NOT + // override it, so skyvault keeps the exact 1.27.0 behavior (JWTUtils may throw on a malformed + // token). This change is additive — no behavior change for existing callers. + protected open fun isValidToken(token: String?): Boolean { + return if (token != "") !JWTUtils.isExpired(token!!) else false + } + + fun getAccessToken(callback: Callback) { + try { + if (!isValidToken(token)) { + Logger.info(tag, Messages.RETRIEVING_BEARER_TOKEN.getMessage(), logLevel) + tokenProvider.getBearerToken(object : Callback { + override fun onSuccess(responseBody: Any) { + Logger.info(tag, Messages.BEARER_TOKEN_RECEIVED.getMessage(), logLevel) + if (!isValidToken(responseBody.toString())) { + callback.onFailure(SkyflowInternalError(SkyflowErrorCode.INVALID_BEARER_TOKEN, tag, logLevel)) + } else { + token = "Bearer $responseBody" + callback.onSuccess(token) + } + } + + override fun onFailure(exception: Any) { + Logger.error(tag, Messages.RETRIEVING_BEARER_TOKEN_FAILED.getMessage(), logLevel) + callback.onFailure(SkyflowInternalError(SkyflowErrorCode.BEARER_TOKEN_REJECTED, tag, logLevel)) + } + }) + } else { + callback.onSuccess(token) + } + } catch (e: Exception) { + callback.onFailure(SkyflowInternalError(SkyflowErrorCode.INVALID_BEARER_TOKEN, tag, logLevel)) + } + } +} diff --git a/common/src/main/kotlin/Skyflow/core/JWTUtils.kt b/common/src/main/kotlin/Skyflow/core/JWTUtils.kt new file mode 100644 index 00000000..aac3db89 --- /dev/null +++ b/common/src/main/kotlin/Skyflow/core/JWTUtils.kt @@ -0,0 +1,38 @@ +package Skyflow.core + +import android.util.Base64 +import org.json.JSONObject +import java.io.UnsupportedEncodingException +import java.nio.charset.Charset +import java.util.* + +/** + * Bearer-token (JWT) helper shared by both SDKs' API clients (legacy `APIClient` and FlowVault + * `FlowDBAPIClient`). It only decodes/inspects the token's expiry — contract-agnostic — so it + * lives in common (see docs/sdk-split-plan.md). + */ +object JWTUtils { + @Throws(java.lang.Exception::class) + fun decoded(JWTEncoded: String): JSONObject { + return try { + val split = JWTEncoded.split(".").toTypedArray() + JSONObject(getJson(split[1])) + } catch (e: UnsupportedEncodingException) { + println(e.toString()) + JSONObject() + } + } + + fun isExpired(JWTEncoded: String): Boolean { + val expireTime = decoded(JWTEncoded).getString("exp") + val cal = Calendar.getInstance() + val currentTime = ((cal.timeInMillis / 1000)).toString() + return currentTime > expireTime + } + + @Throws(UnsupportedEncodingException::class) + private fun getJson(strEncoded: String): String { + val decodedBytes: ByteArray = Base64.decode(strEncoded, Base64.URL_SAFE) + return String(decodedBytes, Charset.forName("UTF-8")) + } +} diff --git a/Skyflow/src/main/kotlin/Skyflow/core/Logger.kt b/common/src/main/kotlin/Skyflow/core/Logger.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/core/Logger.kt rename to common/src/main/kotlin/Skyflow/core/Logger.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/core/Messages.kt b/common/src/main/kotlin/Skyflow/core/Messages.kt similarity index 94% rename from Skyflow/src/main/kotlin/Skyflow/core/Messages.kt rename to common/src/main/kotlin/Skyflow/core/Messages.kt index a4703549..f7229fdb 100644 --- a/Skyflow/src/main/kotlin/Skyflow/core/Messages.kt +++ b/common/src/main/kotlin/Skyflow/core/Messages.kt @@ -1,9 +1,12 @@ package Skyflow.core +import Skyflow.SdkInfo import Skyflow.utils.Utils -import com.skyflow_android.BuildConfig -private const val SDK_NAME_VERSION = "Android SDK v${BuildConfig.SDK_VERSION}" +// Placeholder baked into the message strings at enum-load time; substituted with the live +// per-product identity from SdkInfo at getMessage() call time (see docs/sdk-split-plan.md). +// Core intentionally does not read any product's BuildConfig directly. +private const val SDK_NAME_VERSION = "__SKYFLOW_SDK_ID__" enum class Messages(val message: String) { INVALID_URL("Invalid client credentials. Expecting \"https://XYZ\" for vaultURL"), @@ -199,6 +202,13 @@ enum class Messages(val message: String) { MISMATCH_ELEMENT_COUNT_LAYOUT_SUM("$SDK_NAME_VERSION Mount failed. Invalid layout array values. Make sure all values in the layout array are positive numbers.") } +// Resolve the SDK-identity placeholder to the running product's version. Single-sourced so BOTH +// the logging path (Messages.getMessage) and the thrown-error path (SkyflowErrorCode.getMessage) +// substitute it — otherwise thrown errors leak the raw "__SKYFLOW_SDK_ID__" token to users. +// Per product: skyvault -> "Android SDK v1.27.0", flowvault -> "Android SDK v1.0.0" (SdkInfo.version). +internal fun resolveSdkIdentity(message: String): String = + message.replace(SDK_NAME_VERSION, "Android SDK v${SdkInfo.version}") + fun Messages.getMessage(vararg values: String?): String { - return Utils.constructMessage(this.message, *values) + return Utils.constructMessage(resolveSdkIdentity(this.message), *values) } diff --git a/Skyflow/src/main/kotlin/Skyflow/core/container/ContainerProtocol.kt b/common/src/main/kotlin/Skyflow/core/container/ContainerProtocol.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/core/container/ContainerProtocol.kt rename to common/src/main/kotlin/Skyflow/core/container/ContainerProtocol.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/core/elements/state/State.kt b/common/src/main/kotlin/Skyflow/core/elements/state/State.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/core/elements/state/State.kt rename to common/src/main/kotlin/Skyflow/core/elements/state/State.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/core/elements/state/StateforText.kt b/common/src/main/kotlin/Skyflow/core/elements/state/StateforText.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/core/elements/state/StateforText.kt rename to common/src/main/kotlin/Skyflow/core/elements/state/StateforText.kt diff --git a/common/src/main/kotlin/Skyflow/element/BaseCollectElementInput.kt b/common/src/main/kotlin/Skyflow/element/BaseCollectElementInput.kt new file mode 100644 index 00000000..16e2768d --- /dev/null +++ b/common/src/main/kotlin/Skyflow/element/BaseCollectElementInput.kt @@ -0,0 +1,31 @@ +package Skyflow + +import com.Skyflow.collect.elements.validations.ValidationSet + +/** + * Neutral, contract-agnostic collect-element input shared by both SDKs (see docs/sdk-split-plan.md). + * + * Core reads the neutral [tableName] / [skyflowId] storage. Each product's `CollectElementInput` + * extends this and provides its own public constructor param naming, mapping onto this storage: + * - legacy: `table` / `skyflowID` + * - FlowVault: `tableName` / `skyflowId` + */ +abstract class BaseCollectElementInput { + internal var tableName: String? = null + internal var column: String? = null + internal var inputStyles: Styles = Styles() + internal var labelStyles: Styles = Styles() + internal var errorTextStyles: Styles = Styles() + internal var label: String = "" + internal var placeholder: String = "" + internal var validations: ValidationSet = ValidationSet() + internal var skyflowId: String? = null + + internal lateinit var type: SkyflowElementType + + @Deprecated( + "altText parameter is deprecated", + level = DeprecationLevel.WARNING + ) + internal lateinit var altText: String +} diff --git a/Skyflow/src/main/kotlin/Skyflow/BaseElement.kt b/common/src/main/kotlin/Skyflow/element/BaseElement.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/BaseElement.kt rename to common/src/main/kotlin/Skyflow/element/BaseElement.kt diff --git a/common/src/main/kotlin/Skyflow/element/BaseRevealElementInput.kt b/common/src/main/kotlin/Skyflow/element/BaseRevealElementInput.kt new file mode 100644 index 00000000..6d07c1e6 --- /dev/null +++ b/common/src/main/kotlin/Skyflow/element/BaseRevealElementInput.kt @@ -0,0 +1,16 @@ +package Skyflow + +/** + * Neutral, contract-agnostic reveal-element input shared by both SDKs (see docs/sdk-split-plan.md). + * Holds only the fields the shared reveal UI (`Label`) needs. Contract-specific fields live in the + * per-product subclass — the legacy `RevealElementInput` adds `redaction` (per-token redaction); + * the FlowVault one has none (redaction moved to `RevealOptions.tokenGroupRedactions`). + */ +abstract class BaseRevealElementInput { + internal var token: String? = null + internal var inputStyles: Styles = Styles() + internal var labelStyles: Styles = Styles() + internal var errorTextStyles: Styles = Styles() + internal var label: String = "" + internal var altText: String = "" +} diff --git a/Skyflow/src/main/kotlin/Skyflow/CardMetadata.kt b/common/src/main/kotlin/Skyflow/element/CardMetadata.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/CardMetadata.kt rename to common/src/main/kotlin/Skyflow/element/CardMetadata.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/Element.kt b/common/src/main/kotlin/Skyflow/element/Element.kt similarity index 69% rename from Skyflow/src/main/kotlin/Skyflow/Element.kt rename to common/src/main/kotlin/Skyflow/element/Element.kt index e120f272..5e5dead0 100644 --- a/Skyflow/src/main/kotlin/Skyflow/Element.kt +++ b/common/src/main/kotlin/Skyflow/element/Element.kt @@ -14,7 +14,10 @@ open class Element @JvmOverloads constructor( internal var columnName: String = "" internal var tableName: String = "" internal var skyflowID: String? = null - internal lateinit var collectInput : CollectElementInput + // v2/FlowVault call sites read `element.skyflowId` (lowercase d); legacy reads `skyflowID`. + // Both name the same value — this alias keeps the shared Element neutral to either spelling. + internal val skyflowId: String? get() = skyflowID + internal lateinit var collectInput : BaseCollectElementInput internal lateinit var options : Skyflow.CollectElementOptions internal lateinit var fieldType: SkyflowElementType internal open var uuid = "" @@ -27,16 +30,16 @@ open class Element @JvmOverloads constructor( return state.getInternalState() } /// Field Configuration - internal open fun setupField(collectInput: CollectElementInput, options: Skyflow.CollectElementOptions) { + internal open fun setupField(collectInput: BaseCollectElementInput, options: Skyflow.CollectElementOptions) { 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!! - if(!this.collectInput.skyflowID.equals(null)) - skyflowID = this.collectInput.skyflowID + if(!this.collectInput.skyflowId.equals(null)) + skyflowID = this.collectInput.skyflowId isRequired = this.options.required state = State(columnName,isRequired) } diff --git a/Skyflow/src/main/kotlin/Skyflow/ElementType.kt b/common/src/main/kotlin/Skyflow/element/ElementType.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/ElementType.kt rename to common/src/main/kotlin/Skyflow/element/ElementType.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/Label.kt b/common/src/main/kotlin/Skyflow/element/Label.kt similarity index 98% rename from Skyflow/src/main/kotlin/Skyflow/Label.kt rename to common/src/main/kotlin/Skyflow/element/Label.kt index ae7239af..f2011ac4 100644 --- a/Skyflow/src/main/kotlin/Skyflow/Label.kt +++ b/common/src/main/kotlin/Skyflow/element/Label.kt @@ -30,7 +30,7 @@ class Label @JvmOverloads constructor( internal var label = TextView(context) internal var placeholder = TextView(context) internal var error = TextView(context) - internal lateinit var revealInput: RevealElementInput + internal lateinit var revealInput: BaseRevealElementInput internal lateinit var options: RevealElementOptions internal lateinit var padding: Padding internal var border = GradientDrawable() @@ -98,7 +98,7 @@ class Label @JvmOverloads constructor( } @SuppressLint("NewApi", "WrongConstant") - internal fun setupField(revealInput: RevealElementInput, options: RevealElementOptions) { + internal fun setupField(revealInput: BaseRevealElementInput, options: RevealElementOptions) { this.revealInput = revealInput this.options = options padding = revealInput.inputStyles.base.padding diff --git a/Skyflow/src/main/kotlin/Skyflow/RedactionType.kt b/common/src/main/kotlin/Skyflow/element/RedactionType.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/RedactionType.kt rename to common/src/main/kotlin/Skyflow/element/RedactionType.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/RevealElementOptions.kt b/common/src/main/kotlin/Skyflow/element/RevealElementOptions.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/RevealElementOptions.kt rename to common/src/main/kotlin/Skyflow/element/RevealElementOptions.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/TextField.kt b/common/src/main/kotlin/Skyflow/element/TextField.kt similarity index 97% rename from Skyflow/src/main/kotlin/Skyflow/TextField.kt rename to common/src/main/kotlin/Skyflow/element/TextField.kt index e2c9188f..db99810b 100644 --- a/Skyflow/src/main/kotlin/Skyflow/TextField.kt +++ b/common/src/main/kotlin/Skyflow/element/TextField.kt @@ -5,6 +5,7 @@ import Skyflow.collect.elements.validations.SkyflowValidateYear import Skyflow.composable.ComposableEvents import Skyflow.core.Logger import Skyflow.core.Messages +import Skyflow.core.getMessage import Skyflow.core.elements.state.StateforText import Skyflow.utils.EventName import android.annotation.SuppressLint @@ -202,7 +203,7 @@ class TextField @JvmOverloads constructor( appendIcon("COPIED") } - override fun setupField(collectInput: CollectElementInput, options: CollectElementOptions) { + override fun setupField(collectInput: BaseCollectElementInput, options: CollectElementOptions) { super.setupField(collectInput, options) this.state = StateforText(this) validationRules = fieldType.getType().validation @@ -299,13 +300,17 @@ class TextField @JvmOverloads constructor( EventName.BLUR -> this.userOnBlurListener = handler EventName.FOCUS -> this.userOnFocusListener = handler EventName.SUBMIT -> { - Logger.error(tag, Messages.INVALID_EVENT_TYPE.message, optionsForLogging.logLevel) + Logger.error(tag, Messages.INVALID_EVENT_TYPE.getMessage(), optionsForLogging.logLevel) } } } + // Param type is the per-module CollectElementInput (not the base) to preserve the exact v1 JVM + // descriptor `update(LSkyflow/CollectElementInput;)V` for binary compatibility. common is compiled + // into each module, so this resolves to that module's CollectElementInput; the body only reads + // base properties, so it works for both products. 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 diff --git a/Skyflow/src/main/kotlin/Skyflow/SkyflowErrorCode.kt b/common/src/main/kotlin/Skyflow/error/SkyflowErrorCode.kt similarity index 90% rename from Skyflow/src/main/kotlin/Skyflow/SkyflowErrorCode.kt rename to common/src/main/kotlin/Skyflow/error/SkyflowErrorCode.kt index a5bf26c9..c1c093f4 100644 --- a/Skyflow/src/main/kotlin/Skyflow/SkyflowErrorCode.kt +++ b/common/src/main/kotlin/Skyflow/error/SkyflowErrorCode.kt @@ -1,8 +1,9 @@ package Skyflow import Skyflow.core.Messages +import Skyflow.core.resolveSdkIdentity -enum class SkyflowErrorCode(val code:Int, var message:String) { +enum class SkyflowErrorCode(val code:Int, rawMessage:String) { INVALID_VAULT_ID(400, Messages.INVALID_VAULT_ID.message), INVALID_VAULT_URL(400,Messages.INVALID_VAULT_URL.message), @@ -127,6 +128,14 @@ enum class SkyflowErrorCode(val code:Int, var message:String) { MISSING_TOKEN(400, Messages.MISSING_TOKEN.message) ; + // Backing field holds the raw "__SKYFLOW_SDK_ID__" placeholder; the getter resolves it to the live + // per-product identity at READ time (the version isn't known until init()). This restores 1.27.0, + // where reading `.message` already returned the substituted "Android SDK v …" string — + // so consumers/logs that read `.message` directly no longer see the raw placeholder. Kept as `var` + // to preserve the 1.27.0 getMessage()/setMessage() binary surface. + var message: String = rawMessage + get() = resolveSdkIdentity(field) + @JvmName("getCode1") fun getCode() : Int { return this.code @@ -134,6 +143,8 @@ enum class SkyflowErrorCode(val code:Int, var message:String) { @JvmName("getMessage1") fun getMessage() : String { - return this.message + // Resolve the "__SKYFLOW_SDK_ID__" placeholder — the thrown-error path (SkyflowError / + // SkyflowInternalError) derives its message from here and must not leak the raw token. + return resolveSdkIdentity(this.message) } } \ No newline at end of file diff --git a/Skyflow/src/main/kotlin/Skyflow/LogLevel.kt b/common/src/main/kotlin/Skyflow/logging/LogLevel.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/LogLevel.kt rename to common/src/main/kotlin/Skyflow/logging/LogLevel.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/Margin.kt b/common/src/main/kotlin/Skyflow/style/Margin.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/Margin.kt rename to common/src/main/kotlin/Skyflow/style/Margin.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/Padding.kt b/common/src/main/kotlin/Skyflow/style/Padding.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/Padding.kt rename to common/src/main/kotlin/Skyflow/style/Padding.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/Style.kt b/common/src/main/kotlin/Skyflow/style/Style.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/Style.kt rename to common/src/main/kotlin/Skyflow/style/Style.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/Styles.kt b/common/src/main/kotlin/Skyflow/style/Styles.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/Styles.kt rename to common/src/main/kotlin/Skyflow/style/Styles.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/utils/EventName.kt b/common/src/main/kotlin/Skyflow/utils/EventName.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/utils/EventName.kt rename to common/src/main/kotlin/Skyflow/utils/EventName.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/utils/Utils.kt b/common/src/main/kotlin/Skyflow/utils/Utils.kt similarity index 55% rename from Skyflow/src/main/kotlin/Skyflow/utils/Utils.kt rename to common/src/main/kotlin/Skyflow/utils/Utils.kt index 08f940c8..a375e7ec 100644 --- a/Skyflow/src/main/kotlin/Skyflow/utils/Utils.kt +++ b/common/src/main/kotlin/Skyflow/utils/Utils.kt @@ -3,8 +3,6 @@ package Skyflow.utils import Skyflow.* import Skyflow.LogLevel import Skyflow.core.Logger -import Skyflow.get.GetOptions -import Skyflow.get.GetRecord import android.os.Build import Skyflow.core.Messages import Skyflow.core.getMessage @@ -39,83 +37,6 @@ public class Utils { } //for collect element - fun constructBatchRequestBody( - records: JSONObject, - options: InsertOptions, - logLevel: LogLevel - ): JSONObject { - val postPayload: MutableList = mutableListOf() - val insertTokenPayload: MutableList = mutableListOf() - if (records == {}) { - throw SkyflowError(SkyflowErrorCode.RECORDS_KEY_NOT_FOUND, tag, logLevel) - } else if (!records.has("records")) { - throw SkyflowError(SkyflowErrorCode.RECORDS_KEY_NOT_FOUND, tag, logLevel) - } else if (records.get("records").toString().isEmpty()) { - throw SkyflowError(SkyflowErrorCode.EMPTY_RECORDS, tag, logLevel) - } else if (records.get("records") !is JSONArray) { - throw SkyflowError(SkyflowErrorCode.INVALID_RECORDS, tag, logLevel) - } else { - val obj1 = records.getJSONArray("records") - var i = 0 - while (i < obj1.length()) { - val jsonObj = obj1.getJSONObject(i) - if (!jsonObj.has("table")) { - throw SkyflowError( - SkyflowErrorCode.TABLE_KEY_NOY_FOUND, tag, logLevel, arrayOf("$i") - ) - } else if (jsonObj.get("table") !is String) { - throw SkyflowError( - SkyflowErrorCode.INVALID_TABLE_NAME, tag, logLevel, arrayOf("$i") - ) - } else if (jsonObj.get("table").toString().isEmpty()) { - throw SkyflowError( - SkyflowErrorCode.EMPTY_TABLE_KEY, tag, logLevel, arrayOf("$i") - ) - } else if (!jsonObj.has("fields")) { - throw SkyflowError( - SkyflowErrorCode.FIELDS_KEY_NOT_FOUND, tag, logLevel, arrayOf("$i") - ) - } else if (jsonObj.getJSONObject("fields").toString().equals("{}")) { - throw SkyflowError( - SkyflowErrorCode.EMPTY_FIELDS, tag, logLevel, arrayOf("$i") - ) - } - - val map = HashMap() - map["tableName"] = jsonObj["table"] - map["fields"] = jsonObj["fields"] - map["method"] = "POST" - map["quorum"] = true - map["upsert"] = - getUpsertColumn(jsonObj.getString("table"), options.upsert, logLevel) - val jsonObject = jsonObj["fields"] as JSONObject - val keys: Iterator = jsonObject.keys() - - while (keys.hasNext()) { - val key = keys.next() - if (key.isEmpty()) { - throw SkyflowError( - SkyflowErrorCode.EMPTY_FIELD_IN_FIELDS, tag, logLevel, - params = arrayOf("$i") - ) - } - } - postPayload.add(map) - if (options.tokens) { - val temp2 = HashMap() - temp2["method"] = "GET" - temp2["tableName"] = jsonObj["table"] as String - temp2["ID"] = "\$responses.$i.records.0.skyflow_id" - temp2["tokenization"] = true - insertTokenPayload.add(temp2) - } - i++ - } - val body = HashMap() - body["records"] = postPayload + insertTokenPayload - return JSONObject(body as Map<*, *>) - } - } //check whether pci element is valid or not inside requestbody of connectionConfig fun checkElement(element: Element, callback: Callback, logLevel: LogLevel): Boolean { @@ -133,7 +54,7 @@ public class Utils { } if (errors != "") { val error = - SkyflowError(SkyflowErrorCode.INVALID_INPUT, tag, logLevel, arrayOf(errors)) + SkyflowInternalError(SkyflowErrorCode.INVALID_INPUT, tag, logLevel, arrayOf(errors)) callback.onFailure(constructError(error)) return false } @@ -143,7 +64,7 @@ public class Utils { fun getUpsertColumn(tableName: String, options: JSONArray?, logLevel: LogLevel): String { if (options != null) { if (options.length() == 0) { - throw SkyflowError( + throw SkyflowInternalError( SkyflowErrorCode.EMPTY_UPSERT_OPTIONS_ARRAY, tag, logLevel @@ -151,19 +72,19 @@ public class Utils { } for (index in 0..options.length() - 1) { if (options.get(index) !is JSONObject) { - throw SkyflowError( + throw SkyflowInternalError( SkyflowErrorCode.ALLOW_JSON_OBJECT_IN_UPSERT, tag, logLevel, arrayOf("$index") ) } if (!options.getJSONObject(index).has("table")) { - throw SkyflowError( + throw SkyflowInternalError( SkyflowErrorCode.NO_TABLE_KEY_IN_UPSERT, tag, logLevel, arrayOf(index.toString()) ) } if (!options.getJSONObject(index).has("column")) { - throw SkyflowError( + throw SkyflowInternalError( SkyflowErrorCode.NO_COLUMN_KEY_IN_UPSERT, tag, logLevel, arrayOf(index.toString()) ) @@ -172,7 +93,7 @@ public class Utils { .get("table") !is String || options.getJSONObject(index).get("table") .toString().isEmpty() ) { - throw SkyflowError( + throw SkyflowInternalError( SkyflowErrorCode.INVALID_TABLE_IN_UPSERT_OPTION, tag, logLevel, arrayOf(index.toString()) ) @@ -181,7 +102,7 @@ public class Utils { .get("column") !is String || options.getJSONObject(index).get("column") .toString().isEmpty() ) { - throw SkyflowError( + throw SkyflowInternalError( SkyflowErrorCode.INVALID_COLUMN_IN_UPSERT_OPTION, tag, logLevel, arrayOf(index.toString()) ) @@ -210,6 +131,9 @@ public class Utils { response.remove(key) } } catch (e: Exception) { + // A malformed sub-field is skipped rather than aborting response shaping, + // but no longer silently — surface it for diagnosis. + Log.w(tag, "removeEmptyAndNullFields: skipping malformed field '$key'", e) } } } @@ -241,8 +165,8 @@ public class Utils { } fun constructError(e: Exception, code: Int = 400): JSONObject { - val skyflowError = if (e is SkyflowError) e - else SkyflowError(params = arrayOf(e.message)) + val skyflowError = if (e is SkyflowInternalError) e + else SkyflowInternalError(params = arrayOf(e.message)) skyflowError.setErrorCode(code) @@ -275,7 +199,7 @@ public class Utils { } fun constructErrorResponse(e: Exception, defaultCode: Int = 400): JSONObject { - val code = if (e is SkyflowError) e.getErrorcode() else defaultCode + val code = if (e is SkyflowInternalError) e.getErrorcode() else defaultCode val description = e.message ?: "An error occurred" return constructErrorResponse(code, description) } @@ -424,23 +348,23 @@ public class Utils { } } - internal fun checkVaultDetails(configuration: Configuration) { + internal fun checkVaultDetails(configuration: BaseConfiguration) { if (configuration.vaultURL.isEmpty() || configuration.vaultURL == "/v1/vaults/") { - throw SkyflowError( + throw SkyflowInternalError( SkyflowErrorCode.EMPTY_VAULT_URL, tag, configuration.options.logLevel ) } if (configuration.vaultID.isEmpty()) { - throw SkyflowError( + throw SkyflowInternalError( SkyflowErrorCode.EMPTY_VAULT_ID, tag, configuration.options.logLevel ) } if (!checkUrl(configuration.vaultURL)) { - throw SkyflowError( + throw SkyflowInternalError( SkyflowErrorCode.INVALID_VAULT_URL, tag, configuration.options.logLevel, @@ -521,24 +445,6 @@ public class Utils { return queryString.substring(0, queryString.length - 1) } - fun getRequestbodyForConnection(requestBody: JSONObject, contentType: String): RequestBody { - val mediaType = contentType.toMediaTypeOrNull() - if (contentType.equals(ContentType.FORMURLENCODED.type)) { - return convertJSONToQueryString(requestBody).toRequestBody(mediaType) - } else if (contentType.equals(ContentType.FORMDATA.type)) { - val map = r_urlencode(mutableListOf(), HashMap(), requestBody) - val mutlipartBody = MultipartBody.Builder().setType(MultipartBody.FORM) - map.forEach { (key, value) -> - mutlipartBody.addPart( - Headers.headersOf("Content-Disposition", "form-data; name=\"$key\""), - "$value".toRequestBody(null) - ) - } - return mutlipartBody.build() - } else { - return requestBody.toString().toRequestBody(mediaType) - } - } fun currentTwoDigitYear(): Int { return Calendar.getInstance().get(Calendar.YEAR) % 100 @@ -568,222 +474,6 @@ public class Utils { return metrics } - internal fun validateGetInputAndOptions( - records: JSONObject, - options: GetOptions?, - logLevel: LogLevel - ) { - if (!records.has("records")) { - throw SkyflowError(SkyflowErrorCode.RECORDS_KEY_NOT_FOUND, tag, logLevel) - } else if (records.get("records").toString().isEmpty()) { - throw SkyflowError(SkyflowErrorCode.EMPTY_RECORDS, tag, logLevel) - } else if (records.get("records") !is JSONArray) { - throw SkyflowError(SkyflowErrorCode.INVALID_RECORDS, tag, logLevel) - } - - val recordsArray = records.getJSONArray("records") - - (0 until recordsArray.length()).forEach { - val recordObject = recordsArray.getJSONObject(it) - var hasIds = false - var hasRedaction = false - - if (!recordObject.keys().hasNext()) { - throw SkyflowError( - SkyflowErrorCode.EMPTY_RECORD_OBJECT, tag, logLevel, arrayOf(it.toString()) - ) - } - - // checking for table - if (!recordObject.has("table")) { - throw SkyflowError( - SkyflowErrorCode.TABLE_KEY_NOY_FOUND, tag, logLevel, arrayOf(it.toString()) - ) - } else if (recordObject.get("table") !is String) { - throw SkyflowError( - SkyflowErrorCode.INVALID_TABLE_NAME, tag, logLevel, arrayOf(it.toString()) - ) - } else if (recordObject.get("table").toString().isEmpty()) { - throw SkyflowError( - SkyflowErrorCode.EMPTY_TABLE_KEY, tag, logLevel, arrayOf(it.toString()) - ) - } - - // checking for ids - if (recordObject.has("ids")) { - val ids = recordObject.get("ids") - if (ids !is JSONArray) { - throw SkyflowError( - SkyflowErrorCode.INVALID_IDS, tag, logLevel, arrayOf(it.toString()) - ) - } else if (ids.length() == 0) { - throw SkyflowError( - SkyflowErrorCode.EMPTY_RECORD_IDS, tag, logLevel, arrayOf(it.toString()) - ) - } else { - hasIds = true - for (i in 0 until ids.length()) { - if (ids[i] !is String) { - throw SkyflowError( - SkyflowErrorCode.INVALID_ID_IN_RECORD_IDS, tag, logLevel, - arrayOf(it.toString()) - ) - } else if (ids[i].toString().isEmpty()) { - throw SkyflowError( - SkyflowErrorCode.EMPTY_ID_IN_RECORD_IDS, tag, logLevel, - arrayOf(it.toString()) - ) - } - } - } - } - - // checking for redaction - if (recordObject.has("redaction")) hasRedaction = true - - val hasColumnName = recordObject.has("columnName") - val hasColumnValues = recordObject.has("columnValues") - - if (options?.tokens == true && hasRedaction) { - throw SkyflowError( - SkyflowErrorCode.REDACTION_WITH_TOKENS_NOT_SUPPORTED, tag, logLevel - ) - } else if (options?.tokens == true && hasColumnName && hasColumnValues) { - throw SkyflowError( - SkyflowErrorCode.TOKENS_NOT_SUPPORTED_WITH_COLUMN_DETAILS, tag, logLevel - ) - } else if (options?.tokens == false) { - if (!hasRedaction) { - throw SkyflowError( - SkyflowErrorCode.REDACTION_KEY_NOT_FOUND, tag, logLevel, - arrayOf(it.toString()) - ) - } else if (recordObject.get("redaction").toString().isEmpty()) { - throw SkyflowError( - SkyflowErrorCode.EMPTY_REDACTION_VALUE, tag, logLevel, - arrayOf(it.toString()) - ) - } else if (recordObject.get("redaction") !is RedactionType) { - throw SkyflowError( - SkyflowErrorCode.INVALID_REDACTION_TYPE, tag, logLevel, - arrayOf(it.toString()) - ) - } - } - - // checking for column name and column values - if (!hasColumnName && hasColumnValues) { - throw SkyflowError( - SkyflowErrorCode.MISSING_RECORD_COLUMN_NAME, tag, logLevel, - arrayOf(it.toString()) - ) - } else if (hasColumnName && !hasColumnValues) { - throw SkyflowError( - SkyflowErrorCode.MISSING_RECORD_COLUMN_VALUES, tag, logLevel, - arrayOf(it.toString()) - ) - } else if (hasColumnName && hasColumnValues) { - if (hasIds) { - throw SkyflowError( - SkyflowErrorCode.BOTH_IDS_AND_COLUMN_DETAILS_SPECIFIED, tag, logLevel, - arrayOf(it.toString()) - ) - } - - val columnName = recordObject.get("columnName") - val columnValues = recordObject.get("columnValues") - - if (columnName !is String) { - throw SkyflowError( - SkyflowErrorCode.INVALID_RECORD_COLUMN_NAME_TYPE, tag, logLevel, - arrayOf(it.toString()) - ) - } else if (columnName.toString().isEmpty()) { - throw SkyflowError( - SkyflowErrorCode.EMPTY_RECORD_COLUMN_NAME, tag, logLevel, - arrayOf(it.toString()) - ) - } else if (columnValues !is JSONArray) { - throw SkyflowError( - SkyflowErrorCode.INVALID_RECORD_COLUMN_VALUES_TYPE, - tag, logLevel, - arrayOf(it.toString()) - ) - } else if (columnValues.length() == 0) { - throw SkyflowError( - SkyflowErrorCode.EMPTY_RECORD_COLUMN_VALUES, tag, logLevel, - arrayOf(it.toString()) - ) - } else { - for (i in 0 until columnValues.length()) { - if (columnValues[i] !is String) { - throw SkyflowError( - SkyflowErrorCode.INVALID_COLUMN_VALUE_TYPE, tag, logLevel, - arrayOf(it.toString()) - ) - } else if (columnValues[i].toString().isEmpty()) { - throw SkyflowError( - SkyflowErrorCode.EMPTY_COLUMN_VALUE, tag, logLevel, - arrayOf(it.toString()) - ) - } - } - } - } else { - if (!hasIds) { - throw SkyflowError( - SkyflowErrorCode.NEITHER_IDS_NOR_COLUMN_DETAILS_SPECIFIED, - tag, logLevel, arrayOf(it.toString()) - ) - } - } - } - } - - internal fun constructRequestBodyForGet(records: JSONObject): MutableList { - val requestBody = mutableListOf() - val recordsArray = records.getJSONArray("records") - for (it in 0 until recordsArray.length()) { - val record = recordsArray.getJSONObject(it) - - val table = record.getString("table") - val ids = arrayListOf() - val columnValues = arrayListOf() - - val redaction = if (record.has("redaction")) { - record.getString("redaction") - } else null - - if (record.has("ids")) { - val skyflowIds = record.getJSONArray("ids") - for (i in 0 until skyflowIds.length()) { - ids.add(skyflowIds[i].toString()) - } - - requestBody.add( - GetRecord(skyflowIds = ids, table = table, redaction = redaction) - ) - continue - } else if (record.has("columnValues")) { - val skyflowColumnValues = record.getJSONArray("columnValues") - for (i in 0 until skyflowColumnValues.length()) { - columnValues.add(skyflowColumnValues[i].toString()) - } - } - - val columnName = record.getString("columnName") - - val requestRecord = GetRecord( - table = table, - redaction = redaction, - columnName = columnName, - columnValues = columnValues - ) - - requestBody.add(requestRecord) - } - return requestBody - } fun checkInputFormatOptions( type: SkyflowElementType, diff --git a/Skyflow/src/main/res/anim/error_animation.xml b/common/src/main/res/anim/error_animation.xml similarity index 100% rename from Skyflow/src/main/res/anim/error_animation.xml rename to common/src/main/res/anim/error_animation.xml diff --git a/Skyflow/src/main/res/drawable/ic_amex.xml b/common/src/main/res/drawable/ic_amex.xml similarity index 100% rename from Skyflow/src/main/res/drawable/ic_amex.xml rename to common/src/main/res/drawable/ic_amex.xml diff --git a/Skyflow/src/main/res/drawable/ic_baseline_error_24.xml b/common/src/main/res/drawable/ic_baseline_error_24.xml similarity index 100% rename from Skyflow/src/main/res/drawable/ic_baseline_error_24.xml rename to common/src/main/res/drawable/ic_baseline_error_24.xml diff --git a/Skyflow/src/main/res/drawable/ic_cartes_bancaires.xml b/common/src/main/res/drawable/ic_cartes_bancaires.xml similarity index 100% rename from Skyflow/src/main/res/drawable/ic_cartes_bancaires.xml rename to common/src/main/res/drawable/ic_cartes_bancaires.xml diff --git a/Skyflow/src/main/res/drawable/ic_copied.webp b/common/src/main/res/drawable/ic_copied.webp similarity index 100% rename from Skyflow/src/main/res/drawable/ic_copied.webp rename to common/src/main/res/drawable/ic_copied.webp diff --git a/Skyflow/src/main/res/drawable/ic_copy.webp b/common/src/main/res/drawable/ic_copy.webp similarity index 100% rename from Skyflow/src/main/res/drawable/ic_copy.webp rename to common/src/main/res/drawable/ic_copy.webp diff --git a/Skyflow/src/main/res/drawable/ic_diners.xml b/common/src/main/res/drawable/ic_diners.xml similarity index 100% rename from Skyflow/src/main/res/drawable/ic_diners.xml rename to common/src/main/res/drawable/ic_diners.xml diff --git a/Skyflow/src/main/res/drawable/ic_discover.xml b/common/src/main/res/drawable/ic_discover.xml similarity index 100% rename from Skyflow/src/main/res/drawable/ic_discover.xml rename to common/src/main/res/drawable/ic_discover.xml diff --git a/Skyflow/src/main/res/drawable/ic_dropdown.xml b/common/src/main/res/drawable/ic_dropdown.xml similarity index 100% rename from Skyflow/src/main/res/drawable/ic_dropdown.xml rename to common/src/main/res/drawable/ic_dropdown.xml diff --git a/Skyflow/src/main/res/drawable/ic_emptycard.xml b/common/src/main/res/drawable/ic_emptycard.xml similarity index 100% rename from Skyflow/src/main/res/drawable/ic_emptycard.xml rename to common/src/main/res/drawable/ic_emptycard.xml diff --git a/Skyflow/src/main/res/drawable/ic_hypercard.xml b/common/src/main/res/drawable/ic_hypercard.xml similarity index 100% rename from Skyflow/src/main/res/drawable/ic_hypercard.xml rename to common/src/main/res/drawable/ic_hypercard.xml diff --git a/Skyflow/src/main/res/drawable/ic_jcb.xml b/common/src/main/res/drawable/ic_jcb.xml similarity index 100% rename from Skyflow/src/main/res/drawable/ic_jcb.xml rename to common/src/main/res/drawable/ic_jcb.xml diff --git a/Skyflow/src/main/res/drawable/ic_maestro.xml b/common/src/main/res/drawable/ic_maestro.xml similarity index 100% rename from Skyflow/src/main/res/drawable/ic_maestro.xml rename to common/src/main/res/drawable/ic_maestro.xml diff --git a/Skyflow/src/main/res/drawable/ic_mastercard.xml b/common/src/main/res/drawable/ic_mastercard.xml similarity index 100% rename from Skyflow/src/main/res/drawable/ic_mastercard.xml rename to common/src/main/res/drawable/ic_mastercard.xml diff --git a/Skyflow/src/main/res/drawable/ic_unionpay.xml b/common/src/main/res/drawable/ic_unionpay.xml similarity index 100% rename from Skyflow/src/main/res/drawable/ic_unionpay.xml rename to common/src/main/res/drawable/ic_unionpay.xml diff --git a/Skyflow/src/main/res/drawable/ic_visa.xml b/common/src/main/res/drawable/ic_visa.xml similarity index 100% rename from Skyflow/src/main/res/drawable/ic_visa.xml rename to common/src/main/res/drawable/ic_visa.xml diff --git a/Skyflow/src/main/res/drawable/skyflow_text_field.xml b/common/src/main/res/drawable/skyflow_text_field.xml similarity index 100% rename from Skyflow/src/main/res/drawable/skyflow_text_field.xml rename to common/src/main/res/drawable/skyflow_text_field.xml diff --git a/Skyflow/src/main/res/font/roboto_light.xml b/common/src/main/res/font/roboto_light.xml similarity index 100% rename from Skyflow/src/main/res/font/roboto_light.xml rename to common/src/main/res/font/roboto_light.xml diff --git a/Skyflow/src/main/res/values/attributes.xml b/common/src/main/res/values/attributes.xml similarity index 100% rename from Skyflow/src/main/res/values/attributes.xml rename to common/src/main/res/values/attributes.xml diff --git a/Skyflow/src/main/res/values/colors.xml b/common/src/main/res/values/colors.xml similarity index 100% rename from Skyflow/src/main/res/values/colors.xml rename to common/src/main/res/values/colors.xml diff --git a/Skyflow/src/main/res/values/font_certs.xml b/common/src/main/res/values/font_certs.xml similarity index 100% rename from Skyflow/src/main/res/values/font_certs.xml rename to common/src/main/res/values/font_certs.xml diff --git a/Skyflow/src/main/res/values/string.xml b/common/src/main/res/values/string.xml similarity index 100% rename from Skyflow/src/main/res/values/string.xml rename to common/src/main/res/values/string.xml diff --git a/Skyflow/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml similarity index 100% rename from Skyflow/src/main/res/values/strings.xml rename to common/src/main/res/values/strings.xml diff --git a/Skyflow/src/main/res/values/styles.xml b/common/src/main/res/values/styles.xml similarity index 100% rename from Skyflow/src/main/res/values/styles.xml rename to common/src/main/res/values/styles.xml diff --git a/Skyflow/src/main/res/xml/network_security_config.xml b/common/src/main/res/xml/network_security_config.xml similarity index 100% rename from Skyflow/src/main/res/xml/network_security_config.xml rename to common/src/main/res/xml/network_security_config.xml diff --git a/docs/sdk-split-plan.md b/docs/sdk-split-plan.md new file mode 100644 index 00000000..0a77f543 --- /dev/null +++ b/docs/sdk-split-plan.md @@ -0,0 +1,207 @@ +# Splitting `skyflow-android` into Legacy + FlowVault SDKs — Architecture & Migration Plan + +**Status:** Implemented on `saileshwar/SK-3053-package-split`. Both modules build, assemble, and publish; skyvault v1 suite and flowvault v2 suite are green. +**Scope:** Split the single `skyflow-android` codebase into two independently publishable Android (AAR) SDKs that share one common core, with the FlowVault SDK built by *extending* the shared core. +**Sources:** Legacy (v1) baseline restored from `main` (1.27.0); FlowVault (v2) deltas taken from the `1.28.0-beta.1` tag / `beta-release/26.7.0`. +**Integration branch:** `saileshwar/SK-3053-package-split`, cut from `origin/main`. + +> **Naming:** the Gradle **modules/folders** are `skyvault` (legacy), `flowvault` (FlowVault), and the shared `common/` source folder. The **published artifact ids are unchanged** — `skyflow-android-sdk` and `skyflow-flowvault-android-sdk` — so consumers are unaffected. + +--- + +## 1. Goal + +Produce two independently versioned, independently published SDKs that share a single common code layer. **Which SDK an app gets is decided only by which artifact it installs** — no build flags, no runtime mode switch. + +| Module (folder) | Surface | Published artifact | Sourced from | First version | +|---|---|---|---|---| +| `skyvault` | **Legacy / v1** (existing public API) | `skyflow-android-sdk` | `main` | continues current semver | +| `flowvault` | **FlowVault / v2** (FlowDB `/v2` API) | `skyflow-flowvault-android-sdk` | `1.28.0-beta.1` deltas | `1.0.0` | +| `common/` (shared **source folder**, not a module) | shared code + base types | n/a — compiled into each module | common ancestor of both | — | + +Organizing principle: **create common code both SDKs use, and implement FlowVault by extending it** — not by branching inside shared code. There is no runtime `isFlowDB` toggle; the variant is fixed per module at build time. + +This is a **three-way factoring of a common ancestor**: +- `common/` ≈ contract-agnostic infra present in both `main` and the `1.28.0-beta.1` tag. +- `skyvault` ≈ `main` minus common (the v1 contract layer). +- `flowvault` ≈ the `1.28.0-beta.1` FlowDB additions minus common, expressed as common extensions. + +--- + +## 2. Why `common/` is a shared source folder, not a `:common` module + +Kotlin `internal` is scoped to a **compilation module**. The container extension functions (`fun Container.collect(...)`, `.create(...)`, `.reveal(...)`) depend on `internal` members of `Container`, `Client`, `TextField`, `Label`, and `Utils` (e.g. `Container.client`, `Container.collectElements`, `TextField.setupField`, `TextField.getState`). This "friend surface" is large. + +- If `common` were a **separate Gradle module**, every one of those internals would have to be promoted to `public` (guarded by `@RestrictTo(LIBRARY_GROUP)` + lint), leaking a large surface, and we'd need fat-AAR bundling so apps don't install `common` separately. +- Instead, **`common/` is a plain source folder added to both modules' source sets.** It compiles *into* each module, so: + - Kotlin `internal` works natively → common internals are visible to that module's contract layer, invisible to apps. This is the Kotlin "split-package / internal-friend" mechanism. + - Each AAR is self-contained; an app installs exactly one; the import stays `Skyflow.*` — drop-in identical for both SDKs. + - **Boundary is enforced by construction:** `common/` and each layer share the `Skyflow` package, so a leak is not an *import* (nothing to grep) but an unqualified reference resolved at compile time. The guard is therefore the **dual-module compile itself**: a `common/` file that references a legacy-only symbol fails to compile in the FlowVault module (the symbol is absent there), and a FlowVault-only reference fails in skyvault. Because PR CI builds *both* modules (`runOnGitHub`), any boundary violation breaks the build. (A `Konsist` AST test could add a second, redundant guard; it is not required for correctness and is left as a future enhancement rather than a network dependency in this branch.) + +Trade-off (accepted): common is compiled twice (once per module). This mirrors the JS reference's "core is a folder, not a package" decision and its accepted duplication. + +--- + +## 3. Target repo layout + +``` +skyflow-android/ + common/ # SHARED SOURCE (not a Gradle module) + src/main/kotlin/Skyflow/… # contract-agnostic .kt (package Skyflow, unchanged) + src/main/res/… # shared drawables/anim/styles/font/xml/values + src/test/kotlin/… # shared / core-behavior tests + skyvault/ # LEGACY module (v1) + build.gradle # artifactId = skyflow-android-sdk, own version line + src/main/kotlin/Skyflow/… # v1 contract layer only + src/test/… # restored v1 suites + flowvault/ # FLOWVAULT module (v2) + build.gradle # artifactId = skyflow-flowvault-android-sdk, version 1.0.0 + src/main/kotlin/Skyflow/… # v2 contract layer only (FlowDB*) + src/test/… # v2 suites + settings.gradle # includes the two modules only + scripts/bump_version.sh # takes a product argument + .github/workflows/ # PR builds both; per-product release pipelines + docs/sdk-split-plan.md # this document + samples/ # sample(s) built against each published SDK +``` + +Each module's `build.gradle` folds in `common/` (both keep `namespace "com.skyflow_android"` — safe because installing both SDKs is disallowed): + +```groovy +android { + namespace "com.skyflow_android" + sourceSets.main { + kotlin.srcDirs += "$rootDir/common/src/main/kotlin" // kotlin.srcDirs, NOT java.srcDirs — + res.srcDirs += "$rootDir/common/src/main/res" // java.srcDirs double-compiles the .kt + } +} +``` + +> **Only `kotlin.srcDirs`.** Adding `common` under `java.srcDirs` as well makes the Kotlin compiler pick the files up twice → "conflicting overloads" errors. Use `kotlin.srcDirs` alone. +> +> **Tests are per-module, not a shared `common/src/test`.** Each layer's tests construct that layer's public types (`CollectElementInput(table=…)` for v1 vs `CollectElementInput(tableName=…)` for v2) and assert that layer's wire keys, so they are not byte-identical and cannot be a single shared source set. skyvault keeps `main`'s v1 suites; flowvault carries the beta v2 suites. The overlapping *neutral* suites (`ValidationTests`, `InputFormattingTest`, `UtilsTest`, `ComposableElementsTests`) exist in each module compiled against that layer's types, so both products get that coverage. + +--- + +## 4. File classification + +### CORE → `common/` (contract-agnostic) +- **UI / widgets:** `BaseElement`, `Element`, `TextField`, `Label`, `CollectElementInput`, `CollectElementOptions`, `RevealElementInput`, `RevealElementOptions`, `core/elements/state/{State,StateforText}`, `composable/{ComposableErrorsList,ComposableEvents,ComposableStyles}`. +- **Validations:** all of `collect/elements/validations/*`. +- **Element utils / types:** `collect/elements/utils/{CardType,DateValidator,Spacespan,VibratorHelper}`, `ElementType`, `CardMetadata`. +- **Styles:** `Style`, `Styles`, `Margin`, `Padding`. +- **Errors / logging:** `SkyflowException.kt` (public `SkyflowError` data class), `SkyflowError.kt` (`SkyflowInternalError`), `SkyflowErrorCode`, `core/Logger`, `LogLevel`, `core/Messages`. +- **Auth / callback:** `TokenProvider`, `Callback`. +- **Config / entry:** `Configuration`, `Options`, `Env`, `Init`. +- **Container base:** `Container`, `ContainerType`, `core/container/ContainerProtocol`, `ContainerOptions`. +- **Misc:** `utils/EventName`, all of `res/`, `RedactionType` (documented superset — v1 uses it; v2's `FlowDBRevealRequestRecord` also references it). + +### LEGACY → `skyvault/` (v1-only) +`collect/client/{CollectAPICallback,CollectRequestBody,CollectRequestRecord,MixedAPICallback,UpdateAPICallback,UpdateRequestRecord}`, `reveal/{RevealApiCallback,RevealByIdCallback,RevealRequestBody,RevealRequestRecord,RevealResponse,RevealResponseByID,GetByIdRecord,RevealValueCallback}`, `get/*`, `soap/*`, `core/ConnectionApiCallback`, `ConnectionConfig`, `ContentType`, `RequestMethod`, `InsertOptions`, plus the v1 shapes of `CollectOptions` / `RevealOptions` / response types. + +### FLOWVAULT → `flowvault/` (v2-only) +`core/FlowDBAPIClient` (v2 dispatch), `collect/client/{FlowDBCollectAPICallback,FlowDBCollectRequestBody,FlowDBMixedAPICallback,MockCVV}`, `reveal/{FlowDBRevealApiCallback,FlowDBRevealRequestBody,FlowDBRevealRequestRecord,RevealValueCallback}`, `CollectResponse`, `RevealResponse`, the v2 `CollectOptions`, `RevealOptions` (+ `TokenGroupRedaction`), `AdditionalFields`, `UpsertOptions`, `UpdateType`. + +### SPLIT-NEEDED (decompose — §5) +`Client.kt`, `core/APIClient.kt`, `core/FlowDBAPIClient.kt`, `CollectContainer.kt`, `RevealContainer.kt`, `composable/ComposableContainer.kt`, `utils/Utils.kt`. + +> **Per-layer types with shared names.** `CollectOptions`, `RevealOptions`, `CollectResponse`/`CollectRecord`/`CollectCallback`, `RevealResponse`/`RevealRecord`/`RevealCallback`, `UpsertOptions`, `InsertOptions`, `GetOptions` diverge between contracts, so each layer defines its own with the **same public name**. Apps install one module, so each name resolves to the correct shape. +> +> **Superset fields stay on shared core UI types** rather than forking the type: `RevealElementInput.redaction` (read only by v1), `CollectElementInput.skyflowId`/`tableName` (used by v2 update-by-id). + +--- + +## 5. SPLIT-NEEDED decomposition + +- **Bearer-token lifecycle.** `JWTUtils` (JWT decode + expiry check) was promoted to a single shared `common/src/main/kotlin/Skyflow/core/JWTUtils.kt` (`object JWTUtils`), so both `APIClient` (legacy) and `FlowDBAPIClient` (FlowVault) reuse it instead of each declaring its own. The thin `getAccessToken` wrapper stays inside each api client (it references that client's `token`/`logLevel` state); only the contract-agnostic `JWTUtils` moved to common. +- **`Client.kt` → base-client pattern.** Core defines an abstract **`BaseSkyflowClient`** holding the neutral shell: `configuration`, `elementMap`, and the two `container(...)` factories. Each product's concrete `Client` extends it: + - legacy `Client : BaseSkyflowClient` wires the v1 `APIClient` and keeps the v1 client methods (`insert`/`get`/`getById`/`detokenize` + the deprecated connection calls); + - FlowVault `Client : BaseSkyflowClient` wires `FlowDBAPIClient` and has **no standalone client methods** (only the inherited `container(...)`). + `Container.client` is typed as `BaseSkyflowClient`; each layer's container extensions reach their contract-specific api client via the concrete `Client` (e.g. `(client as Client).apiClient`). The public type name `Client` is unchanged in each package (no v1 breaking change). +- **Divergent request/input types → base + extend (not a superset).** Types whose *shape* differs across contracts follow the base/subclass pattern instead of forking or supersetting: core holds the neutral base, and each package extends it with its own public constructor param names mapped onto neutral storage. Implemented as: + - `BaseCollectElementInput` (neutral `tableName`/`skyflowId`/`column`/styles/…) → legacy `CollectElementInput(table=…, skyflowID=…)` (with `internal val table`/`skyflowID` read aliases) and FlowVault `CollectElementInput(tableName=…, skyflowId=…)`. + - `BaseRevealElementInput` (neutral `token`/styles/label) → legacy `RevealElementInput(redaction=…)` (adds per-token redaction) and FlowVault `RevealElementInput` (no redaction — moved to `RevealOptions.tokenGroupRedactions`). + - `BaseConfiguration` (neutral `vaultID`/`vaultURL`/`tokenProvider`/`options` **plus a neutral superset `okHttpClient`** the v2 client injects and v1 ignores) → legacy `Configuration` (its `init{}` appends `/v1/vaults/`) and FlowVault `Configuration` (no suffix; `/v2/…` applied inside `FlowDBAPIClient`). + - The shared `Element` reads `skyflowID`; a read-only `internal val skyflowId get() = skyflowID` alias lets v2 call sites use the lowercase spelling without forking `Element`. +- **Containers.** `create(...)`, `validateVaultConfig()`/`checkVaultDetails`, `validateElements()`/`validateElement()`, and the neutral orchestration skeleton → **core**. The contract-specific `post()`/`get()`, typed `collect(CollectCallback)` / `reveal(RevealCallback)`, `update(...)`, and the untyped `collect(Callback)` / `reveal(Callback)` → **each layer** (legacy restores `main`'s v1 versions; FlowVault keeps the beta v2 byte-for-byte). +- **`utils/Utils.kt`.** Neutral helpers (`constructError`, `constructErrorResponse`, `checkUrl`, `checkInputFormatOptions`, `checkIfElementsMounted`, element/regex/uuid, `checkVaultDetails`) → **core**; v1 helpers (`constructBatchRequestBody`, `constructRequestBodyForGet` → `get.GetRecord`, `validateGetInputAndOptions`) → **legacy**. +- **`Configuration.kt`.** Neutral base (`BaseConfiguration`) in core with no URL mutation; the legacy `Configuration` subclass keeps `main`'s `init{}` `/v1/vaults/` suffix (base+extend, above) so the 81 v1 tests that assert `configuration.vaultURL` stay green, and the v2 client suffixes `/v2/…` itself. + +--- + +## 6. Contract differences to preserve + +| Aspect | Legacy (v1, from `main`) | FlowVault (v2, from beta) | +|---|---|---| +| Endpoints | `{vaultURL}/v1/vaults/{id}`, `…/detokenize` | `{vaultURL}/v2/records/{insert\|update}`, `…/v2/tokens/detokenize` | +| Collect response keys | `records[].table`, `.fields`, `fields.skyflow_id` | `records[].tableName`, `.tokens`, `.skyflowId`, `.hashedData`, `.httpCode` | +| Callbacks | untyped `Callback` (raw `JSONObject`) | typed `CollectCallback` / `RevealCallback` | +| Reveal redaction | per-element `RevealElementInput.redaction: RedactionType` | request-level `RevealOptions.tokenGroupRedactions` | +| Collect options | `CollectOptions(token: Boolean, additionalFields: JSONObject, upsert: JSONArray)` | `CollectOptions(upsert: List, additionalFields: AdditionalFields)` | + +**Backward compatibility wins:** because the branch is cut from `main`, the legacy contract is the starting point. Where a dormant/beta test encodes beta-era wire keys (`tableName`/`skyflowId`/`tokens`), the legacy code keeps `main`'s released keys (`table`/`fields`/`skyflow_id`) and the test is fixed. + +--- + +## 7. SDK self-identity + +Add to **core**: +```kotlin +object SdkInfo { // defaults match the legacy product + var name: String = "skyflow-android-sdk" + var version: String = "" +} +``` +Each layer's `init()` stamps `SdkInfo.name`/`version` from its own `BuildConfig.SDK_NAME`/`SDK_VERSION` (fed by each module's `ext { mArtifactId, mVersionName }`). Core error messages / telemetry read `SdkInfo` instead of `BuildConfig`. + +--- + +## 8. Versioning & tags + +- Independent semver per module; each `build.gradle` has its own `ext { mArtifactId, mVersionName }`. + - `skyflow-android-sdk` → continues the current version line; publishes `skyflow-android-sdk` / `-beta` / `-dev` (via the existing `-Pbeta` / `-Pdev` flags). + - `skyflow-flowvault-android-sdk` → starts at `1.0.0`; publishes `skyflow-flowvault-android-sdk` / `-beta` / `-dev`. +- **Tag namespaces:** legacy keeps plain-semver tags (`x.y.z`) and `*.*.*-beta.*`; FlowVault uses `flowvault/x.y.z` (and `flowvault/x.y.z-beta.n`). +- `scripts/bump_version.sh` gains a leading **product** argument (`legacy` | `flowvault`) selecting which module's `build.gradle` the `sed` edits; the existing ` [devsha]` behavior is unchanged. + +--- + +## 9. CI / CD + +- **PR CI (`pr.yml`)** builds + tests BOTH modules — it runs `:skyvault:build :flowvault:build` and the root aggregate task `runOnGitHub`, which depends on `:skyvault:{lint,test}` and `:flowvault:{lint,test}`. (Task paths use the Gradle **module** names `:skyvault` / `:flowvault`; the *published artifact ids* remain `skyflow-android-sdk` / `skyflow-flowvault-android-sdk`.) +- **Legacy release** (existing workflows, retargeted to the legacy module): `release.yml` (tag `[0-9]+.[0-9]+.[0-9]+`), `beta_release.yml` (tag `*.*.*-beta.*`), `internal_release.yml` (push `release/*`) → build/publish **legacy only**. +- **FlowVault release** (new, mirrored): `flowvault_release.yml` (tag `flowvault/[0-9]+.[0-9]+.[0-9]+`), `flowvault_beta_release.yml` (tag `flowvault/*-beta.*`), `flowvault_internal_release.yml` (push `flowvault-release/*`) → build/publish **flowvault only**, mirroring the private/dev channel. +- Each release workflow passes the product argument to `bump_version.sh`. + +--- + +## 10. Tests + +Tests live **per module** (see §3 — they construct layer-specific public types and assert layer-specific wire keys, so they are not a single shared source set): + +- **skyvault (v1, from `main`):** `DetokenizeTests`, `GetTests`, `RevealTest`, `CollectTest`, `CollectRequestBodyTest`, `CallbackResponseFormatTest`, `UpdateBySkyflowIdTest`, `InvokeConnectionTest`, `SoapConnectionTest`, `UnitTests`, `ResponseTest`, plus the neutral `ValidationTests` / `InputFormattingTest` / `UtilsTest` / `ComposableElementsTests`. Green. +- **flowvault (v2, from beta):** the beta test tree. Beta had already **emptied** its v1-flow placeholder files (`CollectTest`, `RevealTest`, `GetTests`, `DetokenizeTests`, `CollectRequestBodyTest`, `CallbackResponseFormatTest`, `UpdateBySkyflowIdTest` — 0 bytes, no `@Test`); those were dropped rather than carried as empty files. The live v2 suite is **93 tests**: `MockCVVTest` (14), `ResponseTest` (11, v2 shapes), `ComposableElementsTests` (27), `InputFormattingTest` (25), `UtilsTest` (9), `ValidationTests` (6), `ExampleUnitTest` (1). All green. + +Both suites run under Robolectric. Backward-compat rule from §6 applies where a test would encode beta-era wire keys on the legacy side. + +--- + +## 11. Migration sequencing + +- **Phase 0 — Branch.** Cut `SK-3041/package-split` from `origin/main`. Use `git mv` throughout to preserve blame/history. *(done)* +- **Phase 1 — `common/` + legacy module (from main).** Create `common/` + `skyvault/`; `git mv` neutral files → `common/`, v1 files → the legacy module; wire the shared source set; split `Utils`; extract the token client; neutralize `Configuration`; add `SdkInfo` + stamp in legacy `init()`. Legacy builds and all restored v1 tests pass; public surface unchanged. +- **Phase 2 — FlowVault module (from beta).** Create `flowvault/`; bring `FlowDB*` + v2 types from `1.28.0-beta.1` as core extensions; per-layer options/responses; stamp `SdkInfo` in flowvault `init()`; add v2 tests. Both modules build. *(done)* +- **Phase 3 — Versioning + CI.** Bump-script product arg; per-module versions; PR-builds-both; per-product release workflows keyed to tag namespaces (+ mirrored internal channel). *(done)* +- **Phase 4 — Samples + docs.** Verify per-SDK publish (`publishToMavenLocal`) and finalize this document. *(done)* + +--- + +## 12. Verification + +1. **Build both:** `./gradlew :skyvault:assembleRelease :flowvault:assembleRelease` — zero errors. ✅ +2. **Test both:** `./gradlew runOnGitHub` — skyvault v1 suite + flowvault 93-test v2 suite green. ✅ +3. **Boundary (by construction):** both modules compile in CI; because `common/` is compiled into *each*, a common reference to a symbol that lives only in one layer breaks the other module's build. Proven while building: the flowvault module compiles with `common` + v2 source only (no skyvault source present), confirming `common` is self-contained. ✅ +4. **Visibility:** an app cannot resolve a core `internal` symbol (e.g. `Container.client`) — compile error (Kotlin `internal` is module-scoped; common is folded into the AAR, not re-exported). +5. **Real package manager (per SDK):** `publishToMavenLocal` produced `com.skyflowapi.android:skyflow-android-sdk:1.27.0` and `com.skyflowapi.android:skyflow-flowvault-android-sdk:1.0.0`; the flowvault POM lists only external deps (kotlin-stdlib, core-ktx, material, okhttp) with **no `common` module dependency** — the shared source is compiled into the AAR, so a consumer resolves one self-contained artifact with `import Skyflow.*`. ✅ +6. **Release routing:** a plain-semver / `*.*.*-beta.*` tag triggers only legacy publish (`release.yml` / `beta_release.yml` → `:skyvault:publish`); a `flowvault/*` tag triggers only flowvault publish (`flowvault_release.yml` / `flowvault_beta_release.yml` → `:flowvault:publish`). Internal channels: `release/*` → skyvault dev, `flowvault-release/*` → flowvault dev. +7. **Backward-compat smoke:** the legacy `samples/` app (standalone reference, `implementation project(':skyvault')` — matching how `main` referenced `:Skyflow`; not part of the Gradle build's `settings.gradle`, same as on `main`) uses v1 signatures (`insert`, untyped `collect(Callback)`, `RevealElementInput(redaction=…)`, `CollectOptions(token=…)`) against the legacy artifact. diff --git a/Skyflow/.gitignore b/flowvault/.gitignore similarity index 100% rename from Skyflow/.gitignore rename to flowvault/.gitignore diff --git a/flowvault/README.md b/flowvault/README.md new file mode 100644 index 00000000..0c54d642 --- /dev/null +++ b/flowvault/README.md @@ -0,0 +1,2103 @@ +# skyflow-flowvault-android +--- +[![CI](https://img.shields.io/static/v1?label=CI&message=passing&color=green?style=plastic&logo=github)](https://github.com/skyflowapi/skyflow-android/actions) +[![GitHub release](https://img.shields.io/github/v/release/skyflowapi/skyflow-android.svg)](https://github.com/skyflowapi/skyflow-android/releases) +[![License](https://img.shields.io/github/license/skyflowapi/skyflow-android)](https://github.com/skyflowapi/skyflow-android/blob/main/LICENSE) + +Skyflow’s android SDK can be used to securely collect, tokenize, and display sensitive data in the mobile without exposing your front-end infrastructure to sensitive data. + +# Table of Contents + +1. [**Installation**](#installation) + 1. [Requirements](#requirements) + 2. [Configuration](#configuration) +2. [**Quick Start**](#quick-start) + 1. [Collect data](#collect-data) + 2. [Reveal data](#reveal-data) +3. [**Initializing skyflow-flowvault-android**](#initializing-skyflow-flowvault-android) +4. [**Securely collecting data client-side**](#securely-collecting-data-client-side) + 1. [Using Skyflow Elements to collect data](#using-skyflow-elements-to-collect-data) + 2. [Using Skyflow Elements to update data](#using-skyflow-elements-to-update-data) + 3. [Validations](#validations) + 4. [Event Listener on Collect Elements](#event-listener-on-collect-elements) + 5. [UI Error for Collect Elements](#ui-error-for-collect-elements) + 6. [Set and Clear value for Collect Elements (DEV ENV ONLY)](#set-and-clear-value-for-collect-elements-dev-env-only) +5. [**Securely collecting data client-side using composable elements**](#securely-collecting-data-client-side-using-composable-elements) + 1. [Using Skyflow Composable Elements to collect data](#using-skyflow-composable-elements-to-collect-data) + 2. [Using Skyflow Composable Elements to update data](#using-skyflow-composable-elements-to-update-data) + 3. [Event Listeners on Composable Elements](#event-listeners-on-composable-elements) + 4. [Update Composable Elements](#update-composable-elements) + 5. [Event Listeners on Composable Container](#event-listeners-on-composable-container) +6. [**Securely revealing data client-side**](#securely-revealing-data-client-side) + 1. [Using Skyflow Elements to reveal data](#using-skyflow-elements-to-reveal-data) + 2. [UI Error for Reveal Elements](#ui-error-for-reveal-elements) + 3. [Set token for Reveal Elements](#set-token-for-reveal-elements) + 4. [Set and Clear altText for Reveal Elements](#set-and-clear-alttext-for-reveal-elements) +7. [**Typed callbacks and response handling**](#typed-callbacks-and-response-handling) + 1. [Collect with typed callbacks](#collect-with-typed-callbacks) + 2. [Reveal with typed callbacks](#reveal-with-typed-callbacks) +8. [**Limitation**](#limitation) +9. [**Reporting a Vulnerability**](#reporting-a-vulnerability) + +# Installation + +## Requirements +- Android 5.0 (API level 21) and above +- compileSdk 35 and above +- Android Gradle Plugin 8.6.0 and above + +## Configuration +--- +> **Note:** The Android SDK is distributed through GitHub Package Registry (not Maven Central), so adding it as a dependency requires GitHub authentication — even for public packages. This is why the steps below include generating a Personal Access Token. The iOS and JS SDKs don't require this step. + +### Step 1: Generate a Personal Access Token for GitHub +- Inside your GitHub account: +- Settings -> Developer Settings -> Personal Access Tokens -> Generate new token +- Make sure you select the following scopes (“read:packages”) and Generate a token +- After Generating make sure to copy your new personal access token. You cannot see it again! The only option is to generate a new key. + +### Step 2: Store your GitHub — Personal Access Token details +- Create a github.properties file within your root Android project +- In case of a public repository make sure you add this file to .gitignore to keep the token private +- Add properties gpr.usr=GITHUB_USER_NAME and gpr.key=PERSONAL_ACCESS_TOKEN +- Replace GITHUB_USER_NAME with personal / organisation Github user NAME and PERSONAL_ACCESS_TOKEN with the token generated in [Step 1](#step-1-generate-a-personal-access-token-for-github) + +Alternatively you can also add the GPR_USER_NAME and GPR_PAT values to your environment variables on your local machine or build server to avoid creating a github properties file + +### Step 3: Adding the dependency to the project + +#### Using gradle + +- Add the Github package registry to your root project build.gradle file + + ```java + def githubProperties = new Properties() + githubProperties.load(new FileInputStream(file(“github.properties”))) + allprojects { + repositories { + ... + maven { + url "https://maven.pkg.github.com/skyflowapi/skyflow-android" + credentials { + username = githubProperties['gpr.usr'] ?: System.getenv("GPR_USER_NAME") + password = githubProperties['gpr.key'] ?: System.getenv("GPR_PAT") + } + } + } + ... + } + ``` + +- Add the dependency to your application's build.gradle file + + ```java + implementation 'com.skyflowapi.android:skyflow-flowvault-android-sdk:1.0.0' + ``` + +#### Using maven +- Add the Github package registry in the repositories tag and the GITHUB_USER_NAME, PERSONAL_ACCESS_TOKEN collected from [Step1](#step-1-generate-a-personal-access-token-for-github) in the server tag to your project's settings.xml file. Make sure that the id's for both these tags are the same. + +```xml + + + github + https://maven.pkg.github.com/skyflowapi/skyflow-android + + + + + + github + GITHUB_USER_NAME + PERSONAL_ACCESS_TOKEN + + + ``` + +- Add the package dependencies to the dependencies element of your project pom.xml file +```xml + + com.skyflowapi.android + skyflow-flowvault-android-sdk + 1.0.0 + +``` + + +# Quick Start + +### Collect data + +Collect a card number and get back a token: + +```kotlin +// 1. Configure and initialize the client +val config = Skyflow.Configuration( + vaultID = "", + vaultURL = "", + tokenProvider = myTokenProvider // your Skyflow.TokenProvider implementation — see below +) +val skyflowClient = Skyflow.init(config) + +// 2. Create a container and a Collect Element for the card number +val container = skyflowClient.container(Skyflow.ContainerType.COLLECT) +val cardNumberInput = Skyflow.CollectElementInput( + tableName = "cards", + column = "card_number", + type = Skyflow.ElementType.CARD_NUMBER +) +val cardNumberElement = container.create(context = this, input = cardNumberInput) +parent.addView(cardNumberElement) // it's a View — mount it like any other + +// 3. Collect the value and get back a token +container.collect(object : CollectCallback { + override fun onSuccess(response: CollectResponse) { + println(response.records) + } + override fun onFailure(error: SkyflowError) { + println(error.message) + } +}) +``` + +### Reveal data + +Reveal a token back to its real value (using the same `skyflowClient` from above): + +```kotlin +// 1. Create a container and a Reveal Element for the token +val revealContainer = skyflowClient.container(Skyflow.ContainerType.REVEAL) +val cardNumberReveal = Skyflow.RevealElementInput( + token = "f3907186-e7e2-466f-91e5-48e12c2bcbc1", + label = "Card Number" +) +val revealElement = revealContainer.create(context = this, input = cardNumberReveal) +parent.addView(revealElement) // shows the real value once revealed + +// 2. Reveal it +revealContainer.reveal(object : RevealCallback { + override fun onSuccess(response: RevealResponse) { + println(response.records) + } + override fun onFailure(error: SkyflowError) { + println(error.message) + } +}) +``` + +That's the whole round trip — no card data ever touches your app code on the way in, and it's only ever displayed, never returned to your code, on the way out. Everything below covers this in depth: `TokenProvider`, styling, validation, updates, upsert, and more. + +--- + +# Initializing skyflow-flowvault-android +---- +Use the ```init()``` method to initialize a Skyflow client as shown below. +```kt +val demoTokenProvider = DemoTokenProvider() /*DemoTokenProvider is an implementation of +the Skyflow.TokenProvider interface*/ + +val config = Skyflow.Configuration( + vaultID = , + vaultURL = , + tokenProvider = demoTokenProvider, + options = Skyflow.Options( + logLevel = Skyflow.LogLevel.INFO, // optional, if not specified logLevel is ERROR + env = Skyflow.Env.PROD // optional, if not specified env is PROD + ) +) + +val skyflowClient = Skyflow.init(config) +``` +For the tokenProvider parameter, pass in an implementation of the Skyflow.TokenProvider interface that declares a getBearerToken method which retrieves a Skyflow bearer token from your backend. This function will be invoked when the SDK needs to insert or retrieve data from the vault. + +For example, if the response of the consumer tokenAPI is in the below format + +``` +{ + "accessToken": string, + "tokenType": string +} +``` + +then, your Skyflow.TokenProvider Implementation should be as below + + +```kt +class DemoTokenProvider: Skyflow.TokenProvider { + override fun getBearerToken(callback: Callback) { + val url = "http://10.0.2.2:8000/js/analystToken" + val request = okhttp3.Request.Builder().url(url).build() + val okHttpClient = OkHttpClient() + try { + val thread = Thread { + run { + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) + throw IOException("Unexpected code $response") + val accessTokenObject = JSONObject( + response.body()!!.string().toString() + ) + val accessToken = accessTokenObject["accessToken"] + callback.onSuccess("$accessToken") + } + } + } + thread.start() + }catch (exception:Exception){ + callback.onFailure(exception) + } + } +} +``` + +NOTE: You should pass access token as `String` value in the success callback of getBearerToken. + +For `logLevel` parameter, there are 4 accepted values in Skyflow.LogLevel + +- `DEBUG` + + When `Skyflow.LogLevel.DEBUG` is passed, all level of logs will be printed(DEBUG, INFO, WARN, ERROR). + +- `INFO` + + When `Skyflow.LogLevel.INFO` is passed, INFO logs for every event that has occurred during the SDK flow execution will be printed along with WARN and ERROR logs. + + +- `WARN` + + When `Skyflow.LogLevel.WARN` is passed, WARN and ERROR logs will be printed. + +- `ERROR` + + When `Skyflow.LogLevel.ERROR` is passed, only ERROR logs will be printed. + +`Note`: + - The ranking of logging levels is as follows : DEBUG < INFO < WARN < ERROR + - since `logLevel` is optional, by default the logLevel will be `ERROR`. + + + +For `env` parameter, there are 2 accepted values in Skyflow.Env + +- `PROD` +- `DEV` + + In [Event Listeners](#event-listener-on-collect-elements), actual value of element can only be accessed inside the handler when the `env` is set to `DEV`. + +`Note`: + - since `env` is optional, by default the env will be `PROD`. + - Use `env` option with caution, make sure the env is set to `PROD` when using `skyflow-flowvault-android` in production. + + + +--- +# Securely collecting data client-side +- [**Using Skyflow Elements to collect data**](#using-skyflow-elements-to-collect-data) +- [**Using Skyflow Elements to update data**](#using-skyflow-elements-to-update-data) +- [**Event Listener on Collect Elements**](#event-listener-on-collect-elements) +- [**UI Error for Collect Elements**](#ui-error-for-collect-elements) +- [**Set and Clear value for Collect Elements (DEV ENV ONLY)**](#set-and-clear-value-for-collect-elements-dev-env-only) + + +## Using Skyflow Elements to collect data + +**Skyflow Elements** provide developers with pre-built form elements to securely collect sensitive data client-side. This reduces your PCI compliance scope by not exposing your front-end application to sensitive data. Follow the steps below to securely collect data with Skyflow Elements in your application. + +### Step 1: Create a container + +First create a **container** for the form elements using the ```skyflowClient.container(type: Skyflow.ContainerType)``` method as show below + +```kt +val container = skyflowClient.container(Skyflow.ContainerType.COLLECT) +``` + +### Step 2: Create a collect Element + +To create a collect element, we must first construct `Skyflow.CollectElementInput` object defined as shown below: + +```kt +Skyflow.CollectElementInput( + tableName: String, // required, the table this data belongs to + column: String, // required, the column into which this data should be inserted + type: Skyflow.ElementType //Skyflow.ElementType enum + inputStyles: Skyflow.Styles, //optional styles that should be applied to the form element + labelStyles: Skyflow.Styles, //optional styles that will be applied to the label of the collect element + errorTextStyles: Skyflow.Styles, //optional styles that will be applied to the errorText of the collect element + label: String, //optional label for the form element + placeholder: String, //optional placeholder for the form element + altText: String, //(DEPRECATED) optional string that acts as an initial value for the collect element + validations: ValidationSet // optional set of validations for collect element +) +``` +The `tableName` and `column` fields indicate which table and column in the vault the Element corresponds to. +Note: Use dot delimited strings to specify columns nested inside JSON fields (e.g. address.street.line1). + +The `inputStyles` field accepts a Skyflow.Styles object which consists of multiple `Skyflow.Style` objects which should be applied to the form element in the following states: + +- `base`: all other variants inherit from these styles +- `complete`: applied when the Element has valid input +- `empty`: applied when the Element has no input +- `focus`: applied when the Element has focus +- `invalid`: applied when the Element has invalid input + +Each Style object accepts the following properties, please note that each property is optional: + +```kotlin +Skyflow.Style( + borderColor: Int // optional + cornerRadius: Float // optional + padding: Skyflow.Padding // optional + borderWidth: Int // optional + font: Int // optional + textAlignment: Int // optional + textColor: Int // optional + placeholderColor: Int // optional + width: Int // optional + height: Int // optional + margin: Skyflow.Margin // optional + backgroundColor: Int // optional + minWidth: Int // optional + maxWidth: Int // optional + minHeight: Int // optional + maxHeight: Int // optional +) +``` +Here `Skyflow.Padding` and `Skyflow.Margin` are classes which can be used to set the padding and margin respectively for the composable element which takes all the left, top, right, bottom values. + +```kt +Skyflow.Padding(left: Int, top: Int, right: Int, bottom: Int) + +Skyflow.Margin(left: Int, top: Int, right: Int, bottom: Int) +``` + +An example Skyflow.Styles object +```kotlin +val inputStyles = Skyflow.Styles( + base = Skyflow.Style(), // optional + complete = Skyflow.Style(), // optional + empty = Skyflow.Style(), // optional + focus = Skyflow.Style(), // optional + invalid = Skyflow.Style(), // optional + requiredAsterisk = Skyflow.Style() // optional +) +``` + +The `labelStyles` and `errorTextStyles` fields accept the above mentioned `Skyflow.Styles` object which are applied to the `label` and `errorText` text views respectively. + +The states that are available for `labelStyles` are `base`, `focus` and `requiredAsterisk`. + +`requiredAsterisk`: Styles applied for the Asterisk symbol in the label. Defaults to `red`. + +The state that is available for `errorTextStyles` is only the `base` state, it shows up when there is some error in the collect element. + +The parameters in `Skyflow.Style` object that are respected for `label` and `errorText` text views are +- padding +- font +- textColor +- textAlignment +- width +- height +- margin +- minWidth +- maxWidth +- minHeight +- maxHeight + +Other parameters in the `Skyflow.Style` object are ignored for `label` and `errorText` text views. + +Finally, the `type` field takes a Skyflow ElementType. Each type applies the appropriate regex and validations to the form element. There are currently 8 types: +- `INPUT_FIELD` +- `CARDHOLDER_NAME` +- `CARD_NUMBER` +- `EXPIRATION_DATE` +- `EXPIRATION_MONTH` +- `EXPIRATION_YEAR` +- `CVV` +- `PIN` + +The `INPUT_FIELD` type is a custom UI element without any built-in validations. See the section on [`validations`](#validations) for more information on validations. + +Along with `CollectElementInput` you can define other options in the `CollectElementOptions` object which is described below. + +```kotlin +Skyflow.CollectElementOptions( + required: Boolean, // Indicates whether the field is marked as required. Defaults to 'false' + enableCardIcon: Boolean, // Indicates whether card icon should be enabled (only for CARD_NUMBER inputs) + format: String, // Format for the element (currently applicable for "EXPIRATION_DATE", "CARD_NUMBER", "EXPIRATION_YEAR" and "INPUT_FIELD") + translation: HashMap // Indicates the allowed data type value for format. + enableCopy: Boolean, // Indicates whether to enable the copy icon in collect elements to copy text to clipboard. Defaults to 'false' + cardMetadata: Skyflow.CardMetadata, // Optional, metadata to control card number element behavior. (only applicable for CARD_NUMBER ElementType). +) +``` + +- `required`: Indicates whether the field is marked as required or not. Default is `false`. + +- `enableCardIcon`: Indicates whether the icon is visible for the CARD_NUMBER element. Default is `true`. + +- `format`: A string value that indicates the format pattern applicable to the element type. Only applicable to `EXPIRATION_DATE`, `CARD_NUMBER`, `EXPIRATION_YEAR` and `INPUT_FIELD` elements. + - For `INPUT_FIELD` elements, + - the length of `format` determines the expected length of the user input. + - if `translation` isn't specified, the `format` value is considered a string literal. + +- `translation`: A hashmap of key/value pairs, where the key is a character that appears in `format` and the value is a regex pattern of acceptable inputs for that character. Each key can only appear once. Only applicable for `INPUT_FIELD` elements. + +- `enableCopy`: Indicates whether to enable the copy icon in collect elements to copy text to clipboard. + +- `cardMetadata`: An object of metadata keys to control card number element behavior. It supports an optional key called `scheme`, which accepts an array of Skyflow-supported card types and determines which brands display in the card number element's card brand choice dropdown. `Skyflow.CardType` is an enum with all Skyflow-supported card schemes. + +```kotlin +class CardMetadata(var scheme: Array) {} +``` + +#### Supported card types by Skyflow.CardType : +- `VISA` +- `MASTERCARD` +- `AMEX` +- `DINERS_CLUB` +- `DISCOVER` +- `JCB` +- `MAESTRO` +- `UNIONPAY` +- `HIPERCARD` +- `CARTES_BANCAIRES` + +Accepted values by element type: + +| Element type | `format` | `translation` | Examples | +| --------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| EXPIRATION_DATE |
    • `mm/yy`(default)
    • `mm/yyyy`
    • `yy/mm`
    • `yyyy/mm`
    | N/A |
    • 12/27
    • 12/2027
    • 27/12
    • 2027/12
| +| EXPIRATION_YEAR |
  • `yy`(default)
  • `yyyy`
  • | N/A |
    • 27
    • 2027
    | +| CARD_NUMBER |
    • `XXXX XXXX XXXX XXXX` (default)
    • `XXXX-XXXX-XXXX-XXXX`
    | N/A |
    • 1234 5678 9012 3456
    • 1234-5678-9012-3456
    | +| INPUT_FIELD | A string that matches the desired output, with placeholder characters of your choice. | A hashmap of key/value pairs. Defaults to `hashmapOf('X' to "[0-9]")` | With `format: +91 XXXX-XX-XXXX` and `translation: hashmapOf('X' to "[0-9]")`, user input of "1234121234" displays as "+91 1234-12-1234". | + +Collect Element Options examples for INPUT_FIELD + +Example 1 +```kotlin +Skyflow.CollectElementOptions( + required = true, + enableCardIcon = true, + format = "+91 XXXX-XX-XXXX", + translation = hashmapOf('X' to "[0-9]") +) +``` +User input: "1234121234" + +Value displayed in INPUT_FIELD: "+91 1234-12-1234" + +Example 2 +```kotlin +Skyflow.CollectElementOptions( + required = true, + enableCardIcon = true, + format = "AY XX-XXX-XXXX", + translation = hashmapOf('X' to "[0-9]", 'Y' to "[A-Z]") +) +``` +User input: "B1234121234" + +Value displayed in INPUT_FIELD: "AB 12-341-2123" + +Once the `Skyflow.CollectElementInput` and `Skyflow.CollectElementOptions` objects are defined, add to the container using the ```create(context:Context,input: CollectElementInput, options: CollectElementOptions)``` method as shown below. The `input` param takes a `Skyflow.CollectElementInput` object as defined above and the `options` parameter takes a `Skyflow.CollectElementOptions`, +the `context` param takes android `Context` object as described below: + +```kotlin +val collectElementInput = Skyflow.CollectElementInput( + 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*/ + labelStyles = Skyflow.Styles(), //optional styles that will be applied to the label of the collect element + errorTextStyles = Skyflow.Styles(), //optional styles that will be applied to the errorText of the collect element + label = "string", //optional label for the form element + placeholder = "string", //optional placeholder for the form element + altText = "string", //(DEPRECATED) optional string that acts as an initial value for the collect element + validations = ValidationSet() // optional set of validations for the input element +) + +val collectElementOptions = Skyflow.CollectElementOptions( + required = false, //indicates whether the field is marked as required. Defaults to 'false' + enableCardIcon = true, //indicates whether card icon should be enabled (only for CARD_NUMBER inputs) + format = "mm/yy" //Format for the element (only applies currently for EXPIRATION_DATE element type) +) + +const element = container.create(context = this, collectElementInput, collectElementOptions) +``` + + + +### Step 3: Add Elements to the layout + +To specify where the Elements will be rendered on the screen, set layout params to the view and add it to a layout in your app programmatically. + +```kt +val layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT +) +element.layoutParams = layoutParams +existingLayout.addView(element) +``` + +The Skyflow Element is an implementation of native android View so it can be used/mounted similarly.Alternatively, you can use the `unmount` method to reset any element to it's initial state. + +```kt +fun clearFields(elements: List) { + + //resets all elements to initial value + for element in elements { + element.unmount() + } +} +``` + + +### Step 4: Collect data from Elements + +Call `collect(callback, options)` on the container. `CollectOptions` accepts optional `additionalFields` (non-PCI data) and `upsert` parameters. + +```kotlin +val options = CollectOptions( + additionalFields = AdditionalFields(records = listOf( + AdditionalFieldsRecord(tableName = "persons", data = mapOf("gender" to "MALE")) + )) +) +container.collect(object : CollectCallback { + override fun onSuccess(response: CollectResponse) { + response.records.forEach { record -> + if (record.httpCode == 200) Log.d(TAG, "success: ${record.tokens}") + else Log.d(TAG, "error [${record.httpCode}]: ${record.error}") + } + } + override fun onFailure(error: SkyflowError) { + Log.d(TAG, "failure: ${error.message}") + } +}, options) +``` + + +#### [Sample Code](https://github.com/skyflowapi/skyflow-android/blob/main/samples/flowvault/src/main/java/com/Skyflow/CollectActivity.kt) + + +## Using Skyflow Elements to update data + +You can update data in a vault using Skyflow Elements. Use the following steps to securely update data. + +### Step 1: Create a container + +First create a **container** for the form elements using the ```skyflowClient.container(type: Skyflow.ContainerType)``` method as shown below: + +```kt +val container = skyflowClient.container(Skyflow.ContainerType.COLLECT) +``` + +### Step 2: Create a collect Element + +To create a collect Element, construct a `Skyflow.CollectElementInput` object as shown below: + +```kt +val collectElementInput = Skyflow.CollectElementInput( + tableName: String, // required, the table this data belongs to + column: String, // required, the column into which this data should be inserted + type: Skyflow.ElementType, // Skyflow.ElementType enum + inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element + labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element + errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + label: String, // optional label for the form element + placeholder: String, // optional placeholder for the form element + altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element + validations: ValidationSet, // optional set of validations for the input element + skyflowId: String // The skyflow_id of the record to be updated +) +``` + +The `tableName` and `column` fields indicate which table and column in the vault the Element corresponds to. + +**Note:** +- Use dot delimited strings to specify columns nested inside JSON fields (e.g. `address.street.line1`) + +Along with `CollectElementInput`, you can define other options in the `CollectElementOptions` object as described in the [collect section](#step-2-create-a-collect-element). + +### Step 3: Mount Elements to the Screen + +To specify where the Elements will be rendered on the screen, create a parent UIView (like LinearLayout, etc.) and add it programmatically. + +```kt +val parent = findViewById(R.id.parent) +val lp = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT +) +parent.addView(element) +``` + +The Skyflow Element is an implementation of the View so it can be used/mounted similarly. Alternatively, you can use the `unmount` method to reset any collect element to its initial state. + +```kt +fun clearFieldsOnSubmit(elements: List) { + // resets all elements in the array + for (element in elements) { + element.unmount() + } +} +``` + +### Step 4: Update data from Elements + +When the form is ready to submit, call the `collect(options?)` method on the container object. The `options` parameter takes an object of optional parameters as shown below: + +- `additionalFields`: Non-PCI data to update or insert alongside element values, as an `AdditionalFields` object. +- `upsert`: To support upsert operations, pass a list of `UpsertOptions` specifying the table, update type, and unique columns. + +```kotlin +val options = CollectOptions( + additionalFields = AdditionalFields(records = listOf( + AdditionalFieldsRecord( + tableName = "persons", + data = mapOf("gender" to "MALE"), + skyflowId = "" + ) + )) +) +container.collect(object : CollectCallback { + override fun onSuccess(response: CollectResponse) { + response.records.forEach { record -> + if (record.httpCode == 200) Log.d(TAG, "update success: ${record.tokens}") + else Log.d(TAG, "update error [${record.httpCode}]: ${record.error}") + } + } + override fun onFailure(error: SkyflowError) { + Log.d(TAG, "update failure: ${error.message}") + } +}, options) +``` + + +**Note:** `skyflowId` is required to update an existing record. Without it, a new record is inserted. + +### Validations + +skyflow-flowvault-android provides two types of validations on Collect Elements + +#### 1. Default Validations: +Every Collect Element except of type `INPUT_FIELD` has a set of default validations listed below: +- `CARD_NUMBER`: Card number validation with checkSum algorithm(Luhn algorithm), available card lengths for defined card types +- `CARD_HOLDER_NAME`: Name, should be 2 or more symbols, valid characters shold match pattern `^([a-zA-Z\\ \\,\\.\\-\\']{2,})$` +- `CVV`: Card CVV can have 3-4 digits +- `EXPIRATION_DATE`: Any date starting from current month. By default valid expiration date should be in short year format - `MM/YY` +- `PIN`: Can have 4-12 digits + +#### 2. Custom Validations: +Custom validations can be added to any element which will be checked after the default validations have passed. The following Custom validation rules are currently supported: +- `RegexMatchRule`: You can use this rule to specify any Regular Expression to be matched with the text field value +- `LengthMatchRule`: You can use this rule to set the minimum and maximum permissible length of the textfield value +- `ElementValueMatchRule`: You can use this rule to match the value of one element with another + +The Sample code below illustrates the usage of custom validations: + +```kt +/* + Reset Password - A simple example that illustrates custom validations. The below code shows two input fields with custom validations, one to enter a Password and the second to confirm the same Password. +*/ + +var myRuleset = ValidationSet() +val strongPasswordRule = RegexMatchRule(regex= "^^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]*$", error = "At least one letter and one number") // This rule enforces a strong password +val lengthRule = LengthMatchRule(minLength = 8, maxLength = 16, error = "Must be between 8 and 16 digits") // this rule allows input length between 8 and 16 characters + +// for the Password element +myRuleset.add(rule = strongPasswordRule) +myRuleset.add(rule = lengthRule) + +val passwordInput = CollectElementInput(inputStyles = styles, label = "Password", placeholder = "****", type = ElementType.INPUT_FIELD, validations = myRuleset) + +val Password = container.create(passwordInput) + +// For confirm Password element - shows error when the PINs don't match +val elementMatchRule = ElementMatchRule(element = Password, error = "PINs don't match") + +val confirmPasswordinput = CollectElementInput(inputStyles = styles, label = "Confirm Password", placeholder = "****", type = ElementType.INPUT_FIELD, validations = ValidationSet(rules = mutableListOf(strongPasswordRule, lengthRule, elementMatchRule))) +val confirmPassword = container.create(input = confirmPasswordinput) + +//mount elements to the screen +addView(Password) +addView(confirmPassword) + +``` + +### Event Listener on Collect Elements + + +Helps to communicate with skyflow elements by listening to an event + +```kt +element.on(eventName: Skyflow.EventName) { state -> + //handle function +} +``` + +There are 4 events in `Skyflow.EventName` +- `CHANGE` + Change event is triggered when the Element's value changes. +- `READY` + Ready event is triggered when the Element is fully rendered +- `FOCUS` + Focus event is triggered when the Element gains focus +- `BLUR` + Blur event is triggered when the Element loses focus. +The handler ```(state: JSONObject) -> Unit``` is a callback function you provide, that will be called when the event is fired with the state object as shown below. + +```kt +val state = { + "elementType": Skyflow.ElementType, + "isEmpty": Boolean, + "isRequired": Boolean, + "isFocused": Boolean, + "isValid": Boolean, + "value": String, + "selectedCardScheme": Skyflow.CardType, +} +``` +`Notes:` +- values of SkyflowElements will be returned in element state object only when `env` is `DEV`, else it is empty string i.e, '', but in case of CARD_NUMBER type element when the `env` is `PROD` for all the card types except AMEX, it will return first eight digits, for AMEX it will return first six digits and rest all digits in masked format. +- `selectedCardScheme` is only populated for the `CARD_NUMBER` element states when a user chooses a card brand. By default, `selectedCardScheme` is an empty string. + +##### Sample code snippet for using listeners +```kt +//create skyflow client with loglevel:"DEBUG" +val config = Skyflow.Configuration(vaultID = VAULT_ID, vaultURL = VAULT_URL, tokenProvider = demoTokenProvider, options = Skyflow.Options(logLevel = Skyflow.LogLevel.DEBUG)) + +val skyflowClient = Skyflow.init(config) + +val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT) + +// Create a CollectElementInput +val cardNumberInput = Skyflow.CollectElementInput( + tableName = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER, +) +val cardHolderNameInput = Skyflow.CollectElementInput( + tableName = "cards", + column = "cardHolderName", + type = Skyflow.ElementType.CARDHOLDER_NAME, +) + +val cardNumber = container.create(context = this, input = cardNumberInput) +val cardHolderName = container.create(context = this, input = cardHolderNameInput) + +//subscribing to CHANGE event, which gets triggered when element changes +cardNumber.on(eventName = Skyflow.EventName.CHANGE) { state -> + // Your implementation when Change event occurs + log.info("on change", state) +} +cardHolderName.on(eventName = Skyflow.EventName.CHANGE) { state -> + // Your implementation when Change event occurs + log.info("on change", state) +} +``` +##### Sample Element state object when `Env` is `DEV` +```kt +{ + "elementType": Skyflow.ElementType.CARD_NUMBER, + "isEmpty": false, + "isRequired": false, + "isFocused": true, + "isValid": true, + "value": "4111111111111111" +} +{ + "elementType": Skyflow.ElementType.CARDHOLDER_NAME, + "isEmpty": false, + "isRequired": false, + "isFocused": true, + "isValid": true, + "value": "John" +} +``` +##### Sample Element state object when `Env` is `PROD` +```kt +{ + "elementType": Skyflow.ElementType.CARD_NUMBER, + "isEmpty": false, + "isFocused": true, + "isValid": true, + "value": "41111111XXXXXXXX" +} +{ + "elementType": Skyflow.ElementType.CARDHOLDER_NAME, + "isEmpty": false, + "isFocused": true, + "isValid": true, + "value": "" +} +``` + +### UI Error for Collect Elements + +Helps to display custom error messages on the Skyflow Elements through the methods `setError` and `resetError` on the elements. + +`setError(error : String)` method is used to set the error text for the element, when this method is trigerred, all the current errors present on the element will be overridden with the custom error message passed. This error will be displayed on the element until `resetError()` is trigerred on the same element. + +`resetError()` method is used to clear the custom error message that is set using `setError`. + +##### Sample code snippet for setError and resetError + +```kt +//create skyflow client with loglevel:"DEBUG" +val config = Skyflow.Configuration(vaultID = VAULT_ID, vaultURL = VAULT_URL, tokenProvider = demoTokenProvider, options = Skyflow.Options(logLevel = Skyflow.LogLevel.DEBUG)) + +val skyflowClient = Skyflow.init(config) + +val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT) + +// Create a CollectElementInput +val cardNumberInput = Skyflow.CollectElementInput( + tableName = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER, +) + +val cardNumber = container.create(input = cardNumberInput) + +//Set custom error +cardNumber.setError("custom error") + +//reset custom error +cardNumber.resetError() +``` + + +### Set and Clear value for Collect Elements (DEV ENV ONLY) + +`setValue(value: String)` method is used to set the value of the element. This method will override any previous value present in the element. + +`clearValue()` method is used to reset the value of the element. + +`Note:` This methods are only available in DEV env for testing/developmental purposes and MUST NOT be used in PROD env. + +##### Sample code snippet for setValue and clearValue + +```kotlin +//create skyflow client with env DEV +val config = Skyflow.Configuration( + vaultID = VAULT_ID, + vaultURL = VAULT_URL, + tokenProvider = demoTokenProvider, + options = Skyflow.Options(env = Skyflow.Env.DEV) +) +val skyflowClient = Skyflow.init(config) +val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT) + +// Create a CollectElementInput +val cardNumberInput = Skyflow.CollectElementInput( + tableName = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER, +) +val cardNumber = container.create(input = cardNumberInput) +//Set a value programatically +cardNumber.setValue("4111111111111111") +//Clear the value +cardNumber.clearValue() +``` + +--- + +# Securely collecting data client-side using composable elements +Composable Elements combine multiple Skyflow Elements in a single row. The following steps create a composable element and securely collect data through it. + +- [**Using Skyflow Composable Elements to collect data**](#using-skyflow-composable-elements-to-collect-data) +- [**Using Skyflow Composable Elements to update data**](#using-skyflow-composable-elements-to-update-data) +- [**Event Listeners on Composable Elements**](#event-listeners-on-composable-elements) +- [**Update Composable Elements**](#update-composable-elements) +- [**Event Listeners on Composable Container**](#event-listeners-on-composable-container) + +## Using Skyflow Composable Elements to collect data +### Step 1: Create a composable container + +First create a **container** for the form elements using the `skyflowClient.container(type: Skyflow.ContainerType, options: Skyflow.ContainerOptions)` method as show below + +```kotlin +val container = skyflowClient.container(type = ContainerType.COMPOSABLE, options = ContainerOptions(layout = arrayOf(2, 1))) +``` + +The container requires an options object that contains the following keys: + +- `layout`: An array that indicates the number of rows in the container and the number of elements in each row. The index value of the array defines the number of rows, and each value in the array represents the number of elements in that row, in order. + + For example: `arrayOf(2, 1)` means the container has two rows, with two elements in the first row and one element in the second row. + + `Note`: The sum of values in the layout array should be equal to the number of elements created + +- `styles`: styles to apply to each composable row. + +- `errorTextStyles`: styles to apply if an error is encountered. + +```kotlin +val containerOptions = ContainerOptions( + layout: [1, 1, 2], // required + styles: Skyflow.Styles, // optional + errorTextStyles: Skyflow.Styles // optional +) +``` +### Step 2: Create Composable Elements +Composable Elements use the following schema: + +```kotlin +val composableElementInput = Skyflow.CollectElementInput( + tableName: String, // required, the table this data belongs to + column: String, // required, the column into which this data should be inserted + inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element + labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element + errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + label: String, // optional label for the form element + placeholder: String, // optional placeholder for the form element + altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element + validations: ValidationSet, // optional set of validations for the input element + type: Skyflow.ElementType, // Skyflow.ElementType enum +) +``` +The `tableName` and `column` fields indicate which table and column in the vault the Element corresponds to. + +**Note**: +- Use dot delimited strings to specify columns nested inside JSON fields (e.g. `address.street.line1`) + +The `inputStyles` parameter accepts a `Skyflow.Styles` object which consists of multiple `Skyflow.Style` objects which should be applied to the form element in the following states: + +- `base`: all other variants inherit from these styles +- `complete`: applied when the Element has valid input +- `empty`: applied when the Element has no input +- `focus`: applied when the Element has focus +- `invalid`: applied when the Element has invalid input + +Each Style object accepts the following properties, please note that each property is optional: + +```kotlin +Skyflow.Style( + borderColor: Int // optional + cornerRadius: Float // optional + padding: Skyflow.Padding // optional + borderWidth: Int // optional + font: Int // optional + textAlignment: Int // optional + textColor: Int // optional + placeholderColor: Int // optional + width: Int // optional + height: Int // optional + margin: Skyflow.Margin // optional + backgroundColor: Int // optional + minWidth: Int // optional + maxWidth: Int // optional + minHeight: Int // optional + maxHeight: Int // optional +) +``` + +Here `Skyflow.Padding` and `Skyflow.Margin` are classes which can be used to set the padding and margin respectively for the composable element which takes all the left, top, right, bottom values. + +```kt +Skyflow.Padding(left: Int, top: Int, right: Int, bottom: Int) + +Skyflow.Margin(left: Int, top: Int, right: Int, bottom: Int) +``` + +An example Skyflow.Styles object +```kotlin +val styles = Skyflow.Styles( + base: Style, // optional + complete: Style, // optional + empty: Style, // optional + focus: Style, // optional + invalid: Style // optional +) +``` + +**Notes**: +- The `labelStyles` and `errorTextStyles` fields accept the above mentioned `Skyflow.Styles` object which are applied to the `label` and `errorText` text views respectively. + +- The states that are available for `labelStyles` are `base` and `focus`. + +- The `errorTextStyles` will be ignored for composable element passed in `CollectElementInput` and `errorTextStyles` passed in `ContainerOptions` will be used instead. + +- The state that is available for `errorTextStyles` is only the base state, it shows up when there is some error in the composable element. + +- The parameters in `Skyflow.Style` object that are respected for `label` and `errorText` text views are + - padding + - font + - textColor + - textAlignment + - width + - height + - margin + - minWidth + - maxWidth + - minHeight + - maxHeight + +Other parameters in the `Skyflow.Style` object are ignored for `label` and `errorText` text views. + +Finally, the `type` parameter takes a Skyflow.ElementType. Each type applies the appropriate regex and validations to the form element. + +The Android SDK supports the following composable elements: + +- `INPUT_FIELD` +- `CARDHOLDER_NAME` +- `CARD_NUMBER` +- `EXPIRATION_DATE` +- `CVV` +- `PIN` +- `EXPIRATION_YEAR` +- `EXPIRATION_MONTH` + +**Note**: Only when the entered value in the below composable elements is valid, the focus shifts automatically. The element types are: + +- `CARD_NUMBER` +- `EXPIRATION_DATE` +- `EXPIRATION_MONTH` +- `EXPIRATION_YEAR` + +The `INPUT_FIELD` type is a custom UI element without any built-in validations. See the section on [validations](#validations) for more information on validations. + +Along with `CollectElementInput`, you can define other options in the `CollectElementOptions` object which is described below. + +```kotlin +Skyflow.CollectElementOptions( + required: Boolean, // Indicates whether the field is marked as required. Defaults to 'false' + enableCardIcon: Boolean, // Indicates whether card icon should be enabled (only for CARD_NUMBER inputs) + format: String, // Format for the element + translation: HashMap // Indicates the allowed data type value for format. +) +``` +- `required`: Indicates whether the field is marked as required or not. Default is `false`. +- `enableCardIcon`: Indicates whether the icon is visible for the CARD_NUMBER element. Default is `true`. +- `format`: A string value that indicates the format pattern applicable to the element type. Only applicable to `EXPIRATION_DATE`, `CARD_NUMBER`, `EXPIRATION_YEAR`, and `INPUT_FIELD` elements. + - For INPUT_FIELD elements, + - the length of `format` determines the expected length of the user input. + - if `translation` isn't specified, the `format` value is considered a string literal. +- `translation`: A dictionary of key/value pairs, where the key is a character that appears in `format` and the value is a regex pattern of acceptable inputs for that character. Each key can only appear once. Only applicable for INPUT_FIELD elements. + +Accepted values by element type: + +| Element type | `format` | `translation` | Examples | +| --------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| EXPIRATION_DATE |
    • `mm/yy`(default)
    • `mm/yyyy`
    • `yy/mm`
    • `yyyy/mm`
    | N/A |
    • 12/27
    • 12/2027
    • 27/12
    • 2027/12
| +| EXPIRATION_YEAR |
  • `yy`(default)
  • `yyyy`
  • | N/A |
    • 27
    • 2027
    | +| CARD_NUMBER |
    • `XXXX XXXX XXXX XXXX` (default)
    • `XXXX-XXXX-XXXX-XXXX`
    | N/A |
    • 1234 5678 9012 3456
    • 1234-5678-9012-3456
    | +| INPUT_FIELD | A string that matches the desired output, with placeholder characters of your choice. | A hashmap of key/value pairs. Defaults to `hashmapOf('X' to "[0-9]")` | With `format: +91 XXXX-XX-XXXX` and `translation: hashmapOf('X' to "[0-9]")`, user input of "1234121234" displays as "+91 1234-12-1234". | + +Collect Element Options examples for INPUT_FIELD + +Example 1 +```kotlin +Skyflow.CollectElementOptions( + required = true, + enableCardIcon = true, + format = "+91 XXXX-XX-XXXX", + translation = hashmapOf('X' to "[0-9]") +) +``` +User input: "1234121234" + +Value displayed in INPUT_FIELD: "+91 1234-12-1234" + +Example 2 +```kotlin +Skyflow.CollectElementOptions( + required = true, + enableCardIcon = true, + format = "AY XX-XXX-XXXX", + translation = hashmapOf('X' to "[0-9]", 'Y' to "[A-Z]") +) +``` +User input: "B1234121234" + +Value displayed in INPUT_FIELD: "AB 12-341-2123" + +Once the `Skyflow.CollectElementInput` and `Skyflow.CollectElementOptions` objects are defined, add to the container using the `create(context: Context, input: CollectElementInput, options: CollectElementOptions)` method as shown below. The `input` param takes a `Skyflow.CollectElementInput` object as defined above and the `options` parameter takes an `Skyflow.CollectElementOptions` object as described below: + +```kotlin +val composableElementInput = Skyflow.CollectElementInput( + tableName: String, // required, the table this data belongs to + column: String, // required, the column into which this data should be inserted + inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element + labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element + errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + label: String, // optional label for the form element + placeholder: String, // optional placeholder for the form element + altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element + validations: ValidationSet, // optional set of validations for the input element + type: Skyflow.ElementType, // Skyflow.ElementType enum +) + +val collectElementOptions = Skyflow.CollectElementOptions( + required = false, // indicates whether the field is marked as required. Defaults to 'false' + enableCardIcon = true, // indicates whether card icon should be enabled (only for CARD_NUMBER inputs) + format = "mm/yy" // Format for the element +) + +val element = container.create(context = this, input: composableElementInput, options: collectElementOptions) +``` +### Step 3: Mount Elements to the Screen + +To specify where the Elements will be rendered on the screen, fetch composable layout using `container.getComposableLayout()` and add it to a layout in your app programmatically. + +```kotlin +try { + val composableLayout = container.getComposableLayout() + existingLayout.addView(composableLayout) +} catch(error: Exception) { + println(error) +} +``` + +The Skyflow Element is an implementation of native android View so it can be used/mounted similarly.Alternatively, you can use the unmount method to reset any element to it's initial state. + +```kotlin +fun clearFieldsOnSubmit(elements: List) { + // resets all elements in the array + for element in elements { + element.unmount() + } +} +``` +### Step 4: Collect data from elements + +When the form is ready to be submitted, call the `collect(callback: CollectCallback, options: CollectOptions? = null)` method on the container object. The options parameter takes a `CollectOptions` object. + +`Skyflow.CollectOptions` takes two optional fields +- `additionalFields`: Non-PCI data to be inserted alongside element values. See [Additional fields](#additional-fields-non-pci-data). +- `upsert`: To support upsert operations, the table and a unique column. See [Upsert support](#upsert-support). + +```kotlin +val options = CollectOptions( + additionalFields = AdditionalFields(records = listOf( + AdditionalFieldsRecord(tableName = "persons", data = mapOf("gender" to "MALE")) + )) +) +composableContainer.collect(object : CollectCallback { + override fun onSuccess(response: CollectResponse) { + response.records.forEach { record -> + if (record.httpCode == 200) Log.d(TAG, "success: ${record.tokens}") + else Log.d(TAG, "error [${record.httpCode}]: ${record.error}") + } + } + override fun onFailure(error: SkyflowError) { Log.d(TAG, "failure: ${error.message}") } +}, options) +``` + + +## Using Skyflow Composable Elements to update data + +Composable Elements combine multiple Skyflow Elements in a single row. The following steps create a composable element and securely update data through it. + +### Step 1: Create a composable container + +First create a **container** for the form elements using the `skyflowClient.container(type: Skyflow.ContainerType, options: Skyflow.ContainerOptions)` method as shown below: + +```kt +val containerOptions = ContainerOptions( + layout = arrayOf(1, 1, 2), // required + styles = Skyflow.Styles, // optional + errorTextStyles = Skyflow.Styles // optional +) +val container = skyflowClient.container( + type = ContainerType.COMPOSABLE, + options = containerOptions +) +``` + +### Step 2: Create Composable Elements + +Composable Elements use the following schema: + +```kt +val composableElementInput = Skyflow.CollectElementInput( + tableName: String, // required, the table this data belongs to + column: String, // required, the column into which this data should be updated + type: Skyflow.ElementType, // Skyflow.ElementType enum + inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element + labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element + errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + label: String, // optional label for the form element + placeholder: String, // optional placeholder for the form element + altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element + validations: ValidationSet, // optional set of validations for the input element + skyflowId: String // The skyflow_id of the record to be updated +) +``` + +The `tableName` and `column` fields indicate which table and column in the vault the Element corresponds to. + +**Note:** +- Use dot delimited strings to specify columns nested inside JSON fields (e.g. `address.street.line1`) + +Along with `CollectElementInput`, you can define other options in the `CollectElementOptions` object as described below: + +```kt +Skyflow.CollectElementOptions( + required: Boolean, // Indicates whether the field is marked as required. Defaults to 'false' + enableCardIcon: Boolean, // Indicates whether card icon should be enabled (only for CARD_NUMBER inputs) + format: String, // Format for the element + translation: HashMap // Indicates the allowed data type value for format. +) +``` + +Once the `Skyflow.CollectElementInput` and `Skyflow.CollectElementOptions` objects are defined, add to the container using the `create(context: Context, input: CollectElementInput, options: CollectElementOptions)` method as shown below: + +```kt +val element = container.create(context = this, input = composableElementInput, options = collectElementOptions) +``` + +### Step 3: Mount Elements to the Screen + +To specify where the Elements will be rendered on the screen, fetch composable layout using `container.getComposableLayout()` and add it to a layout in your app programmatically. + +```kt +try { + val composableLayout = container.getComposableLayout() + existingLayout.addView(composableLayout) +} catch (error: Exception) { + println(error) +} +``` + +The Skyflow Element is an implementation of the View so it can be used/mounted similarly. Alternatively, you can use the `unmount` method to reset any collect element to its initial state. + +```kt +fun clearFieldsOnSubmit(elements: List) { + // resets all elements in the array + for (element in elements) { + element.unmount() + } +} +``` + +### Step 4: Update data from Elements + +When you submit the form, call the `collect(callback: CollectCallback, options: CollectOptions? = null)` method on the container object. + +The options parameter takes a `CollectOptions` object with the following optional fields: + +- `additionalFields`: Non-PCI data to insert alongside element values, as an `AdditionalFields` object. +- `upsert`: To support upsert operations, pass a list of `UpsertOptions` specifying the table, update type, and unique columns. + +```kotlin +val options = CollectOptions( + additionalFields = AdditionalFields(records = listOf( + AdditionalFieldsRecord( + tableName = "persons", + data = mapOf("gender" to "MALE"), + skyflowId = "" + ) + )) +) +composableContainer.collect(object : CollectCallback { + override fun onSuccess(response: CollectResponse) { + response.records.forEach { record -> + if (record.httpCode == 200) Log.d(TAG, "update success: ${record.tokens}") + else Log.d(TAG, "update error [${record.httpCode}]: ${record.error}") + } + } + override fun onFailure(error: SkyflowError) { Log.d(TAG, "update failure: ${error.message}") } +}, options) +``` + + +**Note:** `skyflowId` is required to update an existing record. Without it, a new record is inserted. + +## Event Listeners on Composable Elements +You can communicate with Skyflow Elements by listening to element events: + +```kotlin +element.on(eventName: Skyflow.EventName) { state -> + // handle function +} +``` + +The SDK supports four events: + +- `CHANGE`: Triggered when the Element's value changes. +- `READY`: Triggered when the Element is fully rendered. +- `FOCUS`: Triggered when the Element gains focus. +- `BLUR`: Triggered when the Element loses focus. + +The handler `(state: JSONObject) -> Unit` is a callback function you provide, that will be called when the event is fired with the state object as shown below. + +```kotlin +val state = { + "elementType": Skyflow.ElementType, + "isEmpty": Bool , + "isRequired": Bool, + "isFocused": Bool, + "isValid": Bool, + "value": String +} +``` + +`Note`: +values of SkyflowElements will be returned in element state object only when `env` is `DEV`, else it is empty string i.e, '', but in case of CARD_NUMBER type element when the `env` is `PROD` for all the card types except AMEX, it will return first eight digits, for AMEX it will return first six digits and rest all digits in masked format. + +#### Example Usage of Event Listener on Composable Elements + +```kotlin +// create skyflow client with loglevel:"DEBUG" +val config = Skyflow.Configuration( + vaultID = VAULT_ID, + vaultURL = VAULT_URL, + tokenProvider = demoTokenProvider, + options = Skyflow.Options(logLevel = Skyflow.LogLevel.DEBUG) +) + +val skyflowClient = Skyflow.init(config) + +val containerOptions = ContainerOptions( + layout = arrayOf(1, 1), + styles = Styles(base: Style(borderColor: UIColor.gray)), + errorTextStyles = Styles(base: Style(textColor: UIColor.red)) +) + +//Create a Composable Container. +val container = skyflowClient.container(type: Skyflow.ContainerType.COMPOSABLE, options: containerOptions) + +// Create a CollectElementInput +val cardNumberInput = Skyflow.CollectElementInput( + tableName = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER, +) + +val cardHolderNameInput = Skyflow.CollectElementInput( + tableName = "cards", + column = "cardHolderName", + type = Skyflow.ElementType.CARDHOLDER_NAME, +) + +val cardNumber = container.create(context = this, input = cardNumberInput) +val cardHolderName = container.create(context = this, input = cardHolderNameInput) + +try { + val composableLayout = container.getComposableLayout() + parent.addView(composableLayout) +} catch (error: Exception) { + println(error) +} + +// subscribing to CHANGE event, which gets triggered when element changes +cardNumber.on(eventName: Skyflow.EventName.CHANGE) { state -> + // Your implementation when Change event occurs + log.info("on change", state) +} + +cardHolderName.on(eventName: Skyflow.EventName.CHANGE) { state -> + // Your implementation when Change event occurs + log.info("on change", state) +} +``` + +#### Sample Element state object when `env` is `DEV` +```kotlin +{ + "elementType": Skyflow.ElementType.CARD_NUMBER, + "isEmpty": false, + "isRequired": false, + "isFocused": true, + "isValid": true, + "value": "4111111111111111" +} +{ + "elementType": Skyflow.ElementType.CARDHOLDER_NAME, + "isEmpty": false, + "isRequired": false, + "isFocused": true, + "isValid": true, + "value": "John" +} +``` +#### Sample Element state object when `env` is `PROD` +```kotlin +{ + "elementType": Skyflow.ElementType.CARD_NUMBER, + "isEmpty": false, + "isRequired": false, + "isFocused": true, + "isValid": true, + "value": "41111111XXXXXXXX" +} +{ + "elementType": Skyflow.ElementType.CARDHOLDER_NAME, + "isEmpty": false, + "isRequired": false, + "isFocused": true, + "isValid": true, + "value": "" +} +``` +## Update Composable Elements +You can update composable element properties with the `update` interface. + +The `update` interface takes the below object: +```kotlin +val updateElement = Skyflow.CollectElementInput( + tableName: String, // optional the table this data belongs to + column: String, // optional the column into which this data should be inserted + inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element + labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element + errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + label: String, // optional label for the form element + placeholder: String, // optional placeholder for the form element + altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element + validations: ValidationSet, // optional set of validations for the input element +) +``` +Only include the properties that you want to update for the specified composable element. + +Properties your provided when you created the element remain the same until you explicitly update them. + +`Notes`: +- You can't update the type property of an element. +- Upon calling the update method, if not passed, all Styles i.e. `inputStyles`, `labelStyles` and `errorTextStyles` will be overridden by default Styles. + +#### End to end example +```kotlin +// create skyflow client with loglevel:"DEBUG" +val config = Skyflow.Configuration( + vaultID = VAULT_ID, + vaultURL = VAULT_URL, + tokenProvider = demoTokenProvider, + options = Skyflow.Options(logLevel = Skyflow.LogLevel.DEBUG) +) + +val skyflowClient = Skyflow.init(config) + +val containerOptions = ContainerOptions( + layout = arrayOf(1, 1), + styles = Styles(base: Style(borderColor: UIColor.gray)), + errorTextStyles = Styles(base: Style(textColor: UIColor.red)) +) + +//Create a Composable Container. +val container = skyflowClient.container(type: Skyflow.ContainerType.COMPOSABLE, options: containerOptions) + +// Create a CollectElementInput +val cardNumberInput = Skyflow.CollectElementInput( + tableName = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER, +) + +val cardHolderNameInput = Skyflow.CollectElementInput( + tableName = "cards", + column = "cardHolderName", + type = Skyflow.ElementType.CARDHOLDER_NAME, +) + +val cardNumber = container.create(context = this, input = cardNumberInput) +val cardHolderName = container.create(context = this, input = cardHolderNameInput) + +try { + val composableLayout = container.getComposableLayout() + parent.addView(composableLayout) +} catch (error: Exception) { + println(error) +} + +// Update table, column, inputStyles properties on cardNumber. +cardNumber.update(update = CollectElementInput( + tableName = "cards", + column = "cardHolderName", + inputStyles = Skyflow.Styles(base: Style(borderColor: UIColor.red)) +)) + +val lengthRule = LengthMatchRule(minLength = 5, maxLength = 16, error = "Must be between 5 and 16 digits") + +// Update validations and placeholder property on cardHolderName. +cardHolderName.update(update = CollectElementInput( + placeholder = "cardHolderName", + validations = ValidationSet(rules = mutableListOf(lengthRule))) +) +``` + +## Event Listeners on Composable Container + +Currently, the SDK supports one event: +- `SUBMIT`: Triggered when the Enter key is pressed in any container element. + +The handler function `() -> Unit` is a callback function you provide that's called when the `SUBMIT` event fires. + +#### Example +```kotlin +// create skyflow client with loglevel:"DEBUG" +val config = Skyflow.Configuration( + vaultID = VAULT_ID, + vaultURL = VAULT_URL, + tokenProvider = demoTokenProvider, + options = Skyflow.Options(logLevel = Skyflow.LogLevel.DEBUG) +) + +val skyflowClient = Skyflow.init(config) + +val containerOptions = ContainerOptions( + layout = arrayOf(1), + styles = Styles(base: Style(borderColor: UIColor.gray)), + errorTextStyles = Styles(base: Style(textColor: UIColor.red)) +) + +//Create a Composable Container. +val container = skyflowClient.container(type: Skyflow.ContainerType.COMPOSABLE, options: containerOptions) + +// Create a CollectElementInput +val cardNumberInput = Skyflow.CollectElementInput( + tableName = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER, +) + +val cardNumber = container.create(context = this, input = cardNumberInput) + +try { + val composableLayout = container.getComposableLayout() + parent.addView(composableLayout) +} catch (error: Exception) { + println(error) +} + +//Call Submit event listener on container +container.on(EventName.SUBMIT) { + // Your implementation when Submit (enter) event occurs + log.info("on submit", "submit event triggerred") +} +``` + +--- +# Securely revealing data client-side +- [**Using Skyflow Elements to reveal data**](#using-skyflow-elements-to-reveal-data) +- [**UI Error for Reveal Elements**](#ui-error-for-reveal-elements) +- [**Set token for Reveal Elements**](#set-token-for-reveal-elements) +- [**Set and clear altText for Reveal Elements**](#set-and-clear-alttext-for-reveal-elements) + +## Using Skyflow Elements to reveal data +Skyflow Elements can be used to securely reveal data in an application without exposing your front end to the sensitive data. This is great for use-cases like card issuance where you may want to reveal the card number to a user without increasing your PCI compliance scope. +### Step 1: Create a container +To start, create a container using the `skyflowClient.container(Skyflow.ContainerType.REVEAL)` method as shown below. +```kt +val container = skyflowClient.container(type = Skyflow.ContainerType.REVEAL) +``` + +### Step 2: Create a reveal Element +Next, define a `RevealElementInput` for each element to reveal: + +```kotlin +val revealElementInput = RevealElementInput( + token = "", // token of the data to reveal + inputStyles = Skyflow.Styles(), // optional styles for the element + labelStyles = Skyflow.Styles(), // optional styles for the label + errorTextStyles = Skyflow.Styles(), // optional styles for the error text + label = "Card Number", // optional label + altText = "•••• •••• •••• ••••" // optional placeholder shown before reveal +) +``` + + +**Note:** Redaction is not set on individual reveal elements. To apply a redaction to a token group, use `tokenGroupRedactions` on `RevealOptions` passed to `reveal()`. See [Reveal with typed callbacks](#reveal-with-typed-callbacks). + +The `inputStyles` parameter accepts a styles object as described in the [previous section](#step-2-create-a-collect-element) for collecting data but the only state available for a reveal element is the base state. + +The `labelStyles` and `errorTextStyles` fields accept the above mentioned `Skyflow.Styles` object as described in the [previous section](#step-2-create-a-collect-element), the only state available for a reveal element is the base state. + +The `inputStyles`, `labelStyles` and `errorTextStyles` parameters accepts a styles object as described in the [previous section](#step-2-create-a-collect-element) for collecting data but only a single variant is available i.e. base. + +An example of a inputStyles object: + +```kt +var inputStyles = Skyflow.Styles(base = Skyflow.Style( + borderColor = Color.BLUE)) +``` + +An example of a labelStyles object: + +```kt +var labelStyles = Skyflow.Styles(base = + Skyflow.Style(font = 12)) +``` + +An example of a errorTextStyles object: + +```kt +var labelStyles = Skyflow.Styles(base = + Skyflow.Style(textColor = COLOR.RED)) +``` + +Along with `RevealElementInput`, you can define other options in the `RevealElementOptions` object as described below: +```kotlin +Skyflow.RevealElementOptions( + format: String, // Format for the element. + translation: HashMap // Indicates the allowed data type value for format + enableCopy: Boolean, // Indicates whether to enable the copy icon in reveal elements to copy text to clipboard. Defaults to 'false' +) +``` +- `format`: A string value that indicates how the element should display the value, including placeholder characters that map to keys `translation` If `translation` isn't specified, the `format` value is considered a string literal. + +- `translation`: A hashmap of key/value pairs, where the key is a character that appears in `format` and the value is a regex pattern of acceptable inputs for that character. Each key can only appear once. Defaults to `hashmapOf('X' to "[0-9]")`. + +`enableCopy`: Indicates whether to enable the copy icon in reveal elements to copy text to clipboard. + +Reveal Element Options examples: + +Example 1: +```kotlin +val options = Skyflow.RevealElementOptions( + format = "(XXX) XXX-XXXX", + translation = hashmapOf('X' to "[0-9]") +) +``` +Value from vault: "1234121234" + +Value displayed in element: "(123) 412-1234" + +Example 2: +```kotlin +val options = Skyflow.RevealElementOptions( + format = "XXXX-XXXXXX-XXXXX", + translation = hashmapOf('X' to "[0-9]") +) +``` +Value from vault: "374200000000004" + +Value displayed in element: "3742-000000-00004" + +Once you've defined a `Skyflow.RevealElementInput` object and `Skyflow.RevealElementOptions`, you can use the `create()` method of the container to create the Element as shown below: + +```kotlin +val element = container.create( + input = revealElementInput, + options = Skyflow.RevealElementOptions( + format = "XXXX-XXXXXX-XXXXX", + translation = hashmapOf('X' to "[0-9]") + ) +) +``` + +### Step 3: Mount Elements to the Screen + +Elements used for revealing data are mounted to the screen the same way as Elements used for collecting data. Refer to Step 3 of the [section above](#step-3-mount-elements-to-the-screen). + +### Step 4: Reveal data +When the sensitive data is ready to be retrieved and revealed, call the `reveal()` method on the container with a typed `RevealCallback`: + +```kotlin +revealContainer.reveal(object : RevealCallback { + override fun onSuccess(response: RevealResponse) { + response.records.forEach { record -> + if (record.httpCode == 200) { + Log.d(TAG, "revealed: token=${record.token}, group=${record.tokenGroupName}") + } else { + Log.e(TAG, "partial error [${record.httpCode}]: ${record.error}") + } + } + } + override fun onFailure(error: SkyflowError) { + Log.e(TAG, "reveal failed: ${error.message}") + } +}) +``` + + +To apply redaction per token group, pass `RevealOptions`: + +```kotlin +val options = RevealOptions( + tokenGroupRedactions = listOf( + TokenGroupRedaction(tokenGroupName = "", redaction = "") + ) +) +revealContainer.reveal(object : RevealCallback { ... }, options) +``` + + +### UI Error for Reveal Elements + +Helps to display custom error messages on the Skyflow Elements through the methods `setError` and `resetError` on the elements. + +`setError(error : String)` method is used to set the error text for the element, when this method is trigerred, all the current errors present on the element will be overridden with the custom error message passed. This error will be displayed on the element until `resetError()` is trigerred on the same element. + +`resetError()` method is used to clear the custom error message that is set using `setError`. + + +### Set token for Reveal Elements +The `setToken(value: String)` method can be used to set the token of the Reveal Element. If no altText is set, the set token will be displayed on the UI as well. If altText is set, then there will be no change in the UI but the token of the element will be internally updated. +### Set and Clear altText for Reveal Elements +The `setAltText(value: String)` method can be used to set the altText of the Reveal Element. This will cause the altText to be displayed in the UI regardless of whether the token or value is currently being displayed. +`clearAltText()` method can be used to clear the altText, this will cause the element to display the token or actual value of the element. If the element has no token, the element will be empty. + + +### End to end example of revealing data with Skyflow Elements +#### [Sample Code](https://github.com/skyflowapi/skyflow-android/blob/main/samples/flowvault/src/main/java/com/Skyflow/RevealActivity.kt): +```kotlin +// Initialize skyflow configuration +val config = Configuration( + vaultID = "", + vaultURL = "", + tokenProvider = demoTokenProvider +) + +// Initialize skyflow client +val skyflowClient = init(config) + +// Create a Reveal Container +val container = skyflowClient.container(ContainerType.REVEAL) + +// Create Skyflow.Styles with individual Skyflow.Style variants +val baseStyle = Style(borderColor = Color.BLUE) +val baseTextStyle = Style(textColor = Color.BLACK) +val inputStyles = Styles(base = baseStyle) +val labelStyles = Styles(base = baseTextStyle) +val errorTextStyles = Styles(base = baseTextStyle) + +// Create Reveal Elements — no redaction on individual elements +val cardNumberInput = RevealElementInput( + token = "b63ec4e0-bbad-4e43-96e6-6bd50f483f75", + inputStyles = inputStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "Card Number", + altText = "XXXX XXXX XXXX XXXX" +) + +val cardNumberElement = container.create(context = this, input = cardNumberInput) + +val nameInput = RevealElementInput( + token = "89024714-6a26-4256-b9d4-55ad69aa4047", + inputStyles = inputStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "Full Name", + altText = "XXX" +) + +val nameElement = container.create(context = this, input = nameInput) + +// Optionally set/reset custom error text on an element +nameElement.setError("custom error") +nameElement.resetError() + +// Mount elements to the screen +parent.addView(cardNumberElement) +parent.addView(nameElement) + +// Call reveal with typed RevealCallback +container.reveal(object : RevealCallback { + override fun onSuccess(response: RevealResponse) { + Log.d(TAG, response.toJson().toString()) + } + override fun onFailure(error: SkyflowError) { + Log.e(TAG, "reveal failed: ${error.message}") + } +}) +``` + +The `records` list contains both successful and failed tokens. Each record carries its own `httpCode` so you can handle mixed results in a single pass. + +#### Sample Response +```json +{ + "records": [ + { + "token": "b63ec4e0-bbad-4e43-96e6-6bd50f483f75", + "tokenGroupName": "deterministic_string", + "metadata": { + "skyflowId": "3ac0424e-fe45-43a9-9193-2e6d2913cbd2", + "tableName": "cards" + }, + "httpCode": 200 + }, + { + "token": "89024714-6a26-4256-b9d4-55ad69aa4047", + "error": "Detokenize failed. Token 89024714-6a26-4256-b9d4-55ad69aa4047 is invalid. Specify a valid token.", + "httpCode": 404 + } + ] +} +``` + +A whole-request failure (e.g. auth error) skips `onSuccess` and delivers a `SkyflowError` to `onFailure`: +```json +{ + "grpcCode": 13, + "httpCode": 500, + "message": "Skyflow services experienced an internal error.", + "httpStatus": "Internal Server Error", + "details": [] +} +``` +--- + +# Typed callbacks and response handling + +The SDK provides typed callbacks and typed response objects for collect and reveal operations. Both successes and partial errors are returned in the same `records` list — each record carries its own `httpCode`, so you can handle mixed results without exceptions. + +--- + +## Collect with typed callbacks + +### CollectCallback + +Implement `CollectCallback` to receive typed collect results: + +```kotlin +container.collect(object : CollectCallback { + override fun onSuccess(response: CollectResponse) { + Log.d(TAG, response.toJson().toString()) + } + override fun onFailure(error: SkyflowError) { + Log.d(TAG, "collect failure: code=${error.httpCode}, message=${error.message}") + } +}) +``` + +#### Sample Response +```json +{ + "records": [ + { + "tableName": "pii_fields", + "skyflowId": "3ac0424e-fe45-43a9-9193-2e6d2913cbd2", + "tokens": { + "card_number": [ + { "token": "b63ec4e0-bbad-4e43-96e6-6bd50f483f75", "tokenGroupName": "deterministic_string" } + ] + }, + "httpCode": 200 + } + ] +} +``` + +### CollectOptions + +#### Upsert support + +Pass `CollectOptions` with `upsert` to insert-or-update based on a unique column: + +```kotlin +val options = CollectOptions( + upsert = listOf( + UpsertOptions( + tableName = "", + updateType = UpdateType.UPDATE, + uniqueColumns = listOf("") + ) + ) +) +container.collect(object : CollectCallback { ... }, options) +``` + + +#### Full-row overwrite with `UpdateType.REPLACE` + +`UpdateType` controls how a matched record is written: + +- `UpdateType.UPDATE` (used above) merges the fields in this request into the matched record — every other column on that record is left untouched. +- `UpdateType.REPLACE` overwrites the entire matched record — any column not included in this request (element values plus `additionalFields`) is cleared, not just left alone. + +Use `REPLACE` when a stale value from a previous write must not survive: the matched record ends up as exactly what this request contains, rather than layered on top of whatever was already there. + +```kotlin +val options = CollectOptions( + upsert = listOf( + UpsertOptions( + tableName = "", + updateType = UpdateType.REPLACE, + uniqueColumns = listOf("") + ) + ) +) +container.collect(object : CollectCallback { ... }, options) +``` + +#### Additional fields (non-PCI data) + +Pass non-PCI data alongside element values using `AdditionalFields`: + +```kotlin +val options = CollectOptions( + additionalFields = AdditionalFields( + records = listOf( + AdditionalFieldsRecord( + tableName = "", + data = mapOf("" to "") + // skyflowId = "" // set this to update an existing record + ) + ) + ) +) +container.collect(object : CollectCallback { ... }, options) +``` + + +**Note:** Set `skyflowId` on `AdditionalFieldsRecord` to update an existing record. Without it, a new record is inserted. + +### CollectResponse + +`CollectResponse.records` is a flat list of `CollectRecord` objects. Both successes and partial errors are included in the same list. + +```kotlin +data class CollectRecord( + val tableName: String?, + val skyflowId: String?, + val tokens: Map?, + val hashedData: Map?, + val error: String?, + val httpCode: Int +) +``` + +Both success and partial-error records are delivered as `CollectRecord` in the same `records` list. Check `httpCode` on each record to distinguish them. + +#### Sample response (success + partial error): +```json +{ + "records": [ + { + "tableName": "cards", + "skyflowId": "f1714ef8-8deb-489a-a18d-77e0e007f403", + "tokens": { + "cardNumber": [ + {"token": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", "tokenGroupName": "deterministic_string"} + ] + }, + "httpCode": 200 + }, + { + "tableName": "persons", + "error": "Invalid request. Required field ssn is missing.", + "skyflowId": null, + "httpCode": 400 + } + ] +} +``` + +The SDK populates `tableName` on error records from the original request, so `CollectRecord.tableName` is never empty in the delivered response. + +#### If the entire request fails, `onFailure` delivers a `SkyflowError`: + +```kotlin +override fun onFailure(error: SkyflowError) { + Log.d(TAG, "httpCode=${error.httpCode}, message=${error.message}") +} +``` + + +#### Sample Code: +[CollectActivity.kt](https://github.com/skyflowapi/skyflow-android/blob/main/samples/flowvault/src/main/java/com/Skyflow/CollectActivity.kt) + +--- + +## Reveal with typed callbacks + +### RevealCallback + +Implement `RevealCallback` to receive typed reveal results: + +```kotlin +revealContainer.reveal(object : RevealCallback { + override fun onSuccess(response: RevealResponse) { + response.records.forEach { record -> + if (record.httpCode == 200) { + Log.d(TAG, "reveal success: token=${record.token}") + } else { + Log.d(TAG, "reveal error [${record.httpCode}]: ${record.error}") + } + } + } + override fun onFailure(error: SkyflowError) { + Log.d(TAG, "reveal failure: code=${error.httpCode}, message=${error.message}") + } +}) +``` + + +### RevealOptions + +Apply a redaction to an entire token group using `RevealOptions.tokenGroupRedactions`. This is a request-level setting — the redaction applies to every token in the named group, not to individual reveal elements. + +```kotlin +val options = RevealOptions( + tokenGroupRedactions = listOf( + TokenGroupRedaction( + tokenGroupName = "", + redaction = "" + ) + ) +) +revealContainer.reveal(object : RevealCallback { ... }, options) +``` + + +### RevealResponse + +`RevealResponse.records` is a flat list of `RevealRecord` objects. Both successes and partial errors are included in the same list. + +```kotlin +data class RevealRecord( + val token: String, + val tokenGroupName: String?, + val metadata: Map?, // includes skyflowId, tableName + val error: String?, + val httpCode: Int +) +``` + +#### Sample success response: +```json +{ + "records": [ + { + "token": "b63ec4e0-bbad-4e43-96e6-6bd50f483f75", + "tokenGroupName": "deterministic_string", + "metadata": { + "skyflowId": "3ac0424e-fe45-43a9-9193-2e6d2913cbd2", + "tableName": "cards" + }, + "httpCode": 200 + } + ] +} +``` + +#### Sample partial error response: +```json +{ + "records": [ + { + "token": "b63ec4e0-bbad-4e43-96e6-6bd50f483f75", + "tokenGroupName": "deterministic_string", + "metadata": { + "skyflowId": "3ac0424e-fe45-43a9-9193-2e6d2913cbd2", + "tableName": "cards" + }, + "httpCode": 200 + }, + { + "token": "a4b24714-6a26-4256-b9d4-55ad69aa4047", + "error": "Tokens not found for a4b24714-6a26-4256-b9d4-55ad69aa4047", + "httpCode": 404 + } + ] +} +``` + +#### Sample Code: +[RevealActivity.kt](https://github.com/skyflowapi/skyflow-android/blob/main/samples/flowvault/src/main/java/com/Skyflow/RevealActivity.kt) + +--- + +## Limitation +Currently the skyflow collect elements and reveal elements can't be used in the XML layout definition, we have to add them to the views programatically. + +## Reporting a Vulnerability + +If you discover a potential security issue in this project, please reach out to us at security@skyflow.com. Please do not create public GitHub issues or Pull Requests, as malicious actors could potentially view them. + + + diff --git a/flowvault/build.gradle b/flowvault/build.gradle new file mode 100644 index 00000000..d6bfcb93 --- /dev/null +++ b/flowvault/build.gradle @@ -0,0 +1,148 @@ +plugins { + id 'com.android.library' + id 'maven-publish' +} +ext { + mGroupId = "com.skyflowapi.android" + mArtifactId = "skyflow-flowvault-android-sdk" + mVersionCode = 1 + mVersionName = "1.0.0-dev.d837ad9" + + mLibraryName = "skyflow-flowvault-android" + mLibraryDescription = "Skyflow’s FlowVault Android SDK can be used to securely collect, tokenize, and display sensitive data in the mobile without exposing your front-end infrastructure to sensitive data." + +} +android { + namespace "com.skyflow_android" + compileSdk 35 + + // Shared, contract-agnostic sources live in /core and are compiled INTO this module, + // exactly as in the legacy SDK (see docs/sdk-split-plan.md). + sourceSets { + main { + kotlin.srcDirs += "$rootDir/common/src/main/kotlin" + res.srcDirs += "$rootDir/common/src/main/res" + } + } + + defaultConfig { + minSdk 21 + targetSdk 35 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + testOptions { + unitTests { + includeAndroidResources = true + returnDefaultValues = true + } + + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + buildConfigField("String", "SDK_NAME", "\"" + project.ext.mArtifactId + "\"") + buildConfigField("String", "SDK_VERSION", "\"" + project.ext.mVersionName + "\"") + } + + debug { + buildConfigField("String", "SDK_NAME", "\"" + project.ext.mArtifactId + "\"") + buildConfigField("String", "SDK_VERSION", "\"" + project.ext.mVersionName + "\"") + } + + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + publishing { + singleVariant('release') { + withSourcesJar() + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +def isBeta = project.hasProperty('beta') ? project.getProperty('beta') : false +def isInternal = project.hasProperty('dev') ? project.getProperty('dev') : false + +afterEvaluate { + publishing { + publications { + if (!isBeta && !isInternal) { + maven(MavenPublication) { + groupId mGroupId + artifactId mArtifactId + version mVersionName + + from components.release + + pom { + name = mLibraryName + description = mLibraryDescription + } + } + } else if (isBeta && !isInternal) { + mavenBeta(MavenPublication) { + groupId mGroupId + artifactId "$mArtifactId-beta" + version mVersionName + + from components.release + + pom { + name = mLibraryName + description = mLibraryDescription + } + } + } else if (!isBeta && isInternal) { + mavenInternal(MavenPublication) { + groupId mGroupId + artifactId "$mArtifactId-dev" + version mVersionName + + from components.release + + pom { + name = mLibraryName + description = mLibraryDescription + } + } + } + } + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/skyflowapi/skyflow-android") + credentials { + username = project.findProperty("gpr.user") ?: System.getenv("USERNAME") + password = project.findProperty("gpr.key") ?: System.getenv("TOKEN") + } + } + } + } +} +dependencies { + implementation 'androidx.core:core-ktx:1.16.0' + implementation 'com.google.android.material:material:1.12.0' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.14.1' + testImplementation 'io.mockk:mockk:1.14.11' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' +} +publish.dependsOn assemble diff --git a/Skyflow/consumer-rules.pro b/flowvault/consumer-rules.pro similarity index 100% rename from Skyflow/consumer-rules.pro rename to flowvault/consumer-rules.pro diff --git a/Skyflow/proguard-rules.pro b/flowvault/proguard-rules.pro similarity index 100% rename from Skyflow/proguard-rules.pro rename to flowvault/proguard-rules.pro diff --git a/Skyflow/src/main/AndroidManifest.xml b/flowvault/src/main/AndroidManifest.xml similarity index 100% rename from Skyflow/src/main/AndroidManifest.xml rename to flowvault/src/main/AndroidManifest.xml diff --git a/flowvault/src/main/kotlin/Skyflow/client/Client.kt b/flowvault/src/main/kotlin/Skyflow/client/Client.kt new file mode 100644 index 00000000..f6528d58 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/client/Client.kt @@ -0,0 +1,45 @@ +package Skyflow + +import Skyflow.composable.ComposableStyles +import Skyflow.core.FlowDBAPIClient +import android.content.Context +import com.Skyflow.core.container.ContainerProtocol +import kotlin.reflect.KClass + +/** + * FlowVault (v2 / FlowDB) client. Extends the shared [BaseSkyflowClient] (which provides + * `configuration`, `elementMap`, and the `container(...)` factories) and adds only the v2 api + * client. Unlike the legacy `Client` it exposes no standalone client methods — all v2 work goes + * through the container extensions (collect / reveal; update happens within collect when an element + * carries a skyflowId). See docs/sdk-split-plan.md. + */ +class Client internal constructor( + configuration: Configuration, +) : BaseSkyflowClient(configuration) { + + internal val apiClient = FlowDBAPIClient( + configuration.vaultID, + configuration.vaultURL, + configuration.tokenProvider, + configuration.options.logLevel, + okHttpClient = configuration.okHttpClient + ) + + // ContainerOptions.styles/errorTextStyles are nullable and a caller may pass null explicitly. + // The shared Container/composable code force-unwraps them (kept as-is for v1 parity — the legacy + // SDK crashes on null). For v2, default them here BEFORE the shared Container constructor runs so + // a null-styles composable container renders with the default styles instead of throwing NPE. + override fun container( + type: KClass, + context: Context, + options: ContainerOptions + ): Container { + val safeOptions = if (options.styles != null && options.errorTextStyles != null) options + else ContainerOptions( + options.layout, + options.styles ?: ComposableStyles.getStyles(), + options.errorTextStyles ?: ComposableStyles.getErrorTextStyles() + ) + return super.container(type, context, safeOptions) + } +} diff --git a/flowvault/src/main/kotlin/Skyflow/client/Init.kt b/flowvault/src/main/kotlin/Skyflow/client/Init.kt new file mode 100644 index 00000000..c7c9a77c --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/client/Init.kt @@ -0,0 +1,12 @@ +package Skyflow + +import com.skyflow_android.BuildConfig + +/** + * FlowVault (v2) entry point. The shared body lives in the common generic [baseInit] helper; + * this supplies the concrete [Client] and this module's BuildConfig. + */ +fun init(configuration: Configuration): Client = + baseInit(BuildConfig.SDK_VERSION, configuration.options.logLevel) { + Client(configuration) + } diff --git a/flowvault/src/main/kotlin/Skyflow/collect/client/FlowDBCollectAPICallback.kt b/flowvault/src/main/kotlin/Skyflow/collect/client/FlowDBCollectAPICallback.kt new file mode 100644 index 00000000..efea7584 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/collect/client/FlowDBCollectAPICallback.kt @@ -0,0 +1,158 @@ +package Skyflow.collect.client + +import Skyflow.* +import Skyflow.core.FlowDBAPIClient +import Skyflow.core.Logger +import Skyflow.core.Messages +import Skyflow.core.getMessage +import Skyflow.utils.Utils +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException + +internal class FlowDBCollectAPICallback( + private val apiClient: FlowDBAPIClient, + private val requestBody: JSONObject, + val callback: Skyflow.Callback, + private val options: CollectOptions, + val logLevel: LogLevel, + private val endpoint: String = "insert", + private val cvvMap: CVVMap = CVVMap.EMPTY +) : Skyflow.Callback { + private val okHttpClient = apiClient.okHttpClient + private val tag = FlowDBCollectAPICallback::class.qualifiedName + + override fun onSuccess(responseBody: Any) { + try { + Logger.info(tag, Messages.VALIDATE_RECORDS.getMessage(), logLevel) + val body = requestBody.toString().toRequestBody("application/json".toMediaTypeOrNull()) + val metrics = Utils.fetchMetrics() + val url = "${apiClient.vaultURL.trimEnd('/')}/v2/records/$endpoint" + val request = Request.Builder() + .method("POST", body) + .addHeader("Authorization", "$responseBody") + .addHeader("sky-metadata", "$metrics") + .url(url) + .build() + sendRequest(request) + } catch (e: Exception) { + callback.onFailure(Utils.constructErrorResponse(e)) + } + } + + override fun onFailure(exception: Any) { + // getAccessToken delivers a raw SkyflowInternalError here (e.g. INVALID_BEARER_TOKEN). Convert + // it to the standard {errors:[{error:{code,description}}]} shape the app adapter's + // SkyflowError.fromJson parses, so token failures surface with the real code + message rather + // than a mangled 500 from fromJson(nonJsonString). + callback.onFailure(if (exception is Exception) Utils.constructErrorResponse(exception) else exception) + } + + private fun sendRequest(request: Request) { + okHttpClient.newCall(request).enqueue(object : okhttp3.Callback { + override fun onFailure(call: okhttp3.Call, e: IOException) { + callback.onFailure(Utils.constructErrorResponse(e, 500)) + } + + override fun onResponse(call: okhttp3.Call, response: Response) { + verifyResponse(response) + } + }) + } + + private fun verifyResponse(response: Response) { + response.use { + // Build the response INSIDE the try (parse errors -> onFailure), but deliver onSuccess + // AFTER it — so an exception thrown by the app's own onSuccess handler is NOT caught here + // and turned into a second onFailure. Exactly one of onSuccess/onFailure must fire. + val result: JSONObject = try { + val bodyStr = response.body?.string() ?: "" + if (!response.isSuccessful) { + val parsed = try { JSONObject(bodyStr) } catch (e: Exception) { null } + if (parsed != null && parsed.has("records")) { + buildResponse(bodyStr) + } else { + val message = try { + parsed?.getJSONObject("error")?.getString("message") ?: bodyStr + } catch (e: JSONException) { bodyStr } + val requestId = response.headers["x-request-id"] ?: "" + callback.onFailure(Utils.constructErrorResponse(response.code, Utils.appendRequestId(message, requestId))) + return + } + } else { + buildResponse(bodyStr) + } + } catch (e: Exception) { + callback.onFailure(Utils.constructErrorResponse(e, 500)) + return + } + callback.onSuccess(result) + } + } + + private fun buildResponse(bodyStr: String): JSONObject { + val responseJson = JSONObject(bodyStr) + val records = responseJson.optJSONArray("records") ?: JSONArray() + val allRecords = JSONArray() + val requestRecords = requestBody.optJSONArray("records") + val topLevelTableName = requestBody.optString("tableName", "") + val requestTableNames: List = if (topLevelTableName.isNotEmpty()) { + // All records in this request belong to one table + List(requestRecords?.length() ?: 1) { topLevelTableName } + } else { + (0 until (requestRecords?.length() ?: 0)).map { i -> + requestRecords?.optJSONObject(i)?.optString("tableName", "") ?: "" + } + } + + for (i in 0 until records.length()) { + val record = records.getJSONObject(i) + val httpCode = record.optInt("httpCode", 200) + val fallbackTableName = requestTableNames.getOrElse(i) { "" } + val tableName = record.optString("tableName", "").ifEmpty { fallbackTableName } + + if (httpCode != 200) { + allRecords.put( + JSONObject() + .put("error", record.optString("error", "")) + .put("skyflowId", record.opt("skyflowID") ?: record.opt("skyflowId")) + .put("tableName", tableName) + .put("httpCode", httpCode) + ) + continue + } + + val skyflowId = record.optString("skyflowID", "").ifEmpty { record.optString("skyflowId", "") } + val fieldsObject = JSONObject() + + val tokensObj = record.optJSONObject("tokens") + if (tokensObj != null) { + val fieldNames = tokensObj.keys() + while (fieldNames.hasNext()) { + val fieldName = fieldNames.next() + fieldsObject.put(fieldName, tokensObj.getJSONArray(fieldName)) + } + } + + // 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) + .put("tokens", fieldsObject) + .put("httpCode", httpCode) + val hashedData = record.optJSONObject("hashedData") + if (hashedData != null) resultRecord.put("hashedData", hashedData) + allRecords.put(resultRecord) + } + + return JSONObject().put("records", allRecords) + } +} diff --git a/flowvault/src/main/kotlin/Skyflow/collect/client/FlowDBCollectRequestBody.kt b/flowvault/src/main/kotlin/Skyflow/collect/client/FlowDBCollectRequestBody.kt new file mode 100644 index 00000000..b43d1cb2 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/collect/client/FlowDBCollectRequestBody.kt @@ -0,0 +1,187 @@ +package Skyflow.collect.client + +import Skyflow.* +import Skyflow.collect.elements.validations.ElementValueMatchRule +import org.json.JSONArray +import org.json.JSONObject + +internal class FlowDBCollectRequestBody { + companion object { + private val tag = FlowDBCollectRequestBody::class.qualifiedName + + // Validate additionalFields the same way v1 does (empty records / table / fields / column), + // reusing the existing shared error codes. flowvault previously left these unvalidated, so a + // malformed record produced a bad request (or wrote to tableName="") instead of a clear error. + internal fun validateAdditionalFields(additionalFields: AdditionalFields?, logLevel: LogLevel) { + val records = additionalFields?.records ?: return + if (records.isEmpty()) { + throw SkyflowInternalError(SkyflowErrorCode.ADDITIONAL_FIELDS_EMPTY_RECORDS, tag, logLevel) + } + records.forEachIndexed { i, rec -> + if (rec.tableName.isEmpty()) + throw SkyflowInternalError(SkyflowErrorCode.ADDITIONAL_FIELDS_EMPTY_TABLE_KEY, tag, logLevel, arrayOf("$i")) + if (rec.data.isEmpty()) + throw SkyflowInternalError(SkyflowErrorCode.ADDITIONAL_FIELDS_EMPTY_FIELDS, tag, logLevel, arrayOf("$i")) + if (rec.data.keys.any { it.isEmpty() }) + throw SkyflowInternalError(SkyflowErrorCode.EMPTY_COLUMN_NAME, tag, logLevel, arrayOf(rec.tableName)) + } + } + + internal fun buildRequestBody( + vaultID: String, + elements: MutableList, + options: CollectOptions, + logLevel: LogLevel + ): JSONObject { + val tableMap = groupByTable(elements, logLevel) + val upsertByTable = options.upsert?.associateBy { it.tableName } ?: emptyMap() + + // Merge additionalFields insert records into tableMap. Reject any column that collides + // with an element column (or another additionalFields column) in the same table — same + // fail-fast DUPLICATE_COLUMN_FOUND behavior as v1, instead of silently dropping the value. + val seenColumns = HashSet() + tableMap.forEach { (table, records) -> records.forEach { seenColumns.add(table + it.columnName) } } + options.additionalFields?.records?.forEach { rec -> + val existing = tableMap.getOrPut(rec.tableName) { mutableListOf() } + rec.data.forEach { (k, v) -> + if (!seenColumns.add(rec.tableName + k)) { + throw SkyflowInternalError( + SkyflowErrorCode.DUPLICATE_COLUMN_FOUND, tag, logLevel, arrayOf(rec.tableName, k) + ) + } + existing.add(CollectRequestRecord(k, anyToJsonValue(v))) + } + } + + val recordsArray = JSONArray() + for ((tableName, columns) in tableMap) { + val dataObject = JSONObject() + for (record in columns) { + createJSONKey(dataObject, record.columnName, record.value) + } + val recordObj = JSONObject().put("data", dataObject).put("tableName", tableName) + val upsertOpt = upsertByTable[tableName] + if (upsertOpt != null) { + recordObj.put("upsert", JSONObject() + .put("updateType", upsertOpt.updateType.name) + .put("uniqueColumns", JSONArray(upsertOpt.uniqueColumns))) + } + recordsArray.put(recordObj) + } + return JSONObject().put("vaultID", vaultID).put("records", recordsArray) + } + + internal fun buildUpdateRequestBody( + vaultID: String, + tableName: String, + elements: MutableList, + skyflowID: String, + logLevel: LogLevel + ): JSONObject { + val tableMap = groupByTable(elements, logLevel) + val tableElements = tableMap[tableName] ?: mutableListOf() + val dataObject = JSONObject() + for (record in tableElements) { + createJSONKey(dataObject, record.columnName, record.value) + } + return JSONObject() + .put("vaultID", vaultID) + .put("tableName", tableName) + .put("records", JSONArray().put(JSONObject().put("skyflowID", skyflowID).put("data", dataObject))) + } + + // Builds ONE combined update body from update elements + additionalFields update records, + // merging BOTH sources by (tableName, skyflowId) into a single record per record id — matching + // v1's "${table}_${skyflowID}" merge, so the vault gets one update op per record, not two. + // additionalFields overwrite element columns on collision (v1 last-writer-wins). + internal fun buildCombinedUpdateBody( + vaultID: String, + updateElements: List, + additionalUpdates: List, + logLevel: LogLevel + ): JSONObject { + val recordByKey = LinkedHashMap, JSONObject>() + + updateElements.groupBy { it.tableName to it.skyflowId!! }.forEach { (key, elements) -> + val (tableName, skyflowID) = key + val rec = buildUpdateRequestBody( + vaultID, tableName, elements.toMutableList(), skyflowID, logLevel + ).getJSONArray("records").getJSONObject(0).put("tableName", tableName) + recordByKey[key] = rec + } + + additionalUpdates.groupBy { it.tableName to it.skyflowId!! }.forEach { (key, records) -> + val (tableName, skyflowID) = key + val rec = recordByKey.getOrPut(key) { + JSONObject().put("skyflowID", skyflowID).put("data", JSONObject()).put("tableName", tableName) + } + val dataObj = rec.getJSONObject("data") + records.forEach { r -> r.data.forEach { (k, v) -> dataObj.put(k, v) } } + } + + val updateRecordsArray = JSONArray() + recordByKey.values.forEach { updateRecordsArray.put(it) } + return JSONObject().put("vaultID", vaultID).put("records", updateRecordsArray) + } + + private fun groupByTable( + elements: MutableList, + logLevel: LogLevel + ): LinkedHashMap> { + val tableMap = LinkedHashMap>() + val tableWithColumn = HashSet() + for (element in elements) { + val tableName = element.tableName + if (tableMap[tableName] != null) { + if (tableWithColumn.contains(tableName + element.columnName)) { + var hasElementValueMatchRule = false + for (validation in element.collectInput.validations.rules) { + if (validation is ElementValueMatchRule) { + hasElementValueMatchRule = true + break + } + } + if (!hasElementValueMatchRule) + throw SkyflowInternalError( + SkyflowErrorCode.DUPLICATE_COLUMN_FOUND, tag, logLevel, + arrayOf(tableName, element.columnName) + ) + continue + } + tableWithColumn.add(tableName + element.columnName) + tableMap[tableName]!!.add(CollectRequestRecord(element.columnName, element.getValue())) + } else { + tableWithColumn.add(tableName + element.columnName) + tableMap[tableName] = mutableListOf(CollectRequestRecord(element.columnName, element.getValue())) + } + } + 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])) { + if (keys.size > 1) { + createJSONKey(obj.get(keys[0]) as JSONObject, keys.drop(1).joinToString("."), value) + } + } else { + if (keys.size > 1) { + val tempObject = JSONObject() + obj.put(keys[0], tempObject) + createJSONKey(tempObject, keys.drop(1).joinToString("."), value) + } else { + obj.put(keys[0], value) + } + } + } + } +} diff --git a/flowvault/src/main/kotlin/Skyflow/collect/client/FlowDBMixedAPICallback.kt b/flowvault/src/main/kotlin/Skyflow/collect/client/FlowDBMixedAPICallback.kt new file mode 100644 index 00000000..94a6c3d8 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/collect/client/FlowDBMixedAPICallback.kt @@ -0,0 +1,131 @@ +package Skyflow.collect.client + +import Skyflow.Callback +import Skyflow.CollectOptions +import Skyflow.LogLevel +import Skyflow.SkyflowError +import Skyflow.core.FlowDBAPIClient +import Skyflow.utils.Utils +import org.json.JSONArray +import org.json.JSONObject + +/** + * Fans a mixed insert+update collect() into two independent HTTP calls (one per endpoint) and + * reconciles them into EXACTLY ONE terminal callback. + * + * BOTH success and failure count toward completion. Once every sub-call has finished: + * - if any sub-call succeeded, the app gets ONE onSuccess with a consolidated {records:[...]} — + * the succeeded records (with their tokens) plus one error record per record of any failed call + * (matching FlowDB's per-record {error, httpCode, tableName?, skyflowId?} shape). A half that was + * already committed server-side is therefore never dropped. + * - if every sub-call failed, the app gets ONE onFailure carrying the first error. + * + * The `dispatched` guard ensures the app's callback fires exactly once. (Previously each sub-call's + * onFailure was forwarded straight to the app with no bookkeeping: a both-fail case fired onFailure + * twice, and a partial failure never reached completedCalls == totalCalls, silently dropping the + * committed half.) + */ +internal class FlowDBMixedAPICallback( + private val apiClient: FlowDBAPIClient, + private val updateBody: JSONObject?, + private val insertBody: JSONObject?, + private val finalCallback: Callback, + private val options: CollectOptions, + 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) + private val lock = Any() + private var completedCalls = 0 + private var anySuccess = false + private var firstFailure: Any? = null + private var dispatched = false + private val allRecords = JSONArray() + + override fun onSuccess(responseBody: Any) { + val token = responseBody.toString() + updateBody?.let { body -> + FlowDBCollectAPICallback(apiClient, body, subCallbackFor(body), options, logLevel, "update", cvvMap) + .onSuccess(token) + } + insertBody?.let { body -> + FlowDBCollectAPICallback(apiClient, body, subCallbackFor(body), options, logLevel, "insert", cvvMap) + .onSuccess(token) + } + } + + // Token acquisition itself failed (before any sub-call is fired) — a single terminal failure. + override fun onFailure(exception: Any) { + val deliver = synchronized(lock) { + if (dispatched) false else { dispatched = true; true } + } + if (deliver) finalCallback.onFailure(exception) + } + + // internal (not private) so unit tests can drive the reconciliation directly without HTTP. + internal fun subCallbackFor(body: JSONObject): Callback = object : Callback { + override fun onSuccess(responseBody: Any) = complete(body, responseBody, null) + override fun onFailure(exception: Any) = complete(body, null, exception) + } + + private fun complete(body: JSONObject, success: Any?, failure: Any?) { + val dispatch: (() -> Unit)? = synchronized(lock) { + if (success != null) { + mergeInto(allRecords, JSONObject(success.toString()).optJSONArray("records")) + anySuccess = true + } else { + val ex = failure ?: Utils.constructErrorResponse(500, "Unknown error") + if (firstFailure == null) firstFailure = ex + val error = SkyflowError.fromJson(ex.toString()) + mergeInto(allRecords, errorRecordsForBody(body, error.httpCode ?: 500, error.message ?: "Unknown error")) + } + completedCalls++ + when { + completedCalls < totalCalls || dispatched -> null + anySuccess -> { + dispatched = true + val payload = JSONObject().put("records", allRecords) + val action: () -> Unit = { finalCallback.onSuccess(payload) } + action + } + else -> { + dispatched = true + val err = firstFailure ?: Utils.constructErrorResponse(500, "Unknown error") + val action: () -> Unit = { finalCallback.onFailure(err) } + action + } + } + } + dispatch?.invoke() + } + + // Represent a failed call as one error record per record it carried, so the consolidated + // response tells the app exactly which (table[, id]) writes failed and why. + private fun errorRecordsForBody(body: JSONObject, httpCode: Int, message: String): JSONArray { + val out = JSONArray() + val topTable = body.optString("tableName", "") + val records: JSONArray? = body.optJSONArray("records") + if (records == null || records.length() == 0) { + val rec = JSONObject().put("error", message).put("httpCode", httpCode) + if (topTable.isNotEmpty()) rec.put("tableName", topTable) + out.put(rec) + return out + } + for (i in 0 until records.length()) { + val r = records.optJSONObject(i) + val table = (r?.optString("tableName", "") ?: "").ifEmpty { topTable } + val rec = JSONObject().put("error", message).put("httpCode", httpCode) + if (table.isNotEmpty()) rec.put("tableName", table) + val sid = r?.opt("skyflowID") ?: r?.opt("skyflowId") + if (sid != null) rec.put("skyflowId", sid) + out.put(rec) + } + return out + } + + private fun mergeInto(target: JSONArray, source: JSONArray?) { + source ?: return + for (i in 0 until source.length()) target.put(source[i]) + } +} diff --git a/flowvault/src/main/kotlin/Skyflow/collect/client/MockCVV.kt b/flowvault/src/main/kotlin/Skyflow/collect/client/MockCVV.kt new file mode 100644 index 00000000..6d476940 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/collect/client/MockCVV.kt @@ -0,0 +1,113 @@ +package Skyflow.collect.client + +import Skyflow.SkyflowElementType +import Skyflow.TextField +import org.json.JSONObject + +/** + * Captures the actual value entered into each CVV collect element that opted in via + * [Skyflow.CollectElementOptions.returnMockValue], so its token can be swapped for a fixed 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, options and entered value, no identifier plumbing is needed: we read [TextField.fieldType], + * the element's options 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. Elements that did not opt in are skipped entirely, so their real token is returned unchanged. + */ +internal class CVVMap( + val byTable: Map>, + val byRecordId: Map> +) { + fun isEmpty(): Boolean = byTable.isEmpty() && byRecordId.isEmpty() + + companion object { + val EMPTY = CVVMap(emptyMap(), emptyMap()) + + /** + * Builds the map from a set of collect elements. Only CVV elements that opted in via + * [Skyflow.CollectElementOptions.returnMockValue] are captured. 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): CVVMap { + val byTable = LinkedHashMap>() + val byRecordId = LinkedHashMap>() + for (element in elements) { + if (element.fieldType != SkyflowElementType.CVV || !element.options.returnMockValue) 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) + } + } +} + +/** + * Fixed mock CVV placeholders, keyed by CVV length. Deliberately hardcoded (not random) so a + * downstream proxy can reliably identify the mock for detokenization. If the value ever needs to + * change, change it HERE — these two constants are the single source of truth. + */ +internal const val MOCK_CVV_3 = "817" +internal const val MOCK_CVV_4 = "8173" + +/** + * Returns the fixed mock CVV for a value of [length] digits: 4-or-more digits -> [MOCK_CVV_4], + * otherwise -> [MOCK_CVV_3]. A zero/empty length yields "" (nothing was entered, nothing to mock). + * The mock is a fixed value and may coincidentally equal the user's real CVV; that edge case is + * accepted by design. + */ +internal fun mockCVV(length: Int): String = when { + length <= 0 -> "" + length >= 4 -> MOCK_CVV_4 + else -> MOCK_CVV_3 +} + +/** + * Replaces the token value of every captured CVV column in [tokens] with the fixed mock placeholder + * for the entered length (see [mockCVV]). + * + * 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. + * The mock is a fixed value (see [mockCVV]); it may coincidentally equal an entered CVV, which is + * acceptable — entered values never leave the device to the app. + */ +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 = mockCVV(enteredValue.length) + 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) + } + } + } +} diff --git a/flowvault/src/main/kotlin/Skyflow/composable/ComposableContainer.kt b/flowvault/src/main/kotlin/Skyflow/composable/ComposableContainer.kt new file mode 100644 index 00000000..f5187064 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/composable/ComposableContainer.kt @@ -0,0 +1,370 @@ +package Skyflow.composable + +import Skyflow.* +import Skyflow.collect.client.CVVMap +import Skyflow.collect.client.FlowDBCollectRequestBody +import Skyflow.collect.client.FlowDBMixedAPICallback +import Skyflow.core.Logger +import Skyflow.core.Messages +import Skyflow.core.getMessage +import Skyflow.utils.EventName +import Skyflow.utils.Utils +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.res.ResourcesCompat +import org.json.JSONObject +import java.util.* + +// NOTE: `class ComposableContainer : ContainerProtocol` (the container marker) lives in common +// (composable/ComposableContainerClass.kt) so both products share it. Only the v2 extension +// functions live here. + +val tag = ComposableContainer::class.qualifiedName + +fun Container.create( + secondContext: Context, + input: CollectElementInput, + options: CollectElementOptions = CollectElementOptions() +): TextField { + Utils.checkInputFormatOptions(input.type, options, configuration.options.logLevel) + Logger.info( + tag, + Messages.VALIDATE_INPUT_FORMAT_OPTIONS.getMessage(input.label), + configuration.options.logLevel + ) + Logger.info( + tag, + Messages.CREATED_COLLECT_ELEMENT.getMessage(input.label), + configuration.options.logLevel + ) + + val collectElement = TextField( + context = context, + optionsForLogging = configuration.options, + index = collectElements.size, + containerType = ContainerType.COMPOSABLE, + ) + collectElement.setupField(input, options) + collectElements.add(collectElement) + val uuid = UUID.randomUUID().toString() + client.elementMap[uuid] = collectElement + collectElement.uuid = uuid + return collectElement +} + +fun Container.on(eventName: EventName, handler: (() -> Unit)) { + when (eventName) { + EventName.SUBMIT -> { + for (element in collectElements) { + element.containerOnSubmitListener = handler + } + } + else -> { + Logger.error( + tag, + SkyflowErrorCode.INVALID_EVENT_TYPE.message, + configuration.options.logLevel + ) + } + } +} + +fun Container.getComposableLayout(): LinearLayout { + if (collectElements.size != totalComposableElements) { + throw SkyflowInternalError( + SkyflowErrorCode.MISMATCH_ELEMENT_COUNT_LAYOUT_SUM, + tag, + configuration.options.logLevel + ) + } + addViewsToComposableLayout() + return composableLayout +} + +internal fun Container.collect( + callback: Callback, + options: CollectOptions? = CollectOptions() +) { + try { + validateVaultConfig() + Logger.info( + tag, + Messages.VALIDATE_COLLECT_RECORDS.getMessage(), + configuration.options.logLevel + ) + validateElements() + FlowDBCollectRequestBody.validateAdditionalFields(options?.additionalFields, configuration.options.logLevel) + post(callback, options) + } catch (e: Exception) { + callback.onFailure(Utils.constructErrorResponse(e)) + } +} + +fun Container.collect(callback: CollectCallback, options: CollectOptions? = CollectOptions()) { + val logLevel = configuration.options.logLevel + // Deliver the app's CollectCallback on the main thread (see CollectCallback doc): apps can touch + // Views directly, it matches the SDK's own main-thread element updates, and an exception in the + // app's handler surfaces as a normal main-thread crash instead of killing the OkHttp thread. + val mainHandler = Handler(Looper.getMainLooper()) + val adapter = object : Callback { + override fun onSuccess(responseBody: Any) { + val parsed = try { + CollectResponse.fromJsonOrThrow(responseBody.toString(), logLevel) + } catch (e: Exception) { + // A success-path response the SDK cannot decode must NOT be reported as an empty + // success — surface it as a failure (and log) so the app knows something went wrong. + Logger.error("ComposableContainer", "Unable to parse collect response: ${e.message}", logLevel) + mainHandler.post { callback.onFailure(SkyflowError(null, 500, "Unable to parse response from server", null, emptyList())) } + return + } + mainHandler.post { callback.onSuccess(parsed) } + } + override fun onFailure(exception: Any) { + val error = SkyflowError.fromJson(exception.toString()) + mainHandler.post { callback.onFailure(error) } + } + } + collect(adapter, options) +} + +private fun Container.validateVaultConfig() { + if (configuration.vaultID.isEmpty()) { + throw SkyflowInternalError(SkyflowErrorCode.EMPTY_VAULT_ID, tag, configuration.options.logLevel) + } + if (configuration.vaultURL.isEmpty()) { + throw SkyflowInternalError(SkyflowErrorCode.EMPTY_VAULT_URL, tag, configuration.options.logLevel) + } + if (!Utils.checkUrl(configuration.vaultURL)) { + throw SkyflowInternalError(SkyflowErrorCode.INVALID_VAULT_URL, tag, configuration.options.logLevel) + } +} + +private fun Container.validateElements() { + var errors = "" + for (element in this.collectElements) { + errors = validateElement(element, errors) + } + if (errors != "") { + throw SkyflowInternalError( + SkyflowErrorCode.INVALID_INPUT, + tag, configuration.options.logLevel, arrayOf(errors) + ) + } +} + +private fun Container.validateElement( + element: TextField, + err: String +): String { + var errorOnElement = err + if (!element.isAttachedToWindow) { + throw SkyflowInternalError( + SkyflowErrorCode.ELEMENT_NOT_MOUNTED, + tag, + configuration.options.logLevel, + arrayOf(element.columnName) + ) + } + when { + 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.tableName!!.isEmpty() -> { + throw SkyflowInternalError( + SkyflowErrorCode.ELEMENT_EMPTY_TABLE_NAME, + tag, + configuration.options.logLevel, + arrayOf(element.fieldType.toString()) + ) + } + element.collectInput.column!!.isEmpty() -> { + throw SkyflowInternalError( + SkyflowErrorCode.EMPTY_COLUMN_NAME, + tag, + configuration.options.logLevel, + arrayOf(element.fieldType.toString()) + ) + } + else -> { + val state = element.getState() + val error = state["validationError"] + if (!(state["isValid"] as Boolean)) { + element.invalidTextField() + errorOnElement += "for ${element.columnName} ${(error as String)}\n" + } + } + } + return errorOnElement +} + +private fun Container.post(callback: Callback, options: CollectOptions?) { + val collectOptions = options ?: CollectOptions() + val updateElements = collectElements.filter { !it.skyflowId.isNullOrEmpty() } + val insertElements = collectElements.filter { it.skyflowId.isNullOrEmpty() } + + val additionalUpdates = collectOptions.additionalFields?.records + ?.filter { !it.skyflowId.isNullOrEmpty() } ?: emptyList() + val additionalInserts = collectOptions.additionalFields?.records + ?.filter { it.skyflowId.isNullOrEmpty() } ?: emptyList() + + if (updateElements.isNotEmpty() || additionalUpdates.isNotEmpty()) { + // ONE combined update body: element-update columns + additionalFields update data are merged + // by (tableName, skyflowId) into a single record per record id (matching v1). See helper. + val combinedUpdateBody = FlowDBCollectRequestBody.buildCombinedUpdateBody( + configuration.vaultID, updateElements, additionalUpdates, configuration.options.logLevel + ) + + val insertBody: JSONObject? = if (insertElements.isNotEmpty() || additionalInserts.isNotEmpty()) { + val insertOptions = CollectOptions( + upsert = collectOptions.upsert?.filter { opt -> insertElements.any { it.tableName == opt.tableName } }, + additionalFields = if (additionalInserts.isEmpty()) null else AdditionalFields(additionalInserts) + ) + FlowDBCollectRequestBody.buildRequestBody( + configuration.vaultID, insertElements.toMutableList(), + insertOptions, configuration.options.logLevel + ) + } else null + + val mixedCallback = FlowDBMixedAPICallback( + (client as Client).apiClient, combinedUpdateBody, insertBody, callback, collectOptions, + configuration.options.logLevel, CVVMap.capture(collectElements) + ) + (client as Client).apiClient.getAccessToken(mixedCallback) + return + } + + val insertOptions = CollectOptions( + upsert = collectOptions.upsert, + additionalFields = if (additionalInserts.isEmpty()) null else AdditionalFields(additionalInserts) + ) + val requestBody = FlowDBCollectRequestBody.buildRequestBody( + configuration.vaultID, + this.collectElements, + insertOptions, + configuration.options.logLevel + ) + (this.client as Client).apiClient.post(requestBody, callback, collectOptions, cvvMap = CVVMap.capture(this.collectElements)) +} + +private fun Container.addViewsToComposableLayout() { + + // A caller may explicitly pass styles = null (the ctor only DEFAULTS them); fall back to the same + // defaults rather than NPE-ing on a force-unwrap. + val styles = options.styles ?: ComposableStyles.getStyles() + val lp = LinearLayout.LayoutParams( + styles.base.width, + styles.base.height + ) + var k = 0 + for (i in options.layout.indices) { + val padding = styles.base.padding + val margin = styles.base.margin + val composableRow = LinearLayout(context, null, 0) + composableRow.orientation = LinearLayout.HORIZONTAL + composableRow.layoutParams = lp + lp.setMargins(margin.left, margin.top, margin.right, margin.bottom) + composableRow.background = getBackgroundDrawable(true) + composableRow.setPadding(padding.left, padding.top, padding.right, padding.bottom) + + val errorList = ComposableErrorsList(options.layout[i]) + val commonErrorText = TextView(context) + applyStylesToErrorText(commonErrorText) + + for (j in 0 until options.layout[i]) { + val element = collectElements[k++] + + element.applyCallback(ComposableEvents.ON_FOCUS_IS_TRUE) { + errorList.setError(j, String()) + commonErrorText.text = errorList.getErrors() + commonErrorText.visibility = if (errorList.isEmpty()) View.INVISIBLE + else View.VISIBLE + } + + element.applyCallback(ComposableEvents.ON_BEGIN_EDITING) { + if (element.index + 1 < this.totalComposableElements) { + val state = element.getState() + if (state.getBoolean("isValid") && + !state.getBoolean("isEmpty") && + SkyflowElementType.getAutoFocusSupportedElements() + .contains(state.get("elementType")) && + (!state.get("elementType").equals(SkyflowElementType.EXPIRATION_MONTH) + || element.inputField.text.toString() != "1") + ) collectElements[element.index + 1].requestFocus() + } + } + + element.applyCallback(ComposableEvents.ON_END_EDITING) { + errorList.setError(j, element.error.text.toString()) + commonErrorText.text = errorList.getErrors() + commonErrorText.visibility = if (errorList.isEmpty()) View.INVISIBLE + else View.VISIBLE + } + + val elementWidth = element.collectInput.inputStyles.base.width + val elementHeight = element.collectInput.inputStyles.base.height + val elementLP = LinearLayout.LayoutParams(elementWidth, elementHeight) + element.layoutParams = elementLP + composableRow.addView(element) + } + + composableLayout.addView(composableRow) + composableLayout.addView(commonErrorText) + } +} + +private fun Container.applyStylesToErrorText(errorText: TextView) { + errorText.visibility = View.INVISIBLE + val baseErrorTextStyles = (options.errorTextStyles ?: ComposableStyles.getErrorTextStyles()).base + + val errorMargin = baseErrorTextStyles.margin + val lp = LinearLayout.LayoutParams(baseErrorTextStyles.width, baseErrorTextStyles.height) + lp.setMargins(errorMargin.left, errorMargin.top, errorMargin.right, errorMargin.bottom) + errorText.layoutParams = lp + + errorText.background = getBackgroundDrawable(false) + + val errorPadding = baseErrorTextStyles.padding + errorText.setPadding( + errorPadding.left, + errorPadding.top, + errorPadding.right, + errorPadding.bottom + ) + + errorText.setTextColor(baseErrorTextStyles.textColor) + if (baseErrorTextStyles.font != Typeface.NORMAL) { + errorText.typeface = ResourcesCompat.getFont(context, baseErrorTextStyles.font) + } + errorText.gravity = baseErrorTextStyles.textAlignment +} + +private fun Container.getBackgroundDrawable(row: Boolean): Drawable { + val border = GradientDrawable() + border.setColor(Color.WHITE) + val borderStyles = if (row) (options.styles ?: ComposableStyles.getStyles()).base + else (options.errorTextStyles ?: ComposableStyles.getErrorTextStyles()).base + border.setStroke(borderStyles.borderWidth, borderStyles.borderColor) + border.cornerRadius = borderStyles.cornerRadius + return border +} diff --git a/flowvault/src/main/kotlin/Skyflow/config/Configuration.kt b/flowvault/src/main/kotlin/Skyflow/config/Configuration.kt new file mode 100644 index 00000000..cdd5c0db --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/config/Configuration.kt @@ -0,0 +1,15 @@ +package Skyflow + +/** + * FlowVault (v2) configuration. Extends the neutral [BaseConfiguration] and adds nothing to the + * URL: unlike the legacy `Configuration` (which suffixes `/v1/vaults/` in its `init`), the v2 + * `/v2/...` path segments are applied inside `FlowDBAPIClient`, so the stored `vaultURL` is left + * untouched. Exposes the same public param surface as 1.28.0-beta.1, including `okHttpClient`. + */ +class Configuration( + vaultID: String = "", + vaultURL: String = "", + tokenProvider: TokenProvider, + options: Options = Options(), + okHttpClient: okhttp3.OkHttpClient = okhttp3.OkHttpClient(), +) : BaseConfiguration(vaultID, vaultURL, tokenProvider, options, okHttpClient) diff --git a/flowvault/src/main/kotlin/Skyflow/container/CollectContainer.kt b/flowvault/src/main/kotlin/Skyflow/container/CollectContainer.kt new file mode 100644 index 00000000..88cda359 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/container/CollectContainer.kt @@ -0,0 +1,128 @@ +package Skyflow + +import Skyflow.collect.client.CVVMap +import Skyflow.collect.client.FlowDBCollectRequestBody +import Skyflow.collect.client.FlowDBMixedAPICallback +import org.json.JSONObject +import Skyflow.core.Logger +import Skyflow.core.Messages +import Skyflow.core.getMessage +import Skyflow.utils.Utils +import android.content.Context +import android.os.Handler +import android.os.Looper +import java.util.* + +// NOTE: `open class CollectContainer : ContainerProtocol` (the container marker) lives in common +// (ContainerClasses.kt) so both products share it. Only the v2 extension functions live here. + +private val tag = CollectContainer::class.qualifiedName + +// Public signature unchanged; the shared body lives in common (createElement). +fun Container.create( + context: Context, + input: CollectElementInput, + options: CollectElementOptions = CollectElementOptions() +): TextField = createElement(context, input, options) + +internal fun Container.collect(callback: Callback, options: CollectOptions? = CollectOptions()) { + try { + validateVaultConfig() + Logger.info(tag, Messages.VALIDATE_COLLECT_RECORDS.getMessage(), configuration.options.logLevel) + validateElements() + FlowDBCollectRequestBody.validateAdditionalFields(options?.additionalFields, configuration.options.logLevel) + post(callback, options) + } catch (e: Exception) { + callback.onFailure(Utils.constructErrorResponse(e)) + } +} + +internal fun Container.validateVaultConfig() { + if (configuration.vaultID.isEmpty()) { + throw SkyflowInternalError(SkyflowErrorCode.EMPTY_VAULT_ID, tag, configuration.options.logLevel) + } + if (configuration.vaultURL.isEmpty()) { + throw SkyflowInternalError(SkyflowErrorCode.EMPTY_VAULT_URL, tag, configuration.options.logLevel) + } + if (!Utils.checkUrl(configuration.vaultURL)) { + throw SkyflowInternalError(SkyflowErrorCode.INVALID_VAULT_URL, tag, configuration.options.logLevel) + } +} + +// validateElements / validateElement moved to common (BaseCollectContainer.kt) — shared with v1. + +internal fun Container.post(callback: Callback, options: CollectOptions?) { + val collectOptions = options ?: CollectOptions() + val updateElements = collectElements.filter { !it.skyflowId.isNullOrEmpty() } + val insertElements = collectElements.filter { it.skyflowId.isNullOrEmpty() } + + val additionalUpdates = collectOptions.additionalFields?.records + ?.filter { !it.skyflowId.isNullOrEmpty() } ?: emptyList() + val additionalInserts = collectOptions.additionalFields?.records + ?.filter { it.skyflowId.isNullOrEmpty() } ?: emptyList() + + if (updateElements.isNotEmpty() || additionalUpdates.isNotEmpty()) { + // ONE combined update body: element-update columns + additionalFields update data are merged + // by (tableName, skyflowId) into a single record per record id (matching v1). See helper. + val combinedUpdateBody = FlowDBCollectRequestBody.buildCombinedUpdateBody( + configuration.vaultID, updateElements, additionalUpdates, configuration.options.logLevel + ) + + val insertBody: JSONObject? = if (insertElements.isNotEmpty() || additionalInserts.isNotEmpty()) { + val insertOptions = CollectOptions( + upsert = collectOptions.upsert?.filter { opt -> insertElements.any { it.tableName == opt.tableName } }, + additionalFields = if (additionalInserts.isEmpty()) null else AdditionalFields(additionalInserts) + ) + FlowDBCollectRequestBody.buildRequestBody( + configuration.vaultID, insertElements.toMutableList(), + insertOptions, configuration.options.logLevel + ) + } else null + + val mixedCallback = FlowDBMixedAPICallback( + (client as Client).apiClient, combinedUpdateBody, insertBody, callback, collectOptions, + configuration.options.logLevel, CVVMap.capture(collectElements) + ) + (client as Client).apiClient.getAccessToken(mixedCallback) + return + } + + val insertOptions = CollectOptions( + upsert = collectOptions.upsert, + additionalFields = if (additionalInserts.isEmpty()) null else AdditionalFields(additionalInserts) + ) + val requestBody = FlowDBCollectRequestBody.buildRequestBody( + configuration.vaultID, + this.collectElements, + insertOptions, + configuration.options.logLevel + ) + (this.client as Client).apiClient.post(requestBody, callback, collectOptions, cvvMap = CVVMap.capture(this.collectElements)) +} + +fun Container.collect(callback: CollectCallback, options: CollectOptions? = CollectOptions()) { + val logLevel = configuration.options.logLevel + // Deliver the app's CollectCallback on the main thread (see CollectCallback doc): apps can touch + // Views directly, it matches the SDK's own main-thread element updates, and an exception in the + // app's handler surfaces as a normal main-thread crash instead of killing the OkHttp thread. + val mainHandler = Handler(Looper.getMainLooper()) + val adapter = object : Callback { + override fun onSuccess(responseBody: Any) { + val parsed = try { + CollectResponse.fromJsonOrThrow(responseBody.toString(), logLevel) + } catch (e: Exception) { + // A success-path response the SDK cannot decode must NOT be reported as an empty + // success — surface it as a failure (and log) so the app knows something went wrong. + Logger.error("CollectContainer", "Unable to parse collect response: ${e.message}", logLevel) + mainHandler.post { callback.onFailure(SkyflowError(null, 500, "Unable to parse response from server", null, emptyList())) } + return + } + mainHandler.post { callback.onSuccess(parsed) } + } + override fun onFailure(exception: Any) { + val error = SkyflowError.fromJson(exception.toString()) + mainHandler.post { callback.onFailure(error) } + } + } + collect(adapter, options) +} diff --git a/flowvault/src/main/kotlin/Skyflow/container/RevealContainer.kt b/flowvault/src/main/kotlin/Skyflow/container/RevealContainer.kt new file mode 100644 index 00000000..35223060 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/container/RevealContainer.kt @@ -0,0 +1,89 @@ +package Skyflow + +import Skyflow.core.Logger +import Skyflow.core.Messages +import Skyflow.core.getMessage +import android.content.Context +import android.os.Handler +import android.os.Looper +import Skyflow.reveal.FlowDBRevealRequestBody +import Skyflow.reveal.RevealValueCallback +import Skyflow.utils.Utils +import Skyflow.utils.Utils.Companion.checkIfElementsMounted +import java.lang.Exception +import java.util.* + +// NOTE: `class RevealContainer : ContainerProtocol` (the container marker) lives in common +// (ContainerClasses.kt) so both products share it. Only the v2 extension functions live here. + +private val tag = RevealContainer::class.qualifiedName + +// Public signature unchanged; the shared body lives in common (createLabel). +fun Container.create( + context: Context, + input: RevealElementInput, + options: RevealElementOptions = RevealElementOptions() +): Label = createLabel(context, input, options) + +internal fun Container.reveal( + callback: Callback, + options: RevealOptions? = RevealOptions() +) { + try { + // Fail fast on missing/invalid vault config BEFORE the bearer-token round-trip, matching + // flowvault collect and legacy reveal (previously reveal failed opaquely at the network layer). + Utils.checkVaultDetails(configuration) + validateElements() + Logger.info( + tag, + Messages.VALIDATE_REVEAL_RECORDS.getMessage(), + configuration.options.logLevel + ) + get(callback, options) + } catch (e: Exception) { + callback.onFailure(Utils.constructErrorResponse(e)) + } +} + +// validateElements moved to common (BaseRevealContainer.kt) — shared with v1. + +fun Container.reveal(callback: RevealCallback, options: RevealOptions? = RevealOptions()) { + val logLevel = configuration.options.logLevel + // Deliver the app's RevealCallback on the main thread (see RevealCallback doc): apps can touch + // Views directly, it matches the SDK's own main-thread Label updates, and an exception in the + // app's handler surfaces as a normal main-thread crash instead of killing the OkHttp thread. + val mainHandler = Handler(Looper.getMainLooper()) + val adapter = object : Callback { + override fun onSuccess(responseBody: Any) { + val parsed = try { + RevealResponse.fromJsonOrThrow(responseBody.toString(), logLevel) + } catch (e: Exception) { + // A success-path response the SDK cannot decode must NOT be reported as an empty + // success — surface it as a failure (and log) so the app knows something went wrong. + Logger.error("RevealContainer", "Unable to parse reveal response: ${e.message}", logLevel) + mainHandler.post { callback.onFailure(SkyflowError(null, 500, "Unable to parse response from server", null, emptyList())) } + return + } + mainHandler.post { callback.onSuccess(parsed) } + } + override fun onFailure(exception: Any) { + val error = SkyflowError.fromJson(exception.toString()) + mainHandler.post { callback.onFailure(error) } + } + } + reveal(adapter, options) +} + +internal fun Container.get(callback: Callback, options: RevealOptions?) { + val revealValueCallback = RevealValueCallback( + callback, + this.revealElements, + configuration.options.logLevel + ) + val requestBody = FlowDBRevealRequestBody.buildRequestBody( + configuration.vaultID, + this.revealElements, + options + ) + (this.client as Client).apiClient.get(requestBody, revealValueCallback) +} diff --git a/flowvault/src/main/kotlin/Skyflow/core/FlowDBAPIClient.kt b/flowvault/src/main/kotlin/Skyflow/core/FlowDBAPIClient.kt new file mode 100644 index 00000000..f1abce73 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/core/FlowDBAPIClient.kt @@ -0,0 +1,56 @@ +package Skyflow.core + +import Skyflow.* +import Skyflow.collect.client.CVVMap +import Skyflow.collect.client.FlowDBCollectAPICallback +import Skyflow.reveal.FlowDBRevealApiCallback +import Skyflow.utils.Utils +import org.json.JSONObject + +internal class FlowDBAPIClient( + vaultId: String, + vaultURL: String, + tokenProvider: TokenProvider, + logLevel: LogLevel, + token: String = "", + val okHttpClient: okhttp3.OkHttpClient = okhttp3.OkHttpClient() +) : BaseApiClient(vaultId, vaultURL, tokenProvider, logLevel, token) { + // Bearer-token lifecycle (getAccessToken) is inherited from BaseApiClient. + + // Harden the token check for v2: a malformed/opaque bearer token (no dots, or no `exp` claim) + // makes JWTUtils throw. In the base flow that throw is uncaught on the token-provider callback + // thread (a crash). Treat any such token as invalid so getAccessToken emits a typed + // INVALID_BEARER_TOKEN instead. (The legacy client intentionally keeps the 1.27.0 behavior.) + override fun isValidToken(token: String?): Boolean = try { + super.isValidToken(token) + } catch (e: Exception) { + false + } + + fun post(requestBody: JSONObject, callback: Callback, options: CollectOptions, endpoint: String = "insert", cvvMap: CVVMap = CVVMap.EMPTY) { + try { + val recordsArray = requestBody.optJSONArray("records") + if (recordsArray == null || recordsArray.length() == 0) { + // Nothing to collect (no elements and no additionalFields) — fail fast, mirroring reveal. + throw SkyflowInternalError(SkyflowErrorCode.EMPTY_RECORDS, tag, logLevel) + } + val collectApiCallback = FlowDBCollectAPICallback(this, requestBody, callback, options, logLevel, endpoint, cvvMap) + this.getAccessToken(collectApiCallback) + } catch (e: Exception) { + callback.onFailure(Utils.constructErrorResponse(e)) + } + } + + fun get(requestBody: JSONObject, callback: Callback) { + try { + val tokensArray = requestBody.optJSONArray("tokens") + if (tokensArray == null || tokensArray.length() == 0) { + throw SkyflowInternalError(SkyflowErrorCode.EMPTY_RECORDS, tag, logLevel) + } + val revealApiCallback = FlowDBRevealApiCallback(callback, this, requestBody) + this.getAccessToken(revealApiCallback) + } catch (e: Exception) { + callback.onFailure(Utils.constructErrorResponse(e)) + } + } +} diff --git a/flowvault/src/main/kotlin/Skyflow/element/CollectElementInput.kt b/flowvault/src/main/kotlin/Skyflow/element/CollectElementInput.kt new file mode 100644 index 00000000..17a9af2c --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/element/CollectElementInput.kt @@ -0,0 +1,61 @@ +package Skyflow + +import com.Skyflow.collect.elements.validations.ValidationSet + +/** + * FlowVault (v2) collect-element input. Extends the neutral [BaseCollectElementInput] and keeps the + * v2 public constructor param names (`tableName`, `skyflowId`), mapping them onto the neutral + * storage that core reads. Constructor shapes are byte-identical to the beta (1.28.0-beta.1) + * surface: a primary without `type` (used by the update interface) and a secondary with + * `type` + `altText`. + */ +class CollectElementInput : BaseCollectElementInput { + constructor( + tableName: String? = null, + column: String? = null, + inputStyles: Styles = Styles(), + labelStyles: Styles = Styles(), + errorTextStyles: Styles = Styles(), + label: String = "", + placeholder: String = "", + validations: ValidationSet = ValidationSet(), + skyflowId: String? = null + ) : super() { + this.tableName = tableName + this.column = column + this.inputStyles = inputStyles + this.labelStyles = labelStyles + this.errorTextStyles = errorTextStyles + this.label = label + this.placeholder = placeholder + this.validations = validations + this.skyflowId = skyflowId + } + + constructor( + tableName: String? = null, + column: String? = null, + type: SkyflowElementType, + inputStyles: Styles = Styles(), + labelStyles: Styles = Styles(), + errorTextStyles: Styles = Styles(), + label: String = "", + placeholder: String = "", + altText: String = "", + validations: ValidationSet = ValidationSet(), + skyflowId: String? = null + ) : this( + tableName, + column, + inputStyles, + labelStyles, + errorTextStyles, + label, + placeholder, + validations, + skyflowId + ) { + this.type = type + this.altText = altText + } +} diff --git a/flowvault/src/main/kotlin/Skyflow/element/CollectElementOptions.kt b/flowvault/src/main/kotlin/Skyflow/element/CollectElementOptions.kt new file mode 100644 index 00000000..0a461111 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/element/CollectElementOptions.kt @@ -0,0 +1,33 @@ +package Skyflow + +class CollectElementOptions( + var required: Boolean = false, + var enableCardIcon: Boolean = true, + var format: String = "", + var translation: HashMap? = null, + val enableCopy: Boolean = false, + var cardMetadata: CardMetadata = CardMetadata(arrayOf()), + // v2-only: when true, a CVV element's token is replaced in the collect response with a fixed + // mock value (see MockCVV). No-op for non-CVV element types. Default false = return real token. + var returnMockValue: Boolean = false +) { + internal var inputFormat: HashMap = hashMapOf() + private val HYPHEN_CARD_NUMBER_FORMAT = "XXXX-XXXX-XXXX-XXXX" + internal fun parseFormatForSeparator(): Char { + return if (format == HYPHEN_CARD_NUMBER_FORMAT) '-' + else ' ' + } + + internal fun createRegexMap() { + if (translation != null) { + for (key in translation!!.keys) { + val value = translation!![key] ?: return + inputFormat[key] = if (value.isEmpty()) { + Regex("[\\s\\S]*") + } else { + Regex(value) + } + } + } + } +} diff --git a/flowvault/src/main/kotlin/Skyflow/element/RevealElementInput.kt b/flowvault/src/main/kotlin/Skyflow/element/RevealElementInput.kt new file mode 100644 index 00000000..6ae2c6ba --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/element/RevealElementInput.kt @@ -0,0 +1,24 @@ +package Skyflow + +/** + * FlowVault (v2) reveal-element input. Extends the neutral [BaseRevealElementInput]. Unlike the + * legacy input it carries no per-token `redaction` field — v2 redaction is expressed through + * `RevealOptions.tokenGroupRedactions`. Constructor shape is byte-identical to 1.28.0-beta.1. + */ +class RevealElementInput( + token: String? = null, + inputStyles: Styles = Styles(), + labelStyles: Styles = Styles(), + errorTextStyles: Styles = Styles(), + label: String = "", + altText: String = "" +) : BaseRevealElementInput() { + init { + this.token = token + this.inputStyles = inputStyles + this.labelStyles = labelStyles + this.errorTextStyles = errorTextStyles + this.label = label + this.altText = altText + } +} diff --git a/flowvault/src/main/kotlin/Skyflow/error/SkyflowException.kt b/flowvault/src/main/kotlin/Skyflow/error/SkyflowException.kt new file mode 100644 index 00000000..73625dbb --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/error/SkyflowException.kt @@ -0,0 +1,68 @@ +package Skyflow + +import org.json.JSONArray +import org.json.JSONObject + +data class SkyflowError( + val grpcCode: Int?, + val httpCode: Int?, + override val message: String?, + val httpStatus: String?, + val details: List? +) : Error(message) { + companion object { + fun fromJson(json: String): SkyflowError { + return try { + val root = JSONObject(json) + + // Priority 1: flat format { "grpcCode": 13, "httpCode": 500, "message": "...", ... } + if (root.has("grpcCode") || (root.has("httpCode") && !root.has("error") && !root.has("errors"))) { + return SkyflowError( + grpcCode = if (root.has("grpcCode")) root.optInt("grpcCode") else null, + httpCode = if (root.has("httpCode")) root.optInt("httpCode") else null, + message = root.optString("message").ifEmpty { null }, + httpStatus = root.optString("httpStatus").ifEmpty { null }, + details = parseDetails(root.optJSONArray("details")) + ) + } + + // Priority 2: wrapped format { "error": { "grpcCode", "httpCode", "message", ... } } + if (root.has("error") && root.optJSONObject("error") != null) { + val err = root.getJSONObject("error") + return SkyflowError( + grpcCode = if (err.has("grpcCode")) err.optInt("grpcCode") else null, + httpCode = err.optInt("httpCode", err.optInt("code", 500)), + message = err.optString("message", err.optString("description", "Unknown error")).ifEmpty { null }, + httpStatus = err.optString("httpStatus").ifEmpty { null }, + details = parseDetails(err.optJSONArray("details")) + ) + } + + // Priority 3: legacy format { "errors": [{ "error": { "code", "description" } }] } + val errorsArr = root.optJSONArray("errors") + if (errorsArr != null && errorsArr.length() > 0) { + val first = errorsArr.optJSONObject(0) + val err = first?.optJSONObject("error") ?: first + if (err != null) { + return SkyflowError( + grpcCode = null, + httpCode = err.optInt("code", err.optInt("httpCode", 500)), + message = err.optString("description", err.optString("message", "Unknown error")).ifEmpty { null }, + httpStatus = "${err.optInt("code", 500)}", + details = emptyList() + ) + } + } + + SkyflowError(null, 500, json, null, emptyList()) + } catch (e: Exception) { + SkyflowError(null, 500, e.message ?: "Unknown error", null, emptyList()) + } + } + + private fun parseDetails(arr: JSONArray?): List { + if (arr == null) return emptyList() + return (0 until arr.length()).mapNotNull { arr.opt(it) } + } + } +} diff --git a/flowvault/src/main/kotlin/Skyflow/error/SkyflowInternalError.kt b/flowvault/src/main/kotlin/Skyflow/error/SkyflowInternalError.kt new file mode 100644 index 00000000..6273b169 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/error/SkyflowInternalError.kt @@ -0,0 +1,48 @@ +package Skyflow + +import Skyflow.core.Logger +import Skyflow.utils.Utils + +/** + * FlowVault (v2) internal exception. The shared `common/` code throws this; flowvault's typed + * callbacks then convert it into the public [SkyflowError] **data class** before anything reaches + * the app. It is `internal` because the v2 public surface exposes only that `SkyflowError` data + * class — `SkyflowInternalError` is pure plumbing and never leaves the module. + * + * (In the legacy SDK this same name is instead a typealias onto the public v1 `SkyflowError` + * exception — see that module's error/. Keeping the class here, per-product, is what lets each SDK + * own its error types while `common` throws under one neutral name.) + */ +internal class SkyflowInternalError(val skyflowErrorCode: SkyflowErrorCode = SkyflowErrorCode.UNKNOWN_ERROR, val tag : String? = "", logLevel: LogLevel? = null, params: Array = arrayOf()) : Exception(skyflowErrorCode.getMessage()) { + + override var message = "" + internal var internalMessage = "" + private var code = skyflowErrorCode.getCode() + + init { + val logMessage = Utils.constructMessage(skyflowErrorCode.getMessage(), *params) + if(logLevel != null) + Logger.error(tag, logMessage, logLevel) + this.internalMessage = logMessage + this.message = logMessage + } + fun setErrorCode(code:Int) + { + this.code = code + } + fun getErrorcode(): Int + { + return this.code + } + fun getErrorMessage() :String + { + return this.message + } + internal fun getInternalErrorMessage():String{ + return this.internalMessage + } + + override fun toString(): String { + return this.message + } +} diff --git a/flowvault/src/main/kotlin/Skyflow/options/AdditionalFields.kt b/flowvault/src/main/kotlin/Skyflow/options/AdditionalFields.kt new file mode 100644 index 00000000..d35fef73 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/options/AdditionalFields.kt @@ -0,0 +1,9 @@ +package Skyflow + +data class AdditionalFields(val records: List) + +data class AdditionalFieldsRecord( + val tableName: String, + val data: Map, + val skyflowId: String? = null +) diff --git a/flowvault/src/main/kotlin/Skyflow/options/CollectOptions.kt b/flowvault/src/main/kotlin/Skyflow/options/CollectOptions.kt new file mode 100644 index 00000000..f46d8cba --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/options/CollectOptions.kt @@ -0,0 +1,6 @@ +package Skyflow + +data class CollectOptions( + val upsert: List? = null, + val additionalFields: AdditionalFields? = null +) diff --git a/flowvault/src/main/kotlin/Skyflow/options/RevealOptions.kt b/flowvault/src/main/kotlin/Skyflow/options/RevealOptions.kt new file mode 100644 index 00000000..90b8a1b1 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/options/RevealOptions.kt @@ -0,0 +1,10 @@ +package Skyflow + +data class TokenGroupRedaction( + val tokenGroupName: String, + val redaction: String +) + +data class RevealOptions( + val tokenGroupRedactions: List? = null +) diff --git a/flowvault/src/main/kotlin/Skyflow/options/UpdateType.kt b/flowvault/src/main/kotlin/Skyflow/options/UpdateType.kt new file mode 100644 index 00000000..57989bdf --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/options/UpdateType.kt @@ -0,0 +1,6 @@ +package Skyflow + +enum class UpdateType { + UPDATE, + REPLACE +} diff --git a/flowvault/src/main/kotlin/Skyflow/options/UpsertOptions.kt b/flowvault/src/main/kotlin/Skyflow/options/UpsertOptions.kt new file mode 100644 index 00000000..293eb9d7 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/options/UpsertOptions.kt @@ -0,0 +1,7 @@ +package Skyflow + +data class UpsertOptions( + val tableName: String, + val updateType: UpdateType, + val uniqueColumns: List +) diff --git a/flowvault/src/main/kotlin/Skyflow/response/CollectResponse.kt b/flowvault/src/main/kotlin/Skyflow/response/CollectResponse.kt new file mode 100644 index 00000000..f4de9aac --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/response/CollectResponse.kt @@ -0,0 +1,163 @@ +package Skyflow + +import Skyflow.core.Logger +import org.json.JSONArray +import org.json.JSONObject + +// Typed token/hashedData shapes matching the JS SDK (CollectRecordToken / CollectRecordHashedData). +// `path` carries FlowDB's nested JSON-path for nested tokenization. +data class CollectRecordToken( + val token: String, + val tokenGroupName: String? = null, + val path: String? = null +) + +data class CollectRecordHashedData( + val data: String, + val hashName: String +) + +data class CollectRecord( + val tableName: String? = null, + val skyflowId: String? = null, + val error: String? = null, + val tokens: Map>? = null, + val hashedData: Map>? = null, + val httpCode: Int = 0 +) + +data class CollectResponse(val records: List = emptyList()) { + fun toJson(): JSONObject { + val arr = JSONArray() + records.forEach { r -> + val obj = JSONObject().put("httpCode", r.httpCode) + r.tableName?.let { obj.put("tableName", it) } + r.skyflowId?.let { obj.put("skyflowId", it) } + r.error?.let { obj.put("error", it) } + r.tokens?.let { tokens -> + val tokObj = JSONObject() + tokens.forEach { (col, list) -> + val tokenArr = JSONArray() + list.forEach { t -> + val e = JSONObject().put("token", t.token) + t.tokenGroupName?.let { e.put("tokenGroupName", it) } + t.path?.let { e.put("path", it) } + tokenArr.put(e) + } + tokObj.put(col, tokenArr) + } + obj.put("tokens", tokObj) + } + r.hashedData?.let { hd -> + val hdObj = JSONObject() + hd.forEach { (col, list) -> + val hdArr = JSONArray() + list.forEach { h -> hdArr.put(JSONObject().put("data", h.data).put("hashName", h.hashName)) } + hdObj.put(col, hdArr) + } + obj.put("hashedData", hdObj) + } + arr.put(obj) + } + return JSONObject().put("records", arr) + } + + companion object { + private const val TAG = "CollectResponse" + + // Strict decode: throws if the body is not a JSON object, so the SDK's success path can + // surface a real failure (onFailure) instead of a false empty onSuccess. Non-object entries + // in `records` are logged and skipped rather than silently dropped. + internal fun fromJsonOrThrow(json: String, logLevel: LogLevel): CollectResponse { + val root = JSONObject(json) + val records = root.optJSONArray("records")?.let { arr -> + (0 until arr.length()).mapNotNull { i -> + val r = arr.optJSONObject(i) + if (r == null) { + Logger.warn(TAG, "Skipping non-object entry at records[$i] in collect response", logLevel) + return@mapNotNull null + } + val httpCode = r.optInt("httpCode", 200) + if (r.has("error") && !r.isNull("error")) { + CollectRecord( + tableName = r.optString("tableName").ifEmpty { null }, + skyflowId = if (r.isNull("skyflowId")) null else r.optString("skyflowId").ifEmpty { null }, + error = r.optString("error"), + tokens = null, + hashedData = null, + httpCode = httpCode + ) + } else { + CollectRecord( + tableName = r.optString("tableName").ifEmpty { null }, + skyflowId = r.optString("skyflowId").ifEmpty { null }, + error = null, + tokens = parseTokens(r.optJSONObject("tokens")), + hashedData = parseHashedData(r.optJSONObject("hashedData")), + httpCode = httpCode + ) + } + } + } ?: emptyList() + return CollectResponse(records) + } + + // Public, lenient (kept for backwards compatibility): logs and returns an empty response on + // parse failure instead of throwing. The SDK's own success path uses fromJsonOrThrow so an + // undecodable response surfaces as onFailure rather than a false empty onSuccess. + fun fromJson(json: String): CollectResponse { + return try { + fromJsonOrThrow(json, LogLevel.ERROR) + } catch (e: Exception) { + Logger.error(TAG, "Failed to parse collect response: ${e.message}", LogLevel.ERROR) + CollectResponse() + } + } + + private fun parseTokens(obj: JSONObject?): Map>? = obj?.let { + val map = mutableMapOf>() + val keys = it.keys() + while (keys.hasNext()) { + val key = keys.next() + val tokenArr = it.optJSONArray(key) ?: continue + map[key] = (0 until tokenArr.length()).mapNotNull { j -> + val e = tokenArr.optJSONObject(j) ?: return@mapNotNull null + CollectRecordToken( + token = e.optString("token"), + tokenGroupName = e.optString("tokenGroupName").ifEmpty { null }, + path = e.optString("path").ifEmpty { null } + ) + } + } + map + } + + private fun parseHashedData(obj: JSONObject?): Map>? = obj?.let { + val map = mutableMapOf>() + val keys = it.keys() + while (keys.hasNext()) { + val key = keys.next() + val hdArr = it.optJSONArray(key) ?: continue + map[key] = (0 until hdArr.length()).mapNotNull { j -> + val e = hdArr.optJSONObject(j) ?: return@mapNotNull null + CollectRecordHashedData( + data = e.optString("data"), + hashName = e.optString("hashName") + ) + } + } + map + } + } +} + +/** + * Result callback for a `collect()` call. + * + * Threading: both [onSuccess] and [onFailure] are invoked on the **main (UI) thread**, so it is safe + * to update Views directly from them. Exactly one of the two is called per `collect()` invocation. + */ +interface CollectCallback { + fun onSuccess(response: CollectResponse) + fun onFailure(error: SkyflowError) +} diff --git a/flowvault/src/main/kotlin/Skyflow/response/RevealResponse.kt b/flowvault/src/main/kotlin/Skyflow/response/RevealResponse.kt new file mode 100644 index 00000000..7580baa4 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/response/RevealResponse.kt @@ -0,0 +1,107 @@ +package Skyflow + +import Skyflow.core.Logger +import org.json.JSONArray +import org.json.JSONObject + +data class RevealRecordMetadata( + val tableName: String? = null, + val skyflowId: String? = null +) + +data class RevealRecord( + val token: String, + val error: String? = null, + val tokenGroupName: String? = null, + val metadata: RevealRecordMetadata? = null, + val httpCode: Int = 0 +) + +data class RevealResponse(val records: List = emptyList()) { + fun toJson(): JSONObject { + val arr = JSONArray() + records.forEach { r -> + val obj = JSONObject().put("token", r.token).put("httpCode", r.httpCode) + r.tokenGroupName?.let { obj.put("tokenGroupName", it) } + r.error?.let { obj.put("error", it) } + r.metadata?.let { m -> + val meta = JSONObject() + m.tableName?.let { meta.put("tableName", it) } + m.skyflowId?.let { meta.put("skyflowId", it) } + obj.put("metadata", meta) + } + arr.put(obj) + } + return JSONObject().put("records", arr) + } + + companion object { + private const val TAG = "RevealResponse" + + // Strict decode: throws if the body is not a JSON object, so the SDK's success path can + // surface a real failure (onFailure) instead of a false empty onSuccess. Non-object entries + // in `records` are logged and skipped rather than silently dropped. + internal fun fromJsonOrThrow(json: String, logLevel: LogLevel): RevealResponse { + val root = JSONObject(json) + val records = root.optJSONArray("records")?.let { arr -> + (0 until arr.length()).mapNotNull { i -> + val r = arr.optJSONObject(i) + if (r == null) { + Logger.warn(TAG, "Skipping non-object entry at records[$i] in reveal response", logLevel) + return@mapNotNull null + } + val httpCode = r.optInt("httpCode", 200) + if (r.has("error") && !r.isNull("error")) { + RevealRecord( + token = r.optString("token"), + error = r.optString("error"), + tokenGroupName = null, + metadata = null, + httpCode = httpCode + ) + } else { + val metaObj = r.optJSONObject("metadata") + val metadata: RevealRecordMetadata? = metaObj?.let { obj -> + RevealRecordMetadata( + tableName = obj.optString("tableName").ifEmpty { null }, + skyflowId = obj.optString("skyflowId").ifEmpty { null } + ?: obj.optString("skyflowID").ifEmpty { null } + ) + } + RevealRecord( + token = r.optString("token"), + error = null, + tokenGroupName = r.optString("tokenGroupName").ifEmpty { null }, + metadata = metadata, + httpCode = httpCode + ) + } + } + } ?: emptyList() + return RevealResponse(records) + } + + // Public, lenient (kept for backwards compatibility): logs and returns an empty response on + // parse failure instead of throwing. The SDK's own success path uses fromJsonOrThrow so an + // undecodable response surfaces as onFailure rather than a false empty onSuccess. + fun fromJson(json: String): RevealResponse { + return try { + fromJsonOrThrow(json, LogLevel.ERROR) + } catch (e: Exception) { + Logger.error(TAG, "Failed to parse reveal response: ${e.message}", LogLevel.ERROR) + RevealResponse() + } + } + } +} + +/** + * Result callback for a `reveal()` call. + * + * Threading: both [onSuccess] and [onFailure] are invoked on the **main (UI) thread**, so it is safe + * to update Views directly from them. Exactly one of the two is called per `reveal()` invocation. + */ +interface RevealCallback { + fun onSuccess(response: RevealResponse) + fun onFailure(error: SkyflowError) +} diff --git a/flowvault/src/main/kotlin/Skyflow/reveal/FlowDBRevealApiCallback.kt b/flowvault/src/main/kotlin/Skyflow/reveal/FlowDBRevealApiCallback.kt new file mode 100644 index 00000000..2a0e6767 --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/reveal/FlowDBRevealApiCallback.kt @@ -0,0 +1,132 @@ +package Skyflow.reveal + +import Skyflow.Callback +import Skyflow.core.FlowDBAPIClient +import Skyflow.utils.Utils +import okhttp3.Call +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException + +internal class FlowDBRevealApiCallback( + private val callback: Callback, + private val apiClient: FlowDBAPIClient, + private val requestBody: JSONObject +) : Callback { + private val tag = FlowDBRevealApiCallback::class.qualifiedName + private val okHttpClient = apiClient.okHttpClient + + override fun onSuccess(responseBody: Any) { + try { + val url = "${apiClient.vaultURL.trimEnd('/')}/v2/tokens/detokenize" + val body = requestBody.toString().toRequestBody("application/json".toMediaTypeOrNull()) + val metrics = Utils.fetchMetrics() + val request = Request.Builder() + .method("POST", body) + .addHeader("Authorization", "$responseBody") + .addHeader("sky-metadata", "$metrics") + .url(url) + .build() + sendRequest(request) + } catch (e: Exception) { + callback.onFailure(Utils.constructErrorResponse(e)) + } + } + + override fun onFailure(exception: Any) { + // getAccessToken delivers a raw SkyflowInternalError here (e.g. INVALID_BEARER_TOKEN). Convert + // it to the standard {errors:[{error:{code,description}}]} shape so the app receives the real + // code + message instead of a mangled 500 (RevealValueCallback passes this through unchanged). + callback.onFailure(if (exception is Exception) Utils.constructErrorResponse(exception) else exception) + } + + private fun sendRequest(request: Request) { + okHttpClient.newCall(request).enqueue(object : okhttp3.Callback { + override fun onFailure(call: Call, e: IOException) { + callback.onFailure(Utils.constructErrorResponse(e, 500)) + } + + override fun onResponse(call: Call, response: Response) { + verifyResponse(response) + } + }) + } + + private fun verifyResponse(response: Response) { + response.use { + // Build the response INSIDE the try (parse errors -> onFailure), but deliver onSuccess + // AFTER it — so an exception thrown by the app's own onSuccess handler is NOT caught here + // and turned into a second onFailure. Exactly one of onSuccess/onFailure must fire. + val result: JSONObject = try { + val bodyStr = response.body?.string() ?: "" + val responseJson = try { JSONObject(bodyStr) } catch (e: JSONException) { null } + + if (responseJson?.has("response") == true) { + buildResponse(responseJson) + } else if (!response.isSuccessful) { + // Whole-request failure (auth error, malformed request, etc.) + val message = try { + responseJson?.getJSONObject("error")?.getString("message") ?: bodyStr + } catch (e: JSONException) { bodyStr } + val requestId = response.headers["x-request-id"] ?: "" + callback.onFailure(Utils.constructErrorResponse( + response.code, + Utils.appendRequestId(message, requestId) + )) + return + } else { + buildResponse(responseJson ?: JSONObject()) + } + } catch (e: Exception) { + callback.onFailure(Utils.constructErrorResponse(e, 500)) + return + } + callback.onSuccess(result) + } + } + + private fun buildResponse(responseJson: JSONObject): JSONObject { + val responseArray = responseJson.optJSONArray("response") ?: JSONArray() + val allRecords = JSONArray() + + for (i in 0 until responseArray.length()) { + val entry = responseArray.getJSONObject(i) + val httpCode = entry.optInt("httpCode", 200) + val originalToken = entry.optString("token") + + if (httpCode == 200) { + val rawMeta = entry.optJSONObject("metadata") + val normalizedMeta = rawMeta?.let { m -> + JSONObject().also { out -> + m.keys().asSequence().forEach { k -> + val normalizedKey = if (k == "skyflowID") "skyflowId" else k + out.put(normalizedKey, m.opt(k)) + } + } + } + allRecords.put( + JSONObject() + .put("token", originalToken) + .put("value", entry.optString("value")) + .put("tokenGroupName", entry.optString("tokenGroupName")) + .put("httpCode", httpCode) + .put("metadata", normalizedMeta) + ) + } else { + allRecords.put( + JSONObject() + .put("token", originalToken) + .put("error", entry.optString("error", "Detokenize failed")) + .put("httpCode", httpCode) + ) + } + } + + return JSONObject().put("records", allRecords) + } +} diff --git a/flowvault/src/main/kotlin/Skyflow/reveal/FlowDBRevealRequestBody.kt b/flowvault/src/main/kotlin/Skyflow/reveal/FlowDBRevealRequestBody.kt new file mode 100644 index 00000000..8222fc4b --- /dev/null +++ b/flowvault/src/main/kotlin/Skyflow/reveal/FlowDBRevealRequestBody.kt @@ -0,0 +1,41 @@ +package Skyflow.reveal + +import Skyflow.Label +import Skyflow.RevealOptions +import org.json.JSONArray +import org.json.JSONObject + +internal class FlowDBRevealRequestBody { + companion object { + internal fun buildRequestBody( + vaultID: String, + elements: MutableList
| +| EXPIRATION_YEAR |
  • `yy`(default)
  • `yyyy`
  • | N/A |
    • 27
    • 2027
    | +| CARD_NUMBER |
    • `XXXX XXXX XXXX XXXX` (default)
    • `XXXX-XXXX-XXXX-XXXX`
    | N/A |
    • 1234 5678 9012 3456
    • 1234-5678-9012-3456
    | +| INPUT_FIELD | A string that matches the desired output, with placeholder characters of your choice. | A hashmap of key/value pairs. Defaults to `hashmapOf('X' to "[0-9]")` | With `format: +91 XXXX-XX-XXXX` and `translation: hashmapOf('X' to "[0-9]")`, user input of "1234121234" displays as "+91 1234-12-1234". | + +Collect Element Options examples for INPUT_FIELD + +Example 1 +```kotlin +Skyflow.CollectElementOptions( + required: true, + enableCardIcon: true, + format: "+91 XXXX-XX-XXXX", + translation: hashmapOf('X' to "[0-9]") +) +``` +User input: "1234121234" + +Value displayed in INPUT_FIELD: "+91 1234-12-1234" + +Example 2 +```kotlin +Skyflow.CollectElementOptions( + required: true, + enableCardIcon: true, + format: "AY XX-XXX-XXXX", + translation: hashmapOf('X' to "[0-9]", 'Y' to "[A-Z]") +) +``` +User input: "B1234121234" + +Value displayed in INPUT_FIELD: "AB 12-341-2123" + +Once the `Skyflow.CollectElementInput` and `Skyflow.CollectElementOptions` objects are defined, add to the container using the ```create(context:Context,input: CollectElementInput, options: CollectElementOptions)``` method as shown below. The `input` param takes a `Skyflow.CollectElementInput` object as defined above and the `options` parameter takes a `Skyflow.CollectElementOptions`, +the `context` param takes android `Context` object as described below: + +```kotlin +val collectElementInput = Skyflow.CollectElementInput( + table = "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*/ + labelStyles = Skyflow.Styles(), //optional styles that will be applied to the label of the collect element + errorTextStyles = Skyflow.Styles(), //optional styles that will be applied to the errorText of the collect element + label = "string", //optional label for the form element + placeholder = "string", //optional placeholder for the form element + altText: String, //(DEPRECATED) optional string that acts as an initial value for the collect element + validations = ValidationSet() // optional set of validations for the input element +) + +val collectElementOptions = Skyflow.CollectElementOptions( + required = false, //indicates whether the field is marked as required. Defaults to 'false' + enableCardIcon = true //indicates whether card icon should be enabled (only for CARD_NUMBER inputs) + format = "mm/yy" //Format for the element (only applies currently for EXPIRATION_DATE element type) +) + +const element = container.create(context = Context, collectElementInput, collectElementOptions) +``` + + + +### Step 3: Add Elements to the layout + +To specify where the Elements will be rendered on the screen, set layout params to the view and add it to a layout in your app programmatically. + +```kt +val layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT +) +element.layoutParams = layoutParams +existingLayout.addView(element) +``` + +The Skyflow Element is an implementation of native android View so it can be used/mounted similarly.Alternatively, you can use the `unmount` method to reset any element to it's initial state. + +```kt +fun clearFields(elements: List) { + + //resets all elements to initial value + for element in elements { + element.unmount() + } +} +``` + + +### Step 4 : Collect data from Elements +When the form is ready to be submitted, call the collect(options: Skyflow.CollectOptions? = nil, callback: Skyflow.Callback) method on the container object. The options parameter takes `Skyflow.CollectOptions` object. + +`Skyflow.CollectOptions` takes two optional fields +- `tokens`: indicates whether tokens for the collected data should be returned or not. Defaults to 'true' +- `additionalFields`: Non-PCI elements data to be inserted into the vault which should be in the `records` object format as described in the above [Inserting data into vault](#Inserting-data-into-the-vault) section. + +```kt +// NON-PCI fields object creation +val nonPCIRecords = JSONObject() +val recordsArray = JSONArray() +val record = JSONObject() +record.put("table", "persons") +val fields = JSONObject() +fields.put("gender", "MALE") +record.put("fields", fields) +recordsArray.put(record) +nonPCIRecords.put("records", recordsArray) + +val options = Skyflow.CollectOptions(tokens = true, additonalFields = nonPCIRecords) +val insertCallback = InsertCallback() //Custom callback - implementation of Skyflow.callback +container.collect(options, insertCallback) +``` +### End to end example of collecting data with Skyflow Elements + +#### [Sample Code](https://github.com/skyflowapi/skyflow-android/blob/main/samples/skyvault/src/main/java/com/Skyflow/CollectActivity.kt): +```kt +//Initialize skyflow configuration +val config = Skyflow.Configuration(vaultId = VAULT_ID, vaultURL = VAULT_URL, tokenProvider = demoTokenProvider) + +//Initialize skyflow client +val skyflowClient = Skyflow.initialize(config) + +//Create a CollectContainer +val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT) + +//Initialize and set required options +val options = Skyflow.CollectElementOptions(required = true) + +//Create Skyflow.Styles with individual Skyflow.Style variants +val baseStyle = Skyflow.Style(borderColor = Color.BLUE) +val baseTextStyle = Skyflow.Style(textColor = Color.BLACK) +val completedStyle = Skyflow.Style(textColor = Color.GREEN) +val focusTextStyle = Skyflow.Style(textColor = Color.RED) +val inputStyles = Skyflow.Styles(base = baseStyle, complete = completedStyle) +val labelStyles = Skyflow.Styles(base = baseTextStyle, focus = focusTextStyle) +val errorTextStyles = Skyflow.Styles(base = baseTextStyle) + +//Create a CollectElementInput +val input = Skyflow.CollectElementInput( + table = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER + inputStyles = inputStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "card number", + placeholder = "card number", +) + +//Create a CollectElementOptions instance +val options = Skyflow.CollectElementOptions(required = true) + +//Create a Collect Element from the Collect Container +val skyflowElement = container.create(context = Context,input, options) + +//Can interact with this object as a normal UIView Object and add to View + +// Non-PCI fields data +val nonPCIRecords = JSONObject() +val recordsArray = JSONArray() +val record = JSONObject() +record.put("table", "persons") +val fields = JSONObject() +fields.put("gender", "MALE") +record.put("fields", fields) +recordsArray.put(record) +nonPCIRecords.put("records", recordsArray) + +//Initialize and set required options for insertion +val collectOptions = Skyflow.CollectOptions(tokens = true, additionalFields = nonPCIRecords) + +//Implement a custom Skyflow.Callback to be called on Insertion success/failure +public class InsertCallback: Skyflow.Callback { + override fun onSuccess(responseBody: Any) { + print(responseBody) + } + override fun onFailure(_ error: Error) { + print(error) + } +} + +//Initialize InsertCallback which is an implementation of Skyflow.Callback interface +val insertCallback = InsertCallback() + +//Call collect method on CollectContainer +container.collect(options = collectOptions, callback = insertCallback) + +``` +#### Sample Response : +``` +{ + "records": [ + { + "table": "cards", + "fields": { + "cardNumber": "f3907186-e7e2-466f-91e5-48e12c2bcbc1" + } + }, + { + "table": "persons", + "fields": { + "gender": "12f670af-6c7d-4837-83fb-30365fbc0b1e", + } + } + ] +} + +``` + +### End to end example of upsert support with Skyflow Elements + +#### [Sample Code](https://github.com/skyflowapi/skyflow-android/blob/main/samples/skyvault/src/main/java/com/Skyflow/UpsertFeature.kt): +```kt +val config = Skyflow.Configuration(vaultId = VAULT_ID, vaultURL = VAULT_URL, tokenProvider = demoTokenProvider) +val skyflowClient = Skyflow.initialize(config) +val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT) +val options = Skyflow.CollectElementOptions(required = true) +val baseStyle = Skyflow.Style(borderColor = Color.BLUE) +val baseTextStyle = Skyflow.Style(textColor = Color.BLACK) +val completedStyle = Skyflow.Style(textColor = Color.GREEN) +val focusTextStyle = Skyflow.Style(textColor = Color.RED) +val inputStyles = Skyflow.Styles(base = baseStyle, complete = completedStyle) +val labelStyles = Skyflow.Styles(base = baseTextStyle, focus = focusTextStyle) +val errorTextStyles = Skyflow.Styles(base = baseTextStyle) + +val cardNumberInput = Skyflow.CollectElementInput( + table = "cards", + column = "card_number", + type = Skyflow.ElementType.CARD_NUMBER + inputStyles = inputStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "Card number", + placeholder = "enter your card number", +) + +val nameInput = Skyflow.CollectElementInput( + table = "cards", + column = "full_name", + type = Skyflow.ElementType.CARD_NUMBER + inputStyles = inputStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "Full name", + placeholder = "enter your name", +) +val cardNumberElement = container.create(context = Context,cardNumberInput, options) +val nameElement = container.create(context = Context,namerInput, options) + +//Upsert options +val upsertArray = JSONArray() +val upsertColumn = JSONObject() +upsertColumn.put("table", "cards") +upsertColumn.put("column", "card_number") +upsertArray.put(upsertColumn) + +val collectOptions = Skyflow.CollectOptions(tokens = true,upsert = upsertArray) + +public class InsertCallback: Skyflow.Callback { + override fun onSuccess(responseBody: Any) { + print(responseBody) + } + override fun onFailure(_ error: Error) { + print(error) + } +} + +val insertCallback = InsertCallback() +container.collect(options = collectOptions, callback = insertCallback) + +``` +#### Sample Response : +``` +{ + "records": [ + { + "table": "cards", + "fields": { + "card_number": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", + "name": "f3907186-e7e2-464f-91e5-48e12c2bfsi9" + } + } + ] +} + +``` + +## Using Skyflow Elements to update data + +You can update data in a vault using Skyflow Elements. Use the following steps to securely update data. + +### Step 1: Create a container + +First create a **container** for the form elements using the ```skyflowClient.container(type: Skyflow.ContainerType)``` method as shown below: + +```kt +val container = skyflowClient.container(Skyflow.ContainerType.COLLECT) +``` + +### Step 2: Create a collect Element + +To create a collect Element, construct a `Skyflow.CollectElementInput` object as shown below: + +```kt +val collectElementInput = Skyflow.CollectElementInput( + table: String, // optional, the table this data belongs to + column: String, // optional, the column into which this data should be inserted + type: Skyflow.ElementType, // Skyflow.ElementType enum + inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element + labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element + errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + label: String, // optional label for the form element + placeholder: String, // optional placeholder for the form element + altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element + validations: ValidationSet, // optional set of validations for the input element + skyflowID: String // The skyflow_id of the record to be updated +) +``` + +The `table` and `column` fields indicate which table and column in the vault the Element corresponds to. + +**Note:** +- Use dot delimited strings to specify columns nested inside JSON fields (e.g. `address.street.line1`) +- `table` and `column` are optional only if the element is being used in invokeConnection() + +Along with `CollectElementInput`, you can define other options in the `CollectElementOptions` object as described in the [collect section](#step-2-create-a-collect-element). + +### Step 3: Mount Elements to the Screen + +To specify where the Elements will be rendered on the screen, create a parent UIView (like LinearLayout, etc.) and add it programmatically. + +```kt +val parent = findViewById(R.id.parent) +val lp = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT +) +parent.addView(element) +``` + +The Skyflow Element is an implementation of the View so it can be used/mounted similarly. Alternatively, you can use the `unmount` method to reset any collect element to its initial state. + +```kt +fun clearFieldsOnSubmit(elements: List) { + // resets all elements in the array + for (element in elements) { + element.unmount() + } +} +``` + +### Step 4: Update data from Elements + +When the form is ready to submit, call the `collect(options?)` method on the container object. The `options` parameter takes an object of optional parameters as shown below: + +- `tokens`: indicates whether tokens for the collected data should be returned or not. Defaults to 'true' +- `additionalFields`: Non-PCI elements data to update or insert into the vault which should be in the records object format. +- `upsert`: To support upsert operations while collecting data from Skyflow elements, pass the table and column marked as unique in the table. + +```kt +// Non-PCI records with skyflowID for update +val nonPCIRecords = JSONObject().apply { + val recordsArray = JSONArray() + val record = JSONObject().apply { + put("table", "persons") + val fields = JSONObject().apply { + put("gender", "MALE") + put("skyflow_id", "") // skyflowID for update + } + put("fields", fields) + } + recordsArray.put(record) + put("records", recordsArray) +} + +// Upsert options +val upsertArray = JSONArray() +val upsertColumn = JSONObject().apply { + put("table", "cards") + put("column", "card_number") +} +upsertArray.put(upsertColumn) + +// Send the non-PCI records as additionalFields of CollectOptions (optional) +// and apply upsert using `upsert` field of CollectOptions (optional) +val options = CollectOptions(tokens = true, additionalFields = nonPCIRecords, upsert = upsertArray) + +// Custom callback - implementation of Skyflow.Callback +val insertCallback = InsertCallback() +container.collect(callback = insertCallback, options = options) +``` + +**Note:** `skyflowID` is required if you want to update the data. If `skyflowID` isn't specified, the `collect(options?)` method creates a new record in the vault. + +### End to end example of updating data with Skyflow Elements + +```kt +// Initialize skyflow configuration +val tokenProvider = DemoTokenProvider() +val config = Configuration( + vaultID = "", + vaultURL = "", + tokenProvider = tokenProvider +) + +// Initialize skyflow client +val skyflowClient = init(config) + +// Create a CollectContainer +val container = skyflowClient.container(ContainerType.COLLECT) + +// Create Skyflow.Styles with individual Skyflow.Style variants +val padding = Padding(8, 8, 8, 8) +val baseStyle = Style(borderColor = Color.BLUE) +val baseTextStyle = Style(textColor = Color.BLACK) +val completeStyle = Style(borderColor = Color.GREEN) +val focusTextStyle = Style(textColor = Color.RED) +val inputStyles = Styles(base = baseStyle, complete = completeStyle) +val labelStyles = Styles(base = baseTextStyle, focus = focusTextStyle) +val errorTextStyles = Styles(base = baseTextStyle) + +// Create a CollectElementInput with skyflowID for update +val input = CollectElementInput( + table = "cards", + column = "card_number", + type = SkyflowElementType.CARD_NUMBER, + inputStyles = inputStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "Card Number", + placeholder = "XXXX XXXX XXXX XXXX", + skyflowID = "431eaa6c-5c15-4513-aa15-29f50babe882" // skyflowID for update +) + +// Create an option to require the element +val requiredOption = CollectElementOptions(required = true, enableCopy = true) + +// Create a Collect Element from the Collect Container +val skyflowElement = container.create(context = this, input = input, options = requiredOption) + +// Can interact with this object as a normal View Object and add to View +val parent = findViewById(R.id.parent) +parent.addView(skyflowElement) + +// Non-PCI records with skyflowID for update +val nonPCIRecords = JSONObject().apply { + val recordsArray = JSONArray() + // Update existing person record + val personRecord = JSONObject().apply { + put("table", "persons") + val fields = JSONObject().apply { + put("gender", "MALE") + put("skyflowID", "77dc3caf-c452-49e1-8625-07219d7567bf") // skyflowID for update + } + put("fields", fields) + } + // Update existing card record with additional fields + val cardRecord = JSONObject().apply { + put("table", "cards") + val fields = JSONObject().apply { + put("first_name", "Joe") + put("skyflowID", "431eaa6c-5c15-4513-aa15-29f50babe882") // same skyflowID as collect element + } + put("fields", fields) + } + recordsArray.put(personRecord) + recordsArray.put(cardRecord) + put("records", recordsArray) +} + +// Upsert options +val upsertOptions = JSONArray().apply { + val upsertColumn = JSONObject().apply { + put("table", "cards") + put("column", "card_number") + } + put(upsertColumn) +} + +// Send the Non-PCI records as additionalFields of CollectOptions (optional) +// and apply upsert using optional field `upsert` of CollectOptions +val collectOptions = CollectOptions(tokens = true, additionalFields = nonPCIRecords, upsert = upsertOptions) + +// Implement a custom Skyflow.Callback to call on update success/failure +class InsertCallback : Callback { + override fun onSuccess(responseBody: Any) { + Log.d(TAG, "Update successful: $responseBody") + } + + override fun onFailure(exception: Any) { + Log.e(TAG, "Update failed: ${(exception as Exception).message}") + } +} + +// Initialize custom Skyflow.Callback +val insertCallback = InsertCallback() + +// Call collect method on CollectContainer +container.collect(callback = insertCallback, options = collectOptions) +``` + +#### Skyflow returns tokens for the record you just updated: + +```json +{ + "records": [ + { + "table": "persons", + "fields": { + "gender": "12f670af-6c7d-4837-83fb-30365fbc0b1e", + "skyflow_id": "77dc3caf-c452-49e1-8625-07219d7567bf" + } + }, + { + "table": "cards", + "fields": { + "skyflow_id": "431eaa6c-5c15-4513-aa15-29f50babe882", + "card_number": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", + "first_name": "131e70dc-6f76-4319-bdd3-96281e051051" + } + } + ] +} +``` + +### Validations + +skyflow-android provides two types of validations on Collect Elements + +#### 1. Default Validations: +Every Collect Element except of type `INPUT_FIELD` has a set of default validations listed below: +- `CARD_NUMBER`: Card number validation with checkSum algorithm(Luhn algorithm), available card lengths for defined card types +- `CARD_HOLDER_NAME`: Name, should be 2 or more symbols, valid characters shold match pattern `^([a-zA-Z\\ \\,\\.\\-\\']{2,})$` +- `CVV`: Card CVV can have 3-4 digits +- `EXPIRATION_DATE`: Any date starting from current month. By default valid expiration date should be in short year format - `MM/YY` +- `PIN`: Can have 4-12 digits + +#### 2. Custom Validations: +Custom validations can be added to any element which will be checked after the default validations have passed. The following Custom validation rules are currently supported: +- `RegexMatchRule`: You can use this rule to specify any Regular Expression to be matched with the text field value +- `LengthMatchRule`: You can use this rule to set the minimum and maximum permissible length of the textfield value +- `ElementValueMatchRule`: You can use this rule to match the value of one element with another + +The Sample code below illustrates the usage of custom validations: + +```kt +/* + Reset Password - A simple example that illustrates custom validations. The below code shows two input fields with custom validations, one to enter a Password and the second to confirm the same Password. +*/ + +var myRuleset = ValidationSet() +val strongPasswordRule = RegexMatchRule(regex= "^^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]*$", error = "At least one letter and one number") // This rule enforces a strong password +val lengthRule = LengthMatchRule(minLength = 8, maxLength = 16, error = "Must be between 8 and 16 digits") // this rule allows input length between 8 and 16 characters + +// for the Password element +myRuleset.add(rule = strongPasswordRule) +myRuleset.add(rule = lengthRule) + +val passwordInput = CollectElementInput(inputStyles = styles, label = "Password", placeholder = "****", type = ElementType.INPUT_FIELD, validations = myRuleset) + +val Password = container.create(passwordInput) + +// For confirm Password element - shows error when the PINs don't match +val elementMatchRule = ElementMatchRule(element = Password, error = "PINs don't match") + +val confirmPasswordinput = CollectElementInput(inputStyles = styles, label = "Confirm Password", placeholder = "****", type = ElementType.INPUT_FIELD, validations = ValidationSet(rules = mutableListOf(strongPasswordRule, lengthRule, elementMatchRule))) +val confirmPassword = container.create(input = confirmPasswordinput) + +//mount elements to the screen +addView(Password) +addView(confirmPassword) + +``` + +### Event Listener on Collect Elements + + +Helps to communicate with skyflow elements by listening to an event + +```kt +element.on(eventName: Skyflow.EventName) { state -> + //handle function +} +``` + +There are 4 events in `Skyflow.EventName` +- `CHANGE` + Change event is triggered when the Element's value changes. +- `READY` + Ready event is triggered when the Element is fully rendered +- `FOCUS` + Focus event is triggered when the Element gains focus +- `BLUR` + Blur event is triggered when the Element loses focus. +The handler ```(state: JSONObject) -> Unit``` is a callback function you provide, that will be called when the event is fired with the state object as shown below. + +```kt +val state = { + "elementType": Skyflow.ElementType, + "isEmpty": Boolean, + "isRequired": Boolean, + "isFocused": Boolean, + "isValid": Boolean, + "value": String, + "selectedCardScheme": Skyflow.CardType, +} +``` +`Notes:` +- values of SkyflowElements will be returned in element state object only when `env` is `DEV`, else it is empty string i.e, '', but in case of CARD_NUMBER type element when the `env` is `PROD` for all the card types except AMEX, it will return first eight digits, for AMEX it will return first six digits and rest all digits in masked format. +- `selectedCardScheme` is only populated for the `CARD_NUMBER` element states when a user chooses a card brand. By default, `selectedCardScheme` is an empty string. + +##### Sample code snippet for using listeners +```kt +//create skyflow client with loglevel:"DEBUG" +val config = Skyflow.Configuration(vaultID = VAULT_ID, vaultURL = VAULT_URL, tokenProvider = demoTokenProvider, options = Skyflow.Options(logLevel = Skyflow.LogLevel.DEBUG)) + +val skyflowClient = Skyflow.initialize(config) + +val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT) + +// Create a CollectElementInput +val cardNumberInput = Skyflow.CollectElementInput( + table = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER, +) +val cardHolderNameInput = Skyflow.CollectElementInput( + table = "cards", + column = "cardHolderName", + type = Skyflow.ElementType.CARDHOLDER_NAME, +) + +val cardNumber = container.create(context = Context, input = cardNumberInput) +val cardHolderName = container.create(context = Context, input = cardHolderNameInput) + +//subscribing to CHANGE event, which gets triggered when element changes +cardNumber.on(eventName = Skyflow.EventName.CHANGE) { state -> + // Your implementation when Change event occurs + log.info("on change", state) +} +cardHolderName.on(eventName = Skyflow.EventName.CHANGE) { state -> + // Your implementation when Change event occurs + log.info("on change", state) +} +``` +##### Sample Element state object when `Env` is `DEV` +```kt +{ + "elementType": Skyflow.ElementType.CARD_NUMBER, + "isEmpty": false, + "isRequired": false, + "isFocused": true, + "isValid": true, + "value": "4111111111111111" +} +{ + "elementType": Skyflow.ElementType.CARDHOLDER_NAME, + "isEmpty": false, + "isRequired": false, + "isFocused": true, + "isValid": true, + "value": "John" +} +``` +##### Sample Element state object when `Env` is `PROD` +```kt +{ + "elementType": Skyflow.ElementType.CARD_NUMBER, + "isEmpty": false, + "isFocused": true, + "isValid": true, + "value": "41111111XXXXXXXX" +} +{ + "elementType": Skyflow.ElementType.CARDHOLDER_NAME, + "isEmpty": false, + "isFocused": true, + "isValid": true, + "value": "" +} +``` + +### UI Error for Collect Elements + +Helps to display custom error messages on the Skyflow Elements through the methods `setError` and `resetError` on the elements. + +`setError(error : String)` method is used to set the error text for the element, when this method is trigerred, all the current errors present on the element will be overridden with the custom error message passed. This error will be displayed on the element until `resetError()` is trigerred on the same element. + +`resetError()` method is used to clear the custom error message that is set using `setError`. + +##### Sample code snippet for setError and resetError + +```kt +//create skyflow client with loglevel:"DEBUG" +val config = Skyflow.Configuration(vaultID = VAULT_ID, vaultURL = VAULT_URL, tokenProvider = demoTokenProvider, options = Skyflow.Options(logLevel = Skyflow.LogLevel.DEBUG)) + +val skyflowClient = Skyflow.initialize(config) + +val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT) + +// Create a CollectElementInput +val cardNumberInput = Skyflow.CollectElementInput( + table = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER, +) + +val cardNumber = container.create(input = cardNumberInput) + +//Set custom error +cardNumber.setError("custom error") + +//reset custom error +cardNumber.resetError() +``` + + +### Set and Clear value for Collect Elements (DEV ENV ONLY) + +`setValue(value: String)` method is used to set the value of the element. This method will override any previous value present in the element. + +`clearValue()` method is used to reset the value of the element. + +`Note:` This methods are only available in DEV env for testing/developmental purposes and MUST NOT be used in PROD env. + +##### Sample code snippet for setValue and clearValue + +```kotlin +//create skyflow client with env DEV +val config = Skyflow.Configuration( + vaultID = VAULT_ID, + vaultURL = VAULT_URL, + tokenProvider = demoTokenProvider, + options = Skyflow.Options(env = Skyflow.Env.DEV) +) +val skyflowClient = Skyflow.initialize(config) +val container = skyflowClient.container(type = Skyflow.ContainerType.COLLECT) + +// Create a CollectElementInput +val cardNumberInput = Skyflow.CollectElementInput( + table = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER, +) +val cardNumber = container.create(input = cardNumberInput) +//Set a value programatically +cardNumber.setValue("4111111111111111") +//Clear the value +cardNumber.clearValue() +``` + +--- + +# Securely collecting data client-side using composable elements +Composable Elements combine multiple Skyflow Elements in a single row. The following steps create a composable element and securely collect data through it. + +- [**Using Skyflow Composable Elements to collect data**](#using-skyflow-composable-elements-to-collect-data) +- [**Using Skyflow Composable Elements to update data**](#using-skyflow-composable-elements-to-update-data) +- [**Event Listeners on Composable Elements**](#event-listeners-on-composable-elements) +- [**Update Composable Elements**](#update-composable-elements) +- [**Event Listeners on Composable Container**](#event-listeners-on-composable-container) + +## Using Skyflow Composable Elements to collect data +### Step 1: Create a composable container + +First create a **container** for the form elements using the `skyflowClient.container(type: Skyflow.ContainerType, options: Skyflow.ContainerOptions)` method as show below + +```kotlin +val container = skyflowClient.container(type = ContainerType.COMPOSABLE, options = ContainerOptions(layout = arrayOf(2, 1))) +``` + +The container requires an options object that contains the following keys: + +- `layout`: An array that indicates the number of rows in the container and the number of elements in each row. The index value of the array defines the number of rows, and each value in the array represents the number of elements in that row, in order. + + For example: `arrayOf(2, 1)` means the container has two rows, with two elements in the first row and one element in the second row. + + `Note`: The sum of values in the layout array should be equal to the number of elements created + +- `styles`: styles to apply to each composable row. + +- `errorTextStyles`: styles to apply if an error is encountered. + +```kotlin +val containerOptions = ContainerOptions( + layout: [1, 1, 2], // required + styles: Skyflow.Styles, // optional + errorTextStyles: Skyflow.Styles // optional +) +``` +### Step 2: Create Composable Elements +Composable Elements use the following schema: + +```kotlin +val composableElementInput = Skyflow.CollectElementInput( + table: String, // optional, the table this data belongs to + column: String, // optional, the column into which this data should be inserted + inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element + labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element + errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + label: String, // optional label for the form element + placeholder: String, // optional placeholder for the form element + altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element + validations: ValidationSet, // optional set of validations for the input element + type: Skyflow.ElementType, // Skyflow.ElementType enum +) +``` +The `table` and `column` fields indicate which table and column in the vault the Element correspond to. + +**Note**: +- Use dot delimited strings to specify columns nested inside JSON fields (e.g. `address.street.line1`) + +The `inputStyles` parameter accepts a `Skyflow.Styles` object which consists of multiple `Skyflow.Style` objects which should be applied to the form element in the following states: + +- `base`: all other variants inherit from these styles +- `complete`: applied when the Element has valid input +- `empty`: applied when the Element has no input +- `focus`: applied when the Element has focus +- `invalid`: applied when the Element has invalid input + +Each Style object accepts the following properties, please note that each property is optional: + +```kotlin +Skyflow.Style( + borderColor: Int // optional + cornerRadius: Float // optional + padding: Skyflow.Padding // optional + borderWidth: Int // optional + font: Int // optional + textAlignment: Int // optional + textColor: Int // optional + placeholderColor: Int // optional + width: Int // optional + height: Int // optional + margin: Skyflow.Margin // optional + backgroundColor: Int // optional + minWidth: Int // optional + maxWidth: Int // optional + minHeight: Int // optional + maxHeight: Int // optional +) +``` + +Here `Skyflow.Padding` and `Skyflow.Margin` are classes which can be used to set the padding and margin respectively for the composable element which takes all the left, top, right, bottom values. + +```kt +Skyflow.Padding(left: Int, top: Int, right: Int, bottom: Int) + +Skyflow.Margin(left: Int, top: Int, right: Int, bottom: Int) +``` + +An example Skyflow.Styles object +```kotlin +val styles = Skyflow.Styles( + base: Style, // optional + complete: Style, // optional + empty: Style, // optional + focus: Style, // optional + invalid: Style // optional +) +``` + +**Notes**: +- The `labelStyles` and `errorTextStyles` fields accept the above mentioned `Skyflow.Styles` object which are applied to the `label` and `errorText` text views respectively. + +- The states that are available for `labelStyles` are `base` and `focus`. + +- The `errorTextStyles` will be ignored for composable element passed in `CollectElementInput` and `errorTextStyles` passed in `ContainerOptions` will be used instead. + +- The state that is available for `errorTextStyles` is only the base state, it shows up when there is some error in the composable element. + +- The parameters in `Skyflow.Style` object that are respected for `label` and `errorText` text views are + - padding + - font + - textColor + - textAlignment + - width + - height + - margin + - minWidth + - maxWidth + - minHeight + - maxHeight + +Other parameters in the `Skyflow.Style` object are ignored for `label` and `errorText` text views. + +Finally, the `type` parameter takes a Skyflow.ElementType. Each type applies the appropriate regex and validations to the form element. + +The Android SDK supports the following composable elements: + +- `INPUT_FIELD` +- `CARDHOLDER_NAME` +- `CARD_NUMBER` +- `EXPIRATION_DATE` +- `CVV` +- `PIN` +- `EXPIRATION_YEAR` +- `EXPIRATION_MONTH` + +**Note**: Only when the entered value in the below composable elements is valid, the focus shifts automatically. The element types are: + +- `CARD_NUMBER` +- `EXPIRATION_DATE` +- `EXPIRATION_MONTH` +- `EXPIRATION_YEAR` + +The `INPUT_FIELD` type is a custom UI element without any built-in validations. See the section on [validations](#validations) for more information on validations. + +Along with `CollectElementInput`, you can define other options in the `CollectElementOptions` object which is described below. + +```kotlin +Skyflow.CollectElementOptions( + required: Boolean, // Indicates whether the field is marked as required. Defaults to 'false' + enableCardIcon: Boolean, // Indicates whether card icon should be enabled (only for CARD_NUMBER inputs) + format: String, // Format for the element + translation: HashMap // Indicates the allowed data type value for format. +) +``` +- `required`: Indicates whether the field is marked as required or not. Default is `false`. +- `enableCardIcon`: Indicates whether the icon is visible for the CARD_NUMBER element. Default is `true`. +- `format`: A string value that indicates the format pattern applicable to the element type. Only applicable to `EXPIRATION_DATE`, `CARD_NUMBER`, `EXPIRATION_YEAR`, and `INPUT_FIELD` elements. + - For INPUT_FIELD elements, + - the length of `format` determines the expected length of the user input. + - if `translation` isn't specified, the `format` value is considered a string literal. +- `translation`: A dictionary of key/value pairs, where the key is a character that appears in `format` and the value is a regex pattern of acceptable inputs for that character. Each key can only appear once. Only applicable for INPUT_FIELD elements. + +Accepted values by element type: + +| Element type | `format` | `translation` | Examples | +| --------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| EXPIRATION_DATE |
    • `mm/yy`(default)
    • `mm/yyyy`
    • `yy/mm`
    • `yyyy/mm`
    | N/A |
    • 12/27
    • 12/2027
    • 27/12
    • 2027/12
| +| EXPIRATION_YEAR |
  • `yy`(default)
  • `yyyy`
  • | N/A |
    • 27
    • 2027
    | +| CARD_NUMBER |
    • `XXXX XXXX XXXX XXXX` (default)
    • `XXXX-XXXX-XXXX-XXXX`
    | N/A |
    • 1234 5678 9012 3456
    • 1234-5678-9012-3456
    | +| INPUT_FIELD | A string that matches the desired output, with placeholder characters of your choice. | A hashmap of key/value pairs. Defaults to `hashmapOf('X' to "[0-9]")` | With `format: +91 XXXX-XX-XXXX` and `translation: hashmapOf('X' to "[0-9]")`, user input of "1234121234" displays as "+91 1234-12-1234". | + +Collect Element Options examples for INPUT_FIELD + +Example 1 +```kotlin +Skyflow.CollectElementOptions( + required: true, + enableCardIcon: true, + format: "+91 XXXX-XX-XXXX", + translation: hashmapOf('X' to "[0-9]") +) +``` +User input: "1234121234" + +Value displayed in INPUT_FIELD: "+91 1234-12-1234" + +Example 2 +```kotlin +Skyflow.CollectElementOptions( + required: true, + enableCardIcon: true, + format: "AY XX-XXX-XXXX", + translation: hashmapOf('X' to "[0-9]", 'Y' to "[A-Z]") +) +``` +User input: "B1234121234" + +Value displayed in INPUT_FIELD: "AB 12-341-2123" + +Once the `Skyflow.CollectElementInput` and `Skyflow.CollectElementOptions` objects are defined, add to the container using the `create(context: Context, input: CollectElementInput, options: CollectElementOptions)` method as shown below. The `input` param takes a `Skyflow.CollectElementInput` object as defined above and the `options` parameter takes an `Skyflow.CollectElementOptions` object as described below: + +```kotlin +val composableElementInput = Skyflow.CollectElementInput( + table: String, // the table this data belongs to + column: String, // the column into which this data should be inserted + inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element + labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element + errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + label: String, // optional label for the form element + placeholder: String, // optional placeholder for the form element + altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element + validations: ValidationSet, // optional set of validations for the input element + type: Skyflow.ElementType, // Skyflow.ElementType enum +) + +val collectElementOptions = Skyflow.CollectElementOptions( + required: false, // indicates whether the field is marked as required. Defaults to 'false', + enableCardIcon: true, // indicates whether card icon should be enabled (only for CARD_NUMBER inputs) + format: "mm/yy" // Format for the element +) + +val element = container.create(context = Context, input: composableElementInput, options: collectElementOptions) +``` +### Step 3: Mount Elements to the Screen + +To specify where the Elements will be rendered on the screen, fetch composable layout using `container.getComposableLayout()` and add it to a layout in your app programmatically. + +```kotlin +try { + val composableLayout = container.getComposableLayout() + existingLayout.addView(composableLayout) +} catch(error: Exception) { + println(error) +} +``` + +The Skyflow Element is an implementation of native android View so it can be used/mounted similarly.Alternatively, you can use the unmount method to reset any element to it's initial state. + +```kotlin +fun clearFieldsOnSubmit(elements: List) { + // resets all elements in the array + for element in elements { + element.unmount() + } +} +``` +### Step 4: Collect data from elements + +When the form is ready to be submitted, call the `collect(options: Skyflow.CollectOptions? = CollectOptions(), callback: Skyflow.Callback)` method on the container object. The options parameter takes `Skyflow.CollectOptions` object. + +`Skyflow.CollectOptions` takes three optional fields +- `tokens`: indicates whether tokens for the collected data should be returned or not. Defaults to 'true' +- `additionalFields`: Non-PCI elements data to be inserted into the vault which should be in the `records` object format as described in the above [Inserting data into vault](#Inserting-data-into-the-vault) section. +- `upsert`: To support upsert operations, the table containing the data and a column marked as unique in that table. + +```kotlin +// NON-PCI fields object creation +val nonPCIRecords = JSONObject() +val recordsArray = JSONArray() + +val record = JSONObject() +record.put("table", "persons") + +val fields = JSONObject() +fields.put("gender", "MALE") + +record.put("fields", fields) +recordsArray.put(record) + +nonPCIRecords.put("records", recordsArray) + +//Upsert options +val upsertArray = JSONArray() + +val upsertColumn = JSONObject() +upsertColumn.put("table", "cards") +upsertColumn.put("column", "card_number") + +upsertArray.put(upsertColumn) + +val options = Skyflow.CollectOptions(tokens = true, additonalFields = nonPCIRecords, upsert = upsertArray) +val insertCallback = InsertCallback() //Custom callback - implementation of Skyflow.callback +container.collect(options, insertCallback) +``` +#### End to end example of collecting data with Composable Elements + +##### [Sample Code](https://github.com/skyflowapi/skyflow-android/blob/main/samples/skyvault/src/main/java/com/Skyflow/ComposableActivity.kt): +```kotlin +//Initialize skyflow configuration +val config = Skyflow.Configuration(vaultID = VAULT_ID, vaultURL = VAULT_URL, tokenProvider = demoTokenProvider) + +//Initialize skyflow client +val skyflowClient = Skyflow.init(config) + +//Create a ComposableContainer +val container = skyflowClient.container( + type = Skyflow.ContainerType.COMPOSABLE, + options = ContainerOptions(layout = arrayOf(1, 2)) +) + +//Initialize and set required options +val options = Skyflow.CollectElementOptions(required = true) + +//Create Skyflow.Styles with individual Skyflow.Style variants +val baseCardStyle = Skyflow.Style(borderColor = Color.TRANSPARENT) +val baseDateStyle = Skyflow.Style(borderColor = Color.TRANSPARENT, width = 300) +val baseCvvStyle = Skyflow.Style(borderColor = Color.TRANSPARENT, width = 200) +val completedStyle = Skyflow.Style(textColor = Color.TRANSPARENT) +val baseTextStyle = Skyflow.Style(textColor = Color.BLACK) +val focusTextStyle = Skyflow.Style(textColor = Color.RED) +val cardStyles = Skyflow.Styles(base = baseCardStyle, complete = completedStyle) +val dateStyles = Skyflow.Styles(base = baseDateStyle, complete = completedStyle) +val cvvStyles = Skyflow.Styles(base = baseCvvStyle, complete = completedStyle) +val labelStyles = Skyflow.Styles(base = baseTextStyle, focus = focusTextStyle) +val errorTextStyles = Skyflow.Styles(base = baseTextStyle) + +//Create a CollectElementInput +val cardNumber = Skyflow.CollectElementInput( + table = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER + inputStyles = cardStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "card number", + placeholder = "card number", +) + +val expDate = Skyflow.CollectElementInput( + table = "cards", + column = "expiryDate", + type = Skyflow.ElementType.EXPIRATION_DATE + inputStyles = dateStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "Expiry Date", + placeholder = "mm/yy", +) + +val cvv = Skyflow.CollectElementInput( + table = "cards", + column = "cvv", + type = Skyflow.ElementType.CVV + inputStyles = cvvStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "CVV", + placeholder = "***", +) + +//Create a CollectElementOptions instance +val options = Skyflow.CollectElementOptions(required = true) + +//Create a Composable Element from the Composable Container +val cardNumberElement = container.create(context = Context, cardNumber, options) +val expDateElement = container.create(context = Context, expDate, options) +val cvvElement = container.create(context = Context, cvv, options) + +//Fetch composable layout and add to main view +try { + val composableLayout = container.getComposableLayout() + parent.addView(composableLayout) +} catch (error: Exception) { + println(error) +} + +// Non-PCI fields data +val nonPCIRecords = JSONObject() +val recordsArray = JSONArray() + +val record = JSONObject() +record.put("table", "persons") + +val fields = JSONObject() +fields.put("gender", "MALE") + +record.put("fields", fields) +recordsArray.put(record) + +nonPCIRecords.put("records", recordsArray) + +//Upsert options +val upsertArray = JSONArray() + +val upsertColumn = JSONObject() +upsertColumn.put("table", "cards") +upsertColumn.put("column", "card_number") + +upsertArray.put(upsertColumn) + +//Initialize and set required options for insertion +val collectOptions = Skyflow.CollectOptions(tokens = true, additionalFields = nonPCIRecords, upsert = upsertArray) + +//Implement a custom Skyflow.Callback to be called on Insertion success/failure +class InsertCallback: Skyflow.Callback { + override fun onSuccess(responseBody: Any) { + print(responseBody) + } + override fun onFailure(_ error: Error) { + print(error) + } +} + +//Initialize InsertCallback which is an implementation of Skyflow.Callback interface +val insertCallback = InsertCallback() + +//Call collect method on CollectContainer +container.collect(options = collectOptions, callback = insertCallback) +``` +##### Sample Response : +```json +{ + "records": [ + { + "table": "cards", + "fields": { + "cardNumber": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", + "expiryDate": "d0369871-91e5-466f-e7e2-48e12c2bcbc2", + "cvv": "c7093186-466f-e7e2-91e5-48e12c2bcbc3", + } + }, + { + "table": "persons", + "fields": { + "gender": "12f670af-6c7d-4837-83fb-30365fbc0b1e", + } + } + ] +} +``` + +[For information on validations, see validations.](#validations) + +## Using Skyflow Composable Elements to update data + +Composable Elements combine multiple Skyflow Elements in a single row. The following steps create a composable element and securely update data through it. + +### Step 1: Create a composable container + +First create a **container** for the form elements using the `skyflowClient.container(type: Skyflow.ContainerType, options: Skyflow.ContainerOptions)` method as shown below: + +```kt +val containerOptions = ContainerOptions( + layout = arrayOf(1, 1, 2), // required + styles = Skyflow.Styles, // optional + errorTextStyles = Skyflow.Styles // optional +) +val container = skyflowClient.container( + type = ContainerType.COMPOSABLE, + options = containerOptions +) +``` + +### Step 2: Create Composable Elements + +Composable Elements use the following schema: + +```kt +val composableElementInput = Skyflow.CollectElementInput( + table: String, // optional, the table this data belongs to + column: String, // optional, the column into which this data should be updated + type: Skyflow.ElementType, // Skyflow.ElementType enum + inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element + labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element + errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + label: String, // optional label for the form element + placeholder: String, // optional placeholder for the form element + altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element + validations: ValidationSet, // optional set of validations for the input element + skyflowID: String // The skyflow_id of the record to be updated +) +``` + +The `table` and `column` fields indicate which table and column in the vault the Element corresponds to. + +**Note:** +- Use dot delimited strings to specify columns nested inside JSON fields (e.g. `address.street.line1`) + +Along with `CollectElementInput`, you can define other options in the `CollectElementOptions` object as described below: + +```kt +Skyflow.CollectElementOptions( + required: Boolean, // Indicates whether the field is marked as required. Defaults to 'false' + enableCardIcon: Boolean, // Indicates whether card icon should be enabled (only for CARD_NUMBER inputs) + format: String, // Format for the element + translation: HashMap // Indicates the allowed data type value for format. +) +``` + +Once the `Skyflow.CollectElementInput` and `Skyflow.CollectElementOptions` objects are defined, add to the container using the `create(context: Context, input: CollectElementInput, options: CollectElementOptions)` method as shown below: + +```kt +val element = container.create(context = this, input = composableElementInput, options = collectElementOptions) +``` + +### Step 3: Mount Elements to the Screen + +To specify where the Elements will be rendered on the screen, fetch composable layout using `container.getComposableLayout()` and add it to a layout in your app programmatically. + +```kt +try { + val composableLayout = container.getComposableLayout() + existingLayout.addView(composableLayout) +} catch (error: Exception) { + println(error) +} +``` + +The Skyflow Element is an implementation of the View so it can be used/mounted similarly. Alternatively, you can use the `unmount` method to reset any collect element to its initial state. + +```kt +fun clearFieldsOnSubmit(elements: List) { + // resets all elements in the array + for (element in elements) { + element.unmount() + } +} +``` + +### Step 4: Update data from Elements + +When you submit the form, call the `collect(options: Skyflow.CollectOptions? = null, callback: Skyflow.Callback)` method on the container object. + +The options parameter takes a `Skyflow.CollectOptions` object as shown below: + +- `tokens`: Whether or not tokens for the collected data are returned. Defaults to 'true' +- `additionalFields`: Non-PCI elements data to insert into the vault, specified in the records object format. +- `upsert`: To support upsert operations, the table containing the data and a column marked as unique in that table. + +```kt +// Non-PCI records with skyflowID for update +val nonPCIRecords = JSONObject().apply { + val recordsArray = JSONArray() + val record = JSONObject().apply { + put("table", "persons") + val fields = JSONObject().apply { + put("gender", "MALE") + put("skyflowID", "") // skyflowID for update + } + put("fields", fields) + } + recordsArray.put(record) + put("records", recordsArray) +} + +// Upsert options +val upsertOptions = JSONArray().apply { + val upsertColumn = JSONObject().apply { + put("table", "cards") + put("column", "card_number") + } + put(upsertColumn) +} + +// Send the non-PCI records as additionalFields of CollectOptions (optional) +// and apply upsert using `upsert` field of CollectOptions (optional) +val options = CollectOptions(tokens = true, additionalFields = nonPCIRecords, upsert = upsertOptions) + +// Custom callback - implementation of Skyflow.Callback +val insertCallback = InsertCallback() +container.collect(callback = insertCallback, options = options) +``` + +### End to end example of updating data with Composable Elements + +```kt +// Initialize skyflow configuration +val config = Configuration( + vaultID = "", + vaultURL = "", + tokenProvider = demoTokenProvider +) + +// Initialize skyflow client +val skyflowClient = init(config) + +// Create container options with layout +val containerOptions = ContainerOptions( + layout = arrayOf(1, 2), + styles = Styles(base = Style(borderColor = Color.GRAY)), + errorTextStyles = Styles(base = Style(textColor = Color.RED)) +) + +// Create a Composable Container +val container = skyflowClient.container( + type = ContainerType.COMPOSABLE, + options = containerOptions +) + +// Create Skyflow.Styles with individual Skyflow.Style variants +val padding = Padding(8, 8, 8, 8) +val baseStyle = Style(borderColor = Color.BLUE) +val baseTextStyle = Style(textColor = Color.BLACK) +val completeStyle = Style(borderColor = Color.GREEN) +val focusTextStyle = Style(textColor = Color.RED) +val inputStyles = Styles(base = baseStyle, complete = completeStyle) +val labelStyles = Styles(base = baseTextStyle, focus = focusTextStyle) +val errorTextStyles = Styles(base = baseTextStyle) + +// Create Composable Elements with skyflowID for update +val cardHolderNameElementInput = CollectElementInput( + table = "cards", + column = "cardholder_name", + type = SkyflowElementType.CARDHOLDER_NAME, + inputStyles = inputStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "Cardholder Name", + placeholder = "John Doe", + skyflowID = "431eaa6c-5c15-4513-aa15-29f50babe882" // skyflowID for update +) + +// Create an option to require the element +val requiredOption = CollectElementOptions(required = true) + +// Create a Composable Element from the Composable Container +val cardHolderNameElement = container.create( + context = this, + input = cardHolderNameElementInput, + options = requiredOption +) + +val cardNumberElementInput = CollectElementInput( + table = "cards", + column = "card_number", + type = SkyflowElementType.CARD_NUMBER, + inputStyles = inputStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "Card Number", + placeholder = "XXXX XXXX XXXX XXXX", + skyflowID = "431eaa6c-5c15-4513-aa15-29f50babe882" // same skyflowID - will be merged in single update +) + +val cardNumberElement = container.create( + context = this, + input = cardNumberElementInput, + options = requiredOption +) + +val cvvElementInput = CollectElementInput( + table = "cards", + column = "cvv", + type = SkyflowElementType.CVV, + inputStyles = inputStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "CVV", + placeholder = "CVV", + skyflowID = "431eaa6c-5c15-4513-aa15-29f50babe882" // same skyflowID - will be merged in single update +) + +val cvvElement = container.create( + context = this, + input = cvvElementInput, + options = requiredOption +) + +// Add composable layout to screen +val parent = findViewById(R.id.parent) +try { + val composableLayout = container.getComposableLayout() + parent.addView(composableLayout) +} catch (error: Exception) { + println(error) +} + +// Non-PCI records with skyflowID for update +val nonPCIRecords = JSONObject().apply { + val recordsArray = JSONArray() + val record = JSONObject().apply { + put("table", "persons") + val fields = JSONObject().apply { + put("gender", "MALE") + put("skyflowID", "77dc3caf-c452-49e1-8625-07219d7567bf") // skyflowID for update + } + put("fields", fields) + } + recordsArray.put(record) + put("records", recordsArray) +} + +// Upsert options +val upsertOptions = JSONArray().apply { + val upsertColumn = JSONObject().apply { + put("table", "cards") + put("column", "card_number") + } + put(upsertColumn) +} + +// Send the Non-PCI records as additionalFields of CollectOptions (optional) +// and apply upsert using optional field `upsert` of CollectOptions +val collectOptions = CollectOptions( + tokens = true, + additionalFields = nonPCIRecords, + upsert = upsertOptions +) + +// Implement a custom Skyflow.Callback to call on update success/failure +class InsertCallback : Callback { + override fun onSuccess(responseBody: Any) { + Log.d(TAG, "Update successful: $responseBody") + } + + override fun onFailure(exception: Any) { + Log.e(TAG, "Update failed: ${(exception as Exception).message}") + } +} + +// Initialize custom Skyflow.Callback +val insertCallback = InsertCallback() + +// Call collect method on CollectContainer +container.collect(callback = insertCallback, options = collectOptions) +``` + +### Sample Success Response: + +```json +{ + "records": [ + { + "table": "persons", + "fields": { + "gender": "12f670af-6c7d-4837-83fb-30365fbc0b1e", + "skyflow_id": "77dc3caf-c452-49e1-8625-07219d7567bf" + } + }, + { + "table": "cards", + "fields": { + "skyflow_id": "431eaa6c-5c15-4513-aa15-29f50babe882", + "card_number": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", + "cardholder_name": "131e70dc-6f76-4319-bdd3-96281e051051", + "cvv": "098834fe-de99-4fc8-abdf-88c18a28a2cf" + } + } + ] +} +``` + +### Sample Partial Error Response: + +```json +{ + "records": [ + { + "table": "cards", + "fields": { + "skyflow_id": "431eaa6c-5c15-4513-aa15-29f50babe882", + "card_number": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", + "cardholder_name": "131e70dc-6f76-4319-bdd3-96281e051051", + "cvv": "098834fe-de99-4fc8-abdf-88c18a28a2cf" + } + } + ], + "errors": [ + { + "error": { + "code": 400, + "description": "Update failed. skyflow_ids [77dc3caf-c452-49e1-8625-07219d7567bf] are invalid. Specify valid Skyflow IDs. - request-id: cb397-8521-42c2-870c-92dbeec", + "type": 400 + } + } + ] +} +``` + +## Event Listeners on Composable Elements +You can communicate with Skyflow Elements by listening to element events: + +```kotlin +element.on(eventName: Skyflow.EventName) { state -> + // handle function +} +``` + +The SDK supports four events: + +- `CHANGE`: Triggered when the Element's value changes. +- `READY`: Triggered when the Element is fully rendered. +- `FOCUS`: Triggered when the Element gains focus. +- `BLUR`: Triggered when the Element loses focus. + +The handler `(state: JSONObject) -> Unit` is a callback function you provide, that will be called when the event is fired with the state object as shown below. + +```kotlin +val state = { + "elementType": Skyflow.ElementType, + "isEmpty": Bool , + "isRequired": Bool, + "isFocused": Bool, + "isValid": Bool, + "value": String +} +``` + +`Note`: +values of SkyflowElements will be returned in element state object only when `env` is `DEV`, else it is empty string i.e, '', but in case of CARD_NUMBER type element when the `env` is `PROD` for all the card types except AMEX, it will return first eight digits, for AMEX it will return first six digits and rest all digits in masked format. + +#### Example Usage of Event Listener on Composable Elements + +```kotlin +// create skyflow client with loglevel:"DEBUG" +val config = Skyflow.Configuration( + vaultID = VAULT_ID, + vaultURL = VAULT_URL, + tokenProvider = demoTokenProvider, + options = Skyflow.Options(logLevel: Skyflow.LogLevel.DEBUG) +) + +val skyflowClient = Skyflow.init(config) + +val containerOptions = ContainerOptions( + layout = arrayOf(1, 1), + styles = Styles(base: Style(borderColor: UIColor.gray)), + errorTextStyles = Styles(base: Style(textColor: UIColor.red)) +) + +//Create a Composable Container. +val container = skyflowClient.container(type: Skyflow.ContainerType.COMPOSABLE, options: containerOptions) + +// Create a CollectElementInput +val cardNumberInput = Skyflow.CollectElementInput( + table = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER, +) + +val cardHolderNameInput = Skyflow.CollectElementInput( + table = "cards", + column = "cardHolderName", + type = Skyflow.ElementType.CARDHOLDER_NAME, +) + +val cardNumber = container.create(context = Context, input = cardNumberInput) +val cardHolderName = container.create(context = Context, input = cardHolderNameInput) + +try { + val composableLayout = container.getComposableLayout() + parent.addView(composableLayout) +} catch (error: Exception) { + println(error) +} + +// subscribing to CHANGE event, which gets triggered when element changes +cardNumber.on(eventName: Skyflow.EventName.CHANGE) { state -> + // Your implementation when Change event occurs + log.info("on change", state) +} + +cardHolderName.on(eventName: Skyflow.EventName.CHANGE) { state -> + // Your implementation when Change event occurs + log.info("on change", state) +} +``` + +#### Sample Element state object when `env` is `DEV` +```kotlin +{ + "elementType": Skyflow.ElementType.CARD_NUMBER, + "isEmpty": false, + "isRequired": false, + "isFocused": true, + "isValid": true, + "value": "4111111111111111" +} +{ + "elementType": Skyflow.ElementType.CARDHOLDER_NAME, + "isEmpty": false, + "isRequired": false, + "isFocused": true, + "isValid": true, + "value": "John" +} +``` +#### Sample Element state object when `env` is `PROD` +```kotlin +{ + "elementType": Skyflow.ElementType.CARD_NUMBER, + "isEmpty": false, + "isRequired": false, + "isFocused": true, + "isValid": true, + "value": "41111111XXXXXXXX" +} +{ + "elementType": Skyflow.ElementType.CARDHOLDER_NAME, + "isEmpty": false, + "isRequired": false, + "isFocused": true, + "isValid": true, + "value": "" +} +``` +## Update Composable Elements +You can update composable element properties with the `update` interface. + +The `update` interface takes the below object: +```kotlin +val updateElement = Skyflow.CollectElementInput( + table: String, // optional the table this data belongs to + column: String, // optional the column into which this data should be inserted + inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element + labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element + errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + label: String, // optional label for the form element + placeholder: String, // optional placeholder for the form element + altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element + validations: ValidationSet, // optional set of validations for the input element +) +``` +Only include the properties that you want to update for the specified composable element. + +Properties your provided when you created the element remain the same until you explicitly update them. + +`Notes`: +- You can't update the type property of an element. +- Upon calling the update method, if not passed, all Styles i.e. `inputStyles`, `labelStyles` and `errorTextStyles` will be overridden by default Styles. + +#### End to end example +```kotlin +// create skyflow client with loglevel:"DEBUG" +val config = Skyflow.Configuration( + vaultID = VAULT_ID, + vaultURL = VAULT_URL, + tokenProvider = demoTokenProvider, + options = Skyflow.Options(logLevel: Skyflow.LogLevel.DEBUG) +) + +val skyflowClient = Skyflow.init(config) + +val containerOptions = ContainerOptions( + layout = arrayOf(1, 1), + styles = Styles(base: Style(borderColor: UIColor.gray)), + errorTextStyles = Styles(base: Style(textColor: UIColor.red)) +) + +//Create a Composable Container. +val container = skyflowClient.container(type: Skyflow.ContainerType.COMPOSABLE, options: containerOptions) + +// Create a CollectElementInput +val cardNumberInput = Skyflow.CollectElementInput( + table = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER, +) + +val cardHolderNameInput = Skyflow.CollectElementInput( + table = "cards", + column = "cardHolderName", + type = Skyflow.ElementType.CARDHOLDER_NAME, +) + +val cardNumber = container.create(context = Context, input = cardNumberInput) +val cardHolderName = container.create(context = Context, input = cardHolderNameInput) + +try { + val composableLayout = container.getComposableLayout() + parent.addView(composableLayout) +} catch (error: Exception) { + println(error) +} + +// Update table, column, inputStyles properties on cardNumber. +cardNumber.update(update = CollectElementInput( + table = "cards", + column = "cardHolderName", + inputStyles = Skyflow.Styles(base: Style(borderColor: UIColor.red)) +)) + +val lengthRule = LengthMatchRule(minLength = 5, maxLength = 16, error = "Must be between 5 and 16 digits") + +// Update validations and placeholder property on cardHolderName. +cardHolderName.update(update = CollectElementInput( + placeholder = "cardHolderName", + validations = ValidationSet(rules = mutableListOf(lengthRule))) +) +``` + +## Event Listeners on Composable Container + +Currently, the SDK supports one event: +- `SUBMIT`: Triggered when the Enter key is pressed in any container element. + +The handler function `() -> Unit` is a callback function you provide that's called when the `SUBMIT` event fires. + +#### Example +```kotlin +// create skyflow client with loglevel:"DEBUG" +val config = Skyflow.Configuration( + vaultID = VAULT_ID, + vaultURL = VAULT_URL, + tokenProvider = demoTokenProvider, + options = Skyflow.Options(logLevel: Skyflow.LogLevel.DEBUG) +) + +val skyflowClient = Skyflow.init(config) + +val containerOptions = ContainerOptions( + layout = arrayOf(1), + styles = Styles(base: Style(borderColor: UIColor.gray)), + errorTextStyles = Styles(base: Style(textColor: UIColor.red)) +) + +//Create a Composable Container. +val container = skyflowClient.container(type: Skyflow.ContainerType.COMPOSABLE, options: containerOptions) + +// Create a CollectElementInput +val cardNumberInput = Skyflow.CollectElementInput( + table = "cards", + column = "cardNumber", + type = Skyflow.ElementType.CARD_NUMBER, +) + +val cardNumber = container.create(context = Context, input = cardNumberInput) + +try { + val composableLayout = container.getComposableLayout() + parent.addView(composableLayout) +} catch (error: Exception) { + println(error) +} + +//Call Submit event listener on container +container.on(EventName.SUBMIT) { + // Your implementation when Submit (enter) event occurs + log.info("on submit", "submit event triggerred") +} +``` + +--- +# Securely revealing data client-side +- [**Retrieving data from the vault**](#retrieving-data-from-the-vault) +- [**Using Skyflow Elements to reveal data**](#using-skyflow-elements-to-reveal-data) +- [**UI Error for Reveal Elements**](#ui-error-for-reveal-elements) +- [**Set token for Reveal Elements**](#set-token-for-reveal-elements) +- [**Set and clear altText for Reveal Elements**](#set-and-clear-alttext-for-reveal-elements) + +## Retrieving data from the vault +For non-PCI use-cases, retrieving data from the vault and revealing it in the mobile can be done either using the SkyflowID's or tokens as described below + +- ### Using tokens + To retrieve record data using tokens, use the `detokenize(records)` method. The `records` parameter takes a JSON object that contains tokens for record values to fetch: + + ```json5 + { + "records":[ + { + "token": "string", // token for the record to be fetched + "redaction": Skyflow.RedactionType // Optional. Redaction to apply for retrieved data. E.g. RedactionType.MASKED + } + ] + } + ``` + + Note: `redaction` defaults to [`RedactionType.PLAIN_TEXT`](#redaction-types). + + The following example code makes a detokenize call to reveal the masked value of a token: + ```kt + val getCallback = GetCallback() //Custom callback - implementation of Skyflow.Callback + + val records = JSONObject() + val recordsArray = JSONArray() + val recordObj = JSONObject() + recordObj.put("token", "45012507-f72b-4f5c-9bf9-86b133bae719") + recordObj.put("redaction", RedactionType.MASKED) + recordsArray.put(recordObj) + records.put("records", recordsArray) + + skyflowClient.detokenize(records = records, callback = getCallback) + ``` + The sample response: + ```json + { + "records": [ + { + "token": "131e70dc-6f76-4319-bdd3-96281e051051", + "value": "j***oe" + } + ] + } + ``` + +- ### Using Skyflow ID's or Unique Column Values + For retrieving data from the vault, use the `get(records: JSONObject, options: GetOptions? = GetOptions(), callback: Skyflow.Callback)` method. + + The `records` parameter takes a JSON object that contains an array of the records to fetch. Each object inside array should contain: + + - Either an array of Skyflow IDs to fetch + - Or a column name and an array of column values + + The second parameter, `options`, is a `GetOptions` object that retrieves tokens of Skyflow IDs. + + Notes: + - You can use either Skyflow IDs or unique values to retrieve records. You can't use both at the same time. + - GetOptions parameter is applicable only for retrieving tokens using Skyflow ID. + - You can't pass GetOptions along with the redaction type. + - `tokens` defaults to false. + + ```json5 + { + "records":[ + { + "ids": JSONArray(), // Array of SkyflowID's of the records to be fetched + "table": String, // name of table holding the above skyflow_id's + "redaction": Skyflow.RedactionType // redaction to be applied to retrieved data + }, + { + "table": String, // name of table from where records are to be fetched + "redaction": Skyflow.RedactionType, // redaction to be applied to retrieved data + "columnName": String, // a unique column name + "colunmnValues": JSONArray() // Array of Column Values of the records to be fetched + } + ] + } + ``` + + An example of get call to fetch records: + ```kotlin + val getCallback = GetCallback() //Custom callback - implementation of Skyflow.Callback + + val recordsArray = JSONArray() + + val record = JSONObject() + val skyflowIDs = JSONArray() + skyflowIDs.put("f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9") + skyflowIDs.put("da26de53-95d5-4bdb-99db-8d8c66a35ff9") + + record.put("ids", skyflowIDs) + record.put("table", "cards") + record.put("redaction", RedactionType.PLAIN_TEXT) + + val record1 = JSONObject() + val recordSkyflowIDs = JSONArray() + recordSkyflowIDs.put("invalid skyflow id") // invalid skyflow ID + + record1.put("ids", recordSkyflowIDs) + record1.put("table", "cards") + record1.put("redaction", RedactionType.PLAIN_TEXT) + + val record2 = JSONObject() + val columnValues = JSONArray() + columnValues.put("john.doe@gmail.com") + columnValues.put("jane.doe@gmail.com") + + record2.put("table", "customers") + record2.put("redaction", RedactionType.PLAIN_TEXT) + record2.put("columnName", "email") + record2.put("columnValues", columnValues) + + val record3 = JSONObject() + val columnValues1 = JSONArray() + columnValues1.put("invalid column value") // invalid column value + + record3.put("table", "customers") + record3.put("redaction", RedactionType.PLAIN_TEXT) + record3.put("columnName", "email") + record3.put("columnValues", columnValues1) + + recordsArray.put(record) + recordsArray.put(record1) + recordsArray.put(record2) + recordsArray.put(record3) + + val records = JSONObject() + records.put("records", recordsArray) + + skyflowClient.getById(records = records, GetOptions(), callback = getCallback) + ``` + + The sample response: + ```json + { + "records": [ + { + "fields": { + "card_number": "4111111111111111", + "expiry_date": "11/35", + "fullname": "myname", + "id": "f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9" + }, + "table": "cards" + }, + { + "fields": { + "card_number": "4111111111111111", + "expiry_date": "10/23", + "fullname": "sam", + "id": "da26de53-95d5-4bdb-99db-8d8c66a35ff9" + }, + "table": "cards" + }, + { + "fields": { + "card_number": "4111111111111111", + "email": "john@doe@gmail.com", + "name": "john", + "id": "f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9" + }, + "table": "customers" + }, + { + "fields": { + "card_number": "4111111111111111", + "email": "jane@doe@gmail.com", + "name": "jane", + "id": "f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9" + }, + "table": "customers" + } + ], + "errors": [ + { + "error": { + "code": "404", + "description": "No Records Found" + }, + "ids": ["invalid skyflow id"] + }, + { + "error": { + "code": "404", + "description": "No Records Found" + }, + "columnName": "customers", + "columnValues": ["invalid column value"] + } + ] + } + ``` + + An example of get call to fetch tokens: + ```kotlin + val getCallback = GetCallback() //Custom callback - implementation of Skyflow.Callback + + val recordsArray = JSONArray() + + val validRecord = JSONObject() + val validSkyflowIDs = JSONArray() + validSkyflowIDs.put("f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9") + validSkyflowIDs.put("da26de53-95d5-4bdb-99db-8d8c66a35ff9") + + validRecord.put("ids", validSkyflowIDs) + validRecord.put("table", "cards") + + val invalidRecord = JSONObject() + val invalidRecordSkyflowIDs = JSONArray() + invalidRecordSkyflowIDs.put("invalid skyflow id") // invalid skyflow ID + + invalidRecord.put("ids", invalidRecordSkyflowIDs) + invalidRecord.put("table", "cards") + + recordsArray.put(validRecord) + recordsArray.put(invalidRecord) + + val records = JSONObject() + records.put("records", recordsArray) + + skyflowClient.getById(records = records, GetOptions(true), callback = getCallback) + ``` + + The sample Response: + ```json + { + "records": [ + { + "fields": { + "card_number": "9802-3257-3113-0294", + "expiry_date": "45012507-f72b-4f5c-9bf9-86b133bae719", + "fullname": "131e2507-f72b-4f5c-9bf9-86b133bae719", + }, + "table": "cards" + }, + { + "fields": { + "card_number": "0294-3213-3157-9802", + "expiry_date": "131e2507-f72b-4f5c-9bf9-86b133bae719", + "fullname": "45012507-f72b-4f5c-9bf9-86b133bae719", + }, + "table": "cards" + } + ], + "errors": [ + { + "error": { + "code": "404", + "description": "No Records Found" + }, + "ids": ["invalid skyflow id"] + } + ] + } + ``` +### Redaction types + There are four enum values in Skyflow.RedactionType: + - `PLAIN_TEXT` + - `MASKED` + - `REDACTED` + - `DEFAULT` + + +## Using Skyflow Elements to reveal data +Skyflow Elements can be used to securely reveal data in an application without exposing your front end to the sensitive data. This is great for use-cases like card issuance where you may want to reveal the card number to a user without increasing your PCI compliance scope. +### Step 1: Create a container +To start, create a container using the `skyflowClient.container(Skyflow.ContainerType.REVEAL)` method as shown below. +```kt +val container = skyflowClient.container(type = Skyflow.ContainerType.REVEAL) +``` + +### Step 2: Create a reveal Element +Next, define a Skyflow Element to reveal data as shown below: +```kt +val revealElementInput = Skyflow.RevealElementInput( + token = "string", + redaction = Skyflow.RedactionType, // optional. Redaction to apply for retrieved data. E.g. RedactionType.MASKED + inputStyles = Skyflow.Styles(), //optional, styles to be applied to the element + labelStyles = Skyflow.Styles(), //optional, styles to be applied to the label of the reveal element + errorTextStyles = Skyflow.Styles(), //optional styles that will be applied to the errorText of the reveal element + label = "cardNumber" //optional, label for the element, + altText = "XXXX XXXX XXXX XXXX" //optional, string that is shown before reveal, will show token if altText is not provided + ) + +``` + +`Notes`: +- `token` is optional only if it is being used in invokeConnection() +- `redaction` defaults to [`RedactionType.PLAIN_TEXT`](#redaction-types) + +The `inputStyles` parameter accepts a styles object as described in the [previous section](#step-2-create-a-collect-element) for collecting data but the only state available for a reveal element is the base state. + +The `labelStyles` and `errorTextStyles` fields accept the above mentioned `Skyflow.Styles` object as described in the [previous section](#step-2-create-a-collect-element), the only state available for a reveal element is the base state. + +The `inputStyles`, `labelStyles` and `errorTextStyles` parameters accepts a styles object as described in the [previous section](#step-2-create-a-collect-element) for collecting data but only a single variant is available i.e. base. + +An example of a inputStyles object: + +```kt +var inputStyles = Skyflow.Styles(base = Skyflow.Style( + borderColor = Color.BLUE)) +``` + +An example of a labelStyles object: + +```kt +var labelStyles = Skyflow.Styles(base = + Skyflow.Style(font = 12)) +``` + +An example of a errorTextStyles object: + +```kt +var labelStyles = Skyflow.Styles(base = + Skyflow.Style(textColor = COLOR.RED)) +``` + +Along with `RevealElementInput`, you can define other options in the `RevealElementOptions` object as described below: +```kotlin +Skyflow.RevealElementOptions( + format: String, // Format for the element. + translation: HashMap // Indicates the allowed data type value for format + enableCopy: Boolean, // Indicates whether to enable the copy icon in reveal elements to copy text to clipboard. Defaults to 'false' +) +``` +- `format`: A string value that indicates how the element should display the value, including placeholder characters that map to keys `translation` If `translation` isn't specified, the `format` value is considered a string literal. + +- `translation`: A hashmap of key/value pairs, where the key is a character that appears in `format` and the value is a regex pattern of acceptable inputs for that character. Each key can only appear once. Defaults to `hashmapOf('X' to "[0-9]")`. + +`enableCopy`: Indicates whether to enable the copy icon in reveal elements to copy text to clipboard. + +Reveal Element Options examples: + +Example 1: +```kotlin +let element = container.create(input: revealElementInput) +Skyflow.RevealElementOptions( + format: "(XXX) XXX-XXXX", + translation: hashmapOf('X' to "[0-9]") +) +``` +Value from vault: "1234121234" + +Value displayed in element: "(123) 412-1234" + +Example 2: +```kotlin +Skyflow.RevealElementOptions( + format: "XXXX-XXXXXX-XXXXX", + translation: hashmapOf('X' to "[0-9]") +) +``` +Value from vault: "374200000000004" + +Value displayed in element: "3742-000000-00004" + +Once you've defined a `Skyflow.RevealElementInput` object and `Skyflow.RevealElementOptions`, you can use the `create()` method of the container to create the Element as shown below: + +```kotlin +let element = container.create(input: revealElementInput, options: Skyflow.RevealElementOptions(format: "XXXX-XXXXXX-XXXXX", +translation: hashmapOf('X' to "[0-9]") +)) +``` + +### Step 3: Mount Elements to the Screen + +Elements used for revealing data are mounted to the screen the same way as Elements used for collecting data. Refer to Step 3 of the [section above](#step-3-mount-elements-to-the-screen). + +### Step 4: Reveal data +When the sensitive data is ready to be retrieved and revealed, call the `reveal()` method on the container as shown below: +```kt +val revealCallback = RevealCallback() //Custom callback - implementation of Skyflow.Callback +container.reveal(callback = revealCallback) +``` + +### UI Error for Reveal Elements + +Helps to display custom error messages on the Skyflow Elements through the methods `setError` and `resetError` on the elements. + +`setError(error : String)` method is used to set the error text for the element, when this method is trigerred, all the current errors present on the element will be overridden with the custom error message passed. This error will be displayed on the element until `resetError()` is trigerred on the same element. + +`resetError()` method is used to clear the custom error message that is set using `setError`. + + +### Set token for Reveal Elements +The `setToken(value: String)` method can be used to set the token of the Reveal Element. If no altText is set, the set token will be displayed on the UI as well. If altText is set, then there will be no change in the UI but the token of the element will be internally updated. +### Set and Clear altText for Reveal Elements +The `setAltText(value: String)` method can be used to set the altText of the Reveal Element. This will cause the altText to be displayed in the UI regardless of whether the token or value is currently being displayed. +`clearAltText()` method can be used to clear the altText, this will cause the element to display the token or actual value of the element. If the element has no token, the element will be empty. + + +### End to end example of revealing data with Skyflow Elements +#### [Sample Code](https://github.com/skyflowapi/skyflow-android/blob/main/samples/skyvault/src/main/java/com/Skyflow/RevealActivity.kt): +```kt +//Initialize skyflow configuration +val config = Skyflow.Configuration(vaultId = , vaultURL = , tokenProvider = demoTokenProvider) + +//Initialize skyflow client +val skyflowClient = Skyflow.initialize(config) + +//Create a Reveal Container +val container = skyflowClient.container(type = Skyflow.ContainerType.REVEAL) + + +//Create Skyflow.Styles with individual Skyflow.Style variants +val baseStyle = Skyflow.Style(borderColor = Color.BLUE) +val baseTextStyle = Skyflow.Style(textColor = Color.BLACK) +val inputStyles = Skyflow.Styles(base = baseStyle) +val labelStyles = Skyflow.Styles(base = baseTextStyle) +val errorTextStyles = Skyflow.Styles(base = baseTextStyle) + +//Create Reveal Elements +val cardNumberInput = Skyflow.RevealElementInput( + token = "b63ec4e0-bbad-4e43-96e6-6bd50f483f75", + redaction = RedactionType.MASKED, + inputStyles = inputStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "cardnumber", + altText = "XXXX XXXX XXXX XXXX" +) + +val cardNumberElement = container.create(context = Context, input = cardNumberInput) + +val nameInput = Skyflow.RevealElementInput( + token = "89024714-6a26-4256-b9d4-55ad69aa4047", + redaction = RedactionType.DEFAULT, + inputStyles = inputStyles, + labelStyles = labelStyles, + errorTextStyles = errorTextStyles, + label = "fullname", + altText = "XXX" +) + +val nameElement = container.create(context = Context,input = nameInput) + +//set error to the element +nameElement.setError("custom error") + +//reset error to the element +nameElement.resetError() + +//Can interact with these objects as a normal UIView Object and add to View + + +//Implement a custom Skyflow.Callback to be called on Reveal success/failure +public class RevealCallback: Skyflow.Callback { + override fun onSuccess(responseBody: Any) { + print(responseBody) + } + override fun onFailure(exception: Exception) { + print(exception) + } +} + +//Initialize custom Skyflow.Callback +val revealCallback = RevealCallback() + +//Call reveal method on RevealContainer +container.reveal(callback = revealCallback) + +``` +The response below shows that some tokens assigned to the reveal elements get revealed successfully, while others fail and remain unrevealed. + + +#### Sample Response:Callback +```json +{ + "success": [ + { + "token": "b63ec4e0-bbad-4e43-96e6-6bd50f483f75" + } + ], + "errors": [ + { + "id": "89024714-6a26-4256-b9d4-55ad69aa4047", + "error": { + "code": 404, + "description": "Tokens not found for 89024714-6a26-4256-b9d4-55ad69aa4047" + } + } + ] +} +``` +## Limitation +Currently the skyflow collect elements and reveal elements can't be used in the XML layout definition, we have to add them to the views programatically. + + + diff --git a/Skyflow/build.gradle b/skyvault/build.gradle similarity index 90% rename from Skyflow/build.gradle rename to skyvault/build.gradle index 1a5ecefb..aa3e98c8 100644 --- a/Skyflow/build.gradle +++ b/skyvault/build.gradle @@ -8,7 +8,7 @@ ext { mGroupId = "com.skyflowapi.android" mArtifactId = "skyflow-android-sdk" mVersionCode = 1 - mVersionName = "1.27.0" + mVersionName = "1.27.0-dev.d837ad9" mLibraryName = "skyflow-android" mLibraryDescription = "Skyflow’s android SDK can be used to securely collect, tokenize, and display sensitive data in the mobile without exposing your front-end infrastructure to sensitive data." @@ -18,6 +18,16 @@ android { namespace "com.skyflow_android" compileSdk 35 + // Shared, contract-agnostic sources live in /core and are compiled INTO this module + // (see docs/sdk-split-plan.md). This is what lets core `internal` stay visible to the + // contract layer but hidden from apps, without a separate :core module. + sourceSets { + main { + kotlin.srcDirs += "$rootDir/common/src/main/kotlin" + res.srcDirs += "$rootDir/common/src/main/res" + } + } + defaultConfig { minSdk 21 targetSdk 35 diff --git a/skyvault/consumer-rules.pro b/skyvault/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/skyvault/proguard-rules.pro b/skyvault/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/skyvault/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Skyflow/src/androidTest/java/com/Skyflow/ExampleInstrumentedTest.kt b/skyvault/src/androidTest/java/com/Skyflow/ExampleInstrumentedTest.kt similarity index 100% rename from Skyflow/src/androidTest/java/com/Skyflow/ExampleInstrumentedTest.kt rename to skyvault/src/androidTest/java/com/Skyflow/ExampleInstrumentedTest.kt diff --git a/Skyflow/src/androidTest/java/com/Skyflow/GetByIdsTest.kt b/skyvault/src/androidTest/java/com/Skyflow/GetByIdsTest.kt similarity index 100% rename from Skyflow/src/androidTest/java/com/Skyflow/GetByIdsTest.kt rename to skyvault/src/androidTest/java/com/Skyflow/GetByIdsTest.kt diff --git a/Skyflow/src/androidTest/java/com/Skyflow/GetTest.kt b/skyvault/src/androidTest/java/com/Skyflow/GetTest.kt similarity index 100% rename from Skyflow/src/androidTest/java/com/Skyflow/GetTest.kt rename to skyvault/src/androidTest/java/com/Skyflow/GetTest.kt diff --git a/Skyflow/src/androidTest/java/com/Skyflow/InsertTest.kt b/skyvault/src/androidTest/java/com/Skyflow/InsertTest.kt similarity index 100% rename from Skyflow/src/androidTest/java/com/Skyflow/InsertTest.kt rename to skyvault/src/androidTest/java/com/Skyflow/InsertTest.kt diff --git a/skyvault/src/main/AndroidManifest.xml b/skyvault/src/main/AndroidManifest.xml new file mode 100644 index 00000000..695c502d --- /dev/null +++ b/skyvault/src/main/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Skyflow/src/main/kotlin/Skyflow/Client.kt b/skyvault/src/main/kotlin/Skyflow/client/Client.kt similarity index 89% rename from Skyflow/src/main/kotlin/Skyflow/Client.kt rename to skyvault/src/main/kotlin/Skyflow/client/Client.kt index d465822d..53448804 100644 --- a/Skyflow/src/main/kotlin/Skyflow/Client.kt +++ b/skyvault/src/main/kotlin/Skyflow/client/Client.kt @@ -6,25 +6,30 @@ import Skyflow.get.GetOptions import Skyflow.reveal.GetByIdRecord import Skyflow.soap.SoapConnectionConfig import Skyflow.utils.Utils -import android.content.Context -import com.Skyflow.core.container.ContainerProtocol import org.json.JSONArray import org.json.JSONObject import org.xml.sax.InputSource import java.io.StringReader import javax.xml.parsers.DocumentBuilderFactory import kotlin.Exception -import kotlin.reflect.KClass +/** + * Legacy (v1 / privacyDB) client. Extends the shared [BaseSkyflowClient] (which provides + * `configuration`, `elementMap`, and the `container(...)` factories) and adds the v1 client + * methods and the v1 api client. The FlowVault SDK's `Client` extends the same base but exposes + * no standalone client methods. See docs/sdk-split-plan.md. + */ class Client internal constructor( - val configuration: Configuration, -){ - internal val tag = Client::class.qualifiedName + configuration: Configuration, +) : BaseSkyflowClient(configuration) { + + // Covariantly narrow to the v1 `Configuration` so the getter descriptor stays + // `getConfiguration()LSkyflow/Configuration;` — binary-compatible with 1.27.0. + override val configuration: Configuration get() = super.configuration as Configuration internal val apiClient = APIClient(configuration.vaultID, configuration.vaultURL, configuration.tokenProvider,configuration.options.logLevel) - internal val elementMap = HashMap() fun insert(records: JSONObject, options: InsertOptions? = InsertOptions(), callback: Callback){ try { Utils.checkVaultDetails(configuration) @@ -222,34 +227,6 @@ class Client internal constructor( return result } - fun container(type: KClass) : Container{ - if(type == ContainerType.COLLECT){ - Logger.info(tag, Messages.COLLECT_CONTAINER_CREATED.getMessage(), configuration.options.logLevel) - } - else if(type == ContainerType.REVEAL){ - Logger.info(tag, Messages.REVEAL_CONTAINER_CREATED.getMessage(), configuration.options.logLevel) - } - return Container(configuration,this) - } - - fun container( - type: KClass, - context: Context, - options: ContainerOptions - ): Container { - when (type) { - ContainerType.COMPOSABLE -> { - Logger.info( - tag, - Messages.COMPOSABLE_CONTAINER_CREATED.getMessage(), - configuration.options.logLevel - ) - } - else -> container(type) - } - return Container(configuration, this, context, options) - } - inner class loggingCallback( private val clientCallback: Callback, private val successMessage: String, @@ -263,6 +240,3 @@ class Client internal constructor( } } } - - - diff --git a/skyvault/src/main/kotlin/Skyflow/client/Init.kt b/skyvault/src/main/kotlin/Skyflow/client/Init.kt new file mode 100644 index 00000000..41579aa1 --- /dev/null +++ b/skyvault/src/main/kotlin/Skyflow/client/Init.kt @@ -0,0 +1,12 @@ +package Skyflow + +import com.skyflow_android.BuildConfig + +/** + * Legacy (v1) entry point. Public signature is unchanged from 1.27.0; the shared body lives in the + * common generic [baseInit] helper. Supplies the concrete [Client] and this module's BuildConfig. + */ +fun init(configuration: Configuration): Client = + baseInit(BuildConfig.SDK_VERSION, configuration.options.logLevel) { + Client(configuration) + } diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/client/CollectAPICallback.kt b/skyvault/src/main/kotlin/Skyflow/collect/client/CollectAPICallback.kt similarity index 97% rename from Skyflow/src/main/kotlin/Skyflow/collect/client/CollectAPICallback.kt rename to skyvault/src/main/kotlin/Skyflow/collect/client/CollectAPICallback.kt index 7c80d6b5..97e428ff 100644 --- a/Skyflow/src/main/kotlin/Skyflow/collect/client/CollectAPICallback.kt +++ b/skyvault/src/main/kotlin/Skyflow/collect/client/CollectAPICallback.kt @@ -7,6 +7,7 @@ import Skyflow.core.Logger import Skyflow.core.Messages import Skyflow.core.getMessage import Skyflow.utils.Utils +import Skyflow.utils.LegacyUtils import okhttp3.* import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody @@ -37,7 +38,7 @@ internal class CollectAPICallback( fun buildRequest(responseBody: Any): Request { val url = apiClient.vaultURL + apiClient.vaultId Logger.info(tag, Messages.VALIDATE_RECORDS.getMessage(), apiClient.logLevel) - val jsonBody: JSONObject = Utils.constructBatchRequestBody(records, options, logLevel) + val jsonBody: JSONObject = LegacyUtils.constructBatchRequestBody(records, options, logLevel) val body: RequestBody = jsonBody.toString().toRequestBody("application/json".toMediaTypeOrNull()) diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/client/CollectRequestBody.kt b/skyvault/src/main/kotlin/Skyflow/collect/client/CollectRequestBody.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/collect/client/CollectRequestBody.kt rename to skyvault/src/main/kotlin/Skyflow/collect/client/CollectRequestBody.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/client/MixedAPICallback.kt b/skyvault/src/main/kotlin/Skyflow/collect/client/MixedAPICallback.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/collect/client/MixedAPICallback.kt rename to skyvault/src/main/kotlin/Skyflow/collect/client/MixedAPICallback.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/client/UpdateAPICallback.kt b/skyvault/src/main/kotlin/Skyflow/collect/client/UpdateAPICallback.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/collect/client/UpdateAPICallback.kt rename to skyvault/src/main/kotlin/Skyflow/collect/client/UpdateAPICallback.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/collect/client/UpdateRequestRecord.kt b/skyvault/src/main/kotlin/Skyflow/collect/client/UpdateRequestRecord.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/collect/client/UpdateRequestRecord.kt rename to skyvault/src/main/kotlin/Skyflow/collect/client/UpdateRequestRecord.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/composable/ComposableContainer.kt b/skyvault/src/main/kotlin/Skyflow/composable/ComposableContainer.kt similarity index 95% rename from Skyflow/src/main/kotlin/Skyflow/composable/ComposableContainer.kt rename to skyvault/src/main/kotlin/Skyflow/composable/ComposableContainer.kt index 35d2b102..e7f407c1 100644 --- a/Skyflow/src/main/kotlin/Skyflow/composable/ComposableContainer.kt +++ b/skyvault/src/main/kotlin/Skyflow/composable/ComposableContainer.kt @@ -20,9 +20,7 @@ import com.Skyflow.core.container.ContainerProtocol import org.json.JSONObject import java.util.* -class ComposableContainer : ContainerProtocol { -} - +// ComposableContainer (marker class) lives in core; these are the legacy (v1) container operations. val tag = ComposableContainer::class.qualifiedName fun Container.create( @@ -130,7 +128,7 @@ private fun Container.validateElement( ) } when { - element.collectInput.table.equals(null) -> { + element.collectInput.tableName.equals(null) -> { throw SkyflowError( SkyflowErrorCode.MISSING_TABLE_IN_ELEMENT, tag, @@ -146,7 +144,7 @@ private fun Container.validateElement( arrayOf(element.fieldType.toString()) ) } - element.collectInput.table!!.isEmpty() -> { + element.collectInput.tableName!!.isEmpty() -> { throw SkyflowError( SkyflowErrorCode.ELEMENT_EMPTY_TABLE_NAME, tag, @@ -198,11 +196,11 @@ private fun Container.post(callback: Callback, options: Col } val insertOptions = InsertOptions(options?.token ?: true, options?.upsert) - this.client.apiClient.postWithUpdate(insertRecordsJson, updateRecords, callback, insertOptions) + (this.client as Client).apiClient.postWithUpdate(insertRecordsJson, updateRecords, callback, insertOptions) } else if (hasUpdateRecords) { // Only update records val insertOptions = InsertOptions(options?.token ?: true, options?.upsert) - this.client.apiClient.postWithUpdate(null, updateRecords, callback, insertOptions) + (this.client as Client).apiClient.postWithUpdate(null, updateRecords, callback, insertOptions) } else { // Only insert records val records = CollectRequestBody.createRequestBody( @@ -211,7 +209,7 @@ private fun Container.post(callback: Callback, options: Col configuration.options.logLevel ) val insertOptions = InsertOptions(options?.token ?: true, options?.upsert) - this.client.apiClient.post(JSONObject(records), callback, insertOptions) + (this.client as Client).apiClient.post(JSONObject(records), callback, insertOptions) } } diff --git a/skyvault/src/main/kotlin/Skyflow/config/Configuration.kt b/skyvault/src/main/kotlin/Skyflow/config/Configuration.kt new file mode 100644 index 00000000..d995da74 --- /dev/null +++ b/skyvault/src/main/kotlin/Skyflow/config/Configuration.kt @@ -0,0 +1,22 @@ +package Skyflow + +/** + * Legacy (v1) configuration. Extends the neutral [BaseConfiguration] and preserves the v1 + * behavior of appending the `/v1/vaults/` path to the vault URL, exactly as before the split + * (so the observable `vaultURL` value is byte-identical to 1.27.0). The FlowVault SDK's + * `Configuration` extends the same base with no such suffix. + */ +class Configuration( + vaultID: String = "", + vaultURL: String = "", + tokenProvider: TokenProvider, + options: Options = Options(), +) : BaseConfiguration(vaultID, vaultURL, tokenProvider, options) { + init { + if (this.vaultURL.endsWith("/")) { + this.vaultURL += "v1/vaults/" + } else { + this.vaultURL += "/v1/vaults/" + } + } +} diff --git a/Skyflow/src/main/kotlin/Skyflow/ConnectionConfig.kt b/skyvault/src/main/kotlin/Skyflow/connection/ConnectionConfig.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/ConnectionConfig.kt rename to skyvault/src/main/kotlin/Skyflow/connection/ConnectionConfig.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/ContentType.kt b/skyvault/src/main/kotlin/Skyflow/connection/ContentType.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/ContentType.kt rename to skyvault/src/main/kotlin/Skyflow/connection/ContentType.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/RequestMethod.kt b/skyvault/src/main/kotlin/Skyflow/connection/RequestMethod.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/RequestMethod.kt rename to skyvault/src/main/kotlin/Skyflow/connection/RequestMethod.kt diff --git a/skyvault/src/main/kotlin/Skyflow/container/CollectContainer.kt b/skyvault/src/main/kotlin/Skyflow/container/CollectContainer.kt new file mode 100644 index 00000000..e003eaf3 --- /dev/null +++ b/skyvault/src/main/kotlin/Skyflow/container/CollectContainer.kt @@ -0,0 +1,78 @@ +package Skyflow + +import Skyflow.collect.client.CollectRequestBody +import Skyflow.core.Logger +import Skyflow.core.Messages +import Skyflow.core.getMessage +import Skyflow.utils.Utils +import android.content.Context +import com.Skyflow.core.container.ContainerProtocol +import org.json.JSONObject +import java.util.* + +// CollectContainer (marker class) lives in core; these are the legacy (v1) container operations. +private val tag = CollectContainer::class.qualifiedName + +// Public v1 signature unchanged; the shared body lives in common (createElement). +fun Container.create( + context: Context, + input: CollectElementInput, + options: CollectElementOptions = CollectElementOptions() +): TextField = createElement(context, input, options) + +fun Container.collect(callback: Callback, options: CollectOptions? = CollectOptions()){ + try { + Utils.checkVaultDetails(client.configuration) + Logger.info(tag, Messages.VALIDATE_COLLECT_RECORDS.getMessage(), configuration.options.logLevel) + validateElements() + post(callback,options) + } + catch (e:Exception) + { + callback.onFailure(Utils.constructErrorResponse(e)) + } +} +// validateElements / validateElement moved to common (BaseCollectContainer.kt) — shared with v2. + +internal fun Container.post(callback:Callback,options: CollectOptions?) +{ + // Separate insert and update elements/records + val (insertElements, insertAdditionalFields, updateRecords) = Skyflow.collect.client.CollectRequestBody.separateInsertAndUpdateRecords( + this.collectElements, + options?.additionalFields, + configuration.options.logLevel + ) + + val hasInsertData = insertElements.isNotEmpty() || insertAdditionalFields != null + val hasUpdateRecords = updateRecords.isNotEmpty() + + if (hasInsertData && hasUpdateRecords) { + // Mixed case: both insert and update + val insertRecordsJson = if (insertElements.isNotEmpty()) { + JSONObject(Skyflow.collect.client.CollectRequestBody.createRequestBody( + insertElements, + insertAdditionalFields, + configuration.options.logLevel + )) + } else { + insertAdditionalFields + } + + val insertOptions = InsertOptions(options?.token ?: true, options?.upsert) + (this.client as Client).apiClient.postWithUpdate(insertRecordsJson, updateRecords, callback, insertOptions) + } else if (hasUpdateRecords) { + // Only update records + val insertOptions = InsertOptions(options?.token ?: true, options?.upsert) + (this.client as Client).apiClient.postWithUpdate(null, updateRecords, callback, insertOptions) + } else { + // Only insert records + val records = Skyflow.collect.client.CollectRequestBody.createRequestBody( + this.collectElements, + insertAdditionalFields, + configuration.options.logLevel + ) + val insertOptions = InsertOptions(options?.token ?: true, options?.upsert) + (this.client as Client).apiClient.post(JSONObject(records), callback, insertOptions) + } +} + diff --git a/skyvault/src/main/kotlin/Skyflow/container/RevealContainer.kt b/skyvault/src/main/kotlin/Skyflow/container/RevealContainer.kt new file mode 100644 index 00000000..315e22a0 --- /dev/null +++ b/skyvault/src/main/kotlin/Skyflow/container/RevealContainer.kt @@ -0,0 +1,53 @@ +package Skyflow + +import Skyflow.core.Logger +import Skyflow.core.Messages +import Skyflow.core.getMessage +import android.content.Context +import com.Skyflow.core.container.ContainerProtocol +import Skyflow.reveal.RevealRequestBody +import Skyflow.reveal.RevealValueCallback +import Skyflow.utils.Utils +import Skyflow.utils.Utils.Companion.checkIfElementsMounted +import java.lang.Exception +import java.util.* + +// RevealContainer (marker class) lives in core; these are the legacy (v1) container operations. +private val tag = RevealContainer::class.qualifiedName + +// Public v1 signature unchanged; the shared body lives in common (createLabel). +fun Container.create( + context: Context, + input: RevealElementInput, + options: RevealElementOptions = RevealElementOptions() +): Label = createLabel(context, input, options) + +fun Container.reveal( + callback: Callback, + options: RevealOptions? = RevealOptions() +) { + try { + Utils.checkVaultDetails(client.configuration) + validateElements() + Logger.info( + tag, + Messages.VALIDATE_REVEAL_RECORDS.getMessage(), + configuration.options.logLevel + ) + get(callback, options) + } catch (e: Exception) { + callback.onFailure(Utils.constructError(e)) + } +} + +// validateElements moved to common (BaseRevealContainer.kt) — shared with v2. + +internal fun Container.get(callback: Callback, options: RevealOptions?) { + val revealValueCallback = RevealValueCallback( + callback, + this.revealElements, + configuration.options.logLevel + ) + val records = RevealRequestBody.createRequestBody(this.revealElements) + (this.client as Client).apiClient.get(records, revealValueCallback) +} \ No newline at end of file diff --git a/Skyflow/src/main/kotlin/Skyflow/core/APIClient.kt b/skyvault/src/main/kotlin/Skyflow/core/APIClient.kt similarity index 64% rename from Skyflow/src/main/kotlin/Skyflow/core/APIClient.kt rename to skyvault/src/main/kotlin/Skyflow/core/APIClient.kt index f3b1e464..53bf9b36 100644 --- a/Skyflow/src/main/kotlin/Skyflow/core/APIClient.kt +++ b/skyvault/src/main/kotlin/Skyflow/core/APIClient.kt @@ -13,94 +13,27 @@ import Skyflow.soap.SoapApiCallback import Skyflow.soap.SoapConnectionConfig import Skyflow.soap.SoapValueCallback import Skyflow.utils.Utils +import Skyflow.utils.LegacyUtils import org.json.JSONArray import org.json.JSONObject import java.io.UnsupportedEncodingException import java.nio.charset.Charset import java.util.* -object JWTUtils { - @Throws(java.lang.Exception::class) - fun decoded(JWTEncoded: String): JSONObject { - return try { - val split = JWTEncoded.split(".").toTypedArray() - JSONObject(getJson(split[1])) - } catch (e: UnsupportedEncodingException) { - println(e.toString()) - JSONObject() - } - } - - fun isExpired(JWTEncoded: String): Boolean { - val expireTime = decoded(JWTEncoded).getString("exp") - val cal = Calendar.getInstance() - val currentTime = ((cal.timeInMillis / 1000)).toString() - return currentTime > expireTime - } - - @Throws(UnsupportedEncodingException::class) - private fun getJson(strEncoded: String): String { - val decodedBytes: ByteArray = Base64.decode(strEncoded, Base64.URL_SAFE) - return String(decodedBytes, Charset.forName("UTF-8")) - } -} +// JWTUtils moved to common (Skyflow.core.JWTUtils) — shared by the legacy and FlowVault API clients. internal class APIClient( - val vaultId: String, - val vaultURL: String, - private val tokenProvider: TokenProvider, - val logLevel: LogLevel, - private var token: String = "" -) { - private val tag = APIClient::class.qualifiedName - private fun isValidToken(token: String?): Boolean { - return if (token != "") { - !JWTUtils.isExpired(token!!) - } else { - false - } - } - - fun getAccessToken(callback: Callback) { - try { - if (!isValidToken(token)) { - Logger.info(tag, Messages.RETRIEVING_BEARER_TOKEN.getMessage(), logLevel) - tokenProvider.getBearerToken(object : Callback { - override fun onSuccess(responseBody: Any) { - Logger.info(tag, Messages.BEARER_TOKEN_RECEIVED.getMessage(), logLevel) - if (!isValidToken(responseBody.toString())) { - val error = - SkyflowError(SkyflowErrorCode.INVALID_BEARER_TOKEN, tag, logLevel) - callback.onFailure(error) - } else { - token = "Bearer $responseBody" - callback.onSuccess(token) - } - } - - override fun onFailure(exception: Any) { - Logger.error( - tag, - Messages.RETRIEVING_BEARER_TOKEN_FAILED.getMessage(), - logLevel - ) - val error = - SkyflowError(SkyflowErrorCode.BEARER_TOKEN_REJECTED, tag, logLevel) - callback.onFailure(error) - } - }) - } else { - callback.onSuccess(token) - } - } catch (e: Exception) { - val error = SkyflowError(SkyflowErrorCode.INVALID_BEARER_TOKEN, tag, logLevel) - callback.onFailure(error) - } - } + vaultId: String, + vaultURL: String, + tokenProvider: TokenProvider, + logLevel: LogLevel, + token: String = "" +) : BaseApiClient(vaultId, vaultURL, tokenProvider, logLevel, token) { + // Bearer-token lifecycle (isValidToken / getAccessToken) is inherited from BaseApiClient. fun post(records: JSONObject, callback: Callback, options: InsertOptions) { try { - val finalRecords = Utils.constructBatchRequestBody(records, options, logLevel) + val finalRecords = LegacyUtils.constructBatchRequestBody(records, options, logLevel) val collectApiCallback = CollectAPICallback(this, records, callback, options, logLevel) this.getAccessToken(collectApiCallback) } catch (e: Exception) { @@ -155,8 +88,8 @@ internal class APIClient( fun get(records: JSONObject, options: GetOptions? = GetOptions(false), callback: Callback) { try { - Utils.validateGetInputAndOptions(records, options, logLevel) - val requestBody = Utils.constructRequestBodyForGet(records) + LegacyUtils.validateGetInputAndOptions(records, options, logLevel) + val requestBody = LegacyUtils.constructRequestBodyForGet(records) val getAPICallback = GetAPICallback(callback, this, requestBody, options) this.getAccessToken(getAPICallback) } catch (e: Exception) { diff --git a/Skyflow/src/main/kotlin/Skyflow/core/ConnectionApiCallback.kt b/skyvault/src/main/kotlin/Skyflow/core/ConnectionApiCallback.kt similarity index 99% rename from Skyflow/src/main/kotlin/Skyflow/core/ConnectionApiCallback.kt rename to skyvault/src/main/kotlin/Skyflow/core/ConnectionApiCallback.kt index 8481263a..93062ccf 100644 --- a/Skyflow/src/main/kotlin/Skyflow/core/ConnectionApiCallback.kt +++ b/skyvault/src/main/kotlin/Skyflow/core/ConnectionApiCallback.kt @@ -1,4 +1,5 @@ package Skyflow.core +import Skyflow.utils.LegacyUtils import Skyflow.* import Skyflow.Callback @@ -165,7 +166,7 @@ internal class ConnectionApiCallback( if(!(key.equals("content-type") && it.value.equals(ContentType.FORMDATA.type))) request.addHeader(key,it.value) } - val requestBuild = request.post(Utils.getRequestbodyForConnection(requestBody,getContentType())).build() + val requestBuild = request.post(LegacyUtils.getRequestbodyForConnection(requestBody,getContentType())).build() return requestBuild } fun sendRequest(requestBuild: Request) { //send request to Connection diff --git a/skyvault/src/main/kotlin/Skyflow/element/CollectElementInput.kt b/skyvault/src/main/kotlin/Skyflow/element/CollectElementInput.kt new file mode 100644 index 00000000..6ef55241 --- /dev/null +++ b/skyvault/src/main/kotlin/Skyflow/element/CollectElementInput.kt @@ -0,0 +1,64 @@ +package Skyflow + +import com.Skyflow.collect.elements.validations.ValidationSet + +/** + * Legacy (v1) collect-element input. Extends the neutral [BaseCollectElementInput] and keeps the + * v1 public constructor param names (`table`, `skyflowID`), mapping them onto the neutral + * `tableName` / `skyflowId` storage that core reads. Constructor shapes are byte-identical to + * 1.27.0 (primary without `type` for the update interface; secondary with `type` + `altText`). + */ +class CollectElementInput : BaseCollectElementInput { + constructor( + table: String? = null, + column: String? = null, + inputStyles: Styles = Styles(), + labelStyles: Styles = Styles(), + errorTextStyles: Styles = Styles(), + label: String = "", + placeholder: String = "", + validations: ValidationSet = ValidationSet(), + skyflowID: String? = null + ) : super() { + this.tableName = table + this.column = column + this.inputStyles = inputStyles + this.labelStyles = labelStyles + this.errorTextStyles = errorTextStyles + this.label = label + this.placeholder = placeholder + this.validations = validations + this.skyflowId = skyflowID + } + + constructor( + table: String? = null, + column: String? = null, + type: SkyflowElementType, + inputStyles: Styles = Styles(), + labelStyles: Styles = Styles(), + errorTextStyles: Styles = Styles(), + label: String = "", + placeholder: String = "", + altText: String = "", + validations: ValidationSet = ValidationSet(), + skyflowID: String? = null + ) : this( + table, + column, + inputStyles, + labelStyles, + errorTextStyles, + label, + placeholder, + validations, + skyflowID + ) { + this.type = type + this.altText = altText + } + + // v1 read aliases onto the neutral base storage (preserve the 1.27.0 internal read surface). + internal val table: String? get() = tableName + internal val skyflowID: String? get() = skyflowId +} diff --git a/Skyflow/src/main/kotlin/Skyflow/CollectElementOptions.kt b/skyvault/src/main/kotlin/Skyflow/element/CollectElementOptions.kt similarity index 99% rename from Skyflow/src/main/kotlin/Skyflow/CollectElementOptions.kt rename to skyvault/src/main/kotlin/Skyflow/element/CollectElementOptions.kt index 52171773..78903559 100644 --- a/Skyflow/src/main/kotlin/Skyflow/CollectElementOptions.kt +++ b/skyvault/src/main/kotlin/Skyflow/element/CollectElementOptions.kt @@ -27,4 +27,4 @@ class CollectElementOptions( } } } -} \ No newline at end of file +} diff --git a/skyvault/src/main/kotlin/Skyflow/element/RevealElementInput.kt b/skyvault/src/main/kotlin/Skyflow/element/RevealElementInput.kt new file mode 100644 index 00000000..47789a83 --- /dev/null +++ b/skyvault/src/main/kotlin/Skyflow/element/RevealElementInput.kt @@ -0,0 +1,25 @@ +package Skyflow + +/** + * Legacy (v1) reveal-element input. Extends the neutral [BaseRevealElementInput] and adds the v1 + * per-token `redaction` field. The public constructor is byte-identical to 1.27.0 (redaction is the + * 2nd param, default PLAIN_TEXT). FlowVault's RevealElementInput has no `redaction`. + */ +class RevealElementInput( + token: String? = null, + internal var redaction: RedactionType? = RedactionType.PLAIN_TEXT, + inputStyles: Styles = Styles(), + labelStyles: Styles = Styles(), + errorTextStyles: Styles = Styles(), + label: String = "", + altText: String = "" +) : BaseRevealElementInput() { + init { + this.token = token + this.inputStyles = inputStyles + this.labelStyles = labelStyles + this.errorTextStyles = errorTextStyles + this.label = label + this.altText = altText + } +} diff --git a/Skyflow/src/main/kotlin/Skyflow/SkyflowError.kt b/skyvault/src/main/kotlin/Skyflow/error/SkyflowError.kt similarity index 67% rename from Skyflow/src/main/kotlin/Skyflow/SkyflowError.kt rename to skyvault/src/main/kotlin/Skyflow/error/SkyflowError.kt index 2c763cfd..1682f709 100644 --- a/Skyflow/src/main/kotlin/Skyflow/SkyflowError.kt +++ b/skyvault/src/main/kotlin/Skyflow/error/SkyflowError.kt @@ -3,8 +3,15 @@ package Skyflow import Skyflow.core.Logger import Skyflow.utils.Utils - - +/** + * v1 public error type — the exception thrown by the legacy SDK and caught by consumers. + * + * This is byte-for-byte the `SkyflowError` class shipped in 1.27.0, restored here so the legacy + * artifact keeps the exact JVM class `Skyflow.SkyflowError` — preserving backward compatibility for + * Kotlin, Java, and already-compiled (binary) consumers. `common` throws this via the neutral name + * `SkyflowInternalError` (see SkyflowInternalError.kt in this module, a typealias onto this class); + * in the FlowVault SDK that name is instead a separate internal exception class. + */ class SkyflowError(val skyflowErrorCode: SkyflowErrorCode = SkyflowErrorCode.UNKNOWN_ERROR, val tag : String? = "", logLevel: LogLevel? = null, params: Array = arrayOf()) : Exception(skyflowErrorCode.getMessage()) { override var message = "" diff --git a/skyvault/src/main/kotlin/Skyflow/error/SkyflowInternalError.kt b/skyvault/src/main/kotlin/Skyflow/error/SkyflowInternalError.kt new file mode 100644 index 00000000..3f037449 --- /dev/null +++ b/skyvault/src/main/kotlin/Skyflow/error/SkyflowInternalError.kt @@ -0,0 +1,12 @@ +package Skyflow + +/** + * The shared `common/` code throws under the neutral name `SkyflowInternalError`. In the legacy SDK + * that name maps straight onto the v1 public [SkyflowError] class, so `common`'s throws produce real + * `Skyflow.SkyflowError` instances (v1 backward compatibility). It is `internal` because 1.27.0 never + * exposed a `SkyflowInternalError` type — consumers catch `SkyflowError`. + * + * (In the FlowVault SDK `SkyflowInternalError` is instead a distinct internal exception class, and + * `SkyflowError` there is a typed response data class — see that module's error/.) + */ +internal typealias SkyflowInternalError = SkyflowError diff --git a/Skyflow/src/main/kotlin/Skyflow/get/GetAPICallback.kt b/skyvault/src/main/kotlin/Skyflow/get/GetAPICallback.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/get/GetAPICallback.kt rename to skyvault/src/main/kotlin/Skyflow/get/GetAPICallback.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/get/GetOptions.kt b/skyvault/src/main/kotlin/Skyflow/get/GetOptions.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/get/GetOptions.kt rename to skyvault/src/main/kotlin/Skyflow/get/GetOptions.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/get/GetRecord.kt b/skyvault/src/main/kotlin/Skyflow/get/GetRecord.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/get/GetRecord.kt rename to skyvault/src/main/kotlin/Skyflow/get/GetRecord.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/get/GetResponse.kt b/skyvault/src/main/kotlin/Skyflow/get/GetResponse.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/get/GetResponse.kt rename to skyvault/src/main/kotlin/Skyflow/get/GetResponse.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/CollectOptions.kt b/skyvault/src/main/kotlin/Skyflow/options/CollectOptions.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/CollectOptions.kt rename to skyvault/src/main/kotlin/Skyflow/options/CollectOptions.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/InsertOptions.kt b/skyvault/src/main/kotlin/Skyflow/options/InsertOptions.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/InsertOptions.kt rename to skyvault/src/main/kotlin/Skyflow/options/InsertOptions.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/RevealOptions.kt b/skyvault/src/main/kotlin/Skyflow/options/RevealOptions.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/RevealOptions.kt rename to skyvault/src/main/kotlin/Skyflow/options/RevealOptions.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/reveal/GetByIdRecord.kt b/skyvault/src/main/kotlin/Skyflow/reveal/GetByIdRecord.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/reveal/GetByIdRecord.kt rename to skyvault/src/main/kotlin/Skyflow/reveal/GetByIdRecord.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/reveal/RevealApiCallback.kt b/skyvault/src/main/kotlin/Skyflow/reveal/RevealApiCallback.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/reveal/RevealApiCallback.kt rename to skyvault/src/main/kotlin/Skyflow/reveal/RevealApiCallback.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/reveal/RevealByIdCallback.kt b/skyvault/src/main/kotlin/Skyflow/reveal/RevealByIdCallback.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/reveal/RevealByIdCallback.kt rename to skyvault/src/main/kotlin/Skyflow/reveal/RevealByIdCallback.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/reveal/RevealRequestBody.kt b/skyvault/src/main/kotlin/Skyflow/reveal/RevealRequestBody.kt similarity index 82% rename from Skyflow/src/main/kotlin/Skyflow/reveal/RevealRequestBody.kt rename to skyvault/src/main/kotlin/Skyflow/reveal/RevealRequestBody.kt index 44849fc0..b7379652 100644 --- a/Skyflow/src/main/kotlin/Skyflow/reveal/RevealRequestBody.kt +++ b/skyvault/src/main/kotlin/Skyflow/reveal/RevealRequestBody.kt @@ -1,4 +1,5 @@ package Skyflow.reveal +import Skyflow.RevealElementInput import Skyflow.Label import org.json.JSONArray @@ -12,7 +13,7 @@ internal class RevealRequestBody { for (element in elements) { val entry = JSONObject() entry.put("token", element.revealInput.token) - entry.put("redaction", element.revealInput.redaction) + entry.put("redaction", (element.revealInput as RevealElementInput).redaction) payload.put(entry) } val result = JSONObject() diff --git a/Skyflow/src/main/kotlin/Skyflow/reveal/RevealRequestRecord.kt b/skyvault/src/main/kotlin/Skyflow/reveal/RevealRequestRecord.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/reveal/RevealRequestRecord.kt rename to skyvault/src/main/kotlin/Skyflow/reveal/RevealRequestRecord.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/reveal/RevealResponse.kt b/skyvault/src/main/kotlin/Skyflow/reveal/RevealResponse.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/reveal/RevealResponse.kt rename to skyvault/src/main/kotlin/Skyflow/reveal/RevealResponse.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/reveal/RevealResponseByID.kt b/skyvault/src/main/kotlin/Skyflow/reveal/RevealResponseByID.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/reveal/RevealResponseByID.kt rename to skyvault/src/main/kotlin/Skyflow/reveal/RevealResponseByID.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/reveal/RevealValueCallback.kt b/skyvault/src/main/kotlin/Skyflow/reveal/RevealValueCallback.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/reveal/RevealValueCallback.kt rename to skyvault/src/main/kotlin/Skyflow/reveal/RevealValueCallback.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/soap/SoapApiCallback.kt b/skyvault/src/main/kotlin/Skyflow/soap/SoapApiCallback.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/soap/SoapApiCallback.kt rename to skyvault/src/main/kotlin/Skyflow/soap/SoapApiCallback.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/soap/SoapConnectionConfig.kt b/skyvault/src/main/kotlin/Skyflow/soap/SoapConnectionConfig.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/soap/SoapConnectionConfig.kt rename to skyvault/src/main/kotlin/Skyflow/soap/SoapConnectionConfig.kt diff --git a/Skyflow/src/main/kotlin/Skyflow/soap/SoapValueCallback.kt b/skyvault/src/main/kotlin/Skyflow/soap/SoapValueCallback.kt similarity index 100% rename from Skyflow/src/main/kotlin/Skyflow/soap/SoapValueCallback.kt rename to skyvault/src/main/kotlin/Skyflow/soap/SoapValueCallback.kt diff --git a/skyvault/src/main/kotlin/Skyflow/utils/LegacyUtils.kt b/skyvault/src/main/kotlin/Skyflow/utils/LegacyUtils.kt new file mode 100644 index 00000000..37ac655b --- /dev/null +++ b/skyvault/src/main/kotlin/Skyflow/utils/LegacyUtils.kt @@ -0,0 +1,332 @@ +package Skyflow.utils + +import Skyflow.* +import Skyflow.get.GetOptions +import Skyflow.get.GetRecord +import org.json.JSONArray +import org.json.JSONObject +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * Legacy (v1) request/validation helpers extracted from Utils so that core/Utils stays + * contract-neutral (these reference v1-only InsertOptions / GetOptions / GetRecord). + */ +object LegacyUtils { + fun constructBatchRequestBody( + records: JSONObject, + options: InsertOptions, + logLevel: LogLevel + ): JSONObject { + val postPayload: MutableList = mutableListOf() + val insertTokenPayload: MutableList = mutableListOf() + if (records == {}) { + throw SkyflowError(SkyflowErrorCode.RECORDS_KEY_NOT_FOUND, Utils.tag, logLevel) + } else if (!records.has("records")) { + throw SkyflowError(SkyflowErrorCode.RECORDS_KEY_NOT_FOUND, Utils.tag, logLevel) + } else if (records.get("records").toString().isEmpty()) { + throw SkyflowError(SkyflowErrorCode.EMPTY_RECORDS, Utils.tag, logLevel) + } else if (records.get("records") !is JSONArray) { + throw SkyflowError(SkyflowErrorCode.INVALID_RECORDS, Utils.tag, logLevel) + } else { + val obj1 = records.getJSONArray("records") + var i = 0 + while (i < obj1.length()) { + val jsonObj = obj1.getJSONObject(i) + if (!jsonObj.has("table")) { + throw SkyflowError( + SkyflowErrorCode.TABLE_KEY_NOY_FOUND, Utils.tag, logLevel, arrayOf("$i") + ) + } else if (jsonObj.get("table") !is String) { + throw SkyflowError( + SkyflowErrorCode.INVALID_TABLE_NAME, Utils.tag, logLevel, arrayOf("$i") + ) + } else if (jsonObj.get("table").toString().isEmpty()) { + throw SkyflowError( + SkyflowErrorCode.EMPTY_TABLE_KEY, Utils.tag, logLevel, arrayOf("$i") + ) + } else if (!jsonObj.has("fields")) { + throw SkyflowError( + SkyflowErrorCode.FIELDS_KEY_NOT_FOUND, Utils.tag, logLevel, arrayOf("$i") + ) + } else if (jsonObj.getJSONObject("fields").toString().equals("{}")) { + throw SkyflowError( + SkyflowErrorCode.EMPTY_FIELDS, Utils.tag, logLevel, arrayOf("$i") + ) + } + + val map = HashMap() + map["tableName"] = jsonObj["table"] + map["fields"] = jsonObj["fields"] + map["method"] = "POST" + map["quorum"] = true + map["upsert"] = + Utils.getUpsertColumn(jsonObj.getString("table"), options.upsert, logLevel) + val jsonObject = jsonObj["fields"] as JSONObject + val keys: Iterator = jsonObject.keys() + + while (keys.hasNext()) { + val key = keys.next() + if (key.isEmpty()) { + throw SkyflowError( + SkyflowErrorCode.EMPTY_FIELD_IN_FIELDS, Utils.tag, logLevel, + params = arrayOf("$i") + ) + } + } + postPayload.add(map) + if (options.tokens) { + val temp2 = HashMap() + temp2["method"] = "GET" + temp2["tableName"] = jsonObj["table"] as String + temp2["ID"] = "\$responses.$i.records.0.skyflow_id" + temp2["tokenization"] = true + insertTokenPayload.add(temp2) + } + i++ + } + val body = HashMap() + body["records"] = postPayload + insertTokenPayload + return JSONObject(body as Map<*, *>) + } + } + + internal fun validateGetInputAndOptions( + records: JSONObject, + options: GetOptions?, + logLevel: LogLevel + ) { + if (!records.has("records")) { + throw SkyflowError(SkyflowErrorCode.RECORDS_KEY_NOT_FOUND, Utils.tag, logLevel) + } else if (records.get("records").toString().isEmpty()) { + throw SkyflowError(SkyflowErrorCode.EMPTY_RECORDS, Utils.tag, logLevel) + } else if (records.get("records") !is JSONArray) { + throw SkyflowError(SkyflowErrorCode.INVALID_RECORDS, Utils.tag, logLevel) + } + + val recordsArray = records.getJSONArray("records") + + (0 until recordsArray.length()).forEach { + val recordObject = recordsArray.getJSONObject(it) + var hasIds = false + var hasRedaction = false + + if (!recordObject.keys().hasNext()) { + throw SkyflowError( + SkyflowErrorCode.EMPTY_RECORD_OBJECT, Utils.tag, logLevel, arrayOf(it.toString()) + ) + } + + // checking for table + if (!recordObject.has("table")) { + throw SkyflowError( + SkyflowErrorCode.TABLE_KEY_NOY_FOUND, Utils.tag, logLevel, arrayOf(it.toString()) + ) + } else if (recordObject.get("table") !is String) { + throw SkyflowError( + SkyflowErrorCode.INVALID_TABLE_NAME, Utils.tag, logLevel, arrayOf(it.toString()) + ) + } else if (recordObject.get("table").toString().isEmpty()) { + throw SkyflowError( + SkyflowErrorCode.EMPTY_TABLE_KEY, Utils.tag, logLevel, arrayOf(it.toString()) + ) + } + + // checking for ids + if (recordObject.has("ids")) { + val ids = recordObject.get("ids") + if (ids !is JSONArray) { + throw SkyflowError( + SkyflowErrorCode.INVALID_IDS, Utils.tag, logLevel, arrayOf(it.toString()) + ) + } else if (ids.length() == 0) { + throw SkyflowError( + SkyflowErrorCode.EMPTY_RECORD_IDS, Utils.tag, logLevel, arrayOf(it.toString()) + ) + } else { + hasIds = true + for (i in 0 until ids.length()) { + if (ids[i] !is String) { + throw SkyflowError( + SkyflowErrorCode.INVALID_ID_IN_RECORD_IDS, Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } else if (ids[i].toString().isEmpty()) { + throw SkyflowError( + SkyflowErrorCode.EMPTY_ID_IN_RECORD_IDS, Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } + } + } + } + + // checking for redaction + if (recordObject.has("redaction")) hasRedaction = true + + val hasColumnName = recordObject.has("columnName") + val hasColumnValues = recordObject.has("columnValues") + + if (options?.tokens == true && hasRedaction) { + throw SkyflowError( + SkyflowErrorCode.REDACTION_WITH_TOKENS_NOT_SUPPORTED, Utils.tag, logLevel + ) + } else if (options?.tokens == true && hasColumnName && hasColumnValues) { + throw SkyflowError( + SkyflowErrorCode.TOKENS_NOT_SUPPORTED_WITH_COLUMN_DETAILS, Utils.tag, logLevel + ) + } else if (options?.tokens == false) { + if (!hasRedaction) { + throw SkyflowError( + SkyflowErrorCode.REDACTION_KEY_NOT_FOUND, Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } else if (recordObject.get("redaction").toString().isEmpty()) { + throw SkyflowError( + SkyflowErrorCode.EMPTY_REDACTION_VALUE, Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } else if (recordObject.get("redaction") !is RedactionType) { + throw SkyflowError( + SkyflowErrorCode.INVALID_REDACTION_TYPE, Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } + } + + // checking for column name and column values + if (!hasColumnName && hasColumnValues) { + throw SkyflowError( + SkyflowErrorCode.MISSING_RECORD_COLUMN_NAME, Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } else if (hasColumnName && !hasColumnValues) { + throw SkyflowError( + SkyflowErrorCode.MISSING_RECORD_COLUMN_VALUES, Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } else if (hasColumnName && hasColumnValues) { + if (hasIds) { + throw SkyflowError( + SkyflowErrorCode.BOTH_IDS_AND_COLUMN_DETAILS_SPECIFIED, Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } + + val columnName = recordObject.get("columnName") + val columnValues = recordObject.get("columnValues") + + if (columnName !is String) { + throw SkyflowError( + SkyflowErrorCode.INVALID_RECORD_COLUMN_NAME_TYPE, Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } else if (columnName.toString().isEmpty()) { + throw SkyflowError( + SkyflowErrorCode.EMPTY_RECORD_COLUMN_NAME, Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } else if (columnValues !is JSONArray) { + throw SkyflowError( + SkyflowErrorCode.INVALID_RECORD_COLUMN_VALUES_TYPE, + Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } else if (columnValues.length() == 0) { + throw SkyflowError( + SkyflowErrorCode.EMPTY_RECORD_COLUMN_VALUES, Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } else { + for (i in 0 until columnValues.length()) { + if (columnValues[i] !is String) { + throw SkyflowError( + SkyflowErrorCode.INVALID_COLUMN_VALUE_TYPE, Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } else if (columnValues[i].toString().isEmpty()) { + throw SkyflowError( + SkyflowErrorCode.EMPTY_COLUMN_VALUE, Utils.tag, logLevel, + arrayOf(it.toString()) + ) + } + } + } + } else { + if (!hasIds) { + throw SkyflowError( + SkyflowErrorCode.NEITHER_IDS_NOR_COLUMN_DETAILS_SPECIFIED, + Utils.tag, logLevel, arrayOf(it.toString()) + ) + } + } + } + } + + internal fun constructRequestBodyForGet(records: JSONObject): MutableList { + val requestBody = mutableListOf() + val recordsArray = records.getJSONArray("records") + for (it in 0 until recordsArray.length()) { + val record = recordsArray.getJSONObject(it) + + val table = record.getString("table") + val ids = arrayListOf() + val columnValues = arrayListOf() + + val redaction = if (record.has("redaction")) { + record.getString("redaction") + } else null + + if (record.has("ids")) { + val skyflowIds = record.getJSONArray("ids") + for (i in 0 until skyflowIds.length()) { + ids.add(skyflowIds[i].toString()) + } + + requestBody.add( + GetRecord(skyflowIds = ids, table = table, redaction = redaction) + ) + continue + } else if (record.has("columnValues")) { + val skyflowColumnValues = record.getJSONArray("columnValues") + for (i in 0 until skyflowColumnValues.length()) { + columnValues.add(skyflowColumnValues[i].toString()) + } + } + + val columnName = record.getString("columnName") + + val requestRecord = GetRecord( + table = table, + redaction = redaction, + columnName = columnName, + columnValues = columnValues + ) + + requestBody.add(requestRecord) + } + return requestBody + } + + fun getRequestbodyForConnection(requestBody: JSONObject, contentType: String): RequestBody { + val mediaType = contentType.toMediaTypeOrNull() + if (contentType.equals(ContentType.FORMURLENCODED.type)) { + return Utils.convertJSONToQueryString(requestBody).toRequestBody(mediaType) + } else if (contentType.equals(ContentType.FORMDATA.type)) { + val map = Utils.r_urlencode(mutableListOf(), HashMap(), requestBody) + val mutlipartBody = MultipartBody.Builder().setType(MultipartBody.FORM) + map.forEach { (key, value) -> + mutlipartBody.addPart( + Headers.headersOf("Content-Disposition", "form-data; name=\"$key\""), + "$value".toRequestBody(null) + ) + } + return mutlipartBody.build() + } else { + return requestBody.toString().toRequestBody(mediaType) + } + } +} diff --git a/Skyflow/src/test/java/com/Skyflow/CallbackResponseFormatTest.kt b/skyvault/src/test/java/com/Skyflow/CallbackResponseFormatTest.kt similarity index 100% rename from Skyflow/src/test/java/com/Skyflow/CallbackResponseFormatTest.kt rename to skyvault/src/test/java/com/Skyflow/CallbackResponseFormatTest.kt diff --git a/Skyflow/src/test/java/com/Skyflow/CollectRequestBodyTest.kt b/skyvault/src/test/java/com/Skyflow/CollectRequestBodyTest.kt similarity index 100% rename from Skyflow/src/test/java/com/Skyflow/CollectRequestBodyTest.kt rename to skyvault/src/test/java/com/Skyflow/CollectRequestBodyTest.kt diff --git a/Skyflow/src/test/java/com/Skyflow/CollectTest.kt b/skyvault/src/test/java/com/Skyflow/CollectTest.kt similarity index 100% rename from Skyflow/src/test/java/com/Skyflow/CollectTest.kt rename to skyvault/src/test/java/com/Skyflow/CollectTest.kt diff --git a/Skyflow/src/test/java/com/Skyflow/ComposableElementsTests.kt b/skyvault/src/test/java/com/Skyflow/ComposableElementsTests.kt similarity index 99% rename from Skyflow/src/test/java/com/Skyflow/ComposableElementsTests.kt rename to skyvault/src/test/java/com/Skyflow/ComposableElementsTests.kt index f774e864..7a3aecf2 100644 --- a/Skyflow/src/test/java/com/Skyflow/ComposableElementsTests.kt +++ b/skyvault/src/test/java/com/Skyflow/ComposableElementsTests.kt @@ -3,6 +3,7 @@ package com.Skyflow import Skyflow.* import Skyflow.composable.* import Skyflow.core.Messages +import Skyflow.core.getMessage import Skyflow.core.elements.state.StateforText import Skyflow.utils.EventName import Skyflow.utils.Utils @@ -1082,7 +1083,7 @@ class ComposableElementsTests { for (logItem in logItems) { if (logItem.type == Log.ERROR) { Assert.assertEquals(tag, logItem.tag) - Assert.assertEquals(Messages.INVALID_EVENT_TYPE.message, logItem.msg) + Assert.assertEquals(Messages.INVALID_EVENT_TYPE.getMessage(), logItem.msg) logFound = true count++ } @@ -1129,7 +1130,7 @@ class ComposableElementsTests { for (logItem in logItems) { if (logItem.type == Log.ERROR) { Assert.assertEquals(tag, logItem.tag) - Assert.assertEquals(Messages.INVALID_EVENT_TYPE.message, logItem.msg) + Assert.assertEquals(Messages.INVALID_EVENT_TYPE.getMessage(), logItem.msg) logFound = true count++ } diff --git a/Skyflow/src/test/java/com/Skyflow/DetokenizeTests.kt b/skyvault/src/test/java/com/Skyflow/DetokenizeTests.kt similarity index 100% rename from Skyflow/src/test/java/com/Skyflow/DetokenizeTests.kt rename to skyvault/src/test/java/com/Skyflow/DetokenizeTests.kt diff --git a/skyvault/src/test/java/com/Skyflow/ExampleUnitTest.kt b/skyvault/src/test/java/com/Skyflow/ExampleUnitTest.kt new file mode 100644 index 00000000..62878434 --- /dev/null +++ b/skyvault/src/test/java/com/Skyflow/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.Skyflow + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/Skyflow/src/test/java/com/Skyflow/GetTests.kt b/skyvault/src/test/java/com/Skyflow/GetTests.kt similarity index 89% rename from Skyflow/src/test/java/com/Skyflow/GetTests.kt rename to skyvault/src/test/java/com/Skyflow/GetTests.kt index afe44114..8d1bb23b 100644 --- a/Skyflow/src/test/java/com/Skyflow/GetTests.kt +++ b/skyvault/src/test/java/com/Skyflow/GetTests.kt @@ -1,4 +1,5 @@ package com.Skyflow +import Skyflow.utils.LegacyUtils import Skyflow.* import Skyflow.core.APIClient @@ -165,7 +166,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -177,7 +178,7 @@ class GetTests { val skyflowError = SkyflowError(SkyflowErrorCode.EMPTY_RECORDS, utilsTag, logLevel) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -189,7 +190,7 @@ class GetTests { val skyflowError = SkyflowError(SkyflowErrorCode.INVALID_RECORDS, utilsTag, logLevel) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -205,7 +206,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -221,7 +222,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -236,7 +237,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -251,7 +252,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -266,7 +267,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -281,7 +282,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -298,7 +299,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -312,7 +313,7 @@ class GetTests { SkyflowError(SkyflowErrorCode.EMPTY_ID_IN_RECORD_IDS, utilsTag, logLevel, arrayOf("0")) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -327,7 +328,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -342,7 +343,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -356,7 +357,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(true), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(true), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -369,7 +370,7 @@ class GetTests { getRecords.put("records", recordsArray) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(true), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(true), logLevel) } catch (exception: Exception) { Assert.fail(exception.message) } @@ -384,7 +385,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -395,7 +396,7 @@ class GetTests { getRecords.put("records", recordsArray) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(false), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(false), logLevel) } catch (exception: Exception) { Assert.fail(exception.message) } @@ -411,7 +412,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -427,7 +428,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -444,7 +445,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(true), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(true), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -461,7 +462,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -477,7 +478,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -493,7 +494,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -509,7 +510,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -525,7 +526,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -541,7 +542,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -557,7 +558,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -574,7 +575,7 @@ class GetTests { ) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) } catch (exception: Exception) { Assert.assertEquals(skyflowError.getErrorMessage(), exception.message) } @@ -589,7 +590,7 @@ class GetTests { getRecords.put("records", recordsArray) try { - Utils.validateGetInputAndOptions(getRecords, GetOptions(true), logLevel) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(true), logLevel) } catch (exception: Exception) { Assert.fail(exception.message) } @@ -599,8 +600,8 @@ class GetTests { fun testConstructRequestBodyForGet() { try { getRecords.put("records", recordsArray) - Utils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) - val requestBody = Utils.constructRequestBodyForGet(getRecords) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(), logLevel) + val requestBody = LegacyUtils.constructRequestBodyForGet(getRecords) Assert.assertEquals(2, requestBody.size) for ((index, request) in requestBody.withIndex()) { @@ -635,8 +636,8 @@ class GetTests { recordsArray.put(record1) getRecords.put("records", recordsArray) - Utils.validateGetInputAndOptions(getRecords, GetOptions(true), logLevel) - val requestBody = Utils.constructRequestBodyForGet(getRecords) + LegacyUtils.validateGetInputAndOptions(getRecords, GetOptions(true), logLevel) + val requestBody = LegacyUtils.constructRequestBodyForGet(getRecords) Assert.assertEquals(2, requestBody.size) for (request in requestBody) { diff --git a/Skyflow/src/test/java/com/Skyflow/InputFormattingTest.kt b/skyvault/src/test/java/com/Skyflow/InputFormattingTest.kt similarity index 100% rename from Skyflow/src/test/java/com/Skyflow/InputFormattingTest.kt rename to skyvault/src/test/java/com/Skyflow/InputFormattingTest.kt diff --git a/Skyflow/src/test/java/com/Skyflow/InvokeConnectionTest.kt b/skyvault/src/test/java/com/Skyflow/InvokeConnectionTest.kt similarity index 100% rename from Skyflow/src/test/java/com/Skyflow/InvokeConnectionTest.kt rename to skyvault/src/test/java/com/Skyflow/InvokeConnectionTest.kt diff --git a/Skyflow/src/test/java/com/Skyflow/ResponseTest.kt b/skyvault/src/test/java/com/Skyflow/ResponseTest.kt similarity index 100% rename from Skyflow/src/test/java/com/Skyflow/ResponseTest.kt rename to skyvault/src/test/java/com/Skyflow/ResponseTest.kt diff --git a/Skyflow/src/test/java/com/Skyflow/RevealTest.kt b/skyvault/src/test/java/com/Skyflow/RevealTest.kt similarity index 99% rename from Skyflow/src/test/java/com/Skyflow/RevealTest.kt rename to skyvault/src/test/java/com/Skyflow/RevealTest.kt index 4ff68bf4..b2f4e425 100644 --- a/Skyflow/src/test/java/com/Skyflow/RevealTest.kt +++ b/skyvault/src/test/java/com/Skyflow/RevealTest.kt @@ -304,7 +304,7 @@ class RevealTest { altText = "expire date" ) val revealElement = revealContainer.create(activity, revealInput, RevealElementOptions()) - Assert.assertEquals(RedactionType.PLAIN_TEXT, revealElement.revealInput.redaction) + Assert.assertEquals(RedactionType.PLAIN_TEXT, (revealElement.revealInput as RevealElementInput).redaction) } @Test diff --git a/Skyflow/src/test/java/com/Skyflow/SoapConnectionTest.kt b/skyvault/src/test/java/com/Skyflow/SoapConnectionTest.kt similarity index 100% rename from Skyflow/src/test/java/com/Skyflow/SoapConnectionTest.kt rename to skyvault/src/test/java/com/Skyflow/SoapConnectionTest.kt diff --git a/Skyflow/src/test/java/com/Skyflow/TestApplication.kt b/skyvault/src/test/java/com/Skyflow/TestApplication.kt similarity index 100% rename from Skyflow/src/test/java/com/Skyflow/TestApplication.kt rename to skyvault/src/test/java/com/Skyflow/TestApplication.kt diff --git a/Skyflow/src/test/java/com/Skyflow/UnitTests.kt b/skyvault/src/test/java/com/Skyflow/UnitTests.kt similarity index 96% rename from Skyflow/src/test/java/com/Skyflow/UnitTests.kt rename to skyvault/src/test/java/com/Skyflow/UnitTests.kt index 0675a52f..ec16fc92 100644 --- a/Skyflow/src/test/java/com/Skyflow/UnitTests.kt +++ b/skyvault/src/test/java/com/Skyflow/UnitTests.kt @@ -1,4 +1,5 @@ package com.Skyflow +import Skyflow.utils.LegacyUtils import Skyflow.* import Skyflow.collect.elements.utils.* @@ -6,6 +7,8 @@ import Skyflow.core.APIClient import Skyflow.core.JWTUtils import Skyflow.core.Logger import Skyflow.core.Messages +import Skyflow.core.getMessage +import Skyflow.core.resolveSdkIdentity import Skyflow.core.elements.state.StateforText import Skyflow.utils.EventName import Skyflow.utils.Utils @@ -386,7 +389,7 @@ class UnitTests { // records.put("records", recordsArray) try { - Utils.constructBatchRequestBody(records, InsertOptions(), LogLevel.ERROR) + LegacyUtils.constructBatchRequestBody(records, InsertOptions(), LogLevel.ERROR) } catch (exception: Exception) { val skyflowError = SkyflowError(SkyflowErrorCode.RECORDS_KEY_NOT_FOUND) assertEquals( @@ -409,7 +412,7 @@ class UnitTests { recordsArray.put(record) records.put("records", JSONObject()) try { - Utils.constructBatchRequestBody(records, InsertOptions(), LogLevel.ERROR) + LegacyUtils.constructBatchRequestBody(records, InsertOptions(), LogLevel.ERROR) } catch (exception: Exception) { val skyflowError = SkyflowError(SkyflowErrorCode.INVALID_RECORDS) assertEquals( @@ -433,7 +436,7 @@ class UnitTests { records.put("records", recordsArray) try { - val x = Utils.constructBatchRequestBody(records, InsertOptions(), LogLevel.ERROR) + val x = LegacyUtils.constructBatchRequestBody(records, InsertOptions(), LogLevel.ERROR) assertEquals(x.toString().trim(), JSONObject().toString().trim()) } catch (exception: Exception) { val skyflowError = @@ -459,7 +462,7 @@ class UnitTests { recordsArray.put(record) records.put("records", recordsArray) try { - val x = Utils.constructBatchRequestBody(records, InsertOptions(), LogLevel.ERROR) + val x = LegacyUtils.constructBatchRequestBody(records, InsertOptions(), LogLevel.ERROR) assertEquals(x.toString().trim(), JSONObject().toString().trim()) } catch (exception: Exception) { val skyflowError = @@ -485,7 +488,7 @@ class UnitTests { recordsArray.put(record) records.put("records", recordsArray) try { - val x = Utils.constructBatchRequestBody(records, InsertOptions(), LogLevel.ERROR) + val x = LegacyUtils.constructBatchRequestBody(records, InsertOptions(), LogLevel.ERROR) assertEquals(x.toString().trim(), JSONObject().toString().trim()) } catch (exception: Exception) { val skyflowError = SkyflowError(SkyflowErrorCode.EMPTY_TABLE_KEY, params = arrayOf("0")) @@ -509,7 +512,7 @@ class UnitTests { recordsArray.put(record) records.put("records", recordsArray) try { - val x = Utils.constructBatchRequestBody(records, InsertOptions(), LogLevel.ERROR) + val x = LegacyUtils.constructBatchRequestBody(records, InsertOptions(), LogLevel.ERROR) assertEquals(x.toString().trim(), JSONObject().toString().trim()) } catch (exception: Exception) { val skyflowError = SkyflowError( @@ -1703,15 +1706,15 @@ class UnitTests { card.put("number", JSONArray().put("123")) json.put("card", card) assertTrue( - Utils.getRequestbodyForConnection(json, ContentType.FORMURLENCODED.type).contentType() + LegacyUtils.getRequestbodyForConnection(json, ContentType.FORMURLENCODED.type).contentType() .toString().contains(ContentType.FORMURLENCODED.type) ) assertTrue( - Utils.getRequestbodyForConnection(json, ContentType.FORMDATA.type).contentType() + LegacyUtils.getRequestbodyForConnection(json, ContentType.FORMDATA.type).contentType() .toString().contains(ContentType.FORMDATA.type) ) assertTrue( - Utils.getRequestbodyForConnection(json, ContentType.APPLICATIONORJSON.type) + LegacyUtils.getRequestbodyForConnection(json, ContentType.APPLICATIONORJSON.type) .contentType().toString().contains(ContentType.APPLICATIONORJSON.type) ) } @@ -1746,7 +1749,7 @@ class UnitTests { assertEquals("card_number", Utils.getUpsertColumn("cards", options, LogLevel.DEBUG)) } catch (e: SkyflowError) { assertEquals( - String.format(Messages.NO_TABLE_KEY_IN_UPSERT.message, 0), + Messages.NO_TABLE_KEY_IN_UPSERT.getMessage("0"), e.getInternalErrorMessage() ) } @@ -1762,7 +1765,7 @@ class UnitTests { assertEquals("card_number", Utils.getUpsertColumn("cards", options, LogLevel.DEBUG)) } catch (e: SkyflowError) { assertEquals( - String.format(Messages.NO_COLUMN_KEY_IN_UPSERT.message, 0), + Messages.NO_COLUMN_KEY_IN_UPSERT.getMessage("0"), e.getInternalErrorMessage() ) } @@ -1779,7 +1782,7 @@ class UnitTests { assertEquals("card_number", Utils.getUpsertColumn("cards", options, LogLevel.DEBUG)) } catch (e: SkyflowError) { assertEquals( - String.format(Messages.INVALID_TABLE_IN_UPSERT_OPTION.message, 0), + Messages.INVALID_TABLE_IN_UPSERT_OPTION.getMessage("0"), e.getInternalErrorMessage() ) } @@ -1796,7 +1799,7 @@ class UnitTests { assertEquals("card_number", Utils.getUpsertColumn("cards", options, LogLevel.DEBUG)) } catch (e: SkyflowError) { assertEquals( - String.format(Messages.INVALID_COLUMN_IN_UPSERT_OPTION.message, 0), + Messages.INVALID_COLUMN_IN_UPSERT_OPTION.getMessage("0"), e.getInternalErrorMessage() ) } @@ -1813,7 +1816,7 @@ class UnitTests { assertEquals("card_number", Utils.getUpsertColumn("cards", options, LogLevel.DEBUG)) } catch (e: SkyflowError) { assertEquals( - String.format(Messages.INVALID_TABLE_IN_UPSERT_OPTION.message, 0), + Messages.INVALID_TABLE_IN_UPSERT_OPTION.getMessage("0"), e.getInternalErrorMessage() ) } @@ -1830,7 +1833,7 @@ class UnitTests { assertEquals("card_number", Utils.getUpsertColumn("cards", options, LogLevel.DEBUG)) } catch (e: SkyflowError) { assertEquals( - String.format(Messages.INVALID_COLUMN_IN_UPSERT_OPTION.message, 0), + Messages.INVALID_COLUMN_IN_UPSERT_OPTION.getMessage("0"), e.getInternalErrorMessage() ) } @@ -1842,12 +1845,29 @@ class UnitTests { assertEquals("card_number", Utils.getUpsertColumn("cards", JSONArray(), LogLevel.DEBUG)) } catch (e: SkyflowError) { assertEquals( - String.format(Messages.EMPTY_UPSERT_OPTIONS_ARRAY.message, 0), + Messages.EMPTY_UPSERT_OPTIONS_ARRAY.getMessage("0"), e.getInternalErrorMessage() ) } } + // --- B1 regression: the "__SKYFLOW_SDK_ID__" placeholder must never leak into error messages --- + + @Test + fun testNoErrorCodeLeaksSdkPlaceholder() { + SkyflowErrorCode.values().forEach { + Assert.assertFalse("$it leaks __SKYFLOW_SDK_ID__", resolveSdkIdentity(it.message).contains("__SKYFLOW_SDK_ID__")) + } + } + + @Test + fun testSkyvaultRendersItsSdkVersionInErrors() { + // skyvault: SdkInfo.version defaults to 1.27.0 — errors must match the 1.27.0 baseline prefix. + val msg = SkyflowError(SkyflowErrorCode.EMPTY_VAULT_URL).getErrorMessage() + Assert.assertFalse(msg.contains("__SKYFLOW_SDK_ID__")) + Assert.assertTrue(msg.startsWith("Android SDK v1.27.0")) + } + @Test fun testNotJSONObjectInUpsert() { diff --git a/Skyflow/src/test/java/com/Skyflow/UtilsTest.kt b/skyvault/src/test/java/com/Skyflow/UtilsTest.kt similarity index 100% rename from Skyflow/src/test/java/com/Skyflow/UtilsTest.kt rename to skyvault/src/test/java/com/Skyflow/UtilsTest.kt diff --git a/Skyflow/src/test/java/com/Skyflow/ValidationTests.kt b/skyvault/src/test/java/com/Skyflow/ValidationTests.kt similarity index 69% rename from Skyflow/src/test/java/com/Skyflow/ValidationTests.kt rename to skyvault/src/test/java/com/Skyflow/ValidationTests.kt index c3033026..01d2f41f 100644 --- a/Skyflow/src/test/java/com/Skyflow/ValidationTests.kt +++ b/skyvault/src/test/java/com/Skyflow/ValidationTests.kt @@ -5,6 +5,7 @@ import Skyflow.collect.elements.validations.ElementValueMatchRule import android.app.Activity import com.Skyflow.collect.elements.validations.* import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -113,4 +114,36 @@ class ValidationTests{ confirmPin!!.inputField.setText("11111") assertEquals("not matched", confirmPin.validate()) } + + // Issue #1 regression: a CONSUMER-authored ValidationRule — implementing ONLY the public + // ValidationRule interface (all an app in another module can implement) — must validate normally. + // Before the fix, SkyflowValidator force-cast every rule to the internal + // SkyflowInternalValidationProtocol, so a consumer rule threw ClassCastException inside + // SkyflowValidator.validate — reached synchronously from container.create() (StateforText) / the + // first keystroke, on the UI thread. (This test could not even have compiled before the fix, + // since ValidationRule had no validate() to override.) + @Test + fun testConsumerAuthoredValidationRule() { + val onlyAcme = object : ValidationRule { + override var error: SkyflowValidationError = "must be ACME" + override fun validate(text: String?): Boolean = text.isNullOrEmpty() || text == "ACME" + } + + // 1) Direct validator path — the exact call site that used to throw ClassCastException. + val set = ValidationSet() + set.add(onlyAcme) + assertEquals("", SkyflowValidator.validate("ACME", set)) + assertEquals("must be ACME", SkyflowValidator.validate("ZEBRA", set)) + + // 2) Real-world entry point: container.create() runs validation via StateforText and must NOT + // crash. INPUT_FIELD carries no built-in rules, so only the consumer rule applies. + val container = skyflow.container(ContainerType.COLLECT) + val field = container.create(activity, CollectElementInput("cards", "name", + SkyflowElementType.INPUT_FIELD, placeholder = "name", validations = set)) as? TextField + assertNotNull("container.create() must not crash for a consumer-authored rule", field) + field!!.inputField.setText("ZEBRA"); field.actualValue = "ZEBRA" + assertEquals("must be ACME", field.validate()) + field.inputField.setText("ACME"); field.actualValue = "ACME" + assertEquals("", field.validate()) + } } \ No newline at end of file diff --git a/Skyflow/src/test/kotlin/Skyflow/collect/client/UpdateBySkyflowIdTest.kt b/skyvault/src/test/kotlin/Skyflow/collect/client/UpdateBySkyflowIdTest.kt similarity index 100% rename from Skyflow/src/test/kotlin/Skyflow/collect/client/UpdateBySkyflowIdTest.kt rename to skyvault/src/test/kotlin/Skyflow/collect/client/UpdateBySkyflowIdTest.kt diff --git a/skyvault/src/test/resources/robolectric.properties b/skyvault/src/test/resources/robolectric.properties new file mode 100644 index 00000000..28a4ed29 --- /dev/null +++ b/skyvault/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=30