跳到主要内容

截图同步

概述

截图同步是 Evatar 的核心入口功能。Android 端通过监控系统 MediaStore 自动发现新截图,经过增量比对后上传到后端服务,后端负责存储文件、生成缩略图,并自动触发 AI 分析管线。

同步流程

Android 端实现

MediaStore 查询

Android 端通过 ContentResolver 查询系统 MediaStore,筛选截图文件:

// SyncManager.scanMediaStoreSince()
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE,
MediaStore.Images.Media.DATE_ADDED,
MediaStore.Images.Media.MIME_TYPE,
MediaStore.Images.Media.RELATIVE_PATH,
)

// 过滤条件:路径或文件名包含 "Screenshot"
val parts = mutableListOf(
"(${MediaStore.Images.Media.RELATIVE_PATH} LIKE ? OR " +
"${MediaStore.Images.Media.DISPLAY_NAME} LIKE ?)"
)
val args = mutableListOf("%Screenshots%", "%screenshot%")

// 增量条件:只查询上次同步时间之后的新截图
if (sinceMs > 0) {
parts.add("${MediaStore.Images.Media.DATE_ADDED} > ?")
args.add((sinceMs / 1000).toString())
}

content:// URI 兼容

Android API 29+ 引入 Scoped Storage,文件路径变为 content:// URI,无法直接用 java.io.File 读取。SyncManager 会先将内容复制到临时文件:

// SyncManager.uploadOne()
val uploadPath = if (photo.filePath.startsWith("content://")) {
val tmpFile = File(appContext.cacheDir, "upload_${photo.id}_${photo.displayName}")
appContext.contentResolver.openInputStream(Uri.parse(photo.filePath))?.use { input ->
tmpFile.outputStream().use { output -> input.copyTo(output) }
}
tmpFile.absolutePath
} else {
photo.filePath
}

同步调度

系统提供两种同步调度方式:

方式机制间隔说明
前台服务SyncService (LifecycleService)60 秒常驻后台,显示持久通知
WorkManagerSyncWorker (CoroutineWorker)30 分钟由系统调度,需网络连接
// WorkScheduler.kt
val request = PeriodicWorkRequestBuilder<SyncWorker>(30, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build())
.build()

应用排除

用户可以排除特定应用的截图不参与同步。SyncManager 通过 AppExclusionManager 检查截图的 RELATIVE_PATH 是否包含排除的包名:

private fun isExcludedByPath(relativePath: String): Boolean {
val exclusions = exclusionManager.getExclusions()
return exclusions.any { excluded ->
relativePath.contains(excluded, ignoreCase = true)
}
}

并发上传

上传使用 Kotlin Coroutines 的 Semaphore 限制并发数为 3,避免同时上传过多文件导致内存溢出:

val semaphore = Semaphore(MAX_CONCURRENT) // MAX_CONCURRENT = 3
coroutineScope {
newPhotos.map { photo ->
async {
semaphore.withPermit {
uploadOne(photo)
}
}
}.awaitAll()
}

后端实现

去重机制

后端使用 device_id + local_media_store_id 组合作为唯一约束,避免同一设备的同一截图被重复上传:

# models.py
class Photo(Base):
__table_args__ = (
UniqueConstraint("device_id", "local_media_store_id", name="uq_device_local_id"),
)

上传时先查询是否已存在:

# api/photos.py - _save_upload()
existing = db.query(Photo).filter(
Photo.device_id == device_id,
Photo.local_media_store_id == local_media_store_id,
).first()
if existing:
return {"id": existing.id, "filename": existing.filename, "dedup": True}

为处理并发写入的竞态条件,还增加了 IntegrityError 捕获:

try:
db.flush()
except IntegrityError:
db.rollback()
existing = db.query(Photo).filter(...)
if existing:
return {"id": existing.id, "filename": existing.filename, "dedup": True}

上传元数据

每张截图上传时携带以下元数据:

字段类型说明
fileBinary截图文件内容
device_idString设备标识 (格式: 制造商_型号_ANDROID_ID)
device_nameString设备名称 (如 "Xiaomi 2312DRAABC")
source_typeString来源类型,默认 "screenshot"
local_media_store_idStringMediaStore 中的 _ID
original_timestampString截图时间戳 (毫秒)
mime_typeStringMIME 类型 (image/jpeg 等)

文件存储

文件按日期分目录存储,原图和缩略图在同一目录下:

data/photos/
2024-01-15/
a1b2c3d4e5f6.jpg # 原图
a1b2c3d4e5f6_thumb.jpg # 缩略图 (512px)
2024-01-16/
...
# services/storage.py
def save_photo(file_bytes, original_filename):
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
day_dir = settings.photos_dir / today
uid = uuid.uuid4().hex[:12]
# 保存原图
original_path = str(day_dir / f"{uid}{ext}")
# 生成缩略图 (最大 512px, JPEG quality=80)
_make_thumbnail(img, thumb_path, max_size=512)

同步状态管理

服务端为每个设备维护同步状态 (DeviceSyncState),记录最后同步时间戳和累计同步数量:

# models.py
class DeviceSyncState(Base):
device_id = Column(String(256), primary_key=True)
device_name = Column(String(256))
last_synced_timestamp = Column(DateTime) # 最后同步的截图原始时间
last_sync_time = Column(DateTime) # 最后一次同步操作时间
total_synced = Column(Integer, default=0)

Android 端在每次同步前先查询服务端的 last_synced_ts_ms,然后只查询 MediaStore 中该时间之后的新截图,实现增量同步。

device_id 格式

device_id 由 Android 端生成,格式为 制造商_型号_ANDROID_ID,服务端通过正则校验:

_DEVICE_ID_RE = re.compile(r'^[a-zA-Z0-9_.\-]{1,256}$')

批量上传

支持一次上传最多 50 张截图,元数据通过逗号分隔的字符串传递:

POST /api/photos/upload-batch
- device_id: string
- files: File[] (最多 50 个)
- timestamps: "ts1,ts2,ts3"
- local_ids: "id1,id2,id3"
- mime_types: "image/jpeg,image/png,image/jpeg"

API 端点

方法路径说明
POST/api/photos/upload上传单张截图
POST/api/photos/upload-batch批量上传截图 (≤50)
GET/api/photos/sync-state查询设备同步状态
POST/api/photos/sync-state设置同步起始时间
GET/api/photos分页查询截图列表
GET/api/photos/{id}获取截图详情及分析结果
GET/api/photos/{id}/image获取原图文件
GET/api/photos/{id}/thumbnail获取缩略图文件
GET/api/photos/devices列出所有同步设备
DELETE/api/photos/{id}删除截图及文件