Compare commits
9 Commits
4d37f264bf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f166c2efa | ||
|
|
491513fc5e | ||
|
|
2798b5a66a | ||
|
|
5f6e045e98 | ||
|
|
19441399d0 | ||
|
|
31d1681947 | ||
|
|
1286a63507 | ||
|
|
7cd6fbd1f2 | ||
|
|
fe5cb9af37 |
@@ -42,3 +42,7 @@ android {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<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
|
<application
|
||||||
android:label="wireless_text_syncer_android"
|
android:label="文本同步"
|
||||||
android:name="${applicationName}"
|
android:name=".WirelessTextSyncerApplicationHolder"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -27,6 +32,28 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<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
|
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.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">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</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>
|
</resources>
|
||||||
|
|||||||
@@ -15,4 +15,11 @@
|
|||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</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>
|
</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
943
lib/main.dart
32
pubspec.lock
@@ -41,14 +41,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
crypto:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: crypto
|
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.7"
|
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -317,14 +309,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -349,22 +333,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ dependencies:
|
|||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
web_socket_channel: ^3.0.3
|
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
@@ -59,6 +58,11 @@ flutter:
|
|||||||
# the material Icons class.
|
# the material Icons class.
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
|
fonts:
|
||||||
|
- family: WDXL Lubrifont SC
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/WDXLLubrifontSC-Regular.ttf
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# assets:
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
|
|||||||
@@ -6,20 +6,65 @@
|
|||||||
// tree, read text, and verify that the values of widget properties are correct.
|
// tree, read text, and verify that the values of widget properties are correct.
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:wireless_text_syncer_android/main.dart';
|
import 'package:wireless_text_syncer_android/main.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
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 {
|
testWidgets('shows v2 draft board controls', (WidgetTester tester) async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
await tester.pumpWidget(const WirelessTextSyncerApp());
|
await tester.pumpWidget(const WirelessTextSyncerApp());
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
|
||||||
expect(find.text('未连接 (点击配置)'), findsOneWidget);
|
expect(find.text('未连接 (点击配置)'), findsOneWidget);
|
||||||
expect(find.text('IP 地址'), findsOneWidget);
|
expect(find.text('IP 地址'), findsOneWidget);
|
||||||
expect(find.text('端口'), 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);
|
||||||
expect(find.text('追加回车'), findsOneWidget);
|
expect(find.text('追加回车'), findsOneWidget);
|
||||||
|
|||||||