Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ android {
}
}
buildToolsVersion = "34.0.0"

testOptions {
unitTests.isReturnDefaultValues = true
}
}

kotlin {
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/me/grey/picquery/common/AppModules.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 11 additions & 1 deletion app/src/main/java/me/grey/picquery/data/dao/EmbeddingDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ interface EmbeddingDao {
fun getAllByPhotoIds(photoIds: LongArray): List<Embedding>

@Query(
"SELECT * FROM $tableName WHERE album_id IS (:albumId)"
"SELECT * FROM $tableName WHERE album_id = :albumId"
)
fun getAllByAlbumId(albumId: Long): List<Embedding>

Expand All @@ -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<Long>

@Upsert
fun upsertAll(embeddings: List<Embedding>)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,21 @@ class ObjectBoxEmbeddingDao(private val embeddingBox: Box<ObjectBoxEmbedding>) {
}.remove()
}

// 根据照片ID列表批量删除嵌入向量(用于增量更新)
fun removeByPhotoIds(photoIds: LongArray) {
if (photoIds.isEmpty()) return
embeddingBox.query {
`in`(ObjectBoxEmbedding_.photoId, photoIds)
}.remove()
}

// 仅查询指定相册下已索引的照片ID列表(高效,不加载向量数据)
fun getPhotoIdsByAlbumId(albumId: Long): List<Long> {
return embeddingBox.query {
equal(ObjectBoxEmbedding_.albumId, albumId)
}.property(ObjectBoxEmbedding_.photoId).find()
}

// 批量更新或插入嵌入向量
fun upsertAll(embeddings: List<ObjectBoxEmbedding>) {
embeddingBox.put(embeddings)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long> {
return dataSource.getPhotoIdsByAlbumId(albumId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long> {
return dataSource.getPhotoIdsByAlbumId(albumId)
}

fun searchByVector(vector: ByteArray): List<ObjectWithScore<ObjectBoxEmbedding>> {
return dataSource.searchNearestVectors(vector.toFloatArray())
}
Expand Down
38 changes: 38 additions & 0 deletions app/src/main/java/me/grey/picquery/domain/AlbumChange.kt
Original file line number Diff line number Diff line change
@@ -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<Photo>,
val removedPhotoIds: Set<Long>,
val modifiedPhotos: List<Photo> = 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
}
99 changes: 99 additions & 0 deletions app/src/main/java/me/grey/picquery/domain/AlbumChangeDetector.kt
Original file line number Diff line number Diff line change
@@ -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<Long>,
indexedPhotoIds: Set<Long>
): 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<Photo>,
indexedPhotoIds: Set<Long>
): AlbumChange {
val currentPhotoIds = HashSet<Long>(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<Album>,
currentPhotosByAlbumId: Map<Long, List<Photo>>,
indexedPhotoIdsByAlbumId: Map<Long, Set<Long>>
): List<AlbumChange> {
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<Long>,
val removedIds: Set<Long>
) {
val hasChanges: Boolean get() = addedIds.isNotEmpty() || removedIds.isNotEmpty()
val addedCount: Int get() = addedIds.size
val removedCount: Int get() = removedIds.size
}
Loading