package uk.beads.manager import android.content.Context import com.google.gson.Gson import com.google.gson.annotations.SerializedName import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.ResponseBody.Companion.toResponseBody import java.util.concurrent.TimeUnit import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec object ApiClient { const val BASE_URL = "https://gamze.site.je" // Shared in-memory cookie store (persists across requests in one session) private val cookieStore = mutableMapOf>() /** Called by SessionInitializer to inject WebView cookies into OkHttp */ fun addCookie(host: String, cookie: Cookie) { cookieStore.getOrPut(host) { mutableListOf() }.apply { removeAll { it.name == cookie.name } add(cookie) } } private val client: OkHttpClient by lazy { val trustAllCerts = arrayOf( object : javax.net.ssl.X509TrustManager { override fun checkClientTrusted(chain: Array, authType: String) {} override fun checkServerTrusted(chain: Array, authType: String) {} override fun getAcceptedIssuers() = arrayOf() } ) val sslContext = javax.net.ssl.SSLContext.getInstance("SSL") sslContext.init(null, trustAllCerts, java.security.SecureRandom()) OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .sslSocketFactory(sslContext.socketFactory, trustAllCerts[0]) .hostnameVerifier { _, _ -> true } .followRedirects(true) .cookieJar(object : CookieJar { override fun saveFromResponse(url: HttpUrl, cookies: List) { val key = url.host cookieStore.getOrPut(key) { mutableListOf() }.apply { // Remove old cookies with the same name cookies.forEach { newCookie -> removeAll { it.name == newCookie.name } } addAll(cookies) } } override fun loadForRequest(url: HttpUrl): List { return cookieStore[url.host] ?: emptyList() } }) .build() } private val gson = Gson() private val jsonType = "application/json; charset=utf-8".toMediaType() /** * Detects InfinityFree's AES bot-protection challenge and solves it. * Returns true if a challenge was solved (caller should retry the request). */ private fun solveChallenge(body: String, url: String): Boolean { if (!body.contains("slowAES") || !body.contains("__test")) { return false } android.util.Log.d("ApiClient", "Bot challenge detected — solving AES challenge") try { // Extract hex values using regex val hexPattern = Regex("""toNumbers\("([0-9a-f]+)"\)""") val matches = hexPattern.findAll(body).map { it.groupValues[1] }.toList() if (matches.size < 3) { android.util.Log.e("ApiClient", "Could not extract AES params from challenge") return false } val keyHex = matches[0] // a = AES key val ivHex = matches[1] // b = IV val ctHex = matches[2] // c = ciphertext val key = hexToBytes(keyHex) val iv = hexToBytes(ivHex) val ciphertext = hexToBytes(ctHex) // AES-CBC decrypt (mode 2 = CBC in slowAES) val cipher = Cipher.getInstance("AES/CBC/NoPadding") cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) val decrypted = cipher.doFinal(ciphertext) val cookieValue = bytesToHex(decrypted) android.util.Log.d("ApiClient", "Solved __test cookie: $cookieValue") // Store the solved cookie so it gets sent with the next request val httpUrl = url.toHttpUrlOrNull() ?: return false val solvedCookie = Cookie.Builder() .name("__test") .value(cookieValue) .domain(httpUrl.host) .path("/") .build() cookieStore.getOrPut(httpUrl.host) { mutableListOf() }.apply { removeAll { it.name == "__test" } add(solvedCookie) } return true } catch (e: Exception) { android.util.Log.e("ApiClient", "Failed to solve challenge: ${e.message}") return false } } private fun hexToBytes(hex: String): ByteArray { val result = ByteArray(hex.length / 2) for (i in result.indices) { result[i] = hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() } return result } private fun bytesToHex(bytes: ByteArray): String { return bytes.joinToString("") { "%02x".format(it.toInt() and 0xFF) } } /** * Execute a request, automatically solving InfinityFree bot protection if encountered. */ private fun executeWithBypass(request: Request): okhttp3.Response { val firstResponse = client.newCall(request).execute() val body = firstResponse.body?.string().orEmpty() // Check if we hit the bot challenge if (body.contains("slowAES") && body.contains("__test")) { firstResponse.close() val challengeSolved = solveChallenge(body, request.url.toString()) if (challengeSolved) { android.util.Log.d("ApiClient", "Retrying request after solving challenge") // Retry original request — cookies now include __test return client.newCall(request).execute() } } // No challenge — wrap already-read body back into a new Response val mediaType = firstResponse.body?.contentType() val newBody = body.toResponseBody(mediaType) return firstResponse.newBuilder().body(newBody).build() } fun login(username: String, password: String): LoginResponse { val body = gson.toJson(mapOf("username" to username, "password" to password)) .toRequestBody(jsonType) val loginUrl = "$BASE_URL/login.php" val request = Request.Builder() .url(loginUrl) .post(body) .addHeader("Accept", "application/json") .addHeader("User-Agent", "BeadsManager-Android/1.0") .build() executeWithBypass(request).use { response -> val text = response.body?.string().orEmpty() android.util.Log.d("ApiClient", "Login URL: $loginUrl") android.util.Log.d("ApiClient", "Response code: ${response.code}") android.util.Log.d("ApiClient", "Response body: ${text.take(300)}") if (!response.isSuccessful) { throw ApiException("Login failed (${response.code}): ${text.take(200)}") } if (text.isEmpty()) { throw ApiException("Empty response from server") } if (text.trimStart().startsWith("<")) { throw ApiException("Server returned HTML instead of JSON — bot protection may be active") } try { val parsed = gson.fromJson(text, LoginResponse::class.java) if (parsed.success != true) { throw ApiException(parsed.message ?: "Invalid credentials") } return parsed } catch (e: ApiException) { throw e } catch (e: Exception) { throw ApiException("Invalid JSON response: ${e.message}\nBody: ${text.take(200)}") } } } fun pollOrders(context: Context, since: String? = null): PollResponse { val auth = SessionManager.getToken(context) ?: throw ApiException("Not logged in") val encoded = since?.let { java.net.URLEncoder.encode(it, "UTF-8") } val url = if (encoded != null) { "$BASE_URL/poll.php?since=$encoded&token=$auth" } else { "$BASE_URL/poll.php?token=$auth" } val request = Request.Builder() .url(url) .addHeader("Authorization", "Bearer $auth") .addHeader("Accept", "application/json") .addHeader("User-Agent", "BeadsManager-Android/1.0") .get() .build() executeWithBypass(request).use { response -> val text = response.body?.string().orEmpty() if (!response.isSuccessful) throw ApiException("Poll failed: ${text.take(200)}") return gson.fromJson(text, PollResponse::class.java) } } fun fetchOrders(context: Context): List { val auth = SessionManager.getToken(context) ?: throw ApiException("Not logged in") val request = Request.Builder() .url("$BASE_URL/orders.php?token=$auth") .addHeader("Authorization", "Bearer $auth") .addHeader("Accept", "application/json") .addHeader("User-Agent", "BeadsManager-Android/1.0") .get() .build() executeWithBypass(request).use { response -> val text = response.body?.string().orEmpty() if (!response.isSuccessful) throw ApiException("Could not load orders: ${text.take(200)}") if (text.trimStart().startsWith("<")) throw ApiException("Server returned HTML for orders") val parsed = gson.fromJson(text, OrdersResponse::class.java) return parsed.orders ?: emptyList() } } fun fetchOrder(context: Context, id: Int): OrderDto { val auth = SessionManager.getToken(context) ?: throw ApiException("Not logged in") val request = Request.Builder() .url("$BASE_URL/orders.php?id=$id&token=$auth") .addHeader("Authorization", "Bearer $auth") .addHeader("Accept", "application/json") .addHeader("User-Agent", "BeadsManager-Android/1.0") .get() .build() executeWithBypass(request).use { response -> val text = response.body?.string().orEmpty() if (!response.isSuccessful) throw ApiException("Order not found: ${text.take(200)}") if (text.trimStart().startsWith("<")) throw ApiException("Server returned HTML for order") val parsed = gson.fromJson(text, OrderResponse::class.java) return parsed.order ?: throw ApiException("Order not found") } } } class ApiException(message: String) : Exception(message) data class LoginResponse( val success: Boolean, val token: String?, val message: String? ) data class OrdersResponse(val success: Boolean, val orders: List?) data class OrderResponse(val success: Boolean, val order: OrderDto?) data class OrderDto( val id: Int, @SerializedName("order_number") val orderNumber: String, val status: String, val customer: CustomerDto, @SerializedName("delivery_address") val deliveryAddress: AddressDto, val subtotal: Double, @SerializedName("delivery_fee") val deliveryFee: Double, @SerializedName("vat_amount") val vatAmount: Double, val total: Double, val currency: String, val items: List ) data class CustomerDto( @SerializedName("first_name") val firstName: String, @SerializedName("last_name") val lastName: String, val email: String, val phone: String? ) data class AddressDto( val line1: String, val line2: String?, val city: String, val postcode: String, val country: String ) data class OrderItemDto( @SerializedName("product_name") val productName: String, @SerializedName("product_image") val productImage: String?, @SerializedName("unit_price") val unitPrice: Double, val quantity: Int, @SerializedName("line_total") val lineTotal: Double ) data class PollResponse( val success: Boolean, val orders: List, @SerializedName("server_time") val serverTime: String? )