编程解决一切烦恼
Obsidian搭建个人笔记
最近在使用Obsidian搭建个人云笔记
尽管我使用腾讯云COS
图床+gitee
实现了云备份,但是在Android
上使的Obsidian
备份有点麻烦。还好我主要是在电脑端做笔记,手机只是作为阅读工具。
所以,我写一个局域网文件夹同步工具,来解决这个问题。
传输速度很快
局域网文件互传
Windows和Android之间实现局域网内文件互传有以下几种协议
HTTP 协议
优点:
- 实现简单,客户端和服务器都有成熟的库
- 安全性较好,支持HTTPS加密
- 可以传输不同类型的数据,包括文件、文本等
缺点
:
- 传输效率比Socket等协议低
- 需要自行处理大文件分片上传和下载
Socket 协议
优点:
- 传输效率高,特别适合传输大文件
- 建立连接简单快速
缺点
:
- 需要处理粘包问题,协议较为复杂
- 没有加密,安全性差
- 需要处理网络状态变化等异常
SFTP 协议
优点:
- 安全性好,基于SSH通道传输
- 支持直接映射为本地磁盘访问
缺点
:
- 实现较复杂,需要找到可用的SFTP库
- 传输效率比Socket低
WebSocket 协议
优点:
- 传输效率高,支持双向通信
- 接口简单统一
缺点
:
- 需要处理连接状态,实现较为复杂
- 没有加密,安全性较差
综合来说,使用HTTP
或Socket
都是不错的选择
WebSocket
但是最后我选择了WebSocket
,原因是Socket
在处理接收数据的时候需要考虑缓冲区的大小和计算json
结尾标识,实现起来较为繁琐,而WebSocket
与Socket
在实现这个简单的功能时的性能差别几乎可以忽略不计,而且WebSocket
可以轻松实现按行读取数据,有效避免数据污染和丢失的问题。最关键的一点是,WebSocket
还可以轻松实现剪贴板同步
功能。
我一开始尝试使用Socket来实现这个功能,但很快就发现实现起来相当麻烦,于是换用了WebSocket
,两者在速度上没有任何差别,用WebSocket
起来舒服多了!
我最近开发了一个笔录加密共享App 也是使用了WebSocket
下载地址:https://www.wordsfairy.cloud/introduce/
思路
MD5校验没写,一直用着也没发现有压缩包损坏的情况(超小声)
定义json格式和功能标识码
为每个功能定义标识码
enum class SocketType(val type: String, val msg: String) {
FILE_SYNC("FILE_SYNC", "文件同步"),
FOLDER_SYNC("FOLDER_SYNC", "文件夹同步"),
CLIPBOARD_SYNC("CLIPBOARD_SYNC", "剪贴板同步"),
HEARTBEAT("HEARTBEAT", "心跳"),
FILE_SENDING("FILE_SENDING", "发送中"),
FOLDER_SYNCING("FOLDER_SYNCING", "文件夹同步中"),
FILE_SENDEND("FILE_SENDEND", "发送完成");
}
用于文件传输过程中表示文件发送进度的模型类
data class FileSendingDot(
val fileName: String,
val bufferSize: Int,
val total: Long,
val sent: Long,
val data: String
)
Python服务器端实现
创建websocket服务端
使用Python
的asyncio
和websockets
模块实现了一个异步的WebSocket
服务器,通过异步事件循环来处理客户端的连接和通信。
import asyncio
import websockets
start_server = websockets.serve(handle_client, "", 9999)
asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever()
解析同步请求,操作本地文件夹
json_obj = json.loads(data)
type_value = json_obj["type"]
data_value = json_obj["data"]
if type_value == "FILE_SYNC":
await send_file(websocket,"FILE_SENDING", file_path)
利用循环分块读取文件并通过WebSocket发送每个数据块,同时构造消息对象封装文件信息
file_data = f.read(buffer_size)
sent_size += len(file_data)
# 发送数据块,包含序号和数据
send_file_data = base64.b64encode(file_data).decode()
file_seading_data = {
"fileName": filename,
"bufferSize":buffer_size,
"total": total_size,
"sent": sent_size,
"data": send_file_data,
}
msg = {
"type": type,
"msg": "发送中",
"data": json.dumps(file_seading_data),
}
await ws.send(json.dumps(msg))
安卓客户端 Jetpack ComposeUI 实现
请求所有文件访问权限
va launcher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()) { result ->
// 权限已授权 or 权限被拒绝
}
private fun checkAndRequestAllFilePermissions() {
//检查权限
if (!Environment.isExternalStorageManager()) {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.setData(Uri.parse("package:$packageName"))
launcher.launch(intent)
}
}
自定义保存路径
选择文件夹
rememberLauncherForActivityResult()
创建一个ActivityResultLauncher
,用于启动并获取文件夹选择的回调结果。
val selectFolderResult = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { data ->
val uri = data.data?.data
if (uri != null) {
intentChannel.trySend(ViewIntent.SelectFolder(uri))
} else {
ToastModel("选择困难! ƪ(˘⌣˘)ʃ", ToastModel.Type.Info).showToast()
}
}
Uri的path
fun Uri.toFilePath(): String {
val uriPath = this.path ?: return ""
val path = uriPath.split(":")[1]
return Environment.getExternalStorageDirectory().path + "/" + path
}
okhttp实现websocket
private val client = OkHttpClient.Builder().build()
//通过callbackFlow封装,实现流式API
fun connect() =
createSocketFlow()
.onEach {
LogX.i("WebSocket", "收到消息 $it")
}.retry(reconnectInterval)
private fun createSocketFlow(): Flow<String> = callbackFlow {
val request = Request.Builder()
.url("ws://192.168.0.102:9999")
.build()
val listener = object : WebSocketListener() {
...接收消息的回调
}
socket = client.newWebSocket(request, listener)
//心跳机制
launchHeartbeat()
awaitClose { socket?.cancel() }
}.flowOn(Dispatchers.IO)
//服务端发送数据
fun send(message: String) {
socket?.send(message)
}
接收文件
使用 Base64.decode()
方法将 base64
数据解码成字节数组 fileData
val fileName = dot.fileName
val file = File(AppSystemSetManage.fileSavePath, fileName)
val fileData = Base64.decode(dot.data, Base64.DEFAULT)
- 接着就是使用IO数据流
OutputStream
加上自定义的路径
一顿操作 就得到zip文件了 - 最后解压zip到当前文件夹
接收文件
显示发送进度
用drawBehind
在后面绘制矩形实现进度条占位。根据进度计算矩形宽度,实现进度填充效果。不会遮挡子组件,很简洁地实现自定义进度条。
Box(
modifier = Modifier
.fillMaxWidth()
.drawBehind {
val fraction = progress * size.width
drawRoundRect(
color = progressColor,
size = Size(width = fraction, height = size.height),
cornerRadius = CornerRadius(12.dp.toPx()),
alpha = 0.9f,
)
}
@Composable
fun ProgressCard(
modifier: Modifier = Modifier,
title: String,
progress: Float,
onClick: () -> Unit = {}
) {
val progressColor = WordsFairyTheme.colors.themeAccent
//通过判断progress的值来决定是否显示加载
val load = progress > 0F
val textColor = if (load) WordsFairyTheme.colors.themeUi else WordsFairyTheme.colors.textPrimary
OutlinedCard(
modifier = modifier,
onClick = onClick,
colors =
CardDefaults.cardColors(WordsFairyTheme.colors.itemBackground),
border = BorderStroke(1.dp, textColor)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.drawBehind {
val fraction = progress * size.width
drawRoundRect(
color = progressColor,
size = Size(width = fraction, height = size.height),
cornerRadius = CornerRadius(12.dp.toPx()),
alpha = 0.9f,
)
},
content = {
Row {
Title(
title = title, Modifier.padding(16.dp),
color = textColor
)
Spacer(Modifier.weight(1f))
if (load)
Title(
title = "${(progress * 100).toInt()}%", Modifier.padding(16.dp),
color = textColor
)
}
}
)
}
}
效果图
python代码
import asyncio
import websockets
import os
from pathlib import Path
import pyperclip
import json
import base64
import zipfile
import math
FILE_BUFFER_MIN = 1024
FILE_BUFFER_MAX = 1024 * 1024 # 1MB
file_path = "E:\\xy\\FruitSugarContentDetection.zip"
folder_path = "E:\\Note\\Obsidian"
zip_path = "E:\\Note\\Obsidian.zip"
async def send_file(ws,type, filepath):
# 获取文件名
filename = os.path.basename(filepath)
total_size = os.path.getsize(filepath)
sent_size = 0
if total_size < FILE_BUFFER_MAX * 10:
buffer_size = math.ceil(total_size / 100)
else:
buffer_size = FILE_BUFFER_MAX
with open(filepath, "rb") as f:
while sent_size < total_size:
file_data = f.read(buffer_size)
sent_size += len(file_data)
# 发送数据块,包含序号和数据
send_file_data = base64.b64encode(file_data).decode()
file_seading_data = {
"fileName": filename,
"bufferSize":buffer_size,
"total": total_size,
"sent": sent_size,
"data": send_file_data,
}
msg = {
"type": type,
"msg": "发送中",
"data": json.dumps(file_seading_data),
}
await ws.send(json.dumps(msg))
print((sent_size / total_size) * 100)
# 发送结束标志
endmsg = {"type": "FILE_SENDEND", "msg": "发送完成", "data": "发送完成"}
await ws.send(json.dumps(endmsg))
async def handle_client(websocket, path):
# 用户连接时打印日志
print("用户连接")
async for data in websocket:
print(data)
json_obj = json.loads(data)
type_value = json_obj["type"]
data_value = json_obj["data"]
if type_value == "FILE_SYNC":
await send_file(websocket,"FILE_SENDING", file_path)
if type_value == "FOLDER_SYNC":
zip_folder(folder_path, zip_path)
await send_file(websocket,"FOLDER_SYNCING", zip_path)
if type_value == "CLIPBOARD_SYNC":
pyperclip.copy(data_value)
print(data_value)
if type_value == "HEARTBEAT":
dictionary_data = {
"type": "HEARTBEAT",
"msg": "hi",
"data": "",
}
await websocket.send(json.dumps(dictionary_data))
# 用户断开时打印日志
print("用户断开")
def zip_folder(folder_path, zip_path):
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
for root, _, files in os.walk(folder_path):
for file in files:
file_path = os.path.join(root, file)
zipf.write(file_path, arcname=os.path.relpath(file_path, folder_path))
start_server = websockets.serve(handle_client, "", 9999)
asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever()
源码
github: https://github.com/JIULANG9/FileSync
gitee: https://gitee.com/JIULANG9/FileSync