From 60671fe461b08463b57fb6ef8482428db6d63e89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sat, 16 Aug 2025 23:07:19 +0800 Subject: [PATCH] feat: native widget url settings dialog (#856) --- android/app/src/main/AndroidManifest.xml | 9 + .../tech/lolli/toolbox/widget/HomeWidget.kt | 268 ++++++++++++------ .../toolbox/widget/WidgetConfigureActivity.kt | 82 ++++++ .../app/src/main/res/layout/home_widget.xml | 150 ++++++---- .../src/main/res/layout/widget_configure.xml | 38 +++ android/app/src/main/res/xml/home_widget.xml | 1 + 6 files changed, 402 insertions(+), 146 deletions(-) create mode 100644 android/app/src/main/kotlin/tech/lolli/toolbox/widget/WidgetConfigureActivity.kt create mode 100644 android/app/src/main/res/layout/widget_configure.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c1d36c21..ae394adf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -46,6 +46,15 @@ android:name="flutterEmbedding" android:value="2" /> + + + + + + () + } override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { for (appWidgetId in appWidgetIds) { updateAppWidget(context, appWidgetManager, appWidgetId) @@ -27,105 +38,184 @@ class HomeWidget : AppWidgetProvider() { } private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { - val views = RemoteViews(context.packageName, R.layout.home_widget) - val sp = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) - var url = sp.getString("widget_$appWidgetId", null) - if (url.isNullOrEmpty()) { - url = sp.getString("$appWidgetId", null) - } - if (url.isNullOrEmpty()) { - val gUrl = sp.getString("widget_*", null) - url = gUrl - } - - if (url.isNullOrEmpty()) { - Log.e("HomeWidget", "URL not found") - } - - val intentUpdate = Intent(context, HomeWidget::class.java) - intentUpdate.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE - val ids = intArrayOf(appWidgetId) - intentUpdate.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) - - var flag = PendingIntent.FLAG_UPDATE_CURRENT - if (Build.VERSION_CODES.O <= Build.VERSION.SDK_INT) { - flag = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - } - - val pendingUpdate: PendingIntent = PendingIntent.getBroadcast( - context, - appWidgetId, - intentUpdate, - flag) - views.setOnClickPendingIntent(R.id.widget_container, pendingUpdate) - - if (url.isNullOrEmpty()) { - views.setTextViewText(R.id.widget_name, "No URL") - // Update the widget to display a message for missing URL - views.setViewVisibility(R.id.error_message, View.VISIBLE) - views.setTextViewText(R.id.error_message, "Please configure the widget URL.") - views.setViewVisibility(R.id.widget_content, View.GONE) - views.setFloat(R.id.widget_name, "setAlpha", 1f) - views.setFloat(R.id.error_message, "setAlpha", 1f) - appWidgetManager.updateAppWidget(appWidgetId, views) + // Prevent concurrent updates for the same widget + if (activeUpdates.putIfAbsent(appWidgetId, true) == true) { + Log.d(TAG, "Widget $appWidgetId is already updating, skipping") return - } else { - views.setViewVisibility(R.id.widget_cpu_label, View.VISIBLE) - views.setViewVisibility(R.id.widget_mem_label, View.VISIBLE) - views.setViewVisibility(R.id.widget_disk_label, View.VISIBLE) - views.setViewVisibility(R.id.widget_net_label, View.VISIBLE) } + val views = RemoteViews(context.packageName, R.layout.home_widget) + val url = getWidgetUrl(context, appWidgetId) + + if (url.isNullOrEmpty()) { + Log.w(TAG, "URL not found for widget $appWidgetId") + showErrorState(views, appWidgetManager, appWidgetId, "Please configure the widget URL.") + activeUpdates.remove(appWidgetId) + return + } + + setupClickIntent(context, views, appWidgetId) + + showLoadingState(views, appWidgetManager, appWidgetId) + CoroutineScope(Dispatchers.IO).launch { - try { - val connection = URL(url).openConnection() as HttpURLConnection - connection.requestMethod = "GET" - val responseCode = connection.responseCode - if (responseCode == HttpURLConnection.HTTP_OK) { - val jsonStr = connection.inputStream.bufferedReader().use { it.readText() } - val jsonObject = JSONObject(jsonStr) - val data = jsonObject.getJSONObject("data") - val server = data.getString("name") - val cpu = data.getString("cpu") - val mem = data.getString("mem") - val disk = data.getString("disk") - val net = data.getString("net") - withContext(Dispatchers.Main) { - if (mem.isEmpty() || disk.isEmpty()) { - Log.e("HomeWidget", "Failed to retrieve status: Memory or disk information is empty") - return@withContext + withTimeoutOrNull(COROUTINE_TIMEOUT) { + try { + val serverData = fetchServerData(url) + if (serverData != null) { + withContext(Dispatchers.Main) { + showSuccessState(views, appWidgetManager, appWidgetId, serverData) + } + } else { + withContext(Dispatchers.Main) { + showErrorState(views, appWidgetManager, appWidgetId, "Invalid server data received.") } - views.setTextViewText(R.id.widget_name, server) - views.setTextViewText(R.id.widget_cpu, cpu) - views.setTextViewText(R.id.widget_mem, mem) - views.setTextViewText(R.id.widget_disk, disk) - views.setTextViewText(R.id.widget_net, net) - val timeStr = android.text.format.DateFormat.format("HH:mm", java.util.Date()).toString() - views.setTextViewText(R.id.widget_time, timeStr) - views.setFloat(R.id.widget_name, "setAlpha", 1f) - views.setFloat(R.id.widget_cpu_label, "setAlpha", 1f) - views.setFloat(R.id.widget_mem_label, "setAlpha", 1f) - views.setFloat(R.id.widget_disk_label, "setAlpha", 1f) - views.setFloat(R.id.widget_net_label, "setAlpha", 1f) - views.setFloat(R.id.widget_time, "setAlpha", 1f) - appWidgetManager.updateAppWidget(appWidgetId, views) } - } else { - throw FileNotFoundException("HTTP response code: $responseCode") + } catch (e: Exception) { + Log.e(TAG, "Error updating widget $appWidgetId: ${e.message}", e) + withContext(Dispatchers.Main) { + val errorMessage = when (e) { + is SocketTimeoutException -> "Connection timeout. Please check your network." + is IOException -> "Network error. Please check your connection." + is JSONException -> "Invalid data format received from server." + else -> "Failed to retrieve data: ${e.message}" + } + showErrorState(views, appWidgetManager, appWidgetId, errorMessage) + } } - } catch (e: Exception) { - Log.e("HomeWidget", "Error updating widget: ${e.localizedMessage}", e) + } ?: run { + Log.w(TAG, "Widget update timed out for widget $appWidgetId") withContext(Dispatchers.Main) { - views.setTextViewText(R.id.widget_name, "Error") - // Update the widget to display a message for data retrieval failure - views.setViewVisibility(R.id.error_message, View.VISIBLE) - views.setTextViewText(R.id.error_message, "Failed to retrieve data.") - views.setViewVisibility(R.id.widget_content, View.GONE) - views.setFloat(R.id.widget_name, "setAlpha", 1f) - views.setFloat(R.id.error_message, "setAlpha", 1f) - appWidgetManager.updateAppWidget(appWidgetId, views) + showErrorState(views, appWidgetManager, appWidgetId, "Update timed out. Please try again.") } } + activeUpdates.remove(appWidgetId) } } + + private fun getWidgetUrl(context: Context, appWidgetId: Int): String? { + val sp = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) + return sp.getString("widget_$appWidgetId", null) + ?: sp.getString("$appWidgetId", null) + ?: sp.getString("widget_*", null) + } + + private fun setupClickIntent(context: Context, views: RemoteViews, appWidgetId: Int) { + val intentConfigure = Intent(context, WidgetConfigureActivity::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + } + + val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + + val pendingConfigure = PendingIntent.getActivity(context, appWidgetId, intentConfigure, flag) + views.setOnClickPendingIntent(R.id.widget_container, pendingConfigure) + } + + private suspend fun fetchServerData(url: String): ServerData? = withContext(Dispatchers.IO) { + var connection: HttpURLConnection? = null + try { + connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = NETWORK_TIMEOUT.toInt() + readTimeout = NETWORK_TIMEOUT.toInt() + setRequestProperty("User-Agent", "ServerBox-Widget/1.0") + setRequestProperty("Accept", "application/json") + } + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + throw IOException("HTTP ${connection.responseCode}: ${connection.responseMessage}") + } + + val jsonStr = connection.inputStream.bufferedReader().use { it.readText() } + parseServerData(jsonStr) + } finally { + connection?.disconnect() + } + } + + private fun parseServerData(jsonStr: String): ServerData? { + return try { + val jsonObject = JSONObject(jsonStr) + val data = jsonObject.getJSONObject("data") + + val server = data.optString("name", "Unknown Server") + val cpu = data.optString("cpu", "").takeIf { it.isNotBlank() } ?: "N/A" + val mem = data.optString("mem", "").takeIf { it.isNotBlank() } ?: "N/A" + val disk = data.optString("disk", "").takeIf { it.isNotBlank() } ?: "N/A" + val net = data.optString("net", "").takeIf { it.isNotBlank() } ?: "N/A" + + // Return data even if some fields are missing, providing defaults + // Only reject if we can't parse the JSON structure properly + ServerData(server, cpu, mem, disk, net) + } catch (e: JSONException) { + Log.e(TAG, "JSON parsing error: ${e.message}", e) + null + } + } + + private fun showLoadingState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int) { + views.apply { + setTextViewText(R.id.widget_name, "Loading...") + setViewVisibility(R.id.error_message, View.GONE) + setViewVisibility(R.id.widget_content, View.VISIBLE) + setViewVisibility(R.id.widget_cpu_label, View.VISIBLE) + setViewVisibility(R.id.widget_mem_label, View.VISIBLE) + setViewVisibility(R.id.widget_disk_label, View.VISIBLE) + setViewVisibility(R.id.widget_net_label, View.VISIBLE) + setViewVisibility(R.id.widget_progress, View.VISIBLE) + setFloat(R.id.widget_name, "setAlpha", 0.7f) + } + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + private fun showSuccessState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int, data: ServerData) { + views.apply { + setTextViewText(R.id.widget_name, data.name) + setTextViewText(R.id.widget_cpu, data.cpu) + setTextViewText(R.id.widget_mem, data.mem) + setTextViewText(R.id.widget_disk, data.disk) + setTextViewText(R.id.widget_net, data.net) + + val timeStr = android.text.format.DateFormat.format("HH:mm", java.util.Date()).toString() + setTextViewText(R.id.widget_time, timeStr) + + setViewVisibility(R.id.error_message, View.GONE) + setViewVisibility(R.id.widget_content, View.VISIBLE) + setViewVisibility(R.id.widget_progress, View.GONE) + + // Smooth fade-in animation + setFloat(R.id.widget_name, "setAlpha", 1f) + setFloat(R.id.widget_cpu_label, "setAlpha", 1f) + setFloat(R.id.widget_mem_label, "setAlpha", 1f) + setFloat(R.id.widget_disk_label, "setAlpha", 1f) + setFloat(R.id.widget_net_label, "setAlpha", 1f) + setFloat(R.id.widget_time, "setAlpha", 1f) + } + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + private fun showErrorState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int, errorMessage: String) { + views.apply { + setTextViewText(R.id.widget_name, "Error") + setViewVisibility(R.id.error_message, View.VISIBLE) + setTextViewText(R.id.error_message, errorMessage) + setViewVisibility(R.id.widget_content, View.GONE) + setViewVisibility(R.id.widget_progress, View.GONE) + setFloat(R.id.widget_name, "setAlpha", 1f) + setFloat(R.id.error_message, "setAlpha", 1f) + } + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + data class ServerData( + val name: String, + val cpu: String, + val mem: String, + val disk: String, + val net: String + ) } \ No newline at end of file diff --git a/android/app/src/main/kotlin/tech/lolli/toolbox/widget/WidgetConfigureActivity.kt b/android/app/src/main/kotlin/tech/lolli/toolbox/widget/WidgetConfigureActivity.kt new file mode 100644 index 00000000..f87e0431 --- /dev/null +++ b/android/app/src/main/kotlin/tech/lolli/toolbox/widget/WidgetConfigureActivity.kt @@ -0,0 +1,82 @@ +package tech.lolli.toolbox.widget + +import android.app.Activity +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import android.util.Patterns +import android.widget.Button +import android.widget.EditText +import tech.lolli.toolbox.R + +class WidgetConfigureActivity : Activity() { + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + private lateinit var urlEditText: EditText + private lateinit var saveButton: Button + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.widget_configure) + + // 设置结果为取消,以防用户在完成配置前退出 + setResult(RESULT_CANCELED) + + // 获取 widget ID + val extras = intent.extras + if (extras != null) { + appWidgetId = extras.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + } + + // 如果没有有效的 widget ID,完成 activity + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + // 初始化 UI 元素 + urlEditText = findViewById(R.id.url_edit_text) + saveButton = findViewById(R.id.save_button) + + // 从 SharedPreferences 加载现有配置 + val sp = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE) + val existingUrl = sp.getString("widget_$appWidgetId", "") + urlEditText.setText(existingUrl) + + // 设置保存按钮点击事件 + saveButton.setOnClickListener { + val url = urlEditText.text.toString().trim() + if (url.isEmpty()) { + urlEditText.error = "Please enter a URL" + return@setOnClickListener + } + + // 验证 URL 格式 + if (!Patterns.WEB_URL.matcher(url).matches()) { + urlEditText.error = "Please enter a valid URL" + return@setOnClickListener + } + + // 保存 URL 到 SharedPreferences + val editor = sp.edit() + editor.putString("widget_$appWidgetId", url) + editor.apply() + + // 更新 widget 使用 AppWidgetManager + val appWidgetManager = AppWidgetManager.getInstance(this) + val updateIntent = Intent(this, HomeWidget::class.java).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId)) + } + sendBroadcast(updateIntent) + + // 设置结果并结束 activity + val resultValue = Intent() + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + setResult(RESULT_OK, resultValue) + finish() + } + } +} \ No newline at end of file diff --git a/android/app/src/main/res/layout/home_widget.xml b/android/app/src/main/res/layout/home_widget.xml index af31dbde..89f45b85 100644 --- a/android/app/src/main/res/layout/home_widget.xml +++ b/android/app/src/main/res/layout/home_widget.xml @@ -10,14 +10,17 @@ @@ -27,121 +30,138 @@ android:layout_height="wrap_content" android:orientation="vertical" android:layout_below="@id/widget_name" - android:paddingTop="13dp"> + android:layout_marginTop="8dp"> + android:orientation="horizontal" + android:alpha="0" + android:animateLayoutChanges="true"> - + android:layout_width="16dp" + android:layout_height="16dp" + android:src="@drawable/speed_24" + android:layout_gravity="center_vertical" + android:contentDescription="CPU usage" /> + android:textSize="12sp" + tools:text="CPU: 25.6%" /> + android:orientation="horizontal" + android:alpha="0" + android:animateLayoutChanges="true"> - + android:layout_width="16dp" + android:layout_height="16dp" + android:src="@drawable/memory_24" + android:layout_gravity="center_vertical" + android:contentDescription="Memory usage" /> + android:textSize="12sp" + tools:text="Memory: 4.2GB / 8GB" /> + android:orientation="horizontal" + android:alpha="0" + android:animateLayoutChanges="true"> - + android:layout_width="16dp" + android:layout_height="16dp" + android:src="@drawable/storage_24" + android:layout_gravity="center_vertical" + android:contentDescription="Disk usage" /> + android:textSize="12sp" + tools:text="Disk: 125GB / 250GB" /> + android:orientation="horizontal" + android:alpha="0" + android:animateLayoutChanges="true"> - + android:layout_width="16dp" + android:layout_height="16dp" + android:src="@drawable/net_24" + android:layout_gravity="center_vertical" + android:contentDescription="Network usage" /> + android:textSize="12sp" + tools:text="Network: 15MB/s ↓ 8MB/s ↑" /> @@ -149,29 +169,45 @@ - + + android:lineSpacingMultiplier="1.2" + android:maxLines="3" + android:ellipsize="end" + tools:text="Error message text that might be longer than usual" /> + android:fontFamily="monospace" + tools:text="12:34" /> + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/widget_configure.xml b/android/app/src/main/res/layout/widget_configure.xml new file mode 100644 index 00000000..879e462b --- /dev/null +++ b/android/app/src/main/res/layout/widget_configure.xml @@ -0,0 +1,38 @@ + + + + + + + +