/** * EotpLicense.kt — eOTP DEVELOPER license check for Android (single file, no external libraries). * * Usage: * EotpLicense.check(context, "eo_live_xxxxxxxx") { r -> * if (r.licensed) Log.i("eOTP", "Licensed: ${r.project} tier ${r.tier}") else Log.w("eOTP", "Not licensed: ${r.error}") * } * * Calls GET https://eotp.com.vn/api/v1/license?app= with header X-Api-Key. Call it once when the app starts. * Your FREE project is upgraded to DEVELOPER automatically once eOTP has both: * - license checks with the right package name from at least 2 different networks (for example Wi-Fi and 4G), and * - at least 1 real OTP sent and verified through the API (/otp/send + /otp/verify from your server). * OTPs sent in test mode (fixed code 000000) do not count. * Result.unlockedNow is true on the call that upgraded the project; Result.evidence shows the progress. * * API key is a public identifier; never ship the API secret in an app. * Sending OTP must still go through your own server (API key + secret stay server side). * * Requires: android.permission.INTERNET. Uses HttpsURLConnection + org.json (bundled with Android). * Network runs on a background thread; the callback is always delivered on the main thread. * * Error codes (Result.error): unknown_key, missing_app, app_mismatch, project_not_approved, * store_unavailable, rate_limited, network_error, bad_response, http_. */ package com.eotp.license import android.content.Context import android.os.Handler import android.os.Looper import org.json.JSONObject import java.io.BufferedReader import java.io.InputStreamReader import java.net.URL import java.net.URLEncoder import javax.net.ssl.HttpsURLConnection object EotpLicense { private const val ENDPOINT = "https://eotp.com.vn/api/v1/license" private const val TIMEOUT_MS = 8000 /** Integration evidence reported by the server (null when the field is missing). */ data class Evidence( val pingsOk: Int?, val subnets: Int?, val otpVerified: Int?, val requiredSubnets: Int?, val requiredOtpVerified: Int? ) /** * unlockedNow: true only on the call that upgraded the project to DEVELOPER (null when not reported). * evidence: progress towards DEVELOPER (null on errors or when not reported). */ data class Result @JvmOverloads constructor( val ok: Boolean, val licensed: Boolean, val project: String?, val tier: Int?, val error: String?, val unlockedNow: Boolean? = null, val evidence: Evidence? = null ) /** * Checks the DEVELOPER license of this app. * @param context any Context (used for the default appId = packageName). * @param apiKey project API key (eo_live_…). Do NOT pass the API secret. * @param appId package name registered on the store; defaults to context.packageName. * @param callback receives the Result on the main thread. */ @JvmStatic @JvmOverloads fun check(context: Context, apiKey: String, appId: String? = null, callback: (Result) -> Unit) { val app = appId ?: context.packageName val main = Handler(Looper.getMainLooper()) Thread({ val result = request(apiKey, app) main.post { callback(result) } }, "eotp-license").start() } private fun request(apiKey: String, app: String): Result { var conn: HttpsURLConnection? = null return try { val url = URL(ENDPOINT + "?app=" + URLEncoder.encode(app, "UTF-8")) conn = (url.openConnection() as HttpsURLConnection).apply { requestMethod = "GET" connectTimeout = TIMEOUT_MS readTimeout = TIMEOUT_MS useCaches = false setRequestProperty("X-Api-Key", apiKey) setRequestProperty("Accept", "application/json") } val status = conn.responseCode val stream = if (status in 200..299) conn.inputStream else conn.errorStream val body = stream?.let { s -> BufferedReader(InputStreamReader(s, Charsets.UTF_8)).use { it.readText() } } ?: "" parse(status, body) } catch (e: Exception) { Result(ok = false, licensed = false, project = null, tier = null, error = "network_error") } finally { conn?.disconnect() } } private fun parse(status: Int, body: String): Result { val json = try { JSONObject(body) } catch (e: Exception) { null } if (json == null) { return Result(false, false, null, null, if (status in 200..299) "bad_response" else "http_$status") } val ok = json.optBoolean("ok", false) && status in 200..299 val project = if (json.has("project") && !json.isNull("project")) json.optString("project") else null val tier = if (json.has("tier") && !json.isNull("tier")) json.optInt("tier") else null val error = if (ok) null else (json.optString("error", "").ifEmpty { "http_$status" }) val unlockedNow = if (json.has("unlocked_now") && !json.isNull("unlocked_now")) json.optBoolean("unlocked_now", false) else null val ev = json.optJSONObject("evidence") val req = ev?.optJSONObject("required") val evidence = if (ev == null) null else Evidence( pingsOk = intOrNull(ev, "pings_ok"), subnets = intOrNull(ev, "subnets"), otpVerified = intOrNull(ev, "otp_verified"), requiredSubnets = req?.let { intOrNull(it, "subnets") }, requiredOtpVerified = req?.let { intOrNull(it, "otp_verified") } ) return Result( ok = ok, licensed = ok && json.optBoolean("licensed", false), project = project, tier = tier, error = error, unlockedNow = unlockedNow, evidence = evidence ) } private fun intOrNull(o: JSONObject, name: String): Int? = if (o.has(name) && !o.isNull(name)) o.optInt(name) else null }