diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..beda834
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,100 @@
+# Built application files
+*.apk
+*.aar
+*.ap_
+*.aab
+
+# APK output directory
+APK/
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+# Uncomment the following line in case you need and you don't have the release build type files in your app
+# release/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# IntelliJ
+*.iml
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/gradle.xml
+.idea/assetWizardSettings.xml
+.idea/dictionaries
+.idea/libraries
+.idea/jarRepositories.xml
+# Android Studio 3 in .gitignore file.
+.idea/caches
+.idea/modules.xml
+# Comment next line if keeping position of elements in Navigation Editor is desired
+.idea/navEditor.xml
+.idea/misc.xml
+.idea/studiobot.xml
+
+# Keystore files
+# Uncomment the following lines if you do not want to check your keystore files in.
+#*.jks
+#*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+.cxx/
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
+
+# Version control
+vcs.xml
+
+# lint
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+# lint/reports/
+
+# Android Profiling
+*.hprof
+
+# macOS
+.DS_Store
+
+# Sensitive information
+# gradle.properties - committed with placeholder values
diff --git a/README.md b/README.md
index 9f9ab69..84fda55 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,438 @@
-# tokenio-android-webview-sdk
-Android webview SDK for Hosted Payments pages • This repository is defined and managed in Terraform
+# Token.io WebView Integration for Android
+
+This guide shows you how to integrate Token.io's web-based payment flows into your existing Android payment interface using a secure WebView implementation.
+
+**Perfect for developers who already have a payment interface and just need to launch Token.io's web app securely.**
+
+---
+
+## 📋 Table of Contents
+
+1. [Quick Overview](#quick-overview)
+2. [What You Need](#what-you-need)
+3. [WebView Components](#webview-components)
+4. [Integration Steps](#integration-steps)
+5. [Launching Token.io Web App](#launching-tokenio-web-app)
+6. [Handling Payment Results](#handling-payment-results)
+7. [Troubleshooting WebView Issues](#troubleshooting-webview-issues)
+
+---
+
+## 🎯 Quick Overview
+
+If you already have a payment interface, you only need to:
+
+1. **Add a secure WebView component** to launch Token.io's web app
+2. **Handle payment callbacks** from the web app back to your app
+3. **Process payment results** in your existing flow
+
+**⚠️ Security First:**
+> Use the provided WebView implementation - it handles security, SSL certificates, and proper callback routing that a basic WebView cannot.
+
+---
+
+## 🛠️ What You Need
+
+Just **3 core files** and **2 simple steps**:
+
+### Files to Copy:
+1. **`PaymentWebViewActivity.kt`** - The secure WebView that launches Token.io
+2. **`UnifiedWebViewClient.kt`** - Handles payment callbacks and redirects
+3. **`activity_payment_webview.xml`** - WebView layout
+
+### Steps:
+1. **Add WebView Activity to your manifest**
+2. **Launch WebView from your existing payment flow**
+
+That's it! 🎉
+
+---
+
+## 📱 WebView Components
+
+### Core WebView Files You Need:
+
+```
+📁 From this demo project:
+├── PaymentWebViewActivity.kt # Main WebView Activity
+├── UnifiedWebViewClient.kt # Callback Handler
+└── res/layout/activity_payment_webview.xml # WebView Layout
+```
+
+**What each file does:**
+- **`PaymentWebViewActivity`**: Secure WebView that loads Token.io's web app
+- **`UnifiedWebViewClient`**: Intercepts payment success/failure callbacks
+- **`activity_payment_webview.xml`**: Simple WebView layout with progress bar
+
+---
+
+## ⚡ Integration Steps
+
+### Step 1: Add the WebView Activity
+
+Add to your `AndroidManifest.xml`:
+
+```xml
+
+```
+
+### Step 2: Launch Token.io from Your Payment Flow
+
+From your existing payment button/method:
+
+```kotlin
+// In your existing payment activity/fragment
+private lateinit var tokenWebViewLauncher: ActivityResultLauncher
+
+override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Set up the WebView launcher
+ tokenWebViewLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ handleTokenResult(result)
+ }
+}
+
+// When user clicks your "Pay with Bank" button
+private fun launchTokenWebView() {
+ val intent = Intent(this, PaymentWebViewActivity::class.java).apply {
+ putExtra("PAYMENT_URL", "https://your-token-payment-url.com")
+ putExtra("CALLBACK_SCHEME", "yourapp") // Your app's callback scheme
+ }
+ tokenWebViewLauncher.launch(intent)
+}
+
+private fun handleTokenResult(result: ActivityResult) {
+ when (result.resultCode) {
+ Activity.RESULT_OK -> {
+ val paymentId = result.data?.getStringExtra("PAYMENT_ID")
+ // Payment successful - continue your flow
+ onPaymentSuccess(paymentId)
+ }
+ Activity.RESULT_CANCELED -> {
+ val error = result.data?.getStringExtra("ERROR_MESSAGE")
+ // Payment failed - handle error
+ onPaymentFailed(error)
+ }
+ }
+}
+```
+
+---
+
+## 🚀 Launching Token.io Web App
+
+### Option A: With Your Token.io Payment URL
+
+If you already generate Token.io payment URLs:
+
+```kotlin
+private fun launchTokenPayment(tokenPaymentUrl: String) {
+ val intent = Intent(this, PaymentWebViewActivity::class.java).apply {
+ putExtra("PAYMENT_URL", tokenPaymentUrl)
+ putExtra("CALLBACK_SCHEME", "yourapp")
+ putExtra("TITLE", "Complete Payment") // Optional: Custom title
+ }
+ tokenWebViewLauncher.launch(intent)
+}
+```
+
+### Option B: Generate URL and Launch
+
+If you need to create the payment first:
+
+```kotlin
+private fun startTokenPayment(amount: String, currency: String, recipientName: String) {
+ // Your existing payment creation logic
+ val paymentUrl = createTokenPayment(amount, currency, recipientName)
+
+ // Launch WebView with the URL
+ launchTokenPayment(paymentUrl)
+}
+```
+
+### Option C: Generate Payment Session and Launch WebView
+
+To create a payment session and get the web app URL:
+
+```kotlin
+// Add these dependencies for Token.io API calls:
+// implementation 'com.squareup.retrofit2:retrofit:2.9.0'
+// implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
+// implementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
+
+// 1. Set up API service
+interface TokenApiService {
+ @POST("payment-requests")
+ suspend fun createPaymentRequest(
+ @Header("Authorization") authorization: String,
+ @Body paymentRequest: PaymentRequest
+ ): PaymentResponse
+}
+
+// 2. Create payment session and launch WebView
+private fun createPaymentSessionAndLaunch() {
+ lifecycleScope.launch {
+ try {
+ // Create payment request with proper locale formatting
+ val paymentRequest = PaymentRequest(
+ initiation = Initiation(
+ refId = UUID.randomUUID().toString(),
+ flowType = "FULL_HOSTED_PAGES",
+ remittanceInformationPrimary = "Payment for services",
+ remittanceInformationSecondary = "Order #${System.currentTimeMillis()}",
+ amount = Amount(
+ value = formatAmountForApi(100.50), // Always use English locale
+ currency = "GBP" // or "EUR"
+ ),
+ localInstrument = "FASTER_PAYMENTS", // or "SEPA_CREDIT_TRANSFER"
+ creditor = Creditor(
+ name = "Your Business Name",
+ sortCode = "123456", // For UK payments
+ accountNumber = "12345678" // For UK payments
+ // iban = "GB29NWBK60161331926819" // For EUR/SEPA payments
+ ),
+ callbackUrl = "yourapp://payment-complete",
+ callbackState = UUID.randomUUID().toString()
+ ),
+ pispConsentAccepted = true
+ )
+
+ // Call Token.io API to create payment session
+ val response = tokenApiService.createPaymentRequest(
+ authorization = "Bearer ${BuildConfig.TOKEN_API_KEY}",
+ paymentRequest = paymentRequest
+ )
+
+ // Launch WebView with the payment URL
+ launchTokenPayment(response.paymentUrl)
+
+ } catch (e: Exception) {
+ Log.e("Payment", "Failed to create payment session", e)
+ handleApiError(e)
+ }
+ }
+}
+
+// 3. Helper function to ensure English locale for amounts
+private fun formatAmountForApi(amount: Double): String {
+ return String.format(Locale.ENGLISH, "%.2f", amount)
+}
+
+// 4. Data classes for API communication
+@JsonClass(generateAdapter = true)
+data class PaymentRequest(
+ val initiation: Initiation,
+ val pispConsentAccepted: Boolean
+)
+
+@JsonClass(generateAdapter = true)
+data class Initiation(
+ val refId: String,
+ val flowType: String,
+ val remittanceInformationPrimary: String,
+ val remittanceInformationSecondary: String,
+ val amount: Amount,
+ val localInstrument: String,
+ val creditor: Creditor,
+ val callbackUrl: String,
+ val callbackState: String
+)
+
+@JsonClass(generateAdapter = true)
+data class Amount(
+ val value: String, // Always formatted with English locale (e.g., "100.50")
+ val currency: String // "GBP", "EUR", etc.
+)
+
+@JsonClass(generateAdapter = true)
+data class Creditor(
+ val name: String,
+ val sortCode: String? = null, // For UK payments
+ val accountNumber: String? = null, // For UK payments
+ val iban: String? = null // For EUR/SEPA payments
+)
+
+@JsonClass(generateAdapter = true)
+data class PaymentResponse(
+ val paymentUrl: String,
+ val paymentId: String,
+ val status: String
+)
+```
+
+---
+
+## 🔄 Handling Payment Results
+
+The WebView will return results to your app in two ways:
+
+### In-App Results (Most Common)
+When payment completes within the WebView:
+
+```kotlin
+private fun handleTokenResult(result: ActivityResult) {
+ when (result.resultCode) {
+ Activity.RESULT_OK -> {
+ // ✅ Payment successful
+ val paymentId = result.data?.getStringExtra("PAYMENT_ID")
+ val amount = result.data?.getStringExtra("AMOUNT")
+
+ // Continue your app's payment flow
+ showPaymentSuccess(paymentId, amount)
+ navigateToConfirmationScreen()
+ }
+
+ Activity.RESULT_CANCELED -> {
+ // ❌ Payment failed or cancelled
+ val error = result.data?.getStringExtra("ERROR_MESSAGE")
+
+ if (error != null) {
+ showPaymentError(error)
+ } else {
+ showPaymentCancelled()
+ }
+ }
+ }
+}
+```
+
+### External Browser Results (For Some Banks)
+Some banks redirect outside the app. Handle these with a deep link:
+
+**1. Add to your `AndroidManifest.xml`:**
+```xml
+
+
+
+
+
+
+
+
+```
+
+**2. Handle the callback:**
+```kotlin
+override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+
+ if (intent.data?.scheme == "yourapp") {
+ val paymentId = intent.data?.getQueryParameter("payment-id")
+ val error = intent.data?.getQueryParameter("error")
+
+ when {
+ paymentId != null -> onPaymentSuccess(paymentId)
+ error != null -> onPaymentFailed(error)
+ else -> onPaymentCancelled()
+ }
+ }
+}
+```
+
+---
+
+## 🎯 WebView Flow Summary
+
+```
+Your Payment Button
+ ↓
+Launch PaymentWebViewActivity
+ ↓
+Token.io Web App loads
+ ↓
+User completes bank payment
+ ↓
+Result returns to your app
+ ↓
+Continue your payment flow
+```
+
+**That's it! 🎉 Your existing payment interface can now launch Token.io's secure web app.**
+
+---
+
+## 🔍 Troubleshooting WebView Issues
+
+### WebView Not Loading:
+```kotlin
+// Check these in your PaymentWebViewActivity:
+webView.settings.javaScriptEnabled = true
+webView.settings.domStorageEnabled = true
+webView.settings.loadWithOverviewMode = true
+webView.settings.useWideViewPort = true
+```
+
+### Payment Callbacks Not Working:
+1. **Check your callback scheme** matches in both:
+ - AndroidManifest.xml: ``
+ - WebView intent: `putExtra("CALLBACK_SCHEME", "yourapp")`
+
+2. **Test callback manually:**
+ ```bash
+ adb shell am start -a android.intent.action.VIEW \
+ -d "yourapp://payment-complete?payment-id=test123" \
+ com.yourpackage.yourapp
+ ```
+
+### WebView SSL/Security Issues:
+- The provided `UnifiedWebViewClient` handles SSL certificates
+- Never disable SSL verification in production
+- Ensure your app targets API 28+ for network security
+
+### Debug WebView Loading:
+```kotlin
+// Add to your WebView setup for debugging
+if (BuildConfig.DEBUG) {
+ WebView.setWebContentsDebuggingEnabled(true)
+}
+```
+
+---
+
+## 🔐 Production Checklist
+
+Before going live:
+
+- [ ] **Remove debug logs** from WebView components
+- [ ] **Test on real devices** with actual bank accounts
+- [ ] **Verify SSL certificates** are properly validated
+- [ ] **Test both callback methods** (WebView + deep link)
+- [ ] **Handle network connectivity** issues gracefully
+- [ ] **Add loading states** for better UX
+
+---
+
+## 💰 Currency and Locale Handling
+
+**Important:** Always use English locale for amount formatting to ensure API compatibility:
+
+```kotlin
+// ✅ Correct - English locale formatting
+private fun formatAmountForApi(amount: Double): String {
+ return String.format(Locale.ENGLISH, "%.2f", amount)
+}
+
+// Examples:
+formatAmountForApi(100.5) // Returns "100.50" ✅
+formatAmountForApi(1000.0) // Returns "1000.00" ✅
+
+// ❌ Wrong - Device locale might use comma separator
+String.format("%.2f", 100.5) // Could return "100,50" on some devices
+```
+
+**Supported Currencies:**
+- **GBP** (UK Faster Payments): Requires `sortCode` and `accountNumber`
+- **EUR** (SEPA Credit Transfer): Requires `iban`
+- **Other currencies** as supported by Token.io
+
+---
+
+**🚀 Ready to integrate?** Copy the 3 WebView files and you're set!
+
+**📱 Questions?** Check the demo app to see the WebView in action.
diff --git a/Very Small Uses Only - Token icon - Dark navy on light blue (RGB).png b/Very Small Uses Only - Token icon - Dark navy on light blue (RGB).png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/Very Small Uses Only - Token icon - Dark navy on light blue (RGB).png differ
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..06f5013
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,91 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.google.devtools.ksp") // KSP for Moshi
+ id("com.google.gms.google-services")
+}
+
+android {
+ namespace = "com.example.paymentdemoandroid"
+ compileSdk = 34 // Use a recent SDK
+
+ defaultConfig {
+ applicationId = "com.example.paymentdemoandroid"
+ minSdk = 23
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ // Retrieve BETA API key from gradle.properties
+ val betaApiKey: String = project.properties["BETA_API_KEY"] as? String ?: "your_beta_api_key_here"
+
+ // Expose BETA API key to BuildConfig
+ buildConfigField("String", "BETA_API_KEY", "\"${betaApiKey}\"")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+
+ buildFeatures {
+ viewBinding = true
+ buildConfig = true // Enable BuildConfig generation
+ }
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ all {
+ it.jvmArgs("-Dnet.bytebuddy.experimental=true")
+ }
+ }
+ }
+}
+
+dependencies {
+
+ implementation("androidx.core:core-ktx:1.12.0") // Updated core-ktx version
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("com.google.android.material:material:1.11.0") // Updated material version
+ implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") // For lifecycleScope
+
+ // Networking - Retrofit & Moshi
+ implementation("com.squareup.retrofit2:retrofit:2.9.0")
+ implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
+ implementation("com.squareup.moshi:moshi-kotlin:1.15.0") // Ensure Moshi Kotlin support
+ ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0") // Moshi codegen processor
+ implementation("com.squareup.okhttp3:logging-interceptor:4.11.0") // For logging network calls
+
+ // Coroutines
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+
+// firebase
+ implementation(platform("com.google.firebase:firebase-bom:33.1.1"))
+ implementation("com.google.firebase:firebase-analytics")
+ // Testing
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.mockito:mockito-core:5.7.0")
+ testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.0")
+
+ // ByteBuddy compatibility for Java 23
+ testImplementation("net.bytebuddy:byte-buddy:1.15.10")
+ testImplementation("net.bytebuddy:byte-buddy-agent:1.15.10")
+ androidTestImplementation("androidx.test.ext:junit:1.1.5")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+}
diff --git a/app/google-services.json b/app/google-services.json
new file mode 100644
index 0000000..53fb098
--- /dev/null
+++ b/app/google-services.json
@@ -0,0 +1,29 @@
+{
+ "project_info": {
+ "project_number": "594624484851",
+ "project_id": "token-webview-testapp",
+ "storage_bucket": "token-webview-testapp.firebasestorage.app"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:594624484851:android:5c60372b85b2112a13ae86",
+ "android_client_info": {
+ "package_name": "com.example.paymentdemoandroid"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "AIzaSyDiyWck0Ov84NzSnBu5APYQSirL3wyp3bk"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..f4198d8
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,35 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in /Users/your_user/Library/Android/sdk/tools/proguard/proguard-android-optimize.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.kts.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# Add any project specific keep options here:
+
+# If you use reflection or JNI define guards here to keep interfaces,
+# methods and fields used from native code.
+# -keep PackageName.** { *; }
+
+# If you use libraries that require some extra configuration please refer to the documentation of
+# those libraries.
+
+# Keep Moshi generated adapters
+-keep class com.squareup.moshi.JsonAdapter { *; }
+-keep class com.example.paymentdemoandroid.model.**JsonAdapter { *; }
+-keep @com.squareup.moshi.JsonClass class * {
+ *;
+}
+
+# Keep classes needed by Retrofit
+-dontwarn retrofit2.Platform$Java8
+-keep class retrofit2.** { *; }
+-keep interface retrofit2.** { *; }
+
+# Keep OkHttp classes
+-keep class okhttp3.** { *; }
+-keep interface okhttp3.** { *; }
+-dontwarn okhttp3.**
+-dontwarn okio.**
diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json
new file mode 100644
index 0000000..5ef6e86
--- /dev/null
+++ b/app/release/output-metadata.json
@@ -0,0 +1,20 @@
+{
+ "version": 3,
+ "artifactType": {
+ "type": "APK",
+ "kind": "Directory"
+ },
+ "applicationId": "com.example.paymentdemoandroid",
+ "variantName": "release",
+ "elements": [
+ {
+ "type": "SINGLE",
+ "filters": [],
+ "attributes": [],
+ "versionCode": 1,
+ "versionName": "1.0",
+ "outputFile": "app-release.apk"
+ }
+ ],
+ "elementType": "File"
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..ac6a177
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/example/paymentdemoandroid/Constants.kt b/app/src/main/java/com/example/paymentdemoandroid/Constants.kt
new file mode 100644
index 0000000..379e7ac
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/Constants.kt
@@ -0,0 +1,25 @@
+package com.example.paymentdemoandroid
+
+import com.example.paymentdemoandroid.BuildConfig
+
+object Constants {
+ const val CALLBACK_SCHEME = "paymentdemoapp"
+ const val CALLBACK_HOST = "payment-complete"
+ const val CALLBACK_URL = "$CALLBACK_SCHEME://$CALLBACK_HOST"
+
+ // --- Environment Configuration ---
+
+ enum class Environment(val displayName: String, val apiKey: String, val baseUrl: String) {
+ BETA(
+ "Beta",
+ BuildConfig.BETA_API_KEY,
+ "https://api.beta.token.io/" // Corrected Beta Base URL
+ )
+ }
+
+ // Default environment - set to BETA since it's the only option
+ var selectedEnvironment: Environment = Environment.BETA
+
+ // IMPORTANT: Storing API keys directly in code is insecure for production apps.
+ // Consider using BuildConfig fields or other secure methods.
+}
diff --git a/app/src/main/java/com/example/paymentdemoandroid/MainActivity.kt b/app/src/main/java/com/example/paymentdemoandroid/MainActivity.kt
new file mode 100644
index 0000000..9e52f84
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/MainActivity.kt
@@ -0,0 +1,303 @@
+package com.example.paymentdemoandroid
+
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.widget.Toast
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.widget.doAfterTextChanged
+import androidx.lifecycle.lifecycleScope
+import com.example.paymentdemoandroid.databinding.ActivityMainBinding
+import com.example.paymentdemoandroid.model.Amount
+import com.example.paymentdemoandroid.model.Creditor
+import com.example.paymentdemoandroid.model.Initiation
+import com.example.paymentdemoandroid.model.PaymentRequest
+import com.example.paymentdemoandroid.repository.PaymentRepository
+import com.squareup.moshi.JsonAdapter
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import java.util.UUID
+import java.util.Locale
+
+class MainActivity : AppCompatActivity() {
+ private var paymentHandledByIntent = false
+ private var paymentHandled = false // Prevents duplicate toasts after payment result
+
+ private lateinit var binding: ActivityMainBinding
+ // --- Activity Result Launcher ---
+ private lateinit var paymentWebViewLauncher: ActivityResultLauncher
+ // --------------------------------
+
+ companion object {
+ private const val TAG = "MainActivity"
+
+ /**
+ * Safely formats an amount for API calls using English locale to avoid
+ * locale-specific decimal separators (e.g., comma in French locale)
+ */
+ private fun formatAmountForApi(amount: Double): String {
+ return String.format(Locale.ENGLISH, "%.2f", amount)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ setupCurrencySelection()
+ setupPaymentButton()
+
+ updateTotalLabel(binding.amountInput.text.toString(), "GBP")
+
+ binding.amountInput.doAfterTextChanged { text ->
+ val selectedCurrency = if (binding.gbpRadioButton.isChecked) "GBP" else "EUR"
+ updateTotalLabel(text.toString(), selectedCurrency)
+ }
+
+ paymentWebViewLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (paymentHandledByIntent) {
+ paymentHandledByIntent = false // reset for next payment
+ return@registerForActivityResult
+ }
+ if (paymentHandled) {
+ // Already handled, suppress any further toasts/flows
+ return@registerForActivityResult
+ }
+ // Always launch PaymentResultActivity for ANY result
+ val callbackUri = result.data?.data
+ val intent = Intent(this, PaymentResultActivity::class.java).apply {
+ putExtra("callback_uri", callbackUri?.toString())
+ // Pass through any extras that might indicate cancellation or errors
+ result.data?.extras?.let { putExtras(it) }
+ if (result.resultCode == PaymentWebViewActivity.RESULT_PAYMENT_CANCELLED) {
+ putExtra("payment_cancelled", true)
+ }
+ }
+ paymentHandled = true
+ startActivity(intent)
+
+ }
+ }
+
+ override fun onResume() {
+ paymentHandled = false // Reset for new payment flow on resume
+
+ super.onResume()
+ // Ensure UI is enabled when the activity resumes
+ showLoading(false)
+ }
+
+ private fun setupCurrencySelection() {
+ binding.currencyRadioGroup.setOnCheckedChangeListener { _, checkedId ->
+ val isEuroSelected = checkedId == R.id.eurRadioButton
+ binding.sepaRadioGroup.visibility = if (isEuroSelected) View.VISIBLE else View.GONE
+ binding.ibanInputLayout.visibility = if (isEuroSelected) View.VISIBLE else View.GONE
+ binding.sortCodeInputLayout.visibility = if (!isEuroSelected) View.VISIBLE else View.GONE
+ binding.accountNumberInputLayout.visibility = if (!isEuroSelected) View.VISIBLE else View.GONE
+
+ if (isEuroSelected) {
+ binding.sortCodeInputLayout.error = null
+ binding.accountNumberInputLayout.error = null
+ } else {
+ binding.ibanInputLayout.error = null
+ }
+
+ val selectedCurrency = if (isEuroSelected) "EUR" else "GBP"
+ updateTotalLabel(binding.amountInput.text.toString(), selectedCurrency)
+ }
+ }
+
+ private fun setupPaymentButton() {
+ binding.payButton.setOnClickListener {
+ if (validateInput()) {
+ startPaymentFlow()
+ }
+ }
+ }
+
+ private fun validateInput(): Boolean {
+ binding.amountInput.error = null
+ binding.payeeNameInputLayout.error = null
+ binding.ibanInputLayout.error = null
+ binding.sortCodeInputLayout.error = null
+ binding.accountNumberInputLayout.error = null
+
+ val amountStr = binding.amountInput.text.toString()
+ if (amountStr.isBlank() || amountStr.toDoubleOrNull() == null || amountStr.toDouble() <= 0) {
+ binding.amountInput.error = "Please enter a valid amount"
+ return false
+ }
+
+ val payeeName = binding.payeeNameEditText.text.toString()
+ if (payeeName.isBlank()) {
+ binding.payeeNameInputLayout.error = "Payee name is required"
+ return false
+ }
+
+ if (binding.eurRadioButton.isChecked) {
+ val iban = binding.ibanEditText.text.toString().trim()
+ if (iban.isBlank()) {
+ binding.ibanInputLayout.error = "IBAN is required for EUR payments"
+ return false
+ }
+ } else {
+ val sortCode = binding.sortCodeEditText.text.toString()
+ val accountNumber = binding.accountNumberEditText.text.toString()
+
+ if (sortCode.isBlank()) {
+ binding.sortCodeInputLayout.error = "Sort Code is required for GBP payments"
+ return false
+ }
+ if (accountNumber.isBlank()) {
+ binding.accountNumberInputLayout.error = "Account Number is required for GBP payments"
+ return false
+ }
+ }
+ return true
+ }
+
+ private fun startPaymentFlow() {
+ showLoading(true)
+ Log.d("MainActivity", "Starting payment flow...")
+
+ val amountStr = binding.amountInput.text.toString()
+ val amountValue = try {
+ val parsedAmount = amountStr.toDouble()
+ val formattedAmount = formatAmountForApi(parsedAmount) // Fixed version
+ // val formattedAmount = "%.2f".format(parsedAmount) // Problematic version - uses device locale
+ Log.d("MainActivity", "Amount formatting - Input: '$amountStr', Locale: ${Locale.getDefault()}, Formatted: '$formattedAmount'")
+ formattedAmount
+ } catch (e: Exception) {
+ Log.e("MainActivity", "Failed to format amount: $amountStr", e)
+ "0.00"
+ }
+ val currency = if (binding.gbpRadioButton.isChecked) "GBP" else "EUR"
+ val payeeName = binding.payeeNameEditText.text.toString()
+ val localInstrument: String
+ val creditor: Creditor
+
+ if (currency == "EUR") {
+ val iban = binding.ibanEditText.text.toString().trim()
+ localInstrument = if (binding.sepaInstantRadioButton.isChecked) "SEPA_INSTANT" else "SEPA"
+ creditor = Creditor(
+ name = payeeName,
+ sortCode = null,
+ accountNumber = null,
+ iban = iban
+ )
+ } else {
+ val sortCode = binding.sortCodeEditText.text.toString()
+ val accountNumber = binding.accountNumberEditText.text.toString()
+ localInstrument = "FASTER_PAYMENTS"
+ creditor = Creditor(
+ name = payeeName,
+ sortCode = sortCode,
+ accountNumber = accountNumber,
+ iban = null
+ )
+ }
+
+ val refId = generateRandomAlphaNumericString(12)
+ val paymentRequest = PaymentRequest(
+ initiation = Initiation(
+ refId = refId,
+ flowType = if (currency == "EUR") "FULL_HOSTED_PAGES" else "FULL_HOSTED_PAGES",
+ remittanceInformationPrimary = "RP$refId",
+ remittanceInformationSecondary = "RS$refId",
+ amount = Amount(
+ value = amountValue,
+ currency = currency
+ ),
+ localInstrument = localInstrument,
+ creditor = creditor,
+ callbackUrl = Constants.CALLBACK_URL,
+ callbackState = generateRandomAlphaNumericString(12)
+ ),
+ pispConsentAccepted = true
+ )
+
+ Log.d("MainActivity", "PaymentRequest created - Amount: '${paymentRequest.initiation.amount.value}', Currency: '${paymentRequest.initiation.amount.currency}'")
+
+ PaymentSdk.startPaymentFlow(
+ activity = this,
+ paymentRequest = paymentRequest,
+ launcher = paymentWebViewLauncher
+ ) { paymentResult ->
+ showLoading(false)
+ when (paymentResult) {
+ is PaymentResult.Success -> {
+ val intent = Intent(this, PaymentResultActivity::class.java).apply {
+ putExtra(PaymentResultActivity.EXTRA_PAYMENT_ID, paymentResult.paymentId)
+ }
+ startActivity(intent)
+ }
+ is PaymentResult.Failure -> {
+ // Launch PaymentResultActivity and pass the error message
+ val intent = Intent(this, PaymentResultActivity::class.java).apply {
+ putExtra("payment_error", paymentResult.error)
+ }
+ startActivity(intent)
+ }
+ is PaymentResult.Cancelled -> {
+ Toast.makeText(this, "Payment cancelled", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+
+ private fun generateRandomAlphaNumericString(length: Int): String {
+ val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
+ return (1..length)
+ .map { allowedChars.random() }
+ .joinToString("")
+ }
+
+ private fun showLoading(isLoading: Boolean) {
+ binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
+ binding.payButton.isEnabled = !isLoading
+ binding.amountInput.isEnabled = !isLoading
+ binding.payeeNameInputLayout.isEnabled = !isLoading
+ binding.currencyRadioGroup.isEnabled = !isLoading
+ binding.sepaRadioGroup.isEnabled = !isLoading
+ binding.ibanInputLayout.isEnabled = !isLoading
+ binding.sortCodeInputLayout.isEnabled = !isLoading
+ binding.accountNumberInputLayout.isEnabled = !isLoading
+ }
+
+ // No longer needed: all error/success messages are handled in PaymentResultActivity
+ // private fun showError(message: String) {
+ // showLoading(false)
+ // Toast.makeText(this, message, Toast.LENGTH_LONG).show()
+ // }
+
+ private fun updateTotalLabel(amountStr: String?, currency: String) {
+ val symbol = if (currency == "GBP") "£" else "€"
+ val amount = amountStr?.toDoubleOrNull() ?: 0.00
+ binding.totalValueTextView.text = "$symbol${String.format("%.2f", amount)}"
+ }
+
+ override fun onNewIntent(intent: Intent?) {
+ super.onNewIntent(intent)
+ // Handle browser intent callback if activity is already running
+ if (intent?.action == Intent.ACTION_VIEW && intent.data != null && intent.data?.scheme == "paymentdemoapp" && intent.data?.host == "payment-complete") {
+ android.util.Log.i("MainActivity", "Received browser callback intent (onNewIntent): action=${intent.action}, data=${intent.data}")
+ if (!paymentHandledByIntent) {
+ paymentHandledByIntent = true
+ val callbackUri = intent.data
+ val resultIntent = Intent(this, PaymentResultActivity::class.java).apply {
+ putExtra("callback_uri", callbackUri?.toString())
+ }
+ startActivity(resultIntent)
+ }
+ }
+ // No other payment result handling here; handled via ActivityResult
+ }
+}
diff --git a/app/src/main/java/com/example/paymentdemoandroid/PaymentResultActivity.kt b/app/src/main/java/com/example/paymentdemoandroid/PaymentResultActivity.kt
new file mode 100644
index 0000000..3ceb02b
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/PaymentResultActivity.kt
@@ -0,0 +1,255 @@
+package com.example.paymentdemoandroid
+
+import android.graphics.Color
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import com.example.paymentdemoandroid.databinding.ActivityPaymentResultBinding
+import com.example.paymentdemoandroid.model.PaymentStatusResponse
+import com.example.paymentdemoandroid.repository.PaymentRepository
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.util.concurrent.TimeUnit
+
+class PaymentResultActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityPaymentResultBinding
+ private val paymentRepository = PaymentRepository()
+ private var paymentId: String? = null
+ private var pollingJob: Job? = null
+
+ companion object {
+ const val EXTRA_PAYMENT_ID = "extra_payment_id"
+ private const val POLLING_INTERVAL_MS = 3000L // Poll every 3 seconds
+ private val POLLING_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(2) // Stop polling after 2 minutes
+ private const val TAG = "PaymentResultActivity"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityPaymentResultBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ val paymentCancelled = intent.getBooleanExtra("payment_cancelled", false)
+ if (paymentCancelled) {
+ // Show cancelled UI, skip polling
+ binding.textViewStatusValue.text = "CANCELLED"
+ binding.textViewStatusValue.setTextColor(getStatusColor("CANCELLED"))
+ binding.textViewErrorLabel.visibility = View.VISIBLE
+ binding.textViewErrorLabel.text = "Payment Cancelled"
+ binding.textViewErrorValue.visibility = View.GONE
+ binding.textViewAmountLabel.visibility = View.GONE
+ binding.textViewAmountValue.visibility = View.GONE
+ binding.progressBarResult.visibility = View.GONE
+ binding.textViewPaymentIdValue.text = "-"
+ Toast.makeText(this, "Payment cancelled", Toast.LENGTH_LONG).show()
+ binding.buttonCloseResult.setOnClickListener {
+ val intent = Intent(this, MainActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ startActivity(intent)
+ finish()
+}
+ return
+ }
+
+ paymentId = intent.getStringExtra(EXTRA_PAYMENT_ID)
+ if (paymentId == null) {
+ // Try to extract payment-id from callback_uri
+ val callbackUriString = intent.getStringExtra("callback_uri")
+ val callbackUri = callbackUriString?.let { android.net.Uri.parse(it) }
+ paymentId = callbackUri?.getQueryParameter("payment-id")
+ if (paymentId == null) {
+ // Optionally, try to extract a reference or state parameter for fallback polling
+ val stateOrRef = callbackUri?.getQueryParameter("state") ?: callbackUri?.getQueryParameter("reference")
+ if (stateOrRef != null) {
+ // Optionally, you could attempt polling by state/ref here if your backend supports it
+ Log.w(TAG, "No payment-id, but found state/reference: $stateOrRef. Implement polling by reference if possible.")
+ }
+ // Show a user-friendly error and do not crash
+ Log.e(TAG, "Error: Payment ID not found in intent extras or callback URI.")
+ binding.textViewStatusValue.text = "UNKNOWN"
+ binding.textViewStatusValue.setTextColor(getStatusColor("UNKNOWN"))
+ binding.textViewErrorLabel.visibility = View.VISIBLE
+ binding.textViewErrorLabel.text = "Unable to retrieve payment status."
+ binding.textViewErrorValue.visibility = View.VISIBLE
+ binding.textViewErrorValue.text = "We could not determine your payment status. Please check your account or contact support."
+ binding.textViewAmountLabel.visibility = View.GONE
+ binding.textViewAmountValue.visibility = View.GONE
+ binding.progressBarResult.visibility = View.GONE
+ binding.textViewPaymentIdValue.text = "-"
+ Toast.makeText(this, "Unable to retrieve payment status. Please check your account or contact support.", Toast.LENGTH_LONG).show()
+ binding.buttonCloseResult.setOnClickListener {
+ val intent = Intent(this, MainActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ startActivity(intent)
+ finish()
+}
+ return
+ }
+ }
+
+ Log.i(TAG, "Received Payment ID: $paymentId")
+ binding.textViewPaymentIdValue.text = paymentId // Display payment ID immediately
+ binding.buttonCloseResult.setOnClickListener {
+ val intent = Intent(this, MainActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ startActivity(intent)
+ finish()
+}
+
+ startPollingPaymentStatus()
+ }
+
+ private fun startPollingPaymentStatus() {
+ Log.d(TAG, "Starting payment status polling for ID: $paymentId")
+ binding.progressBarResult.visibility = View.VISIBLE
+ binding.textViewStatusValue.text = "Polling..."
+ hideResultDetails()
+
+ val startTime = System.currentTimeMillis()
+
+ pollingJob = lifecycleScope.launch { // Use lifecycleScope for automatic cancellation
+ try {
+ while (isActive) { // Loop while the coroutine is active
+ // Check for timeout
+ if (System.currentTimeMillis() - startTime > POLLING_TIMEOUT_MS) {
+ Log.w(TAG, "Polling timed out for payment ID: $paymentId")
+ updateUiWithError("Polling timed out. Please check status later.")
+ break // Exit loop on timeout
+ }
+
+ Log.d(TAG, "Polling status for payment ID: $paymentId...")
+ val response = paymentRepository.getPaymentStatus(paymentId!!)
+
+ if (response.isSuccessful) {
+ val paymentStatus = response.body()
+ Log.i(TAG, "Received payment status: ${paymentStatus?.status}")
+ if (paymentStatus != null) {
+ updateUiWithStatus(paymentStatus)
+ if (paymentStatus.isFinalStatus()) {
+ Log.i(TAG, "Received final status: ${paymentStatus.status}. Stopping polling.")
+ break // Exit loop on final status
+ }
+ } else {
+ Log.w(TAG, "Polling response body was null.")
+ // Optionally handle null body (retry or show error)
+ }
+ } else {
+ Log.e(TAG, "API Error fetching status: ${response.code()} - ${response.message()}")
+ // Consider showing an error message, but continue polling unless it's a fatal error like 404?
+ updateUiWithError("Error checking status: ${response.code()}")
+ // Decide if we should break here based on error type, e.g., break on 404 Not Found
+ // break
+ }
+
+ delay(POLLING_INTERVAL_MS) // Wait before the next poll
+ }
+ } catch (e: CancellationException) {
+ Log.i(TAG, "Polling cancelled.") // Expected when activity is destroyed
+ } catch (e: Exception) {
+ Log.e(TAG, "Exception during polling", e)
+ updateUiWithError("An error occurred while checking status.")
+ }
+ }
+ }
+
+ private fun updateUiWithStatus(statusResponse: PaymentStatusResponse) {
+ binding.progressBarResult.visibility = View.GONE
+
+ // Set status text and color
+ val status = statusResponse.status ?: "N/A"
+ binding.textViewStatusValue.text = status
+ binding.textViewStatusValue.setTextColor(getStatusColor(status))
+
+ // Set amount
+ binding.textViewAmountValue.text =
+ if (statusResponse.initiation?.amount != null)
+ "${statusResponse.initiation.amount.value} ${statusResponse.initiation.amount.currency}"
+ else "N/A"
+
+ // Check final statuses associated with failure/rejection
+ if (statusResponse.status in listOf("FAILURE", "REJECTED", "CANCELLED", "EXPIRED")) {
+ binding.textViewErrorLabel.visibility = View.VISIBLE
+ binding.textViewErrorValue.visibility = View.VISIBLE
+ binding.textViewErrorValue.text = statusResponse.errorMessage ?: "Payment ended in non-success state."
+ } else {
+ binding.textViewErrorLabel.visibility = View.GONE
+ binding.textViewErrorValue.visibility = View.GONE
+ }
+ showResultDetails()
+
+ // --- Add Toast for Final Status ---
+ if (statusResponse.isFinalStatus()) {
+ val toastMessage = when (status) {
+ "SUCCESS", "EXECUTED", "SETTLED" -> "Payment Completed"
+ "FAILURE", "REJECTED" -> "Payment Failed/Declined"
+ "CANCELLED", "EXPIRED" -> "Payment Cancelled/Expired"
+ "INITIATION_COMPLETED" -> "Payment Initiation Complete" // Show only this for initiation complete
+ else -> null // Should not happen if isFinalStatus() is correct
+ }
+ // Only show toast if message is not null and status is not an intermediate state
+ if (toastMessage != null) {
+ Toast.makeText(this, toastMessage, Toast.LENGTH_LONG).show()
+ }
+ }
+ // ------------------------------------
+ }
+
+ /**
+ * Returns an appropriate color for the given payment status
+ */
+ private fun getStatusColor(status: String): Int {
+ return when (status) {
+ "SUCCESS", "EXECUTED", "SETTLED" ->
+ android.graphics.Color.parseColor("#4CAF50") // Green for success
+ "INITIATION_COMPLETED" ->
+ android.graphics.Color.parseColor("#2196F3") // Blue for completed initiation
+ "PENDING", "PROCESSING" ->
+ android.graphics.Color.parseColor("#FFA000") // Amber for in-progress
+ "FAILURE", "REJECTED", "CANCELLED", "EXPIRED" ->
+ android.graphics.Color.parseColor("#D32F2F") // Red for failure/rejection
+ else ->
+ android.graphics.Color.parseColor("#757575") // Gray for unknown status
+ }
+ }
+
+ private fun updateUiWithError(errorMessage: String) {
+ binding.progressBarResult.visibility = View.GONE
+ binding.textViewStatusValue.text = "Error"
+ binding.textViewErrorLabel.visibility = View.VISIBLE
+ binding.textViewErrorValue.visibility = View.VISIBLE
+ binding.textViewErrorValue.text = errorMessage
+ hideResultDetails(keepErrorVisible = true) // Keep error visible but hide others if needed
+ }
+
+ // Helper to hide specific result fields initially or on error
+ private fun hideResultDetails(keepErrorVisible: Boolean = false) {
+ binding.textViewAmountLabel.visibility = View.GONE
+ binding.textViewAmountValue.visibility = View.GONE
+ if (!keepErrorVisible) {
+ binding.textViewErrorLabel.visibility = View.GONE
+ binding.textViewErrorValue.visibility = View.GONE
+ }
+ }
+
+ // Helper to show result fields once data is available
+ private fun showResultDetails() {
+ binding.textViewAmountLabel.visibility = View.VISIBLE
+ binding.textViewAmountValue.visibility = View.VISIBLE
+ // Error visibility is handled in updateUiWithStatus/updateUiWithError
+ }
+
+
+ override fun onDestroy() {
+ super.onDestroy()
+ pollingJob?.cancel() // Cancel the polling job when the activity is destroyed
+ Log.d(TAG, "onDestroy: Polling job cancelled.")
+ }
+}
diff --git a/app/src/main/java/com/example/paymentdemoandroid/PaymentSdk.kt b/app/src/main/java/com/example/paymentdemoandroid/PaymentSdk.kt
new file mode 100644
index 0000000..779e133
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/PaymentSdk.kt
@@ -0,0 +1,116 @@
+package com.example.paymentdemoandroid
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.result.ActivityResultLauncher
+import androidx.appcompat.app.AppCompatActivity
+import com.example.paymentdemoandroid.model.PaymentRequest
+import com.example.paymentdemoandroid.model.PaymentResponse
+import com.example.paymentdemoandroid.repository.PaymentRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+/**
+ * Result of the payment flow, delivered via callback.
+ */
+sealed class PaymentResult {
+ data class Success(val paymentId: String) : PaymentResult()
+ data class Failure(val error: String) : PaymentResult()
+ object Cancelled : PaymentResult()
+}
+
+/**
+ * Main entry point for integrating the payment SDK into your app.
+ * Use [startPaymentFlow] to initiate a payment and [handleWebViewResult] to process results.
+ */
+object PaymentSdk {
+ private const val TAG = "PaymentSdk"
+
+ /**
+ * Launches the payment flow: initiates payment, opens WebView, and delivers result via callback.
+ *
+ * @param activity The Activity context to use for launching flows.
+ * @param paymentRequest The payment request object.
+ * @param launcher The ActivityResultLauncher for launching PaymentWebViewActivity.
+ * @param onResult Callback delivering the payment result.
+ */
+ /**
+ * Initiates the payment flow: creates payment, opens WebView, and delivers result via callback.
+ *
+ * @param activity The Activity context to use for launching flows.
+ * @param paymentRequest The payment request object.
+ * @param launcher The ActivityResultLauncher for launching PaymentWebViewActivity.
+ * @param onResult Callback delivering the payment result.
+ */
+ fun startPaymentFlow(
+ activity: AppCompatActivity,
+ paymentRequest: PaymentRequest,
+ launcher: ActivityResultLauncher,
+ onResult: (PaymentResult) -> Unit
+ ) {
+ // Launch payment initiation in coroutine
+ CoroutineScope(Dispatchers.Main).launch {
+ try {
+ Log.d(TAG, "Initiating payment - Amount: '${paymentRequest.initiation.amount.value}', Currency: '${paymentRequest.initiation.amount.currency}'")
+ val repository = PaymentRepository()
+ val response = repository.initiatePayment(paymentRequest)
+ if (response.isSuccessful && response.body() != null) {
+ val paymentResponse = response.body()!!
+ val paymentUrl = paymentResponse.payment.authentication.redirectUrl
+ // Launch the WebView flow
+ val intent = Intent(activity, PaymentWebViewActivity::class.java)
+ intent.putExtra(PaymentWebViewActivity.EXTRA_URL, paymentUrl)
+ launcher.launch(intent)
+ // The result will be handled in ActivityResult callback (see below)
+ } else {
+ val errorMsg = "Failed to initiate payment. Code: ${response.code()}"
+ Log.e(TAG, errorMsg)
+ onResult(PaymentResult.Failure(errorMsg))
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Exception during payment initiation", e)
+ onResult(PaymentResult.Failure("Error initiating payment: ${e.localizedMessage}"))
+ }
+ }
+ }
+
+ /**
+ * Handles the callback from PaymentWebViewActivity and delivers the result.
+ * Call this from your ActivityResultLauncher callback.
+ */
+ /**
+ * Handles the callback from PaymentWebViewActivity and delivers the result.
+ * Call this from your ActivityResultLauncher callback.
+ */
+ fun handleWebViewResult(resultCode: Int, data: Intent?, onResult: (PaymentResult) -> Unit) {
+ when (resultCode) {
+ Activity.RESULT_OK -> {
+ // The payment was completed, extract payment ID from callback URI
+ val uri: Uri? = data?.data
+ val errorParam = uri?.getQueryParameter("error")
+ val messageParam = uri?.getQueryParameter("message")
+ if (errorParam == "access_denied" || messageParam?.contains("User Cancelled", ignoreCase = true) == true) {
+ onResult(PaymentResult.Cancelled)
+ } else {
+ val paymentId = uri?.getQueryParameter("payment-id")
+ if (paymentId != null) {
+ onResult(PaymentResult.Success(paymentId))
+ } else {
+ onResult(PaymentResult.Failure("Payment completed but payment ID missing in callback."))
+ }
+ }
+ }
+ PaymentWebViewActivity.RESULT_PAYMENT_CANCELLED -> {
+ onResult(PaymentResult.Cancelled)
+ }
+ else -> {
+ onResult(PaymentResult.Failure("Payment flow failed or was cancelled."))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/paymentdemoandroid/PaymentWebViewActivity.kt b/app/src/main/java/com/example/paymentdemoandroid/PaymentWebViewActivity.kt
new file mode 100644
index 0000000..046dcc2
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/PaymentWebViewActivity.kt
@@ -0,0 +1,112 @@
+package com.example.paymentdemoandroid
+
+import android.annotation.SuppressLint
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.activity.OnBackPressedCallback
+import com.example.paymentdemoandroid.databinding.ActivityPaymentWebviewBinding
+
+class PaymentWebViewActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityPaymentWebviewBinding
+ private var initialPaymentUrl: String? = null
+ private var initialPaymentHost: String? = null
+
+ companion object {
+ const val EXTRA_URL = "extra_url"
+ const val RESULT_PAYMENT_CANCELLED = 2
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityPaymentWebviewBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ initialPaymentUrl = intent.getStringExtra(EXTRA_URL)
+
+ if (initialPaymentUrl == null) {
+ Log.e("PaymentWebView", "Error: No URL provided in intent extras.")
+ Toast.makeText(this, "Error: Invalid payment URL", Toast.LENGTH_LONG).show()
+ finish() // Close the activity if no URL is provided
+ return
+ }
+
+ // Extract host from the initial URL for comparison later
+ try {
+ initialPaymentHost = Uri.parse(initialPaymentUrl).host
+ Log.i("PaymentWebView", "Initial payment host: $initialPaymentHost")
+ } catch (e: Exception) {
+ Log.e("PaymentWebView", "Error parsing initial URL host: $initialPaymentUrl", e)
+ Toast.makeText(this, "Error: Invalid initial payment URL format", Toast.LENGTH_LONG).show()
+ finish()
+ return
+ }
+
+ Log.i("PaymentWebView", "Loading initial URL: $initialPaymentUrl")
+
+ // Configure WebView settings
+ binding.webView.settings.javaScriptEnabled = true // Enable JavaScript if required by the payment page
+ binding.webView.settings.domStorageEnabled = true // Enable DOM storage if needed
+ binding.webView.settings.loadsImagesAutomatically = true
+ binding.webView.settings.mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_ALWAYS_ALLOW // Adjust if needed
+
+ android.util.Log.i("PaymentWebViewActivity", "onCreate: intent.action=${intent.action}, intent.data=${intent.data}")
+ binding.webView.webViewClient = UnifiedWebViewClient(
+ context = this,
+ callbackScheme = Constants.CALLBACK_SCHEME,
+ callbackHost = Constants.CALLBACK_HOST,
+ allowedHost = initialPaymentHost,
+ onCallback = { uri: Uri? ->
+ android.util.Log.i("PaymentWebViewActivity", "onCallback received URI: $uri")
+ // Parse the callback URI for payment result
+ val errorParam = uri?.getQueryParameter("error")
+ val messageParam = uri?.getQueryParameter("message")
+ val paymentId = uri?.getQueryParameter("payment-id")
+ val resultIntent = Intent().apply { data = uri }
+ when {
+ errorParam == "access_denied" || messageParam?.contains("User Cancelled", ignoreCase = true) == true -> {
+ android.util.Log.i("PaymentWebViewActivity", "setResult: RESULT_PAYMENT_CANCELLED for URI: $uri")
+ setResult(RESULT_PAYMENT_CANCELLED, resultIntent)
+ }
+ else -> {
+ android.util.Log.i("PaymentWebViewActivity", "setResult: RESULT_OK for URI: $uri")
+ // Always treat as RESULT_OK for any non-cancellation callback
+ setResult(RESULT_OK, resultIntent)
+ }
+ }
+ finish()
+ }
+ )
+ // Load the initial URL
+ binding.webView.loadUrl(initialPaymentUrl!!)
+
+ // Modern back press handling
+ onBackPressedDispatcher.addCallback(this,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (binding.webView.canGoBack()) {
+ Log.d("PaymentWebView", "Navigating back in WebView history.")
+ binding.webView.goBack()
+ } else {
+ Log.d("PaymentWebView", "User cancelled payment via back button.")
+ setResult(RESULT_PAYMENT_CANCELLED)
+ finish()
+ }
+ }
+ }
+ )
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/paymentdemoandroid/UnifiedWebViewClient.kt b/app/src/main/java/com/example/paymentdemoandroid/UnifiedWebViewClient.kt
new file mode 100644
index 0000000..55bd51b
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/UnifiedWebViewClient.kt
@@ -0,0 +1,50 @@
+package com.example.paymentdemoandroid
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.Log
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Toast
+
+/**
+ * Unified WebViewClient for handling SDK redirects, callbacks, and external links.
+ * Use this client in both PaymentWebViewActivity and WebViewActivity for consistency and maintainability.
+ */
+class UnifiedWebViewClient(
+ private val context: Context,
+ private val callbackScheme: String,
+ private val callbackHost: String,
+ private val allowedHost: String?, // can be null if not restricting to a specific domain
+ private val onCallback: (Uri?) -> Unit
+) : WebViewClient() {
+ override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
+ val uri = request?.url
+ android.util.Log.i("UnifiedWebViewClient", "Intercepted URL: ${uri}")
+ if (uri != null) {
+ if (uri.scheme == callbackScheme && uri.host == callbackHost) {
+ android.util.Log.i("UnifiedWebViewClient", "onCallback called with URI: ${uri}")
+ onCallback(uri)
+ return true
+ }
+ }
+
+ // 2. Internal domain (allow in WebView)
+ if ((uri?.scheme == "http" || uri?.scheme == "https") && allowedHost != null && uri.host == allowedHost) {
+ return false
+ }
+
+ // 3. All other URLs (external redirect)
+ return try {
+ val intent = Intent(Intent.ACTION_VIEW, uri)
+ context.startActivity(intent)
+ true
+ } catch (e: ActivityNotFoundException) {
+ Toast.makeText(context, "Cannot open link: No app found to handle it.", Toast.LENGTH_SHORT).show()
+ true
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/paymentdemoandroid/WebViewActivity.kt b/app/src/main/java/com/example/paymentdemoandroid/WebViewActivity.kt
new file mode 100644
index 0000000..ff7bbd5
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/WebViewActivity.kt
@@ -0,0 +1,80 @@
+package com.example.paymentdemoandroid
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.appcompat.app.AppCompatActivity
+import com.example.paymentdemoandroid.databinding.ActivityWebViewBinding
+
+class WebViewActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityWebViewBinding
+
+ companion object {
+ const val EXTRA_URL = "com.example.paymentdemoandroid.URL"
+ const val TAG = "WebViewActivity"
+ const val CALLBACK_SCHEME = "example" // From Constants.CALLBACK_URL
+ const val CALLBACK_HOST = "callback" // From Constants.CALLBACK_URL
+ // TODO: Confirm the actual domain used by Nuvei payment pages
+ const val PAYMENT_PROVIDER_DOMAIN = "nuvei.com"
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityWebViewBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ val url = intent.getStringExtra(EXTRA_URL)
+
+ if (url == null) {
+ Log.e(TAG, "URL is missing, finishing activity.")
+ finish()
+ return
+ }
+
+ Log.d(TAG, "Loading URL: $url")
+
+ // Configure WebView settings
+ binding.webView.settings.javaScriptEnabled = true
+ binding.webView.settings.domStorageEnabled = true // Often needed
+ // Optional: Improve viewport handling
+ // binding.webView.settings.useWideViewPort = true
+ // binding.webView.settings.loadWithOverviewMode = true
+
+ // Set the unified WebViewClient
+ binding.webView.webViewClient = UnifiedWebViewClient(
+ context = this,
+ callbackScheme = CALLBACK_SCHEME,
+ callbackHost = CALLBACK_HOST,
+ allowedHost = PAYMENT_PROVIDER_DOMAIN,
+ onCallback = {
+ finish()
+ }
+ )
+
+ // Load the initial URL
+ binding.webView.loadUrl(url)
+
+ // Modern back press handling
+ onBackPressedDispatcher.addCallback(this,
+ object : androidx.activity.OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (binding.webView.canGoBack()) {
+ Log.d(TAG, "Navigating back in WebView history.")
+ binding.webView.goBack()
+ } else {
+ Log.d(TAG, "User cancelled WebView via back button.")
+ finish()
+ }
+ }
+ }
+ )
+ }
+
+
+}
diff --git a/app/src/main/java/com/example/paymentdemoandroid/model/PaymentRequest.kt b/app/src/main/java/com/example/paymentdemoandroid/model/PaymentRequest.kt
new file mode 100644
index 0000000..ceca55d
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/model/PaymentRequest.kt
@@ -0,0 +1,55 @@
+package com.example.paymentdemoandroid.model
+
+/**
+ * Public data classes for creating a payment request with the SDK.
+ * These are the only model classes customers should use directly.
+ */
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+// Using Moshi for JSON serialization
+@JsonClass(generateAdapter = true)
+/**
+ * Represents the payload to initiate a payment via the SDK.
+ */
+data class PaymentRequest(
+ @Json(name = "initiation") val initiation: Initiation,
+ @Json(name = "pispConsentAccepted") val pispConsentAccepted: Boolean = true
+)
+
+@JsonClass(generateAdapter = true)
+/**
+ * Details of the payment to be initiated (amount, creditor, remittance, etc).
+ */
+data class Initiation(
+ @Json(name = "refId") val refId: String,
+ @Json(name = "flowType") val flowType: String = "FULL_HOSTED_PAGES",
+ @Json(name = "remittanceInformationPrimary") val remittanceInformationPrimary: String,
+ @Json(name = "remittanceInformationSecondary") val remittanceInformationSecondary: String,
+ @Json(name = "amount") val amount: Amount,
+ @Json(name = "localInstrument") val localInstrument: String = "FASTER_PAYMENTS",
+ @Json(name = "creditor") val creditor: Creditor,
+ @Json(name = "callbackUrl") val callbackUrl: String,
+ @Json(name = "callbackState") val callbackState: String
+)
+
+@JsonClass(generateAdapter = true)
+/**
+ * Amount and currency for the payment.
+ */
+data class Amount(
+ @Json(name = "value") val value: String,
+ @Json(name = "currency") val currency: String
+)
+
+@JsonClass(generateAdapter = true)
+/**
+ * Recipient bank details (IBAN or sort code/account number).
+ */
+data class Creditor(
+ @Json(name = "name") val name: String,
+ @Json(name = "sortCode") val sortCode: String? = null,
+ @Json(name = "accountNumber") val accountNumber: String? = null,
+ @Json(name = "iban") val iban: String? = null
+)
diff --git a/app/src/main/java/com/example/paymentdemoandroid/model/PaymentResponse.kt b/app/src/main/java/com/example/paymentdemoandroid/model/PaymentResponse.kt
new file mode 100644
index 0000000..462f320
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/model/PaymentResponse.kt
@@ -0,0 +1,30 @@
+package com.example.paymentdemoandroid.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+/**
+ * [INTERNAL] Used for parsing payment initiation API responses. Not for public SDK use.
+ */
+data class PaymentResponse(
+ @Json(name = "payment") val payment: PaymentDetails
+)
+
+@JsonClass(generateAdapter = true)
+/**
+ * [INTERNAL] Details of the payment returned from the API. Not for public SDK use.
+ */
+data class PaymentDetails(
+ @Json(name = "id") val id: String,
+ @Json(name = "authentication") val authentication: Authentication
+ // Add other fields if needed, e.g., status, createdDateTime
+)
+
+@JsonClass(generateAdapter = true)
+/**
+ * [INTERNAL] Authentication/redirect URL details. Not for public SDK use.
+ */
+data class Authentication(
+ @Json(name = "redirectUrl") val redirectUrl: String
+)
diff --git a/app/src/main/java/com/example/paymentdemoandroid/model/PaymentStatusApiResponse.kt b/app/src/main/java/com/example/paymentdemoandroid/model/PaymentStatusApiResponse.kt
new file mode 100644
index 0000000..28dd0f1
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/model/PaymentStatusApiResponse.kt
@@ -0,0 +1,16 @@
+package com.example.paymentdemoandroid.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+/**
+ * Wrapper class to handle the top-level "payment" key in the API response
+ * for GET /v2/payments/{paymentId}.
+ */
+@JsonClass(generateAdapter = true)
+/**
+ * [INTERNAL] Wrapper for payment status API responses. Not for public SDK use.
+ */
+data class PaymentStatusApiResponse(
+ @Json(name = "payment") val payment: PaymentStatusResponse?
+)
diff --git a/app/src/main/java/com/example/paymentdemoandroid/model/PaymentStatusResponse.kt b/app/src/main/java/com/example/paymentdemoandroid/model/PaymentStatusResponse.kt
new file mode 100644
index 0000000..6e6ac2f
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/model/PaymentStatusResponse.kt
@@ -0,0 +1,81 @@
+package com.example.paymentdemoandroid.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+/**
+ * Represents the data structure for the payment status response from GET /v2/payments/{paymentId}.
+ * Note: The actual API response nests this structure under a top-level "payment" key.
+ * Retrofit/Moshi needs to be configured to handle this (e.g., using a wrapper or adapter).
+ */
+@JsonClass(generateAdapter = true)
+/**
+ * [INTERNAL] Used for parsing payment status API responses. Not for public SDK use.
+ */
+data class PaymentStatusResponse(
+ @Json(name = "id") val id: String?,
+ @Json(name = "status") val status: String?, // e.g., "INITIATION_COMPLETED", "EXECUTED", "FAILED"
+ @Json(name = "memberId") val memberId: String?,
+ @Json(name = "initiation") val initiation: Initiation?,
+ @Json(name = "bankPaymentId") val bankPaymentId: String?,
+ @Json(name = "bankTransactionId") val bankTransactionId: String?,
+ @Json(name = "createdDateTime") val createdDateTime: String?, // Consider parsing to Date/Timestamp
+ @Json(name = "updatedDateTime") val updatedDateTime: String?, // Consider parsing to Date/Timestamp
+ @Json(name = "bankPaymentStatus") val bankPaymentStatus: String?, // e.g., "AcceptedSettlementCompleted"
+ @Json(name = "errorMessage") val errorMessage: String? // Populated on failure/rejection
+) {
+
+ @JsonClass(generateAdapter = true)
+ /**
+ * [INTERNAL] Initiation details for status API. Not for public SDK use.
+ */
+data class Initiation(
+ @Json(name = "bankId") val bankId: String?,
+ @Json(name = "refId") val refId: String?,
+ @Json(name = "remittanceInformationPrimary") val remittanceInformationPrimary: String?,
+ @Json(name = "remittanceInformationSecondary") val remittanceInformationSecondary: String?,
+ @Json(name = "amount") val amount: Amount?,
+ @Json(name = "localInstrument") val localInstrument: String?,
+ @Json(name = "creditor") val creditor: Creditor?,
+ @Json(name = "callbackUrl") val callbackUrl: String?,
+ @Json(name = "callbackState") val callbackState: String?,
+ @Json(name = "flowType") val flowType: String?
+ )
+
+ @JsonClass(generateAdapter = true)
+ /**
+ * [INTERNAL] Amount details for status API. Not for public SDK use.
+ */
+data class Amount(
+ @Json(name = "value") val value: String?,
+ @Json(name = "currency") val currency: String?
+ )
+
+ @JsonClass(generateAdapter = true)
+ /**
+ * [INTERNAL] Creditor details for status API. Not for public SDK use.
+ */
+data class Creditor(
+ @Json(name = "name") val name: String?,
+ @Json(name = "sortCode") val sortCode: String?,
+ @Json(name = "accountNumber") val accountNumber: String?
+ )
+
+ /**
+ * Checks if the current payment status is considered final (non-polling).
+ * Note: This list might need adjustment based on the exact final statuses returned by the API.
+ */
+ fun isFinalStatus(): Boolean {
+ // Add all known terminal statuses here based on API documentation
+ return status in listOf(
+ "SUCCESS", // Hypothetical final success status
+ "EXECUTED", // Likely final success
+ "SETTLED", // Likely final success
+ "FAILURE",
+ "REJECTED",
+ "CANCELLED",
+ "EXPIRED",
+ "INITIATION_COMPLETED" // Now considered final for polling purposes
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/paymentdemoandroid/network/ApiService.kt b/app/src/main/java/com/example/paymentdemoandroid/network/ApiService.kt
new file mode 100644
index 0000000..1ef6c28
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/network/ApiService.kt
@@ -0,0 +1,27 @@
+package com.example.paymentdemoandroid.network
+
+import com.example.paymentdemoandroid.model.PaymentRequest
+import com.example.paymentdemoandroid.model.PaymentResponse
+import com.example.paymentdemoandroid.model.PaymentStatusApiResponse
+import com.example.paymentdemoandroid.model.PaymentStatusResponse
+import retrofit2.Response
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.POST
+import retrofit2.http.Path
+
+interface ApiService {
+
+ @POST("v2/payments")
+ suspend fun initiatePayment(
+ // Remove the Authorization Header parameter, it's handled by the interceptor
+ // @Header("Authorization") apiKey: String,
+ @Body paymentRequest: PaymentRequest
+ ): Response
+
+ @GET("v2/payments/{paymentId}")
+ suspend fun getPaymentStatus(
+ @Path("paymentId") paymentId: String
+ ): Response
+
+}
diff --git a/app/src/main/java/com/example/paymentdemoandroid/network/RetrofitClient.kt b/app/src/main/java/com/example/paymentdemoandroid/network/RetrofitClient.kt
new file mode 100644
index 0000000..f6c95fa
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/network/RetrofitClient.kt
@@ -0,0 +1,52 @@
+package com.example.paymentdemoandroid.network
+
+import android.util.Log
+import com.example.paymentdemoandroid.Constants
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.Response
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+
+object RetrofitClient {
+
+ private val moshi = Moshi.Builder()
+ .add(KotlinJsonAdapterFactory())
+ .build()
+
+ private val loggingInterceptor = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+
+ private class AuthInterceptor : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+ val apiKey = Constants.selectedEnvironment.apiKey
+ Log.d("AuthInterceptor", "Using API Key for ${Constants.selectedEnvironment.displayName}")
+ val newRequest = originalRequest.newBuilder()
+ .header("Authorization", "Basic $apiKey")
+ .header("Content-Type", "application/json")
+ .build()
+ return chain.proceed(newRequest)
+ }
+ }
+
+ private val okHttpClient = OkHttpClient.Builder()
+ .addInterceptor(AuthInterceptor())
+ .addInterceptor(loggingInterceptor)
+ .build()
+
+ fun getApiService(): ApiService {
+ val currentEnvironment = Constants.selectedEnvironment
+ Log.d("RetrofitClient", "Creating ApiService for ${currentEnvironment.displayName} with URL: ${currentEnvironment.baseUrl}")
+ return Retrofit.Builder()
+ .baseUrl(currentEnvironment.baseUrl)
+ .client(okHttpClient)
+ .addConverterFactory(MoshiConverterFactory.create(moshi))
+ .build()
+ .create(ApiService::class.java)
+ }
+}
diff --git a/app/src/main/java/com/example/paymentdemoandroid/repository/PaymentRepository.kt b/app/src/main/java/com/example/paymentdemoandroid/repository/PaymentRepository.kt
new file mode 100644
index 0000000..6988a85
--- /dev/null
+++ b/app/src/main/java/com/example/paymentdemoandroid/repository/PaymentRepository.kt
@@ -0,0 +1,43 @@
+package com.example.paymentdemoandroid.repository
+
+import com.example.paymentdemoandroid.Constants
+import com.example.paymentdemoandroid.model.PaymentRequest
+import com.example.paymentdemoandroid.model.PaymentResponse
+import com.example.paymentdemoandroid.model.PaymentStatusApiResponse
+import com.example.paymentdemoandroid.model.PaymentStatusResponse
+import com.example.paymentdemoandroid.network.RetrofitClient
+import retrofit2.Response
+
+/**
+ * [INTERNAL] Handles API calls for payment initiation and status.
+ * Not intended for direct use by SDK integrators.
+ */
+internal class PaymentRepository {
+
+ // Updated to call the function that builds ApiService dynamically
+ suspend fun initiatePayment(paymentRequest: PaymentRequest): Response {
+ // Get the ApiService instance configured for the selected environment
+ val apiService = RetrofitClient.getApiService()
+ return apiService.initiatePayment(paymentRequest)
+ // Old call: return RetrofitClient.instance.initiatePayment(paymentRequest)
+ }
+
+ suspend fun getPaymentStatus(paymentId: String): Response {
+ val apiResponse: Response = RetrofitClient.getApiService().getPaymentStatus(paymentId)
+
+ // Handle the response and extract the nested PaymentStatusResponse
+ return if (apiResponse.isSuccessful && apiResponse.body()?.payment != null) {
+ // Create a new successful Response containing the unwrapped PaymentStatusResponse
+ Response.success(apiResponse.body()!!.payment, apiResponse.raw())
+ } else if (!apiResponse.isSuccessful) {
+ // Propagate the error response
+ Response.error(apiResponse.code(), apiResponse.errorBody() ?: okhttp3.ResponseBody.create(null, ""))
+ } else {
+ // Handle the case where the response was successful but the body or nested payment was null
+ // Return an error or a specific representation indicating missing data
+ // For simplicity, returning an error similar to a non-successful response
+ // You might want a custom error handling strategy here.
+ Response.error(500, okhttp3.ResponseBody.create(null, "")) // Or a more specific error code/body
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_bank_white_24dp.xml b/app/src/main/res/drawable/ic_bank_white_24dp.xml
new file mode 100644
index 0000000..c1c9e02
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bank_white_24dp.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..fb7cbc9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/product_placeholder.xml b/app/src/main/res/drawable/product_placeholder.xml
new file mode 100644
index 0000000..d00fb39
--- /dev/null
+++ b/app/src/main/res/drawable/product_placeholder.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..edb80b7
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,519 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_payment_result.xml b/app/src/main/res/layout/activity_payment_result.xml
new file mode 100644
index 0000000..2cfe9fa
--- /dev/null
+++ b/app/src/main/res/layout/activity_payment_result.xml
@@ -0,0 +1,187 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_payment_webview.xml b/app/src/main/res/layout/activity_payment_webview.xml
new file mode 100644
index 0000000..81e2a16
--- /dev/null
+++ b/app/src/main/res/layout/activity_payment_webview.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_web_view.xml b/app/src/main/res/layout/activity_web_view.xml
new file mode 100644
index 0000000..ac2242e
--- /dev/null
+++ b/app/src/main/res/layout/activity_web_view.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..d372a4f
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..d372a4f
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..48918db
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..1134a4d
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ PaymentDemoAndroid
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..2dd30bb
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..dc1cd06
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..fd65ac6
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/com/example/paymentdemoandroid/PaymentSdkTest.kt b/app/src/test/java/com/example/paymentdemoandroid/PaymentSdkTest.kt
new file mode 100644
index 0000000..5866fa7
--- /dev/null
+++ b/app/src/test/java/com/example/paymentdemoandroid/PaymentSdkTest.kt
@@ -0,0 +1,78 @@
+package com.example.paymentdemoandroid
+
+import android.app.Activity
+import android.content.Intent
+import androidx.activity.result.ActivityResultLauncher
+import androidx.appcompat.app.AppCompatActivity
+import com.example.paymentdemoandroid.model.Amount
+import com.example.paymentdemoandroid.model.Creditor
+import com.example.paymentdemoandroid.model.Initiation
+import com.example.paymentdemoandroid.model.PaymentRequest
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.*
+import org.junit.Test
+import org.mockito.kotlin.*
+import java.util.UUID
+
+class PaymentSdkTest {
+ private val dummyActivity = mock()
+ private val dummyLauncher = mock>()
+
+ private fun buildPaymentRequest(): PaymentRequest = PaymentRequest(
+ initiation = Initiation(
+ refId = UUID.randomUUID().toString(),
+ flowType = "FULL_HOSTED_PAGES",
+ remittanceInformationPrimary = "Test",
+ remittanceInformationSecondary = "Test2",
+ amount = Amount("10.00", "GBP"),
+ localInstrument = "FASTER_PAYMENTS",
+ creditor = Creditor(name = "Test Creditor", sortCode = "123456", accountNumber = "12345678", iban = null),
+ callbackUrl = "paymentdemoapp://payment-complete",
+ callbackState = UUID.randomUUID().toString()
+ ),
+ pispConsentAccepted = true
+ )
+
+ @Test
+ fun `PaymentResult Success delivers paymentId`() {
+ val intent = Intent().apply {
+ data = android.net.Uri.parse("paymentdemoapp://payment-complete?payment-id=abc123")
+ }
+ var result: PaymentResult? = null
+ PaymentSdk.handleWebViewResult(Activity.RESULT_OK, intent) {
+ println("Callback called with: $it")
+ result = it
+ }
+ if (result == null) fail("Callback was not called; result is still null after handleWebViewResult.")
+ assertNotNull("Result should not be null", result)
+ assertTrue("Result should be Success but was $result", result is PaymentResult.Success)
+ assertEquals("abc123", (result as PaymentResult.Success).paymentId)
+ }
+
+ @Test
+ fun `PaymentResult Failure delivers error on missing paymentId`() {
+ val intent = Intent().apply {
+ data = android.net.Uri.parse("paymentdemoapp://payment-complete")
+ }
+ var result: PaymentResult? = null
+ PaymentSdk.handleWebViewResult(Activity.RESULT_OK, intent) {
+ println("Callback called with: $it")
+ result = it
+ }
+ if (result == null) fail("Callback was not called; result is still null after handleWebViewResult.")
+ assertNotNull("Result should not be null", result)
+ assertTrue("Result should be Failure but was $result", result is PaymentResult.Failure)
+ assertTrue((result as PaymentResult.Failure).error.contains("missing", ignoreCase = true))
+ }
+
+ @Test
+ fun `PaymentResult Cancelled is delivered`() {
+ var result: PaymentResult? = null
+ PaymentSdk.handleWebViewResult(PaymentWebViewActivity.RESULT_PAYMENT_CANCELLED, null) {
+ result = it
+ }
+ assertTrue(result is PaymentResult.Cancelled)
+ }
+
+ // You can add more tests here for edge cases, e.g., null intents, unexpected result codes, etc.
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..1cbfbba
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,7 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id("com.android.application") version "8.2.1" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.22" apply false // Compatible with AGP 7.4.0
+ id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false // Matching KSP version
+ id("com.google.gms.google-services") version "4.4.3" apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..40f0273
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,7 @@
+android.defaults.buildfeatures.buildconfig=true
+android.nonFinalResIds=false
+android.nonTransitiveRClass=false
+properties
+android.useAndroidX=true
+android.enableJetifier=true
+BETA_API_KEY=your_beta_api_key_here
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..37f853b
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..f5feea6
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..9b42019
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..83f0823
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "PaymentDemoAndroid"
+include(":app")