implement FlowWidget
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
994
app/src/main/java/net/rodakot/posthub/FlowWidgetProvider.kt
Normal file
994
app/src/main/java/net/rodakot/posthub/FlowWidgetProvider.kt
Normal 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
|
||||||
|
}
|
||||||
35
app/src/main/res/drawable/ic_widget_refresh.xml
Normal file
35
app/src/main/res/drawable/ic_widget_refresh.xml
Normal 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>
|
||||||
8
app/src/main/res/drawable/widget_bg.xml
Normal file
8
app/src/main/res/drawable/widget_bg.xml
Normal 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>
|
||||||
5
app/src/main/res/drawable/widget_pill_bg.xml
Normal file
5
app/src/main/res/drawable/widget_pill_bg.xml
Normal 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>
|
||||||
5
app/src/main/res/drawable/widget_refresh_bg.xml
Normal file
5
app/src/main/res/drawable/widget_refresh_bg.xml
Normal 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>
|
||||||
110
app/src/main/res/layout/widget_flow.xml
Normal file
110
app/src/main/res/layout/widget_flow.xml
Normal 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>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Post HUB</string>
|
<string name="app_name">Post HUB</string>
|
||||||
|
<string name="widget_description">Run your saved PostHUB flow from the home screen.</string>
|
||||||
|
<string name="widget_refresh">Refresh flow</string>
|
||||||
</resources>
|
</resources>
|
||||||
12
app/src/main/res/xml/flow_widget_info.xml
Normal file
12
app/src/main/res/xml/flow_widget_info.xml
Normal 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" />
|
||||||
Reference in New Issue
Block a user