Ref(DeviceInfo-PaymentResult-WebView):

- Refactor `PaymentResultEntity` to include a nested `PaymentResultDataEntity` and companion factory methods for success and failure.
- Update `PaymentResultStatus` enum members from `OK`/`ERROR` to `SUCCESS`/`FAILURE`.
- Modify `PspService` and its implementations (`P3`, `PS4`, `Stage`) to return a non-nullable `PaymentResultEntity`.
- Enhance `PspWebView` with improved instance lifecycle management, `rememberUpdatedState` for callbacks, and new `onWebViewReady` and `onPageFinished` hooks.
- Optimize `PspWebView` to prevent redundant `loadUrl` calls during recomposition.
- Add `README.md` and `AGENT.md` to provide project architecture overviews and hardware-specific development guidelines.
- Update `TisWebViewScreen` and `StageWebViewScreen` to handle the refactored result models and utilize new WebView callbacks.
This commit is contained in:
Amir Mousavi
2026-05-17 13:40:32 +03:30
parent 2b03af6e52
commit 08060001e1
14 changed files with 254 additions and 72 deletions
+14
View File
@@ -12,10 +12,24 @@
</SelectionState>
<SelectionState runConfigName="tis_app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-12T16:21:58.593804400Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=TP030090489" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="stage_app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-12T16:21:58.593804400Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=TP030090489" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
+56
View File
@@ -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.
+49
View File
@@ -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.
@@ -12,5 +12,5 @@ interface PspService {
fun decodePosResponse(
id: String?,
activityResult: ActivityResult
): PaymentResultEntity?
): PaymentResultEntity
}
@@ -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
)
}
)
fun failure(
id: String? = null,
message: String? = "Failure",
data: PaymentResultDataEntity? = null
): PaymentResultEntity {
return PaymentResultEntity(
id = id,
status = PaymentResultStatus.FAILURE,
message = message,
paymentResultDataEntity = data
)
}
}
}
@@ -1,6 +1,6 @@
package com.example.core.model
enum class PaymentResultStatus {
OK,
ERROR
SUCCESS,
FAILURE
}
@@ -46,13 +46,13 @@ fun PspWebView(
val scope = rememberCoroutineScope()
val context = LocalContext.current
var lastBackTime by remember { mutableLongStateOf(0L) }
var webView by remember { mutableStateOf<WebView?>(null) }
var webViewInstance by remember { mutableStateOf<WebView?>(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)
}
}
)
}
@@ -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<Map<String, Any>>() {}.type
val data = gson.fromJson<Map<String, Any>>(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 ?: "در هنگام پرداخت خطایی رخ داده است!"
)
}
}
}
+6 -2
View File
@@ -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
)
}
}
}
@@ -18,5 +18,5 @@ class Ps4Service @Inject constructor() : PspService {
override fun decodePosResponse(
id: String?,
activityResult: ActivityResult
): PaymentResultEntity? = null
): PaymentResultEntity = PaymentResultEntity.success()
}
@@ -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
}
@@ -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<PrintEntity>(
@@ -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<String?>(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
}
}
)
}
@@ -32,7 +32,7 @@ class WebViewModel @Inject constructor(
fun paymentResult(
id: String?,
activityResult: ActivityResult
): PaymentResultEntity? {
): PaymentResultEntity {
return pspService.decodePosResponse(
id = id,
activityResult = activityResult