implement FlowWidget
This commit is contained in:
@@ -24,6 +24,19 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</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>
|
||||
<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>
|
||||
|
||||
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