Compare commits

..

10 Commits

Author SHA1 Message Date
Misaka
3f166c2efa Remove unused _ConnectionIndicator and _StatusDot widgets
Delete dead code that triggered an unused_element analyzer warning.
_ConnectionIndicator was unreferenced and _StatusDot was only used by it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-17 22:55:42 +08:00
Misaka
491513fc5e Add desktop mute toggle button in draft board
Add a setMute WebSocket action and a mute IconButton in the draft
board title bar. The button optimistically flips local state and
rolls back with a toast when the desktop is unreachable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-17 22:12:29 +08:00
Misaka
2798b5a66a Remove toast on history item restore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-17 21:49:30 +08:00
Misaka
5f6e045e98 Persist quick send history safely 2026-05-17 17:10:01 +08:00
Misaka
19441399d0 Sync append enter setting on resume 2026-05-17 16:47:07 +08:00
Misaka
31d1681947 Improve Android connection controls and status display 2026-05-17 16:42:40 +08:00
Misaka
1286a63507 Detect desktop disconnects on Android 2026-05-17 14:21:09 +08:00
Misaka
7cd6fbd1f2 Update Android icons and enter action 2026-05-16 22:00:02 +08:00
Misaka
fe5cb9af37 Implement Android v2 quick send UX 2026-05-16 20:36:13 +08:00
Misaka
4d37f264bf Implement Android v2 draft board experience 2026-05-16 08:46:16 +08:00
27 changed files with 2591 additions and 122 deletions

View File

@@ -42,3 +42,7 @@ android {
flutter {
source = "../.."
}
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

View File

@@ -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

View File

@@ -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
)
}

View File

@@ -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
)
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View 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>

View 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>

View 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>

View 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="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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -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>

View File

@@ -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>

View 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.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -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:

View File

@@ -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

View File

@@ -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);
});
}