diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0da30a6..7a71884 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -24,6 +24,19 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/net/rodakot/posthub/FlowWidgetProvider.kt b/app/src/main/java/net/rodakot/posthub/FlowWidgetProvider.kt
new file mode 100644
index 0000000..20f17a2
--- /dev/null
+++ b/app/src/main/java/net/rodakot/posthub/FlowWidgetProvider.kt
@@ -0,0 +1,994 @@
+package net.rodakot.posthub
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.net.Uri
+import android.widget.RemoteViews
+import org.json.JSONArray
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.ProtocolException
+import java.net.URL
+import java.util.Locale
+import java.util.concurrent.Executors
+
+class FlowWidgetProvider : AppWidgetProvider() {
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray
+ ) {
+ appWidgetIds.forEach { widgetId ->
+ appWidgetManager.updateAppWidget(
+ widgetId,
+ widgetViews(context, widgetId, loadLastWidgetState(context))
+ )
+ }
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != ACTION_REFRESH) {
+ super.onReceive(context, intent)
+ return
+ }
+
+ val pendingResult = goAsync()
+ val appContext = context.applicationContext
+ val manager = AppWidgetManager.getInstance(appContext)
+ val ids = widgetIds(appContext, manager, intent)
+
+ ids.forEach { widgetId ->
+ manager.updateAppWidget(
+ widgetId,
+ widgetViews(
+ context = appContext,
+ widgetId = widgetId,
+ state = WidgetState(
+ subtitle = "Running saved flow",
+ status = "Running",
+ meta = "Starting",
+ body = "Connecting to the first request...",
+ statusColor = COLOR_AMBER
+ )
+ )
+ )
+ }
+
+ Executor.execute {
+ try {
+ val state = runSavedFlow(appContext) { runningState ->
+ ids.forEach { widgetId ->
+ manager.updateAppWidget(
+ widgetId,
+ widgetViews(appContext, widgetId, runningState)
+ )
+ }
+ }
+ saveLastWidgetState(appContext, state)
+ ids.forEach { widgetId ->
+ manager.updateAppWidget(widgetId, widgetViews(appContext, widgetId, state))
+ }
+ } catch (error: Exception) {
+ val state = WidgetState(
+ subtitle = "Flow failed",
+ status = "Error",
+ meta = error.javaClass.simpleName,
+ body = error.cleanMessage(),
+ statusColor = COLOR_RED
+ )
+ saveLastWidgetState(appContext, state)
+ ids.forEach { widgetId ->
+ manager.updateAppWidget(widgetId, widgetViews(appContext, widgetId, state))
+ }
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+
+ companion object {
+ private const val ACTION_REFRESH = "net.rodakot.posthub.action.RUN_WIDGET_FLOW"
+ private const val PREF_NAME = "posthub_state"
+ private const val ENVIRONMENT_KEY = "environment_variables"
+ private const val FLOW_KEY = "request_flow"
+ private const val WIDGET_STATE_KEY = "widget_state"
+ private const val COLOR_PRIMARY = 0xFF006B5B.toInt()
+ private const val COLOR_AMBER = 0xFFC47A12.toInt()
+ private const val COLOR_GREEN = 0xFF147A56.toInt()
+ private const val COLOR_RED = 0xFFC7383D.toInt()
+ private const val COLOR_MUTED = 0xFF66736D.toInt()
+ private val VariablePattern = Regex("""\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}""")
+ private val Executor = Executors.newSingleThreadExecutor()
+ }
+}
+
+private data class WidgetState(
+ val title: String = "PostHUB Flow",
+ val subtitle: String = "Home runner",
+ val status: String = "Ready",
+ val meta: String = "Tap refresh",
+ val body: String = "Run your saved flow from the home screen.",
+ val statusColor: Int = 0xFF006B5B.toInt()
+)
+
+private data class WidgetRequestField(
+ val id: Int,
+ val key: String,
+ val value: String,
+ val enabled: Boolean = true
+)
+
+private data class WidgetAuthState(
+ val type: String = "None",
+ val bearerToken: String = "",
+ val username: String = "",
+ val password: String = ""
+)
+
+private data class WidgetCaptureField(
+ val id: Int,
+ val variableName: String,
+ val source: String,
+ val enabled: Boolean = true
+)
+
+private data class WidgetSavedRequest(
+ val name: String,
+ val method: String,
+ val url: String,
+ val params: List,
+ val body: String,
+ val headers: List,
+ val auth: WidgetAuthState,
+ val captures: List
+)
+
+private data class WidgetFlowStep(
+ val id: Long,
+ val request: WidgetSavedRequest
+)
+
+private data class WidgetRequestSnapshot(
+ val method: String,
+ val url: String,
+ val params: List,
+ val headers: List,
+ val auth: WidgetAuthState,
+ val body: String
+)
+
+private data class WidgetResponseHeader(
+ val name: String,
+ val value: String
+)
+
+private data class WidgetRequestResult(
+ val statusCode: Int?,
+ val statusText: String,
+ val durationMs: Long,
+ val byteCount: Int,
+ val finalUrl: String,
+ val body: String,
+ val rawBody: String,
+ val headers: List,
+ val error: String? = null
+)
+
+private fun widgetViews(
+ context: Context,
+ widgetId: Int,
+ state: WidgetState
+): RemoteViews {
+ return RemoteViews(context.packageName, R.layout.widget_flow).apply {
+ setTextViewText(R.id.widget_title, state.title)
+ setTextViewText(R.id.widget_subtitle, state.subtitle)
+ setTextViewText(R.id.widget_status, state.status)
+ setTextViewText(R.id.widget_meta, state.meta)
+ setTextViewText(R.id.widget_body, state.body)
+ setTextColor(R.id.widget_status, state.statusColor)
+ setOnClickPendingIntent(R.id.widget_refresh, refreshIntent(context, widgetId))
+ setOnClickPendingIntent(R.id.widget_root, launchIntent(context))
+ }
+}
+
+private fun refreshIntent(context: Context, widgetId: Int): PendingIntent {
+ val intent = Intent(context, FlowWidgetProvider::class.java).apply {
+ action = "net.rodakot.posthub.action.RUN_WIDGET_FLOW"
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
+ }
+ return PendingIntent.getBroadcast(
+ context,
+ widgetId,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+}
+
+private fun launchIntent(context: Context): PendingIntent {
+ val intent = Intent(context, MainActivity::class.java)
+ return PendingIntent.getActivity(
+ context,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+}
+
+private fun widgetIds(
+ context: Context,
+ manager: AppWidgetManager,
+ intent: Intent
+): IntArray {
+ val id = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
+ if (id != AppWidgetManager.INVALID_APPWIDGET_ID) return intArrayOf(id)
+ return manager.getAppWidgetIds(ComponentName(context, FlowWidgetProvider::class.java))
+}
+
+private fun runSavedFlow(
+ context: Context,
+ onProgress: (WidgetState) -> Unit
+): WidgetState {
+ val steps = loadFlowSteps(context).ifEmpty { defaultWidgetFlow() }
+ var environmentRows = loadEnvironmentVariables(context)
+ var lastResult: WidgetRequestResult? = null
+
+ steps.forEachIndexed { index, step ->
+ onProgress(
+ WidgetState(
+ subtitle = "Step ${index + 1} of ${steps.size}",
+ status = "Running",
+ meta = step.request.name,
+ body = step.request.url,
+ statusColor = 0xFFC47A12.toInt()
+ )
+ )
+
+ val snapshot = step.request
+ .toSnapshot()
+ .resolveVariables(environmentRows.toEnvironmentMap())
+ val result = executeWidgetRequest(snapshot)
+ lastResult = result
+
+ if (result.error != null) {
+ return WidgetState(
+ subtitle = "Failed at ${step.request.name}",
+ status = "Error",
+ meta = result.statusText,
+ body = result.error,
+ statusColor = 0xFFC7383D.toInt()
+ )
+ }
+
+ val captured = captureWidgetValues(result, step.request.captures)
+ if (captured.isNotEmpty()) {
+ environmentRows = mergeCapturedVariables(environmentRows, captured)
+ saveEnvironmentVariables(context, environmentRows)
+ }
+ }
+
+ val result = lastResult ?: return WidgetState(
+ subtitle = "No flow steps",
+ status = "Empty",
+ meta = "Open PostHUB",
+ body = "Create or add requests to a flow, then refresh this widget.",
+ statusColor = 0xFF66736D.toInt()
+ )
+
+ return WidgetState(
+ subtitle = "${steps.size} step flow",
+ status = result.statusCode?.toString() ?: "Done",
+ meta = "${result.durationMs} ms ${formatBytes(result.byteCount)}",
+ body = summarizeWidgetResponse(result),
+ statusColor = statusColor(result.statusCode)
+ )
+}
+
+private fun executeWidgetRequest(snapshot: WidgetRequestSnapshot): WidgetRequestResult {
+ val started = System.nanoTime()
+ var connection: HttpURLConnection? = null
+ var finalUrl = ""
+
+ return try {
+ finalUrl = buildWidgetUrl(snapshot.url, snapshot.params)
+ connection = (URL(finalUrl).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 15_000
+ readTimeout = 30_000
+ instanceFollowRedirects = true
+ setRequestMethodCompat(snapshot.method)
+ setRequestProperty("User-Agent", "PostHUB Android/1.0")
+ }
+
+ snapshot.headers
+ .filter { it.enabled && it.key.isNotBlank() }
+ .forEach { header ->
+ connection.setRequestProperty(header.key.trim(), header.value)
+ }
+
+ authHeader(snapshot.auth)?.let { authorization ->
+ val hasAuthorization = snapshot.headers.any {
+ it.enabled && it.key.equals("Authorization", ignoreCase = true)
+ }
+ if (!hasAuthorization) {
+ connection.setRequestProperty("Authorization", authorization)
+ }
+ }
+
+ if (snapshot.method.allowsBody() && snapshot.body.isNotBlank()) {
+ val requestBytes = snapshot.body.toByteArray(Charsets.UTF_8)
+ connection.doOutput = true
+ if (connection.getRequestProperty("Content-Type").isNullOrBlank()) {
+ connection.setRequestProperty("Content-Type", "application/json")
+ }
+ connection.setRequestProperty("Content-Length", requestBytes.size.toString())
+ connection.outputStream.use { output ->
+ output.write(requestBytes)
+ }
+ }
+
+ val statusCode = connection.responseCode
+ val statusText = connection.responseMessage.orEmpty()
+ val bytes = readResponseBytes(connection, statusCode)
+ val rawBody = bytes.toString(Charsets.UTF_8)
+
+ WidgetRequestResult(
+ statusCode = statusCode,
+ statusText = statusText,
+ durationMs = elapsedMs(started),
+ byteCount = bytes.size,
+ finalUrl = finalUrl,
+ body = prettyPrintBody(rawBody),
+ rawBody = rawBody,
+ headers = connection.headerFields
+ .orEmpty()
+ .filterKeys { it != null }
+ .map {
+ val name = it.key.orEmpty()
+ val separator = if (name.equals("Set-Cookie", ignoreCase = true)) "\n" else "; "
+ WidgetResponseHeader(name, it.value.orEmpty().joinToString(separator))
+ }
+ .sortedBy { it.name.lowercase(Locale.US) }
+ )
+ } catch (error: Exception) {
+ WidgetRequestResult(
+ statusCode = null,
+ statusText = error.javaClass.simpleName,
+ durationMs = elapsedMs(started),
+ byteCount = 0,
+ finalUrl = finalUrl.ifBlank { snapshot.url },
+ body = "",
+ rawBody = "",
+ headers = emptyList(),
+ error = error.cleanMessage()
+ )
+ } finally {
+ connection?.disconnect()
+ }
+}
+
+private fun loadFlowSteps(context: Context): List {
+ val raw = context
+ .getSharedPreferences("posthub_state", Context.MODE_PRIVATE)
+ .getString("request_flow", null)
+
+ if (raw.isNullOrBlank()) return emptyList()
+
+ return runCatching {
+ val array = JSONArray(raw)
+ (0 until array.length()).mapNotNull { index ->
+ val item = array.optJSONObject(index) ?: return@mapNotNull null
+ val request = item.optJSONObject("request") ?: return@mapNotNull null
+ WidgetFlowStep(
+ id = item.optLong("id", System.currentTimeMillis() + index),
+ request = savedRequestFromJson(request)
+ )
+ }
+ }.getOrDefault(emptyList())
+}
+
+private fun loadEnvironmentVariables(context: Context): List {
+ val raw = context
+ .getSharedPreferences("posthub_state", Context.MODE_PRIVATE)
+ .getString("environment_variables", null)
+
+ if (raw.isNullOrBlank()) return defaultEnvironmentVariables()
+
+ val loaded = runCatching {
+ requestFieldsFromJson(JSONArray(raw)).ifEmpty { defaultEnvironmentVariables() }
+ }.getOrDefault(defaultEnvironmentVariables())
+
+ return mergeDefaultEnvironmentVariables(loaded)
+}
+
+private fun saveEnvironmentVariables(
+ context: Context,
+ variables: List
+) {
+ context
+ .getSharedPreferences("posthub_state", Context.MODE_PRIVATE)
+ .edit()
+ .putString("environment_variables", requestFieldsToJson(variables).toString())
+ .apply()
+}
+
+private fun loadLastWidgetState(context: Context): WidgetState {
+ val raw = context
+ .getSharedPreferences("posthub_state", Context.MODE_PRIVATE)
+ .getString("widget_state", null)
+
+ if (raw.isNullOrBlank()) return WidgetState()
+
+ return runCatching {
+ val json = JSONObject(raw)
+ WidgetState(
+ title = json.optString("title", "PostHUB Flow"),
+ subtitle = json.optString("subtitle", "Home runner"),
+ status = json.optString("status", "Ready"),
+ meta = json.optString("meta", "Tap refresh"),
+ body = json.optString("body", "Run your saved flow from the home screen."),
+ statusColor = json.optInt("statusColor", 0xFF006B5B.toInt())
+ )
+ }.getOrDefault(WidgetState())
+}
+
+private fun saveLastWidgetState(
+ context: Context,
+ state: WidgetState
+) {
+ val json = JSONObject()
+ .put("title", state.title)
+ .put("subtitle", state.subtitle)
+ .put("status", state.status)
+ .put("meta", state.meta)
+ .put("body", state.body)
+ .put("statusColor", state.statusColor)
+
+ context
+ .getSharedPreferences("posthub_state", Context.MODE_PRIVATE)
+ .edit()
+ .putString("widget_state", json.toString())
+ .apply()
+}
+
+private fun requestFieldsToJson(rows: List): JSONArray {
+ val array = JSONArray()
+ rows.forEach { row ->
+ array.put(
+ JSONObject()
+ .put("id", row.id)
+ .put("key", row.key)
+ .put("value", row.value)
+ .put("enabled", row.enabled)
+ )
+ }
+ return array
+}
+
+private fun requestFieldsFromJson(array: JSONArray): List {
+ return (0 until array.length()).mapNotNull { index ->
+ val item = array.optJSONObject(index) ?: return@mapNotNull null
+ WidgetRequestField(
+ id = item.optInt("id", index + 1),
+ key = item.optString("key"),
+ value = item.optString("value"),
+ enabled = item.optBoolean("enabled", true)
+ )
+ }
+}
+
+private fun captureFieldsFromJson(array: JSONArray): List {
+ return (0 until array.length()).mapNotNull { index ->
+ val item = array.optJSONObject(index) ?: return@mapNotNull null
+ WidgetCaptureField(
+ id = item.optInt("id", index + 1),
+ variableName = item.optString("variableName"),
+ source = item.optString("source"),
+ enabled = item.optBoolean("enabled", true)
+ )
+ }
+}
+
+private fun authStateFromJson(json: JSONObject?): WidgetAuthState {
+ if (json == null) return WidgetAuthState()
+
+ return WidgetAuthState(
+ type = json.optString("type", "None"),
+ bearerToken = json.optString("bearerToken"),
+ username = json.optString("username"),
+ password = json.optString("password")
+ )
+}
+
+private fun savedRequestFromJson(json: JSONObject): WidgetSavedRequest {
+ return WidgetSavedRequest(
+ name = json.optString("name", "Request"),
+ method = json.optString("method", "GET"),
+ url = json.optString("url"),
+ params = json.optJSONArray("params")?.let(::requestFieldsFromJson) ?: defaultParamRows(),
+ body = json.optString("body"),
+ headers = json.optJSONArray("headers")?.let(::requestFieldsFromJson) ?: defaultHeaderRows(),
+ auth = authStateFromJson(json.optJSONObject("auth")),
+ captures = json.optJSONArray("captures")?.let(::captureFieldsFromJson) ?: emptyList()
+ )
+}
+
+private fun WidgetSavedRequest.toSnapshot(): WidgetRequestSnapshot {
+ return WidgetRequestSnapshot(
+ method = method,
+ url = url,
+ params = params,
+ headers = headers,
+ auth = auth,
+ body = body
+ )
+}
+
+private fun WidgetRequestSnapshot.resolveVariables(variables: Map): WidgetRequestSnapshot {
+ if (variables.isEmpty()) return this
+
+ return copy(
+ url = url.applyVariables(variables),
+ params = params.map { it.resolveVariables(variables) },
+ headers = headers.map { it.resolveVariables(variables) },
+ auth = auth.resolveVariables(variables),
+ body = body.applyVariables(variables)
+ )
+}
+
+private fun WidgetRequestField.resolveVariables(variables: Map): WidgetRequestField {
+ return copy(
+ key = key.applyVariables(variables),
+ value = value.applyVariables(variables)
+ )
+}
+
+private fun WidgetAuthState.resolveVariables(variables: Map): WidgetAuthState {
+ return copy(
+ bearerToken = bearerToken.applyVariables(variables),
+ username = username.applyVariables(variables),
+ password = password.applyVariables(variables)
+ )
+}
+
+private fun String.applyVariables(variables: Map): String {
+ var resolved = this
+ repeat(3) {
+ val next = Regex("""\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}""").replace(resolved) { match ->
+ variables[match.groupValues[1]] ?: match.value
+ }
+ if (next == resolved) return resolved
+ resolved = next
+ }
+ return resolved
+}
+
+private fun List.toEnvironmentMap(): Map {
+ return filter { it.enabled && it.key.isNotBlank() }
+ .associate { it.key.trim() to it.value }
+}
+
+private fun captureWidgetValues(
+ result: WidgetRequestResult,
+ captures: List
+): Map {
+ if (result.error != null) return emptyMap()
+
+ return captures
+ .filter { it.enabled && it.variableName.isNotBlank() }
+ .mapNotNull { capture ->
+ val source = capture.source.ifBlank { capture.variableName }.trim()
+ extractCaptureValue(result, source)?.let { value ->
+ capture.variableName.trim() to value
+ }
+ }
+ .toMap()
+}
+
+private fun extractCaptureValue(
+ result: WidgetRequestResult,
+ source: String
+): String? {
+ val expression = source.trim()
+ if (expression.isBlank()) return null
+
+ if (expression.equals("cookie", ignoreCase = true) || expression.equals("cookies", ignoreCase = true)) {
+ return responseCookies(result.headers)
+ }
+
+ if (expression.startsWith("cookie:", ignoreCase = true)) {
+ return responseCookies(
+ headers = result.headers,
+ cookieName = expression.substringAfter(":").trim().takeIf { it.isNotBlank() }
+ )
+ }
+
+ if (expression.startsWith("regex:", ignoreCase = true)) {
+ return extractRegexValue(result.rawBody, expression.substringAfter(":"))
+ }
+
+ val headerName = when {
+ expression.startsWith("header:", ignoreCase = true) -> expression.substringAfter(":")
+ expression.startsWith("headers.", ignoreCase = true) -> expression.substringAfter(".")
+ else -> null
+ }?.trim()
+
+ if (!headerName.isNullOrBlank()) {
+ return result.headers.firstOrNull { it.name.equals(headerName, ignoreCase = true) }?.value
+ }
+
+ return extractJsonValue(result.rawBody, expression)
+}
+
+private fun responseCookies(
+ headers: List,
+ cookieName: String? = null
+): String? {
+ val cookies = headers
+ .filter { it.name.equals("Set-Cookie", ignoreCase = true) }
+ .flatMap { it.value.split(Regex("""(?:\n|,\s*(?=[A-Za-z0-9_.-]+=))""")) }
+ .mapNotNull { setCookie ->
+ val cookiePair = setCookie.substringBefore(";").trim()
+ if (!cookiePair.contains("=")) return@mapNotNull null
+ if (cookieName == null || cookiePair.substringBefore("=").equals(cookieName, ignoreCase = true)) {
+ cookiePair
+ } else {
+ null
+ }
+ }
+
+ return cookies.joinToString("; ").takeIf { it.isNotBlank() }
+}
+
+private fun extractRegexValue(
+ body: String,
+ pattern: String
+): String? {
+ if (pattern.isBlank()) return null
+
+ return runCatching {
+ val match = Regex(pattern, setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)).find(body)
+ match?.groups?.get(1)?.value ?: match?.value
+ }.getOrNull()?.trim()?.takeIf { it.isNotBlank() }
+}
+
+private fun extractJsonValue(
+ body: String,
+ path: String
+): String? {
+ val trimmedBody = body.trim()
+ if (trimmedBody.isBlank()) return null
+
+ val root: Any = try {
+ when {
+ trimmedBody.startsWith("{") -> JSONObject(trimmedBody)
+ trimmedBody.startsWith("[") -> JSONArray(trimmedBody)
+ else -> return null
+ }
+ } catch (_: JSONException) {
+ return null
+ }
+
+ val tokens = jsonPathTokens(path)
+ var current: Any? = root
+
+ if (tokens.isEmpty()) return jsonValueToString(current)
+
+ tokens.forEach { token ->
+ current = walkJsonToken(current, token)
+ if (current == null || current == JSONObject.NULL) return null
+ }
+
+ return jsonValueToString(current)
+}
+
+private fun jsonPathTokens(path: String): List {
+ val clean = path
+ .trim()
+ .removePrefix("$")
+ .trimStart('.')
+ .replace("[", ".[")
+ .trim('.')
+
+ if (clean.isBlank()) return emptyList()
+
+ return clean.split(".")
+ .map { it.trim() }
+ .filter { it.isNotBlank() }
+}
+
+private fun walkJsonToken(
+ current: Any?,
+ token: String
+): Any? {
+ var value = current ?: return null
+ val fieldName = token.substringBefore("[")
+
+ if (fieldName.isNotBlank()) {
+ value = when (value) {
+ is JSONObject -> value.opt(fieldName)
+ else -> return null
+ }
+ }
+
+ Regex("""\[(\d+)]""").findAll(token).forEach { match ->
+ val index = match.groupValues[1].toInt()
+ value = when (value) {
+ is JSONArray -> value.opt(index)
+ else -> return null
+ }
+ }
+
+ return value
+}
+
+private fun jsonValueToString(value: Any?): String? {
+ return when (value) {
+ null, JSONObject.NULL -> null
+ is JSONObject -> value.toString()
+ is JSONArray -> value.toString()
+ else -> value.toString()
+ }
+}
+
+private fun mergeCapturedVariables(
+ variables: List,
+ captured: Map
+): List {
+ var next = variables
+
+ captured.forEach { (name, value) ->
+ val existingIndex = next.indexOfFirst { it.key == name }
+ next = if (existingIndex >= 0) {
+ next.mapIndexed { index, field ->
+ if (index == existingIndex) field.copy(value = value, enabled = true) else field
+ }
+ } else {
+ next + WidgetRequestField(nextRowId(next), name, value, enabled = true)
+ }
+ }
+
+ return next.ifEmpty { defaultEnvironmentVariables() }
+}
+
+private fun mergeDefaultEnvironmentVariables(variables: List): List {
+ var next = variables
+ defaultEnvironmentVariables().forEach { defaultRow ->
+ val exists = next.any { it.key == defaultRow.key }
+ if (!exists) {
+ next = next + defaultRow.copy(id = nextRowId(next))
+ }
+ }
+ return next
+}
+
+private fun buildWidgetUrl(rawUrl: String, params: List): String {
+ val trimmed = rawUrl.trim()
+ require(trimmed.isNotBlank()) { "URL is required" }
+
+ val normalized = if (
+ trimmed.startsWith("http://", ignoreCase = true) ||
+ trimmed.startsWith("https://", ignoreCase = true)
+ ) {
+ trimmed
+ } else {
+ "https://$trimmed"
+ }
+
+ val builder = Uri.parse(normalized).buildUpon()
+ params
+ .filter { it.enabled && it.key.isNotBlank() }
+ .forEach { param ->
+ builder.appendQueryParameter(param.key.trim(), param.value)
+ }
+
+ return builder.build().toString()
+}
+
+private fun readResponseBytes(connection: HttpURLConnection, statusCode: Int): ByteArray {
+ val stream = try {
+ if (statusCode >= HttpURLConnection.HTTP_BAD_REQUEST) {
+ connection.errorStream ?: connection.inputStream
+ } else {
+ connection.inputStream
+ }
+ } catch (_: IOException) {
+ connection.errorStream
+ }
+
+ return stream?.use { it.readBytes() } ?: ByteArray(0)
+}
+
+private fun HttpURLConnection.setRequestMethodCompat(method: String) {
+ try {
+ requestMethod = method
+ return
+ } catch (error: ProtocolException) {
+ if (method != "PATCH") throw error
+
+ var targetClass: Class<*>? = this.javaClass
+ while (targetClass != null) {
+ try {
+ val field = targetClass.getDeclaredField("method")
+ field.isAccessible = true
+ field.set(this, method)
+ return
+ } catch (_: NoSuchFieldException) {
+ targetClass = targetClass.superclass
+ } catch (_: Exception) {
+ throw ProtocolException("PATCH is not supported on this device")
+ }
+ }
+
+ throw ProtocolException("PATCH is not supported on this device")
+ }
+}
+
+private fun authHeader(auth: WidgetAuthState): String? {
+ return when (auth.type) {
+ "Bearer" -> auth.bearerToken
+ .takeIf { it.isNotBlank() }
+ ?.let { "Bearer ${it.trim()}" }
+
+ "Basic" -> {
+ if (auth.username.isBlank() && auth.password.isBlank()) return null
+ val credentials = "${auth.username}:${auth.password}"
+ android.util.Base64.encodeToString(credentials.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
+ .let { "Basic $it" }
+ }
+
+ else -> null
+ }
+}
+
+private fun String.allowsBody(): Boolean {
+ return this in listOf("POST", "PUT", "PATCH", "DELETE")
+}
+
+private fun prettyPrintBody(body: String): String {
+ val trimmed = body.trim()
+ if (trimmed.isBlank()) return body
+
+ return try {
+ when {
+ trimmed.startsWith("{") -> JSONObject(trimmed).toString(2)
+ trimmed.startsWith("[") -> JSONArray(trimmed).toString(2)
+ else -> body
+ }
+ } catch (_: JSONException) {
+ body
+ }
+}
+
+private fun summarizeWidgetResponse(result: WidgetRequestResult): String {
+ val json = runCatching { JSONObject(result.rawBody) }.getOrNull()
+ if (json != null) {
+ val parts = listOfNotNull(
+ json.optString("ip").takeIf { it.isNotBlank() }?.let { "IP $it" },
+ json.optString("city").takeIf { it.isNotBlank() },
+ json.optString("country_name").takeIf { it.isNotBlank() },
+ json.optString("org").takeIf { it.isNotBlank() },
+ json.optString("totalUnusedBytes").takeIf { it.isNotBlank() }?.let {
+ "Unused $it ${json.optString("bytesUnusedUnit")}"
+ }
+ )
+ if (parts.isNotEmpty()) return parts.joinToString("\n")
+ }
+
+ return result.body
+ .ifBlank { result.statusText }
+ .replace(Regex("""\s+"""), " ")
+ .take(260)
+}
+
+private fun defaultWidgetFlow(): List {
+ val findIp = WidgetSavedRequest(
+ name = "Find public IP",
+ method = "GET",
+ url = "https://api.ipify.org?format=json",
+ params = defaultParamRows(),
+ body = "",
+ headers = listOf(
+ WidgetRequestField(1, "Accept", "application/json", true),
+ WidgetRequestField(2, "User-Agent", "PostHUB Android/1.0", true)
+ ),
+ auth = WidgetAuthState(),
+ captures = listOf(WidgetCaptureField(1, "public_ip", "\$.ip", true))
+ )
+ val ipInfo = WidgetSavedRequest(
+ name = "IP information",
+ method = "GET",
+ url = "https://ipapi.co/{{public_ip}}/json/",
+ params = defaultParamRows(),
+ body = "",
+ headers = listOf(
+ WidgetRequestField(1, "Accept", "application/json", true),
+ WidgetRequestField(2, "User-Agent", "PostHUB Android/1.0", true)
+ ),
+ auth = WidgetAuthState(),
+ captures = listOf(
+ WidgetCaptureField(1, "ip_city", "\$.city", true),
+ WidgetCaptureField(2, "ip_region", "\$.region", true),
+ WidgetCaptureField(3, "ip_country", "\$.country_name", true),
+ WidgetCaptureField(4, "ip_timezone", "\$.timezone", true),
+ WidgetCaptureField(5, "ip_org", "\$.org", true),
+ WidgetCaptureField(6, "ip_asn", "\$.asn", true)
+ )
+ )
+ return listOf(
+ WidgetFlowStep(1L, findIp),
+ WidgetFlowStep(2L, ipInfo)
+ )
+}
+
+private fun defaultEnvironmentVariables(): List {
+ return listOf(
+ WidgetRequestField(1, "base_url", "https://jsonplaceholder.typicode.com", true),
+ WidgetRequestField(2, "token", "", true),
+ WidgetRequestField(3, "mci_username", "", true),
+ WidgetRequestField(4, "mci_password", "", true),
+ WidgetRequestField(5, "mci_access_token", "", true),
+ WidgetRequestField(6, "mci_refresh_token", "", true),
+ WidgetRequestField(7, "mci_session_state", "", true),
+ WidgetRequestField(8, "mci_total_unused", "", true),
+ WidgetRequestField(9, "mci_unused_unit", "", true),
+ WidgetRequestField(10, "tci_username", "", true),
+ WidgetRequestField(11, "tci_password", "", true),
+ WidgetRequestField(12, "tci_captcha", "", true),
+ WidgetRequestField(13, "tci_cookie", "", true),
+ WidgetRequestField(14, "tci_login_action", "/panel", true),
+ WidgetRequestField(15, "tci_captcha_path", "", true),
+ WidgetRequestField(16, "public_ip", "", true),
+ WidgetRequestField(17, "ip_city", "", true),
+ WidgetRequestField(18, "ip_region", "", true),
+ WidgetRequestField(19, "ip_country", "", true),
+ WidgetRequestField(20, "ip_timezone", "", true),
+ WidgetRequestField(21, "ip_org", "", true),
+ WidgetRequestField(22, "ip_asn", "", true)
+ )
+}
+
+private fun defaultParamRows(): List {
+ return listOf(WidgetRequestField(1, "", "", true))
+}
+
+private fun defaultHeaderRows(): List {
+ return listOf(
+ WidgetRequestField(1, "Accept", "application/json", true),
+ WidgetRequestField(2, "Content-Type", "application/json", false)
+ )
+}
+
+private fun nextRowId(rows: List): Int {
+ return (rows.maxOfOrNull { it.id } ?: 0) + 1
+}
+
+private fun elapsedMs(started: Long): Long {
+ return (System.nanoTime() - started) / 1_000_000
+}
+
+private fun formatBytes(bytes: Int): String {
+ return when {
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> String.format(Locale.US, "%.1f KB", bytes / 1024f)
+ else -> String.format(Locale.US, "%.1f MB", bytes / (1024f * 1024f))
+ }
+}
+
+private fun statusColor(statusCode: Int?): Int {
+ return when (statusCode) {
+ in 200..299 -> 0xFF147A56.toInt()
+ in 300..399 -> 0xFFC47A12.toInt()
+ in 400..499 -> 0xFFD84B35.toInt()
+ else -> 0xFFC7383D.toInt()
+ }
+}
+
+private fun Throwable.cleanMessage(): String {
+ return message?.takeIf { it.isNotBlank() } ?: javaClass.simpleName
+}
diff --git a/app/src/main/res/drawable/ic_widget_refresh.xml b/app/src/main/res/drawable/ic_widget_refresh.xml
new file mode 100644
index 0000000..8303a42
--- /dev/null
+++ b/app/src/main/res/drawable/ic_widget_refresh.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_bg.xml b/app/src/main/res/drawable/widget_bg.xml
new file mode 100644
index 0000000..b64e473
--- /dev/null
+++ b/app/src/main/res/drawable/widget_bg.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_pill_bg.xml b/app/src/main/res/drawable/widget_pill_bg.xml
new file mode 100644
index 0000000..056f0e6
--- /dev/null
+++ b/app/src/main/res/drawable/widget_pill_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_refresh_bg.xml b/app/src/main/res/drawable/widget_refresh_bg.xml
new file mode 100644
index 0000000..ed333d4
--- /dev/null
+++ b/app/src/main/res/drawable/widget_refresh_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_flow.xml b/app/src/main/res/layout/widget_flow.xml
new file mode 100644
index 0000000..5c40a6f
--- /dev/null
+++ b/app/src/main/res/layout/widget_flow.xml
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a7e42cb..92b854b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,5 @@
Post HUB
-
\ No newline at end of file
+ Run your saved PostHUB flow from the home screen.
+ Refresh flow
+
diff --git a/app/src/main/res/xml/flow_widget_info.xml b/app/src/main/res/xml/flow_widget_info.xml
new file mode 100644
index 0000000..5398c3e
--- /dev/null
+++ b/app/src/main/res/xml/flow_widget_info.xml
@@ -0,0 +1,12 @@
+
+