refactor code + generate qr (bug pending)

This commit is contained in:
riz081 2025-08-02 11:43:46 +07:00
parent e0aec6e840
commit 8cef8fdb22
15 changed files with 2451 additions and 1607 deletions

View File

@ -59,8 +59,9 @@
android:name=".QrisActivity"
android:exported="false" />
<!-- FIXED: Updated to correct package path -->
<activity
android:name=".QrisResultActivity"
android:name=".qris.view.QrisResultActivity"
android:exported="false" />
<activity
@ -106,4 +107,5 @@
<activity android:name="com.sunmi.emv.l2.view.AppSelectActivity"/>
</application>
</manifest>
</manifest>

View File

@ -1,4 +1,5 @@
package com.example.bdkipoc;
import com.example.bdkipoc.qris.view.QrisResultActivity;
import android.content.Context;
import android.content.Intent;

View File

@ -0,0 +1,187 @@
package com.example.bdkipoc.qris.model;
import android.util.Log;
import com.example.bdkipoc.qris.network.QrisApiService;
import org.json.JSONObject;
/**
* Repository class untuk menghandle semua data access terkait QRIS
* Mengabstraksi sumber data (API, local storage, etc.)
*/
public class QrisRepository {
private static final String TAG = "QrisRepository";
private QrisApiService apiService;
// Singleton pattern
private static QrisRepository instance;
private QrisRepository() {
this.apiService = new QrisApiService();
}
public static QrisRepository getInstance() {
if (instance == null) {
instance = new QrisRepository();
}
return instance;
}
/**
* Interface untuk callback hasil operasi
*/
public interface RepositoryCallback<T> {
void onSuccess(T result);
void onError(String errorMessage);
}
/**
* Refresh QR Code
*/
public void refreshQrCode(QrisTransaction transaction, RepositoryCallback<QrRefreshResult> callback) {
Log.d(TAG, "🔄 Refreshing QR code for transaction: " + transaction.getOrderId());
new Thread(() -> {
try {
QrRefreshResult result = apiService.generateNewQrCode(transaction);
if (result != null && result.qrUrl != null && !result.qrUrl.isEmpty()) {
Log.d(TAG, "✅ QR refresh successful");
callback.onSuccess(result);
} else {
Log.e(TAG, "❌ QR refresh failed - empty result");
callback.onError("Failed to generate new QR code");
}
} catch (Exception e) {
Log.e(TAG, "❌ QR refresh exception: " + e.getMessage(), e);
callback.onError("QR refresh error: " + e.getMessage());
}
}).start();
}
/**
* Check payment status
*/
public void checkPaymentStatus(QrisTransaction transaction, RepositoryCallback<PaymentStatusResult> callback) {
Log.d(TAG, "🔍 Checking payment status for: " + transaction.getCurrentQrTransactionId());
new Thread(() -> {
try {
// Gunakan current transaction ID, bukan original
PaymentStatusResult result = apiService.checkTransactionStatus(transaction);
if (result != null) {
Log.d(TAG, "✅ Payment status check successful: " + result.status);
// Update transaction ID jika berbeda
if (result.transactionId != null &&
!result.transactionId.equals(transaction.getCurrentQrTransactionId())) {
transaction.setCurrentQrTransactionId(result.transactionId);
}
callback.onSuccess(result);
} else {
Log.w(TAG, "⚠️ Payment status check returned null");
callback.onError("Failed to check payment status");
}
} catch (Exception e) {
Log.e(TAG, "❌ Payment status check exception: " + e.getMessage(), e);
callback.onError("Payment status error: " + e.getMessage());
}
}).start();
}
/**
* Send webhook simulation
*/
public void simulatePayment(QrisTransaction transaction, RepositoryCallback<Boolean> callback) {
Log.d(TAG, "🚀 Simulating payment for: " + transaction.getOrderId());
new Thread(() -> {
try {
boolean success = apiService.simulateWebhook(transaction);
if (success) {
Log.d(TAG, "✅ Payment simulation successful");
callback.onSuccess(true);
} else {
Log.e(TAG, "❌ Payment simulation failed");
callback.onError("Payment simulation failed");
}
} catch (Exception e) {
Log.e(TAG, "❌ Payment simulation exception: " + e.getMessage(), e);
callback.onError("Payment simulation error: " + e.getMessage());
}
}).start();
}
/**
* Poll for payment logs
*/
public void pollPaymentLogs(String orderId, RepositoryCallback<PaymentLogResult> callback) {
Log.d(TAG, "📊 Polling payment logs for: " + orderId);
new Thread(() -> {
try {
PaymentLogResult result = apiService.pollPendingPaymentLog(orderId);
if (result != null) {
Log.d(TAG, "✅ Payment log polling successful");
callback.onSuccess(result);
} else {
Log.w(TAG, "⚠️ No payment logs found");
callback.onError("No payment logs found");
}
} catch (Exception e) {
Log.e(TAG, "❌ Payment log polling exception: " + e.getMessage(), e);
callback.onError("Payment log polling error: " + e.getMessage());
}
}).start();
}
/**
* Result classes
*/
public static class QrRefreshResult {
public String qrUrl;
public String qrString;
public String transactionId;
public QrRefreshResult(String qrUrl, String qrString, String transactionId) {
this.qrUrl = qrUrl;
this.qrString = qrString;
this.transactionId = transactionId;
}
}
public static class PaymentStatusResult {
public String status;
public String paymentType;
public String issuer;
public String acquirer;
public String qrString;
public boolean statusChanged;
public String transactionId;
public PaymentStatusResult(String status) {
this.status = status;
this.statusChanged = false;
}
}
public static class PaymentLogResult {
public boolean found;
public String status;
public String orderId;
public PaymentLogResult(boolean found, String status, String orderId) {
this.found = found;
this.status = status;
this.orderId = orderId;
}
}
}

View File

@ -0,0 +1,3 @@
public class QrisResponse {
}

View File

@ -0,0 +1,288 @@
package com.example.bdkipoc.qris.model;
import android.util.Log;
import java.util.HashMap;
import java.util.Map;
/**
* Model class untuk data transaksi QRIS
* Menampung semua data yang dibutuhkan untuk transaksi
*/
public class QrisTransaction {
private static final String TAG = "QrisTransaction";
// Transaction identifiers
private String orderId;
private String transactionId;
private String referenceId;
private String merchantId;
// Amount information
private int originalAmount;
private String grossAmount;
private String formattedAmount;
// QR Code information
private String qrImageUrl;
private String qrString;
private long qrCreationTime;
private int qrExpirationMinutes;
// Provider information
private String acquirer;
private String detectedProvider;
private String actualIssuer;
private String actualAcquirer;
// Transaction timing
private String transactionTime;
private long creationTimestamp;
// Status tracking
private String currentStatus;
private boolean paymentProcessed;
private boolean isQrRefreshTransaction;
private String currentQrTransactionId;
// Provider expiration mapping
private static final Map<String, Integer> PROVIDER_EXPIRATION_MAP = new HashMap<String, Integer>() {{
put("shopeepay", 5);
put("shopee", 5);
put("airpay shopee", 5);
put("gopay", 15);
put("dana", 15);
put("ovo", 15);
put("linkaja", 15);
put("link aja", 15);
put("jenius", 15);
put("qris", 15);
put("others", 15);
}};
// Provider display name mapping
private static final Map<String, String> ISSUER_DISPLAY_MAP = new HashMap<String, String>() {{
put("airpay shopee", "ShopeePay");
put("shopeepay", "ShopeePay");
put("shopee", "ShopeePay");
put("linkaja", "LinkAja");
put("link aja", "LinkAja");
put("dana", "DANA");
put("ovo", "OVO");
put("gopay", "GoPay");
put("jenius", "Jenius");
put("sakuku", "Sakuku");
put("bni", "BNI");
put("bca", "BCA");
put("mandiri", "Mandiri");
put("bri", "BRI");
put("cimb", "CIMB Niaga");
put("permata", "Permata");
put("maybank", "Maybank");
put("qris", "QRIS");
}};
// Constructor
public QrisTransaction() {
this.creationTimestamp = System.currentTimeMillis();
this.currentStatus = "pending";
this.paymentProcessed = false;
this.isQrRefreshTransaction = false;
}
// Initialization method
public void initialize(String orderId, String transactionId, int amount,
String qrImageUrl, String qrString, String acquirer) {
this.orderId = orderId;
this.transactionId = transactionId;
this.currentQrTransactionId = transactionId;
this.originalAmount = amount;
this.qrImageUrl = qrImageUrl;
this.qrString = qrString;
this.acquirer = acquirer;
// Detect provider and set expiration
this.detectedProvider = detectProviderFromData();
this.qrExpirationMinutes = PROVIDER_EXPIRATION_MAP.get(detectedProvider.toLowerCase());
this.qrCreationTime = System.currentTimeMillis();
// Format amount
this.formattedAmount = formatRupiahAmount(String.valueOf(amount));
}
/**
* Detect provider dari acquirer atau QR string
*/
private String detectProviderFromData() {
// Try to detect from acquirer first
if (acquirer != null && !acquirer.isEmpty()) {
String lowerAcquirer = acquirer.toLowerCase().trim();
if (PROVIDER_EXPIRATION_MAP.containsKey(lowerAcquirer)) {
return lowerAcquirer;
}
}
// Try to detect from QR string content
if (qrString != null && !qrString.isEmpty()) {
String lowerQrString = qrString.toLowerCase();
for (String provider : PROVIDER_EXPIRATION_MAP.keySet()) {
if (lowerQrString.contains(provider.toLowerCase())) {
return provider;
}
}
}
return "others";
}
/**
* Format amount ke format Rupiah
*/
private String formatRupiahAmount(String amount) {
try {
String cleanAmount = amount.replaceAll("[^0-9]", "");
long amountLong = Long.parseLong(cleanAmount);
return "RP." + String.format("%,d", amountLong).replace(',', '.');
} catch (NumberFormatException e) {
return "RP." + amount;
}
}
/**
* Check apakah QR sudah expired
*/
public boolean isQrExpired() {
long currentTime = System.currentTimeMillis();
long elapsedMinutes = (currentTime - qrCreationTime) / (1000 * 60);
boolean expired = elapsedMinutes >= qrExpirationMinutes;
Log.d(TAG, "QR expired check: " + elapsedMinutes + "/" + qrExpirationMinutes + " = " + expired);
return expired;
}
/**
* Get remaining time dalam detik
*/
public int getRemainingTimeInSeconds() {
long currentTime = System.currentTimeMillis();
long elapsedMs = currentTime - qrCreationTime;
long totalExpirationMs = qrExpirationMinutes * 60 * 1000;
long remainingMs = totalExpirationMs - elapsedMs;
return (int) Math.max(0, remainingMs / 1000);
}
/**
* Update QR code dengan data baru
*/
public void updateQrCode(String newQrUrl, String newQrString, String newTransactionId) {
this.qrImageUrl = newQrUrl;
this.qrString = newQrString;
this.qrCreationTime = System.currentTimeMillis();
if (newTransactionId != null && !newTransactionId.isEmpty()) {
this.currentQrTransactionId = newTransactionId;
this.isQrRefreshTransaction = true;
}
}
/**
* Get display name untuk provider
*/
public String getDisplayProviderName() {
String issuerToCheck = actualIssuer != null && !actualIssuer.isEmpty()
? actualIssuer : acquirer;
if (issuerToCheck == null || issuerToCheck.isEmpty()) {
return "QRIS";
}
String lowerName = issuerToCheck.toLowerCase().trim();
String displayName = ISSUER_DISPLAY_MAP.get(lowerName);
if (displayName != null) {
return displayName;
}
// Fallback: capitalize first letter
String[] words = issuerToCheck.split("\\s+");
StringBuilder result = new StringBuilder();
for (String word : words) {
if (word.length() > 0) {
result.append(Character.toUpperCase(word.charAt(0)))
.append(word.substring(1).toLowerCase())
.append(" ");
}
}
return result.toString().trim();
}
// Getters and Setters
public String getOrderId() { return orderId; }
public void setOrderId(String orderId) { this.orderId = orderId; }
public String getTransactionId() { return transactionId; }
public void setTransactionId(String transactionId) { this.transactionId = transactionId; }
public String getCurrentQrTransactionId() { return currentQrTransactionId; }
public void setCurrentQrTransactionId(String currentQrTransactionId) {
this.currentQrTransactionId = currentQrTransactionId;
}
public String getReferenceId() { return referenceId; }
public void setReferenceId(String referenceId) { this.referenceId = referenceId; }
public String getMerchantId() { return merchantId; }
public void setMerchantId(String merchantId) { this.merchantId = merchantId; }
public int getOriginalAmount() { return originalAmount; }
public void setOriginalAmount(int originalAmount) { this.originalAmount = originalAmount; }
public String getGrossAmount() { return grossAmount; }
public void setGrossAmount(String grossAmount) { this.grossAmount = grossAmount; }
public String getFormattedAmount() { return formattedAmount; }
public void setFormattedAmount(String formattedAmount) { this.formattedAmount = formattedAmount; }
public String getQrImageUrl() { return qrImageUrl; }
public void setQrImageUrl(String qrImageUrl) { this.qrImageUrl = qrImageUrl; }
public String getQrString() { return qrString; }
public void setQrString(String qrString) { this.qrString = qrString; }
public long getQrCreationTime() { return qrCreationTime; }
public void setQrCreationTime(long qrCreationTime) { this.qrCreationTime = qrCreationTime; }
public int getQrExpirationMinutes() { return qrExpirationMinutes; }
public void setQrExpirationMinutes(int qrExpirationMinutes) {
this.qrExpirationMinutes = qrExpirationMinutes;
}
public String getAcquirer() { return acquirer; }
public void setAcquirer(String acquirer) { this.acquirer = acquirer; }
public String getDetectedProvider() { return detectedProvider; }
public void setDetectedProvider(String detectedProvider) { this.detectedProvider = detectedProvider; }
public String getActualIssuer() { return actualIssuer; }
public void setActualIssuer(String actualIssuer) { this.actualIssuer = actualIssuer; }
public String getActualAcquirer() { return actualAcquirer; }
public void setActualAcquirer(String actualAcquirer) { this.actualAcquirer = actualAcquirer; }
public String getTransactionTime() { return transactionTime; }
public void setTransactionTime(String transactionTime) { this.transactionTime = transactionTime; }
public long getCreationTimestamp() { return creationTimestamp; }
public void setCreationTimestamp(long creationTimestamp) { this.creationTimestamp = creationTimestamp; }
public String getCurrentStatus() { return currentStatus; }
public void setCurrentStatus(String currentStatus) { this.currentStatus = currentStatus; }
public boolean isPaymentProcessed() { return paymentProcessed; }
public void setPaymentProcessed(boolean paymentProcessed) { this.paymentProcessed = paymentProcessed; }
public boolean isQrRefreshTransaction() { return isQrRefreshTransaction; }
public void setQrRefreshTransaction(boolean qrRefreshTransaction) {
this.isQrRefreshTransaction = qrRefreshTransaction;
}
}

View File

@ -0,0 +1,3 @@
public class MidtransApiClient {
}

View File

@ -0,0 +1,410 @@
package com.example.bdkipoc.qris.network;
import android.util.Log;
import com.example.bdkipoc.qris.model.QrisRepository;
import com.example.bdkipoc.qris.model.QrisTransaction;
import com.example.bdkipoc.qris.model.QrisRepository.PaymentStatusResult;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.security.MessageDigest;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
/**
* API Service untuk handling semua network calls terkait QRIS
* Mengabstraksi implementasi detail dari repository
*/
public class QrisApiService {
private static final String TAG = "QrisApiService";
// API Endpoints
private static final String MIDTRANS_SANDBOX_AUTH = "Basic U0ItTWlkLXNlcnZlci1PM2t1bXkwVDl4M1VvYnVvVTc3NW5QbXc=";
private static final String MIDTRANS_PRODUCTION_AUTH = "TWlkLXNlcnZlci1sMlZPalotdVlVanpvNnU4VzAtYmF1a2o=";
private static final String MIDTRANS_AUTH = MIDTRANS_SANDBOX_AUTH; // Default to sandbox
private static final String MIDTRANS_CHARGE_URL = "https://api.sandbox.midtrans.com/v2/charge";
private static final String MIDTRANS_STATUS_BASE_URL = "https://api.sandbox.midtrans.com/v2/";
private String backendBase = "https://be-edc.msvc.app";
private String webhookUrl = "https://be-edc.msvc.app/webhooks/midtrans";
/**
* Generate new QR code via Midtrans API
*/
public QrisRepository.QrRefreshResult generateNewQrCode(QrisTransaction transaction) throws Exception {
Log.d(TAG, "🔧 Generating new QR code for: " + transaction.getOrderId());
// Generate unique order ID untuk QR refresh
String shortTimestamp = String.valueOf(System.currentTimeMillis()).substring(7);
String newOrderId = transaction.getOrderId().substring(0, Math.min(transaction.getOrderId().length(), 43)) + "-q" + shortTimestamp;
// Validate order ID length
if (newOrderId.length() > 50) {
newOrderId = transaction.getOrderId().substring(0, 36) + "-q" + shortTimestamp.substring(0, Math.min(shortTimestamp.length(), 7));
}
Log.d(TAG, "🆕 New QR Order ID: " + newOrderId);
// Create enhanced payload
JSONObject payload = createQrRefreshPayload(transaction, newOrderId);
// Make API call
URL url = new URI(MIDTRANS_CHARGE_URL).toURL();
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Authorization", MIDTRANS_AUTH);
conn.setRequestProperty("X-Override-Notification", webhookUrl);
conn.setRequestProperty("User-Agent", "BDKIPOCApp/1.0 QR-Refresh-Enhanced");
conn.setRequestProperty("X-QR-Refresh", "true");
conn.setRequestProperty("X-Parent-Transaction", transaction.getTransactionId());
conn.setRequestProperty("X-Provider", transaction.getDetectedProvider());
conn.setRequestProperty("X-Expiration-Minutes", String.valueOf(transaction.getQrExpirationMinutes()));
conn.setDoOutput(true);
conn.setConnectTimeout(30000);
conn.setReadTimeout(30000);
try (OutputStream os = conn.getOutputStream()) {
byte[] input = payload.toString().getBytes("utf-8");
os.write(input, 0, input.length);
}
int responseCode = conn.getResponseCode();
Log.d(TAG, "📥 QR refresh response code: " + responseCode);
if (responseCode == 200 || responseCode == 201) {
String response = readResponse(conn.getInputStream());
return parseQrRefreshResponse(response);
} else {
String errorResponse = readResponse(conn.getErrorStream());
throw new Exception("QR refresh failed: HTTP " + responseCode + " - " + errorResponse);
}
}
/**
* Check transaction status via Midtrans API
*/
public PaymentStatusResult checkTransactionStatus(QrisTransaction transaction) throws Exception {
String monitoringTransactionId = transaction.getCurrentQrTransactionId();
String statusUrl = MIDTRANS_STATUS_BASE_URL + monitoringTransactionId + "/status";
Log.d(TAG, "🔍 Checking status for: " + monitoringTransactionId);
URL url = new URI(statusUrl).toURL();
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Authorization", MIDTRANS_AUTH);
conn.setRequestProperty("User-Agent", "BDKIPOCApp/1.0");
conn.setConnectTimeout(8000);
conn.setReadTimeout(8000);
int responseCode = conn.getResponseCode();
if (responseCode == 200) {
String response = readResponse(conn.getInputStream());
PaymentStatusResult result = parseStatusResponse(response, transaction);
JSONObject statusResponse = new JSONObject(response);
result.transactionId = statusResponse.optString("transaction_id", monitoringTransactionId);
return result;
} else {
throw new Exception("Status check failed: HTTP " + responseCode);
}
}
/**
* Simulate webhook payment
*/
public boolean simulateWebhook(QrisTransaction transaction) throws Exception {
Log.d(TAG, "🚀 Simulating webhook for: " + transaction.getOrderId());
String serverKey = getServerKey();
String signatureKey = generateSignature(
transaction.getOrderId(),
"200",
transaction.getGrossAmount() != null ? transaction.getGrossAmount() : String.valueOf(transaction.getOriginalAmount()),
serverKey
);
JSONObject payload = createWebhookPayload(transaction, signatureKey);
URL url = new URL(webhookUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("User-Agent", "BDKIPOCApp/1.0-Enhanced-Simulation");
conn.setRequestProperty("X-Simulation", "true");
conn.setRequestProperty("X-Provider", transaction.getDetectedProvider());
conn.setDoOutput(true);
conn.setConnectTimeout(15000);
conn.setReadTimeout(15000);
try (OutputStream os = conn.getOutputStream()) {
os.write(payload.toString().getBytes("utf-8"));
}
int responseCode = conn.getResponseCode();
Log.d(TAG, "📥 Webhook simulation response: " + responseCode);
if (responseCode == 200 || responseCode == 201) {
String response = readResponse(conn.getInputStream());
Log.d(TAG, "✅ Webhook simulation successful");
return true;
} else {
String errorResponse = readResponse(conn.getErrorStream());
throw new Exception("Webhook simulation failed: HTTP " + responseCode + " - " + errorResponse);
}
}
/**
* Poll for pending payment logs
*/
public QrisRepository.PaymentLogResult pollPendingPaymentLog(String orderId) throws Exception {
Log.d(TAG, "📊 Polling payment logs for: " + orderId);
String urlStr = backendBase + "/api-logs?request_body_search_strict={\"order_id\":\"" + orderId + "\"}";
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("User-Agent", "BDKIPOCApp/1.0-Enhanced");
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
int responseCode = conn.getResponseCode();
if (responseCode == 200) {
String response = readResponse(conn.getInputStream());
return parsePaymentLogResponse(response, orderId);
} else {
throw new Exception("Payment log polling failed: HTTP " + responseCode);
}
}
/**
* Helper methods
*/
private JSONObject createQrRefreshPayload(QrisTransaction transaction, String newOrderId) throws Exception {
JSONObject customField1 = new JSONObject();
customField1.put("parent_transaction_id", transaction.getTransactionId());
customField1.put("parent_order_id", transaction.getOrderId());
customField1.put("parent_reference_id", transaction.getReferenceId());
customField1.put("qr_refresh_time", new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault()).format(new Date()));
customField1.put("qr_refresh_count", System.currentTimeMillis());
customField1.put("is_qr_refresh", true);
customField1.put("detected_provider", transaction.getDetectedProvider());
customField1.put("expiration_minutes", transaction.getQrExpirationMinutes());
JSONObject payload = new JSONObject();
payload.put("payment_type", "qris");
JSONObject transactionDetails = new JSONObject();
transactionDetails.put("order_id", newOrderId);
transactionDetails.put("gross_amount", transaction.getOriginalAmount());
payload.put("transaction_details", transactionDetails);
JSONObject customerDetails = new JSONObject();
customerDetails.put("first_name", "Test");
customerDetails.put("last_name", "Customer");
customerDetails.put("email", "test@example.com");
customerDetails.put("phone", "081234567890");
payload.put("customer_details", customerDetails);
JSONArray itemDetails = new JSONArray();
JSONObject item = new JSONObject();
item.put("id", "item1_qr_refresh_" + System.currentTimeMillis());
item.put("price", transaction.getOriginalAmount());
item.put("quantity", 1);
item.put("name", "QRIS Payment QR Refresh - " + new SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(new Date()) +
" (" + transaction.getDetectedProvider().toUpperCase() + " - " + transaction.getQrExpirationMinutes() + "min)");
itemDetails.put(item);
payload.put("item_details", itemDetails);
payload.put("custom_field1", customField1.toString());
JSONObject qrisDetails = new JSONObject();
qrisDetails.put("acquirer", "gopay");
qrisDetails.put("qr_refresh", true);
qrisDetails.put("parent_transaction_id", transaction.getTransactionId());
qrisDetails.put("refresh_timestamp", System.currentTimeMillis());
qrisDetails.put("provider", transaction.getDetectedProvider());
qrisDetails.put("expiration_minutes", transaction.getQrExpirationMinutes());
payload.put("qris", qrisDetails);
return payload;
}
private JSONObject createWebhookPayload(QrisTransaction transaction, String signatureKey) throws Exception {
JSONObject payload = new JSONObject();
payload.put("transaction_type", "on-us");
payload.put("transaction_time", transaction.getTransactionTime() != null ? transaction.getTransactionTime() : getCurrentISOTime());
payload.put("transaction_status", "settlement");
payload.put("transaction_id", transaction.getCurrentQrTransactionId());
payload.put("status_message", "midtrans payment notification");
payload.put("status_code", "200");
payload.put("signature_key", signatureKey);
payload.put("settlement_time", getCurrentISOTime());
payload.put("payment_type", "qris");
payload.put("order_id", transaction.getOrderId());
payload.put("merchant_id", transaction.getMerchantId() != null ? transaction.getMerchantId() : "G616299250");
payload.put("issuer", transaction.getActualIssuer() != null ? transaction.getActualIssuer() : transaction.getAcquirer());
payload.put("gross_amount", transaction.getGrossAmount() != null ? transaction.getGrossAmount() : String.valueOf(transaction.getOriginalAmount()));
payload.put("fraud_status", "accept");
payload.put("currency", "IDR");
payload.put("acquirer", transaction.getActualAcquirer() != null ? transaction.getActualAcquirer() : transaction.getAcquirer());
payload.put("shopeepay_reference_number", "");
payload.put("reference_id", transaction.getReferenceId() != null ? transaction.getReferenceId() : "DUMMY_REFERENCE_ID");
// Enhanced fields
payload.put("detected_provider", transaction.getDetectedProvider());
payload.put("qr_expiration_minutes", transaction.getQrExpirationMinutes());
payload.put("is_simulation", true);
payload.put("simulation_type", "enhanced_manual");
if (transaction.getQrString() != null && !transaction.getQrString().isEmpty()) {
payload.put("qr_string", transaction.getQrString());
}
return payload;
}
private QrisRepository.QrRefreshResult parseQrRefreshResponse(String response) throws Exception {
JSONObject jsonResponse = new JSONObject(response);
if (jsonResponse.has("status_code")) {
String statusCode = jsonResponse.getString("status_code");
if (!statusCode.equals("201")) {
String statusMessage = jsonResponse.optString("status_message", "Unknown error");
throw new Exception("QR refresh failed: " + statusCode + " - " + statusMessage);
}
}
String newQrUrl = null;
String newQrString = null;
String newTransactionId = jsonResponse.optString("transaction_id", "");
// Get QR URL from actions
if (jsonResponse.has("actions")) {
JSONArray actionsArray = jsonResponse.getJSONArray("actions");
if (actionsArray.length() > 0) {
JSONObject actions = actionsArray.getJSONObject(0);
newQrUrl = actions.getString("url");
}
}
// Get QR String
if (jsonResponse.has("qr_string")) {
newQrString = jsonResponse.getString("qr_string");
}
return new QrisRepository.QrRefreshResult(newQrUrl, newQrString, newTransactionId);
}
private QrisRepository.PaymentStatusResult parseStatusResponse(String response, QrisTransaction transaction) throws Exception {
JSONObject statusResponse = new JSONObject(response);
String transactionStatus = statusResponse.optString("transaction_status", "");
String paymentType = statusResponse.optString("payment_type", "");
String actualIssuer = statusResponse.optString("issuer", "");
String actualAcquirer = statusResponse.optString("acquirer", "");
String qrStringFromStatus = statusResponse.optString("qr_string", "");
QrisRepository.PaymentStatusResult result = new QrisRepository.PaymentStatusResult(transactionStatus);
result.paymentType = paymentType;
result.issuer = actualIssuer;
result.acquirer = actualAcquirer;
result.qrString = qrStringFromStatus;
result.statusChanged = !transactionStatus.equals(transaction.getCurrentStatus());
return result;
}
private QrisRepository.PaymentLogResult parsePaymentLogResponse(String response, String orderId) throws Exception {
JSONObject json = new JSONObject(response);
JSONArray results = json.optJSONArray("results");
if (results != null && results.length() > 0) {
for (int i = 0; i < results.length(); i++) {
JSONObject log = results.getJSONObject(i);
JSONObject reqBody = log.optJSONObject("request_body");
if (reqBody != null) {
String transactionStatus = reqBody.optString("transaction_status");
String logOrderId = reqBody.optString("order_id");
if (orderId.equals(logOrderId) &&
(transactionStatus.equals("pending") ||
transactionStatus.equals("settlement") ||
transactionStatus.equals("capture") ||
transactionStatus.equals("success"))) {
return new QrisRepository.PaymentLogResult(true, transactionStatus, logOrderId);
}
}
}
}
return new QrisRepository.PaymentLogResult(false, "", orderId);
}
private String readResponse(InputStream inputStream) throws Exception {
if (inputStream == null) return "";
BufferedReader br = new BufferedReader(new InputStreamReader(inputStream, "utf-8"));
StringBuilder response = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
response.append(line);
}
return response.toString();
}
private String getServerKey() {
try {
String base64 = MIDTRANS_AUTH.replace("Basic ", "");
byte[] decoded = android.util.Base64.decode(base64, android.util.Base64.DEFAULT);
String decodedString = new String(decoded);
return decodedString.replace(":", "");
} catch (Exception e) {
Log.e(TAG, "Error decoding server key: " + e.getMessage());
return "";
}
}
private String generateSignature(String orderId, String statusCode, String grossAmount, String serverKey) {
String input = orderId + statusCode + grossAmount + serverKey;
try {
MessageDigest md = MessageDigest.getInstance("SHA-512");
byte[] messageDigest = md.digest(input.getBytes());
StringBuilder hexString = new StringBuilder();
for (byte b : messageDigest) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
} catch (Exception e) {
Log.e(TAG, "Error generating signature: " + e.getMessage());
return "dummy_signature";
}
}
private String getCurrentISOTime() {
return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault())
.format(new Date());
}
}

View File

@ -0,0 +1,543 @@
package com.example.bdkipoc.qris.presenter;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.example.bdkipoc.qris.model.QrisRepository;
import com.example.bdkipoc.qris.model.QrisTransaction;
import com.example.bdkipoc.qris.view.QrisResultContract;
/**
* Presenter untuk QrisResult module
* Menghandle semua business logic dan koordinasi antara Model dan View
*/
public class QrisResultPresenter implements QrisResultContract.Presenter {
private static final String TAG = "QrisResultPresenter";
private QrisResultContract.View view;
private QrisRepository repository;
private QrisTransaction transaction;
// Handlers untuk background tasks
private Handler timerHandler;
private Handler qrRefreshHandler;
private Handler paymentMonitorHandler;
// Runnables untuk periodic tasks
private Runnable timerRunnable;
private Runnable qrRefreshRunnable;
private Runnable paymentMonitorRunnable;
// State management
private boolean isTimerActive = false;
private boolean isQrRefreshActive = false;
private boolean isPaymentMonitorActive = false;
private String lastKnownStatus = "pending";
private int refreshCounter = 0;
private static final int MAX_REFRESH_ATTEMPTS = 5;
public QrisResultPresenter() {
this.repository = QrisRepository.getInstance();
this.transaction = new QrisTransaction();
// Initialize handlers
this.timerHandler = new Handler(Looper.getMainLooper());
this.qrRefreshHandler = new Handler(Looper.getMainLooper());
this.paymentMonitorHandler = new Handler(Looper.getMainLooper());
}
@Override
public void attachView(QrisResultContract.View view) {
this.view = view;
Log.d(TAG, "📎 View attached to presenter");
}
@Override
public void detachView() {
this.view = null;
Log.d(TAG, "📎 View detached from presenter");
}
@Override
public void onDestroy() {
stopAllTimers();
detachView();
Log.d(TAG, "💀 Presenter destroyed");
}
@Override
public void initializeTransaction(String orderId, String transactionId, String amount,
String qrImageUrl, String qrString, String acquirer) {
Log.d(TAG, "🚀 Initializing transaction");
try {
int amountInt = Integer.parseInt(amount);
transaction.initialize(orderId, transactionId, amountInt, qrImageUrl, qrString, acquirer);
if (view != null) {
view.showAmount(transaction.getFormattedAmount());
view.showQrImage(transaction.getQrImageUrl());
view.showProviderName(transaction.getDisplayProviderName());
view.showStatus("Waiting for payment...");
}
Log.d(TAG, "✅ Transaction initialized successfully");
Log.d(TAG, " Provider: " + transaction.getDetectedProvider());
Log.d(TAG, " Expiration: " + transaction.getQrExpirationMinutes() + " minutes");
} catch (Exception e) {
Log.e(TAG, "❌ Failed to initialize transaction: " + e.getMessage(), e);
if (view != null) {
view.showError("Failed to initialize transaction: " + e.getMessage());
}
}
}
@Override
public void startQrManagement() {
Log.d(TAG, "🔄 Starting QR management");
isQrRefreshActive = true;
startTimer();
startQrRefreshMonitoring();
}
@Override
public void stopQrManagement() {
Log.d(TAG, "🛑 Stopping QR management");
isQrRefreshActive = false;
stopTimer();
if (qrRefreshHandler != null && qrRefreshRunnable != null) {
qrRefreshHandler.removeCallbacks(qrRefreshRunnable);
}
}
@Override
public void startTimer() {
if (isTimerActive) {
stopTimer();
}
Log.d(TAG, "⏰ Starting timer");
isTimerActive = true;
// Reset creation time
transaction.setQrCreationTime(System.currentTimeMillis());
timerRunnable = new Runnable() {
@Override
public void run() {
if (!isTimerActive || transaction.isPaymentProcessed()) {
return;
}
int remainingSeconds = transaction.getRemainingTimeInSeconds();
if (remainingSeconds > 0) {
// Update UI di main thread
new Handler(Looper.getMainLooper()).post(() -> {
if (view != null) {
int displayMinutes = remainingSeconds / 60;
int displaySeconds = remainingSeconds % 60;
String timeDisplay = String.format("%d:%02d", displayMinutes, displaySeconds);
view.showTimer(timeDisplay);
}
});
// Schedule next update
timerHandler.postDelayed(this, 1000);
} else {
// Timer expired
Log.w(TAG, "⏰ Timer expired");
isTimerActive = false;
onQrExpired();
}
}
};
timerHandler.post(timerRunnable);
}
@Override
public void stopTimer() {
Log.d(TAG, "⏰ Stopping timer");
isTimerActive = false;
if (timerHandler != null && timerRunnable != null) {
timerHandler.removeCallbacks(timerRunnable);
}
}
@Override
public void refreshQrCode() {
Log.d(TAG, "🔄 Refreshing QR code - Attempt " + refreshCounter);
// Pastikan di Main Thread untuk UI updates
new Handler(Looper.getMainLooper()).post(() -> {
if (view != null) {
view.showQrRefreshing();
view.showLoading();
}
});
repository.refreshQrCode(transaction, new QrisRepository.RepositoryCallback<QrisRepository.QrRefreshResult>() {
@Override
public void onSuccess(QrisRepository.QrRefreshResult result) {
Log.d(TAG, "✅ QR refresh successful");
// Update transaction data
transaction.updateQrCode(result.qrUrl, result.qrString, result.transactionId);
transaction.setQrCreationTime(System.currentTimeMillis()); // Reset creation time
// Pastikan di Main Thread untuk UI updates
new Handler(Looper.getMainLooper()).post(() -> {
if (view != null) {
view.hideLoading();
view.updateQrImage(result.qrUrl);
view.updateQrUrl(result.qrUrl);
view.showQrRefreshSuccess();
view.showToast("QR Code berhasil diperbarui!");
}
// Stop dan restart timer dengan benar
stopTimer();
startTimer();
// Restart monitoring
isQrRefreshActive = true;
startQrRefreshMonitoring();
});
}
@Override
public void onError(String errorMessage) {
Log.e(TAG, "❌ QR refresh failed: " + errorMessage);
new Handler(Looper.getMainLooper()).post(() -> {
if (view != null) {
view.hideLoading();
view.showQrRefreshFailed(errorMessage);
if (refreshCounter >= MAX_REFRESH_ATTEMPTS) {
view.navigateToMain();
}
}
});
}
});
}
@Override
public void onQrExpired() {
Log.w(TAG, "⏰ Handling QR expiration");
// Stop current timers to prevent race conditions
stopTimer();
if (view != null) {
view.showQrExpired();
}
// Cek apakah sudah mencapai limit refresh
if (refreshCounter >= MAX_REFRESH_ATTEMPTS) {
Log.w(TAG, "🛑 Maximum refresh attempts reached");
if (view != null) {
view.showToast("Maksimum percobaan refresh QR tercapai");
view.navigateToMain();
}
return;
}
// Increment counter
refreshCounter++;
Log.d(TAG, "🔄 Refresh attempt #" + refreshCounter);
// Auto-refresh tanpa delay
refreshQrCode();
}
private void startQrRefreshMonitoring() {
Log.d(TAG, "🔄 Starting QR refresh monitoring");
qrRefreshRunnable = new Runnable() {
@Override
public void run() {
if (!isQrRefreshActive || transaction.isPaymentProcessed()) {
return;
}
// Check if QR expired
if (transaction.isQrExpired()) {
Log.w(TAG, "⏰ QR Code expired during monitoring");
onQrExpired();
return;
}
// Schedule next check in 30 seconds
if (isQrRefreshActive) {
qrRefreshHandler.postDelayed(this, 30000);
}
}
};
qrRefreshHandler.post(qrRefreshRunnable);
}
@Override
public void startPaymentMonitoring() {
Log.d(TAG, "🔍 Starting payment monitoring");
isPaymentMonitorActive = true;
paymentMonitorRunnable = new Runnable() {
@Override
public void run() {
if (!isPaymentMonitorActive || transaction.isPaymentProcessed()) {
return;
}
checkPaymentStatus();
// Schedule next check in 3 seconds
if (isPaymentMonitorActive && !transaction.isPaymentProcessed()) {
paymentMonitorHandler.postDelayed(this, 3000);
}
}
};
paymentMonitorHandler.post(paymentMonitorRunnable);
}
@Override
public void stopPaymentMonitoring() {
Log.d(TAG, "🔍 Stopping payment monitoring");
isPaymentMonitorActive = false;
if (paymentMonitorHandler != null && paymentMonitorRunnable != null) {
paymentMonitorHandler.removeCallbacks(paymentMonitorRunnable);
}
}
@Override
public void checkPaymentStatus() {
repository.checkPaymentStatus(transaction, new QrisRepository.RepositoryCallback<QrisRepository.PaymentStatusResult>() {
@Override
public void onSuccess(QrisRepository.PaymentStatusResult result) {
handlePaymentStatusResult(result);
}
@Override
public void onError(String errorMessage) {
Log.w(TAG, "⚠️ Payment status check failed: " + errorMessage);
// Don't show error to user untuk status check failures
}
});
}
private void handlePaymentStatusResult(QrisRepository.PaymentStatusResult result) {
Log.d(TAG, "💳 Payment status result: " + result.status);
// Update transaction dengan actual issuer/acquirer
if (result.issuer != null && !result.issuer.isEmpty()) {
transaction.setActualIssuer(result.issuer);
}
if (result.acquirer != null && !result.acquirer.isEmpty()) {
transaction.setActualAcquirer(result.acquirer);
}
// Update QR string jika ada
if (result.qrString != null && !result.qrString.isEmpty()) {
transaction.setQrString(result.qrString);
}
// Handle status changes
if (!result.status.equals(lastKnownStatus)) {
Log.d(TAG, "📊 Status changed: " + lastKnownStatus + " -> " + result.status);
lastKnownStatus = result.status;
transaction.setCurrentStatus(result.status);
if (view != null) {
switch (result.status) {
case "settlement":
case "capture":
case "success":
if (!transaction.isPaymentProcessed()) {
handlePaymentSuccess();
}
break;
case "expire":
case "cancel":
view.showPaymentFailed("Payment " + result.status);
stopAllTimers();
break;
case "pending":
view.showPaymentPending();
break;
default:
view.showStatus("Status: " + result.status);
break;
}
}
}
}
private void handlePaymentSuccess() {
Log.d(TAG, "🎉 Payment successful!");
transaction.setPaymentProcessed(true);
stopAllTimers();
if (view != null) {
String providerName = transaction.getDisplayProviderName();
view.showPaymentSuccess(providerName);
view.startSuccessAnimation();
view.showToast("Pembayaran " + providerName + " berhasil! 🎉");
}
// Don't auto-navigate here - let the view handle the navigation timing
}
@Override
public void onCancelClicked() {
Log.d(TAG, "❌ Cancel clicked");
stopAllTimers();
if (view != null) {
view.finishActivity();
}
}
@Override
public void onBackPressed() {
Log.d(TAG, "⬅️ Back pressed");
stopAllTimers();
if (view != null) {
view.finishActivity();
}
}
@Override
public void onSimulatePayment() {
Log.d(TAG, "🚀 Simulating payment");
if (transaction.isPaymentProcessed()) {
Log.w(TAG, "⚠️ Payment already processed");
if (view != null) {
view.showToast("Pembayaran sudah diproses");
}
return;
}
stopAllTimers();
if (view != null) {
view.showToast("Mensimulasikan pembayaran...");
view.showLoading();
}
repository.simulatePayment(transaction, new QrisRepository.RepositoryCallback<Boolean>() {
@Override
public void onSuccess(Boolean result) {
Log.d(TAG, "✅ Payment simulation successful");
if (view != null) {
view.hideLoading();
}
// Wait a bit then trigger success
new Handler(Looper.getMainLooper()).postDelayed(() -> {
if (!transaction.isPaymentProcessed()) {
handlePaymentSuccess();
}
}, 2000);
}
@Override
public void onError(String errorMessage) {
Log.e(TAG, "❌ Payment simulation failed: " + errorMessage);
if (view != null) {
view.hideLoading();
view.showError("Simulasi gagal: " + errorMessage);
}
// Restart monitoring after simulation failure
startPaymentMonitoring();
}
});
}
/**
* Stop all timers dan background tasks
*/
private void stopAllTimers() {
Log.d(TAG, "🛑 Stopping all timers");
stopTimer();
stopQrManagement();
stopPaymentMonitoring();
// Clear all pending callbacks
if (timerHandler != null) {
timerHandler.removeCallbacksAndMessages(null);
}
if (qrRefreshHandler != null) {
qrRefreshHandler.removeCallbacksAndMessages(null);
}
if (paymentMonitorHandler != null) {
paymentMonitorHandler.removeCallbacksAndMessages(null);
}
}
/**
* Public method untuk start semua monitoring
*/
public void startAllMonitoring() {
Log.d(TAG, "🚀 Starting all monitoring");
startQrManagement();
startPaymentMonitoring();
// Start polling untuk payment logs
repository.pollPaymentLogs(transaction.getOrderId(), new QrisRepository.RepositoryCallback<QrisRepository.PaymentLogResult>() {
@Override
public void onSuccess(QrisRepository.PaymentLogResult result) {
if (result.found) {
Log.d(TAG, "📊 Payment log found with status: " + result.status);
if ("settlement".equals(result.status) ||
"capture".equals(result.status) ||
"success".equals(result.status)) {
if (!transaction.isPaymentProcessed()) {
handlePaymentSuccess();
}
}
if (view != null) {
view.showToast("Payment log found!");
}
}
}
@Override
public void onError(String errorMessage) {
Log.w(TAG, "⚠️ Payment log polling failed: " + errorMessage);
// Don't show error to user
}
});
}
// Getter untuk transaction (untuk testing atau debugging)
public QrisTransaction getTransaction() {
return transaction;
}
}

View File

@ -0,0 +1,3 @@
public class PaymentStatussMonitor {
}

View File

@ -0,0 +1,162 @@
package com.example.bdkipoc.qris.utils;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.AsyncTask;
import android.util.Log;
import android.widget.ImageView;
import android.widget.Toast;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
/**
* Utility class untuk loading QR images secara asynchronous
* Dengan error handling dan validation yang proper
*/
public class QrImageLoader {
private static final String TAG = "QrImageLoader";
private static final String MIDTRANS_AUTH = "Basic U0ItTWlkLXNlcnZlci1PM2t1bXkwVDl4M1VvYnVvVTc3NW5QbXc=";
/**
* Interface untuk callback hasil loading image
*/
public interface ImageLoadCallback {
void onImageLoaded(Bitmap bitmap);
void onImageLoadFailed(String errorMessage);
}
/**
* Load QR image dari URL dengan callback
*/
public static void loadQrImage(String qrImageUrl, ImageLoadCallback callback) {
if (qrImageUrl == null || qrImageUrl.isEmpty()) {
Log.w(TAG, "⚠️ QR image URL is empty");
callback.onImageLoadFailed("QR image URL is empty");
return;
}
if (!qrImageUrl.startsWith("http")) {
Log.e(TAG, "❌ Invalid QR URL format: " + qrImageUrl);
callback.onImageLoadFailed("Invalid QR code URL format");
return;
}
Log.d(TAG, "🖼️ Loading QR image from: " + qrImageUrl);
new EnhancedDownloadImageTask(callback).execute(qrImageUrl);
}
/**
* Load QR image langsung ke ImageView (legacy support)
*/
public static void loadQrImageToView(String qrImageUrl, ImageView imageView) {
loadQrImage(qrImageUrl, new ImageLoadCallback() {
@Override
public void onImageLoaded(Bitmap bitmap) {
if (imageView != null) {
imageView.setImageBitmap(bitmap);
Log.d(TAG, "✅ QR code image displayed successfully");
}
}
@Override
public void onImageLoadFailed(String errorMessage) {
Log.e(TAG, "❌ Failed to display QR code image: " + errorMessage);
if (imageView != null) {
imageView.setImageResource(android.R.drawable.ic_menu_report_image);
if (imageView.getContext() != null) {
Toast.makeText(imageView.getContext(), "QR Error: " + errorMessage, Toast.LENGTH_LONG).show();
}
}
}
});
}
/**
* Enhanced AsyncTask untuk download image dengan proper error handling
*/
private static class EnhancedDownloadImageTask extends AsyncTask<String, Void, Bitmap> {
private ImageLoadCallback callback;
private String errorMessage;
EnhancedDownloadImageTask(ImageLoadCallback callback) {
this.callback = callback;
}
@Override
protected Bitmap doInBackground(String... urls) {
String urlDisplay = urls[0];
Bitmap bitmap = null;
try {
if (urlDisplay == null || urlDisplay.isEmpty()) {
Log.e(TAG, "❌ Empty QR URL provided");
errorMessage = "QR URL is empty";
return null;
}
if (!urlDisplay.startsWith("http")) {
Log.e(TAG, "❌ Invalid QR URL format: " + urlDisplay);
errorMessage = "Invalid QR URL format";
return null;
}
Log.d(TAG, "📥 Downloading image from: " + urlDisplay);
URL url = new URI(urlDisplay).toURL();
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setDoInput(true);
connection.setConnectTimeout(15000);
connection.setReadTimeout(15000);
connection.setRequestProperty("User-Agent", "BDKIPOCApp/1.0");
connection.setRequestProperty("Accept", "image/*");
// Add auth header untuk Midtrans URLs
if (urlDisplay.contains("midtrans.com")) {
connection.setRequestProperty("Authorization", MIDTRANS_AUTH);
}
connection.connect();
int responseCode = connection.getResponseCode();
Log.d(TAG, "📥 Image download response code: " + responseCode);
if (responseCode == 200) {
InputStream input = connection.getInputStream();
bitmap = BitmapFactory.decodeStream(input);
if (bitmap != null) {
Log.d(TAG, "✅ Image downloaded successfully. Size: " +
bitmap.getWidth() + "x" + bitmap.getHeight());
} else {
Log.e(TAG, "❌ Failed to decode bitmap from stream");
errorMessage = "Failed to decode QR code image";
}
} else {
Log.e(TAG, "❌ Failed to download image. HTTP code: " + responseCode);
errorMessage = "Failed to download QR code (HTTP " + responseCode + ")";
}
} catch (Exception e) {
Log.e(TAG, "❌ Exception downloading image: " + e.getMessage(), e);
errorMessage = "Error downloading QR code: " + e.getMessage();
}
return bitmap;
}
@Override
protected void onPostExecute(Bitmap result) {
if (callback != null) {
if (result != null) {
callback.onImageLoaded(result);
} else {
callback.onImageLoadFailed(errorMessage != null ? errorMessage : "Unknown error");
}
}
}
}
}

View File

@ -0,0 +1,3 @@
public class QrisValidator {
}

View File

@ -0,0 +1,757 @@
package com.example.bdkipoc.qris.view;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.cardview.widget.CardView;
import com.example.bdkipoc.R;
import com.example.bdkipoc.ReceiptActivity;
import com.example.bdkipoc.qris.model.QrisTransaction;
import com.example.bdkipoc.qris.presenter.QrisResultPresenter;
import com.example.bdkipoc.qris.utils.QrImageLoader;
/**
* QrisResultActivity - refactored menggunakan MVP pattern
* Hanya menghandle UI logic, business logic ada di Presenter
*/
public class QrisResultActivity extends AppCompatActivity implements QrisResultContract.View {
private static final String TAG = "QrisResultActivity";
// Presenter
private QrisResultPresenter presenter;
// Main UI Components
private ImageView qrImageView;
private TextView amountTextView;
private TextView timerTextView;
private Button cancelButton;
private TextView qrisLogo;
private CardView mainCard;
private View headerBackground;
private View backNavigation;
// Hidden components for functionality
private TextView referenceTextView;
private TextView statusTextView;
private TextView qrStatusTextView;
private ProgressBar progressBar;
private Button downloadQrisButton;
private Button checkStatusButton;
private Button returnMainButton;
// Success screen views
private View successScreen;
private ImageView successIcon;
private TextView successMessage;
private TextView qrUrlTextView;
private Button simulatorButton;
// Animation handler
private Handler animationHandler = new Handler(Looper.getMainLooper());
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_qris_result);
Log.d(TAG, "=== QRIS RESULT ACTIVITY STARTED (MVP) ===");
// Initialize presenter
presenter = new QrisResultPresenter();
presenter.attachView(this);
// Initialize views
initializeViews();
// Setup UI components
setupUI();
setupClickListeners();
// Get intent data dan initialize transaction
initializeFromIntent();
// Start monitoring
presenter.startAllMonitoring();
}
/**
* Initialize all view components
*/
private void initializeViews() {
// Main visible components
qrImageView = findViewById(R.id.qrImageView);
amountTextView = findViewById(R.id.amountTextView);
timerTextView = findViewById(R.id.timerTextView);
cancelButton = findViewById(R.id.cancel_button);
qrisLogo = findViewById(R.id.qris_logo);
mainCard = findViewById(R.id.main_card);
headerBackground = findViewById(R.id.header_background);
backNavigation = findViewById(R.id.back_navigation);
// Hidden components for functionality
referenceTextView = findViewById(R.id.referenceTextView);
statusTextView = findViewById(R.id.statusTextView);
qrStatusTextView = findViewById(R.id.qrStatusTextView);
progressBar = findViewById(R.id.progressBar);
downloadQrisButton = findViewById(R.id.downloadQrisButton);
checkStatusButton = findViewById(R.id.checkStatusButton);
returnMainButton = findViewById(R.id.returnMainButton);
// Success screen views
successScreen = findViewById(R.id.success_screen);
successIcon = findViewById(R.id.success_icon);
successMessage = findViewById(R.id.success_message);
qrUrlTextView = findViewById(R.id.qrUrlTextView);
simulatorButton = findViewById(R.id.simulatorButton);
}
/**
* Setup basic UI components
*/
private void setupUI() {
// Hide success screen initially
if (successScreen != null) {
successScreen.setVisibility(View.GONE);
}
// Disable check status button initially
if (checkStatusButton != null) {
checkStatusButton.setEnabled(false);
}
// Setup URL copy functionality
setupUrlCopyFunctionality();
// Setup simulator button
setupSimulatorButton();
}
/**
* Get data dari intent dan initialize transaction
*/
private void initializeFromIntent() {
Intent intent = getIntent();
String orderId = intent.getStringExtra("orderId");
String transactionId = intent.getStringExtra("transactionId");
String amount = String.valueOf(intent.getIntExtra("amount", 0));
String qrImageUrl = intent.getStringExtra("qrImageUrl");
String qrString = intent.getStringExtra("qrString");
String acquirer = intent.getStringExtra("acquirer");
Log.d(TAG, "Initializing transaction with data:");
Log.d(TAG, " Order ID: " + orderId);
Log.d(TAG, " Transaction ID: " + transactionId);
Log.d(TAG, " Amount: " + amount);
Log.d(TAG, " QR URL: " + qrImageUrl);
Log.d(TAG, " Acquirer: " + acquirer);
// Validate required data
if (orderId == null || transactionId == null) {
Log.e(TAG, "❌ Critical error: orderId or transactionId is null!");
showError("Missing transaction details! Cannot proceed.");
finish();
return;
}
// Initialize via presenter
presenter.initializeTransaction(orderId, transactionId, amount, qrImageUrl, qrString, acquirer);
// Set additional data
if (referenceTextView != null) {
String referenceId = intent.getStringExtra("referenceId");
referenceTextView.setText("Reference ID: " + referenceId);
}
}
/**
* Setup click listeners untuk semua buttons dan views
*/
private void setupClickListeners() {
// Cancel button
if (cancelButton != null) {
cancelButton.setOnClickListener(v -> {
addClickAnimation(v);
presenter.onCancelClicked();
});
}
// Back navigation
if (backNavigation != null) {
backNavigation.setOnClickListener(v -> {
addClickAnimation(v);
presenter.onBackPressed();
});
}
// Hidden check status button untuk testing
if (checkStatusButton != null) {
checkStatusButton.setOnClickListener(v -> {
Log.d(TAG, "Manual payment simulation triggered");
presenter.onSimulatePayment();
});
}
// Hidden return main button
if (returnMainButton != null) {
returnMainButton.setOnClickListener(v -> {
navigateToMain();
});
}
// Double tap pada QR logo untuk testing
if (qrisLogo != null) {
qrisLogo.setOnClickListener(new View.OnClickListener() {
private int clickCount = 0;
private Handler handler = new Handler();
private final int DOUBLE_TAP_TIMEOUT = 300;
@Override
public void onClick(View v) {
clickCount++;
if (clickCount == 1) {
handler.postDelayed(() -> clickCount = 0, DOUBLE_TAP_TIMEOUT);
} else if (clickCount == 2) {
// Double tap detected - simulate payment
clickCount = 0;
Log.d(TAG, "Double tap detected - simulating payment");
presenter.onSimulatePayment();
}
}
});
}
}
private void setupUrlCopyFunctionality() {
if (qrUrlTextView != null) {
qrUrlTextView.setOnClickListener(v -> {
if (presenter.getTransaction() != null) {
String qrUrl = presenter.getTransaction().getQrImageUrl();
if (qrUrl != null) {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText("QR URL", qrUrl);
clipboard.setPrimaryClip(clip);
showToast("URL copied to clipboard");
}
}
});
}
}
private void setupSimulatorButton() {
if (simulatorButton != null) {
simulatorButton.setOnClickListener(v -> {
try {
String simulatorUrl = "https://simulator.sandbox.midtrans.com/v2/qris/index";
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(simulatorUrl));
startActivity(browserIntent);
} catch (Exception e) {
showToast("Could not open browser");
Log.e(TAG, "Error opening simulator URL", e);
}
});
}
}
// ========================================================================================
// MVP CONTRACT VIEW IMPLEMENTATIONS
// ========================================================================================
@Override
public void showQrImage(String qrImageUrl) {
Log.d(TAG, "🖼️ Showing QR image: " + qrImageUrl);
if (qrImageUrl != null && !qrImageUrl.isEmpty()) {
QrImageLoader.loadQrImage(qrImageUrl, new QrImageLoader.ImageLoadCallback() {
@Override
public void onImageLoaded(Bitmap bitmap) {
if (qrImageView != null) {
qrImageView.setImageBitmap(bitmap);
qrImageView.setAlpha(1.0f); // Ensure fully visible
}
}
@Override
public void onImageLoadFailed(String errorMessage) {
Log.e(TAG, "❌ Failed to load QR image: " + errorMessage);
if (qrImageView != null) {
qrImageView.setVisibility(View.GONE);
}
showError("Failed to load QR code: " + errorMessage);
}
});
// Update URL display
if (qrUrlTextView != null) {
qrUrlTextView.setText(qrImageUrl);
}
} else {
Log.w(TAG, "⚠️ QR image URL is not available");
if (qrImageView != null) {
qrImageView.setVisibility(View.GONE);
}
showToast("QR code URL not available");
}
}
@Override
public void showAmount(String formattedAmount) {
Log.d(TAG, "💰 Showing amount: " + formattedAmount);
if (amountTextView != null) {
amountTextView.setText(formattedAmount);
}
}
@Override
public void showTimer(String timeDisplay) {
if (timerTextView != null) {
timerTextView.setText(timeDisplay);
timerTextView.setTextColor(getResources().getColor(android.R.color.black));
}
}
@Override
public void showStatus(String status) {
Log.d(TAG, "📊 Showing status: " + status);
if (statusTextView != null) {
statusTextView.setText(status);
}
}
@Override
public void showProviderName(String providerName) {
Log.d(TAG, "🏷️ Showing provider: " + providerName);
// Provider name bisa ditampilkan di UI jika ada komponen khusus
}
@Override
public void updateQrImage(String newQrImageUrl) {
Log.d(TAG, "🔄 Updating QR image: " + newQrImageUrl);
runOnUiThread(() -> {
// Reset QR image appearance first
if (qrImageView != null) {
qrImageView.setAlpha(1.0f);
qrImageView.setVisibility(View.VISIBLE);
}
// Load new QR image
showQrImage(newQrImageUrl);
// Update timer display
if (timerTextView != null) {
timerTextView.setTextColor(getResources().getColor(android.R.color.black));
}
});
}
@Override
public void updateQrUrl(String newQrUrl) {
Log.d(TAG, "🔄 Updating QR URL: " + newQrUrl);
runOnUiThread(() -> {
if (qrUrlTextView != null) {
qrUrlTextView.setText(newQrUrl);
qrUrlTextView.setVisibility(View.VISIBLE);
}
});
}
@Override
public void showQrExpired() {
Log.w(TAG, "⏰ Showing QR expired");
runOnUiThread(() -> {
// Make QR semi-transparent
if (qrImageView != null) {
qrImageView.setAlpha(0.5f);
}
if (timerTextView != null) {
timerTextView.setText("EXPIRED");
timerTextView.setTextColor(getResources().getColor(android.R.color.holo_red_dark));
}
});
}
@Override
public void showQrRefreshing() {
Log.d(TAG, "🔄 Showing QR refreshing");
runOnUiThread(() -> {
if (timerTextView != null) {
timerTextView.setText("Refreshing...");
timerTextView.setTextColor(getResources().getColor(android.R.color.holo_orange_dark));
}
});
}
@Override
public void showQrRefreshFailed(String errorMessage) {
Log.e(TAG, "❌ QR refresh failed: " + errorMessage);
runOnUiThread(() -> {
if (timerTextView != null) {
timerTextView.setText("Refresh Gagal");
timerTextView.setTextColor(getResources().getColor(android.R.color.holo_red_dark));
}
// Tidak langsung navigate, biarkan presenter handle
showToast("Gagal refresh QR: " + errorMessage);
});
}
@Override
public void showQrRefreshSuccess() {
Log.d(TAG, "✅ QR refresh successful");
runOnUiThread(() -> {
// Reset QR image appearance
if (qrImageView != null) {
qrImageView.setAlpha(1.0f);
qrImageView.setVisibility(View.VISIBLE);
}
// Reset timer color and show success message
if (timerTextView != null) {
timerTextView.setTextColor(getResources().getColor(android.R.color.black));
}
// Show success toast
showToast("QR Code berhasil diperbarui!");
});
}
@Override
public void showPaymentSuccess(String providerName) {
Log.d(TAG, "🎉 Showing payment success for: " + providerName);
runOnUiThread(() -> {
showFullScreenSuccess(providerName);
// Navigate to receipt after 3 seconds, then to main activity
new Handler(Looper.getMainLooper()).postDelayed(() -> {
// Fixed: Remove the undefined 'view' variable and just check if activity is still valid
if (!isFinishing() && !isDestroyed()) {
navigateToReceipt(presenter.getTransaction());
}
}, 3000);
});
}
@Override
public void showPaymentFailed(String reason) {
Log.w(TAG, "❌ Payment failed: " + reason);
showToast("Payment failed: " + reason);
}
@Override
public void showPaymentPending() {
Log.d(TAG, "⏳ Payment pending");
showStatus("Payment pending...");
}
@Override
public void showLoading() {
if (progressBar != null) {
progressBar.setVisibility(View.VISIBLE);
}
}
@Override
public void hideLoading() {
if (progressBar != null) {
progressBar.setVisibility(View.GONE);
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == 1001) { // Receipt activity result
Log.d(TAG, "📄 Receipt activity finished, navigating to main");
navigateToMainWithTransactionComplete();
}
}
private void navigateToMainWithTransactionComplete() {
Intent intent = new Intent(this, com.example.bdkipoc.MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
// Add transaction completion data
intent.putExtra("transaction_completed", true);
if (presenter != null && presenter.getTransaction() != null) {
intent.putExtra("transaction_amount", String.valueOf(presenter.getTransaction().getOriginalAmount()));
intent.putExtra("payment_provider", presenter.getTransaction().getDisplayProviderName());
}
startActivity(intent);
finishAffinity(); // Clear all activities in the task
}
@Override
public void navigateToReceipt(QrisTransaction transaction) {
Log.d(TAG, "📄 Navigating to receipt");
Intent intent = new Intent(this, ReceiptActivity.class);
// Put transaction data
intent.putExtra("calling_activity", "QrisResultActivity");
intent.putExtra("transaction_id", transaction.getTransactionId());
intent.putExtra("reference_id", transaction.getReferenceId());
intent.putExtra("order_id", transaction.getOrderId());
intent.putExtra("transaction_amount", String.valueOf(transaction.getOriginalAmount()));
intent.putExtra("gross_amount", transaction.getGrossAmount() != null ? transaction.getGrossAmount() : String.valueOf(transaction.getOriginalAmount()));
intent.putExtra("created_at", getCurrentDateTime());
intent.putExtra("transaction_date", getCurrentDateTime());
intent.putExtra("payment_method", "QRIS");
intent.putExtra("channel_code", "QRIS");
intent.putExtra("channel_category", "RETAIL_OUTLET");
intent.putExtra("card_type", transaction.getDisplayProviderName());
intent.putExtra("merchant_name", "Marcel Panjaitan");
intent.putExtra("merchant_location", "Jakarta, Indonesia");
intent.putExtra("acquirer", transaction.getActualIssuer() != null ? transaction.getActualIssuer() : transaction.getAcquirer());
intent.putExtra("mid", "71000026521");
intent.putExtra("tid", "73001500");
// Enhanced data
intent.putExtra("detected_provider", transaction.getDetectedProvider());
intent.putExtra("qr_expiration_minutes", transaction.getQrExpirationMinutes());
intent.putExtra("was_qr_refresh_transaction", transaction.isQrRefreshTransaction());
// QR string
if (transaction.getQrString() != null && !transaction.getQrString().isEmpty()) {
intent.putExtra("qr_string", transaction.getQrString());
}
// Add flag to automatically return to main after receipt
intent.putExtra("auto_return_to_main", true);
startActivityForResult(intent, 1001);
}
@Override
public void navigateToMain() {
Intent intent = new Intent(this, com.example.bdkipoc.MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
finishAffinity();
}
@Override
public void finishActivity() {
finish();
}
@Override
public void showToast(String message) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
}
@Override
public void showError(String errorMessage) {
Log.e(TAG, "❌ Error: " + errorMessage);
Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show();
}
@Override
public void startSuccessAnimation() {
Log.d(TAG, "🎬 Starting success animation");
// Animation akan di-handle oleh showFullScreenSuccess
}
@Override
public void stopAllAnimations() {
Log.d(TAG, "🛑 Stopping all animations");
if (animationHandler != null) {
animationHandler.removeCallbacksAndMessages(null);
}
}
// ========================================================================================
// PRIVATE HELPER METHODS
// ========================================================================================
/**
* Show full screen success dengan animations
*/
private void showFullScreenSuccess(String providerName) {
if (successScreen != null && !isFinishing()) {
// Hide main UI components
hideMainUIComponents();
// Set success message
if (successMessage != null) {
successMessage.setText("Pembayaran " + providerName + " Berhasil");
}
// Show success screen dengan fade in animation
successScreen.setVisibility(View.VISIBLE);
successScreen.setAlpha(0f);
// Fade in background
ObjectAnimator backgroundFadeIn = ObjectAnimator.ofFloat(successScreen, "alpha", 0f, 1f);
backgroundFadeIn.setDuration(500);
backgroundFadeIn.start();
// Success icon animation
if (successIcon != null) {
successIcon.setScaleX(0f);
successIcon.setScaleY(0f);
successIcon.setAlpha(0f);
ObjectAnimator scaleX = ObjectAnimator.ofFloat(successIcon, "scaleX", 0f, 1.2f, 1f);
ObjectAnimator scaleY = ObjectAnimator.ofFloat(successIcon, "scaleY", 0f, 1.2f, 1f);
ObjectAnimator iconFadeIn = ObjectAnimator.ofFloat(successIcon, "alpha", 0f, 1f);
AnimatorSet iconAnimation = new AnimatorSet();
iconAnimation.playTogether(scaleX, scaleY, iconFadeIn);
iconAnimation.setDuration(800);
iconAnimation.setStartDelay(300);
iconAnimation.setInterpolator(new android.view.animation.OvershootInterpolator(1.2f));
iconAnimation.start();
}
// Success message animation
if (successMessage != null) {
successMessage.setAlpha(0f);
successMessage.setTranslationY(50f);
ObjectAnimator messageSlideUp = ObjectAnimator.ofFloat(successMessage, "translationY", 50f, 0f);
ObjectAnimator messageFadeIn = ObjectAnimator.ofFloat(successMessage, "alpha", 0f, 1f);
AnimatorSet messageAnimation = new AnimatorSet();
messageAnimation.playTogether(messageSlideUp, messageFadeIn);
messageAnimation.setDuration(600);
messageAnimation.setStartDelay(600);
messageAnimation.setInterpolator(new android.view.animation.DecelerateInterpolator());
messageAnimation.start();
}
}
}
/**
* Hide main UI components untuk clean success screen
*/
private void hideMainUIComponents() {
if (mainCard != null) {
mainCard.setVisibility(View.GONE);
}
if (headerBackground != null) {
headerBackground.setVisibility(View.GONE);
}
if (backNavigation != null) {
backNavigation.setVisibility(View.GONE);
}
if (cancelButton != null) {
cancelButton.setVisibility(View.GONE);
}
}
/**
* Add click animation ke view
*/
private void addClickAnimation(View view) {
ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 0.95f, 1f);
ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 0.95f, 1f);
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(scaleX, scaleY);
animatorSet.setDuration(150);
animatorSet.start();
}
/**
* Get current date time untuk receipt
*/
private String getCurrentDateTime() {
java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("d/M/y H:m:s", new java.util.Locale("id", "ID"));
return sdf.format(new java.util.Date());
}
// ========================================================================================
// LIFECYCLE METHODS
// ========================================================================================
@Override
protected void onDestroy() {
super.onDestroy();
// Cleanup presenter
if (presenter != null) {
presenter.onDestroy();
}
// Cleanup animation handler
if (animationHandler != null) {
animationHandler.removeCallbacksAndMessages(null);
}
Log.d(TAG, "💀 QrisResultActivity destroyed");
}
@Override
protected void onPause() {
super.onPause();
Log.d(TAG, "⏸️ QrisResultActivity paused");
}
@Override
protected void onResume() {
super.onResume();
Log.d(TAG, "▶️ QrisResultActivity resumed");
}
@Override
public void onBackPressed() {
// Prevent back press during success screen animation
if (successScreen != null && successScreen.getVisibility() == View.VISIBLE) {
Log.d(TAG, "⬅️ Back press blocked during success screen");
return;
}
// Show confirmation dialog before leaving
androidx.appcompat.app.AlertDialog.Builder builder = new androidx.appcompat.app.AlertDialog.Builder(this);
builder.setTitle("Batalkan Transaksi");
builder.setMessage("Apakah Anda yakin ingin membatalkan transaksi ini?");
builder.setPositiveButton("Ya, Batalkan", (dialog, which) -> {
if (presenter != null) {
presenter.onBackPressed();
} else {
super.onBackPressed();
}
});
builder.setNegativeButton("Tidak", (dialog, which) -> {
dialog.dismiss();
});
builder.show();
}
}

View File

@ -0,0 +1,86 @@
package com.example.bdkipoc.qris.view;
import com.example.bdkipoc.qris.model.QrisTransaction;
/**
* Contract interface untuk QrisResult module
* Mendefinisikan komunikasi antara View dan Presenter
*/
public interface QrisResultContract {
/**
* View interface - apa yang bisa dilakukan oleh View (Activity)
*/
interface View {
// UI Display methods
void showQrImage(String qrImageUrl);
void showAmount(String formattedAmount);
void showTimer(String timeDisplay);
void showStatus(String status);
void showProviderName(String providerName);
// QR Management
void updateQrImage(String newQrImageUrl);
void updateQrUrl(String newQrUrl);
void showQrExpired();
void showQrRefreshing();
void showQrRefreshFailed(String errorMessage);
void showQrRefreshSuccess();
// Payment Status
void showPaymentSuccess(String providerName);
void showPaymentFailed(String reason);
void showPaymentPending();
// Loading states
void showLoading();
void hideLoading();
// Navigation
void navigateToReceipt(QrisTransaction transaction);
void navigateToMain();
void finishActivity();
// User feedback
void showToast(String message);
void showError(String errorMessage);
// Animation
void startSuccessAnimation();
void stopAllAnimations();
}
/**
* Presenter interface - apa yang bisa dilakukan oleh Presenter
*/
interface Presenter {
// Lifecycle
void attachView(View view);
void detachView();
void onDestroy();
// Initialization
void initializeTransaction(String orderId, String transactionId, String amount,
String qrImageUrl, String qrString, String acquirer);
// QR Management
void startQrManagement();
void stopQrManagement();
void refreshQrCode();
void onQrExpired();
// Payment Monitoring
void startPaymentMonitoring();
void stopPaymentMonitoring();
void checkPaymentStatus();
// User Actions
void onCancelClicked();
void onBackPressed();
void onSimulatePayment(); // For testing
// Timer
void startTimer();
void stopTimer();
}
}

View File

@ -268,4 +268,4 @@
android:letterSpacing="0.02"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</ScrollView>