diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..f5b3751f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.19.4 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.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/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/components/components.tsx b/example/components/components.tsx index e75586e9..0bb0e232 100644 --- a/example/components/components.tsx +++ b/example/components/components.tsx @@ -1,5 +1,4 @@ import { - Button, View, Text, ActivityIndicator, @@ -7,6 +6,7 @@ import { TouchableOpacity, StyleSheet, Clipboard, + Pressable, } from "react-native"; import { styles } from "../styles/common"; import { SessionState } from "../types/types"; @@ -110,18 +110,44 @@ export function ConnectButton({ } return ( - + ); +} + +export function PrimaryButton({ + title, + onPress, + disabled, + loading = false, +}: { + title: string; + onPress: () => void; + disabled?: boolean; + loading?: boolean; +}) { + const isDisabled = disabled || loading; + + return ( + [ + styles.button, + componentStyles.primaryButton, + isDisabled && styles.buttonDisabled, + pressed && !isDisabled && componentStyles.primaryButtonPressed, + ]} > -