Improve Android connection controls and status display
This commit is contained in:
@@ -40,7 +40,8 @@ class ConnectionService : Service() {
|
|||||||
ACTION_CONNECT -> {
|
ACTION_CONNECT -> {
|
||||||
val host = intent.getStringExtra(EXTRA_HOST).orEmpty()
|
val host = intent.getStringExtra(EXTRA_HOST).orEmpty()
|
||||||
val port = intent.getIntExtra(EXTRA_PORT, 8181)
|
val port = intent.getIntExtra(EXTRA_PORT, 8181)
|
||||||
connectWebSocket(host, port)
|
val name = intent.getStringExtra(EXTRA_NAME).orEmpty()
|
||||||
|
connectWebSocket(host, port, name)
|
||||||
}
|
}
|
||||||
ACTION_DISCONNECT -> disconnectWebSocket("已断开连接")
|
ACTION_DISCONNECT -> disconnectWebSocket("已断开连接")
|
||||||
}
|
}
|
||||||
@@ -54,8 +55,8 @@ class ConnectionService : Service() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun connectWebSocket(host: String, port: Int) {
|
private fun connectWebSocket(host: String, port: Int, name: String) {
|
||||||
Log.d(tag, "connectWebSocket host=$host port=$port")
|
Log.d(tag, "connectWebSocket host=$host port=$port name=$name")
|
||||||
if (host.isBlank()) {
|
if (host.isBlank()) {
|
||||||
updateState(connected = false, connecting = false, host = host, port = port, lastError = "请先填写 Windows IP")
|
updateState(connected = false, connecting = false, host = host, port = port, lastError = "请先填写 Windows IP")
|
||||||
return
|
return
|
||||||
@@ -65,6 +66,7 @@ class ConnectionService : Service() {
|
|||||||
.edit()
|
.edit()
|
||||||
.putString(KEY_HOST, host)
|
.putString(KEY_HOST, host)
|
||||||
.putInt(KEY_PORT, port)
|
.putInt(KEY_PORT, port)
|
||||||
|
.putString(KEY_NAME, name)
|
||||||
.apply()
|
.apply()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -77,13 +79,14 @@ class ConnectionService : Service() {
|
|||||||
connecting = false,
|
connecting = false,
|
||||||
host = host,
|
host = host,
|
||||||
port = port,
|
port = port,
|
||||||
|
name = name,
|
||||||
lastError = exception.message ?: "前台服务启动失败"
|
lastError = exception.message ?: "前台服务启动失败"
|
||||||
)
|
)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentSocket?.close(1000, "reconnect")
|
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()
|
val request = Request.Builder().url("ws://$host:$port").build()
|
||||||
Log.d(tag, "Creating WebSocket ws://$host:$port")
|
Log.d(tag, "Creating WebSocket ws://$host:$port")
|
||||||
@@ -94,12 +97,22 @@ class ConnectionService : Service() {
|
|||||||
Log.d(tag, "Ignoring stale WebSocket onOpen")
|
Log.d(tag, "Ignoring stale WebSocket onOpen")
|
||||||
return
|
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 {
|
Handler(Looper.getMainLooper()).post {
|
||||||
startForeground(NOTIFICATION_ID, buildNotification(host, port, "已连接"))
|
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) {
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
Log.d(tag, "WebSocket onClosed code=$code reason=$reason")
|
Log.d(tag, "WebSocket onClosed code=$code reason=$reason")
|
||||||
if (currentSocket != webSocket) {
|
if (currentSocket != webSocket) {
|
||||||
@@ -107,7 +120,7 @@ class ConnectionService : Service() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentSocket = null
|
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 {
|
Handler(Looper.getMainLooper()).post {
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
}
|
}
|
||||||
@@ -130,6 +143,7 @@ class ConnectionService : Service() {
|
|||||||
connecting = false,
|
connecting = false,
|
||||||
host = host,
|
host = host,
|
||||||
port = port,
|
port = port,
|
||||||
|
name = state.name.ifBlank { name },
|
||||||
lastError = errorMessage
|
lastError = errorMessage
|
||||||
)
|
)
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
@@ -148,6 +162,35 @@ class ConnectionService : Service() {
|
|||||||
stopSelf()
|
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 {
|
private fun buildNotification(host: String, port: Int, status: String): Notification {
|
||||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
Notification.Builder(this, CHANNEL_ID)
|
Notification.Builder(this, CHANNEL_ID)
|
||||||
@@ -179,23 +222,26 @@ class ConnectionService : Service() {
|
|||||||
private const val ACTION_DISCONNECT = "wireless_text_syncer.DISCONNECT"
|
private const val ACTION_DISCONNECT = "wireless_text_syncer.DISCONNECT"
|
||||||
private const val EXTRA_HOST = "host"
|
private const val EXTRA_HOST = "host"
|
||||||
private const val EXTRA_PORT = "port"
|
private const val EXTRA_PORT = "port"
|
||||||
|
private const val EXTRA_NAME = "name"
|
||||||
private const val CHANNEL_ID = "wireless_text_syncer_connection"
|
private const val CHANNEL_ID = "wireless_text_syncer_connection"
|
||||||
private const val NOTIFICATION_ID = 1001
|
private const val NOTIFICATION_ID = 1001
|
||||||
private const val PREFS = "wireless_text_syncer_connection"
|
private const val PREFS = "wireless_text_syncer_connection"
|
||||||
private const val KEY_HOST = "server_host"
|
private const val KEY_HOST = "server_host"
|
||||||
private const val KEY_PORT = "server_port"
|
private const val KEY_PORT = "server_port"
|
||||||
|
private const val KEY_NAME = "server_name"
|
||||||
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
@Volatile private var currentSocket: WebSocket? = null
|
@Volatile private var currentSocket: WebSocket? = null
|
||||||
@Volatile private var state = ConnectionStateSnapshot()
|
@Volatile private var state = ConnectionStateSnapshot()
|
||||||
@Volatile var eventSink: EventChannel.EventSink? = null
|
@Volatile var eventSink: EventChannel.EventSink? = null
|
||||||
|
|
||||||
fun connect(context: Context, host: String, port: Int) {
|
fun connect(context: Context, host: String, port: Int, name: String = "") {
|
||||||
Log.d("WTS", "ConnectionService.connect requested host=$host port=$port sdk=${Build.VERSION.SDK_INT}")
|
Log.d("WTS", "ConnectionService.connect requested host=$host port=$port name=$name sdk=${Build.VERSION.SDK_INT}")
|
||||||
val intent = Intent(context, ConnectionService::class.java)
|
val intent = Intent(context, ConnectionService::class.java)
|
||||||
.setAction(ACTION_CONNECT)
|
.setAction(ACTION_CONNECT)
|
||||||
.putExtra(EXTRA_HOST, host)
|
.putExtra(EXTRA_HOST, host)
|
||||||
.putExtra(EXTRA_PORT, port)
|
.putExtra(EXTRA_PORT, port)
|
||||||
|
.putExtra(EXTRA_NAME, name)
|
||||||
try {
|
try {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
context.startForegroundService(intent)
|
context.startForegroundService(intent)
|
||||||
@@ -209,6 +255,7 @@ class ConnectionService : Service() {
|
|||||||
connecting = false,
|
connecting = false,
|
||||||
host = host,
|
host = host,
|
||||||
port = port,
|
port = port,
|
||||||
|
name = name,
|
||||||
lastError = exception.message ?: "连接服务启动失败"
|
lastError = exception.message ?: "连接服务启动失败"
|
||||||
)
|
)
|
||||||
throw exception
|
throw exception
|
||||||
@@ -273,6 +320,7 @@ class ConnectionService : Service() {
|
|||||||
"connecting" to snapshot.connecting,
|
"connecting" to snapshot.connecting,
|
||||||
"host" to snapshot.host,
|
"host" to snapshot.host,
|
||||||
"port" to snapshot.port,
|
"port" to snapshot.port,
|
||||||
|
"name" to snapshot.name,
|
||||||
"lastError" to snapshot.lastError
|
"lastError" to snapshot.lastError
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -282,13 +330,14 @@ class ConnectionService : Service() {
|
|||||||
connecting: Boolean,
|
connecting: Boolean,
|
||||||
host: String = state.host,
|
host: String = state.host,
|
||||||
port: Int = state.port,
|
port: Int = state.port,
|
||||||
|
name: String = state.name,
|
||||||
lastError: String? = null
|
lastError: String? = null
|
||||||
) {
|
) {
|
||||||
Log.d(
|
Log.d(
|
||||||
"WTS",
|
"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 {
|
mainHandler.post {
|
||||||
eventSink?.success(stateMap())
|
eventSink?.success(stateMap())
|
||||||
QuickSendTileService.requestTileRefresh()
|
QuickSendTileService.requestTileRefresh()
|
||||||
@@ -301,6 +350,7 @@ class ConnectionService : Service() {
|
|||||||
val connecting: Boolean = false,
|
val connecting: Boolean = false,
|
||||||
val host: String = "",
|
val host: String = "",
|
||||||
val port: Int = 8181,
|
val port: Int = 8181,
|
||||||
|
val name: String = "",
|
||||||
val lastError: String? = null
|
val lastError: String? = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<DiscoveredService> {
|
||||||
|
Log.d(tag, "Discovery started")
|
||||||
|
val devices = mutableListOf<DiscoveredService>()
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
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<String, Any> {
|
||||||
|
return mapOf(
|
||||||
|
"name" to name,
|
||||||
|
"host" to host,
|
||||||
|
"port" to port
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
package com.wirelesstextsyncer.wireless_text_syncer_android
|
package com.wirelesstextsyncer.wireless_text_syncer_android
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.wifi.WifiManager
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodChannel
|
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() {
|
class MainActivity : FlutterActivity() {
|
||||||
private val tag = "WTS"
|
private val tag = "WTS"
|
||||||
@@ -26,9 +20,10 @@ class MainActivity : FlutterActivity() {
|
|||||||
"connect" -> {
|
"connect" -> {
|
||||||
val host = call.argument<String>("host").orEmpty()
|
val host = call.argument<String>("host").orEmpty()
|
||||||
val port = call.argument<Int>("port") ?: 8181
|
val port = call.argument<Int>("port") ?: 8181
|
||||||
Log.d(tag, "MethodChannel connect host=$host port=$port")
|
val name = call.argument<String>("name").orEmpty()
|
||||||
|
Log.d(tag, "MethodChannel connect host=$host port=$port name=$name")
|
||||||
try {
|
try {
|
||||||
ConnectionService.connect(this, host, port)
|
ConnectionService.connect(this, host, port, name)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
Log.e(tag, "MethodChannel connect failed", exception)
|
Log.e(tag, "MethodChannel connect failed", exception)
|
||||||
@@ -73,67 +68,8 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun startDiscovery(result: MethodChannel.Result) {
|
private fun startDiscovery(result: MethodChannel.Result) {
|
||||||
Log.d(tag, "Discovery started")
|
|
||||||
Thread {
|
Thread {
|
||||||
val devices = mutableListOf<Map<String, Any>>()
|
val devices = DiscoveryClient.discover(applicationContext).map { it.toMap() }
|
||||||
val seen = mutableSetOf<String>()
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
Log.d(tag, "Discovery finished count=${devices.size}")
|
Log.d(tag, "Discovery finished count=${devices.size}")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
import android.view.WindowInsets
|
import android.view.WindowInsets
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
@@ -18,6 +19,7 @@ import android.widget.EditText
|
|||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.Switch
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
|
||||||
class QuickSendActivity : Activity() {
|
class QuickSendActivity : Activity() {
|
||||||
@@ -25,11 +27,31 @@ class QuickSendActivity : Activity() {
|
|||||||
private lateinit var sendButton: LinearLayout
|
private lateinit var sendButton: LinearLayout
|
||||||
private lateinit var sendIcon: ImageView
|
private lateinit var sendIcon: ImageView
|
||||||
private lateinit var sendLabel: TextView
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
Log.d("WTS", "QuickSendActivity onCreate")
|
Log.d("WTS", "QuickSendActivity onCreate")
|
||||||
|
appendEnter = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getBoolean(KEY_APPEND_ENTER, false)
|
||||||
buildUi()
|
buildUi()
|
||||||
input.requestFocus()
|
input.requestFocus()
|
||||||
input.post {
|
input.post {
|
||||||
@@ -60,10 +82,164 @@ class QuickSendActivity : Activity() {
|
|||||||
sheet.setBackgroundColor(Color.WHITE)
|
sheet.setBackgroundColor(Color.WHITE)
|
||||||
sheet.setOnClickListener { }
|
sheet.setOnClickListener { }
|
||||||
|
|
||||||
statusText = TextView(this)
|
val topRow = LinearLayout(this)
|
||||||
statusText.textSize = 14f
|
topRow.gravity = Gravity.CENTER_VERTICAL
|
||||||
statusText.setTextColor(Color.rgb(71, 85, 105))
|
topRow.orientation = LinearLayout.HORIZONTAL
|
||||||
sheet.addView(statusText)
|
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 = EditText(this)
|
||||||
input.minLines = 3
|
input.minLines = 3
|
||||||
@@ -117,19 +293,164 @@ class QuickSendActivity : Activity() {
|
|||||||
root.addView(sheet, params)
|
root.addView(sheet, params)
|
||||||
setContentView(root)
|
setContentView(root)
|
||||||
refreshState()
|
refreshState()
|
||||||
|
autoScanIfDisconnected()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshState() {
|
private fun refreshState() {
|
||||||
val state = ConnectionService.stateMap()
|
val state = ConnectionService.stateMap()
|
||||||
val connected = state["connected"] as? Boolean ?: false
|
val connected = state["connected"] as? Boolean ?: false
|
||||||
|
val connecting = state["connecting"] as? Boolean ?: false
|
||||||
val host = state["host"] as? String ?: ""
|
val host = state["host"] as? String ?: ""
|
||||||
val port = state["port"] as? Int ?: 8181
|
val port = state["port"] as? Int ?: 8181
|
||||||
statusText.text = if (connected) "● 已连接 $host:$port" else "● 未连接,请先打开主应用连接电脑"
|
val name = state["name"] as? String ?: ""
|
||||||
statusText.setTextColor(if (connected) Color.rgb(22, 101, 52) else Color.rgb(185, 28, 28))
|
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.isEnabled = connected
|
||||||
sendButton.alpha = if (connected) 1f else 0.48f
|
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<DiscoveredService>) {
|
||||||
|
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() {
|
private fun sendAndClose() {
|
||||||
val text = input.text.toString()
|
val text = input.text.toString()
|
||||||
Log.d("WTS", "QuickSendActivity send length=${text.length}")
|
Log.d("WTS", "QuickSendActivity send length=${text.length}")
|
||||||
@@ -137,7 +458,7 @@ class QuickSendActivity : Activity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ConnectionService.sendText(text)) {
|
if (ConnectionService.sendText(text) && (!appendEnter || ConnectionService.sendEnter())) {
|
||||||
sendButton.background = roundedDrawable(Color.rgb(22, 163, 74), dp(26).toFloat())
|
sendButton.background = roundedDrawable(Color.rgb(22, 163, 74), dp(26).toFloat())
|
||||||
sendIcon.setImageResource(R.drawable.ic_check_24)
|
sendIcon.setImageResource(R.drawable.ic_check_24)
|
||||||
sendIcon.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN)
|
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() {
|
private fun hideKeyboard() {
|
||||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
imm.hideSoftInputFromWindow(input.windowToken, 0)
|
imm.hideSoftInputFromWindow(input.windowToken, 0)
|
||||||
@@ -176,4 +525,9 @@ class QuickSendActivity : Activity() {
|
|||||||
private fun dp(value: Int): Int {
|
private fun dp(value: Int): Int {
|
||||||
return (value * resources.displayMetrics.density).toInt()
|
return (value * resources.displayMetrics.density).toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS = "FlutterSharedPreferences"
|
||||||
|
private const val KEY_APPEND_ENTER = "flutter.append_enter"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
android/app/src/main/res/drawable/ic_sync_24.xml
Normal file
9
android/app/src/main/res/drawable/ic_sync_24.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<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="#FFFFFFFF"
|
||||||
|
android:pathData="M12,4V1L8,5L12,9V6C15.31,6 18,8.69 18,12C18,13.01 17.75,13.96 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12C20,7.58 16.42,4 12,4ZM6,12C6,10.99 6.25,10.04 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12C4,16.42 7.58,20 12,20V23L16,19L12,15V18C8.69,18 6,15.31 6,12Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<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="#FFFFFFFF"
|
||||||
|
android:pathData="M10.86,5.14L8,8L10.86,10.86V8.42C12.58,8.83 14.02,9.96 14.83,11.48L16.31,10C15.15,8.15 13.2,6.84 10.86,6.41V5.14ZM2.81,2L1.39,3.41L5.38,7.4C4.51,8.7 4,10.29 4,12C4,16.42 7.58,20 12,20V23L16,19L12,15V18C8.69,18 6,15.31 6,12C6,10.84 6.33,9.76 6.9,8.85L19.59,21.54L21,20.13L2.81,2ZM18.62,16.6C19.49,15.3 20,13.71 20,12C20,7.58 16.42,4 12,4V1L9.38,3.62L11.1,5.34C11.4,5.13 11.69,4.91 12,4.75V6C15.31,6 18,8.69 18,12C18,13.16 17.67,14.24 17.1,15.15L18.62,16.6Z" />
|
||||||
|
</vector>
|
||||||
94
assets/fonts/WDXLLubrifontSC-OFL.txt
Normal file
94
assets/fonts/WDXLLubrifontSC-OFL.txt
Normal file
@@ -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.
|
||||||
BIN
assets/fonts/WDXLLubrifontSC-Regular.ttf
Normal file
BIN
assets/fonts/WDXLLubrifontSC-Regular.ttf
Normal file
Binary file not shown.
168
lib/main.dart
168
lib/main.dart
@@ -28,12 +28,15 @@ class WirelessTextSyncerApp extends StatelessWidget {
|
|||||||
|
|
||||||
enum SendButtonState { idle, sending, success }
|
enum SendButtonState { idle, sending, success }
|
||||||
|
|
||||||
|
const connectionInfoFontFamily = 'WDXL Lubrifont SC';
|
||||||
|
|
||||||
class ConnectionSnapshot {
|
class ConnectionSnapshot {
|
||||||
const ConnectionSnapshot({
|
const ConnectionSnapshot({
|
||||||
this.connected = false,
|
this.connected = false,
|
||||||
this.connecting = false,
|
this.connecting = false,
|
||||||
this.host = '',
|
this.host = '',
|
||||||
this.port = 8181,
|
this.port = 8181,
|
||||||
|
this.name = '',
|
||||||
this.lastError,
|
this.lastError,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ class ConnectionSnapshot {
|
|||||||
connecting: map['connecting'] == true,
|
connecting: map['connecting'] == true,
|
||||||
host: (map['host'] as String?) ?? '',
|
host: (map['host'] as String?) ?? '',
|
||||||
port: (map['port'] as num?)?.toInt() ?? 8181,
|
port: (map['port'] as num?)?.toInt() ?? 8181,
|
||||||
|
name: (map['name'] as String?) ?? '',
|
||||||
lastError: map['lastError'] as String?,
|
lastError: map['lastError'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -55,6 +59,7 @@ class ConnectionSnapshot {
|
|||||||
final bool connecting;
|
final bool connecting;
|
||||||
final String host;
|
final String host;
|
||||||
final int port;
|
final int port;
|
||||||
|
final String name;
|
||||||
final String? lastError;
|
final String? lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +110,11 @@ class NativeConnectionApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> connect(String host, int port) async {
|
Future<void> connect(String host, int port, {String? name}) async {
|
||||||
await _methodChannel.invokeMethod<void>('connect', {
|
await _methodChannel.invokeMethod<void>('connect', {
|
||||||
'host': host,
|
'host': host,
|
||||||
'port': port,
|
'port': port,
|
||||||
|
if (name != null) 'name': name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,9 +294,15 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
scanning = false;
|
scanning = false;
|
||||||
discoveredDevices = devices;
|
discoveredDevices = devices;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (devices.length == 1 &&
|
||||||
|
!connection.connected &&
|
||||||
|
!connection.connecting) {
|
||||||
|
await _connectDevice(devices.single);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _connect() async {
|
Future<void> _connect({String? name}) async {
|
||||||
final host = hostController.text.trim();
|
final host = hostController.text.trim();
|
||||||
final portText = portController.text.trim();
|
final portText = portController.text.trim();
|
||||||
final port = int.tryParse(portText);
|
final port = int.tryParse(portText);
|
||||||
@@ -307,15 +319,24 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
await prefs.setString(_portKey, port.toString());
|
await prefs.setString(_portKey, port.toString());
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
connection = ConnectionSnapshot(connecting: true, host: host, port: port);
|
connection = ConnectionSnapshot(
|
||||||
|
connecting: true,
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
name: name ?? '',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.connect(host, port);
|
await api.connect(host, port, name: name);
|
||||||
} on PlatformException catch (exception) {
|
} on PlatformException catch (exception) {
|
||||||
_showToast(exception.message ?? '连接失败,请检查地址');
|
_showToast(exception.message ?? '连接失败,请检查地址');
|
||||||
setState(() {
|
setState(() {
|
||||||
connection = ConnectionSnapshot(host: host, port: port);
|
connection = ConnectionSnapshot(
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
name: name ?? '',
|
||||||
|
);
|
||||||
headerExpanded = true;
|
headerExpanded = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -324,7 +345,7 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
Future<void> _connectDevice(DiscoveredDevice device) async {
|
Future<void> _connectDevice(DiscoveredDevice device) async {
|
||||||
hostController.text = device.host;
|
hostController.text = device.host;
|
||||||
portController.text = device.port.toString();
|
portController.text = device.port.toString();
|
||||||
await _connect();
|
await _connect(name: device.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _disconnect() async {
|
Future<void> _disconnect() async {
|
||||||
@@ -333,6 +354,7 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
connection = ConnectionSnapshot(
|
connection = ConnectionSnapshot(
|
||||||
host: hostController.text.trim(),
|
host: hostController.text.trim(),
|
||||||
port: int.tryParse(portController.text.trim()) ?? 8181,
|
port: int.tryParse(portController.text.trim()) ?? 8181,
|
||||||
|
name: connection.name,
|
||||||
);
|
);
|
||||||
headerExpanded = true;
|
headerExpanded = true;
|
||||||
sendButtonState = SendButtonState.idle;
|
sendButtonState = SendButtonState.idle;
|
||||||
@@ -542,11 +564,6 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
|
|
||||||
Widget _buildHeader(BuildContext context) {
|
Widget _buildHeader(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final statusText = connection.connected
|
|
||||||
? '已连接: ${connection.host}:${connection.port}'
|
|
||||||
: connection.connecting
|
|
||||||
? '正在连接: ${connection.host}:${connection.port}'
|
|
||||||
: '未连接 (点击配置)';
|
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: colorScheme.surface,
|
color: colorScheme.surface,
|
||||||
@@ -566,29 +583,23 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 44,
|
height: 52,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_ConnectionIndicator(
|
|
||||||
connected: connection.connected,
|
|
||||||
connecting: connection.connecting,
|
|
||||||
animation: statusPulseController,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: _ConnectionSummary(
|
||||||
statusText,
|
connected: connection.connected,
|
||||||
overflow: TextOverflow.ellipsis,
|
connecting: connection.connecting,
|
||||||
style: TextStyle(
|
endpoint: connection.host.isEmpty
|
||||||
color: connection.connected
|
? '未设置地址'
|
||||||
? const Color(0xFF166534)
|
: '${connection.host}:${connection.port}',
|
||||||
: connection.connecting
|
deviceName: connection.name.isEmpty
|
||||||
? colorScheme.primary
|
? 'Windows 桌面端'
|
||||||
: colorScheme.error,
|
: connection.name,
|
||||||
fontWeight: FontWeight.w600,
|
animation: statusPulseController,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Icon(
|
Icon(
|
||||||
headerExpanded
|
headerExpanded
|
||||||
? Icons.keyboard_arrow_up
|
? Icons.keyboard_arrow_up
|
||||||
@@ -979,6 +990,105 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<double> 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 {
|
class _ConnectionIndicator extends StatelessWidget {
|
||||||
const _ConnectionIndicator({
|
const _ConnectionIndicator({
|
||||||
required this.connected,
|
required this.connected,
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ flutter:
|
|||||||
# the material Icons class.
|
# the material Icons class.
|
||||||
uses-material-design: true
|
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:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# assets:
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
|
|||||||
Reference in New Issue
Block a user