From f506da6fe5964bdf5b28ae48a28c768fe1910247 Mon Sep 17 00:00:00 2001 From: dtroupe Date: Thu, 25 Jun 2026 10:33:33 -0700 Subject: [PATCH 1/8] Update Android SDK to 6.0.0 --- android/build.gradle | 25 ++ .../PlaidEmbeddedResultDispatcher.kt | 19 ++ .../plaidlinksdk/PlaidResultMappers.kt | 217 ++++++++++++++++++ .../ReactNativePlaidLinkSdkModule.kt | 175 +++++++++++++- .../ReactNativePlaidLinkSdkView.kt | 69 ++++++ .../plaidlinksdk/PlaidResultMappersTest.kt | 108 +++++++++ example/app.json | 14 +- example/package-lock.json | 22 +- example/package.json | 3 +- src/PlaidEmbeddedSearchView.tsx | 13 +- 10 files changed, 647 insertions(+), 18 deletions(-) create mode 100644 android/src/main/java/expo/modules/plaidlinksdk/PlaidEmbeddedResultDispatcher.kt create mode 100644 android/src/main/java/expo/modules/plaidlinksdk/PlaidResultMappers.kt create mode 100644 android/src/main/java/expo/modules/plaidlinksdk/ReactNativePlaidLinkSdkView.kt create mode 100644 android/src/test/java/expo/modules/plaidlinksdk/PlaidResultMappersTest.kt diff --git a/android/build.gradle b/android/build.gradle index 58d6391d..b984daf1 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -16,3 +16,28 @@ android { abortOnError false } } + + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xskip-metadata-version-check") + } +} + +dependencies { + implementation "com.plaid.link:sdk-core:6.0.0" + testImplementation "junit:junit:4.13.2" +} + +gradle.projectsEvaluated { + rootProject.allprojects { project -> + project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { task -> + task.compilerOptions.freeCompilerArgs.add("-Xskip-metadata-version-check") + } + if (project.name.startsWith("expo")) { + project.tasks.withType(org.gradle.api.tasks.testing.Test).configureEach { task -> + task.failOnNoDiscoveredTests = false + } + } + } +} diff --git a/android/src/main/java/expo/modules/plaidlinksdk/PlaidEmbeddedResultDispatcher.kt b/android/src/main/java/expo/modules/plaidlinksdk/PlaidEmbeddedResultDispatcher.kt new file mode 100644 index 00000000..48bd8a6f --- /dev/null +++ b/android/src/main/java/expo/modules/plaidlinksdk/PlaidEmbeddedResultDispatcher.kt @@ -0,0 +1,19 @@ +package expo.modules.plaidlinksdk + +import com.plaid.link.result.LinkResult + +internal object PlaidEmbeddedResultDispatcher { + private val handlers = mutableSetOf<(LinkResult) -> Unit>() + + fun register(handler: (LinkResult) -> Unit) { + handlers.add(handler) + } + + fun unregister(handler: (LinkResult) -> Unit) { + handlers.remove(handler) + } + + fun dispatch(result: LinkResult) { + handlers.toList().forEach { it(result) } + } +} diff --git a/android/src/main/java/expo/modules/plaidlinksdk/PlaidResultMappers.kt b/android/src/main/java/expo/modules/plaidlinksdk/PlaidResultMappers.kt new file mode 100644 index 00000000..f687b5ab --- /dev/null +++ b/android/src/main/java/expo/modules/plaidlinksdk/PlaidResultMappers.kt @@ -0,0 +1,217 @@ +package expo.modules.plaidlinksdk + +import com.plaid.link.event.LinkEvent +import com.plaid.link.event.LinkEventMetadata +import com.plaid.link.result.LinkAccount +import com.plaid.link.result.LinkError +import com.plaid.link.result.LinkExit +import com.plaid.link.result.LinkExitMetadata +import com.plaid.link.result.LinkInstitution +import com.plaid.link.result.LinkSuccess +import com.plaid.link.result.LinkSuccessMetadata + +internal fun LinkSuccess.toWritableMap(): Map = successPayload( + publicToken = publicToken, + metadata = metadata.toWritableMap(), +) + +internal fun LinkSuccessMetadata.toWritableMap(): Map = successMetadataPayload( + linkSessionId = linkSessionId, + institution = institution?.toWritableMap() ?: "", + accounts = accounts.map { it.toWritableMap() }, + metadataJson = metadataJson.orEmpty(), +) + +internal fun LinkExit.toWritableMap(): Map = exitPayload( + error = error?.toWritableMap() ?: emptyMap(), + metadata = metadata.toWritableMap(), +) + +internal fun LinkExitMetadata.toWritableMap(): Map = exitMetadataPayload( + status = status?.jsonValue.orEmpty(), + institution = institution?.toWritableMap() ?: "", + requestId = requestId.orEmpty(), + linkSessionId = linkSessionId.orEmpty(), + metadataJson = metadataJson.orEmpty(), +) + +internal fun LinkError.toWritableMap(): Map = errorPayload( + errorType = errorCode.errorType.json, + errorCode = errorCode.json, + errorMessage = errorMessage, + displayMessage = displayMessage.orEmpty(), + errorJson = errorJson.orEmpty(), +) + +internal fun LinkEvent.toWritableMap(): Map = eventPayload( + eventName = eventName.json, + metadata = metadata.toWritableMap(), +) + +internal fun LinkEventMetadata.toWritableMap(): Map = eventMetadataPayload( + errorType = errorType.orEmpty(), + errorCode = errorCode.orEmpty(), + errorMessage = errorMessage.orEmpty(), + exitStatus = exitStatus.orEmpty(), + institutionId = institutionId.orEmpty(), + institutionName = institutionName.orEmpty(), + institutionSearchQuery = institutionSearchQuery.orEmpty(), + accountNumberMask = accountNumberMask.orEmpty(), + isUpdateMode = isUpdateMode.orEmpty(), + matchReason = matchReason.orEmpty(), + routingNumber = routingNumber.orEmpty(), + selection = selection.orEmpty(), + linkSessionId = linkSessionId, + mfaType = mfaType.orEmpty(), + requestId = requestId.orEmpty(), + issueId = issueId.orEmpty(), + issueDescription = issueDescription.orEmpty(), + issueDetectedAt = issueDetectedAt.orEmpty(), + timestamp = timestamp, + viewName = viewName?.jsonValue.orEmpty(), + metadataJson = metadataJson.orEmpty(), +) + +internal fun LinkInstitution.toWritableMap(): Map = institutionPayload( + name = name, + id = id, +) + +internal fun LinkAccount.toWritableMap(): Map = accountPayload( + id = id, + name = name.orEmpty(), + mask = mask.orEmpty(), + subtype = subtype.json, + type = subtype.accountType.json, + verificationStatus = verificationStatus?.json.orEmpty(), +) + +internal fun successPayload(publicToken: String, metadata: Map): Map = mapOf( + "publicToken" to publicToken, + "metadata" to metadata, +) + +internal fun successMetadataPayload( + linkSessionId: String, + institution: Any, + accounts: List>, + metadataJson: String, +): Map = mapOf( + "linkSessionId" to linkSessionId, + "institution" to institution, + "accounts" to accounts, + "metadataJson" to metadataJson, +) + +internal fun exitPayload(error: Map, metadata: Map): Map = mapOf( + "error" to error, + "metadata" to metadata, +) + +internal fun exitMetadataPayload( + status: String, + institution: Any, + requestId: String, + linkSessionId: String, + metadataJson: String, +): Map = mapOf( + "status" to status, + "institution" to institution, + "requestId" to requestId, + "linkSessionId" to linkSessionId, + "metadataJson" to metadataJson, +) + +internal fun errorPayload( + errorType: String, + errorCode: String, + errorMessage: String, + displayMessage: String, + errorJson: String, +): Map = mapOf( + "errorType" to errorType, + "errorCode" to errorCode, + "errorMessage" to errorMessage, + "displayMessage" to displayMessage, + "errorJson" to errorJson, +) + +internal fun eventPayload(eventName: String, metadata: Map): Map = mapOf( + "eventName" to eventName, + "metadata" to metadata, +) + +internal fun eventMetadataPayload( + errorType: String, + errorCode: String, + errorMessage: String, + exitStatus: String, + institutionId: String, + institutionName: String, + institutionSearchQuery: String, + accountNumberMask: String, + isUpdateMode: String, + matchReason: String, + routingNumber: String, + selection: String, + linkSessionId: String, + mfaType: String, + requestId: String, + issueId: String, + issueDescription: String, + issueDetectedAt: String, + timestamp: String, + viewName: String, + metadataJson: String, +): Map = mapOf( + "errorType" to errorType, + "errorCode" to errorCode, + "errorMessage" to errorMessage, + "exitStatus" to exitStatus, + "institutionId" to institutionId, + "institutionName" to institutionName, + "institutionSearchQuery" to institutionSearchQuery, + "accountNumberMask" to accountNumberMask, + "isUpdateMode" to isUpdateMode, + "matchReason" to matchReason, + "routingNumber" to routingNumber, + "selection" to selection, + "linkSessionId" to linkSessionId, + "mfaType" to mfaType, + "requestId" to requestId, + "issueId" to issueId, + "issueDescription" to issueDescription, + "issueDetectedAt" to issueDetectedAt, + "timestamp" to timestamp, + "viewName" to viewName, + "metadataJson" to metadataJson, +) + +internal fun institutionPayload(name: String, id: String): Map = mapOf( + "name" to name, + "id" to id, +) + +internal fun accountPayload( + id: String, + name: String, + mask: String, + subtype: String, + type: String, + verificationStatus: String, +): Map = mapOf( + "id" to id, + "name" to name, + "mask" to mask, + "subtype" to subtype, + "type" to type, + "verificationStatus" to verificationStatus, +) + +internal fun emptyExitMetadata(): Map = exitMetadataPayload( + linkSessionId = "", + institution = "", + status = "", + requestId = "", + metadataJson = "", +) diff --git a/android/src/main/java/expo/modules/plaidlinksdk/ReactNativePlaidLinkSdkModule.kt b/android/src/main/java/expo/modules/plaidlinksdk/ReactNativePlaidLinkSdkModule.kt index 01f7a9c8..bc8f4bae 100644 --- a/android/src/main/java/expo/modules/plaidlinksdk/ReactNativePlaidLinkSdkModule.kt +++ b/android/src/main/java/expo/modules/plaidlinksdk/ReactNativePlaidLinkSdkModule.kt @@ -1,14 +1,183 @@ package expo.modules.plaidlinksdk +import android.app.Activity +import android.content.Intent +import com.plaid.link.OnLoadCallback +import com.plaid.link.Plaid +import com.plaid.link.PlaidHeadlessSession +import com.plaid.link.PlaidLayerSession +import com.plaid.link.PlaidLinkSession +import com.plaid.link.PlaidSession +import com.plaid.link.SubmissionData +import com.plaid.link.configuration.LayerTokenConfiguration +import com.plaid.link.configuration.LinkTokenConfiguration +import com.plaid.link.result.LinkExit +import com.plaid.link.result.LinkSuccess +import expo.modules.kotlin.Promise +import expo.modules.kotlin.exception.CodedException import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition class ReactNativePlaidLinkSdkModule : Module() { + private var linkSession: PlaidLinkSession? = null + private var layerSession: PlaidLayerSession? = null + private var headlessSession: PlaidHeadlessSession? = null + private var activeSession: PlaidSession? = null + private var sessionCreationError: Throwable? = null + override fun definition() = ModuleDefinition { Name("ReactNativePlaidLinkSdk") - // TODO: Implement Android Plaid Link SDK integration - // This module currently contains only the basic structure - // The iOS implementation is complete - see ios/src/ReactNativePlaidLinkSdkModule.swift + Constant("sdkVersion") { Plaid.VERSION_NAME } + + Events("PlaidLink.onSuccess", "PlaidLink.onExit", "PlaidLink.onEvent") + + View(ReactNativePlaidLinkSdkView::class) { + Events("onSuccess", "onExit", "onEvent", "onLoad") + Prop("token") { view: ReactNativePlaidLinkSdkView, token: String -> view.setToken(token) } + } + + OnActivityResult { _, payload -> + if (payload.requestCode == Plaid.LINK_REQUEST_CODE) { + handleActivityResult(payload.resultCode, payload.data) + } + } + + AsyncFunction("createPlaidLinkSession") { token: String, promise: Promise -> + try { + val activity = requireActivity() + Plaid.setLinkEventListener { event -> sendEvent("PlaidLink.onEvent", event.toWritableMap()) } + val config = LinkTokenConfiguration.Builder() + .token(token) + .onLoad(OnLoadCallback { promise.resolve(null) }) + .build() + linkSession = Plaid.createPlaidLinkSession(activity, config) + activeSession = linkSession + sessionCreationError = null + } catch (error: Throwable) { + sessionCreationError = error + promise.reject("LINK_SESSION_CREATE_ERROR", error.message ?: "Failed to create Link session", error) + } + } + + AsyncFunction("createPlaidLayerSession") { token: String, promise: Promise -> + try { + val activity = requireActivity() + Plaid.setLinkEventListener { event -> sendEvent("PlaidLink.onEvent", event.toWritableMap()) } + val config = LayerTokenConfiguration.Builder().token(token).build() + layerSession = Plaid.createPlaidLayerSession(activity, config) + activeSession = layerSession + sessionCreationError = null + promise.resolve(null) + } catch (error: Throwable) { + sessionCreationError = error + promise.reject("LAYER_SESSION_CREATE_ERROR", error.message ?: "Failed to create Layer session", error) + } + } + + AsyncFunction("createPlaidHeadlessSession") { token: String, promise: Promise -> + try { + val activity = requireActivity() + Plaid.setLinkEventListener { event -> sendEvent("PlaidLink.onEvent", event.toWritableMap()) } + val config = LinkTokenConfiguration.Builder() + .token(token) + .onLoad(OnLoadCallback { promise.resolve(null) }) + .build() + headlessSession = Plaid.createPlaidHeadlessSession(activity, config) + activeSession = headlessSession + sessionCreationError = null + } catch (error: Throwable) { + sessionCreationError = error + promise.reject("HEADLESS_SESSION_CREATE_ERROR", error.message ?: "Failed to create Headless session", error) + } + } + + AsyncFunction("openLinkSession") { _: Boolean, promise: Promise -> + openSession(linkSession, "createPlaidLinkSession was not called.", promise) + } + + AsyncFunction("openLayerSession") { promise: Promise -> + openSession(layerSession, "createPlaidLayerSession was not called.", promise) + } + + AsyncFunction("startHeadlessSession") { promise: Promise -> + openSession(headlessSession, "createPlaidHeadlessSession was not called.", promise) + } + + AsyncFunction("submitLayerData") { phoneNumber: String?, dateOfBirth: String?, params: Map?, promise: Promise -> + val session = layerSession + if (session == null) { + promise.reject("PLAID_NO_LAYER_SESSION", "Layer session not found. Call createPlaidLayerSession first.", null) + return@AsyncFunction + } + session.submit(SubmissionData(phoneNumber = phoneNumber, dateOfBirth = dateOfBirth, params = params)) + promise.resolve(null) + } + + AsyncFunction("syncFinanceKit") { _: String, _: Boolean, _: Int, promise: Promise -> + promise.reject("UNSUPPORTED_ANDROID", "FinanceKit is only available on iOS", null) + } + } + + private fun openSession(session: PlaidSession?, missingSessionMessage: String, promise: Promise) { + if (session == null) { + sendCreationExit(missingSessionMessage) + promise.resolve(null) + return + } + + try { + val activity = requireActivity() + activeSession = session + session.open(activity) + promise.resolve(null) + } catch (error: Throwable) { + promise.reject("PLAID_OPEN_ERROR", error.message ?: "Failed to open Plaid session", error) + } + } + + private fun handleActivityResult(resultCode: Int, data: Intent?) { + when (val result = Plaid.parseResult(Plaid.LINK_REQUEST_CODE, resultCode, data)) { + is LinkSuccess -> { + PlaidEmbeddedResultDispatcher.dispatch(result) + sendEvent("PlaidLink.onSuccess", result.toWritableMap()) + clearActiveSession() + } + is LinkExit -> { + PlaidEmbeddedResultDispatcher.dispatch(result) + sendEvent("PlaidLink.onExit", result.toWritableMap()) + clearActiveSession() + } + null -> Unit + } } + + private fun clearActiveSession() { + when (activeSession) { + linkSession -> linkSession = null + layerSession -> layerSession = null + headlessSession -> headlessSession = null + } + activeSession = null + } + + private fun sendCreationExit(defaultMessage: String) { + val errorMessage = sessionCreationError?.localizedMessage ?: defaultMessage + sendEvent( + "PlaidLink.onExit", + mapOf( + "error" to mapOf( + "errorType" to "creation error", + "errorCode" to "-1", + "errorMessage" to errorMessage, + "displayMessage" to errorMessage, + "errorJson" to "", + ), + "metadata" to emptyExitMetadata(), + ), + ) + } + + private fun requireActivity(): Activity = appContext.currentActivity + ?: throw CodedException("Could not find current activity.") } diff --git a/android/src/main/java/expo/modules/plaidlinksdk/ReactNativePlaidLinkSdkView.kt b/android/src/main/java/expo/modules/plaidlinksdk/ReactNativePlaidLinkSdkView.kt new file mode 100644 index 00000000..623d23af --- /dev/null +++ b/android/src/main/java/expo/modules/plaidlinksdk/ReactNativePlaidLinkSdkView.kt @@ -0,0 +1,69 @@ +package expo.modules.plaidlinksdk + +import android.content.Context +import android.widget.FrameLayout +import com.plaid.link.OnLinkContinuation +import com.plaid.link.Plaid +import com.plaid.link.configuration.EmbeddedLinkTokenConfiguration +import com.plaid.link.result.LinkExit +import com.plaid.link.result.LinkResult +import com.plaid.link.result.LinkSuccess +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.views.ExpoView +import expo.modules.kotlin.viewevent.EventDispatcher + +class ReactNativePlaidLinkSdkView( + context: Context, + appContext: AppContext, +) : ExpoView(context, appContext) { + private val container = FrameLayout(context) + private val onSuccess by EventDispatcher() + private val onExit by EventDispatcher() + private val onEvent by EventDispatcher() + private val onLoad by EventDispatcher() + + init { + addView(container, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + fun setToken(token: String) { + if (token.isBlank()) return + + Plaid.setLinkEventListener { event -> onEvent(event.toWritableMap()) } + + val config = EmbeddedLinkTokenConfiguration.Builder() + .token(token) + .onEmbeddedViewExit { exit -> onExit(exit.toWritableMap()) } + .build() + + val embeddedView = Plaid.createPlaidEmbeddedLinkView( + context, + config, + OnLinkContinuation { session -> + val activity = appContext.currentActivity ?: return@OnLinkContinuation + session.open(activity) + }, + ) + + container.removeAllViews() + container.addView(embeddedView, FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + onLoad(mapOf()) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + PlaidEmbeddedResultDispatcher.register(::handleResult) + } + + override fun onDetachedFromWindow() { + PlaidEmbeddedResultDispatcher.unregister(::handleResult) + super.onDetachedFromWindow() + } + + private fun handleResult(result: LinkResult) { + when (result) { + is LinkSuccess -> onSuccess(result.toWritableMap()) + is LinkExit -> onExit(result.toWritableMap()) + } + } +} diff --git a/android/src/test/java/expo/modules/plaidlinksdk/PlaidResultMappersTest.kt b/android/src/test/java/expo/modules/plaidlinksdk/PlaidResultMappersTest.kt new file mode 100644 index 00000000..0563d1a6 --- /dev/null +++ b/android/src/test/java/expo/modules/plaidlinksdk/PlaidResultMappersTest.kt @@ -0,0 +1,108 @@ +package expo.modules.plaidlinksdk + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlaidResultMappersTest { + @Test + fun mapsSuccessPayload() { + val institution = institutionPayload("First Platypus Bank", "ins_109508") + val account = checkingAccountPayload() + val metadata = successMetadataPayload( + linkSessionId = "session-id", + institution = institution, + accounts = listOf(account), + metadataJson = "{\"accounts\":[]}", + ) + val map = successPayload("public-sandbox-token", metadata) + + assertEquals("public-sandbox-token", map["publicToken"]) + assertEquals("session-id", metadata["linkSessionId"]) + assertEquals("First Platypus Bank", institution["name"]) + assertEquals("ins_109508", institution["id"]) + assertEquals(account, (metadata["accounts"] as List<*>).first()) + assertEquals("{\"accounts\":[]}", metadata["metadataJson"]) + } + + @Test + fun mapsExitPayload() { + val error = errorPayload( + errorType = "INVALID_INPUT", + errorCode = "INVALID_LINK_TOKEN", + errorMessage = "Invalid token", + displayMessage = "Try again", + errorJson = "{\"error_code\":\"INVALID_LINK_TOKEN\"}", + ) + val metadata = exitMetadataPayload( + status = "requires_account_selection", + institution = institutionPayload("First Platypus Bank", "ins_109508"), + requestId = "request-id", + linkSessionId = "session-id", + metadataJson = "{\"status\":\"requires_account_selection\"}", + ) + val map = exitPayload(error, metadata) + + assertEquals("INVALID_LINK_TOKEN", error["errorCode"]) + assertEquals("Invalid token", error["errorMessage"]) + assertEquals("requires_account_selection", metadata["status"]) + assertEquals("request-id", metadata["requestId"]) + assertEquals("session-id", metadata["linkSessionId"]) + assertEquals(error, map["error"]) + assertEquals(metadata, map["metadata"]) + } + + @Test + fun mapsEventPayload() { + val metadata = eventMetadataPayload( + errorType = "INVALID_INPUT", + errorCode = "ERROR_CODE", + errorMessage = "Something happened", + exitStatus = "requires_credentials", + institutionId = "ins_109508", + institutionName = "First Platypus Bank", + institutionSearchQuery = "first", + accountNumberMask = "1234", + isUpdateMode = "false", + matchReason = "manual_select", + routingNumber = "021000021", + selection = "checking", + linkSessionId = "session-id", + mfaType = "sms", + requestId = "request-id", + issueId = "issue-id", + issueDescription = "issue-description", + issueDetectedAt = "2026-06-25T00:00:00Z", + timestamp = "2026-06-25T01:00:00Z", + viewName = "CREDENTIAL", + metadataJson = "{\"event\":true}", + ) + val map = eventPayload("TRANSITION_VIEW", metadata) + + assertEquals("TRANSITION_VIEW", map["eventName"]) + assertEquals("session-id", metadata["linkSessionId"]) + assertEquals("CREDENTIAL", metadata["viewName"]) + assertEquals("{\"event\":true}", metadata["metadataJson"]) + assertEquals("1234", metadata["accountNumberMask"]) + } + + @Test + fun mapsAccountPayload() { + val map = checkingAccountPayload() + + assertEquals("account-id", map["id"]) + assertEquals("Plaid Checking", map["name"]) + assertEquals("0000", map["mask"]) + assertEquals("checking", map["subtype"]) + assertEquals("depository", map["type"]) + assertEquals("automatically_verified", map["verificationStatus"]) + } + + private fun checkingAccountPayload(): Map = accountPayload( + id = "account-id", + name = "Plaid Checking", + mask = "0000", + subtype = "checking", + type = "depository", + verificationStatus = "automatically_verified", + ) +} diff --git a/example/app.json b/example/app.json index eace2130..263b9c12 100644 --- a/example/app.json +++ b/example/app.json @@ -27,6 +27,16 @@ }, "web": { "favicon": "./assets/favicon.png" - } + }, + "plugins": [ + [ + "expo-build-properties", + { + "android": { + "minSdkVersion": 26 + } + } + ] + ] } -} \ No newline at end of file +} diff --git a/example/package-lock.json b/example/package-lock.json index 8b66b1c5..e370f091 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -10,6 +10,7 @@ "license": "0BSD", "dependencies": { "expo": "~55.0.8", + "expo-build-properties": "~55.0.10", "expo-clipboard": "~55.0.9", "react": "19.2.0", "react-native": "0.83.2" @@ -1571,9 +1572,10 @@ } }, "node_modules/@expo/schema-utils": { - "version": "55.0.2", - "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-55.0.2.tgz", - "integrity": "sha512-QZ5WKbJOWkCrMq0/kfhV9ry8te/OaS34YgLVpG8u9y2gix96TlpRTbxM/YATjNcUR2s4fiQmPCOxkGtog4i37g==" + "version": "55.0.4", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-55.0.4.tgz", + "integrity": "sha512-65IdeeE8dAZR3n3J5Eq7LYiQ8BFGeEYCWPBCzycvafL7PkskbCyIclTQarRwf/HXFoRvezKCjaLwy/8v9Prk6g==", + "license": "MIT" }, "node_modules/@expo/sdk-runtime-versions": { "version": "1.0.0", @@ -3107,6 +3109,20 @@ } } }, + "node_modules/expo-build-properties": { + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-55.0.15.tgz", + "integrity": "sha512-mLCtpn/nBEg4lm7tQUhh/yT5l+8gqwHU/ONqs/zbeeqnNbvryjTuGE0krluueB7Tol4/epfgjiKL4hVEW2VAvA==", + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^55.0.4", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-clipboard": { "version": "55.0.9", "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-55.0.9.tgz", diff --git a/example/package.json b/example/package.json index fc6ea87e..a22c947d 100644 --- a/example/package.json +++ b/example/package.json @@ -13,7 +13,8 @@ "expo": "~55.0.8", "expo-clipboard": "~55.0.9", "react": "19.2.0", - "react-native": "0.83.2" + "react-native": "0.83.2", + "expo-build-properties": "~55.0.10" }, "devDependencies": { "@types/react": "~19.2.2", diff --git a/src/PlaidEmbeddedSearchView.tsx b/src/PlaidEmbeddedSearchView.tsx index a1cb66dc..18738ab6 100644 --- a/src/PlaidEmbeddedSearchView.tsx +++ b/src/PlaidEmbeddedSearchView.tsx @@ -1,6 +1,6 @@ import { requireNativeViewManager } from "expo-modules-core"; import * as React from "react"; -import { Platform, ViewProps } from "react-native"; +import { ViewProps } from "react-native"; import { LinkExit, @@ -16,16 +16,11 @@ export type PlaidEmbeddedSearchViewProps = { onLoad?: () => void; } & ViewProps; -const NativeView: React.ComponentType = - Platform.OS === "ios" - ? requireNativeViewManager("ReactNativePlaidLinkSdk") - : () => null; +const NativeView: React.ComponentType = requireNativeViewManager( + "ReactNativePlaidLinkSdk", +); export function PlaidEmbeddedSearchView(props: PlaidEmbeddedSearchViewProps) { - if (Platform.OS !== "ios") { - return null; - } - const { onSuccess, onExit, onEvent, onLoad, ...restProps } = props; return ( From 6b7ae03655b35e30a4091d0b677cfa45687bdb1a Mon Sep 17 00:00:00 2001 From: dtroupe Date: Thu, 25 Jun 2026 12:06:27 -0700 Subject: [PATCH 2/8] Update Android example app flows --- example/screens/LinkExitScreen.tsx | 32 +++++--- example/screens/LinkSuccessScreen.tsx | 24 ++++-- example/screens/PlaidEmbeddedSearchScreen.tsx | 78 +++++++++++++------ 3 files changed, 93 insertions(+), 41 deletions(-) diff --git a/example/screens/LinkExitScreen.tsx b/example/screens/LinkExitScreen.tsx index 8a8c910d..e4b9c205 100644 --- a/example/screens/LinkExitScreen.tsx +++ b/example/screens/LinkExitScreen.tsx @@ -1,15 +1,17 @@ import { Alert, Modal, - Pressable, ScrollView, - SectionList, StyleSheet, Text, TouchableOpacity, View, } from "react-native"; -import { LinkExit, LinkEvent } from "react-native-plaid-link-sdk"; +import { + LinkExit, + LinkEvent, + LinkInstitution, +} from "react-native-plaid-link-sdk"; type Props = { linkExit: LinkExit; @@ -19,16 +21,15 @@ type Props = { export function LinkExitScreen({ linkExit, events, onClose }: Props) { const { error, metadata } = linkExit; + const institution = getInstitution(metadata.institution); const copyToClipboard = async (text: string) => { - // expo-clipboard if available, otherwise Alert Alert.alert("Copied", text); }; return ( - {/* Header */} 🔴 LinkExit @@ -37,7 +38,6 @@ export function LinkExitScreen({ linkExit, events, onClose }: Props) { - {/* Exit Error */} {error ? ( @@ -54,12 +54,11 @@ export function LinkExitScreen({ linkExit, events, onClose }: Props) { )} - {/* Metadata */} {metadata.status && } - {metadata.institution && ( - + {institution && ( + )} - {/* Metadata JSON */} @@ -85,7 +83,6 @@ export function LinkExitScreen({ linkExit, events, onClose }: Props) { - {/* Link Events */} {events.length > 0 && ( <> @@ -124,6 +121,19 @@ function ExpandableEvent({ event }: { event: LinkEvent }) { ); } +function getInstitution(institution: unknown): LinkInstitution | null { + if ( + typeof institution === "object" && + institution !== null && + "name" in institution && + "id" in institution + ) { + return institution as LinkInstitution; + } + + return null; +} + const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#f2f2f7" }, header: { diff --git a/example/screens/LinkSuccessScreen.tsx b/example/screens/LinkSuccessScreen.tsx index f1d2aa2b..0f3e1011 100644 --- a/example/screens/LinkSuccessScreen.tsx +++ b/example/screens/LinkSuccessScreen.tsx @@ -12,6 +12,7 @@ import { import { LinkAccount, LinkEvent, + LinkInstitution, LinkSuccess, } from "react-native-plaid-link-sdk"; @@ -23,6 +24,7 @@ type Props = { export function LinkSuccessScreen({ linkSuccess, events, onClose }: Props) { const { publicToken, metadata } = linkSuccess; + const institution = getInstitution(metadata.institution); const copyToClipboard = async (text: string, label: string) => { await Clipboard.setStringAsync(text); @@ -32,7 +34,6 @@ export function LinkSuccessScreen({ linkSuccess, events, onClose }: Props) { return ( - {/* Header */} 🟢 LinkSuccess @@ -41,7 +42,6 @@ export function LinkSuccessScreen({ linkSuccess, events, onClose }: Props) { - {/* Public Token */} - {/* Events */} {events.length > 0 && ( <> @@ -71,13 +70,12 @@ export function LinkSuccessScreen({ linkSuccess, events, onClose }: Props) { )} - {/* Metadata */} - {metadata.institution && ( + {institution && ( )} @@ -97,7 +95,6 @@ export function LinkSuccessScreen({ linkSuccess, events, onClose }: Props) { - {/* Metadata JSON */} @@ -169,6 +166,19 @@ function ExpandableEvent({ ); } +function getInstitution(institution: unknown): LinkInstitution | null { + if ( + typeof institution === "object" && + institution !== null && + "name" in institution && + "id" in institution + ) { + return institution as LinkInstitution; + } + + return null; +} + const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#f2f2f7" }, header: { diff --git a/example/screens/PlaidEmbeddedSearchScreen.tsx b/example/screens/PlaidEmbeddedSearchScreen.tsx index b1d53c7d..29b91867 100644 --- a/example/screens/PlaidEmbeddedSearchScreen.tsx +++ b/example/screens/PlaidEmbeddedSearchScreen.tsx @@ -1,25 +1,21 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Button, + Platform, SafeAreaView, ScrollView, + StyleSheet, Text, View, - Platform, - StyleSheet, } from "react-native"; import { - PlaidEmbeddedSearchView, LinkExit, LinkEvent, LinkSuccess, + PlaidEmbeddedSearchView, } from "react-native-plaid-link-sdk"; import ReactNativePlaidLinkSdk from "react-native-plaid-link-sdk"; -import { - ErrorView, - SdkVersionView, - TokenInputView, -} from "../components/components"; +import { SdkVersionView, TokenInputView } from "../components/components"; import { styles } from "../styles/common"; import { isValidToken } from "../utils/validation"; import { LinkExitScreen } from "./LinkExitScreen"; @@ -30,13 +26,18 @@ type Props = { onBack: () => void }; export function PlaidEmbeddedSearchScreen({ onBack }: Props) { const [token, setToken] = useState(""); const [isViewReady, setIsViewReady] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); const [linkExit, setLinkExit] = useState(null); const [linkSuccess, setLinkSuccess] = useState(null); const events = useRef([]); - const hasValidToken = isValidToken(token.trim()); - const canShowView = hasValidToken && Platform.OS === "ios"; + const trimmedToken = token.trim(); + const hasValidToken = isValidToken(trimmedToken); + const canShowView = hasValidToken; + + useEffect(() => { + setIsViewReady(false); + events.current = []; + }, [trimmedToken]); const handleSuccess = (success: LinkSuccess) => { console.log( @@ -62,7 +63,6 @@ export function PlaidEmbeddedSearchScreen({ onBack }: Props) { const handleLoad = () => { console.log("[PlaidEmbeddedSearch] onLoad - view is ready"); setIsViewReady(true); - setErrorMessage(null); }; return ( @@ -78,16 +78,19 @@ export function PlaidEmbeddedSearchScreen({ onBack }: Props) { - {Platform.OS !== "ios" && ( - - )} - - {errorMessage && } + + + + + - {canShowView && ( + {canShowView ? ( - )} - - {!hasValidToken && ( + ) : ( Enter a valid link token to see the embedded search view @@ -134,7 +135,38 @@ export function PlaidEmbeddedSearchScreen({ onBack }: Props) { ); } +function StatusRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + const componentStyles = StyleSheet.create({ + statusCard: { + width: "100%", + maxWidth: 330, + backgroundColor: "#fff", + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 10, + }, + statusRow: { + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 4, + }, + statusLabel: { + fontSize: 13, + color: "#555", + }, + statusValue: { + fontSize: 13, + color: "#111", + fontWeight: "600", + }, embeddedContainer: { width: "100%", maxWidth: 330, From 2950fd6fdc0548ccb93ba5113ed9f8548b4ce889 Mon Sep 17 00:00:00 2001 From: dtroupe Date: Thu, 25 Jun 2026 13:27:45 -0700 Subject: [PATCH 3/8] Update Android example app safe areas --- example/App.tsx | 2 +- example/screens/LinkExitScreen.tsx | 7 ++++++- example/screens/LinkSuccessScreen.tsx | 7 ++++++- example/screens/PlaidEmbeddedSearchScreen.tsx | 2 +- example/screens/PlaidLayerSessionScreen.tsx | 2 +- .../screens/PlaidLinkHeadlessSessionScreen.tsx | 2 +- example/screens/PlaidLinkSessionScreen.tsx | 2 +- example/screens/SyncFinanceKitScreen.tsx | 16 +++++++++++++++- example/styles/common.ts | 5 ++++- 9 files changed, 36 insertions(+), 9 deletions(-) diff --git a/example/App.tsx b/example/App.tsx index 7964bd8a..2719a363 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -33,7 +33,7 @@ export default function App() { return setScreen("list")} />; return ( - + LinkKit Examples diff --git a/example/screens/LinkExitScreen.tsx b/example/screens/LinkExitScreen.tsx index e4b9c205..85c3a1ab 100644 --- a/example/screens/LinkExitScreen.tsx +++ b/example/screens/LinkExitScreen.tsx @@ -1,8 +1,10 @@ import { Alert, Modal, + Platform, ScrollView, StyleSheet, + StatusBar, Text, TouchableOpacity, View, @@ -29,7 +31,7 @@ export function LinkExitScreen({ linkExit, events, onClose }: Props) { return ( - + 🔴 LinkExit @@ -136,6 +138,9 @@ function getInstitution(institution: unknown): LinkInstitution | null { const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#f2f2f7" }, + androidSafeArea: { + paddingTop: Platform.OS === "android" ? StatusBar.currentHeight : 0, + }, header: { flexDirection: "row", alignItems: "center", diff --git a/example/screens/LinkSuccessScreen.tsx b/example/screens/LinkSuccessScreen.tsx index 0f3e1011..8dcc9d82 100644 --- a/example/screens/LinkSuccessScreen.tsx +++ b/example/screens/LinkSuccessScreen.tsx @@ -3,8 +3,10 @@ import { useState } from "react"; import { Alert, Modal, + Platform, ScrollView, StyleSheet, + StatusBar, Text, TouchableOpacity, View, @@ -33,7 +35,7 @@ export function LinkSuccessScreen({ linkSuccess, events, onClose }: Props) { return ( - + 🟢 LinkSuccess @@ -181,6 +183,9 @@ function getInstitution(institution: unknown): LinkInstitution | null { const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#f2f2f7" }, + androidSafeArea: { + paddingTop: Platform.OS === "android" ? StatusBar.currentHeight : 0, + }, header: { flexDirection: "row", alignItems: "center", diff --git a/example/screens/PlaidEmbeddedSearchScreen.tsx b/example/screens/PlaidEmbeddedSearchScreen.tsx index 29b91867..b56155b3 100644 --- a/example/screens/PlaidEmbeddedSearchScreen.tsx +++ b/example/screens/PlaidEmbeddedSearchScreen.tsx @@ -66,7 +66,7 @@ export function PlaidEmbeddedSearchScreen({ onBack }: Props) { }; return ( - +