diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 9ee426c..de575c8 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -12,10 +12,24 @@ diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..71d2eda --- /dev/null +++ b/AGENT.md @@ -0,0 +1,56 @@ +# Agent Instructions: PSP Project + +This document provides essential context and guidelines for AI agents working on the PSP (Payment Service Provider) project. + +## Project Purpose +A multi-module Android application for specialized POS (Point of Sale) hardware, providing a bridge between a Web-based POS interface and native hardware features (Thermal Printing & Payment Terminal). + +## Architecture Overview +The project follows **Dependency Inversion** principles. Features are defined as interfaces in `:core` and implemented in hardware-specific modules like `:p3` or `:ps4`. + +### Key Modules +- **`:core`**: Shared domain models (`PrintEntity`, `PaymentResultEntity`) and interfaces (`PspService`, `Printer`). +- **`:design_system`**: Contains `PspWebView` and the `PspJavaScriptInterface` (Web-Native Bridge). +- **`:p3` / `:ps4`**: Hardware SDK integrations. +- **`:app` / `:tis_app` / `:stage_app`**: Application entry points and Hilt dependency configuration. + +## Critical Classes & Interfaces + +### 1. Web-Native Bridge +- **`PspWebView.kt`**: The core component. Uses a `WebViewManager` for pooling and is highly optimized for weak hardware. +- **`PspJavaScriptInterface.kt`**: Defines the methods exposed to JavaScript (`pay`, `print`, `reload`, `uuid`). + - **Important**: Methods must be annotated with `@JavascriptInterface` and `@Keep`. + - **Note**: Numbers from JS arrive as `Double`. + +### 2. Printer Implementation (`:p3`) +- **`P3Printer.kt`**: Implements the `Printer` interface. +- **`POIPrinterManager`**: The vendor SDK class. +- **Font Handling**: Use `getFontPath(resourceName: String)` to copy fonts from `res/font` to internal storage before passing the path to the SDK. +- **Logo Rendering**: Use `drawableToBitmap()` to handle VectorDrawables correctly. + +### 3. Payment Service (`:p3`) +- **`Ps3Service.kt`**: Implementation of `PspService`. +- **`IapPosService.kt`**: Handles communication with the underlying POS payment application. + +## Common Agent Tasks + +### Adding a new Bridge Method +1. Add the method to `PspJavaScriptInterface.kt` with `@JavascriptInterface` and `@Keep`. +2. Update the constructor of `PspJavaScriptInterface` to take a lambda. +3. Update `PspWebView.kt` to pass the lambda from the UI layer. + +### Optimizing for Weak Hardware +When performance issues arise in the WebView: +1. Check `WebViewManager.kt` settings. +2. Ensure `setLayerType(View.LAYER_TYPE_HARDWARE, null)` is set. +3. Keep `setOffscreenPreRaster(false)` for very weak hardware to save RAM. +4. Ensure `loadUrl` is NOT called in the `AndroidView.update` block (use `LaunchedEffect` instead). + +## Development Rules +- **RTL Support**: The printer uses Persian/Arabic text. Always ensure numbers are converted to Persian digits using `toPersianDigits()` where appropriate. +- **Dependency Injection**: Use Hilt for all service injections. Do not instantiate implementation classes (`P3Printer`, `Ps3Service`) directly in the app modules. +- **WebView Lifecycle**: Always release WebViews back to the `WebViewManager` in `onDispose`. + +## Mocking in `:stage_app` +- Use `StageViewModel` to return success/failure mocks for payment results without requiring physical hardware. +- Use the `PaymentResultEntity.success()` or `PaymentResultEntity.failure()` factory methods. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8c57fe --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# PSP (Payment Service Provider) Project + +This project is a multi-module Android application designed to provide payment and printing services on specialized POS hardware. It uses a modular architecture to separate core logic, hardware SDKs, and different application variants. + +## Module Structure + +### Core Modules +* **`:core`**: The backbone of the project. Contains shared domain models (`PaymentResultEntity`, `PrintEntity`), interfaces for services (`PspService`, `Printer`), and common utilities. It has no dependencies on other modules. +* **`:design_system`**: Contains shared UI components and utilities. The most critical component is `PspWebView`, a highly optimized WebView wrapper used across all app variants for rendering the web-based POS interface. It depends on `:core`. + +### Hardware Implementation Modules +These modules contain the specific SDK integrations for different POS hardware. +* **`:p3`**: Implementation of `PspService` and `Printer` for P3 hardware. Includes SDK-specific logic for handling payments and thermal printing. +* **`:ps4`**: Implementation for PS4 hardware variants. + +### Application Variants +* **`:app`**: The primary production application. +* **`:tis_app`**: A specialized version of the application targeting the TIS environment (`https://tis.shift-am.ir`). +* **`:stage_app`**: A development and staging variant used for testing. It often uses mock implementations (like `StageViewModel`) to simulate POS behavior without requiring physical hardware. + +## Architecture & Relationships + +```mermaid +graph TD + subgraph Apps + tis_app --> core + tis_app --> design_system + stage_app --> core + stage_app --> design_system + app --> core + app --> design_system + end + + subgraph Hardware SDKs + p3 --> core + ps4 --> core + end + + design_system --> core +``` + +1. **Dependency Inversion**: Apps depend on interfaces defined in `:core`. The actual hardware implementation (`:p3` or `:ps4`) is injected via Hilt, allowing the same app logic to run on different devices. +2. **Web-Native Bridge**: The `design_system` module provides `PspJavaScriptInterface`, which allows the Web POS (running inside `PspWebView`) to communicate with native hardware features like the printer and payment terminal. +3. **Hardware Performance**: The WebView is optimized for weak POS hardware with features like pooled instances, hardware acceleration, and lean rendering profiles. + +## Key Features +* **Thermal Printing**: Optimized for RTL (Persian) text and high-performance bitmap rendering for logos (including VectorDrawable support). +* **Payment Integration**: Standardized bridge for processing payments and returning structured results to the web-front-end. +* **Weak Hardware Optimization**: Custom WebView configurations to handle smooth animations (drawers/sheets) on low-spec devices. diff --git a/core/src/main/java/com/example/core/domain/PspService.kt b/core/src/main/java/com/example/core/domain/PspService.kt index ffd5f64..d0edf7b 100644 --- a/core/src/main/java/com/example/core/domain/PspService.kt +++ b/core/src/main/java/com/example/core/domain/PspService.kt @@ -12,5 +12,5 @@ interface PspService { fun decodePosResponse( id: String?, activityResult: ActivityResult - ): PaymentResultEntity? + ): PaymentResultEntity } \ No newline at end of file diff --git a/core/src/main/java/com/example/core/model/PaymentResultEntity.kt b/core/src/main/java/com/example/core/model/PaymentResultEntity.kt index 0738182..41601a2 100644 --- a/core/src/main/java/com/example/core/model/PaymentResultEntity.kt +++ b/core/src/main/java/com/example/core/model/PaymentResultEntity.kt @@ -1,15 +1,48 @@ package com.example.core.model +import com.google.gson.annotations.SerializedName + data class PaymentResultEntity( - val id : String? = null, - val status : PaymentResultStatus, - val terminalId : String, - val stan : String, - val rrn : String, - val responseCode : String, - val customerCardNO : String, - val transactionDateTime : String, - val description : String?, + val id: String? = null, + val status: PaymentResultStatus, + val message: String?, + @SerializedName("data") + val paymentResultDataEntity: PaymentResultDataEntity? = null, +) { + data class PaymentResultDataEntity( + val terminalId: String, + val stan: String, + val rrn: String, + val responseCode: String, + val customerCardNO: String, + val transactionDateTime: String, + ) + companion object { + fun success( + id: String? = null, + message: String? = "Success", + data: PaymentResultDataEntity? = null + ): PaymentResultEntity { + return PaymentResultEntity( + id = id, + status = PaymentResultStatus.SUCCESS, + message = message, + paymentResultDataEntity = data + ) + } -) \ No newline at end of file + fun failure( + id: String? = null, + message: String? = "Failure", + data: PaymentResultDataEntity? = null + ): PaymentResultEntity { + return PaymentResultEntity( + id = id, + status = PaymentResultStatus.FAILURE, + message = message, + paymentResultDataEntity = data + ) + } + } +} diff --git a/core/src/main/java/com/example/core/model/PaymentResultStatus.kt b/core/src/main/java/com/example/core/model/PaymentResultStatus.kt index d5826c0..cea2a18 100644 --- a/core/src/main/java/com/example/core/model/PaymentResultStatus.kt +++ b/core/src/main/java/com/example/core/model/PaymentResultStatus.kt @@ -1,6 +1,6 @@ package com.example.core.model enum class PaymentResultStatus { - OK, - ERROR + SUCCESS, + FAILURE } diff --git a/design_system/src/main/java/com/example/design_system/PspWebView.kt b/design_system/src/main/java/com/example/design_system/PspWebView.kt index 21db73e..2718240 100644 --- a/design_system/src/main/java/com/example/design_system/PspWebView.kt +++ b/design_system/src/main/java/com/example/design_system/PspWebView.kt @@ -46,13 +46,13 @@ fun PspWebView( val scope = rememberCoroutineScope() val context = LocalContext.current var lastBackTime by remember { mutableLongStateOf(0L) } - var webView by remember { mutableStateOf(null) } + var webViewInstance by remember { mutableStateOf(null) } - - // Use rememberUpdatedState to ensure the bridge always calls the latest callbacks val currentOnPayClick by rememberUpdatedState(onPayClick) val currentOnPrintClick by rememberUpdatedState(onPrintClick) val currentOnDeviceInfo by rememberUpdatedState(onDeviceInfo) + val currentOnWebViewReady by rememberUpdatedState(onWebViewReady) + val currentOnPageFinished by rememberUpdatedState(onPageFinished) val bridge = remember { PspJavaScriptInterface( @@ -63,8 +63,8 @@ fun PspWebView( } BackHandler { - if (canGoBack.value) { - webView?.goBack() + if (webViewInstance?.canGoBack() == true) { + webViewInstance?.goBack() } else { val now = System.currentTimeMillis() if (now - lastBackTime < 2000) { @@ -72,38 +72,34 @@ fun PspWebView( } else { lastBackTime = now scope.launch { - SnackbarController.sendEvent( - SnackbarEvent( - message = "برای خروج، دوباره بر روی بازگشت کلیک کنید." - ) - ) + SnackbarController.sendEvent(SnackbarEvent("برای خروج، دوباره کلیک کنید.")) } } } } + // Manage pooling and lifecycle DisposableEffect(Unit) { - webView = WebViewManager.getWebView(context) - webView?.let(onWebViewReady) onDispose { - webView?.let { WebViewManager.releaseWebView(it) } + webViewInstance?.let { WebViewManager.releaseWebView(it) } } } AndroidView( modifier = modifier, factory = { ctx -> - (webView ?: WebViewManager.getWebView(ctx)).apply { + val wv = WebViewManager.getWebView(ctx).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) overScrollMode = View.OVER_SCROLL_NEVER + webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) canGoBack.value = view?.canGoBack() ?: false - view?.let(onPageFinished) + view?.let(currentOnPageFinished) } } @@ -112,16 +108,21 @@ fun PspWebView( javaScriptEnabled = true domStorageEnabled = true cacheMode = WebSettings.LOAD_NO_CACHE - allowFileAccess = true // Needed for Service Worker + allowFileAccess = true } - clearCache(true) + removeJavascriptInterface("NativeBridge") addJavascriptInterface(bridge, "NativeBridge") } + + webViewInstance = wv + currentOnWebViewReady(wv) // CRITICAL: Send the ACTUAL instance being shown + wv }, update = { - it.loadUrl(url) + if (it.url != url) { + it.loadUrl(url) + } } ) - } diff --git a/p3/src/main/java/com/example/p3/IapPosService.kt b/p3/src/main/java/com/example/p3/IapPosService.kt index 215941b..583fd32 100644 --- a/p3/src/main/java/com/example/p3/IapPosService.kt +++ b/p3/src/main/java/com/example/p3/IapPosService.kt @@ -159,24 +159,33 @@ class IapPosService @Inject constructor( if (result.isNullOrEmpty()) { - throw Exception("پرداخت کنسل شد") + return PaymentResultEntity.failure( + id = id, + message = "پرداخت لغو شد!" + ) } return try { val type = object : TypeToken>() {}.type val data = gson.fromJson>(result, type) PaymentResultEntity( id = id, - status = if (data["Status"] == "OK") PaymentResultStatus.OK else PaymentResultStatus.ERROR, - terminalId = data["TerminalId"].toString(), - stan = data["STAN"].toString(), - rrn = data["RRN"].toString(), - responseCode = data["ResponseCode"].toString(), - customerCardNO = data["CustomerCardNO"].toString(), - transactionDateTime = data["TransactionDateTime"].toString(), - description = data["Description"].toString() + status = if (data["Status"] == "OK") PaymentResultStatus.SUCCESS else PaymentResultStatus.FAILURE, + paymentResultDataEntity = PaymentResultEntity.PaymentResultDataEntity( + terminalId = data["TerminalId"].toString(), + stan = data["STAN"].toString(), + rrn = data["RRN"].toString(), + responseCode = data["ResponseCode"].toString(), + customerCardNO = data["CustomerCardNO"].toString(), + transactionDateTime = data["TransactionDateTime"].toString(), + ), + message = data["Description"].toString() + ) } catch (e: Exception) { - throw Exception("POS response is not a JSON object.") + PaymentResultEntity.failure( + id = id, + message = e.message ?: "در هنگام پرداخت خطایی رخ داده است!" + ) } } } diff --git a/p3/src/main/java/com/example/p3/P3Service.kt b/p3/src/main/java/com/example/p3/P3Service.kt index 968fcc5..f455010 100644 --- a/p3/src/main/java/com/example/p3/P3Service.kt +++ b/p3/src/main/java/com/example/p3/P3Service.kt @@ -30,14 +30,18 @@ class P3Service @Inject constructor( override fun decodePosResponse( id: String?, activityResult: ActivityResult - ): PaymentResultEntity? { + ): PaymentResultEntity { return try { iapPosService.decodeResponse( id = id, activityResult = activityResult ) } catch (e: Exception) { - null + e.printStackTrace() + PaymentResultEntity.failure( + id = id, + e.message + ) } } } diff --git a/ps4/src/main/java/com/example/ps4/Ps4Service.kt b/ps4/src/main/java/com/example/ps4/Ps4Service.kt index 76ec744..b3b3870 100644 --- a/ps4/src/main/java/com/example/ps4/Ps4Service.kt +++ b/ps4/src/main/java/com/example/ps4/Ps4Service.kt @@ -18,5 +18,5 @@ class Ps4Service @Inject constructor() : PspService { override fun decodePosResponse( id: String?, activityResult: ActivityResult - ): PaymentResultEntity? = null + ): PaymentResultEntity = PaymentResultEntity.success() } diff --git a/stage_app/src/main/java/com/example/stage_app/screen/StageWebViewScreen.kt b/stage_app/src/main/java/com/example/stage_app/screen/StageWebViewScreen.kt index 21c84bd..16c1981 100644 --- a/stage_app/src/main/java/com/example/stage_app/screen/StageWebViewScreen.kt +++ b/stage_app/src/main/java/com/example/stage_app/screen/StageWebViewScreen.kt @@ -54,13 +54,13 @@ fun StageWebViewScreen( activityResult = result ) val jsResult = viewModel.postPaymentResult(paymentResultEntity) - val currentWebView = webView - if (currentWebView == null) { - Log.w(stageWebViewTag, "Skipping onPaymentResult JS callback: WebView is not ready yet.") - } else { - currentWebView.post { - currentWebView.evaluateJavascript(jsResult, null) + webView?.let { + it.post { + it.evaluateJavascript(jsResult, null) } + } ?: run { + Log.w(stageWebViewTag, "Skipping onPaymentResult JS callback: WebView is not ready yet.") + } isPaymentProcessing = false } diff --git a/stage_app/src/main/java/com/example/stage_app/viewmodel/StageViewModel.kt b/stage_app/src/main/java/com/example/stage_app/viewmodel/StageViewModel.kt index 555b9b0..a03df2d 100644 --- a/stage_app/src/main/java/com/example/stage_app/viewmodel/StageViewModel.kt +++ b/stage_app/src/main/java/com/example/stage_app/viewmodel/StageViewModel.kt @@ -32,17 +32,23 @@ class StageViewModel @Inject constructor( id: String?, activityResult: ActivityResult ): PaymentResultEntity { - return PaymentResultEntity( + val mockPaymentResult = PaymentResultEntity.success( id = id, - status = com.example.core.model.PaymentResultStatus.ERROR, - terminalId = "123456", - stan = "123456", - rrn = "123456", - responseCode = "01", - customerCardNO = "1234567890123456", - transactionDateTime = "14030101120000", - description = "تراکنش ناموفق" + message = "تراکنش با موفقیت انجام شد", + data = PaymentResultEntity.PaymentResultDataEntity( + terminalId = "123456", + stan = "123456", + rrn = "123456789012", + responseCode = "00", + customerCardNO = "603799******1234", + transactionDateTime = "1403/05/20 12:30:45" + ) ) + return mockPaymentResult +// return pspService.decodePosResponse( +// id = id, +// activityResult = activityResult +// ) } private val mock = listOf( diff --git a/tis_app/src/main/java/com/example/tis_app/screen/TisWebViewScreen.kt b/tis_app/src/main/java/com/example/tis_app/screen/TisWebViewScreen.kt index 32b59b4..78511b6 100644 --- a/tis_app/src/main/java/com/example/tis_app/screen/TisWebViewScreen.kt +++ b/tis_app/src/main/java/com/example/tis_app/screen/TisWebViewScreen.kt @@ -1,5 +1,6 @@ package com.example.tis_app.screen +import android.util.Log import android.webkit.WebView import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -17,10 +18,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.example.core.getAndroidId import com.example.core.getDeviceName import com.example.design_system.PspWebView -import com.example.design_system.util.WebViewManager import com.example.tis_app.viewmodel.WebViewModel private const val tisWebUrl = "https://tis.shift-am.ir" +private const val tisWebViewTag = "TisWebViewScreen" @Composable fun TisWebViewScreen( @@ -29,10 +30,11 @@ fun TisWebViewScreen( ) { val context = LocalContext.current - var webView: WebView by remember { mutableStateOf(WebViewManager.getWebView(context)) } + var webView: WebView? by remember { mutableStateOf(null) } var currentPaymentId by rememberSaveable { mutableStateOf(null) } var isPaymentProcessing by rememberSaveable { mutableStateOf(false) } var isPrinting by rememberSaveable { mutableStateOf(false) } + var initialCallbackSent by rememberSaveable { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() val deviceInfo by remember { mutableStateOf( @@ -50,17 +52,16 @@ fun TisWebViewScreen( val paymentResultEntity = viewModel.paymentResult( id = currentPaymentId, activityResult = result - ) - - webView.post { - webView.evaluateJavascript( - viewModel.postPaymentResult(paymentResultEntity = paymentResultEntity), - null - ) + val jsResult = viewModel.postPaymentResult(paymentResultEntity) + webView?.let { + it.post { + it.evaluateJavascript(jsResult, null) + } + } ?: run { + Log.w(tisWebViewTag, "Skipping onPaymentResult JS callback: WebView is not ready yet.") } isPaymentProcessing = false - } PspWebView( @@ -86,6 +87,15 @@ fun TisWebViewScreen( }, onDeviceInfo = { deviceInfo + }, + onWebViewReady = { readyWebView -> + webView = readyWebView + }, + onPageFinished = { loadedWebView -> + if (!initialCallbackSent) { + loadedWebView.evaluateJavascript(viewModel.postPaymentResult(null), null) + initialCallbackSent = true + } } ) -} \ No newline at end of file +} diff --git a/tis_app/src/main/java/com/example/tis_app/viewmodel/WebViewModel.kt b/tis_app/src/main/java/com/example/tis_app/viewmodel/WebViewModel.kt index 4cd3a3a..b486a31 100644 --- a/tis_app/src/main/java/com/example/tis_app/viewmodel/WebViewModel.kt +++ b/tis_app/src/main/java/com/example/tis_app/viewmodel/WebViewModel.kt @@ -32,7 +32,7 @@ class WebViewModel @Inject constructor( fun paymentResult( id: String?, activityResult: ActivityResult - ): PaymentResultEntity? { + ): PaymentResultEntity { return pspService.decodePosResponse( id = id, activityResult = activityResult