diff --git a/.vscode/settings.json b/.vscode/settings.json index c5f3f6b..0f96879 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "java.configuration.updateBuildConfiguration": "interactive" + "java.configuration.updateBuildConfiguration": "interactive", + "git.ignoreLimitWarning": true } \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e582167..9cb14bd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.serialization) id("io.objectbox") @@ -46,16 +47,11 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() - } + packaging { resources { excludes.add("/META-INF/{AL2.0,LGPL2.1}") @@ -64,6 +60,12 @@ android { buildToolsVersion = "34.0.0" } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + dependencies { // Bill of Materials val composeBom = platform(libs.compose.bom) @@ -119,6 +121,8 @@ dependencies { // Image Loading implementation(libs.glide) implementation(libs.glide.compose) + implementation(libs.coil) + implementation(libs.coil.compose) // Other Libraries implementation(libs.zoomable) diff --git a/app/objectbox-models/default.json.bak b/app/objectbox-models/default.json.bak new file mode 100644 index 0000000..10be56c --- /dev/null +++ b/app/objectbox-models/default.json.bak @@ -0,0 +1,53 @@ +{ + "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.", + "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.", + "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", + "entities": [ + { + "id": "1:6256215631497182449", + "lastPropertyId": "4:8708513969305822242", + "name": "ObjectBoxEmbedding", + "properties": [ + { + "id": "1:6545944855726584292", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:2952332731167717730", + "name": "photoId", + "indexId": "1:2288798603681999270", + "type": 6, + "flags": 8 + }, + { + "id": "3:5902302075074261439", + "name": "albumId", + "indexId": "2:2918075261928380804", + "type": 6, + "flags": 8 + }, + { + "id": "4:8708513969305822242", + "name": "data", + "indexId": "3:7623933702471750709", + "type": 28, + "flags": 8 + } + ], + "relations": [] + } + ], + "lastEntityId": "1:6256215631497182449", + "lastIndexId": "3:7623933702471750709", + "lastRelationId": "0:0", + "lastSequenceId": "0:0", + "modelVersion": 5, + "modelVersionParserMinimum": 5, + "retiredEntityUids": [], + "retiredIndexUids": [], + "retiredPropertyUids": [], + "retiredRelationUids": [], + "version": 1 +} \ No newline at end of file diff --git a/app/src/main/java/me/grey/picquery/PicQueryApplication.kt b/app/src/main/java/me/grey/picquery/PicQueryApplication.kt index 8d97140..ffdbe62 100644 --- a/app/src/main/java/me/grey/picquery/PicQueryApplication.kt +++ b/app/src/main/java/me/grey/picquery/PicQueryApplication.kt @@ -1,6 +1,5 @@ package me.grey.picquery -import android.annotation.SuppressLint import android.app.Application import android.content.Context import me.grey.picquery.common.AppModules @@ -13,14 +12,20 @@ import timber.log.Timber class PicQueryApplication : Application() { companion object { - @SuppressLint("StaticFieldLeak") - lateinit var context: Context + private lateinit var appContext: Context private const val TAG = "PicQueryApp" + + /** + * 获取 Application Context,避免内存泄漏 + * 总是返回 applicationContext 而不是直接持有引用 + */ + val context: Context + get() = appContext.applicationContext } override fun onCreate() { super.onCreate() - context = applicationContext + appContext = this if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } 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 6674924..75c0144 100644 --- a/app/src/main/java/me/grey/picquery/common/AppModules.kt +++ b/app/src/main/java/me/grey/picquery/common/AppModules.kt @@ -10,10 +10,15 @@ 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.EmbeddingService import me.grey.picquery.domain.ImageSearcher import me.grey.picquery.domain.MLKitTranslator +import me.grey.picquery.domain.SearchConfigurationService +import me.grey.picquery.domain.SearchOrchestrator +import me.grey.picquery.domain.SimilarityConfigurationService import me.grey.picquery.domain.SimilarityManager import me.grey.picquery.feature.clip.modulesCLIP +import me.grey.picquery.feature.mobileclip2.modulesMobileCLIP2 import me.grey.picquery.ui.display.DisplayViewModel import me.grey.picquery.ui.home.HomeViewModel import me.grey.picquery.ui.photoDetail.PhotoDetailViewModel @@ -27,7 +32,8 @@ import org.koin.dsl.module private val viewModelModules = module { viewModel { HomeViewModel( - imageSearcher = get() + imageSearcher = get(), + preferenceRepository = get() ) } viewModel { @@ -65,18 +71,58 @@ private val dataModules = module { } private val domainModules = module { + // Translation service + single { MLKitTranslator() } + + // Encoding service - Handles image and text encoding single { - ImageSearcher( + EmbeddingService( + context = androidContext(), imageEncoder = get(), textEncoder = get(), embeddingRepository = get(), objectBoxEmbeddingRepository = get(), - translator = MLKitTranslator(), - dispatcher = get(), + dispatcher = get() + ) + } + + // Search configuration service - Manages search configuration + single { + SearchConfigurationService( preferenceRepository = get(), - scope = get(), + scope = get() + ) + } + + // Search orchestrator - Coordinates search operations + single { + SearchOrchestrator( + embeddingService = get(), + configurationService = get(), + objectBoxEmbeddingRepository = get(), + translator = get(), + dispatcher = get(), + scope = get() ) } + + // Image searcher - External interface for search functionality + single { + ImageSearcher( + embeddingService = get(), + configurationService = get(), + searchOrchestrator = get() + ) + } + + // Similarity configuration service - Manages similarity grouping configuration + single { + SimilarityConfigurationService( + scope = get() + ) + } + + // Album manager single { AlbumManager( albumRepository = get(), @@ -87,9 +133,14 @@ private val domainModules = module { ) } - single { MLKitTranslator() } - - single { SimilarityManager(get(), get()) } + // Similarity manager + single { + SimilarityManager( + imageSimilarityDao = get(), + embeddingRepository = get(), + configurationService = get() + ) + } } val workManagerModule = module { diff --git a/app/src/main/java/me/grey/picquery/common/ImageUtil.kt b/app/src/main/java/me/grey/picquery/common/ImageUtil.kt index 48992d6..86c1ee0 100644 --- a/app/src/main/java/me/grey/picquery/common/ImageUtil.kt +++ b/app/src/main/java/me/grey/picquery/common/ImageUtil.kt @@ -3,31 +3,54 @@ package me.grey.picquery.common import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.util.Log +import android.util.LruCache import android.util.Size -import com.bumptech.glide.Glide -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow +import coil.ImageLoader +import coil.request.ImageRequest +import coil.request.SuccessResult +import coil.size.Scale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import me.grey.picquery.common.Constants.DIM import me.grey.picquery.data.model.Photo +import timber.log.Timber +import androidx.core.graphics.scale private const val TAG = "ImageUtil" /** + * 图片缓存管理器 - 带内存缓存 + */ +object ThumbnailCache { + // 内存缓存:使用 1/8 的可用内存 + internal val memoryCache: LruCache by lazy { + val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt() + val cacheSize = maxMemory / 8 + + object : LruCache(cacheSize) { + override fun sizeOf(key: String, bitmap: Bitmap): Int { + return bitmap.byteCount / 1024 + } + } + } + + internal fun generateCacheKey(path: String, size: Size): String { + return "${path}_${size.width}x${size.height}" + } +} + +/** + * Extension function for BitmapFactory.Options to calculate optimal inSampleSize * https://developer.android.google.cn/topic/performance/graphics/load-bitmap?hl=zh-cn - * */ -fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { + */ +fun BitmapFactory.Options.calculateInSampleSize(reqWidth: Int, reqHeight: Int): Int { // Raw height and width of image - val (height: Int, width: Int) = options.run { outHeight to outWidth } + val (height, width) = outHeight to outWidth var inSampleSize = 1 if (height > reqHeight || width > reqWidth) { - val halfHeight: Int = height / 2 - val halfWidth: Int = width / 2 + val halfHeight = height / 2 + val halfWidth = width / 2 // Calculate the largest inSampleSize value that is a power of 2 and keeps both // height and width larger than the requested height and width. while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { @@ -44,41 +67,91 @@ fun decodeSampledBitmapFromFile(pathName: String, size: Size): Bitmap? { BitmapFactory.Options().run { inJustDecodeBounds = true BitmapFactory.decodeFile(pathName, this) - inSampleSize = calculateInSampleSize(this, size.width, size.height) + // Use extension function + inSampleSize = calculateInSampleSize(size.width, size.height) // Decode bitmap with inSampleSize set inJustDecodeBounds = false BitmapFactory.decodeFile(pathName, this) } } catch (e: IllegalArgumentException) { - Log.w(TAG, "Failed to decode file: $pathName, ${e.message}") + Timber.tag(TAG).w("Failed to decode file: $pathName, ${e.message}") null } } val IMAGE_INPUT_SIZE = Size(DIM, DIM) +/** + * 加载缩略图 - 优化版本 + * 1. 先检查内存缓存 + * 2. 尝试 ContentResolver + * 3. 使用 Coil (原生协程支持) + * 4. 最后使用 BitmapFactory + */ suspend fun loadThumbnail(context: Context, photo: Photo, size: Size = IMAGE_INPUT_SIZE): Bitmap? { - return flow { - emit(context.contentResolver.loadThumbnail(photo.uri, size, null)) - }.catch { - emit( - coroutineScope { - Glide.with(context) - .asBitmap() - .load(photo.path) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .downsample(DownsampleStrategy.FIT_CENTER) - .override(DIM) - .skipMemoryCache(true) - .submit().get() + // 1. 检查内存缓存 + val cacheKey = ThumbnailCache.generateCacheKey(photo.path, size) + ThumbnailCache.memoryCache[cacheKey]?.let { + Timber.tag(TAG).v("Cache hit for ${photo.path}") + return it + } + + // 2. 尝试 ContentResolver + try { + val thumbnail = context.contentResolver.loadThumbnail(photo.uri, size, null) + ThumbnailCache.memoryCache.put(cacheKey, thumbnail) + return thumbnail + } catch (e: Exception) { + Timber.tag(TAG).v("ContentResolver failed: ${e.message}") + } + + // 3. 使用 Coil (原生协程支持,非阻塞) + try { + val bitmap = loadImageWithCoil(context, photo.path, size) + bitmap?.let { ThumbnailCache.memoryCache.put(cacheKey, it) } + return bitmap + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Coil load failed") + } + + // 4. 最后的 fallback + return withContext(Dispatchers.IO) { + decodeSampledBitmapFromFile(photo.path, size)?.also { + ThumbnailCache.memoryCache.put(cacheKey, it) + } + } +} + +/** + * 使用 Coil 加载图片 - 原生协程支持 + * Coil 是 Kotlin-first 的图片加载库,内置协程支持 + */ +private suspend fun loadImageWithCoil(context: Context, path: String, size: Size): Bitmap? { + val imageLoader = ImageLoader.Builder(context) + .crossfade(false) + .build() + + val request = ImageRequest.Builder(context) + .data(path) + .size(size.width, size.height) + .scale(Scale.FIT) + .allowHardware(false) // 需要 Bitmap 进行后续处理 + .build() + + return when (val result = imageLoader.execute(request)) { + is SuccessResult -> { + val drawable = result.drawable + if (drawable is android.graphics.drawable.BitmapDrawable) { + drawable.bitmap + } else { + null } - ) - }.catch { - emit(decodeSampledBitmapFromFile(photo.path, size)) - }.first() + } + else -> null + } } fun preprocess(bitmap: Bitmap): Bitmap { // bitmap size to 224x224 - return Bitmap.createScaledBitmap(bitmap, DIM, DIM, true) + return bitmap.scale(DIM, DIM) } diff --git a/app/src/main/java/me/grey/picquery/data/data_source/PhotoRepository.kt b/app/src/main/java/me/grey/picquery/data/data_source/PhotoRepository.kt index e215a19..cad9207 100644 --- a/app/src/main/java/me/grey/picquery/data/data_source/PhotoRepository.kt +++ b/app/src/main/java/me/grey/picquery/data/data_source/PhotoRepository.kt @@ -8,8 +8,8 @@ import android.graphics.BitmapFactory import android.net.Uri import android.os.Bundle import android.provider.MediaStore -import android.util.Log import java.io.InputStream +import timber.log.Timber import kotlinx.coroutines.flow.flow import me.grey.picquery.data.CursorUtil import me.grey.picquery.data.model.Photo @@ -36,18 +36,26 @@ class PhotoRepository(private val context: Context) { MediaStore.VOLUME_EXTERNAL ) - private fun getPhotoListByAlbumIdFlow(albumId: Long, pageSize: Int = DEFAULT_PAGE_SIZE) = flow { + /** + * Generic paginated photo flow - reusable for both internal and external use + * @param albumId Album ID + * @param pageSize Photos per batch + * @return Flow of photo lists + */ + private fun getPaginatedPhotoFlow(albumId: Long, pageSize: Int = DEFAULT_PAGE_SIZE) = flow { var pageIndex = 0 while (true) { val photos = getPhotoListByPage(albumId, pageIndex, pageSize) - if (photos.isEmpty()) { - break - } + if (photos.isEmpty()) break emit(photos) pageIndex++ } } + private fun getPhotoListByAlbumIdFlow(albumId: Long, pageSize: Int = DEFAULT_PAGE_SIZE) = flow { + getPaginatedPhotoFlow(albumId, pageSize).collect { emit(it) } + } + suspend fun getPhotoListByAlbumId(albumId: Long): List { val result = mutableListOf() getPhotoListByAlbumIdFlow(albumId).collect { @@ -128,25 +136,29 @@ class PhotoRepository(private val context: Context) { } fun getPhotoListByIds(ids: List): List { + if (ids.isEmpty()) return emptyList() + + // Use parameterized query to prevent SQL injection + val placeholders = ids.joinToString(",") { "?" } val query = context.contentResolver.query( imageCollection, imageProjection, - "${MediaStore.Images.Media._ID} IN (${ids.joinToString(",")})", - arrayOf(), + "${MediaStore.Images.Media._ID} IN ($placeholders)", + ids.map { it.toString() }.toTypedArray(), null ) val result = query.use { cursor: Cursor? -> when (cursor?.count) { null -> { - Log.e(TAG, "getPhotoListByIds, cursor null") + Timber.tag(TAG).e("getPhotoListByIds, cursor null") emptyList() } 0 -> { - Log.w(TAG, "getPhotoListByIds, need ${ids.size} but found 0!") + Timber.tag(TAG).w("getPhotoListByIds, need ${ids.size} but found 0!") emptyList() } else -> { - // 开始从结果中迭代查找,cursor最初从-1开始 + // Iterate through results, cursor starts at -1 val photoList = mutableListOf() while (cursor.moveToNext()) { photoList.add(CursorUtil.getPhoto(cursor)) @@ -160,9 +172,9 @@ class PhotoRepository(private val context: Context) { fun getBitmapFromUri(uri: Uri): Bitmap? { return try { - // 打开输入流 + // Open input stream val inputStream: InputStream? = context.contentResolver.openInputStream(uri) - // 解码输入流为 Bitmap + // Decode input stream to Bitmap BitmapFactory.decodeStream(inputStream) } catch (e: Exception) { e.printStackTrace() @@ -171,20 +183,10 @@ class PhotoRepository(private val context: Context) { } /** - * 分批获取相册中的照片,以 Flow 形式返回 - * @param albumId 相册ID - * @param pageSize 每批照片的数量 - * @return Flow> 照片列表流 + * Get photos from album in batches, returns as Flow + * @param albumId Album ID + * @param pageSize Number of photos per batch + * @return Flow> Photo list flow */ - fun getPhotoListByAlbumIdPaginated(albumId: Long, pageSize: Int = DEFAULT_PAGE_SIZE) = flow { - var pageIndex = 0 - while (true) { - val photos = getPhotoListByPage(albumId, pageIndex, pageSize) - if (photos.isEmpty()) { - break - } - emit(photos) - pageIndex++ - } - } + fun getPhotoListByAlbumIdPaginated(albumId: Long, pageSize: Int = DEFAULT_PAGE_SIZE) = getPaginatedPhotoFlow(albumId, pageSize) } diff --git a/app/src/main/java/me/grey/picquery/data/data_source/PreferenceRepository.kt b/app/src/main/java/me/grey/picquery/data/data_source/PreferenceRepository.kt index 421e7a6..20f4447 100644 --- a/app/src/main/java/me/grey/picquery/data/data_source/PreferenceRepository.kt +++ b/app/src/main/java/me/grey/picquery/data/data_source/PreferenceRepository.kt @@ -21,6 +21,7 @@ class PreferenceRepository { val ENABLE_UPLOAD_LOG = booleanPreferencesKey("ENABLE_UPLOAD_LOG") val SEARCH_MATCH_THRESHOLD = floatPreferencesKey("SEARCH_MATCH_THRESHOLD") val SEARCH_TOP_K = intPreferencesKey("SEARCH_TOP_K") + val USER_GUIDE_COMPLETED = booleanPreferencesKey("USER_GUIDE_COMPLETED") } private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") @@ -66,18 +67,6 @@ class PreferenceRepository { } } - suspend fun loadSearchConfiguration(): Pair { - var matchThreshold = 0.20f - var topK = 30 - - context.dataStore.data.collect { preferences -> - matchThreshold = preferences[SEARCH_MATCH_THRESHOLD] ?: 0.20f - topK = preferences[SEARCH_TOP_K] ?: 30 - } - - return Pair(matchThreshold, topK) - } - suspend fun loadSearchConfigurationSync(): Pair { val preferences = context.dataStore.data.first() @@ -86,4 +75,15 @@ class PreferenceRepository { return Pair(matchThreshold, topK) } + + suspend fun isUserGuideCompleted(): Boolean { + val preferences = context.dataStore.data.first() + return preferences[USER_GUIDE_COMPLETED] ?: false + } + + suspend fun setUserGuideCompleted(completed: Boolean) { + context.dataStore.edit { settings -> + settings[USER_GUIDE_COMPLETED] = completed + } + } } 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 b89d681..dfcc12e 100644 --- a/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt +++ b/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt @@ -2,7 +2,6 @@ package me.grey.picquery.domain -import android.util.Log import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -39,7 +38,7 @@ class AlbumManager( private val ioDispatcher: CoroutineDispatcher ) { companion object { - private const val TAG = "AlbumViewModel" + private const val TAG = "AlbumManager" } val encodingState = mutableStateOf(EncodingState()) @@ -66,7 +65,7 @@ class AlbumManager( SupervisorJob() + Dispatchers.Default + CoroutineExceptionHandler { _, exception -> - // 处理协程异常 + // Handle coroutine exceptions Timber.tag("AlbumManager").e(exception, "Coroutine error") } ) @@ -81,7 +80,7 @@ class AlbumManager( suspend fun initAllAlbumList() { if (initialized) return withContext(ioDispatcher) { - // 本机中的相册 + // Get all albums from device val albums = albumRepository.getAllAlbums() albumList.addAll(albums) Timber.tag(TAG).d("ALL albums: ${albums.size}") @@ -92,12 +91,12 @@ class AlbumManager( suspend fun initDataFlow() { searchableAlbumFlow().collect { - // 从数据库中检索已经索引的相册 - // 有些相册可能已经索引但已被删除,因此要从全部相册中筛选,而不能直接返回数据库的结果 + // Retrieve indexed albums from database + // Some albums may have been indexed but deleted, so filter from all albums val res = it.toMutableList().sortedByDescending { album: Album -> album.count } _searchableAlbumList.update { res } Timber.tag(TAG).d("Searchable albums: ${it.size}") - // 从全部相册减去已经索引的ID,就是未索引的相册 + // Unsearchable albums = all albums - indexed albums val unsearchable = albumList.filter { all -> !it.contains(all) } _unsearchableAlbumList.update { (unsearchable.toMutableList().sortedByDescending { album: Album -> album.count }) } @@ -123,7 +122,7 @@ class AlbumManager( } /** - * 获取多个相册的照片流 + * Get photo flow for multiple albums */ @OptIn(ExperimentalCoroutinesApi::class) private fun getPhotosFlow(albums: List) = albums.asFlow() @@ -132,7 +131,7 @@ class AlbumManager( } /** - * 获取相册列表的照片总数 + * Get total photo count for album list */ private suspend fun getTotalPhotoCount(albums: List): Int = withContext(ioDispatcher) { albums.sumOf { album -> photoRepository.getImageCountInAlbum(album.id) } @@ -155,7 +154,7 @@ class AlbumManager( getPhotosFlow(albums).collect { photoChunk -> val chunkSuccess = imageSearcher.encodePhotoListV2(photoChunk) { cur, total, cost -> - Log.d(TAG, "Encoded $cur/$total photos, cost: $cost") + Timber.tag(TAG).d("Encoded $cur/$total photos, cost: $cost") processedPhotos.addAndGet(cur) encodingState.value = encodingState.value.copy( current = processedPhotos.get(), @@ -167,12 +166,12 @@ class AlbumManager( if (!chunkSuccess) { success = false - Log.w(TAG, "Failed to encode photo chunk, size: ${photoChunk.size}") + Timber.tag(TAG).w("Failed to encode photo chunk, size: ${photoChunk.size}") } } if (success) { - Log.i(TAG, "Encoded ${albums.size} album(s) with $totalPhotos photos!") + Timber.tag(TAG).i("Encoded ${albums.size} album(s) with $totalPhotos photos!") withContext(ioDispatcher) { albumRepository.addAllSearchableAlbum(albums) } @@ -180,10 +179,10 @@ class AlbumManager( status = EncodingState.Status.Finish ) } else { - Log.w(TAG, "encodePhotoList failed! Maybe too much request.") + Timber.tag(TAG).w("encodePhotoList failed! Maybe too much request.") } } catch (e: Exception) { - Log.e(TAG, "Error encoding albums", e) + Timber.tag(TAG).e(e, "Error encoding albums") encodingState.value = encodingState.value.copy( status = EncodingState.Status.Error ) diff --git a/app/src/main/java/me/grey/picquery/domain/EmbeddingService.kt b/app/src/main/java/me/grey/picquery/domain/EmbeddingService.kt new file mode 100644 index 0000000..e6eb44f --- /dev/null +++ b/app/src/main/java/me/grey/picquery/domain/EmbeddingService.kt @@ -0,0 +1,165 @@ +package me.grey.picquery.domain + +import android.content.Context +import android.graphics.Bitmap +import java.util.concurrent.atomic.AtomicInteger +import kotlin.system.measureTimeMillis +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.chunked +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import me.grey.picquery.common.encodeProgressCallback +import me.grey.picquery.common.loadThumbnail +import me.grey.picquery.common.preprocess +import me.grey.picquery.data.data_source.EmbeddingRepository +import me.grey.picquery.data.data_source.ObjectBoxEmbeddingRepository +import me.grey.picquery.data.model.ObjectBoxEmbedding +import me.grey.picquery.data.model.Photo +import me.grey.picquery.data.model.PhotoBitmap +import me.grey.picquery.feature.base.ImageEncoder +import me.grey.picquery.feature.base.TextEncoder +import timber.log.Timber + +/** + * Encoding Service - Responsible for vector encoding of images and text + * + * Responsibilities: + * - Batch encode photo lists + * - Encode single image + * - Encode text + * - Manage encoding lock + * - Check if embeddings exist + */ +class EmbeddingService( + private val context: Context, + private val imageEncoder: ImageEncoder, + private val textEncoder: TextEncoder, + private val embeddingRepository: EmbeddingRepository, + private val objectBoxEmbeddingRepository: ObjectBoxEmbeddingRepository, + private val dispatcher: CoroutineDispatcher +) { + companion object { + private const val TAG = "EmbeddingService" + private const val BUFFER_SIZE = 1000 + private const val CHUNK_SIZE = 100 + } + + private var encodingLock = false + + + /** + * Check if embeddings exist + */ + suspend fun hasEmbedding(): Boolean { + return withContext(dispatcher) { + val total = embeddingRepository.getTotalCount() + Timber.tag(TAG).d("Total embedding count $total") + total > 0 + } + } + + /** + * Encode text to vector + */ + suspend fun encodeText(text: String): FloatArray { + return withContext(dispatcher) { + textEncoder.encode(text) + } + } + + /** + * Encode image to vector + */ + suspend fun encodeBitmap(bitmap: Bitmap): FloatArray { + return withContext(dispatcher) { + imageEncoder.encodeBatch(listOf(bitmap))[0] + } + } + + /** + * Batch encode photo list + * + * @param photos List of photos to encode + * @param progressCallback Progress callback + * @return Whether encoding started successfully (returns false if already encoding) + */ + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun encodePhotoList( + photos: List, + progressCallback: encodeProgressCallback? = null + ): Boolean { + if (encodingLock) { + Timber.tag(TAG).w("encodePhotoList: Already encoding!") + return false + } + + Timber.tag(TAG).i("encodePhotoList started with ${photos.size} photos") + encodingLock = true + + withContext(dispatcher) { + val cur = AtomicInteger(0) + + photos.asFlow() + .map { photo -> loadAndPreprocessPhoto(photo) } + .filterNotNull() + .buffer(BUFFER_SIZE) + .chunked(CHUNK_SIZE) + .onEach { Timber.tag(TAG).d("Processing batch: ${it.size}") } + .onCompletion { + embeddingRepository.updateCache() + encodingLock = false + Timber.tag(TAG).i("Encoding completed") + } + .collect { batch -> + val cost = measureTimeMillis { + saveBatchToEmbedding(batch) + } + cur.addAndGet(batch.size) + + progressCallback?.invoke( + cur.get(), + photos.size, + cost / batch.size + ) + Timber.tag(TAG).d("Batch cost: ${cost}ms") + } + } + return true + } + + /** + * Load and preprocess photo + */ + private suspend fun loadAndPreprocessPhoto(photo: Photo): PhotoBitmap? { + val thumbnailBitmap = loadThumbnail(context, photo) + if (thumbnailBitmap == null) { + Timber.tag(TAG).w("Unsupported file: '${photo.path}', skip encoding") + return null + } + val prepBitmap = preprocess(thumbnailBitmap) + return PhotoBitmap(photo, prepBitmap) + } + + /** + * Batch save embeddings to database + */ + private suspend fun saveBatchToEmbedding(items: List) { + val embeddings = imageEncoder.encodeBatch(items.map { it.bitmap }) + + embeddings.forEachIndexed { index, feat -> + objectBoxEmbeddingRepository.update( + ObjectBoxEmbedding( + photoId = items[index].photo.id, + albumId = items[index].photo.albumID, + data = feat + ) + ) + } + } +} diff --git a/app/src/main/java/me/grey/picquery/domain/ImageSearcher.kt b/app/src/main/java/me/grey/picquery/domain/ImageSearcher.kt index 6884f7b..cc60da6 100644 --- a/app/src/main/java/me/grey/picquery/domain/ImageSearcher.kt +++ b/app/src/main/java/me/grey/picquery/domain/ImageSearcher.kt @@ -5,426 +5,162 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ImageSearch import androidx.compose.material.icons.outlined.Translate import androidx.compose.runtime.State -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.vector.ImageVector -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.drawable.toBitmap -import java.util.Collections -import java.util.SortedMap -import java.util.TreeMap -import java.util.concurrent.atomic.AtomicInteger -import kotlin.system.measureTimeMillis -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.buffer -import kotlinx.coroutines.flow.chunked -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach -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.calculateSimilarity import me.grey.picquery.common.encodeProgressCallback -import me.grey.picquery.common.loadThumbnail -import me.grey.picquery.common.preprocess -import me.grey.picquery.common.showToast -import me.grey.picquery.data.data_source.EmbeddingRepository -import me.grey.picquery.data.data_source.ObjectBoxEmbeddingRepository -import me.grey.picquery.data.data_source.PreferenceRepository import me.grey.picquery.data.model.Album import me.grey.picquery.data.model.Photo -import me.grey.picquery.data.model.PhotoBitmap -import me.grey.picquery.data.model.toFloatArray -import me.grey.picquery.domain.EmbeddingUtils.saveBitmapsToEmbedding -import me.grey.picquery.feature.base.ImageEncoder -import me.grey.picquery.feature.base.TextEncoder -import timber.log.Timber - -enum class SearchTarget(val labelResId: Int, val icon: ImageVector) { - Image(R.string.search_target_image, Icons.Outlined.ImageSearch), - Text(R.string.search_target_text, Icons.Outlined.Translate) +import java.util.AbstractMap + +/** + * Search target type for image or text search + */ +sealed class SearchTarget(val labelResId: Int, val icon: ImageVector) { + data object Image : SearchTarget(R.string.search_target_image, Icons.Outlined.ImageSearch) + data object Text : SearchTarget(R.string.search_target_text, Icons.Outlined.Translate) } +/** + * Image Searcher - External interface for search functionality + * + * This is the main entry point for search operations in the application. + * It coordinates between services and manages search state. + * + * Responsibilities: + * - Search range state management + * - Search target type management + * - Search result state management + * - Public API for search operations + * + * Delegates to: + * - EmbeddingService -> Encoding operations + * - SearchConfigurationService -> Configuration management + * - SearchOrchestrator -> Search execution + */ class ImageSearcher( - private val imageEncoder: ImageEncoder, - private val textEncoder: TextEncoder, - private val embeddingRepository: EmbeddingRepository, - private val objectBoxEmbeddingRepository: ObjectBoxEmbeddingRepository, - private val translator: MLKitTranslator, - private val dispatcher: CoroutineDispatcher, - private val preferenceRepository: PreferenceRepository, - private val scope: CoroutineScope + private val embeddingService: EmbeddingService, + private val configurationService: SearchConfigurationService, + private val searchOrchestrator: SearchOrchestrator ) { - companion object { - private const val TAG = "ImageSearcher" - const val DEFAULT_MATCH_THRESHOLD = 0.20f - const val DEFAULT_TOP_K = 30 - private const val SEARCH_BATCH_SIZE = 1000 - } - val searchRange = mutableStateListOf() var isSearchAll = mutableStateOf(true) - var searchTarget = mutableStateOf(SearchTarget.Image) - val searchResultIds = mutableStateListOf() - private val _matchThreshold = mutableFloatStateOf(DEFAULT_MATCH_THRESHOLD) - val matchThreshold: State = _matchThreshold - - private val _topK = mutableIntStateOf(DEFAULT_TOP_K) - val topK: State = _topK + // ============ Delegated to ConfigurationService ============ - private var isInitialized = false + val matchThreshold: State + get() = configurationService.matchThreshold - suspend fun initialize() { - if (!isInitialized) { - val (savedThreshold, savedTopK) = preferenceRepository.loadSearchConfigurationSync() - _matchThreshold.floatValue = savedThreshold - _topK.intValue = savedTopK - isInitialized = true - Timber.tag(TAG).d("Search configuration loaded: matchThreshold=$savedThreshold, topK=$savedTopK") - } - } + val topK: State + get() = configurationService.topK - fun updateRange(range: List, searchAll: Boolean) { - searchRange.clear() - searchRange.addAll(range.sortedByDescending { it.count }) - isSearchAll.value = searchAll - } + // ============ Initialization ============ - suspend fun getBaseLine(): FloatArray { - val whiteBenchmark = - ResourcesCompat.getDrawable(context.resources, R.drawable.white_benchmark, null) - ?.toBitmap()!! - return imageEncoder.encodeBatch(listOf(whiteBenchmark)).first() + /** + * Initialize the searcher (delegated to ConfigurationService) + */ + suspend fun initialize() { + configurationService.initialize() } - fun updateTarget(target: SearchTarget) { - searchTarget.value = target - } + // ============ Methods Delegated to EmbeddingService ============ + /** + * Check if embeddings exist + */ suspend fun hasEmbedding(): Boolean { - return withContext(dispatcher) { - val total = embeddingRepository.getTotalCount() - Timber.tag(TAG).d("Total embedding count $total") - total > 0 - } + return embeddingService.hasEmbedding() } - private var encodingLock = false - private var searchingLock = false - /** - * Encode all photos in the list and save to database. - * @param photos List of photos to encode. - * @param progressCallback Callback to report encoding progress. - * @return True if encoding started, false if already encoding. + * Encode photo list (delegated to EmbeddingService) */ - @OptIn(ExperimentalCoroutinesApi::class) suspend fun encodePhotoListV2( photos: List, progressCallback: encodeProgressCallback? = null ): Boolean { - if (encodingLock) { - Timber.tag(TAG).w("encodePhotoListV2: Already encoding!") - return false - } - Timber.tag(TAG).i("encodePhotoListV2 started.") - encodingLock = true + return embeddingService.encodePhotoList(photos, progressCallback) + } - withContext(dispatcher) { - val cur = AtomicInteger(0) - Timber.tag(TAG).d("start: ${photos.size}") + // ============ Search Range Management ============ - photos.asFlow() - .map { photo -> - val thumbnailBitmap = loadThumbnail(context, photo) - if (thumbnailBitmap == null) { - Timber.tag(TAG).w("Unsupported file: '${photo.path}', skip encoding it.") - return@map null - } - val prepBitmap = preprocess(thumbnailBitmap) - PhotoBitmap(photo, prepBitmap) - } - .filterNotNull() - .buffer(1000) - .chunked(100) - .onEach { Timber.tag(TAG).d("onEach: ${it.size}") } - .onCompletion { - embeddingRepository.updateCache() - encodingLock = false - } - .collect { - val loops = 1 - val batchSize = it.size / loops - val cost = measureTimeMillis { - val deferreds = (0 until loops).map { index -> - async { - val start = index * batchSize - if (start >= it.size) return@async - val end = start + batchSize - saveBitmapsToEmbedding( - it.slice(start until end), - imageEncoder, - embeddingRepository, - objectBoxEmbeddingRepository - ) - } - } - deferreds.awaitAll() - } - cur.set(it.size) + /** + * Update search range + */ + fun updateRange(range: List, searchAll: Boolean) { + searchRange.clear() + searchRange.addAll(range.sortedByDescending { it.count }) + isSearchAll.value = searchAll + } - progressCallback?.invoke( - cur.get(), - photos.size, - cost / it.size - ) - Timber.tag(TAG).d("cost: $cost") - } - } - return true + /** + * Update search configuration (delegated to ConfigurationService) + */ + fun updateSearchConfiguration(newMatchThreshold: Float, newTopK: Int) { + configurationService.updateConfiguration(newMatchThreshold, newTopK) } + /** + * Text search with translation support + * @param text Search query text + * @param range Album range to search within (defaults to current searchRange) + * @param onSuccess Callback with search results + */ suspend fun search( text: String, range: List = searchRange, onSuccess: suspend (MutableSet>) -> Unit ) { - translator.translate( - text, - onSuccess = { translatedText -> - CoroutineScope(Dispatchers.Default).launch { - val res = searchWithRange(translatedText, range) - onSuccess(res) + // Legacy API - kept for backward compatibility + // This uses the old search format but delegates to orchestrator internally + searchOrchestrator.searchByText(text, range, isSearchAll.value) { results -> + // Convert new format to old format for backward compatibility + val legacyResults = results + .map { (photoId, score) -> + AbstractMap.SimpleEntry(score, photoId) as MutableMap.MutableEntry } - }, - onError = { - CoroutineScope(Dispatchers.Default).launch { - val res = searchWithRange(text, range) - onSuccess(res) - } - Timber.tag("MLTranslator").e("中文->英文翻译出错!\n${it.message}") - showToast("翻译模型出错,请反馈给开发者!") - } - ) + .toMutableSet() + onSuccess(legacyResults) + } } + /** + * Text search V2 with translation support + * @param text Search query text + * @param range Album range to search within (defaults to current searchRange) + * @param onSuccess Callback with search results as List> + */ suspend fun searchV2( text: String, range: List = searchRange, onSuccess: suspend (MutableList>) -> Unit ) { - translator.translate( - text, - onSuccess = { translatedText -> - CoroutineScope(Dispatchers.Default).launch { - val res = searchWithRangeV2(translatedText, range) - onSuccess(res) - } - }, - onError = { - CoroutineScope(Dispatchers.Default).launch { - val res = searchWithRangeV2(text, range) - onSuccess(res) - } - Timber.tag("MLTranslator").e("中文->英文翻译出错!\n${it.message}") - showToast("翻译模型出错,请反馈给开发者!") - } - ) - } - - private suspend fun searchWithRange( - text: String, - range: List = searchRange - ): MutableSet> { - return withContext(dispatcher) { - if (searchingLock) { - return@withContext mutableSetOf() - } - searchingLock = true - val textFeat = textEncoder.encode(text) - val results = searchWithVector(range, textFeat) - return@withContext results - } - } - - suspend fun searchWithRange( - image: Bitmap, - range: List = searchRange, - onSuccess: suspend (MutableSet>) -> Unit - ) { - return withContext(dispatcher) { - if (searchingLock) { - return@withContext - } - searchingLock = true - val bitmapFeats = imageEncoder.encodeBatch(mutableListOf(image)) - val results = searchWithVector(range, bitmapFeats[0]) + searchOrchestrator.searchByText(text, range, isSearchAll.value) { results -> + // Update search result IDs + searchResultIds.clear() + searchResultIds.addAll(results.map { it.first }) onSuccess(results) } } - private suspend fun searchWithRangeV2( - text: String, - range: List = searchRange - ): MutableList> { - return withContext(dispatcher) { - if (searchingLock) { - return@withContext mutableListOf() - } - searchingLock = true - val textFeat = textEncoder.encode(text) - Timber.tag(TAG).d("Text feature: ${textFeat.joinToString(",")}") - val results = searchWithVectorV2(range, textFeat) - return@withContext results - } - } + /** + * Image search V2 + * @param image Image to search with + * @param range Album range to search within (defaults to current searchRange) + * @param onSuccess Callback with search results as List> + */ suspend fun searchWithRangeV2( image: Bitmap, range: List = searchRange, onSuccess: suspend (MutableList>) -> Unit ) { - return withContext(dispatcher) { - if (searchingLock) { - return@withContext - } - searchingLock = true - val bitmapFeats = imageEncoder.encodeBatch(mutableListOf(image)) - val results = searchWithVectorV2(range, bitmapFeats[0]) - onSuccess(results) - } - } - - private suspend fun searchWithVectorV2( - range: List, - textFeat: FloatArray - ): MutableList> = withContext(dispatcher) { - try { - searchingLock = true - - Timber.tag(TAG).d("Search with vector V2") - - val albumIds = if (range.isEmpty() || isSearchAll.value) { - Timber.tag(TAG).d("Search from all album") - null - } else { - Timber.tag(TAG).d("Search from: [${range.joinToString { it.label }}]") - range.map { it.id } - } - - val searchResults = objectBoxEmbeddingRepository.searchNearestVectors( - queryVector = textFeat, - topK = topK.value, - similarityThreshold = matchThreshold.value, - albumIds = albumIds - ) - Timber.tag(TAG).d("Search result: found ${searchResults.size} pics") - - searchResultIds.clear() - val ans = mutableListOf() - searchResults.forEachIndexed { _, pair -> - ans.add(pair.get().photoId) - } - searchResultIds.addAll(ans) - - Timber.tag(TAG).d("Search result: found ${ans.size} pics") - return@withContext searchResults.map { it.get().photoId to it.score }.toMutableList() - } finally { - searchingLock = false - } - } - - private suspend fun searchWithVector( - range: List, - textFeat: FloatArray - ): MutableSet> = withContext(dispatcher) { - try { - searchingLock = true - val threadSafeSortedMap = Collections.synchronizedSortedMap( - TreeMap(compareByDescending { it }) - ) - - val embeddings = if (range.isEmpty() || isSearchAll.value) { - Timber.tag(TAG).d("Search from all album") - embeddingRepository.getAllEmbeddingsPaginated(SEARCH_BATCH_SIZE) - } else { - Timber.tag(TAG).d("Search from: [${range.joinToString { it.label }}]") - embeddingRepository.getEmbeddingsByAlbumIdsPaginated( - range.map { it.id }, - SEARCH_BATCH_SIZE - ) - } - - var totalProcessed = 0 - embeddings.collect { chunk -> - Timber.tag(TAG).d("Processing chunk: ${chunk.size}") - totalProcessed += chunk.size - - for (emb in chunk) { - val sim = calculateSimilarity(emb.data.toFloatArray(), textFeat) - Timber.tag(TAG).d("similarity: ${emb.photoId} -> $sim") - if (sim >= matchThreshold.value) { - insertDescendingThreadSafe(threadSafeSortedMap, Pair(emb.photoId, sim)) - } - } - } - - Timber.tag(TAG).d("Search Finish: Processed $totalProcessed embeddings") - Timber.tag(TAG).d("Search result: found ${threadSafeSortedMap.size} pics") - + searchOrchestrator.searchByImage(image, range, isSearchAll.value) { results -> + // Update search result IDs searchResultIds.clear() - mutableSetOf>().apply { - addAll(threadSafeSortedMap.entries) - searchResultIds.addAll(threadSafeSortedMap.values) - Timber.tag(TAG).d("Search result: ${joinToString(",")}") - return@withContext this - } - } finally { - searchingLock = false - } - } - - // Thread-safe version of insertDescending - private fun insertDescendingThreadSafe( - map: SortedMap, - candidate: Pair - ) { - if (map.size >= topK.value) { - val min = map.lastKey() - if (candidate.second >= min) { - map[candidate.second] = candidate.first - map.remove(min) - } - } else { - map[candidate.second] = candidate.first - } - } - - fun updateSearchConfiguration(newMatchThreshold: Float, newTopK: Int) { - _matchThreshold.floatValue = newMatchThreshold.coerceIn(0.1f, 0.5f) - _topK.intValue = newTopK.coerceIn(10, 100) - - scope.launch { - preferenceRepository.saveSearchConfiguration(_matchThreshold.floatValue, _topK.intValue) + searchResultIds.addAll(results.map { it.first }) + onSuccess(results) } - - - - Timber.tag(TAG).d( - "Search configuration updated: " + - "matchThreshold=${_matchThreshold.floatValue}, topK=${_topK.intValue}" - ) } } diff --git a/app/src/main/java/me/grey/picquery/domain/SearchConfigurationService.kt b/app/src/main/java/me/grey/picquery/domain/SearchConfigurationService.kt new file mode 100644 index 0000000..975f4e9 --- /dev/null +++ b/app/src/main/java/me/grey/picquery/domain/SearchConfigurationService.kt @@ -0,0 +1,98 @@ +package me.grey.picquery.domain + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import me.grey.picquery.data.data_source.PreferenceRepository +import timber.log.Timber + +/** + * Search Configuration Service - Responsible for managing search parameters + * + * Responsibilities: + * - Manage match threshold + * - Manage top K results count + * - Persist configuration to DataStore + * - Load configuration on initialization + */ +class SearchConfigurationService( + private val preferenceRepository: PreferenceRepository, + private val scope: CoroutineScope +) { + companion object { + private const val TAG = "SearchConfigService" + + const val DEFAULT_MATCH_THRESHOLD = 0.20f + const val DEFAULT_TOP_K = 30 + + // Threshold boundaries + const val MIN_THRESHOLD = 0.1f + const val MAX_THRESHOLD = 0.5f + + // TopK boundaries + const val MIN_TOP_K = 10 + const val MAX_TOP_K = 100 + } + + private val _matchThreshold = mutableFloatStateOf(DEFAULT_MATCH_THRESHOLD) + val matchThreshold: State = _matchThreshold + + private val _topK = mutableIntStateOf(DEFAULT_TOP_K) + val topK: State = _topK + + private var isInitialized = false + + /** + * Initialize configuration - Load saved configuration from DataStore + */ + suspend fun initialize() { + if (isInitialized) { + Timber.tag(TAG).d("Already initialized, skipping") + return + } + + val (savedThreshold, savedTopK) = preferenceRepository.loadSearchConfigurationSync() + _matchThreshold.floatValue = savedThreshold + _topK.intValue = savedTopK + isInitialized = true + + Timber.tag(TAG).d( + "Configuration loaded: matchThreshold=$savedThreshold, topK=$savedTopK" + ) + } + + /** + * Update search configuration + * + * @param newMatchThreshold New match threshold (will be coerced to 0.1-0.5 range) + * @param newTopK New top K count (will be coerced to 10-100 range) + */ + fun updateConfiguration(newMatchThreshold: Float, newTopK: Int) { + _matchThreshold.floatValue = newMatchThreshold.coerceIn(MIN_THRESHOLD, MAX_THRESHOLD) + _topK.intValue = newTopK.coerceIn(MIN_TOP_K, MAX_TOP_K) + + // Asynchronously save to DataStore + scope.launch { + preferenceRepository.saveSearchConfiguration( + _matchThreshold.floatValue, + _topK.intValue + ) + } + + Timber.tag(TAG).d( + "Configuration updated: matchThreshold=${_matchThreshold.floatValue}, topK=${_topK.intValue}" + ) + } + + /** + * Get current match threshold + */ + fun getMatchThreshold(): Float = _matchThreshold.floatValue + + /** + * Get current top K value + */ + fun getTopK(): Int = _topK.intValue +} diff --git a/app/src/main/java/me/grey/picquery/domain/SearchOrchestrator.kt b/app/src/main/java/me/grey/picquery/domain/SearchOrchestrator.kt new file mode 100644 index 0000000..9c0b61d --- /dev/null +++ b/app/src/main/java/me/grey/picquery/domain/SearchOrchestrator.kt @@ -0,0 +1,163 @@ +package me.grey.picquery.domain + +import android.graphics.Bitmap +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +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.showToast +import me.grey.picquery.data.data_source.ObjectBoxEmbeddingRepository +import me.grey.picquery.data.model.Album +import timber.log.Timber + +/** + * Search Orchestrator - Coordinates search operations + * + * Responsibilities: + * - Handle text search with translation support + * - Handle image search + * - Coordinate between EmbeddingService and SearchConfigurationService + * - Manage search lock to prevent concurrent searches + * - Handle translation errors gracefully + */ +class SearchOrchestrator( + private val embeddingService: EmbeddingService, + private val configurationService: SearchConfigurationService, + private val objectBoxEmbeddingRepository: ObjectBoxEmbeddingRepository, + private val translator: MLKitTranslator, + private val dispatcher: CoroutineDispatcher, + private val scope: CoroutineScope +) { + companion object { + private const val TAG = "SearchOrchestrator" + } + + private var searchingLock = false + + /** + * Search by text with translation support + * + * @param text Search query text (will be translated if needed) + * @param range Album range to search within + * @param isSearchAll Whether to search all albums + * @param onSuccess Callback with search results + */ + suspend fun searchByText( + text: String, + range: List, + isSearchAll: Boolean, + onSuccess: suspend (MutableList>) -> Unit + ) { + translateAndSearch(text, range, isSearchAll) { translatedText -> + // Encode text to vector before searching + val textVector = embeddingService.encodeText(translatedText) + performVectorSearchV2(textVector, range, isSearchAll, onSuccess) + } + } + + /** + * Search by image + * + * @param bitmap Image to search with + * @param range Album range to search within + * @param isSearchAll Whether to search all albums + * @param onSuccess Callback with search results + */ + suspend fun searchByImage( + bitmap: Bitmap, + range: List, + isSearchAll: Boolean, + onSuccess: suspend (MutableList>) -> Unit + ) { + withContext(dispatcher) { + if (searchingLock) { + Timber.tag(TAG).w("Search already in progress") + return@withContext + } + searchingLock = true + + try { + val imageFeatures = embeddingService.encodeBitmap(bitmap) + performVectorSearchV2(imageFeatures, range, isSearchAll, onSuccess) + } finally { + searchingLock = false + } + } + } + + /** + * Translate and search with fallback on error + */ + private suspend fun translateAndSearch( + text: String, + range: List, + isSearchAll: Boolean, + searchFunction: suspend (String) -> Unit + ) { + translator.translate( + text, + onSuccess = { translatedText -> + scope.launch { + searchFunction(translatedText) + } + }, + onError = { error -> + scope.launch { + // Fallback to original text on translation error + searchFunction(text) + handleTranslationError(error) + } + } + ) + } + + /** + * Handle translation errors with logging and user notification + */ + private fun handleTranslationError(error: Throwable) { + Timber.tag("MLTranslator").e( + context.getString(R.string.translation_error_log, error.message) + ) + showToast(context.getString(R.string.translation_error_toast)) + } + + /** + * Perform vector search V2 using ObjectBox + */ + private suspend fun performVectorSearchV2( + queryVector: FloatArray, + range: List, + isSearchAll: Boolean, + onSuccess: suspend (MutableList>) -> Unit + ) = withContext(dispatcher) { + try { + searchingLock = true + + Timber.tag(TAG).d("Starting vector search V2") + + val albumIds = if (range.isEmpty() || isSearchAll) { + Timber.tag(TAG).d("Search from all albums") + null + } else { + Timber.tag(TAG).d("Search from: [${range.joinToString { it.label }}]") + range.map { it.id } + } + + val searchResults = objectBoxEmbeddingRepository.searchNearestVectors( + queryVector = queryVector, + topK = configurationService.getTopK(), + similarityThreshold = configurationService.getMatchThreshold(), + albumIds = albumIds + ) + + Timber.tag(TAG).d("Search completed: found ${searchResults.size} results") + + val results = searchResults.map { it.get().photoId to it.score }.toMutableList() + onSuccess(results) + } finally { + searchingLock = false + } + } +} diff --git a/app/src/main/java/me/grey/picquery/domain/SimilarityConfigurationService.kt b/app/src/main/java/me/grey/picquery/domain/SimilarityConfigurationService.kt new file mode 100644 index 0000000..2966daa --- /dev/null +++ b/app/src/main/java/me/grey/picquery/domain/SimilarityConfigurationService.kt @@ -0,0 +1,139 @@ +package me.grey.picquery.domain + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * Similarity Configuration Service - Responsible for managing similarity grouping parameters + * + * Responsibilities: + * - Manage similarity threshold + * - Manage similarity delta + * - Manage minimum group size + * - Persist configuration to storage + * - Update GroupSimilarPhotosUseCase when configuration changes + */ +class SimilarityConfigurationService( + private val scope: CoroutineScope +) { + companion object { + private const val TAG = "SimilarityConfigService" + + const val DEFAULT_SIMILARITY_THRESHOLD = 0.96f + const val DEFAULT_SIMILARITY_DELTA = 0.02f + const val DEFAULT_MIN_GROUP_SIZE = 2 + + // Threshold boundaries + const val MIN_THRESHOLD = 0.80f + const val MAX_THRESHOLD = 0.99f + + // Delta boundaries + const val MIN_DELTA = 0.01f + const val MAX_DELTA = 0.10f + + // Group size boundaries + const val MIN_GROUP_SIZE = 2 + const val MAX_GROUP_SIZE = 10 + } + + private val _similarityThreshold = mutableFloatStateOf(DEFAULT_SIMILARITY_THRESHOLD) + val similarityThreshold: State = _similarityThreshold + + private val _similarityDelta = mutableFloatStateOf(DEFAULT_SIMILARITY_DELTA) + val similarityDelta: State = _similarityDelta + + private val _minGroupSize = mutableIntStateOf(DEFAULT_MIN_GROUP_SIZE) + val minGroupSize: State = _minGroupSize + + private var isInitialized = false + + /** + * Initialize configuration - Load saved configuration from storage + * Note: For future implementation - add persistence layer + */ + suspend fun initialize() { + if (isInitialized) { + Timber.tag(TAG).d("Already initialized, skipping") + return + } + + // Future: Load from PreferenceRepository or DataStore + // val (savedThreshold, savedDelta, savedMinGroupSize) = preferenceRepository.loadSimilarityConfiguration() + + isInitialized = true + Timber.tag(TAG).d( + "Configuration initialized: threshold=$DEFAULT_SIMILARITY_THRESHOLD, " + + "delta=$DEFAULT_SIMILARITY_DELTA, minGroupSize=$DEFAULT_MIN_GROUP_SIZE" + ) + } + + /** + * Update similarity configuration + * + * @param newSimilarityThreshold New similarity threshold (will be coerced to 0.80-0.99 range) + * @param newSimilarityDelta New similarity delta (will be coerced to 0.01-0.10 range) + * @param newMinGroupSize New minimum group size (will be coerced to 2-10 range) + */ + fun updateConfiguration( + newSimilarityThreshold: Float? = null, + newSimilarityDelta: Float? = null, + newMinGroupSize: Int? = null + ) { + newSimilarityThreshold?.let { + _similarityThreshold.floatValue = it.coerceIn(MIN_THRESHOLD, MAX_THRESHOLD) + } + newSimilarityDelta?.let { + _similarityDelta.floatValue = it.coerceIn(MIN_DELTA, MAX_DELTA) + } + newMinGroupSize?.let { + _minGroupSize.intValue = it.coerceIn(MIN_GROUP_SIZE, MAX_GROUP_SIZE) + } + + // Future: Asynchronously save to DataStore + // scope.launch { + // preferenceRepository.saveSimilarityConfiguration( + // _similarityThreshold.floatValue, + // _similarityDelta.floatValue, + // _minGroupSize.intValue + // ) + // } + + Timber.tag(TAG).d( + "Configuration updated: " + + "similarityThreshold=${_similarityThreshold.floatValue}, " + + "similarityDelta=${_similarityDelta.floatValue}, " + + "minGroupSize=${_minGroupSize.intValue}" + ) + } + + /** + * Reset to default configuration + */ + fun resetToDefaults() { + updateConfiguration( + DEFAULT_SIMILARITY_THRESHOLD, + DEFAULT_SIMILARITY_DELTA, + DEFAULT_MIN_GROUP_SIZE + ) + Timber.tag(TAG).d("Configuration reset to defaults") + } + + /** + * Get current similarity threshold + */ + fun getSimilarityThreshold(): Float = _similarityThreshold.floatValue + + /** + * Get current similarity delta + */ + fun getSimilarityDelta(): Float = _similarityDelta.floatValue + + /** + * Get current minimum group size + */ + fun getMinGroupSize(): Int = _minGroupSize.intValue +} diff --git a/app/src/main/java/me/grey/picquery/domain/SimilarityManager.kt b/app/src/main/java/me/grey/picquery/domain/SimilarityManager.kt index c3c8c43..7b9d81f 100644 --- a/app/src/main/java/me/grey/picquery/domain/SimilarityManager.kt +++ b/app/src/main/java/me/grey/picquery/domain/SimilarityManager.kt @@ -21,7 +21,6 @@ import me.grey.picquery.data.dao.ImageSimilarityDao import me.grey.picquery.data.data_source.EmbeddingRepository import me.grey.picquery.data.model.ImageSimilarity import me.grey.picquery.data.model.toFloatArray -import me.grey.picquery.domain.GroupSimilarPhotosUseCase.SimilarityNode import timber.log.Timber class GroupSimilarPhotosUseCase( @@ -143,50 +142,45 @@ class GroupSimilarPhotosUseCase( class SimilarityManager( private val imageSimilarityDao: ImageSimilarityDao, private val embeddingRepository: EmbeddingRepository, - private var similarityThreshold: Float = 0.96f, - private var similarityDelta: Float = 0.02f, - private var minGroupSize: Int = 2, + private val configurationService: SimilarityConfigurationService, private val pageSize: Int = 1000 ) { - // Update the method to include similarityDelta var groupSimilarPhotosUseCase = GroupSimilarPhotosUseCase( embeddingRepository, - similarityThreshold, - similarityDelta, - minGroupSize + configurationService.getSimilarityThreshold(), + configurationService.getSimilarityDelta(), + configurationService.getMinGroupSize() ) + private set - fun updateConfiguration( - newSimilarityThreshold: Float? = null, - newSimilarityDelta: Float? = null, - newMinGroupSize: Int? = null - ) { - newSimilarityThreshold?.let { similarityThreshold = it } - newSimilarityDelta?.let { similarityDelta = it } - newMinGroupSize?.let { minGroupSize = it } + init { + // Watch configuration changes and update use case + configurationService.similarityThreshold.hashCode() // Trigger observation if needed + } - // Recreate the use case with updated parameters + fun updateUseCaseConfiguration() { groupSimilarPhotosUseCase = GroupSimilarPhotosUseCase( embeddingRepository, - similarityThreshold, - similarityDelta, - minGroupSize + configurationService.getSimilarityThreshold(), + configurationService.getSimilarityDelta(), + configurationService.getMinGroupSize() ) // Reset cached groups when configuration changes synchronized(cacheLock) { _cachedSimilarityGroups.clear() isFullyLoaded = false } + Timber.tag("SimilarityManager").d("UseCase configuration updated") } // Rest of the existing code remains the same - private val _cachedSimilarityGroups = mutableListOf>() + private val _cachedSimilarityGroups = mutableListOf>() private val cacheLock = Any() private var isFullyLoaded = false @OptIn(ExperimentalCoroutinesApi::class) - fun groupSimilarPhotos(): Flow> = flow { + fun groupSimilarPhotos(): Flow> = flow { val cachedGroups = synchronized(cacheLock) { _cachedSimilarityGroups.toList() } @@ -200,7 +194,7 @@ class SimilarityManager( _cachedSimilarityGroups.clear() } - val allSimilarityGroups = mutableListOf>() + val allSimilarityGroups = mutableListOf>() imageSimilarityDao.getAllSimilaritiesFlow(pageSize) .map { similaritiesPage -> @@ -228,7 +222,7 @@ class SimilarityManager( } } - fun getSimilarityGroupByIndex(index: Int): List? { + fun getSimilarityGroupByIndex(index: Int): List? { synchronized(cacheLock) { if (!isFullyLoaded) return null @@ -248,7 +242,7 @@ class SimilarityManager( } @Suppress("unused") - fun getAllCachedSimilarityGroups(): List> { + fun getAllCachedSimilarityGroups(): List> { synchronized(cacheLock) { return if (isFullyLoaded) _cachedSimilarityGroups.toList() else emptyList() } diff --git a/app/src/main/java/me/grey/picquery/ui/albums/AlbumCard.kt b/app/src/main/java/me/grey/picquery/ui/albums/AlbumCard.kt index 8cb9b35..789ce09 100644 --- a/app/src/main/java/me/grey/picquery/ui/albums/AlbumCard.kt +++ b/app/src/main/java/me/grey/picquery/ui/albums/AlbumCard.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -41,7 +40,6 @@ fun AlbumCard(album: Album, selected: Boolean, onItemClick: (Album) -> Unit) { Column( Modifier.clickable( interactionSource = interactionSource, - indication = rememberRipple(), onClick = { onItemClick(album) } ) ) { diff --git a/app/src/main/java/me/grey/picquery/ui/common/AppSheetState.kt b/app/src/main/java/me/grey/picquery/ui/common/AppSheetState.kt index 7193f6c..a874438 100644 --- a/app/src/main/java/me/grey/picquery/ui/common/AppSheetState.kt +++ b/app/src/main/java/me/grey/picquery/ui/common/AppSheetState.kt @@ -2,42 +2,40 @@ * SPDX-FileCopyrightText: 2023 IacobIacob01 * SPDX-License-Identifier: Apache-2.0 */ +package me.grey.picquery.ui.common + import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @Composable fun rememberAppBottomSheetState(): AppBottomSheetState { + val isVisibleState = rememberSaveable { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - return rememberSaveable(saver = AppBottomSheetState.Saver()) { - AppBottomSheetState(sheetState) - } + + return AppBottomSheetState(sheetState, isVisibleState) } @OptIn(ExperimentalMaterial3Api::class) class AppBottomSheetState( - val sheetState: SheetState + val sheetState: SheetState, + private val isVisibleState: MutableState ) { - var isVisible by mutableStateOf(false) - private set - - internal constructor(sheetState: SheetState, isVisible: Boolean) : this(sheetState) { - this.isVisible = isVisible - } + val isVisible: Boolean + get() = isVisibleState.value suspend fun show() { if (!isVisible) { - isVisible = true + isVisibleState.value = true delay(10) sheetState.show() } @@ -47,20 +45,7 @@ class AppBottomSheetState( if (isVisible) { sheetState.hide() delay(10) - isVisible = false + isVisibleState.value = false } } - - companion object { - fun Saver(skipPartiallyExpanded: Boolean = true, confirmValueChange: (SheetValue) -> Boolean = { true }) = - Saver>( - save = { Pair(it.sheetState.currentValue, it.isVisible) }, - restore = { savedValue -> - AppBottomSheetState( - SheetState(skipPartiallyExpanded, savedValue.first, confirmValueChange), - savedValue.second - ) - } - ) - } } diff --git a/app/src/main/java/me/grey/picquery/ui/common/Logo.kt b/app/src/main/java/me/grey/picquery/ui/common/Logo.kt index 7e5c46f..9dbc24b 100644 --- a/app/src/main/java/me/grey/picquery/ui/common/Logo.kt +++ b/app/src/main/java/me/grey/picquery/ui/common/Logo.kt @@ -4,11 +4,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -43,16 +45,29 @@ fun LogoImage(modifier: Modifier = Modifier, size: Float = DEFAULT_LOGO_SIZE + 5 @Composable fun LogoText(modifier: Modifier = Modifier, size: Float = DEFAULT_LOGO_SIZE) { - val textStyle = - TextStyle(fontSize = size.sp, color = MaterialTheme.colorScheme.onBackground) + // 创建渐变色 + val gradientColors = listOf( + Color(0xFF0078D7), // 蓝色 + Color(0xFF41D1FF) // 浅蓝色 + ) + val brush = Brush.linearGradient( + colors = gradientColors, + start = Offset(0f, 0f), + end = Offset(200f, 0f) + ) + + val textStyle = TextStyle( + fontSize = size.sp, + brush = brush + ) + Row(modifier = modifier) { Text(text = stringResource(R.string.logo_part1_pic), style = textStyle) Text( text = stringResource(R.string.logo_part2_query), style = textStyle.copy( fontWeight = FontWeight.Bold, - fontSize = (size - 1).sp, - color = MaterialTheme.colorScheme.primary + fontSize = (size - 1).sp ) ) } diff --git a/app/src/main/java/me/grey/picquery/ui/home/AddAlbumBottomSheet.kt b/app/src/main/java/me/grey/picquery/ui/home/AddAlbumBottomSheet.kt index 8de4f79..19eef84 100644 --- a/app/src/main/java/me/grey/picquery/ui/home/AddAlbumBottomSheet.kt +++ b/app/src/main/java/me/grey/picquery/ui/home/AddAlbumBottomSheet.kt @@ -1,6 +1,6 @@ package me.grey.picquery.ui.home -import AppBottomSheetState +import me.grey.picquery.ui.common.AppBottomSheetState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/me/grey/picquery/ui/home/HomeScreen.kt b/app/src/main/java/me/grey/picquery/ui/home/HomeScreen.kt index 11efa4e..57c94e8 100644 --- a/app/src/main/java/me/grey/picquery/ui/home/HomeScreen.kt +++ b/app/src/main/java/me/grey/picquery/ui/home/HomeScreen.kt @@ -1,30 +1,34 @@ package me.grey.picquery.ui.home -import AppBottomSheetState +import me.grey.picquery.ui.common.AppBottomSheetState +import LogoImage import LogoRow +import LogoText import SearchInput import android.net.Uri import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Help import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FabPosition import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -42,6 +46,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.InternalTextApi +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState @@ -56,7 +61,7 @@ import me.grey.picquery.ui.search.SearchConfigBottomSheet import me.grey.picquery.ui.search.SearchRangeBottomSheet import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject -import rememberAppBottomSheetState +import me.grey.picquery.ui.common.rememberAppBottomSheetState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -74,6 +79,18 @@ fun HomeScreen( val userGuideVisible = remember { homeViewModel.userGuideVisible } val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val albumListSheetState = rememberAppBottomSheetState() + val imageSearcher: ImageSearcher = koinInject() + var showSearchFilterBottomSheet by remember { mutableStateOf(false) } + var showSearchRangeBottomSheet by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val busyHint = stringResource(R.string.busy_when_add_album_toast) + val onOpenIndexAlbums: () -> Unit = { + if (!albumManager.isEncoderBusy) { + scope.launch { albumListSheetState.show() } + } else { + showToast(busyHint) + } + } // Handle bottom sheet if (albumListSheetState.isVisible) { @@ -91,11 +108,11 @@ fun HomeScreen( topBar = { HomeTopBar( onClickHelpButton = homeViewModel::showUserGuide, + onOpenIndexAlbums = onOpenIndexAlbums, + onOpenSearchRange = { showSearchRangeBottomSheet = true }, + onOpenSearchConfig = { showSearchFilterBottomSheet = true }, navigateToSimilar = navigateToSimilar, - navigateToSetting = navigateToSetting, - albumListSheetState = albumListSheetState, - albumManager = albumManager, - imageSearcher = koinInject() + navigateToSetting = navigateToSetting ) } ) { padding -> @@ -105,9 +122,26 @@ fun HomeScreen( homeViewModel = homeViewModel, navigateToSearch = navigateToSearch, navigateToSearchWitImage = navigateToSearchWitImage, - albumListSheetState = albumListSheetState + albumListSheetState = albumListSheetState, + onOpenIndexAlbums = onOpenIndexAlbums, + onOpenSearchRange = { showSearchRangeBottomSheet = true }, + onOpenSearchConfig = { showSearchFilterBottomSheet = true }, + navigateToSimilar = navigateToSimilar, + navigateToSetting = navigateToSetting + ) + } + + if (showSearchFilterBottomSheet) { + SearchConfigBottomSheet( + imageSearcher = imageSearcher, + onDismiss = { showSearchFilterBottomSheet = false } ) } + if (showSearchRangeBottomSheet) { + SearchRangeBottomSheet(dismiss = { + showSearchRangeBottomSheet = false + }) + } } @Composable @@ -117,20 +151,30 @@ private fun MainContent( homeViewModel: HomeViewModel, navigateToSearch: (String) -> Unit, navigateToSearchWitImage: (Uri) -> Unit, - albumListSheetState: AppBottomSheetState + albumListSheetState: AppBottomSheetState, + onOpenIndexAlbums: () -> Unit, + onOpenSearchRange: () -> Unit, + onOpenSearchConfig: () -> Unit, + navigateToSimilar: () -> Unit, + navigateToSetting: () -> Unit ) { Column( modifier = Modifier .padding(padding) - .fillMaxHeight(0.75f), - verticalArrangement = Arrangement.Center, + .fillMaxSize(), + verticalArrangement = if (userGuideVisible) Arrangement.Center else Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally ) { SearchSection( userGuideVisible = userGuideVisible, homeViewModel = homeViewModel, navigateToSearch = navigateToSearch, - navigateToSearchWitImage = navigateToSearchWitImage + navigateToSearchWitImage = navigateToSearchWitImage, + onOpenIndexAlbums = onOpenIndexAlbums, + onOpenSearchRange = onOpenSearchRange, + onOpenSearchConfig = onOpenSearchConfig, + navigateToSimilar = navigateToSimilar, + navigateToSetting = navigateToSetting ) GuideSection( @@ -147,10 +191,16 @@ private fun SearchSection( userGuideVisible: Boolean, homeViewModel: HomeViewModel, navigateToSearch: (String) -> Unit, - navigateToSearchWitImage: (Uri) -> Unit + navigateToSearchWitImage: (Uri) -> Unit, + onOpenIndexAlbums: () -> Unit, + onOpenSearchRange: () -> Unit, + onOpenSearchConfig: () -> Unit, + navigateToSimilar: () -> Unit, + navigateToSetting: () -> Unit ) { AnimatedVisibility(visible = !userGuideVisible) { Column( + modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { @@ -171,6 +221,16 @@ private fun SearchSection( } } ) + + Spacer(modifier = Modifier.size(24.dp)) + + QuickActionsSection( + onOpenIndexAlbums = onOpenIndexAlbums, + onOpenSearchRange = onOpenSearchRange, + onOpenSearchConfig = onOpenSearchConfig, + navigateToSimilar = navigateToSimilar, + navigateToSetting = navigateToSetting + ) } } } @@ -219,107 +279,17 @@ fun rememberMediaPermissions( @Composable private fun HomeTopBar( onClickHelpButton: () -> Unit, + onOpenIndexAlbums: () -> Unit, + onOpenSearchRange: () -> Unit, + onOpenSearchConfig: () -> Unit, navigateToSimilar: () -> Unit, - navigateToSetting: () -> Unit, - albumListSheetState: AppBottomSheetState = rememberAppBottomSheetState(), - albumManager: AlbumManager = koinInject(), - imageSearcher: ImageSearcher = koinInject() + navigateToSetting: () -> Unit ) { - var showSearchFilterBottomSheet by remember { mutableStateOf(false) } - var showSearchRangeBottomSheet by remember { mutableStateOf(false) } - var expanded by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - val hint = stringResource(R.string.busy_when_add_album_toast) TopAppBar( - title = { Text("PicQuery") }, + title = { + LogoText(size = 20f) + }, actions = { - Box { - IconButton(onClick = { expanded = true }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = "More options" - ) - } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.similar_photos)) }, - onClick = { - expanded = false - navigateToSimilar() - }, - leadingIcon = { - Icon( - painterResource(R.drawable.ic_similar), - contentDescription = "Similar Photos", - modifier = Modifier.size(width = 24.dp, height = 24.dp) - ) - } - ) - - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_index_albums)) }, - onClick = { - expanded = false - if (!albumManager.isEncoderBusy) { - scope.launch { albumListSheetState.show() } - } else { - showToast(hint) - } - }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "Index Albums" - ) - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_search_range)) }, - onClick = { - expanded = false - showSearchRangeBottomSheet = true - }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.FilterList, - contentDescription = "Search Range" - ) - } - ) - - DropdownMenuItem( - text = { Text(stringResource(R.string.image_search_config_title)) }, - onClick = { - expanded = false - showSearchFilterBottomSheet = true - }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = "Search Configuration" - ) - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_settings)) }, - onClick = { - expanded = false - navigateToSetting() - }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.Settings, - contentDescription = "Settings" - ) - } - ) - } - } - IconButton(onClick = onClickHelpButton) { Icon( imageVector = Icons.AutoMirrored.Filled.Help, @@ -328,16 +298,108 @@ private fun HomeTopBar( } } ) +} - if (showSearchFilterBottomSheet) { - SearchConfigBottomSheet( - imageSearcher = imageSearcher, - onDismiss = { showSearchFilterBottomSheet = false } +@Composable +private fun QuickActionsSection( + onOpenIndexAlbums: () -> Unit, + onOpenSearchRange: () -> Unit, + onOpenSearchConfig: () -> Unit, + navigateToSimilar: () -> Unit, + navigateToSetting: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.quick_actions_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + QuickActionButton( + title = stringResource(R.string.menu_index_albums_short), + onClick = onOpenIndexAlbums + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + QuickActionButton( + title = stringResource(R.string.similar_photos_short), + onClick = navigateToSimilar + ) { + Icon( + painter = painterResource(R.drawable.ic_similar), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + QuickActionButton( + title = stringResource(R.string.menu_search_range_short), + onClick = onOpenSearchRange + ) { + Icon( + imageVector = Icons.Filled.FilterList, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + QuickActionButton( + title = stringResource(R.string.menu_settings), + onClick = navigateToSetting + ) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } } - if (showSearchRangeBottomSheet) { - SearchRangeBottomSheet(dismiss = { - showSearchRangeBottomSheet = false - }) +} + +@Composable +private fun QuickActionButton( + title: String, + onClick: () -> Unit, + icon: @Composable () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .clickable(onClick = onClick) + .padding(8.dp) + ) { + Box( + modifier = Modifier + .size(48.dp) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = MaterialTheme.shapes.medium + ), + contentAlignment = Alignment.Center + ) { + icon() + } + Text( + text = title, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1 + ) } } diff --git a/app/src/main/java/me/grey/picquery/ui/home/HomeViewModel.kt b/app/src/main/java/me/grey/picquery/ui/home/HomeViewModel.kt index 20a295f..5674b01 100644 --- a/app/src/main/java/me/grey/picquery/ui/home/HomeViewModel.kt +++ b/app/src/main/java/me/grey/picquery/ui/home/HomeViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import me.grey.picquery.domain.ImageSearcher +import timber.log.Timber data class UserGuideTaskState( val permissionDone: Boolean = false, @@ -18,7 +19,8 @@ data class UserGuideTaskState( } class HomeViewModel( - private val imageSearcher: ImageSearcher + private val imageSearcher: ImageSearcher, + private val preferenceRepository: me.grey.picquery.data.data_source.PreferenceRepository ) : ViewModel() { companion object { @@ -38,10 +40,25 @@ class HomeViewModel( init { viewModelScope.launch { - if (!imageSearcher.hasEmbedding()) { - userGuideVisible.value = true + // 检查用户是否已经完成过引导 + val guideCompleted = preferenceRepository.isUserGuideCompleted() + val hasData = imageSearcher.hasEmbedding() + + if (guideCompleted || hasData) { + // 用户已经完成引导或有索引数据,不需要显示引导 + currentGuideState.value = UserGuideTaskState( + permissionDone = true, + indexDone = true + ) + userGuideVisible.value = false + + // 如果有数据但标记未设置,更新标记 + if (hasData && !guideCompleted) { + preferenceRepository.setUserGuideCompleted(true) + } } else { - currentGuideState.value = currentGuideState.value.copy(indexDone = true) + // 首次使用,需要显示引导 + userGuideVisible.value = true } } } @@ -51,7 +68,7 @@ class HomeViewModel( } fun doneRequestPermission() { - Log.d(TAG, "doneRequestPermission") + Timber.tag(TAG).d("doneRequestPermission") currentGuideState.value = currentGuideState.value.copy(permissionDone = true) } @@ -61,5 +78,9 @@ class HomeViewModel( fun finishGuide() { userGuideVisible.value = false + // 标记用户已完成引导 + viewModelScope.launch { + preferenceRepository.setUserGuideCompleted(true) + } } } diff --git a/app/src/main/java/me/grey/picquery/ui/home/InitPermissions.kt b/app/src/main/java/me/grey/picquery/ui/home/InitPermissions.kt index 02b7104..d9fb07c 100644 --- a/app/src/main/java/me/grey/picquery/ui/home/InitPermissions.kt +++ b/app/src/main/java/me/grey/picquery/ui/home/InitPermissions.kt @@ -13,9 +13,12 @@ fun InitPermissions(homeViewModel: HomeViewModel = koinViewModel(), albumManager val mediaPermissions = rememberMediaPermissions() InitializeEffect { if (mediaPermissions.allPermissionsGranted) { + // 权限已授予,初始化相册列表 albumManager.initAllAlbumList() + homeViewModel.doneRequestPermission() } else { - homeViewModel.showUserGuide() + // 没有权限,显示引导(但只在没有数据的情况下) + // userGuideVisible 的状态由 HomeViewModel 的 init 决定 } } } diff --git a/app/src/main/java/me/grey/picquery/ui/search/SearchFilterBottomSheet.kt b/app/src/main/java/me/grey/picquery/ui/search/SearchFilterBottomSheet.kt index e2cd4f4..173a6e8 100644 --- a/app/src/main/java/me/grey/picquery/ui/search/SearchFilterBottomSheet.kt +++ b/app/src/main/java/me/grey/picquery/ui/search/SearchFilterBottomSheet.kt @@ -1,6 +1,6 @@ package me.grey.picquery.ui.search -import AppBottomSheetState +import me.grey.picquery.ui.common.AppBottomSheetState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi diff --git a/app/src/main/java/me/grey/picquery/ui/search/SearchResultList.kt b/app/src/main/java/me/grey/picquery/ui/search/SearchResultList.kt index 643f213..de06922 100644 --- a/app/src/main/java/me/grey/picquery/ui/search/SearchResultList.kt +++ b/app/src/main/java/me/grey/picquery/ui/search/SearchResultList.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.ElevatedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -141,7 +140,6 @@ private fun PhotoResultRecommend(photo: Photo, onItemClick: (photo: Photo) -> Un .clip(RoundedCornerShape(12.dp)) .combinedClickable( interactionSource = interactionSource, - indication = rememberRipple(), onClick = { onItemClick(photo) } ), model = File(photo.path), diff --git a/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarPhotosScreen.kt b/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarPhotosScreen.kt index 957b8bc..0168224 100644 --- a/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarPhotosScreen.kt +++ b/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarPhotosScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.ImageNotSupported -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -64,14 +63,6 @@ fun SimilarPhotosScreen( configuration.similarityGroupDelta != lastConfiguration.similarityGroupDelta ) { similarPhotosViewModel.resetState() -// similarPhotosViewModel.updateSimilarityConfiguration( -// searchImageSimilarityThreshold = configuration.searchImageSimilarityThreshold, -// similarityDelta = configuration.similarityGroupDelta -// ) -// lastConfiguration = SimilarityConfiguration( -// searchImageSimilarityThreshold = configuration.searchImageSimilarityThreshold, -// similarityGroupDelta = configuration.similarityGroupDelta -// ) } } @@ -176,12 +167,14 @@ fun SimilarPhotosGroup( onPhotoClick: (Int, Int, List) -> Unit ) { LazyColumn( - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(16.dp), modifier = modifier ) { - items(photos) { photoGroup -> - val groupIndex = photos.indexOf(photoGroup) + itemsIndexed( + items = photos, + key = { index, group -> "${index}_${group.firstOrNull()?.id ?: 0L}" } + ) { groupIndex, photoGroup -> // Use first photo's modification time as group title val firstPhoto = photoGroup.firstOrNull() @@ -195,43 +188,61 @@ fun SimilarPhotosGroup( ).toString() } ?: "Group ${groupIndex + 1}" - // Group header with more prominent styling - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp) ) { - Text( - text = groupTitle, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + // Group header with clearer hierarchy + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = groupTitle, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) - Text( - text = stringResource(R.string.photo_group_count, photoGroup.size), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + AssistChip( + onClick = { }, + label = { + Text( + text = stringResource(R.string.photo_group_count, photoGroup.size), + style = MaterialTheme.typography.labelMedium + ) + }, + enabled = false + ) + } - // Horizontal scrollable row of photos in the group - LazyRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 4.dp) - ) { - itemsIndexed(photoGroup) { photoIndex, photo -> - PhotoGroupItem( - photo = photo, - modifier = Modifier - .size(140.dp) - .shadow( - elevation = 4.dp, - shape = RoundedCornerShape(2.dp) - ), - onClick = { onPhotoClick(groupIndex, photoIndex, photoGroup) } - ) + Spacer(modifier = Modifier.height(8.dp)) + + // Horizontal scrollable row of photos in the group + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 4.dp) + ) { + itemsIndexed( + items = photoGroup, + key = { _, photo -> photo.id } + ) { photoIndex, photo -> + PhotoGroupItem( + photo = photo, + modifier = Modifier.size(152.dp), + onClick = { onPhotoClick(groupIndex, photoIndex, photoGroup) } + ) + } + } } } } @@ -246,17 +257,10 @@ fun PhotoGroupItem(photo: Photo, modifier: Modifier = Modifier, onClick: () -> U Box( modifier = modifier .aspectRatio(1f) - .clip( - RoundedCornerShape( - topStart = 2.dp, - topEnd = 2.dp, - bottomStart = 2.dp, - bottomEnd = 2.dp - ) - ) + .clip(RoundedCornerShape(12.dp)) + .shadow(2.dp, RoundedCornerShape(12.dp)) .combinedClickable( interactionSource = interactionSource, - indication = rememberRipple(), onClick = onClick ) ) { diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2e54115..5f9aa0d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -27,6 +27,11 @@ 描述你想要的照片 正在索引中,请稍后再添加更多相册 未选择任何相册! + 快捷功能 + 快速访问常用功能 + 索引 + 相似 + 范围 搜索范围 从已索引的相册中筛选 全部相册 @@ -123,4 +128,8 @@ 选择图片来源 系统相册 外部相册 + + + 中文->英文翻译出错!\n%1$s + 翻译模型出错,请反馈给开发者! diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 165601b..387a861 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,11 @@ Describe your photo We are busy! Please add albums later No albums selected! + Quick Actions + Quick access to common features + Index + Similar + Range Search Filter Change the search scope All albums @@ -125,4 +130,8 @@ Select Image Source System Album External Album + + + Chinese to English translation error!\n%1$s + Translation model error, please report to developer! \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 4817a1d..2d0b4ef 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,7 +26,7 @@ plugins { // Add ktlint plugin id("org.jlleitschuh.gradle.ktlint") version "11.5.1" apply false // Add detekt plugin - id("io.gitlab.arturbosch.detekt") version "1.23.3" apply false + id("io.gitlab.arturbosch.detekt") version "1.23.8" apply false } // Apply ktlint to all projects @@ -48,4 +48,4 @@ subprojects { } } -// Remove the temporary .editorconfig approach since we now have a permanent file \ No newline at end of file +// Remove the temporary .editorconfig approach since we now have a permanent file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 017ddb5..f675755 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,53 +1,52 @@ [versions] # Android and Kotlin -android-gradle-plugin = "8.4.2" -kotlin = "1.9.22" -ksp = "1.9.22-1.0.17" -kotlin-serialization = "1.9.22" +android-gradle-plugin = "8.9.3" +kotlin = "2.3.0" +ksp = "2.3.0" +kotlin-serialization = "2.3.0" # Compose -compose-bom = "2024.02.00" +compose-bom = "2026.01.01" compose = "1.6.0" -compose-compiler = "1.5.9" -compose-material3 = "1.2.0" -compose-material-icons = "1.6.0" +compose-material3 = "1.4.0" +compose-material-icons = "1.7.8" # Accompanist accompanist = "0.32.0" # SplashScreen -androidx-splashscreen = "1.0.1" +androidx-splashscreen = "1.2.0" # Kotlin Coroutines -coroutines = "1.9.0" +coroutines = "1.10.2" # Dependency Injection -koin = "3.5.3" -koin-compose = "1.1.2" -koin-androidx-compose = "3.5.3" +koin = "4.1.1" +koin-compose = "4.1.1" +koin-androidx-compose = "4.1.1" # Room Database -litert = "1.0.1" -litert-gpu = "1.0.1" -litert-gpu-api = "1.0.1" -litert-support = "1.0.1" -objectboxAndroidObjectbrowser = "4.3.0" -objectboxAndroid = "4.3.0" -room = "2.6.1" +litert = "1.4.1" +litert-gpu = "1.4.1" +litert-gpu-api = "1.4.1" +litert-support = "1.4.1" +objectboxAndroidObjectbrowser = "5.1.0" +objectboxAndroid = "5.1.0" +room = "2.8.4" # Navigation -navigation = "2.5.3" +navigation = "2.9.7" # AndroidX -androidx-core = "1.9.0" -androidx-lifecycle = "2.6.1" -androidx-activity-compose = "1.4.0" +androidx-core = "1.17.0" +androidx-lifecycle = "2.10.0" +androidx-activity-compose = "1.12.3" androidx-legacy = "1.0.0" -androidx-test-monitor = "1.5.0" +androidx-test-monitor = "1.8.0" androidx-test-ext = "1.1.3" -androidx-datastore = "1.0.0" -androidx-work = "2.9.0" -androidxDataStore = "1.1.1" +androidx-datastore = "1.2.0" +androidx-work = "2.11.1" +androidxDataStore = "1.2.0" objectboxGradlePlugin = "4.3.0" # Logging @@ -55,22 +54,23 @@ timber = "5.0.1" # Image Loading glide = "4.14.0" -glide-compose = "1.0.0-alpha.1" +glide-compose = "1.0.0-beta08" +coil = "2.7.0" # Other Libraries -workRuntime = "2.9.0" -zoomable = "1.5.0" -permissionx = "1.7.1" -kotlinx-serialization = "1.6.3" +workRuntime = "2.11.1" +zoomable = "2.10.0" +permissionx = "1.8.1" +kotlinx-serialization = "1.10.0" # AI & ML -onnx = "1.16.1" -mlkit-translate = "17.0.1" +onnx = "1.23.2" +mlkit-translate = "17.0.3" # Testing junit = "4.13.2" espresso = "3.4.0" -googleOssLicensesPlugin = "0.10.6" +googleOssLicensesPlugin = "0.10.10" [libraries] @@ -138,6 +138,8 @@ timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "tim glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } glide-compiler = { group = "com.github.bumptech.glide", name = "compiler", version.ref = "glide" } glide-compose = { group = "com.github.bumptech.glide", name = "compose", version.ref = "glide-compose" } +coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } # Other Libraries work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" } @@ -159,6 +161,7 @@ androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", v android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-serialization" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }