diff --git a/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/ConnectionService.kt b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/ConnectionService.kt index 78fd083..9701776 100644 --- a/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/ConnectionService.kt +++ b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/ConnectionService.kt @@ -40,7 +40,8 @@ class ConnectionService : Service() { ACTION_CONNECT -> { val host = intent.getStringExtra(EXTRA_HOST).orEmpty() val port = intent.getIntExtra(EXTRA_PORT, 8181) - connectWebSocket(host, port) + val name = intent.getStringExtra(EXTRA_NAME).orEmpty() + connectWebSocket(host, port, name) } ACTION_DISCONNECT -> disconnectWebSocket("已断开连接") } @@ -54,8 +55,8 @@ class ConnectionService : Service() { super.onDestroy() } - private fun connectWebSocket(host: String, port: Int) { - Log.d(tag, "connectWebSocket host=$host port=$port") + private fun connectWebSocket(host: String, port: Int, name: String) { + Log.d(tag, "connectWebSocket host=$host port=$port name=$name") if (host.isBlank()) { updateState(connected = false, connecting = false, host = host, port = port, lastError = "请先填写 Windows IP") return @@ -65,6 +66,7 @@ class ConnectionService : Service() { .edit() .putString(KEY_HOST, host) .putInt(KEY_PORT, port) + .putString(KEY_NAME, name) .apply() try { @@ -77,13 +79,14 @@ class ConnectionService : Service() { connecting = false, host = host, port = port, + name = name, lastError = exception.message ?: "前台服务启动失败" ) stopSelf() return } currentSocket?.close(1000, "reconnect") - updateState(connected = false, connecting = true, host = host, port = port, lastError = null) + updateState(connected = false, connecting = true, host = host, port = port, name = name, lastError = null) val request = Request.Builder().url("ws://$host:$port").build() Log.d(tag, "Creating WebSocket ws://$host:$port") @@ -94,12 +97,22 @@ class ConnectionService : Service() { Log.d(tag, "Ignoring stale WebSocket onOpen") return } - updateState(connected = true, connecting = false, host = host, port = port, lastError = null) + updateState(connected = true, connecting = false, host = host, port = port, name = name, lastError = null) Handler(Looper.getMainLooper()).post { startForeground(NOTIFICATION_ID, buildNotification(host, port, "已连接")) } } + override fun onMessage(webSocket: WebSocket, text: String) { + Log.d(tag, "WebSocket onMessage $text") + if (currentSocket != webSocket) { + Log.d(tag, "Ignoring stale WebSocket onMessage") + return + } + + updateServiceInfo(text, host, port) + } + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { Log.d(tag, "WebSocket onClosed code=$code reason=$reason") if (currentSocket != webSocket) { @@ -107,7 +120,7 @@ class ConnectionService : Service() { return } currentSocket = null - updateState(connected = false, connecting = false, host = host, port = port, lastError = null) + updateState(connected = false, connecting = false, host = host, port = port, name = state.name, lastError = null) Handler(Looper.getMainLooper()).post { stopForeground(STOP_FOREGROUND_REMOVE) } @@ -130,6 +143,7 @@ class ConnectionService : Service() { connecting = false, host = host, port = port, + name = state.name.ifBlank { name }, lastError = errorMessage ) Handler(Looper.getMainLooper()).post { @@ -148,6 +162,35 @@ class ConnectionService : Service() { stopSelf() } + private fun updateServiceInfo(text: String, fallbackHost: String, fallbackPort: Int) { + try { + val json = JSONObject(text) + if (json.optString("type") != "wirelessTextSyncer.service") { + return + } + + val serviceHost = json.optString("host", fallbackHost).ifBlank { fallbackHost } + val servicePort = json.optInt("port", fallbackPort) + val serviceName = json.optString("name").ifBlank { state.name } + getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putString(KEY_HOST, serviceHost) + .putInt(KEY_PORT, servicePort) + .putString(KEY_NAME, serviceName) + .apply() + updateState( + connected = true, + connecting = false, + host = serviceHost, + port = servicePort, + name = serviceName, + lastError = null + ) + } catch (exception: Exception) { + Log.e(tag, "Failed to parse service info message", exception) + } + } + private fun buildNotification(host: String, port: Int, status: String): Notification { val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { Notification.Builder(this, CHANNEL_ID) @@ -179,23 +222,26 @@ class ConnectionService : Service() { private const val ACTION_DISCONNECT = "wireless_text_syncer.DISCONNECT" private const val EXTRA_HOST = "host" private const val EXTRA_PORT = "port" + private const val EXTRA_NAME = "name" private const val CHANNEL_ID = "wireless_text_syncer_connection" private const val NOTIFICATION_ID = 1001 private const val PREFS = "wireless_text_syncer_connection" private const val KEY_HOST = "server_host" private const val KEY_PORT = "server_port" + private const val KEY_NAME = "server_name" private val mainHandler = Handler(Looper.getMainLooper()) @Volatile private var currentSocket: WebSocket? = null @Volatile private var state = ConnectionStateSnapshot() @Volatile var eventSink: EventChannel.EventSink? = null - fun connect(context: Context, host: String, port: Int) { - Log.d("WTS", "ConnectionService.connect requested host=$host port=$port sdk=${Build.VERSION.SDK_INT}") + fun connect(context: Context, host: String, port: Int, name: String = "") { + Log.d("WTS", "ConnectionService.connect requested host=$host port=$port name=$name sdk=${Build.VERSION.SDK_INT}") val intent = Intent(context, ConnectionService::class.java) .setAction(ACTION_CONNECT) .putExtra(EXTRA_HOST, host) .putExtra(EXTRA_PORT, port) + .putExtra(EXTRA_NAME, name) try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(intent) @@ -209,6 +255,7 @@ class ConnectionService : Service() { connecting = false, host = host, port = port, + name = name, lastError = exception.message ?: "连接服务启动失败" ) throw exception @@ -273,6 +320,7 @@ class ConnectionService : Service() { "connecting" to snapshot.connecting, "host" to snapshot.host, "port" to snapshot.port, + "name" to snapshot.name, "lastError" to snapshot.lastError ) } @@ -282,13 +330,14 @@ class ConnectionService : Service() { connecting: Boolean, host: String = state.host, port: Int = state.port, + name: String = state.name, lastError: String? = null ) { Log.d( "WTS", - "updateState connected=$connected connecting=$connecting host=$host port=$port error=$lastError" + "updateState connected=$connected connecting=$connecting host=$host port=$port name=$name error=$lastError" ) - state = ConnectionStateSnapshot(connected, connecting, host, port, lastError) + state = ConnectionStateSnapshot(connected, connecting, host, port, name, lastError) mainHandler.post { eventSink?.success(stateMap()) QuickSendTileService.requestTileRefresh() @@ -301,6 +350,7 @@ class ConnectionService : Service() { val connecting: Boolean = false, val host: String = "", val port: Int = 8181, + val name: String = "", val lastError: String? = null ) } diff --git a/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/DiscoveryClient.kt b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/DiscoveryClient.kt new file mode 100644 index 0000000..95f518c --- /dev/null +++ b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/DiscoveryClient.kt @@ -0,0 +1,96 @@ +package com.wirelesstextsyncer.wireless_text_syncer_android + +import android.content.Context +import android.net.wifi.WifiManager +import android.util.Log +import org.json.JSONObject +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress + +object DiscoveryClient { + private const val tag = "WTS" + private const val discoveryPort = 8182 + private const val defaultServicePort = 8181 + + fun discover(context: Context): List { + Log.d(tag, "Discovery started") + val devices = mutableListOf() + val seen = mutableSetOf() + var multicastLock: WifiManager.MulticastLock? = null + try { + val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager + multicastLock = wifi?.createMulticastLock("wireless-text-syncer-discovery") + multicastLock?.setReferenceCounted(false) + multicastLock?.acquire() + + DatagramSocket().use { socket -> + socket.broadcast = true + socket.soTimeout = 450 + val request = JSONObject() + .put("type", "wirelessTextSyncer.discovery") + .put("version", 1) + .toString() + .toByteArray(Charsets.UTF_8) + val packet = DatagramPacket( + request, + request.size, + InetAddress.getByName("255.255.255.255"), + discoveryPort + ) + socket.send(packet) + + val deadline = System.currentTimeMillis() + 1600 + while (System.currentTimeMillis() < deadline) { + try { + val buffer = ByteArray(2048) + val response = DatagramPacket(buffer, buffer.size) + socket.receive(response) + val json = JSONObject(String(response.data, 0, response.length, Charsets.UTF_8)) + if (json.optString("type") != "wirelessTextSyncer.service") { + continue + } + + val host = json.optString("host", response.address.hostAddress.orEmpty()) + val port = json.optInt("port", defaultServicePort) + val key = "$host:$port" + if (seen.add(key)) { + Log.d(tag, "Discovery found ${json.optString("name", host)} $key") + devices += DiscoveredService( + name = json.optString("name", host), + host = host, + port = port + ) + } + } catch (_: Exception) { + } + } + } + } catch (exception: Exception) { + Log.e(tag, "Discovery failed", exception) + } finally { + multicastLock?.let { + if (it.isHeld) { + it.release() + } + } + } + + Log.d(tag, "Discovery finished count=${devices.size}") + return devices + } +} + +data class DiscoveredService( + val name: String, + val host: String, + val port: Int +) { + fun toMap(): Map { + return mapOf( + "name" to name, + "host" to host, + "port" to port + ) + } +} diff --git a/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/MainActivity.kt b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/MainActivity.kt index 0f3e180..bb5362f 100644 --- a/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/MainActivity.kt +++ b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/MainActivity.kt @@ -1,16 +1,10 @@ package com.wirelesstextsyncer.wireless_text_syncer_android -import android.content.Context -import android.net.wifi.WifiManager import android.util.Log import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.android.FlutterActivity import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodChannel -import org.json.JSONObject -import java.net.DatagramPacket -import java.net.DatagramSocket -import java.net.InetAddress class MainActivity : FlutterActivity() { private val tag = "WTS" @@ -26,9 +20,10 @@ class MainActivity : FlutterActivity() { "connect" -> { val host = call.argument("host").orEmpty() val port = call.argument("port") ?: 8181 - Log.d(tag, "MethodChannel connect host=$host port=$port") + val name = call.argument("name").orEmpty() + Log.d(tag, "MethodChannel connect host=$host port=$port name=$name") try { - ConnectionService.connect(this, host, port) + ConnectionService.connect(this, host, port, name) result.success(null) } catch (exception: Exception) { Log.e(tag, "MethodChannel connect failed", exception) @@ -73,67 +68,8 @@ class MainActivity : FlutterActivity() { } private fun startDiscovery(result: MethodChannel.Result) { - Log.d(tag, "Discovery started") Thread { - val devices = mutableListOf>() - val seen = mutableSetOf() - var multicastLock: WifiManager.MulticastLock? = null - try { - val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager - multicastLock = wifi?.createMulticastLock("wireless-text-syncer-discovery") - multicastLock?.setReferenceCounted(false) - multicastLock?.acquire() - - DatagramSocket().use { socket -> - socket.broadcast = true - socket.soTimeout = 450 - val request = JSONObject() - .put("type", "wirelessTextSyncer.discovery") - .put("version", 1) - .toString() - .toByteArray(Charsets.UTF_8) - val packet = DatagramPacket( - request, - request.size, - InetAddress.getByName("255.255.255.255"), - 8182 - ) - socket.send(packet) - - val deadline = System.currentTimeMillis() + 1600 - while (System.currentTimeMillis() < deadline) { - try { - val buffer = ByteArray(2048) - val response = DatagramPacket(buffer, buffer.size) - socket.receive(response) - val json = JSONObject(String(response.data, 0, response.length, Charsets.UTF_8)) - if (json.optString("type") != "wirelessTextSyncer.service") { - continue - } - val host = json.optString("host", response.address.hostAddress.orEmpty()) - val port = json.optInt("port", 8181) - val key = "$host:$port" - if (seen.add(key)) { - Log.d(tag, "Discovery found ${json.optString("name", host)} $key") - devices += mapOf( - "name" to json.optString("name", host), - "host" to host, - "port" to port - ) - } - } catch (_: Exception) { - } - } - } - } catch (exception: Exception) { - Log.e(tag, "Discovery failed", exception) - } finally { - multicastLock?.let { - if (it.isHeld) { - it.release() - } - } - } + val devices = DiscoveryClient.discover(applicationContext).map { it.toMap() } runOnUiThread { Log.d(tag, "Discovery finished count=${devices.size}") diff --git a/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/QuickSendActivity.kt b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/QuickSendActivity.kt index 09e1cdc..f9ad120 100644 --- a/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/QuickSendActivity.kt +++ b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/QuickSendActivity.kt @@ -10,6 +10,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import android.view.Gravity +import android.view.View import android.view.WindowInsets import android.view.WindowManager import android.view.inputmethod.InputMethodManager @@ -18,6 +19,7 @@ import android.widget.EditText import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout +import android.widget.Switch import android.widget.TextView class QuickSendActivity : Activity() { @@ -25,11 +27,31 @@ class QuickSendActivity : Activity() { private lateinit var sendButton: LinearLayout private lateinit var sendIcon: ImageView private lateinit var sendLabel: TextView - private lateinit var statusText: TextView + private lateinit var connectionIcon: ImageView + private lateinit var endpointText: TextView + private lateinit var deviceNameText: TextView + private lateinit var connectionPanel: LinearLayout + private lateinit var connectionDot: View + private lateinit var connectionTitle: TextView + private lateinit var connectionSubtitle: TextView + private lateinit var retryText: TextView + private lateinit var scanProgress: View + private lateinit var devicesContainer: LinearLayout + private lateinit var appendEnterToggle: LinearLayout + private lateinit var appendEnterSwitch: Switch + private val connectionInfoTypeface by lazy { + runCatching { + Typeface.createFromAsset(assets, "flutter_assets/assets/fonts/WDXLLubrifontSC-Regular.ttf") + }.getOrDefault(Typeface.DEFAULT) + } + private var scanning = false + private var appendEnter = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Log.d("WTS", "QuickSendActivity onCreate") + appendEnter = getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_APPEND_ENTER, false) buildUi() input.requestFocus() input.post { @@ -60,10 +82,164 @@ class QuickSendActivity : Activity() { sheet.setBackgroundColor(Color.WHITE) sheet.setOnClickListener { } - statusText = TextView(this) - statusText.textSize = 14f - statusText.setTextColor(Color.rgb(71, 85, 105)) - sheet.addView(statusText) + val topRow = LinearLayout(this) + topRow.gravity = Gravity.CENTER_VERTICAL + topRow.orientation = LinearLayout.HORIZONTAL + sheet.addView(topRow, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )) + + val connectionSummary = LinearLayout(this) + connectionSummary.gravity = Gravity.CENTER_VERTICAL + connectionSummary.orientation = LinearLayout.HORIZONTAL + topRow.addView(connectionSummary, LinearLayout.LayoutParams( + 0, + dp(64), + 1f + )) + + connectionIcon = ImageView(this) + connectionSummary.addView(connectionIcon, LinearLayout.LayoutParams(dp(40), dp(40))) + + val summaryCopy = LinearLayout(this) + summaryCopy.orientation = LinearLayout.VERTICAL + summaryCopy.gravity = Gravity.CENTER_VERTICAL + connectionSummary.addView(summaryCopy, LinearLayout.LayoutParams( + 0, + dp(64), + 1f + ).apply { + leftMargin = dp(10) + }) + + endpointText = TextView(this) + endpointText.textSize = 21f + endpointText.typeface = Typeface.create(connectionInfoTypeface, Typeface.BOLD) + endpointText.setTextColor(Color.rgb(15, 23, 42)) + endpointText.includeFontPadding = false + endpointText.maxLines = 1 + summaryCopy.addView(endpointText) + + deviceNameText = TextView(this) + deviceNameText.textSize = 18f + deviceNameText.typeface = connectionInfoTypeface + deviceNameText.setTextColor(Color.rgb(100, 116, 139)) + deviceNameText.includeFontPadding = false + deviceNameText.maxLines = 1 + summaryCopy.addView(deviceNameText, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = dp(2) + }) + + appendEnterToggle = LinearLayout(this) + appendEnterToggle.gravity = Gravity.CENTER_VERTICAL + appendEnterToggle.orientation = LinearLayout.HORIZONTAL + appendEnterToggle.setPadding(dp(10), 0, dp(4), 0) + appendEnterToggle.background = strokeDrawable(Color.WHITE, Color.rgb(226, 232, 240), dp(8).toFloat()) + appendEnterToggle.isClickable = true + appendEnterToggle.isFocusable = true + appendEnterToggle.setOnClickListener { toggleAppendEnter() } + + val appendEnterLabel = TextView(this) + appendEnterLabel.text = "追加回车" + appendEnterLabel.textSize = 13f + appendEnterLabel.typeface = Typeface.DEFAULT_BOLD + appendEnterLabel.setTextColor(Color.rgb(15, 23, 42)) + appendEnterToggle.addView(appendEnterLabel, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )) + + appendEnterSwitch = Switch(this) + appendEnterSwitch.isChecked = appendEnter + appendEnterSwitch.setOnCheckedChangeListener { _, isChecked -> setAppendEnter(isChecked) } + appendEnterToggle.addView(appendEnterSwitch) + topRow.addView(appendEnterToggle, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + dp(44) + ).apply { + leftMargin = dp(10) + }) + + connectionPanel = LinearLayout(this) + connectionPanel.orientation = LinearLayout.VERTICAL + connectionPanel.setPadding(dp(14), dp(12), dp(14), dp(12)) + connectionPanel.background = strokeDrawable(Color.rgb(248, 250, 252), Color.rgb(226, 232, 240), dp(12).toFloat()) + sheet.addView(connectionPanel, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = dp(10) + }) + + val connectionHeader = LinearLayout(this) + connectionHeader.gravity = Gravity.CENTER_VERTICAL + connectionHeader.orientation = LinearLayout.HORIZONTAL + connectionPanel.addView(connectionHeader, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )) + + connectionDot = View(this) + connectionHeader.addView(connectionDot, LinearLayout.LayoutParams(dp(10), dp(10))) + + val connectionCopy = LinearLayout(this) + connectionCopy.orientation = LinearLayout.VERTICAL + connectionHeader.addView(connectionCopy, LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + ).apply { + leftMargin = dp(10) + }) + + connectionTitle = TextView(this) + connectionTitle.textSize = 14f + connectionTitle.typeface = Typeface.DEFAULT_BOLD + connectionTitle.setTextColor(Color.rgb(15, 23, 42)) + connectionCopy.addView(connectionTitle) + + connectionSubtitle = TextView(this) + connectionSubtitle.textSize = 12f + connectionSubtitle.setTextColor(Color.rgb(100, 116, 139)) + connectionCopy.addView(connectionSubtitle, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = dp(2) + }) + + retryText = TextView(this) + retryText.text = "重试" + retryText.textSize = 13f + retryText.typeface = Typeface.DEFAULT_BOLD + retryText.setTextColor(Color.rgb(37, 99, 235)) + retryText.setPadding(dp(10), dp(6), dp(10), dp(6)) + retryText.isClickable = true + retryText.isFocusable = true + retryText.setOnClickListener { scanAndConnect() } + connectionHeader.addView(retryText) + + scanProgress = View(this) + scanProgress.background = roundedDrawable(Color.rgb(37, 99, 235), dp(2).toFloat()) + connectionPanel.addView(scanProgress, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + dp(3) + ).apply { + topMargin = dp(10) + }) + + devicesContainer = LinearLayout(this) + devicesContainer.orientation = LinearLayout.VERTICAL + connectionPanel.addView(devicesContainer, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = dp(10) + }) input = EditText(this) input.minLines = 3 @@ -117,19 +293,164 @@ class QuickSendActivity : Activity() { root.addView(sheet, params) setContentView(root) refreshState() + autoScanIfDisconnected() } private fun refreshState() { val state = ConnectionService.stateMap() val connected = state["connected"] as? Boolean ?: false + val connecting = state["connecting"] as? Boolean ?: false val host = state["host"] as? String ?: "" val port = state["port"] as? Int ?: 8181 - statusText.text = if (connected) "● 已连接 $host:$port" else "● 未连接,请先打开主应用连接电脑" - statusText.setTextColor(if (connected) Color.rgb(22, 101, 52) else Color.rgb(185, 28, 28)) + val name = state["name"] as? String ?: "" + val lastError = state["lastError"] as? String + updateConnectionSummary(connected, connecting, host, port, name) + connectionPanel.visibility = if (connected) View.GONE else View.VISIBLE + connectionDot.background = roundedDrawable(when { + connecting || scanning -> Color.rgb(37, 99, 235) + else -> Color.rgb(185, 28, 28) + }, dp(5).toFloat()) + scanProgress.visibility = if (scanning || connecting) View.VISIBLE else View.GONE + retryText.visibility = if (!connecting && !scanning) View.VISIBLE else View.GONE + when { + connecting -> { + connectionTitle.text = "正在连接电脑" + connectionSubtitle.text = "$host:$port" + } + scanning -> { + connectionTitle.text = "正在寻找附近的电脑" + connectionSubtitle.text = "发现一个会自动连接,多个会显示选择" + } + else -> { + connectionTitle.text = lastError ?: "暂未连接电脑" + connectionSubtitle.text = "将自动扫描局域网内的桌面端服务" + } + } + devicesContainer.visibility = if (connected) View.GONE else devicesContainer.visibility sendButton.isEnabled = connected sendButton.alpha = if (connected) 1f else 0.48f } + private fun updateConnectionSummary( + connected: Boolean, + connecting: Boolean, + host: String, + port: Int, + name: String + ) { + val iconColor = when { + connected -> Color.rgb(22, 163, 74) + connecting -> Color.rgb(37, 99, 235) + else -> Color.rgb(185, 28, 28) + } + connectionIcon.setImageResource(when { + connected || connecting -> R.drawable.ic_sync_24 + else -> R.drawable.ic_sync_disabled_24 + }) + connectionIcon.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN) + endpointText.setTextColor(if (connected) Color.rgb(22, 101, 52) else Color.rgb(15, 23, 42)) + deviceNameText.setTextColor(if (connected) Color.rgb(21, 128, 61) else Color.rgb(100, 116, 139)) + endpointText.text = if (host.isBlank()) "未设置地址" else "$host:$port" + deviceNameText.text = name.ifBlank { "Windows 桌面端" } + } + + private fun scanAndConnect() { + if (scanning) { + return + } + + scanning = true + updateConnectionSummary(connected = false, connecting = true, host = "", port = 8181, name = "") + connectionTitle.text = "正在寻找附近的电脑" + connectionSubtitle.text = "发现一个会自动连接,多个会显示选择" + connectionDot.background = roundedDrawable(Color.rgb(37, 99, 235), dp(5).toFloat()) + retryText.visibility = View.GONE + scanProgress.visibility = View.VISIBLE + devicesContainer.removeAllViews() + devicesContainer.visibility = View.GONE + + Thread { + val devices = DiscoveryClient.discover(applicationContext) + runOnUiThread { + scanning = false + when (devices.size) { + 0 -> { + updateConnectionSummary(connected = false, connecting = false, host = "", port = 8181, name = "") + connectionTitle.text = "没有发现桌面端" + connectionSubtitle.text = "确认电脑和手机在同一局域网后重试" + connectionDot.background = roundedDrawable(Color.rgb(185, 28, 28), dp(5).toFloat()) + retryText.visibility = View.VISIBLE + scanProgress.visibility = View.GONE + } + 1 -> connectTo(devices.single()) + else -> showDeviceChoices(devices) + } + } + }.start() + } + + private fun autoScanIfDisconnected() { + val state = ConnectionService.stateMap() + val connected = state["connected"] as? Boolean ?: false + val connecting = state["connecting"] as? Boolean ?: false + if (!connected && !connecting) { + scanAndConnect() + } + } + + private fun showDeviceChoices(devices: List) { + updateConnectionSummary(connected = false, connecting = true, host = "", port = 8181, name = "") + connectionTitle.text = "选择要连接的电脑" + connectionSubtitle.text = "发现 ${devices.size} 个桌面端服务" + connectionDot.background = roundedDrawable(Color.rgb(37, 99, 235), dp(5).toFloat()) + retryText.visibility = View.VISIBLE + scanProgress.visibility = View.GONE + devicesContainer.removeAllViews() + devicesContainer.visibility = View.VISIBLE + + devices.forEach { device -> + devicesContainer.addView(createDeviceChoice(device), LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + bottomMargin = dp(8) + }) + } + } + + private fun createDeviceChoice(device: DiscoveredService): TextView { + return TextView(this).apply { + text = "${device.name}\n${device.host}:${device.port}" + textSize = 14f + setTextColor(Color.rgb(15, 23, 42)) + setPadding(dp(12), dp(10), dp(12), dp(10)) + background = strokeDrawable(Color.WHITE, Color.rgb(203, 213, 225), dp(10).toFloat()) + isClickable = true + isFocusable = true + setOnClickListener { connectTo(device) } + } + } + + private fun connectTo(device: DiscoveredService) { + updateConnectionSummary( + connected = false, + connecting = true, + host = device.host, + port = device.port, + name = device.name + ) + connectionTitle.text = "正在连接电脑" + connectionSubtitle.text = "${device.host}:${device.port}" + connectionDot.background = roundedDrawable(Color.rgb(37, 99, 235), dp(5).toFloat()) + retryText.visibility = View.GONE + scanProgress.visibility = View.VISIBLE + devicesContainer.removeAllViews() + devicesContainer.visibility = View.GONE + ConnectionService.connect(this, device.host, device.port, device.name) + Handler(Looper.getMainLooper()).postDelayed({ refreshState() }, 700) + Handler(Looper.getMainLooper()).postDelayed({ refreshState() }, 1800) + } + private fun sendAndClose() { val text = input.text.toString() Log.d("WTS", "QuickSendActivity send length=${text.length}") @@ -137,7 +458,7 @@ class QuickSendActivity : Activity() { return } - if (ConnectionService.sendText(text)) { + if (ConnectionService.sendText(text) && (!appendEnter || ConnectionService.sendEnter())) { sendButton.background = roundedDrawable(Color.rgb(22, 163, 74), dp(26).toFloat()) sendIcon.setImageResource(R.drawable.ic_check_24) sendIcon.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN) @@ -151,6 +472,34 @@ class QuickSendActivity : Activity() { } } + private fun toggleAppendEnter() { + setAppendEnter(!appendEnter) + } + + private fun setAppendEnter(value: Boolean) { + if (appendEnter == value && appendEnterSwitch.isChecked == value) { + return + } + + appendEnter = value + getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_APPEND_ENTER, appendEnter) + .apply() + updateAppendEnterToggle() + } + + private fun updateAppendEnterToggle() { + if (appendEnterSwitch.isChecked != appendEnter) { + appendEnterSwitch.isChecked = appendEnter + } + appendEnterToggle.background = strokeDrawable( + Color.WHITE, + if (appendEnter) Color.rgb(147, 197, 253) else Color.rgb(226, 232, 240), + dp(16).toFloat() + ) + } + private fun hideKeyboard() { val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(input.windowToken, 0) @@ -176,4 +525,9 @@ class QuickSendActivity : Activity() { private fun dp(value: Int): Int { return (value * resources.displayMetrics.density).toInt() } + + companion object { + private const val PREFS = "FlutterSharedPreferences" + private const val KEY_APPEND_ENTER = "flutter.append_enter" + } } diff --git a/android/app/src/main/res/drawable/ic_sync_24.xml b/android/app/src/main/res/drawable/ic_sync_24.xml new file mode 100644 index 0000000..a5ebb10 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_sync_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_sync_disabled_24.xml b/android/app/src/main/res/drawable/ic_sync_disabled_24.xml new file mode 100644 index 0000000..832795c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_sync_disabled_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/assets/fonts/WDXLLubrifontSC-OFL.txt b/assets/fonts/WDXLLubrifontSC-OFL.txt new file mode 100644 index 0000000..4c6ce88 --- /dev/null +++ b/assets/fonts/WDXLLubrifontSC-OFL.txt @@ -0,0 +1,94 @@ +Copyright 2025 The WDXL Lubrifont Project Authors (https://github.com/NightFurySL2001/WD-XL-font) +Copyright 2018-2020 The ZCOOL QingKe HuangYou Project Authors (https://www.github.com/googlefonts/zcool-qingke-huangyou) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/assets/fonts/WDXLLubrifontSC-Regular.ttf b/assets/fonts/WDXLLubrifontSC-Regular.ttf new file mode 100644 index 0000000..13d4b26 Binary files /dev/null and b/assets/fonts/WDXLLubrifontSC-Regular.ttf differ diff --git a/lib/main.dart b/lib/main.dart index 5150cf7..7fb50b5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,12 +28,15 @@ class WirelessTextSyncerApp extends StatelessWidget { enum SendButtonState { idle, sending, success } +const connectionInfoFontFamily = 'WDXL Lubrifont SC'; + class ConnectionSnapshot { const ConnectionSnapshot({ this.connected = false, this.connecting = false, this.host = '', this.port = 8181, + this.name = '', this.lastError, }); @@ -47,6 +50,7 @@ class ConnectionSnapshot { connecting: map['connecting'] == true, host: (map['host'] as String?) ?? '', port: (map['port'] as num?)?.toInt() ?? 8181, + name: (map['name'] as String?) ?? '', lastError: map['lastError'] as String?, ); } @@ -55,6 +59,7 @@ class ConnectionSnapshot { final bool connecting; final String host; final int port; + final String name; final String? lastError; } @@ -105,10 +110,11 @@ class NativeConnectionApi { } } - Future connect(String host, int port) async { + Future connect(String host, int port, {String? name}) async { await _methodChannel.invokeMethod('connect', { 'host': host, 'port': port, + if (name != null) 'name': name, }); } @@ -288,9 +294,15 @@ class _InputSyncPageState extends State scanning = false; discoveredDevices = devices; }); + + if (devices.length == 1 && + !connection.connected && + !connection.connecting) { + await _connectDevice(devices.single); + } } - Future _connect() async { + Future _connect({String? name}) async { final host = hostController.text.trim(); final portText = portController.text.trim(); final port = int.tryParse(portText); @@ -307,15 +319,24 @@ class _InputSyncPageState extends State await prefs.setString(_portKey, port.toString()); setState(() { - connection = ConnectionSnapshot(connecting: true, host: host, port: port); + connection = ConnectionSnapshot( + connecting: true, + host: host, + port: port, + name: name ?? '', + ); }); try { - await api.connect(host, port); + await api.connect(host, port, name: name); } on PlatformException catch (exception) { _showToast(exception.message ?? '连接失败,请检查地址'); setState(() { - connection = ConnectionSnapshot(host: host, port: port); + connection = ConnectionSnapshot( + host: host, + port: port, + name: name ?? '', + ); headerExpanded = true; }); } @@ -324,7 +345,7 @@ class _InputSyncPageState extends State Future _connectDevice(DiscoveredDevice device) async { hostController.text = device.host; portController.text = device.port.toString(); - await _connect(); + await _connect(name: device.name); } Future _disconnect() async { @@ -333,6 +354,7 @@ class _InputSyncPageState extends State connection = ConnectionSnapshot( host: hostController.text.trim(), port: int.tryParse(portController.text.trim()) ?? 8181, + name: connection.name, ); headerExpanded = true; sendButtonState = SendButtonState.idle; @@ -542,11 +564,6 @@ class _InputSyncPageState extends State Widget _buildHeader(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final statusText = connection.connected - ? '已连接: ${connection.host}:${connection.port}' - : connection.connecting - ? '正在连接: ${connection.host}:${connection.port}' - : '未连接 (点击配置)'; return Material( color: colorScheme.surface, @@ -566,29 +583,23 @@ class _InputSyncPageState extends State }); }, child: SizedBox( - height: 44, + height: 52, child: Row( children: [ - _ConnectionIndicator( - connected: connection.connected, - connecting: connection.connecting, - animation: statusPulseController, - ), - const SizedBox(width: 10), Expanded( - child: Text( - statusText, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: connection.connected - ? const Color(0xFF166534) - : connection.connecting - ? colorScheme.primary - : colorScheme.error, - fontWeight: FontWeight.w600, - ), + child: _ConnectionSummary( + connected: connection.connected, + connecting: connection.connecting, + endpoint: connection.host.isEmpty + ? '未设置地址' + : '${connection.host}:${connection.port}', + deviceName: connection.name.isEmpty + ? 'Windows 桌面端' + : connection.name, + animation: statusPulseController, ), ), + const SizedBox(width: 8), Icon( headerExpanded ? Icons.keyboard_arrow_up @@ -979,6 +990,105 @@ class _InputSyncPageState extends State } } +class _ConnectionSummary extends StatelessWidget { + const _ConnectionSummary({ + required this.connected, + required this.connecting, + required this.endpoint, + required this.deviceName, + required this.animation, + }); + + final bool connected; + final bool connecting; + final String endpoint; + final String deviceName; + final Animation animation; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final iconColor = connected + ? const Color(0xFF16A34A) + : connecting + ? colorScheme.primary + : colorScheme.error; + final primaryTextColor = connected + ? const Color(0xFF166534) + : const Color(0xFF0F172A); + final secondaryTextColor = connected + ? const Color(0xFF15803D) + : colorScheme.onSurfaceVariant; + + return Row( + children: [ + AnimatedBuilder( + animation: animation, + builder: (context, child) { + return Transform.scale( + scale: connecting ? animation.value : 1, + child: Container( + width: 42, + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + connected + ? Icons.sync + : connecting + ? Icons.sync + : Icons.sync_disabled, + color: iconColor, + size: 24, + ), + ), + ); + }, + ), + const SizedBox(width: 10), + Expanded( + child: SizedBox( + height: 64, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + endpoint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: primaryTextColor, + fontFamily: connectionInfoFontFamily, + fontSize: 21, + fontWeight: FontWeight.w700, + height: 1.15, + ), + ), + const SizedBox(height: 3), + Text( + deviceName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: secondaryTextColor, + fontFamily: connectionInfoFontFamily, + fontSize: 18, + height: 1.15, + ), + ), + ], + ), + ), + ), + ], + ); + } +} + class _ConnectionIndicator extends StatelessWidget { const _ConnectionIndicator({ required this.connected, diff --git a/pubspec.yaml b/pubspec.yaml index 3dd1a59..4ef6e92 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,6 +58,11 @@ flutter: # the material Icons class. uses-material-design: true + fonts: + - family: WDXL Lubrifont SC + fonts: + - asset: assets/fonts/WDXLLubrifontSC-Regular.ttf + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg