Compare commits
10 Commits
8c127cbd6e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f166c2efa | ||
|
|
491513fc5e | ||
|
|
2798b5a66a | ||
|
|
5f6e045e98 | ||
|
|
19441399d0 | ||
|
|
31d1681947 | ||
|
|
1286a63507 | ||
|
|
7cd6fbd1f2 | ||
|
|
fe5cb9af37 | ||
|
|
4d37f264bf |
@@ -42,3 +42,7 @@ android {
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
|
||||
<application
|
||||
android:label="wireless_text_syncer_android"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:label="文本同步"
|
||||
android:name=".WirelessTextSyncerApplicationHolder"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -27,6 +32,28 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".QuickSendActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/TransparentBottomSheetTheme"
|
||||
android:excludeFromRecents="true"
|
||||
android:finishOnTaskLaunch="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<service
|
||||
android:name=".ConnectionService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service
|
||||
android:name=".QuickSendTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_quick_send_tile"
|
||||
android:label="文本同步"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
package com.wirelesstextsyncer.wireless_text_syncer_android
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ConnectionService : Service() {
|
||||
private val tag = "WTS"
|
||||
private val client = OkHttpClient.Builder()
|
||||
.pingInterval(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(tag, "ConnectionService onCreate")
|
||||
ensureNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(tag, "ConnectionService onStartCommand action=${intent?.action}")
|
||||
when (intent?.action) {
|
||||
ACTION_CONNECT -> {
|
||||
val host = intent.getStringExtra(EXTRA_HOST).orEmpty()
|
||||
val port = intent.getIntExtra(EXTRA_PORT, 8181)
|
||||
val name = intent.getStringExtra(EXTRA_NAME).orEmpty()
|
||||
connectWebSocket(host, port, name)
|
||||
}
|
||||
ACTION_DISCONNECT -> disconnectWebSocket("已断开连接")
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(tag, "ConnectionService onDestroy")
|
||||
currentSocket?.close(1000, "service destroyed")
|
||||
currentSocket = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_HOST, host)
|
||||
.putInt(KEY_PORT, port)
|
||||
.putString(KEY_NAME, name)
|
||||
.apply()
|
||||
|
||||
try {
|
||||
Log.d(tag, "startForeground connecting notification")
|
||||
startForeground(NOTIFICATION_ID, buildNotification(host, port, "正在连接"))
|
||||
} catch (exception: Exception) {
|
||||
Log.e(tag, "startForeground failed", exception)
|
||||
updateState(
|
||||
connected = false,
|
||||
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, name = name, lastError = null)
|
||||
|
||||
val request = Request.Builder().url("ws://$host:$port").build()
|
||||
Log.d(tag, "Creating WebSocket ws://$host:$port")
|
||||
currentSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.d(tag, "WebSocket onOpen code=${response.code}")
|
||||
if (currentSocket != webSocket) {
|
||||
Log.d(tag, "Ignoring stale WebSocket onOpen")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
Log.d(tag, "Ignoring stale WebSocket onClosed")
|
||||
return
|
||||
}
|
||||
currentSocket = null
|
||||
updateState(connected = false, connecting = false, host = host, port = port, name = state.name, lastError = null)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e(tag, "WebSocket onFailure responseCode=${response?.code}", t)
|
||||
if (currentSocket != webSocket) {
|
||||
Log.d(tag, "Ignoring stale WebSocket onFailure")
|
||||
return
|
||||
}
|
||||
currentSocket = null
|
||||
val errorMessage = if (state.connected) {
|
||||
"桌面端服务已断开"
|
||||
} else {
|
||||
t.message ?: "连接失败"
|
||||
}
|
||||
updateState(
|
||||
connected = false,
|
||||
connecting = false,
|
||||
host = host,
|
||||
port = port,
|
||||
name = state.name.ifBlank { name },
|
||||
lastError = errorMessage
|
||||
)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun disconnectWebSocket(message: String?) {
|
||||
Log.d(tag, "disconnectWebSocket message=$message")
|
||||
currentSocket?.close(1000, "user disconnected")
|
||||
currentSocket = null
|
||||
updateState(connected = false, connecting = false, lastError = message)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
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)
|
||||
} else {
|
||||
Notification.Builder(this)
|
||||
}
|
||||
|
||||
return builder
|
||||
.setSmallIcon(android.R.drawable.stat_sys_upload_done)
|
||||
.setContentTitle("文本同步")
|
||||
.setContentText("$status $host:$port")
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun ensureNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
|
||||
val channel = NotificationChannel(CHANNEL_ID, "文本同步连接", NotificationManager.IMPORTANCE_LOW)
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_CONNECT = "wireless_text_syncer.CONNECT"
|
||||
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, 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)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Log.e("WTS", "Failed to start ConnectionService", exception)
|
||||
updateState(
|
||||
connected = false,
|
||||
connecting = false,
|
||||
host = host,
|
||||
port = port,
|
||||
name = name,
|
||||
lastError = exception.message ?: "连接服务启动失败"
|
||||
)
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect(context: Context) {
|
||||
Log.d("WTS", "ConnectionService.disconnect requested")
|
||||
try {
|
||||
context.startService(Intent(context, ConnectionService::class.java).setAction(ACTION_DISCONNECT))
|
||||
} catch (exception: Exception) {
|
||||
Log.e("WTS", "Failed to stop ConnectionService", exception)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendText(text: String): Boolean {
|
||||
Log.d("WTS", "ConnectionService.sendText length=${text.length} connected=${state.connected}")
|
||||
val socket = currentSocket ?: return false
|
||||
if (!state.connected || text.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return sendAction(socket, "replaceAll", text)
|
||||
}
|
||||
|
||||
fun sendEnter(): Boolean {
|
||||
Log.d("WTS", "ConnectionService.sendEnter connected=${state.connected}")
|
||||
val socket = currentSocket ?: return false
|
||||
if (!state.connected) {
|
||||
return false
|
||||
}
|
||||
|
||||
return sendAction(socket, "enter", null)
|
||||
}
|
||||
|
||||
fun sendMute(muted: Boolean): Boolean {
|
||||
Log.d("WTS", "ConnectionService.sendMute muted=$muted connected=${state.connected}")
|
||||
val socket = currentSocket ?: return false
|
||||
if (!state.connected) {
|
||||
return false
|
||||
}
|
||||
|
||||
return sendMuteAction(socket, muted)
|
||||
}
|
||||
|
||||
private fun sendMuteAction(socket: WebSocket, muted: Boolean): Boolean {
|
||||
val payload = JSONObject()
|
||||
.put("action", "setMute")
|
||||
.put("muted", muted)
|
||||
.toString()
|
||||
val sent = socket.send(payload)
|
||||
if (!sent && currentSocket == socket) {
|
||||
currentSocket = null
|
||||
updateState(
|
||||
connected = false,
|
||||
connecting = false,
|
||||
lastError = "连接已断开"
|
||||
)
|
||||
}
|
||||
|
||||
return sent
|
||||
}
|
||||
|
||||
private fun sendAction(socket: WebSocket, action: String, text: String?): Boolean {
|
||||
val payload = JSONObject()
|
||||
.put("action", action)
|
||||
.apply {
|
||||
if (text != null) {
|
||||
put("text", text)
|
||||
}
|
||||
}
|
||||
.toString()
|
||||
val sent = socket.send(payload)
|
||||
if (!sent && currentSocket == socket) {
|
||||
currentSocket = null
|
||||
updateState(
|
||||
connected = false,
|
||||
connecting = false,
|
||||
lastError = "连接已断开"
|
||||
)
|
||||
}
|
||||
|
||||
return sent
|
||||
}
|
||||
|
||||
fun stateMap(): Map<String, Any?> {
|
||||
val snapshot = state
|
||||
return mapOf(
|
||||
"connected" to snapshot.connected,
|
||||
"connecting" to snapshot.connecting,
|
||||
"host" to snapshot.host,
|
||||
"port" to snapshot.port,
|
||||
"name" to snapshot.name,
|
||||
"lastError" to snapshot.lastError
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateState(
|
||||
connected: Boolean,
|
||||
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 name=$name error=$lastError"
|
||||
)
|
||||
state = ConnectionStateSnapshot(connected, connecting, host, port, name, lastError)
|
||||
mainHandler.post {
|
||||
eventSink?.success(stateMap())
|
||||
QuickSendTileService.requestTileRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ConnectionStateSnapshot(
|
||||
val connected: Boolean = false,
|
||||
val connecting: Boolean = false,
|
||||
val host: String = "",
|
||||
val port: Int = 8181,
|
||||
val name: String = "",
|
||||
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,5 +1,85 @@
|
||||
package com.wirelesstextsyncer.wireless_text_syncer_android
|
||||
|
||||
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
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val tag = "WTS"
|
||||
private val connectionChannel = "wireless_text_syncer/connection"
|
||||
private val connectionStateChannel = "wireless_text_syncer/connection_state"
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, connectionChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"connect" -> {
|
||||
val host = call.argument<String>("host").orEmpty()
|
||||
val port = call.argument<Int>("port") ?: 8181
|
||||
val name = call.argument<String>("name").orEmpty()
|
||||
Log.d(tag, "MethodChannel connect host=$host port=$port name=$name")
|
||||
try {
|
||||
ConnectionService.connect(this, host, port, name)
|
||||
result.success(null)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(tag, "MethodChannel connect failed", exception)
|
||||
result.error("CONNECT_FAILED", exception.message, null)
|
||||
}
|
||||
}
|
||||
"disconnect" -> {
|
||||
Log.d(tag, "MethodChannel disconnect")
|
||||
ConnectionService.disconnect(this)
|
||||
result.success(null)
|
||||
}
|
||||
"sendText" -> {
|
||||
val text = call.argument<String>("text").orEmpty()
|
||||
Log.d(tag, "MethodChannel sendText length=${text.length}")
|
||||
result.success(ConnectionService.sendText(text))
|
||||
}
|
||||
"sendEnter" -> {
|
||||
Log.d(tag, "MethodChannel sendEnter")
|
||||
result.success(ConnectionService.sendEnter())
|
||||
}
|
||||
"sendMute" -> {
|
||||
val muted = call.argument<Boolean>("muted") ?: false
|
||||
Log.d(tag, "MethodChannel sendMute muted=$muted")
|
||||
result.success(ConnectionService.sendMute(muted))
|
||||
}
|
||||
"getState" -> {
|
||||
Log.d(tag, "MethodChannel getState")
|
||||
result.success(ConnectionService.stateMap())
|
||||
}
|
||||
"startDiscovery" -> startDiscovery(result)
|
||||
"stopDiscovery" -> result.success(null)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, connectionStateChannel)
|
||||
.setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
ConnectionService.eventSink = events
|
||||
events?.success(ConnectionService.stateMap())
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
ConnectionService.eventSink = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun startDiscovery(result: MethodChannel.Result) {
|
||||
Thread {
|
||||
val devices = DiscoveryClient.discover(applicationContext).map { it.toMap() }
|
||||
|
||||
runOnUiThread {
|
||||
Log.d(tag, "Discovery finished count=${devices.size}")
|
||||
result.success(devices)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
package com.wirelesstextsyncer.wireless_text_syncer_android
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
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
|
||||
import android.content.Context
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import org.json.JSONArray
|
||||
|
||||
class QuickSendActivity : Activity() {
|
||||
private lateinit var input: EditText
|
||||
private lateinit var sendButton: LinearLayout
|
||||
private lateinit var sendIcon: ImageView
|
||||
private lateinit var sendLabel: 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 {
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildUi() {
|
||||
val root = FrameLayout(this)
|
||||
root.setBackgroundColor(0x66000000)
|
||||
root.setOnClickListener { finish() }
|
||||
root.setOnApplyWindowInsetsListener { view, insets ->
|
||||
val imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom
|
||||
val navBottom = insets.getInsets(WindowInsets.Type.navigationBars()).bottom
|
||||
val keyboardLift = (imeBottom - navBottom).coerceAtLeast(0)
|
||||
view.setPadding(0, 0, 0, keyboardLift)
|
||||
insets
|
||||
}
|
||||
window.setSoftInputMode(
|
||||
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or
|
||||
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE
|
||||
)
|
||||
|
||||
val sheet = LinearLayout(this)
|
||||
sheet.orientation = LinearLayout.VERTICAL
|
||||
sheet.setPadding(dp(20), dp(18), dp(20), dp(18))
|
||||
sheet.setBackgroundColor(Color.WHITE)
|
||||
sheet.setOnClickListener { }
|
||||
|
||||
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
|
||||
input.maxLines = 6
|
||||
input.hint = "输入要发送到电脑的文本"
|
||||
input.textSize = 16f
|
||||
input.setPadding(dp(14), dp(12), dp(14), dp(12))
|
||||
input.background = strokeDrawable(Color.WHITE, Color.rgb(226, 232, 240), dp(12).toFloat())
|
||||
sheet.addView(input, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
topMargin = dp(12)
|
||||
})
|
||||
|
||||
sendButton = LinearLayout(this)
|
||||
sendButton.gravity = Gravity.CENTER
|
||||
sendButton.orientation = LinearLayout.HORIZONTAL
|
||||
sendButton.setPadding(dp(16), 0, dp(16), 0)
|
||||
sendButton.background = roundedDrawable(Color.rgb(37, 99, 235), dp(26).toFloat())
|
||||
sendButton.isClickable = true
|
||||
sendButton.isFocusable = true
|
||||
sendButton.setOnClickListener { sendAndClose() }
|
||||
sendIcon = ImageView(this)
|
||||
sendIcon.setImageResource(R.drawable.ic_send_24)
|
||||
sendIcon.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN)
|
||||
sendLabel = TextView(this)
|
||||
sendLabel.text = "发送到电脑"
|
||||
sendLabel.textSize = 16f
|
||||
sendLabel.typeface = Typeface.DEFAULT_BOLD
|
||||
sendLabel.setTextColor(Color.WHITE)
|
||||
sendButton.addView(sendIcon, LinearLayout.LayoutParams(dp(20), dp(20)))
|
||||
sendButton.addView(sendLabel, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
leftMargin = dp(8)
|
||||
})
|
||||
sheet.addView(sendButton, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
dp(52)
|
||||
).apply {
|
||||
topMargin = dp(12)
|
||||
})
|
||||
|
||||
val params = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM
|
||||
)
|
||||
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
|
||||
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<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() {
|
||||
val text = input.text.toString()
|
||||
Log.d("WTS", "QuickSendActivity send length=${text.length}")
|
||||
if (text.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (ConnectionService.sendText(text) && (!appendEnter || ConnectionService.sendEnter())) {
|
||||
saveHistory(text)
|
||||
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)
|
||||
sendLabel.text = "发送成功"
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
hideKeyboard()
|
||||
finish()
|
||||
}, 520)
|
||||
} else {
|
||||
refreshState()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveHistory(text: String) {
|
||||
if (text.trim().isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val currentHistory = readHistory()
|
||||
val nextHistory = linkedSetOf(text)
|
||||
currentHistory
|
||||
.filter { it != text }
|
||||
.take(MAX_HISTORY_ITEMS - 1)
|
||||
.forEach { nextHistory.add(it) }
|
||||
|
||||
prefs.edit()
|
||||
.putString(KEY_HISTORY, JSON_LIST_PREFIX + JSONArray(nextHistory.toList()).toString())
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun readHistory(): List<String> {
|
||||
val rawValue = getSharedPreferences(PREFS, Context.MODE_PRIVATE).all[KEY_HISTORY]
|
||||
return when (rawValue) {
|
||||
is String -> decodeHistoryString(rawValue)
|
||||
is Set<*> -> rawValue.filterIsInstance<String>()
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeHistoryString(value: String): List<String> {
|
||||
if (!value.startsWith(JSON_LIST_PREFIX)) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val array = JSONArray(value.substring(JSON_LIST_PREFIX.length))
|
||||
List(array.length()) { index -> array.optString(index) }
|
||||
.filter { it.isNotEmpty() }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun roundedDrawable(color: Int, radius: Float): GradientDrawable {
|
||||
return GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
setColor(color)
|
||||
cornerRadius = radius
|
||||
}
|
||||
}
|
||||
|
||||
private fun strokeDrawable(fill: Int, stroke: Int, radius: Float): GradientDrawable {
|
||||
return GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
setColor(fill)
|
||||
setStroke(dp(1), stroke)
|
||||
cornerRadius = radius
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
private const val KEY_HISTORY = "flutter.send_history"
|
||||
private const val JSON_LIST_PREFIX = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu!"
|
||||
private const val MAX_HISTORY_ITEMS = 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.wirelesstextsyncer.wireless_text_syncer_android
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import android.util.Log
|
||||
|
||||
class QuickSendTileService : TileService() {
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
Log.d("WTS", "QuickSendTileService onStartListening")
|
||||
refreshTile()
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
Log.d("WTS", "QuickSendTileService onClick")
|
||||
val intent = Intent(this, QuickSendActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
startActivityAndCollapse(pendingIntent)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshTile() {
|
||||
val tile = qsTile ?: return
|
||||
val state = ConnectionService.stateMap()
|
||||
val connected = state["connected"] as? Boolean ?: false
|
||||
tile.label = "文本同步"
|
||||
tile.subtitle = if (connected) "已连接" else "未连接"
|
||||
tile.state = if (connected) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
|
||||
tile.updateTile()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun requestTileRefresh() {
|
||||
Log.d("WTS", "QuickSendTileService requestTileRefresh")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
try {
|
||||
requestListeningState(
|
||||
WirelessTextSyncerApplicationHolder.context,
|
||||
ComponentName(WirelessTextSyncerApplicationHolder.context, QuickSendTileService::class.java)
|
||||
)
|
||||
} catch (exception: Exception) {
|
||||
Log.e("WTS", "requestListeningState failed", exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.wirelesstextsyncer.wireless_text_syncer_android
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
|
||||
class WirelessTextSyncerApplicationHolder : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d("WTS", "Application onCreate")
|
||||
context = applicationContext
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var context: Context
|
||||
}
|
||||
}
|
||||
BIN
android/app/src/main/res/drawable-nodpi/app_icon_source.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
android/app/src/main/res/drawable-nodpi/ic_quick_send_tile.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
9
android/app/src/main/res/drawable/ic_check_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="M9,16.2L4.8,12L3.4,13.4L9,19L21,7L19.6,5.6L9,16.2Z" />
|
||||
</vector>
|
||||
9
android/app/src/main/res/drawable/ic_send_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="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
|
||||
</vector>
|
||||
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>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 34 KiB |
@@ -15,4 +15,11 @@
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
<style name="TransparentBottomSheetTheme" parent="@android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
<item name="android:colorAccent">#60A5FA</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -15,4 +15,11 @@
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
<style name="TransparentBottomSheetTheme" parent="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
<item name="android:colorAccent">#2563EB</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
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
1213
lib/main.dart
32
pubspec.lock
@@ -41,14 +41,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -317,14 +309,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -349,22 +333,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -34,7 +34,6 @@ dependencies:
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
web_socket_channel: ^3.0.3
|
||||
shared_preferences: ^2.5.5
|
||||
|
||||
dev_dependencies:
|
||||
@@ -59,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
|
||||
|
||||
@@ -6,15 +6,68 @@
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:wireless_text_syncer_android/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('shows connection controls', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const WirelessTextSyncerApp());
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
expect(find.text('Windows IP'), findsOneWidget);
|
||||
expect(find.text('Port'), findsOneWidget);
|
||||
expect(find.text('Type here'), findsOneWidget);
|
||||
const connectionChannel = MethodChannel('wireless_text_syncer/connection');
|
||||
const stateChannel = MethodChannel('wireless_text_syncer/connection_state');
|
||||
|
||||
setUp(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(connectionChannel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'getState':
|
||||
return {
|
||||
'connected': false,
|
||||
'connecting': false,
|
||||
'host': '',
|
||||
'port': 8181,
|
||||
'lastError': null,
|
||||
};
|
||||
case 'startDiscovery':
|
||||
return [
|
||||
{'name': 'Desktop-WIN11', 'host': '192.168.1.10', 'port': 8181},
|
||||
];
|
||||
case 'connect':
|
||||
case 'disconnect':
|
||||
return null;
|
||||
case 'sendText':
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(stateChannel, (call) async => null);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(connectionChannel, null);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(stateChannel, null);
|
||||
});
|
||||
|
||||
testWidgets('shows v2 draft board controls', (WidgetTester tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
await tester.pumpWidget(const WirelessTextSyncerApp());
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
|
||||
expect(find.text('未连接 (点击配置)'), findsOneWidget);
|
||||
expect(find.text('IP 地址'), findsOneWidget);
|
||||
expect(find.text('端口'), findsOneWidget);
|
||||
expect(find.text('局域网设备雷达'), findsOneWidget);
|
||||
expect(find.text('Desktop-WIN11'), findsOneWidget);
|
||||
expect(find.text('可连接'), findsOneWidget);
|
||||
expect(find.text('草稿板'), findsOneWidget);
|
||||
expect(find.text('发送后清空'), findsOneWidget);
|
||||
expect(find.text('追加回车'), findsOneWidget);
|
||||
expect(find.text('发送到电脑'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||