add support to detect dhiraagu and ooredoo packages
Auto Tag on Version Change / check-version (push) Successful in 2s

This commit is contained in:
2026-05-17 00:25:48 +05:00
parent 93405aade2
commit ccc9e11d55
9 changed files with 781 additions and 3 deletions
@@ -0,0 +1,71 @@
package sh.sar.basedbank.api.dhiraagu
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class DhiraaguClient {
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
enum class CustType { RELOAD, BILL_PAY, UNSUPPORTED }
companion object {
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
}
data class Result(val type: CustType, val ownerName: String = "")
fun validateNumber(number: String): Result {
// Step 1: fetch Easy Pay page to extract the nonce
val pageResp = client.newCall(
Request.Builder()
.url("https://www.dhiraagu.com.mv/services/easy-pay")
.header("User-Agent", UA)
.get()
.build()
).execute()
val html = pageResp.body?.string() ?: return Result(CustType.UNSUPPORTED)
pageResp.close()
val nonce = Regex("""var nonce = "([^"]+)"""").find(html)
?.groupValues?.getOrNull(1) ?: return Result(CustType.UNSUPPORTED)
// Step 2: look up number
val body = """{"number":"$number"}"""
.toRequestBody("application/json".toMediaType())
val resp = client.newCall(
Request.Builder()
.url("https://www.dhiraagu.com.mv/api/sdk-dhr-webapi.ashx?website_id=CA2BB809-3A22-485B-A518-DA6B6DE653A5&sub=dhiraaguIO&act=infoUnlisted")
.post(body)
.header("User-Agent", UA)
.header("nonce", nonce)
.header("Origin", "https://dhiraagu.com.mv")
.build()
).execute()
val json = resp.body?.string() ?: return Result(CustType.UNSUPPORTED)
resp.close()
return try {
val obj = JSONObject(json)
if (obj.optString("respStatus") != "OK") return Result(CustType.UNSUPPORTED)
val prepaid = obj.optJSONArray("serviceDetails")
?.optJSONObject(0)
?.optString("prepaidIndicator")
val ownerName = obj.optJSONObject("accountOwnerInfo")
?.optString("name") ?: ""
val type = when (prepaid) {
"Y" -> CustType.RELOAD
"N" -> CustType.BILL_PAY
else -> CustType.UNSUPPORTED
}
Result(type, ownerName)
} catch (_: Exception) { Result(CustType.UNSUPPORTED) }
}
}
@@ -0,0 +1,43 @@
package sh.sar.basedbank.api.fahipay
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class OoredooClient {
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
enum class CustType { PRE, POST, HYBRID, UNSUPPORTED }
/**
* Validates an Ooredoo number and returns which services are supported.
* @param number 7-digit Maldivian phone number (without country code)
*/
fun validateNumber(number: String): CustType {
val resp = client.newCall(
Request.Builder()
.url("https://www.ooredoo.mv/ooredoo-prod/QuickPayPackage/v1/numberTypeValidation?action=cust_details&msisdn=960$number")
.get()
.build()
).execute()
val json = resp.body?.string() ?: return CustType.UNSUPPORTED
resp.close()
return try {
val custType = JSONObject(json)
.optJSONObject("data")
?.optString("custType")
?.takeIf { it.isNotBlank() && it != "null" }
when (custType) {
"PRE" -> CustType.PRE
"POST" -> CustType.POST
"HYBRID" -> CustType.HYBRID
else -> CustType.UNSUPPORTED
}
} catch (_: Exception) { CustType.UNSUPPORTED }
}
}
@@ -32,6 +32,8 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.OoredooClient
import sh.sar.basedbank.api.bml.BmlTransferResult
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
@@ -65,6 +67,10 @@ class TransferFragment : Fragment() {
private var resolvedRecipientName = ""
private var resolvedBankName = ""
// Selected Fahipay service when source is Fahipay and destination is a phone number
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
private var selectedFahipayService: String? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
@@ -220,7 +226,9 @@ class TransferFragment : Fragment() {
binding.btnClearToInfo.setOnClickListener {
resolvedAccountNumber = ""
resolvedRecipientName = ""
selectedFahipayService = null
binding.cardToInfo.visibility = View.GONE
binding.layoutServiceSelector.visibility = View.INVISIBLE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
@@ -265,6 +273,16 @@ class TransferFragment : Fragment() {
return
}
// Fahipay source: only phone numbers are supported
if (selectedAccount?.profileType == "FAHIPAY") {
if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
lookupFahipayTarget(accountNumber)
} else {
binding.tilTo.error = getString(R.string.transfer_fahipay_phone_only)
}
return
}
val mibSess = session
val bmlSess = bmlSession
if (mibSess == null && bmlSess == null) {
@@ -352,6 +370,99 @@ class TransferFragment : Fragment() {
}
}
private fun lookupFahipayTarget(number: String) {
binding.tilTo.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
data class LookupResult(
val dhiraagu: DhiraaguClient.Result,
val ooredoo: OoredooClient.CustType
)
val result = withContext(Dispatchers.IO) {
if (number.startsWith("7")) {
// Dhiraagu first, fall back to Ooredoo
val d = try { DhiraaguClient().validateNumber(number) }
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED)
try { OoredooClient().validateNumber(number) }
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
else OoredooClient.CustType.UNSUPPORTED
LookupResult(d, o)
} else {
// Ooredoo first, fall back to Dhiraagu
val o = try { OoredooClient().validateNumber(number) }
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
val d = if (o == OoredooClient.CustType.UNSUPPORTED)
try { DhiraaguClient().validateNumber(number) }
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
LookupResult(d, o)
}
}
binding.tilTo.isEnabled = true
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
// Collect all applicable services
val services = buildList {
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add("DHIRAAGU_RELOAD")
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add("DHIRAAGU_BILL")
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add("RAASTAS")
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add("OOREDOO_BILL")
}
if (services.isEmpty()) return@launch
// Only one option — auto-select, no chip UI needed
if (services.size == 1) {
selectFahipayService(services[0], number, dhiraaguName)
return@launch
}
// Multiple options (Ooredoo HYBRID) — show chips
binding.chipDhiraaguReload.visibility = if ("DHIRAAGU_RELOAD" in services) View.VISIBLE else View.GONE
binding.chipDhiraaguBill.visibility = if ("DHIRAAGU_BILL" in services) View.VISIBLE else View.GONE
binding.chipRaastas.visibility = if ("RAASTAS" in services) View.VISIBLE else View.GONE
binding.chipOoredooBill.visibility = if ("OOREDOO_BILL" in services) View.VISIBLE else View.GONE
binding.layoutServiceSelector.visibility = View.VISIBLE
binding.chipGroupService.clearCheck()
binding.chipDhiraaguReload.setOnCheckedChangeListener { _, checked ->
if (checked) { selectFahipayService("DHIRAAGU_RELOAD", number, dhiraaguName); binding.layoutServiceSelector.visibility = View.INVISIBLE }
}
binding.chipDhiraaguBill.setOnCheckedChangeListener { _, checked ->
if (checked) { selectFahipayService("DHIRAAGU_BILL", number, dhiraaguName); binding.layoutServiceSelector.visibility = View.INVISIBLE }
}
binding.chipRaastas.setOnCheckedChangeListener { _, checked ->
if (checked) { selectFahipayService("RAASTAS", number); binding.layoutServiceSelector.visibility = View.INVISIBLE }
}
binding.chipOoredooBill.setOnCheckedChangeListener { _, checked ->
if (checked) { selectFahipayService("OOREDOO_BILL", number); binding.layoutServiceSelector.visibility = View.INVISIBLE }
}
}
}
private fun selectFahipayService(service: String, number: String, ownerName: String? = null) {
selectedFahipayService = service
val contacts = viewModel.contacts.value ?: emptyList()
val displayName = ownerName
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
?: number
val serviceLabel = when (service) {
"RAASTAS" -> "Raastas"
"OOREDOO_BILL" -> "Ooredoo Bill Pay"
"DHIRAAGU_RELOAD" -> "Dhiraagu Reload"
"DHIRAAGU_BILL" -> "Dhiraagu Bill Pay"
else -> service
}
prefillToDirectly(
accountNumber = number,
displayName = displayName,
subtitle = "$serviceLabel · $number",
colorHex = "#FF6B00",
imageHash = null
)
}
private fun prefillToDirectly(
accountNumber: String,
displayName: String,
@@ -661,7 +772,9 @@ class TransferFragment : Fragment() {
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedBankName = ""
selectedFahipayService = null
binding.cardToInfo.visibility = View.GONE
binding.chipGroupService.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE