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 @@ + + + + +