diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e31ce9b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,99 @@ +name: Build & Test + +on: + push: + branches: ["master", "main"] + pull_request: + branches: ["master", "main"] + +jobs: + # Job 1: 代码静态检查 + 单元测试 + test: + name: Static Analysis & Unit Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run Detekt (static code analysis) + run: ./gradlew detekt --no-daemon + + - name: Run unit tests + run: ./gradlew testDebugUnitTest --no-daemon + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: unit-test-reports + path: app/build/reports/tests/ + retention-days: 7 + + # Job 2: 构建 Debug APK + build: + name: Build Debug APK + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Assemble Debug APK + run: ./gradlew assembleDebug --no-daemon + + - name: Upload Debug APK + uses: actions/upload-artifact@v4 + with: + name: debug-apk + path: app/build/outputs/apk/debug/*.apk + retention-days: 14 + + # Job 3: 构建 Release APK(仅 master 分支) + build-release: + name: Build Release APK + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Assemble Release APK + run: ./gradlew assembleRelease --no-daemon + + - name: Upload Release APK + uses: actions/upload-artifact@v4 + with: + name: release-apk + path: app/build/outputs/apk/release/*.apk + retention-days: 30 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9cb14bd..7492392 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,6 +58,10 @@ android { } } buildToolsVersion = "34.0.0" + + testOptions { + unitTests.isReturnDefaultValues = true + } } kotlin { @@ -150,6 +154,8 @@ dependencies { // Test implementation testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.coroutines.test) // Android test implementation androidTestImplementation(libs.androidx.test.ext) diff --git a/app/src/main/java/me/grey/picquery/common/AppModules.kt b/app/src/main/java/me/grey/picquery/common/AppModules.kt index b158c1f..f0856c9 100644 --- a/app/src/main/java/me/grey/picquery/common/AppModules.kt +++ b/app/src/main/java/me/grey/picquery/common/AppModules.kt @@ -10,6 +10,7 @@ import me.grey.picquery.data.data_source.ObjectBoxEmbeddingRepository import me.grey.picquery.data.data_source.PhotoRepository import me.grey.picquery.data.data_source.PreferenceRepository import me.grey.picquery.domain.AlbumManager +import me.grey.picquery.domain.AlbumUpdateService import me.grey.picquery.domain.EmbeddingService import me.grey.picquery.domain.ImageSearcher import me.grey.picquery.domain.MLKitTranslator @@ -128,10 +129,23 @@ private val domainModules = module { photoRepository = get(), embeddingRepository = get(), imageSearcher = get(), + albumUpdateService = get(), ioDispatcher = get() ) } + // Album update service - 增量更新服务 + single { + AlbumUpdateService( + photoRepository = get(), + objectBoxEmbeddingRepository = get(), + embeddingRepository = get(), + albumRepository = get(), + embeddingService = get(), + dispatcher = get() + ) + } + // Similarity manager single { SimilarityManager( diff --git a/app/src/main/java/me/grey/picquery/data/dao/EmbeddingDao.kt b/app/src/main/java/me/grey/picquery/data/dao/EmbeddingDao.kt index ba537d2..17f866b 100644 --- a/app/src/main/java/me/grey/picquery/data/dao/EmbeddingDao.kt +++ b/app/src/main/java/me/grey/picquery/data/dao/EmbeddingDao.kt @@ -22,7 +22,7 @@ interface EmbeddingDao { fun getAllByPhotoIds(photoIds: LongArray): List @Query( - "SELECT * FROM $tableName WHERE album_id IS (:albumId)" + "SELECT * FROM $tableName WHERE album_id = :albumId" ) fun getAllByAlbumId(albumId: Long): List @@ -41,6 +41,16 @@ interface EmbeddingDao { ) fun removeByAlbumId(albumId: Long): Unit + @Query( + "DELETE FROM $tableName WHERE photo_id IN (:photoIds)" + ) + fun removeByPhotoIds(photoIds: LongArray): Unit + + @Query( + "SELECT photo_id FROM $tableName WHERE album_id = :albumId" + ) + fun getPhotoIdsByAlbumId(albumId: Long): List + @Upsert fun upsertAll(embeddings: List) diff --git a/app/src/main/java/me/grey/picquery/data/dao/ObjectBoxEmbeddingDao.kt b/app/src/main/java/me/grey/picquery/data/dao/ObjectBoxEmbeddingDao.kt index 04fc95f..75df17f 100644 --- a/app/src/main/java/me/grey/picquery/data/dao/ObjectBoxEmbeddingDao.kt +++ b/app/src/main/java/me/grey/picquery/data/dao/ObjectBoxEmbeddingDao.kt @@ -93,6 +93,21 @@ class ObjectBoxEmbeddingDao(private val embeddingBox: Box) { }.remove() } + // 根据照片ID列表批量删除嵌入向量(用于增量更新) + fun removeByPhotoIds(photoIds: LongArray) { + if (photoIds.isEmpty()) return + embeddingBox.query { + `in`(ObjectBoxEmbedding_.photoId, photoIds) + }.remove() + } + + // 仅查询指定相册下已索引的照片ID列表(高效,不加载向量数据) + fun getPhotoIdsByAlbumId(albumId: Long): List { + return embeddingBox.query { + equal(ObjectBoxEmbedding_.albumId, albumId) + }.property(ObjectBoxEmbedding_.photoId).find() + } + // 批量更新或插入嵌入向量 fun upsertAll(embeddings: List) { embeddingBox.put(embeddings) diff --git a/app/src/main/java/me/grey/picquery/data/data_source/EmbeddingRepository.kt b/app/src/main/java/me/grey/picquery/data/data_source/EmbeddingRepository.kt index 97f7db4..0a7676a 100644 --- a/app/src/main/java/me/grey/picquery/data/data_source/EmbeddingRepository.kt +++ b/app/src/main/java/me/grey/picquery/data/data_source/EmbeddingRepository.kt @@ -91,4 +91,18 @@ class EmbeddingRepository( fun removeByAlbum(album: Album) { return dataSource.removeByAlbumId(album.id) } + + /** + * 根据照片ID列表批量删除嵌入向量(用于增量更新) + */ + fun removeByPhotoIds(photoIds: LongArray) { + return dataSource.removeByPhotoIds(photoIds) + } + + /** + * 仅获取指定相册下已索引的照片ID列表 + */ + fun getPhotoIdsByAlbumId(albumId: Long): List { + return dataSource.getPhotoIdsByAlbumId(albumId) + } } diff --git a/app/src/main/java/me/grey/picquery/data/data_source/ObjectBoxEmbeddingRepository.kt b/app/src/main/java/me/grey/picquery/data/data_source/ObjectBoxEmbeddingRepository.kt index d73b471..7c1230c 100644 --- a/app/src/main/java/me/grey/picquery/data/data_source/ObjectBoxEmbeddingRepository.kt +++ b/app/src/main/java/me/grey/picquery/data/data_source/ObjectBoxEmbeddingRepository.kt @@ -103,6 +103,20 @@ class ObjectBoxEmbeddingRepository( return dataSource.removeByAlbumId(album.id) } + /** + * 根据照片ID列表批量删除嵌入向量(用于增量更新时移除已删除照片的向量) + */ + fun removeByPhotoIds(photoIds: LongArray) { + return dataSource.removeByPhotoIds(photoIds) + } + + /** + * 仅获取指定相册下已索引的照片ID列表(不加载向量数据,高效) + */ + fun getPhotoIdsByAlbumId(albumId: Long): List { + return dataSource.getPhotoIdsByAlbumId(albumId) + } + fun searchByVector(vector: ByteArray): List> { return dataSource.searchNearestVectors(vector.toFloatArray()) } diff --git a/app/src/main/java/me/grey/picquery/domain/AlbumChange.kt b/app/src/main/java/me/grey/picquery/domain/AlbumChange.kt new file mode 100644 index 0000000..b77c9eb --- /dev/null +++ b/app/src/main/java/me/grey/picquery/domain/AlbumChange.kt @@ -0,0 +1,38 @@ +package me.grey.picquery.domain + +import me.grey.picquery.data.model.Album +import me.grey.picquery.data.model.Photo + +/** + * 相册变更检测结果 + * + * 表示一个已索引相册相对于当前 MediaStore 状态的变更: + * - [addedPhotos]: 新增的照片(需要编码并添加向量) + * - [removedPhotoIds]: 已删除的照片 ID(需要移除对应向量) + * - [modifiedPhotos]: 内容已修改的照片(预留字段,v1 暂不实现检测逻辑) + * + * @property album 发生变更的相册 + * @property addedPhotos 当前存在但尚未索引的照片列表 + * @property removedPhotoIds 已索引但当前已从相册中删除的照片 ID + * @property modifiedPhotos 已索引且当前仍存在但内容可能已变更的照片(预留) + */ +data class AlbumChange( + val album: Album, + val addedPhotos: List, + val removedPhotoIds: Set, + val modifiedPhotos: List = emptyList() +) { + /** 是否存在任何变更 */ + val hasChanges: Boolean + get() = addedPhotos.isNotEmpty() || removedPhotoIds.isNotEmpty() || modifiedPhotos.isNotEmpty() + + /** 新增照片数量 */ + val addedCount: Int get() = addedPhotos.size + + /** 删除照片数量 */ + val removedCount: Int get() = removedPhotoIds.size + + /** 变更总数量(用于 UI 显示) */ + val totalChangeCount: Int + get() = addedCount + removedCount + modifiedPhotos.size +} diff --git a/app/src/main/java/me/grey/picquery/domain/AlbumChangeDetector.kt b/app/src/main/java/me/grey/picquery/domain/AlbumChangeDetector.kt new file mode 100644 index 0000000..fd96bba --- /dev/null +++ b/app/src/main/java/me/grey/picquery/domain/AlbumChangeDetector.kt @@ -0,0 +1,99 @@ +package me.grey.picquery.domain + +import me.grey.picquery.data.model.Album +import me.grey.picquery.data.model.Photo + +/** + * 相册变更检测器(纯逻辑,无 Android 依赖) + * + * 通过对比当前 MediaStore 中的照片 ID 集合与已索引的照片 ID 集合, + * 计算出新增和删除的照片列表。 + * + * 设计为纯函数式逻辑,便于单元测试: + * - 输入:当前照片列表 + 已索引照片 ID 集合 + * - 输出:[AlbumChange] 变更描述 + * + * 使用 HashSet 进行差集运算,时间复杂度 O(n),适合大相册(数万张照片)。 + */ +object AlbumChangeDetector { + + /** + * 纯 ID 差分(无任何外部依赖,完全可测试) + * + * @param currentPhotoIds 当前照片 ID 集合 + * @param indexedPhotoIds 已索引照片 ID 集合 + * @return ID 差分结果 + */ + fun diffIds( + currentPhotoIds: Set, + indexedPhotoIds: Set + ): IdDiff { + val addedIds = currentPhotoIds - indexedPhotoIds + val removedIds = indexedPhotoIds - currentPhotoIds + return IdDiff(addedIds, removedIds) + } + + /** + * 检测相册变更 + * + * @param album 目标相册 + * @param currentPhotos 当前 MediaStore 中该相册的所有照片 + * @param indexedPhotoIds 已在向量数据库中索引的照片 ID 集合 + * @return 变更描述 [AlbumChange] + */ + fun detect( + album: Album, + currentPhotos: List, + indexedPhotoIds: Set + ): AlbumChange { + val currentPhotoIds = HashSet(currentPhotos.size) + for (photo in currentPhotos) { + currentPhotoIds.add(photo.id) + } + + val diff = diffIds(currentPhotoIds, indexedPhotoIds) + + // 新增照片:当前存在但未索引 + val addedPhotos = currentPhotos.filter { it.id in diff.addedIds } + + return AlbumChange( + album = album, + addedPhotos = addedPhotos, + removedPhotoIds = diff.removedIds + ) + } + + /** + * 批量检测多个相册的变更 + * + * @param albums 目标相册列表 + * @param currentPhotosByAlbumId 每个相册当前的照片列表(key 为 albumId) + * @param indexedPhotoIdsByAlbumId 每个相册已索引的照片 ID 集合(key 为 albumId) + * @return 每个相册的变更描述列表 + */ + fun detectBatch( + albums: List, + currentPhotosByAlbumId: Map>, + indexedPhotoIdsByAlbumId: Map> + ): List { + return albums.map { album -> + detect( + album = album, + currentPhotos = currentPhotosByAlbumId[album.id] ?: emptyList(), + indexedPhotoIds = indexedPhotoIdsByAlbumId[album.id] ?: emptySet() + ) + } + } +} + +/** + * ID 差分结果(纯数据,无 Android 依赖) + */ +data class IdDiff( + val addedIds: Set, + val removedIds: Set +) { + val hasChanges: Boolean get() = addedIds.isNotEmpty() || removedIds.isNotEmpty() + val addedCount: Int get() = addedIds.size + val removedCount: Int get() = removedIds.size +} diff --git a/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt b/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt index dfcc12e..154f31d 100644 --- a/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt +++ b/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.grey.picquery.PicQueryApplication.Companion.context import me.grey.picquery.R +import me.grey.picquery.common.encodeProgressCallback import me.grey.picquery.common.showToast import me.grey.picquery.data.data_source.AlbumRepository import me.grey.picquery.data.data_source.EmbeddingRepository @@ -35,6 +36,7 @@ class AlbumManager( private val photoRepository: PhotoRepository, private val embeddingRepository: EmbeddingRepository, private val imageSearcher: ImageSearcher, + private val albumUpdateService: AlbumUpdateService, private val ioDispatcher: CoroutineDispatcher ) { companion object { @@ -197,4 +199,114 @@ class AlbumManager( embeddingRepository.removeByAlbum(album) albumRepository.removeSearchableAlbum(album) } + + // ============ 增量更新功能 ============ + + /** + * 检测已索引相册的变更(不执行更新) + * + * @param album 已索引的相册 + * @return 变更描述,包含新增和删除的照片信息 + */ + suspend fun detectAlbumChanges(album: Album): AlbumChange { + return albumUpdateService.detectChanges(album) + } + + /** + * 批量检测多个已索引相册的变更 + * + * @param albums 已索引的相册列表 + * @return 有变更的相册列表 + */ + suspend fun detectAlbumChangesBatch(albums: List): List { + return albums.map { detectAlbumChanges(it) }.filter { it.hasChanges } + } + + /** + * 增量更新单个相册的编码向量 + * + * 流程: + * 1. 检测相册变更(新增/删除照片) + * 2. 删除已移除照片的向量 + * 3. 编码新增照片并存储向量 + * 4. 更新相册元数据 + * + * @param album 已索引的相册 + * @param progressCallback 编码进度回调 + * @return 更新结果 + */ + suspend fun updateAlbum( + album: Album, + progressCallback: encodeProgressCallback? = null + ): AlbumUpdateResult { + if (isEncoderBusy) { + Timber.tag(TAG).w("updateAlbum: encoder is busy, aborting") + return AlbumUpdateResult.Success( + album = album, + addedCount = 0, + removedCount = 0, + allEncoded = false + ) + } + + encodingState.value = EncodingState(status = EncodingState.Status.Loading) + + try { + val result = albumUpdateService.updateAlbum(album, progressCallback) + if (result is AlbumUpdateResult.Success) { + encodingState.value = EncodingState(status = EncodingState.Status.Finish) + } + return result + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to update album: ${album.label}") + encodingState.value = EncodingState(status = EncodingState.Status.Error) + return AlbumUpdateResult.Success( + album = album, + addedCount = 0, + removedCount = 0, + allEncoded = false + ) + } + } + + /** + * 对已检测到的变更执行增量更新 + * + * @param change 变更描述(由 [detectAlbumChanges] 产生) + * @param progressCallback 编码进度回调 + * @return 更新结果 + */ + suspend fun applyAlbumUpdate( + change: AlbumChange, + progressCallback: encodeProgressCallback? = null + ): AlbumUpdateResult { + if (isEncoderBusy) { + Timber.tag(TAG).w("applyAlbumUpdate: encoder is busy, aborting") + return AlbumUpdateResult.Success( + album = change.album, + addedCount = 0, + removedCount = 0, + allEncoded = false + ) + } + + encodingState.value = EncodingState(status = EncodingState.Status.Loading) + + try { + val result = albumUpdateService.applyUpdate(change, progressCallback) + if (result is AlbumUpdateResult.Success) { + encodingState.value = EncodingState(status = EncodingState.Status.Finish) + } + return result + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to apply update for album: ${change.album.label}") + encodingState.value = EncodingState(status = EncodingState.Status.Error) + return AlbumUpdateResult.Success( + album = change.album, + addedCount = 0, + removedCount = 0, + allEncoded = false + ) + } + } } diff --git a/app/src/main/java/me/grey/picquery/domain/AlbumUpdateService.kt b/app/src/main/java/me/grey/picquery/domain/AlbumUpdateService.kt new file mode 100644 index 0000000..ddba38d --- /dev/null +++ b/app/src/main/java/me/grey/picquery/domain/AlbumUpdateService.kt @@ -0,0 +1,192 @@ +package me.grey.picquery.domain + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import me.grey.picquery.common.encodeProgressCallback +import me.grey.picquery.data.data_source.AlbumRepository +import me.grey.picquery.data.data_source.EmbeddingRepository +import me.grey.picquery.data.data_source.ObjectBoxEmbeddingRepository +import me.grey.picquery.data.data_source.PhotoRepository +import me.grey.picquery.data.model.Album +import timber.log.Timber + +/** + * 相册增量更新服务 + * + * 负责编排相册的增量更新流程: + * 1. 检测变更(新增照片、删除照片) + * 2. 对新增照片进行编码并存储向量 + * 3. 删除已移除照片的向量 + * 4. 更新相册元数据(count、timestamp) + * + * 同时维护 ObjectBox(主向量存储)和 Room(相似度计算用)两个数据源的一致性。 + * + * @param photoRepository 照片数据源(MediaStore 查询) + * @param objectBoxEmbeddingRepository ObjectBox 向量仓库 + * @param embeddingRepository Room 向量仓库(相似度计算用) + * @param albumRepository 相册元数据仓库 + * @param embeddingService 编码服务 + * @param dispatcher 协程调度器 + */ +class AlbumUpdateService( + private val photoRepository: PhotoRepository, + private val objectBoxEmbeddingRepository: ObjectBoxEmbeddingRepository, + private val embeddingRepository: EmbeddingRepository, + private val albumRepository: AlbumRepository, + private val embeddingService: EmbeddingService, + private val dispatcher: CoroutineDispatcher +) { + companion object { + private const val TAG = "AlbumUpdateService" + } + + /** + * 检测指定相册的变更(不执行更新) + * + * @param album 已索引的相册 + * @return 变更描述 + */ + suspend fun detectChanges(album: Album): AlbumChange = withContext(dispatcher) { + Timber.tag(TAG).d("Detecting changes for album: ${album.label} (id=${album.id})") + + // 获取当前 MediaStore 中的照片列表 + val currentPhotos = photoRepository.getPhotoListByAlbumId(album.id) + + // 获取已索引的照片 ID 集合(ObjectBox + Room 双查,以 ObjectBox 为准) + val objectBoxIndexedIds = objectBoxEmbeddingRepository + .getPhotoIdsByAlbumId(album.id) + .toSet() + + // 同时检查 Room 中的索引 ID(用于诊断双存储不一致) + val roomIndexedIds = embeddingRepository.getPhotoIdsByAlbumId(album.id).toSet() + if (objectBoxIndexedIds != roomIndexedIds) { + Timber.tag(TAG).w( + "Storage inconsistency detected! ObjectBox: ${objectBoxIndexedIds.size}, " + + "Room: ${roomIndexedIds.size} for album ${album.label}" + ) + } + + val change = AlbumChangeDetector.detect(album, currentPhotos, objectBoxIndexedIds) + Timber.tag(TAG).d( + "Changes for '${album.label}': +${change.addedCount} added, " + + "-${change.removedCount} removed" + ) + change + } + + /** + * 执行增量更新 + * + * @param change 变更描述(由 [detectChanges] 产生) + * @param progressCallback 进度回调 + * @return 更新结果 + */ + suspend fun applyUpdate( + change: AlbumChange, + progressCallback: encodeProgressCallback? = null + ): AlbumUpdateResult = withContext(dispatcher) { + if (!change.hasChanges) { + Timber.tag(TAG).i("No changes to apply for album: ${change.album.label}") + return@withContext AlbumUpdateResult.NoChange + } + + Timber.tag(TAG).i( + "Applying update for '${change.album.label}': " + + "+${change.addedCount} to encode, -${change.removedCount} to remove" + ) + + var encodedCount = 0 + var removedCount = 0 + var encodeSuccess = true + + // 步骤 1:删除已移除照片的向量(先删除,避免残留) + if (change.removedPhotoIds.isNotEmpty()) { + val removedIdsArray = change.removedPhotoIds.toLongArray() + try { + objectBoxEmbeddingRepository.removeByPhotoIds(removedIdsArray) + embeddingRepository.removeByPhotoIds(removedIdsArray) + removedCount = change.removedPhotoIds.size + Timber.tag(TAG).d("Removed $removedCount stale embeddings") + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to remove stale embeddings") + } + } + + // 步骤 2:编码新增照片 + if (change.addedPhotos.isNotEmpty()) { + encodeSuccess = embeddingService.encodePhotoList( + photos = change.addedPhotos, + progressCallback = progressCallback + ) + if (encodeSuccess) { + encodedCount = change.addedPhotos.size + Timber.tag(TAG).d("Encoded $encodedCount new photos") + } else { + Timber.tag(TAG).w("Encoding was already in progress, new photos not encoded") + } + } + + // 步骤 3:更新相册元数据(count + timestamp,使 IndexMgrScreen 不再显示"需要更新") + if (encodeSuccess) { + try { + // 从 MediaStore 获取相册最新状态 + val currentAlbum = albumRepository.getAllAlbums() + .find { it.id == change.album.id } + val updatedAlbum = if (currentAlbum != null) { + change.album.copy( + count = currentAlbum.count, + timestamp = currentAlbum.timestamp, + coverPath = currentAlbum.coverPath + ) + } else { + // 相册可能已被删除,用照片数量作为 count + change.album.copy( + count = photoRepository.getImageCountInAlbum(change.album.id).toLong() + ) + } + albumRepository.addSearchableAlbum(updatedAlbum) + Timber.tag(TAG).d("Updated album metadata: count=${updatedAlbum.count}") + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to update album metadata") + } + } + + AlbumUpdateResult.Success( + album = change.album, + addedCount = encodedCount, + removedCount = removedCount, + allEncoded = encodeSuccess + ) + } + + /** + * 便捷方法:检测变更并立即执行更新 + * + * @param album 已索引的相册 + * @param progressCallback 进度回调 + * @return 更新结果 + */ + suspend fun updateAlbum( + album: Album, + progressCallback: encodeProgressCallback? = null + ): AlbumUpdateResult { + val change = detectChanges(album) + return applyUpdate(change, progressCallback) + } +} + +/** + * 增量更新结果 + */ +sealed class AlbumUpdateResult { + /** 无变更 */ + data object NoChange : AlbumUpdateResult() + + /** 更新成功 */ + data class Success( + val album: Album, + val addedCount: Int, + val removedCount: Int, + val allEncoded: Boolean + ) : AlbumUpdateResult() +} diff --git a/app/src/test/java/me/grey/picquery/domain/AlbumChangeDetectorTest.kt b/app/src/test/java/me/grey/picquery/domain/AlbumChangeDetectorTest.kt new file mode 100644 index 0000000..147812d --- /dev/null +++ b/app/src/test/java/me/grey/picquery/domain/AlbumChangeDetectorTest.kt @@ -0,0 +1,274 @@ +package me.grey.picquery.domain + +import android.net.Uri +import io.mockk.mockk +import me.grey.picquery.data.model.Album +import me.grey.picquery.data.model.Photo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [AlbumChangeDetector] 单元测试 + * + * 测试变更检测的核心差分逻辑,包括纯 ID 差分和带 Photo 对象的完整检测。 + */ +class AlbumChangeDetectorTest { + + private fun mockAlbum(id: Long = 1L, label: String = "Camera", count: Long = 10) = Album( + id = id, + label = label, + coverPath = "/path/to/cover", + timestamp = 1000L, + count = count + ) + + private fun mockPhoto(id: Long, albumId: Long = 1L) = Photo( + id = id, + label = "photo_$id", + uri = mockk(relaxed = true), + path = "/path/to/photo_$id", + timestamp = 1000L + id, + albumID = albumId, + albumLabel = "Camera" + ) + + // ============ diffIds 纯逻辑测试 ============ + + @Test + fun `diffIds returns empty when no changes`() { + val currentIds = setOf(1L, 2L, 3L) + val indexedIds = setOf(1L, 2L, 3L) + + val diff = AlbumChangeDetector.diffIds(currentIds, indexedIds) + + assertTrue(diff.addedIds.isEmpty()) + assertTrue(diff.removedIds.isEmpty()) + assertFalse(diff.hasChanges) + } + + @Test + fun `diffIds detects added photos`() { + val currentIds = setOf(1L, 2L, 3L, 4L, 5L) + val indexedIds = setOf(1L, 2L, 3L) + + val diff = AlbumChangeDetector.diffIds(currentIds, indexedIds) + + assertEquals(setOf(4L, 5L), diff.addedIds) + assertTrue(diff.removedIds.isEmpty()) + assertTrue(diff.hasChanges) + assertEquals(2, diff.addedCount) + } + + @Test + fun `diffIds detects removed photos`() { + val currentIds = setOf(1L, 2L) + val indexedIds = setOf(1L, 2L, 3L, 4L) + + val diff = AlbumChangeDetector.diffIds(currentIds, indexedIds) + + assertTrue(diff.addedIds.isEmpty()) + assertEquals(setOf(3L, 4L), diff.removedIds) + assertTrue(diff.hasChanges) + assertEquals(2, diff.removedCount) + } + + @Test + fun `diffIds detects both added and removed photos`() { + val currentIds = setOf(1L, 2L, 5L, 6L) + val indexedIds = setOf(1L, 2L, 3L, 4L) + + val diff = AlbumChangeDetector.diffIds(currentIds, indexedIds) + + assertEquals(setOf(5L, 6L), diff.addedIds) + assertEquals(setOf(3L, 4L), diff.removedIds) + assertTrue(diff.hasChanges) + assertEquals(4, diff.addedCount + diff.removedCount) + } + + @Test + fun `diffIds handles empty current set`() { + val currentIds = emptySet() + val indexedIds = setOf(1L, 2L, 3L) + + val diff = AlbumChangeDetector.diffIds(currentIds, indexedIds) + + assertTrue(diff.addedIds.isEmpty()) + assertEquals(setOf(1L, 2L, 3L), diff.removedIds) + } + + @Test + fun `diffIds handles empty indexed set`() { + val currentIds = setOf(1L, 2L, 3L) + val indexedIds = emptySet() + + val diff = AlbumChangeDetector.diffIds(currentIds, indexedIds) + + assertEquals(setOf(1L, 2L, 3L), diff.addedIds) + assertTrue(diff.removedIds.isEmpty()) + } + + @Test + fun `diffIds handles both empty sets`() { + val diff = AlbumChangeDetector.diffIds(emptySet(), emptySet()) + + assertFalse(diff.hasChanges) + assertEquals(0, diff.addedCount) + assertEquals(0, diff.removedCount) + } + + @Test + fun `diffIds handles large sets`() { + val currentIds = (1L..10000L).toSet() + val indexedIds = (1L..8000L).toSet() + + val diff = AlbumChangeDetector.diffIds(currentIds, indexedIds) + + assertEquals(2000, diff.addedCount) + assertEquals(0, diff.removedCount) + } + + // ============ detect 完整检测测试 ============ + + @Test + fun `detect returns correct change with added photos`() { + val album = mockAlbum() + val currentPhotos = listOf( + mockPhoto(1), mockPhoto(2), mockPhoto(3), mockPhoto(4) + ) + val indexedIds = setOf(1L, 2L, 3L) + + val change = AlbumChangeDetector.detect(album, currentPhotos, indexedIds) + + assertEquals(album, change.album) + assertEquals(1, change.addedCount) + assertEquals(4L, change.addedPhotos[0].id) + assertTrue(change.removedPhotoIds.isEmpty()) + assertTrue(change.hasChanges) + } + + @Test + fun `detect returns correct change with removed photos`() { + val album = mockAlbum() + val currentPhotos = listOf(mockPhoto(1), mockPhoto(2)) + val indexedIds = setOf(1L, 2L, 3L, 4L) + + val change = AlbumChangeDetector.detect(album, currentPhotos, indexedIds) + + assertTrue(change.addedPhotos.isEmpty()) + assertEquals(setOf(3L, 4L), change.removedPhotoIds) + assertEquals(2, change.removedCount) + assertTrue(change.hasChanges) + } + + @Test + fun `detect returns correct change with both add and remove`() { + val album = mockAlbum() + val currentPhotos = listOf(mockPhoto(1), mockPhoto(2), mockPhoto(5), mockPhoto(6)) + val indexedIds = setOf(1L, 2L, 3L, 4L) + + val change = AlbumChangeDetector.detect(album, currentPhotos, indexedIds) + + assertEquals(2, change.addedCount) + assertEquals(listOf(5L, 6L), change.addedPhotos.map { it.id }) + assertEquals(setOf(3L, 4L), change.removedPhotoIds) + assertEquals(4, change.totalChangeCount) + } + + @Test + fun `detect returns no change when perfectly synced`() { + val album = mockAlbum() + val currentPhotos = listOf(mockPhoto(1), mockPhoto(2), mockPhoto(3)) + val indexedIds = setOf(1L, 2L, 3L) + + val change = AlbumChangeDetector.detect(album, currentPhotos, indexedIds) + + assertFalse(change.hasChanges) + assertEquals(0, change.totalChangeCount) + } + + @Test + fun `detect handles empty album`() { + val album = mockAlbum() + val currentPhotos = emptyList() + val indexedIds = setOf(1L, 2L, 3L) + + val change = AlbumChangeDetector.detect(album, currentPhotos, indexedIds) + + assertTrue(change.addedPhotos.isEmpty()) + assertEquals(setOf(1L, 2L, 3L), change.removedPhotoIds) + assertTrue(change.hasChanges) + } + + @Test + fun `detect handles new album with no prior index`() { + val album = mockAlbum() + val currentPhotos = listOf(mockPhoto(1), mockPhoto(2), mockPhoto(3)) + val indexedIds = emptySet() + + val change = AlbumChangeDetector.detect(album, currentPhotos, indexedIds) + + assertEquals(3, change.addedCount) + assertTrue(change.removedPhotoIds.isEmpty()) + assertEquals(listOf(1L, 2L, 3L), change.addedPhotos.map { it.id }) + } + + // ============ detectBatch 批量检测测试 ============ + + @Test + fun `detectBatch handles multiple albums`() { + val album1 = mockAlbum(id = 1, label = "Camera") + val album2 = mockAlbum(id = 2, label = "Screenshots") + + val currentPhotosByAlbum = mapOf( + 1L to listOf(mockPhoto(1, 1), mockPhoto(2, 1), mockPhoto(3, 1)), + 2L to listOf(mockPhoto(10, 2), mockPhoto(11, 2)) + ) + val indexedIdsByAlbum = mapOf( + 1L to setOf(1L, 2L), + 2L to setOf(10L, 11L, 12L) + ) + + val changes = AlbumChangeDetector.detectBatch( + listOf(album1, album2), + currentPhotosByAlbum, + indexedIdsByAlbum + ) + + assertEquals(2, changes.size) + + // Album 1: added photo 3, no removals + val change1 = changes[0] + assertEquals(1, change1.addedCount) + assertEquals(3L, change1.addedPhotos[0].id) + assertTrue(change1.removedPhotoIds.isEmpty()) + + // Album 2: no additions, removed photo 12 + val change2 = changes[1] + assertTrue(change2.addedPhotos.isEmpty()) + assertEquals(setOf(12L), change2.removedPhotoIds) + } + + @Test + fun `detectBatch handles missing album data gracefully`() { + val album1 = mockAlbum(id = 1) + val album2 = mockAlbum(id = 2) + + // Only provide data for album1 + val currentPhotosByAlbum = mapOf(1L to listOf(mockPhoto(1, 1))) + val indexedIdsByAlbum = mapOf(1L to setOf(1L)) + + val changes = AlbumChangeDetector.detectBatch( + listOf(album1, album2), + currentPhotosByAlbum, + indexedIdsByAlbum + ) + + assertEquals(2, changes.size) + // Album 1: no changes + assertFalse(changes[0].hasChanges) + // Album 2: no data → no indexed IDs, 1 current photo → 1 addition + assertEquals(1, changes[1].addedCount) + } +} diff --git a/app/src/test/java/me/grey/picquery/domain/AlbumUpdateServiceTest.kt b/app/src/test/java/me/grey/picquery/domain/AlbumUpdateServiceTest.kt new file mode 100644 index 0000000..971c8fd --- /dev/null +++ b/app/src/test/java/me/grey/picquery/domain/AlbumUpdateServiceTest.kt @@ -0,0 +1,322 @@ +package me.grey.picquery.domain + +import android.net.Uri +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import me.grey.picquery.data.data_source.AlbumRepository +import me.grey.picquery.data.data_source.EmbeddingRepository +import me.grey.picquery.data.data_source.ObjectBoxEmbeddingRepository +import me.grey.picquery.data.data_source.PhotoRepository +import me.grey.picquery.data.model.Album +import me.grey.picquery.data.model.Photo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * [AlbumUpdateService] 单元测试 + * + * 使用 mockk 模拟所有外部依赖,测试增量更新编排逻辑。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AlbumUpdateServiceTest { + + private lateinit var photoRepository: PhotoRepository + private lateinit var objectBoxRepo: ObjectBoxEmbeddingRepository + private lateinit var roomEmbeddingRepo: EmbeddingRepository + private lateinit var albumRepository: AlbumRepository + private lateinit var embeddingService: EmbeddingService + private lateinit var service: AlbumUpdateService + + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() { + photoRepository = mockk(relaxed = true) + objectBoxRepo = mockk(relaxed = true) + roomEmbeddingRepo = mockk(relaxed = true) + albumRepository = mockk(relaxed = true) + embeddingService = mockk(relaxed = true) + + service = AlbumUpdateService( + photoRepository = photoRepository, + objectBoxEmbeddingRepository = objectBoxRepo, + embeddingRepository = roomEmbeddingRepo, + albumRepository = albumRepository, + embeddingService = embeddingService, + dispatcher = testDispatcher + ) + } + + private fun mockAlbum(id: Long = 1L, label: String = "Camera") = Album( + id = id, + label = label, + coverPath = "/path/to/cover", + timestamp = 1000L, + count = 5 + ) + + private fun mockPhoto(id: Long, albumId: Long = 1L) = Photo( + id = id, + label = "photo_$id", + uri = mockk(relaxed = true), + path = "/path/to/photo_$id", + timestamp = 1000L + id, + albumID = albumId, + albumLabel = "Camera" + ) + + // ============ detectChanges 测试 ============ + + @Test + fun `detectChanges returns correct change for added photos`() = runTest { + val album = mockAlbum() + val currentPhotos = listOf(mockPhoto(1), mockPhoto(2), mockPhoto(3), mockPhoto(4)) + val indexedIds = listOf(1L, 2L, 3L) + + coEvery { photoRepository.getPhotoListByAlbumId(album.id) } returns currentPhotos + every { objectBoxRepo.getPhotoIdsByAlbumId(album.id) } returns indexedIds + every { roomEmbeddingRepo.getPhotoIdsByAlbumId(album.id) } returns indexedIds + + val change = service.detectChanges(album) + + assertEquals(album, change.album) + assertEquals(1, change.addedCount) + assertEquals(4L, change.addedPhotos[0].id) + assertTrue(change.removedPhotoIds.isEmpty()) + } + + @Test + fun `detectChanges returns correct change for removed photos`() = runTest { + val album = mockAlbum() + val currentPhotos = listOf(mockPhoto(1), mockPhoto(2)) + val indexedIds = listOf(1L, 2L, 3L, 4L) + + coEvery { photoRepository.getPhotoListByAlbumId(album.id) } returns currentPhotos + every { objectBoxRepo.getPhotoIdsByAlbumId(album.id) } returns indexedIds + every { roomEmbeddingRepo.getPhotoIdsByAlbumId(album.id) } returns indexedIds + + val change = service.detectChanges(album) + + assertTrue(change.addedPhotos.isEmpty()) + assertEquals(setOf(3L, 4L), change.removedPhotoIds) + } + + @Test + fun `detectChanges returns no change when synced`() = runTest { + val album = mockAlbum() + val currentPhotos = listOf(mockPhoto(1), mockPhoto(2), mockPhoto(3)) + val indexedIds = listOf(1L, 2L, 3L) + + coEvery { photoRepository.getPhotoListByAlbumId(album.id) } returns currentPhotos + every { objectBoxRepo.getPhotoIdsByAlbumId(album.id) } returns indexedIds + every { roomEmbeddingRepo.getPhotoIdsByAlbumId(album.id) } returns indexedIds + + val change = service.detectChanges(album) + + assertFalse(change.hasChanges) + } + + // ============ applyUpdate 测试 ============ + + @Test + fun `applyUpdate returns NoChange when no changes`() = runTest { + val album = mockAlbum() + val change = AlbumChange(album, addedPhotos = emptyList(), removedPhotoIds = emptySet()) + + val result = service.applyUpdate(change) + + assertTrue(result is AlbumUpdateResult.NoChange) + // Should not call any encoding or deletion + coVerify(exactly = 0) { embeddingService.encodePhotoList(any(), any()) } + verify(exactly = 0) { objectBoxRepo.removeByPhotoIds(any()) } + } + + @Test + fun `applyUpdate encodes new photos and does not delete when only additions`() = runTest { + val album = mockAlbum() + val addedPhotos = listOf(mockPhoto(4), mockPhoto(5)) + val change = AlbumChange(album, addedPhotos = addedPhotos, removedPhotoIds = emptySet()) + + coEvery { embeddingService.encodePhotoList(any(), any()) } returns true + every { photoRepository.getImageCountInAlbum(album.id) } returns 5 + + val result = service.applyUpdate(change) + + assertTrue(result is AlbumUpdateResult.Success) + val success = result as AlbumUpdateResult.Success + assertEquals(2, success.addedCount) + assertEquals(0, success.removedCount) + assertTrue(success.allEncoded) + + // Verify encode was called with added photos + coVerify { embeddingService.encodePhotoList(addedPhotos, any()) } + // Verify no deletion happened + verify(exactly = 0) { objectBoxRepo.removeByPhotoIds(any()) } + verify(exactly = 0) { roomEmbeddingRepo.removeByPhotoIds(any()) } + // Verify album metadata was updated + verify { albumRepository.addSearchableAlbum(any()) } + } + + @Test + fun `applyUpdate deletes stale embeddings and does not encode when only removals`() = runTest { + val album = mockAlbum() + val removedIds = setOf(3L, 4L, 5L) + val change = AlbumChange(album, addedPhotos = emptyList(), removedPhotoIds = removedIds) + + every { photoRepository.getImageCountInAlbum(album.id) } returns 2 + + val result = service.applyUpdate(change) + + assertTrue(result is AlbumUpdateResult.Success) + val success = result as AlbumUpdateResult.Success + assertEquals(0, success.addedCount) + assertEquals(3, success.removedCount) + assertTrue(success.allEncoded) + + // Verify deletion was called on both repos with matching content + verify { + objectBoxRepo.removeByPhotoIds(match { it.contentEquals(removedIds.toLongArray()) }) + } + verify { + roomEmbeddingRepo.removeByPhotoIds(match { it.contentEquals(removedIds.toLongArray()) }) + } + // Verify no encoding happened + coVerify(exactly = 0) { embeddingService.encodePhotoList(any(), any()) } + // Verify album metadata was updated + verify { albumRepository.addSearchableAlbum(any()) } + } + + @Test + fun `applyUpdate handles both additions and removals`() = runTest { + val album = mockAlbum() + val addedPhotos = listOf(mockPhoto(5), mockPhoto(6)) + val removedIds = setOf(3L, 4L) + val change = AlbumChange(album, addedPhotos = addedPhotos, removedPhotoIds = removedIds) + + coEvery { embeddingService.encodePhotoList(any(), any()) } returns true + every { photoRepository.getImageCountInAlbum(album.id) } returns 4 + + val result = service.applyUpdate(change) + + assertTrue(result is AlbumUpdateResult.Success) + val success = result as AlbumUpdateResult.Success + assertEquals(2, success.addedCount) + assertEquals(2, success.removedCount) + assertTrue(success.allEncoded) + + // Verify both encoding and deletion happened + coVerify { embeddingService.encodePhotoList(addedPhotos, any()) } + verify { + objectBoxRepo.removeByPhotoIds(match { it.contentEquals(removedIds.toLongArray()) }) + } + verify { + roomEmbeddingRepo.removeByPhotoIds(match { it.contentEquals(removedIds.toLongArray()) }) + } + } + + @Test + fun `applyUpdate handles encoding failure gracefully`() = runTest { + val album = mockAlbum() + val addedPhotos = listOf(mockPhoto(5)) + val change = AlbumChange(album, addedPhotos = addedPhotos, removedPhotoIds = emptySet()) + + // Simulate encoding failure (encoder busy) + coEvery { embeddingService.encodePhotoList(any(), any()) } returns false + + val result = service.applyUpdate(change) + + assertTrue(result is AlbumUpdateResult.Success) + val success = result as AlbumUpdateResult.Success + assertEquals(0, success.addedCount) + assertFalse(success.allEncoded) + + // Album metadata should NOT be updated when encoding fails + verify(exactly = 0) { albumRepository.addSearchableAlbum(any()) } + } + + @Test + fun `applyUpdate continues with removals even if encoding fails`() = runTest { + val album = mockAlbum() + val addedPhotos = listOf(mockPhoto(5)) + val removedIds = setOf(3L, 4L) + val change = AlbumChange(album, addedPhotos = addedPhotos, removedPhotoIds = removedIds) + + coEvery { embeddingService.encodePhotoList(any(), any()) } returns false + + val result = service.applyUpdate(change) + + assertTrue(result is AlbumUpdateResult.Success) + val success = result as AlbumUpdateResult.Success + assertEquals(0, success.addedCount) + assertEquals(2, success.removedCount) // Removals still happened + assertFalse(success.allEncoded) + + // Verify deletion still happened + verify { + objectBoxRepo.removeByPhotoIds(match { it.contentEquals(removedIds.toLongArray()) }) + } + verify { + roomEmbeddingRepo.removeByPhotoIds(match { it.contentEquals(removedIds.toLongArray()) }) + } + } + + // ============ updateAlbum (便捷方法) 测试 ============ + + @Test + fun `updateAlbum detects and applies changes end-to-end`() = runTest { + val album = mockAlbum() + val currentPhotos = listOf(mockPhoto(1), mockPhoto(2), mockPhoto(5)) + val indexedIds = listOf(1L, 2L, 3L) // 3 removed, 5 added + + coEvery { photoRepository.getPhotoListByAlbumId(album.id) } returns currentPhotos + every { objectBoxRepo.getPhotoIdsByAlbumId(album.id) } returns indexedIds + every { roomEmbeddingRepo.getPhotoIdsByAlbumId(album.id) } returns indexedIds + coEvery { embeddingService.encodePhotoList(any(), any()) } returns true + every { photoRepository.getImageCountInAlbum(album.id) } returns 3 + + val result = service.updateAlbum(album) + + assertTrue(result is AlbumUpdateResult.Success) + val success = result as AlbumUpdateResult.Success + assertEquals(1, success.addedCount) + assertEquals(1, success.removedCount) + + // Verify remove was called for photo 3 + verify { + objectBoxRepo.removeByPhotoIds(match { it.contentEquals(longArrayOf(3L)) }) + } + // Verify encode was called with photo 5 + coVerify { + embeddingService.encodePhotoList( + match { photos -> photos.size == 1 && photos[0].id == 5L }, + any() + ) + } + } + + @Test + fun `updateAlbum returns NoChange when album is already synced`() = runTest { + val album = mockAlbum() + val currentPhotos = listOf(mockPhoto(1), mockPhoto(2), mockPhoto(3)) + val indexedIds = listOf(1L, 2L, 3L) + + coEvery { photoRepository.getPhotoListByAlbumId(album.id) } returns currentPhotos + every { objectBoxRepo.getPhotoIdsByAlbumId(album.id) } returns indexedIds + every { roomEmbeddingRepo.getPhotoIdsByAlbumId(album.id) } returns indexedIds + + val result = service.updateAlbum(album) + + assertTrue(result is AlbumUpdateResult.NoChange) + coVerify(exactly = 0) { embeddingService.encodePhotoList(any(), any()) } + verify(exactly = 0) { objectBoxRepo.removeByPhotoIds(any()) } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f675755..0aef412 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,6 +71,8 @@ mlkit-translate = "17.0.3" junit = "4.13.2" espresso = "3.4.0" googleOssLicensesPlugin = "0.10.10" +mockk = "1.13.16" +coroutines-test = "1.10.2" [libraries] @@ -153,6 +155,8 @@ mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref # Testing junit = { group = "junit", name = "junit", version.ref = "junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines-test" } # SplashScreen androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidx-splashscreen" } diff --git "a/meta/\351\241\271\347\233\256\345\212\237\350\203\275\350\257\246\350\247\243.md" "b/meta/\351\241\271\347\233\256\345\212\237\350\203\275\350\257\246\350\247\243.md" new file mode 100644 index 0000000..6df0eef --- /dev/null +++ "b/meta/\351\241\271\347\233\256\345\212\237\350\203\275\350\257\246\350\247\243.md" @@ -0,0 +1,504 @@ +# PicQuery 项目功能详解 + +## 1. 项目概述 + +PicQuery 是一款 Android 平台的智能图片搜索应用,使用自然语言搜索本地图片,完全离线运行,保护用户隐私。用户可以通过文本描述(如"书桌上的笔记本电脑"、"海边日落"、"草丛中的小猫")或选择相册中的图片来搜索相似的照片。 + +### 核心特性 + +- **完全免费**:无内购,开源项目 +- **双语支持**:支持中英文搜索 +- **隐私保护**:图片索引和搜索全程离线运行 +- **高性能**:8000+照片搜索1秒内出结果 +- **智能搜索**: + - 文本搜索:通过自然语言描述查找图片 + - 图片搜索:通过选择图片查找相似照片 + - 相似图片分组:自动识别并分组相似图片 + +## 2. 技术栈 + +### 2.1 核心技术 + +- **AI 模型**: + - OpenAI CLIP 模型 + - Apple MobileCLIP 模型 +- **推理引擎**:ONNX Runtime +- **数据库**: + - ObjectBox(向量存储,支持 HNSW 索引) + - Room(SQLite ORM) +- **UI 框架**:Jetpack Compose(Material 3) +- **架构模式**:MVVM + Clean Architecture +- **依赖注入**:Koin +- **异步处理**:Kotlin Coroutines + Flow + +### 2.2 开发工具 + +- **语言**:Kotlin +- **构建系统**:Gradle +- **代码质量**:Detekt +- **日志系统**:Timber + +## 3. 架构设计 + +### 3.1 架构层次 + +项目采用分层架构设计,遵循 Clean Architecture 原则: + +``` +┌─────────────────────────────────────────┐ +│ UI Layer (Compose) │ +│ - Screen(SearchScreen, HomeScreen) │ +│ - ViewModel(SearchViewModel) │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Domain Layer │ +│ - ImageSearcher(搜索入口) │ +│ - SearchOrchestrator(搜索协调) │ +│ - EmbeddingService(编码服务) │ +│ - AlbumManager(相册管理) │ +│ - SimilarityManager(相似度管理) │ +│ - MLKitTranslator(翻译服务) │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Feature Layer │ +│ - ImageEncoder(图像编码器) │ +│ - TextEncoder(文本编码器) │ +│ - Preprocessor(预处理器) │ +│ - CLIP / MobileCLIP 实现 │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Data Layer │ +│ - Repository(数据仓库) │ +│ - ObjectBoxEmbeddingRepository │ +│ - AlbumRepository │ +│ - PhotoRepository │ +│ - DAO(数据访问对象) │ +│ - ObjectBoxEmbeddingDao │ +│ - Model(数据模型) │ +│ - Photo, Album, ObjectBoxEmbedding │ +└─────────────────────────────────────────┘ +``` + +### 3.2 依赖注入配置 + +项目使用 Koin 进行依赖注入,模块化组织: + +```kotlin +val AppModules = listOf( + dispatchersKoinModule, // 协程调度器 + viewModelModules, // ViewModel + dataModules, // 数据层 + modulesCLIP, // CLIP 模型模块(可替换为 modulesMobileCLIP) + domainModules, // 领域层 + workManagerModule // WorkManager +) +``` + +## 4. 核心功能流程 + +### 4.1 图片索引流程 + +图片索引是将手机相册中的图片编码为向量并存储的过程: + +``` +1. 用户选择相册 + ↓ +2. AlbumManager 获取相册中的图片列表 + ↓ +3. EmbeddingService 批量编码图片 + - 加载图片缩略图 + - 预处理图片(resize, normalize) + - 使用 ImageEncoder 编码为向量 + ↓ +4. 存储向量到 ObjectBox 数据库 + - photoId:图片唯一标识 + - albumId:相册标识 + - data:向量数据(FloatArray, 512维) + - HNSW 索引自动构建 + ↓ +5. 更新可搜索相册列表 +``` + +**关键代码**: +- [AlbumManager.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt#L140-L190) - 编码相册 +- [EmbeddingService.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/EmbeddingService.kt#L93-L134) - 批量编码图片 +- [ObjectBoxEmbedding.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/data/model/ObjectBoxEmbedding.kt#L21-L25) - 向量存储模型 + +### 4.2 文本搜索流程 + +文本搜索是用户输入文本描述查找相似图片的过程: + +``` +1. 用户输入搜索文本(中文或英文) + ↓ +2. MLKitTranslator 翻译文本 + - 中文 → 英文(如果需要) + - 翻译失败则使用原文 + ↓ +3. TextEncoder 编码文本为向量 + - 使用 CLIP/MobileCLIP 文本编码器 + - 输出 512 维向量 + ↓ +4. SearchOrchestrator 执行向量搜索 + - 使用 ObjectBox HNSW 索引 + - 查找最相似的 topK 张图片 + - 应用相似度阈值过滤 + ↓ +5. 返回搜索结果列表 + - 每张图片包含相似度得分 + - 按相似度降序排列 +``` + +**关键代码**: +- [SearchOrchestrator.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/SearchOrchestrator.kt#L47-L58) - 文本搜索协调 +- [MLKitTranslator.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/MLKitTranslator.kt#L45-L61) - 翻译服务 +- [ObjectBoxEmbeddingRepository.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/data/data_source/ObjectBoxEmbeddingRepository.kt#L110-L123) - 向量搜索 + +### 4.3 图片搜索流程 + +图片搜索是用户选择一张图片查找相似图片的过程: + +``` +1. 用户选择一张图片 + ↓ +2. EmbeddingService 编码图片为向量 + - 加载图片 Bitmap + - 预处理图片 + - 使用 ImageEncoder 编码 + ↓ +3. SearchOrchestrator 执行向量搜索 + - 与文本搜索相同的流程 + ↓ +4. 返回相似图片列表 +``` + +**关键代码**: +- [SearchOrchestrator.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/SearchOrchestrator.kt#L68-L88) - 图片搜索协调 +- [EmbeddingService.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/EmbeddingService.kt#L79-L83) - 编码单张图片 + +### 4.4 相似图片分组流程 + +自动识别和分组相似的图片: + +``` +1. 查询所有图片的相似度数据 + ↓ +2. GroupSimilarPhotosUseCase 分组相似图片 + - 使用相似度阈值(默认 0.96) + - 使用相似度变化范围(delta) + - 使用并查集(Union-Find)算法 + ↓ +3. 返回相似图片组列表 + - 每组包含至少 2 张相似图片 +``` + +**关键代码**: +- [SimilarityManager.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/SimilarityManager.kt#L26-L141) - 相似度管理 +- [UnionFind.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/UnionFind.kt) - 并查集算法 + +## 5. 多模型架构 + +### 5.1 模型选择机制 + +项目支持两种 AI 模型,通过依赖注入切换: + +```kotlin +// 使用 CLIP 模型 +val AppModules = listOf( + viewModelModules, + dataModules, + modulesCLIP, // ← CLIP 模型模块 + domainModules +) + +// 使用 MobileCLIP 模型 +val AppModules = listOf( + viewModelModules, + dataModules, + modulesMobileCLIP, // ← MobileCLIP 模型模块 + domainModules +) +``` + +### 5.2 编码器接口 + +定义统一的编码器接口,支持不同模型实现: + +```kotlin +// 图像编码器接口 +fun interface ImageEncoder { + suspend fun encodeBatch(bitmaps: List): List +} + +// 文本编码器接口 +fun interface TextEncoder { + fun encode(input: String): FloatArray +} +``` + +### 5.3 CLIP 模型实现 + +使用 OpenAI CLIP 模型: + +- **模型文件**: + - `clip-image-int8.ort`:图像编码器(INT8 量化) + - `clip-text-int8.ort`:文本编码器(INT8 量化) +- **向量维度**:512 维 +- **特点**:通用性强,效果稳定 + +**关键文件**: +- [ModuleCLIP.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/feature/clip/ModuleCLIP.kt) +- [ImageEncoderCLIP.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/feature/clip/ImageEncoderCLIP.kt) +- [TextEncoderCLIP.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/feature/clip/TextEncoderCLIP.kt) + +### 5.4 MobileCLIP 模型实现 + +使用 Apple MobileCLIP 模型: + +- **模型文件**: + - `vision_model.ort`:图像编码器 + - `text_model.ort`:文本编码器 +- **向量维度**:512 维 +- **特点**:专为移动设备优化,速度更快 + +**关键文件**: +- [ModulesMobileCLIP.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/feature/mobileclip/ModulesMobileCLIP.kt) +- [ImageEncoderMobileCLIP.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/feature/mobileclip/ImageEncoderMobileCLIP.kt) +- [TextEncoderMobileCLIP.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/feature/mobileclip/TextEncoderMobileCLIP.kt) + +### 5.5 MobileCLIP v2 模型实现(可选) + +项目还包含 MobileCLIP v2 模型变体,提供另一个优化选项: + +- **特点**:MobileCLIP 的改进版本,可能提供更好的性能或精度 +- **模块名称**:`modulesMobileCLIP2` + +**关键文件**: +- [ModulesMobileCLIPv2.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/feature/mobileclip2/ModulesMobileCLIPv2.kt) +- [ImageEncoderMobileCLIPv2.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/feature/mobileclip2/ImageEncoderMobileCLIPv2.kt) +- [TextEncoderMobileCLIPv2.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/feature/mobileclip2/TextEncoderMobileCLIPv2.kt) + +## 6. 翻译管道 + +### 6.1 翻译流程设计 + +使用 Google ML Kit 进行文本翻译: + +``` +输入文本(中文) + ↓ +检测语言(可选) + ↓ +翻译:中文 → 英文 + ↓ +成功:返回英文文本 +失败:返回原始文本 +``` + +### 6.2 翻译实现 + +```kotlin +class MLKitTranslator { + private val options = TranslatorOptions.Builder() + .setSourceLanguage(TranslateLanguage.CHINESE) + .setTargetLanguage(TranslateLanguage.ENGLISH) + .build() + + suspend fun translate( + text: String, + onSuccess: (String) -> Unit, + onError: (Exception) -> Unit + ): Task { + return englishChineseTranslator.translate(text) + .addOnSuccessListener { translatedText -> + onSuccess(translatedText) + } + .addOnFailureListener { exception -> + onError(exception) + } + } +} +``` + +**关键文件**: +- [MLKitTranslator.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/MLKitTranslator.kt#L30-L61) + +### 6.3 翻译模型管理 + +- 翻译模型内置在应用中(离线运行) +- 模型路径:`assets/mlkit/com.google.mlkit.translate.models/` +- 首次使用时从 assets 复制到应用数据目录 + +## 7. 数据存储方案 + +### 7.1 ObjectBox 向量数据库 + +使用 ObjectBox 存储图片向量: + +```kotlin +@Entity +data class ObjectBoxEmbedding( + @Id var id: Long = 0, + @Index val photoId: Long, // 图片 ID + @Index val albumId: Long, // 相册 ID + @HnswIndex( + dimensions = 512, + distanceType = VectorDistanceType.COSINE + ) + val data: FloatArray // 向量数据 +) +``` + +**优势**: +- 内置 HNSW 索引:高效的向量搜索 +- 支持向量距离计算:余弦相似度 +- 快速查询:毫秒级响应 + +### 7.2 Room SQLite 数据库 + +使用 Room 存储相册和相似度信息: + +```kotlin +@Entity(tableName = "searchable_album") +data class Album( + @PrimaryKey val id: Long, + val label: String, // 相册名称 + var coverPath: String, // 封面路径 + var timestamp: Long, // 时间戳 + var count: Long // 图片数量 +) +``` + +### 7.3 数据仓库模式 + +使用 Repository 模式统一数据访问: + +- **ObjectBoxEmbeddingRepository**:向量数据仓库 +- **AlbumRepository**:相册数据仓库 +- **PhotoRepository**:图片数据仓库 +- **PreferenceRepository**:用户偏好仓库 + +## 8. UI 界面 + +### 8.1 主要界面 + +- **HomeScreen**:首页,显示相册列表和索引状态 +- **SearchScreen**:搜索界面,输入文本或选择图片 +- **DisplayScreen**:搜索结果展示界面 +- **PhotoDetailScreen**:图片详情界面 +- **SimilarPhotosScreen**:相似图片分组界面 +- **SettingScreen**:设置界面 + +### 8.2 Material 3 设计 + +使用 Material 3 设计系统: + +- 动态颜色主题 +- 现代化 UI 组件 +- 流畅的动画效果 + +**关键文件**: +- [ThemeM3.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/theme/ThemeM3.kt) + +## 9. 性能优化 + +### 9.1 批量处理优化 + +图片索引使用批量处理: + +```kotlin +photos.asFlow() + .map { loadAndPreprocessPhoto(it) } + .filterNotNull() + .buffer(1000) // 缓冲区大小 + .chunked(100) // 分块处理 + .collect { batch -> + saveBatchToEmbedding(batch) + } +``` + +### 9.2 向量索引优化 + +使用 HNSW 索引加速向量搜索: + +- 空间效率:适合大规模向量数据 +- 时间效率:近似最近邻搜索 +- 可配置参数:调整精度和速度平衡 + +### 9.3 内存管理 + +- 使用缩略图:避免加载原始大图 +- 批量编码:减少内存峰值 +- 流式处理:避免一次性加载所有数据 + +## 10. 项目特色 + +### 10.1 完全离线运行 + +- **AI 模型本地运行**:ONNX Runtime +- **翻译本地运行**:ML Kit 离线翻译 +- **数据本地存储**:ObjectBox + Room +- **隐私保护**:无需上传数据 + +### 10.2 模型可插拔 + +- 统一的编码器接口 +- 通过依赖注入切换模型 +- 支持 CLIP、MobileCLIP 和 MobileCLIP v2 +- 易于扩展新模型 +- 所有编码器基于 ONNX Runtime 实现 + +### 10.3 架构清晰 + +- 分层架构:职责明确 +- 依赖注入:模块化设计 +- 协程流式处理:异步优化 +- Repository 模式:数据访问统一 + +## 11. 关键文件索引 + +### 11.1 领域层(Domain Layer) + +- [ImageSearcher.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/ImageSearcher.kt) - 搜索入口 +- [SearchOrchestrator.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/SearchOrchestrator.kt) - 搜索协调 +- [EmbeddingService.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/EmbeddingService.kt) - 编码服务 +- [AlbumManager.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt) - 相册管理 +- [SimilarityManager.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/SimilarityManager.kt) - 相似度管理 +- [MLKitTranslator.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/domain/MLKitTranslator.kt) - 翻译服务 + +### 11.2 特征层(Feature Layer) + +- [ModuleCLIP.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/feature/clip/ModuleCLIP.kt) - CLIP 模型模块 +- [ModulesMobileCLIP.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/feature/mobileclip/ModulesMobileCLIP.kt) - MobileCLIP 模型模块 +- [ImageEncoder.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/feature/base/ImageEncoder.kt) - 图像编码器接口 +- [TextEncoder.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/feature/base/TextEncoder.kt) - 文本编码器接口 + +### 11.3 数据层(Data Layer) + +- [ObjectBoxEmbeddingRepository.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/data/data_source/ObjectBoxEmbeddingRepository.kt) - 向量数据仓库 +- [ObjectBoxEmbedding.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/data/model/ObjectBoxEmbedding.kt) - 向量数据模型 +- [Album.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/data/model/Album.kt) - 相册数据模型 +- [Photo.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/data/model/Photo.kt) - 图片数据模型 + +### 11.4 UI 层 + +- [SearchScreen.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/ui/search/SearchScreen.kt) - 搜索界面 +- [HomeScreen.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/ui/home/HomeScreen.kt) - 首页界面 +- [AppModules.kt](file:///d:/data/script/code/picquery/PicQuery/app/src/main/java/me/grey/picquery/common/AppModules.kt) - 依赖注入配置 + +## 12. 总结 + +PicQuery 是一个功能完整、架构清晰的 Android 智能图片搜索应用。它通过 CLIP/MobileCLIP 模型实现了高质量的文本-图片检索能力,支持完全离线运行,保护用户隐私。项目采用现代 Android 开发最佳实践,包括 Jetpack Compose、Kotlin Coroutines、Clean Architecture 和依赖注入,代码结构清晰,易于理解和扩展。 + +核心技术亮点: +1. **AI 模型本地运行**:ONNX Runtime 推理,无需服务器 +2. **高效向量搜索**:ObjectBox HNSW 索引,毫秒级响应 +3. **多模型支持**:CLIP、MobileCLIP 和 MobileCLIP v2 可切换 +4. **双语搜索**:ML Kit 离线翻译,支持中英文 +5. **相似图片分组**:并查集算法,自动识别相似图片 +6. **现代架构**:分层设计,依赖注入,流式处理 + +该项目展示了如何在移动设备上实现高质量的 AI 应用,平衡了性能、隐私和用户体验,是学习移动 AI 应用开发的优秀案例。 \ No newline at end of file