implement FlowWidget

This commit is contained in:
2026-05-06 00:24:56 +03:30
parent 367875f567
commit 805957119f
9 changed files with 1185 additions and 1 deletions

View File

@@ -24,6 +24,19 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<receiver
android:name=".FlowWidgetProvider"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="net.rodakot.posthub.action.RUN_WIDGET_FLOW" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/flow_widget_info" />
</receiver>
</application> </application>
</manifest> </manifest>

View File

@@ -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<WidgetRequestField>,
val body: String,
val headers: List<WidgetRequestField>,
val auth: WidgetAuthState,
val captures: List<WidgetCaptureField>
)
private data class WidgetFlowStep(
val id: Long,
val request: WidgetSavedRequest
)
private data class WidgetRequestSnapshot(
val method: String,
val url: String,
val params: List<WidgetRequestField>,
val headers: List<WidgetRequestField>,
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<WidgetResponseHeader>,
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<WidgetFlowStep> {
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<WidgetRequestField> {
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<WidgetRequestField>
) {
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<WidgetRequestField>): 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<WidgetRequestField> {
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<WidgetCaptureField> {
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<String, String>): 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<String, String>): WidgetRequestField {
return copy(
key = key.applyVariables(variables),
value = value.applyVariables(variables)
)
}
private fun WidgetAuthState.resolveVariables(variables: Map<String, String>): WidgetAuthState {
return copy(
bearerToken = bearerToken.applyVariables(variables),
username = username.applyVariables(variables),
password = password.applyVariables(variables)
)
}
private fun String.applyVariables(variables: Map<String, String>): 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<WidgetRequestField>.toEnvironmentMap(): Map<String, String> {
return filter { it.enabled && it.key.isNotBlank() }
.associate { it.key.trim() to it.value }
}
private fun captureWidgetValues(
result: WidgetRequestResult,
captures: List<WidgetCaptureField>
): Map<String, String> {
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<WidgetResponseHeader>,
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<String> {
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<WidgetRequestField>,
captured: Map<String, String>
): List<WidgetRequestField> {
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<WidgetRequestField>): List<WidgetRequestField> {
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<WidgetRequestField>): 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<WidgetFlowStep> {
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<WidgetRequestField> {
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<WidgetRequestField> {
return listOf(WidgetRequestField(1, "", "", true))
}
private fun defaultHeaderRows(): List<WidgetRequestField> {
return listOf(
WidgetRequestField(1, "Accept", "application/json", true),
WidgetRequestField(2, "Content-Type", "application/json", false)
)
}
private fun nextRowId(rows: List<WidgetRequestField>): 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
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2.2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M21,12a9,9 0,0 1,-15.1 6.6" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2.2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M3,12a9,9 0,0 1,15.1 -6.6" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2.2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M18,3v5h-5" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2.2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M6,21v-5h5" />
</vector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#F7FAF7" />
<corners android:radius="24dp" />
<stroke
android:width="1dp"
android:color="#DDE4DF" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E3F4EF" />
<corners android:radius="14dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#006B5B" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_bg"
android:gravity="center_vertical"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="sans"
android:maxLines="1"
android:text="PostHUB Flow"
android:textColor="#17201C"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Ready"
android:textColor="#66736D"
android:textSize="12sp" />
</LinearLayout>
<ImageButton
android:id="@+id/widget_refresh"
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_marginStart="12dp"
android:background="@drawable/widget_refresh_bg"
android:contentDescription="@string/widget_refresh"
android:padding="10dp"
android:scaleType="center"
android:src="@drawable/ic_widget_refresh" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/widget_pill_bg"
android:ellipsize="end"
android:maxLines="1"
android:minWidth="54dp"
android:paddingStart="10dp"
android:paddingTop="5dp"
android:paddingEnd="10dp"
android:paddingBottom="5dp"
android:text="Idle"
android:textAlignment="center"
android:textColor="#006B5B"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_meta"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="No run yet"
android:textColor="#66736D"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/widget_body"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="12dp"
android:layout_weight="1"
android:ellipsize="end"
android:fontFamily="sans"
android:gravity="top"
android:maxLines="4"
android:text="Tap refresh to run the saved flow."
android:textColor="#17201C"
android:textSize="13sp" />
</LinearLayout>

View File

@@ -1,3 +1,5 @@
<resources> <resources>
<string name="app_name">Post HUB</string> <string name="app_name">Post HUB</string>
</resources> <string name="widget_description">Run your saved PostHUB flow from the home screen.</string>
<string name="widget_refresh">Refresh flow</string>
</resources>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:initialLayout="@layout/widget_flow"
android:minWidth="260dp"
android:minHeight="120dp"
android:previewLayout="@layout/widget_flow"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />