Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/android-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,27 @@ jobs:
echo "NEXT_PUBLIC_ONESIGNAL_DEBUG=true" >> .env.production.local
fi

- name: Write MeaWallet config
# mea_config for the MPP SDK (Google Pay push provisioning) —
# gitignored; bundled from res/raw when present, skipped until the
# secret is provisioned.
if: ${{ secrets.MEAWALLET_CONFIG_BASE64 != '' }}
env:
MEA_CONFIG: ${{ secrets.MEAWALLET_CONFIG_BASE64 }}
run: |
mkdir -p android/app/src/main/res/raw
echo "$MEA_CONFIG" | base64 -d > android/app/src/main/res/raw/mea_config
Comment thread
innolope-dev marked this conversation as resolved.
Outdated

- name: Build signed AAB
env:
# native sentry sdk dsn — read by android/app/build.gradle into a
# manifest placeholder. same project dsn the js layer bakes into
# the static export above; no new secret needed.
SENTRY_DSN_ANDROID: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}
# MeaWallet MPP SDK — gradle gates both the dependency and the
# plugin source on these; absent secrets build without the SDK.
MEAWALLET_NEXUS_USER: ${{ secrets.MEAWALLET_NEXUS_USER }}
MEAWALLET_NEXUS_PASSWORD: ${{ secrets.MEAWALLET_NEXUS_PASSWORD }}
run: |
# versionName: manual dispatch input wins; else the tag name minus
# its leading 'v' (v1.0.10 -> 1.0.10); else build.gradle's
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/ios-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,25 @@ jobs:
fi

- name: Build web + sync iOS
env:
# MeaWallet MPP SDK (Apple Pay push provisioning): postsync
# vendors the xcframework only when these are set, so the
# build stays green until the secrets are provisioned.
MEAWALLET_NEXUS_USER: ${{ secrets.MEAWALLET_NEXUS_USER }}
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
MEAWALLET_NEXUS_PASSWORD: ${{ secrets.MEAWALLET_NEXUS_PASSWORD }}
run: |
node scripts/native-build.js && npx cap sync ios
if [ -f scripts/native-ios-postsync.js ]; then node scripts/native-ios-postsync.js; fi

- name: Write MeaWallet config
# mea_config (encrypted, non-sensitive per MeaWallet, but kept out
# of git) — the Copy MeaWallet Config build phase bundles it when
# present; skipped until the secret is provisioned.
if: ${{ secrets.MEAWALLET_CONFIG_BASE64 != '' }}
env:
MEA_CONFIG: ${{ secrets.MEAWALLET_CONFIG_BASE64 }}
run: echo "$MEA_CONFIG" | base64 -d > ios/App/App/mea_config

- name: Install Apple distribution certificate
uses: apple-actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7
with:
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ e2e/.auth/
keystore.properties
.env.production.local

# meawallet mpp sdk — credential-gated artifacts + encrypted config, never committed
ios/App/CapApp-SPM/Frameworks/
ios/App/App/mea_config
android/app/src/main/res/raw/mea_config

# capgo ota signing keys — private key is a CI secret; public key lives in capacitor.config.ts
.capgo_key_v2
.capgo_key_v2.pub
Expand Down
21 changes: 21 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,27 @@ repositories {
}
}

/*
* MeaWallet MPP SDK (Google Pay push provisioning) — credential-gated.
* The artifact lives on MeaWallet's private Nexus (repo declared in the root
* build.gradle), so both the dependency and PushProvisioningPlugin.java (in
* src/meawallet/java) are compiled only when credentials are present — CI
* release builds and devs who fetched the 1Password credentials. Without them
* the build stays green and MainActivity's reflection lookup of the plugin
* silently no-ops, so the web layer falls back to the manual carousel.
*/
def meaWalletCreds = System.getenv('MEAWALLET_NEXUS_USER') ?: (findProperty('MEAWALLET_NEXUS_USER') ?: null)
if (meaWalletCreds) {
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
logger.lifecycle('[meawallet] Nexus credentials present — MPP SDK enabled')
android.sourceSets.main.java.srcDirs += 'src/meawallet/java'
dependencies {
debugImplementation 'com.meawallet:mpp-prod:2.1.0-debug'
releaseImplementation 'com.meawallet:mpp-prod:2.1.0'
}
} else {
logger.lifecycle('[meawallet] no Nexus credentials — building without MPP SDK (push provisioning stubbed)')
}

dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
Expand Down
26 changes: 26 additions & 0 deletions android/app/src/main/java/me/peanut/wallet/MainActivity.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package me.peanut.wallet;

import android.content.Intent;
import android.os.Bundle;
import android.provider.Settings;
import android.webkit.WebResourceRequest;
Expand All @@ -16,6 +17,30 @@

public class MainActivity extends BridgeActivity {

/*
* PushProvisioningPlugin compiles only when the MeaWallet Nexus credentials
* were present at build time (src/meawallet/java, see app/build.gradle), so
* both the registration and the Google Pay activity-result forward go
* through reflection — a build without the SDK must run exactly as before.
*/
private void registerPushProvisioningPlugin() {
try {
registerPlugin(Class.forName("me.peanut.wallet.PushProvisioningPlugin")
.asSubclass(com.getcapacitor.Plugin.class));
} catch (Exception ignored) {}
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
try {
Object handled = Class.forName("me.peanut.wallet.PushProvisioningPlugin")
.getMethod("handleGooglePayActivityResult", int.class, int.class, Intent.class, android.app.Activity.class)
.invoke(null, requestCode, resultCode, data, this);
if (Boolean.TRUE.equals(handled)) return;
} catch (Exception ignored) {}
super.onActivityResult(requestCode, resultCode, data);
}

private void maybeSentryTestCrash() {
if (getIntent() == null || !getIntent().getBooleanExtra("sentry_test_crash", false)) return;
if (getReferrer() != null) return; // app-to-app starts always carry a referrer; adb doesn't
Expand All @@ -32,6 +57,7 @@ private void maybeSentryTestCrash() {
protected void onCreate(Bundle savedInstanceState) {
// app-local plugin, not auto-discovered — must register before super.onCreate
registerPlugin(InstallReferrerPlugin.class);
registerPushProvisioningPlugin();
super.onCreate(savedInstanceState);

/*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package me.peanut.wallet;

import android.app.Activity;
import android.content.Intent;

import androidx.annotation.NonNull;

import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.google.android.gms.tapandpay.issuer.TokenInfo;
import com.google.android.gms.tapandpay.issuer.UserAddress;
import com.meawallet.mpp.GooglePayRegisteredTokensListener;
import com.meawallet.mpp.MeaPushProvisioning;
import com.meawallet.mpp.MppCardDataParameters;
import com.meawallet.mpp.MppError;
import com.meawallet.mpp.MppPaymentNetwork;
import com.meawallet.mpp.MppPushCardToGooglePayListener;

import java.util.List;

/**
* Google Pay push provisioning via the MeaWallet MPP SDK. This file lives in
* src/meawallet/java, which app/build.gradle adds to the source set only when
* the MeaWallet Nexus credentials are present — builds without the SDK never
* compile it, and MainActivity registers it via a reflection lookup that
* tolerates its absence. The mea_config SDK config ships in res/raw (gitignored,
* CI-injected); without it every method reports unavailable.
*/
@CapacitorPlugin(name = "PushProvisioning")
public class PushProvisioningPlugin extends Plugin {

private static boolean initialize(android.content.Context context) {
try {
if (!MeaPushProvisioning.isInitialized()) {
MeaPushProvisioning.initialize(context);
}
return true;
} catch (Exception e) {
// Missing/broken mea_config — treat as "SDK not in this build".
return false;
}
}

/** Called reflectively from MainActivity.onActivityResult — must not throw. */
public static boolean handleGooglePayActivityResult(int requestCode, int resultCode, Intent data, Activity activity) {
try {
if (!MeaPushProvisioning.isInitialized()) return false;
return MeaPushProvisioning.GooglePay.handleOnActivityResult(requestCode, resultCode, data, activity);
} catch (Exception e) {
return false;
}
}

private boolean hasMeaConfig() {
return getContext().getResources().getIdentifier("mea_config", "raw", getContext().getPackageName()) != 0;
}

@PluginMethod
public void isAvailable(PluginCall call) {
if (!hasMeaConfig() || !initialize(getContext())) {
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
JSObject out = new JSObject();
out.put("available", false);
out.put("alreadyInWallet", false);
call.resolve(out);
return;
}
String last4 = call.getString("last4", "");
if (last4 == null || last4.isEmpty()) {
JSObject out = new JSObject();
out.put("available", true);
out.put("alreadyInWallet", false);
call.resolve(out);
return;
}
MeaPushProvisioning.GooglePay.checkWalletForCardSuffix(last4, new GooglePayRegisteredTokensListener() {
@Override
public void onSuccess(@NonNull List<TokenInfo> tokens) {
JSObject out = new JSObject();
out.put("available", tokens.isEmpty());
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
out.put("alreadyInWallet", !tokens.isEmpty());
call.resolve(out);
}

@Override
public void onFailure(@NonNull MppError error) {
// Can't tell — let the button show; the push flow surfaces the
// real error if the card is genuinely already tokenized.
JSObject out = new JSObject();
out.put("available", true);
out.put("alreadyInWallet", false);
call.resolve(out);
}
});
}

@PluginMethod
public void addCard(PluginCall call) {
String cardId = call.getString("cardId");
String cardSecret = call.getString("cardSecret");
if (cardId == null || cardSecret == null) {
call.reject("cardId and cardSecret are required", "BAD_PARAMS");
return;
}
if (!hasMeaConfig() || !initialize(getContext())) {
call.reject("MeaWallet SDK unavailable in this build", "UNAVAILABLE");
return;
}
Activity activity = getActivity();
if (activity == null) {
call.reject("No foreground activity", "UNAVAILABLE");
return;
}

MppCardDataParameters cardParams = MppCardDataParameters.withCardSecret(cardId, cardSecret);
String displayName = call.getString("displayName", "Peanut Card");
UserAddress userAddress = buildUserAddress(call);

// The SDK routes intermediate results through onActivityResult —
// MainActivity forwards them to handleGooglePayActivityResult above.
MeaPushProvisioning.GooglePay.push(cardParams, displayName, userAddress, activity, new MppPushCardToGooglePayListener() {
@Override
public void onSuccess(String tokenReferenceId, String cardLastFourDigits, MppPaymentNetwork cardNetwork) {
JSObject out = new JSObject();
out.put("added", true);
out.put("last4", cardLastFourDigits);
call.resolve(out);
}

@Override
public void onFailure(MppError error) {
JSObject out = new JSObject();
out.put("added", false);
out.put("error", error != null ? error.getMessage() : "unknown");
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
call.resolve(out);
}
});
}

/**
* Billing address from the provisioning-data endpoint → Google's UserAddress.
* Google uses it to prefill the tokenization sheet; incomplete fields are
* tolerated here (the backend already refused cards with no billing at all).
*/
private UserAddress buildUserAddress(PluginCall call) {
JSObject addr = call.getObject("address", new JSObject());
UserAddress.Builder builder = UserAddress.newBuilder();
String name = call.getString("cardholderName", "");
if (name != null && !name.isEmpty()) builder.setName(name);
putIfPresent(addr, "line1", builder::setAddress1);
putIfPresent(addr, "line2", builder::setAddress2);
putIfPresent(addr, "city", builder::setLocality);
putIfPresent(addr, "region", builder::setAdministrativeArea);
putIfPresent(addr, "postalCode", builder::setPostalCode);
putIfPresent(addr, "countryCode", builder::setCountryCode);
return builder.build();
}

private interface Setter {
void set(String value);
}

private static void putIfPresent(JSObject obj, String key, Setter setter) {
String value = obj.getString(key, "");
if (value != null && !value.isEmpty()) setter.set(value);
}
}
17 changes: 17 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ allprojects {
repositories {
google()
mavenCentral()

// meawallet mpp sdk (google pay push provisioning) — credential-gated
// private maven. credentials via env (CI secret) or gradle property;
// declared unconditionally (gradle only contacts a repo when a
// dependency actually resolves from it, and the mpp dependency itself
// is gated on the same credentials in app/build.gradle).
def meaUser = System.getenv('MEAWALLET_NEXUS_USER') ?: (findProperty('MEAWALLET_NEXUS_USER') ?: null)
def meaPass = System.getenv('MEAWALLET_NEXUS_PASSWORD') ?: (findProperty('MEAWALLET_NEXUS_PASSWORD') ?: null)
if (meaUser && meaPass) {
maven {
url 'https://nexus.ext.meawallet.com/repository/mpp-android-group/'
credentials {
username meaUser
password meaPass
}
}
}
}

// sumsub eid module pulls from a private maven repo requiring credentials.
Expand Down
22 changes: 22 additions & 0 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
7A1C0DE7C11B0A4D2E5F3901 /* AppViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1C0DE7C11B0A4D2E5F3902 /* AppViewController.swift */; };
7A1C0DE7C11B0A4D2E5F3903 /* ClipboardDetectPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1C0DE7C11B0A4D2E5F3904 /* ClipboardDetectPlugin.swift */; };
7A1C0DE7C11B0A4D2E5F3905 /* PushProvisioningPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1C0DE7C11B0A4D2E5F3906 /* PushProvisioningPlugin.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
Expand All @@ -26,6 +27,7 @@
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7A1C0DE7C11B0A4D2E5F3902 /* AppViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewController.swift; sourceTree = "<group>"; };
7A1C0DE7C11B0A4D2E5F3904 /* ClipboardDetectPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardDetectPlugin.swift; sourceTree = "<group>"; };
7A1C0DE7C11B0A4D2E5F3906 /* PushProvisioningPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushProvisioningPlugin.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
Expand Down Expand Up @@ -70,6 +72,7 @@
504EC3071FED79650016851F /* AppDelegate.swift */,
7A1C0DE7C11B0A4D2E5F3902 /* AppViewController.swift */,
7A1C0DE7C11B0A4D2E5F3904 /* ClipboardDetectPlugin.swift */,
7A1C0DE7C11B0A4D2E5F3906 /* PushProvisioningPlugin.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
Expand All @@ -90,6 +93,7 @@
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
7A1C0DE7C11B0A4D2E5F3907 /* Copy MeaWallet Config */,
);
buildRules = (
);
Expand Down Expand Up @@ -156,6 +160,23 @@
};
/* End PBXResourcesBuildPhase section */

/* Begin PBXShellScriptBuildPhase section */
7A1C0DE7C11B0A4D2E5F3907 /* Copy MeaWallet Config */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Copy MeaWallet Config";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# mea_config is the MeaWallet MPP SDK environment config. It is gitignored\n# (CI writes it from a secret; local devs fetch it from 1Password), so it is\n# copied conditionally instead of living in the Resources phase — a missing\n# file must not fail builds that don't ship push provisioning.\nif [ -f \"${SRCROOT}/App/mea_config\" ]; then\n cp \"${SRCROOT}/App/mea_config\" \"${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/mea_config\"\n echo \"mea_config copied into app bundle\"\nelse\n echo \"mea_config not present — push provisioning disabled in this build\"\nfi\n";
};
/* End PBXShellScriptBuildPhase section */

/* Begin PBXSourcesBuildPhase section */
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
Expand All @@ -164,6 +185,7 @@
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
7A1C0DE7C11B0A4D2E5F3901 /* AppViewController.swift in Sources */,
7A1C0DE7C11B0A4D2E5F3903 /* ClipboardDetectPlugin.swift in Sources */,
7A1C0DE7C11B0A4D2E5F3905 /* PushProvisioningPlugin.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
Loading
Loading