diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8ac20b8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +tab_width = 4 + +[*.{kt,kts}] +# Disable specific ktlint rules +ktlint_disabled_rules = no-wildcard-imports,filename,package-name,max-line-length,comment-spacing +# Enable experimental ktlint rules +ktlint_experimental = true +# Android style rules +ktlint_standard_filename = false +ktlint_standard_class-naming = true +ktlint_standard_import-ordering = true + +[*.{yml,yaml}] +indent_size = 2 + +[*.{md,markdown}] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index 3e82c99..4892439 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ key.properties website/docs/.vitepress/cache/ website/docs/.vitepress/dist/ website/docs/.vitepress/dist-ssr/ -node_modules/ \ No newline at end of file +node_modules/ +/.vscode/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c5f3f6b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle.kts similarity index 56% rename from app/build.gradle rename to app/build.gradle.kts index 70e0f82..e582167 100644 --- a/app/build.gradle +++ b/app/build.gradle.kts @@ -3,27 +3,29 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.serialization) + id("io.objectbox") + id("io.gitlab.arturbosch.detekt") } android { - namespace 'me.grey.picquery' - compileSdk 34 + namespace = "me.grey.picquery" + compileSdk = 36 defaultConfig { - applicationId "me.grey.picquery" - minSdk 29 - targetSdk 35 - versionCode 8 - versionName "1.2.0" + applicationId = "me.grey.picquery" + minSdk = 29 + targetSdk = 35 + versionCode = 8 + versionName = "1.2.0" - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { - useSupportLibrary true + useSupportLibrary = true } ndk { //noinspection ChromeOsAbiSupport - abiFilters 'armeabi-v7a', 'arm64-v8a' + abiFilters += listOf("armeabi-v7a", "arm64-v8a") } } @@ -31,38 +33,55 @@ android { debug {} release { - minifyEnabled true - shrinkResources true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '17' + jvmTarget = "17" } buildFeatures { - compose true + compose = true } composeOptions { - kotlinCompilerExtensionVersion libs.versions.compose.compiler.get() + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } - packagingOptions { + packaging { resources { - excludes += '/META-INF/{AL2.0,LGPL2.1}' + excludes.add("/META-INF/{AL2.0,LGPL2.1}") } } - buildToolsVersion = '34.0.0' + buildToolsVersion = "34.0.0" } dependencies { - def composeBom = platform(libs.compose.bom) - implementation composeBom - androidTestImplementation composeBom + // Bill of Materials + val composeBom = platform(libs.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + // Implementation dependencies + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.livedata) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.legacy) + implementation(libs.androidx.datastore) + implementation(libs.androidx.dataStore) + implementation(libs.androidx.work.runtime) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.splashscreen) // Compose implementation(libs.compose.ui) @@ -70,10 +89,6 @@ dependencies { implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) implementation(libs.compose.material.icons.extended) - debugImplementation(libs.compose.ui.tooling) - - // SplashScreen - implementation(libs.androidx.splashscreen) // Accompanist implementation(libs.accompanist.systemuicontroller) @@ -87,9 +102,6 @@ dependencies { implementation(libs.koin.androidx.compose) implementation(libs.koin.androidx.compose.navigation) - // DataStore - implementation(libs.androidx.dataStore) - // Coroutines implementation(libs.coroutines.core) implementation(libs.coroutines.android) @@ -97,23 +109,9 @@ dependencies { // Serialization implementation(libs.kotlinx.serialization) - // AndroidX - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.legacy) - implementation(libs.androidx.test.monitor) - implementation(libs.androidx.test.ext) - implementation(libs.androidx.lifecycle.livedata) - implementation(libs.androidx.lifecycle.viewmodel) - implementation(libs.androidx.datastore) - implementation(libs.androidx.work.runtime) - implementation(libs.androidx.navigation.compose) - // Room implementation(libs.room.runtime) implementation(libs.room.ktx) - ksp(libs.room.compiler) // Logging implementation(libs.timber) @@ -121,27 +119,50 @@ dependencies { // Image Loading implementation(libs.glide) implementation(libs.glide.compose) - annotationProcessor(libs.glide.compiler) // Other Libraries implementation(libs.zoomable) implementation(libs.permissionx) + implementation(libs.work.runtime) // AI & ML implementation(libs.onnx.runtime) implementation(libs.mlkit.translate) - /// LiteRT - implementation libs.litert - implementation libs.litert.support - implementation libs.litert.gpu.api - implementation libs.litert.gpu + // LiteRT + implementation(libs.litert) + implementation(libs.litert.support) + implementation(libs.litert.gpu.api) + implementation(libs.litert.gpu) + + // Debug implementation + debugImplementation(libs.compose.ui.tooling) + + // Annotation processors + annotationProcessor(libs.glide.compiler) - // Testing + // KSP + ksp(libs.room.compiler) + + // Test implementation testImplementation(libs.junit) + + // Android test implementation androidTestImplementation(libs.androidx.test.ext) androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.androidx.test.monitor) + androidTestImplementation(libs.androidx.test.ext) +} - implementation libs.androidx.work.runtime - implementation libs.work.runtime -} \ No newline at end of file +detekt { + toolVersion = "1.23.3" + config.setFrom(files("${project.rootDir}/config/detekt/detekt.yml")) + buildUponDefaultConfig = true + autoCorrect = true + parallel = true + ignoreFailures = true // Set to true to make detekt non-blocking +} + +tasks.withType().configureEach { + jvmTarget = "17" +} diff --git a/app/objectbox-models/default.json b/app/objectbox-models/default.json new file mode 100644 index 0000000..10be56c --- /dev/null +++ b/app/objectbox-models/default.json @@ -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/proguard-rules.pro b/app/proguard-rules.pro index be4bd23..a37a130 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,6 +1,6 @@ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. +# proguardFiles setting in build.gradle.kts. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html diff --git a/app/src/androidTest/java/me/grey/picquery/ExampleInstrumentedTest.kt b/app/src/androidTest/java/me/grey/picquery/ExampleInstrumentedTest.kt index 5c6485f..9474ebf 100644 --- a/app/src/androidTest/java/me/grey/picquery/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/me/grey/picquery/ExampleInstrumentedTest.kt @@ -1,19 +1,12 @@ package me.grey.picquery -import android.util.Log import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - -} +class ExampleInstrumentedTest diff --git a/app/src/androidTest/java/me/grey/picquery/TopkTest.kt b/app/src/androidTest/java/me/grey/picquery/TopkTest.kt index 574b6dc..ec7fc25 100644 --- a/app/src/androidTest/java/me/grey/picquery/TopkTest.kt +++ b/app/src/androidTest/java/me/grey/picquery/TopkTest.kt @@ -1,2 +1 @@ package me.grey.picquery - diff --git a/app/src/main/java/me/grey/picquery/PicQueryApplication.kt b/app/src/main/java/me/grey/picquery/PicQueryApplication.kt index 3344bec..8d97140 100644 --- a/app/src/main/java/me/grey/picquery/PicQueryApplication.kt +++ b/app/src/main/java/me/grey/picquery/PicQueryApplication.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.app.Application import android.content.Context import me.grey.picquery.common.AppModules +import me.grey.picquery.data.ObjectBoxDatabase import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin @@ -27,7 +28,7 @@ class PicQueryApplication : Application() { androidLogger() androidContext(this@PicQueryApplication) modules(AppModules) + ObjectBoxDatabase.getDatabase().initialize(this@PicQueryApplication) } } } - 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 d6ecd47..612ed1d 100644 --- a/app/src/main/java/me/grey/picquery/common/AppModules.kt +++ b/app/src/main/java/me/grey/picquery/common/AppModules.kt @@ -2,9 +2,11 @@ package me.grey.picquery.common import androidx.work.WorkManager import me.grey.picquery.data.AppDatabase +import me.grey.picquery.data.ObjectBoxDatabase import me.grey.picquery.data.dao.EmbeddingDao import me.grey.picquery.data.data_source.AlbumRepository import me.grey.picquery.data.data_source.EmbeddingRepository +import me.grey.picquery.data.data_source.ObjectBoxEmbeddingRepository import me.grey.picquery.data.data_source.PhotoRepository import me.grey.picquery.data.data_source.PreferenceRepository import me.grey.picquery.domain.AlbumManager @@ -35,10 +37,13 @@ private val viewModelModules = module { DisplayViewModel(photoRepository = get(), imageSearcher = get()) } - viewModel { SettingViewModel(preferenceRepository = get()) } - viewModel { SimilarPhotosViewModel(get(),get(),get(),get()) } - viewModel { PhotoDetailViewModel(get(),get()) } + + viewModel { PhotoDetailViewModel(get(), get()) } + + single { + SimilarPhotosViewModel(get(), get(), get(), get(), get()) + } } private val dataModules = module { @@ -50,6 +55,11 @@ private val dataModules = module { single { get().imageSimilarityDao() } single { AlbumRepository(androidContext().contentResolver, database = get()) } single { EmbeddingRepository(dataSource = get()) } + single { + ObjectBoxEmbeddingRepository( + dataSource = ObjectBoxDatabase.getDatabase().embeddingDao() + ) + } single { PhotoRepository(androidContext()) } single { PreferenceRepository() } } @@ -60,6 +70,7 @@ private val domainModules = module { imageEncoder = get(), textEncoder = get(), embeddingRepository = get(), + objectBoxEmbeddingRepository = get(), translator = MLKitTranslator(), dispatcher = get() ) @@ -76,7 +87,7 @@ private val domainModules = module { single { MLKitTranslator() } - single { SimilarityManager(get(),get()) } + single { SimilarityManager(get(), get()) } } val workManagerModule = module { @@ -84,5 +95,11 @@ val workManagerModule = module { } // need inject encoder here -val AppModules = listOf(dispatchersKoinModule, viewModelModules, dataModules, modulesCLIP, domainModules, workManagerModule) - +val AppModules = listOf( + dispatchersKoinModule, + viewModelModules, + dataModules, + modulesCLIP, + domainModules, + workManagerModule +) diff --git a/app/src/main/java/me/grey/picquery/common/AssetUtil.kt b/app/src/main/java/me/grey/picquery/common/AssetUtil.kt index cb5b114..1734daf 100644 --- a/app/src/main/java/me/grey/picquery/common/AssetUtil.kt +++ b/app/src/main/java/me/grey/picquery/common/AssetUtil.kt @@ -1,12 +1,12 @@ package me.grey.picquery.common import android.content.Context +import java.io.* import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -import java.io.* object AssetUtil { @@ -30,7 +30,7 @@ object AssetUtil { } }.fold( left = { Timber.tag(TAG).e(it, "copyAssetsFolder: ") }, - right = { Timber.tag(TAG).d("copyAssetsFolder: Success") }, + right = { Timber.tag(TAG).d("copyAssetsFolder: Success") } ) } } @@ -83,7 +83,6 @@ object AssetUtil { } os.flush() } - } } @@ -144,4 +143,4 @@ object AssetUtil { null } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/common/Callback.kt b/app/src/main/java/me/grey/picquery/common/Callback.kt index 1ebef85..80043cb 100644 --- a/app/src/main/java/me/grey/picquery/common/Callback.kt +++ b/app/src/main/java/me/grey/picquery/common/Callback.kt @@ -1,4 +1,3 @@ package me.grey.picquery.common - -typealias encodeProgressCallback = (cur: Int, total: Int, cost: Long) -> Unit \ No newline at end of file +typealias encodeProgressCallback = (cur: Int, total: Int, cost: Long) -> Unit diff --git a/app/src/main/java/me/grey/picquery/common/Constants.kt b/app/src/main/java/me/grey/picquery/common/Constants.kt index 32757c9..b2efae3 100644 --- a/app/src/main/java/me/grey/picquery/common/Constants.kt +++ b/app/src/main/java/me/grey/picquery/common/Constants.kt @@ -22,5 +22,4 @@ object Constants { const val PRIVACY_URL = "https://grey030.gitee.io/pages/picquery/privacy.html" const val SOURCE_REPO_URL = "https://github.com/greyovo/PicQuery" - -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/common/Convertor.kt b/app/src/main/java/me/grey/picquery/common/Convertor.kt index 5396c79..96c8d46 100644 --- a/app/src/main/java/me/grey/picquery/common/Convertor.kt +++ b/app/src/main/java/me/grey/picquery/common/Convertor.kt @@ -8,10 +8,8 @@ package me.grey.picquery.common * @param costPerItem In milliseconds * @return Seconds in Long that represent the remaining time */ -fun calculateRemainingTime( - current: Int, total: Int, costPerItem: Long -): Long { +fun calculateRemainingTime(current: Int, total: Int, costPerItem: Long): Long { if (costPerItem.toInt() == 0) return 0L val remainItem = (total - current) return (remainItem * (costPerItem) / 1000) -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/common/Either.kt b/app/src/main/java/me/grey/picquery/common/Either.kt index 5964e1f..f23af23 100644 --- a/app/src/main/java/me/grey/picquery/common/Either.kt +++ b/app/src/main/java/me/grey/picquery/common/Either.kt @@ -14,20 +14,15 @@ sealed class Either { } } -fun Either.fold( - left: (T) -> Any, - right: (R) -> Any, -): Any = - when (this) { - is Either.Left -> left(value) - is Either.Right -> right(value) - } +fun Either.fold(left: (T) -> Any, right: (R) -> Any): Any = when (this) { + is Either.Left -> left(value) + is Either.Right -> right(value) +} -fun Either.getOrHandle(default: (T) -> R): R = - when (this) { - is Either.Left -> default(value) - is Either.Right -> value - } +fun Either.getOrHandle(default: (T) -> R): R = when (this) { + is Either.Left -> default(value) + is Either.Right -> value +} suspend fun tryCatch(block: () -> R): Either { return try { @@ -37,5 +32,3 @@ suspend fun tryCatch(block: () -> R): Either { Either.left(e) } } - - 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 e857df5..48992d6 100644 --- a/app/src/main/java/me/grey/picquery/common/ImageUtil.kt +++ b/app/src/main/java/me/grey/picquery/common/ImageUtil.kt @@ -38,10 +38,7 @@ fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeig return inSampleSize } -fun decodeSampledBitmapFromFile( - pathName: String, - size: Size, -): Bitmap? { +fun decodeSampledBitmapFromFile(pathName: String, size: Size): Bitmap? { // First decode with inJustDecodeBounds=true to check dimensions return try { BitmapFactory.Options().run { diff --git a/app/src/main/java/me/grey/picquery/common/UiUtil.kt b/app/src/main/java/me/grey/picquery/common/UiUtil.kt index 6dad27d..b7f2091 100644 --- a/app/src/main/java/me/grey/picquery/common/UiUtil.kt +++ b/app/src/main/java/me/grey/picquery/common/UiUtil.kt @@ -18,7 +18,6 @@ import me.grey.picquery.PicQueryApplication private val context get() = PicQueryApplication.context - fun showToast(text: String, longToast: Boolean = false) { val duration = if (longToast) Toast.LENGTH_LONG else Toast.LENGTH_SHORT Toast.makeText(context, text, duration).show() @@ -35,7 +34,6 @@ fun InitializeEffect(block: suspend CoroutineScope.() -> Unit) { } } - object Animation { /** * Value in ms @@ -50,9 +48,7 @@ object Animation { val popInAnimation = slideInHorizontally { width -> width } val popUpAnimation = slideOutHorizontally { width -> -width } - fun enterAnimation(durationMillis: Int): EnterTransition = - fadeIn(tween(durationMillis)) + fun enterAnimation(durationMillis: Int): EnterTransition = fadeIn(tween(durationMillis)) - fun exitAnimation(durationMillis: Int): ExitTransition = - fadeOut(tween(durationMillis)) -} \ No newline at end of file + fun exitAnimation(durationMillis: Int): ExitTransition = fadeOut(tween(durationMillis)) +} diff --git a/app/src/main/java/me/grey/picquery/data/AppDatabase.kt b/app/src/main/java/me/grey/picquery/data/AppDatabase.kt index d76fc04..eee8ae5 100644 --- a/app/src/main/java/me/grey/picquery/data/AppDatabase.kt +++ b/app/src/main/java/me/grey/picquery/data/AppDatabase.kt @@ -13,7 +13,11 @@ import me.grey.picquery.data.model.Album import me.grey.picquery.data.model.Embedding import me.grey.picquery.data.model.ImageSimilarity -@Database(entities = [Embedding::class, Album::class, ImageSimilarity::class], version = 4, exportSchema = false) +@Database( + entities = [Embedding::class, Album::class, ImageSimilarity::class], + version = 4, + exportSchema = false +) abstract class AppDatabase : RoomDatabase() { abstract fun embeddingDao(): EmbeddingDao @@ -31,7 +35,8 @@ abstract class AppDatabase : RoomDatabase() { val instance = Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, - "app-db") + "app-db" + ) .addMigrations(MIGRATION_2_3, MIGRATION_3_4) .fallbackToDestructiveMigrationFrom(1) .build() @@ -51,12 +56,14 @@ abstract class AppDatabase : RoomDatabase() { private val MIGRATION_3_4 = object : Migration(3, 4) { override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("CREATE TABLE IF NOT EXISTS image_similarity (\n" + + database.execSQL( + "CREATE TABLE IF NOT EXISTS image_similarity (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,\n" + " base_photo_id INTEGER NOT NULL,\n" + " compared_photo_id INTEGER NOT NULL,\n" + " similarity_score REAL NOT NULL\n" + - ");"); + ");" + ) } } } diff --git a/app/src/main/java/me/grey/picquery/data/CursorUtil.kt b/app/src/main/java/me/grey/picquery/data/CursorUtil.kt index 70512d8..b08c9b2 100644 --- a/app/src/main/java/me/grey/picquery/data/CursorUtil.kt +++ b/app/src/main/java/me/grey/picquery/data/CursorUtil.kt @@ -23,7 +23,9 @@ class CursorUtil { // When there are images located on the root of the external storage, // albumLabel will be null. val albumLabel: String? = - cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_DISPLAY_NAME)) + cursor.getString( + cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_DISPLAY_NAME) + ) val path = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)) @@ -43,4 +45,4 @@ class CursorUtil { ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/data/ObjectBoxDatabase.kt b/app/src/main/java/me/grey/picquery/data/ObjectBoxDatabase.kt new file mode 100644 index 0000000..2a55135 --- /dev/null +++ b/app/src/main/java/me/grey/picquery/data/ObjectBoxDatabase.kt @@ -0,0 +1,38 @@ +package me.grey.picquery.data + +import android.content.Context +import io.objectbox.BoxStore +import me.grey.picquery.data.dao.ObjectBoxEmbeddingDao +import me.grey.picquery.data.model.MyObjectBox +import me.grey.picquery.data.model.ObjectBoxEmbedding + +class ObjectBoxDatabase private constructor() { + private lateinit var boxStore: BoxStore + + fun initialize(context: Context) { + boxStore = MyObjectBox.builder() + .androidContext(context.applicationContext) + .build() + } + + fun embeddingDao(): ObjectBoxEmbeddingDao { + return ObjectBoxEmbeddingDao(boxStore.boxFor(ObjectBoxEmbedding::class.java)) + } + + companion object { + @Volatile + private var INSTANCE: ObjectBoxDatabase? = null + + fun getDatabase(): ObjectBoxDatabase { + return INSTANCE ?: synchronized(this) { + val instance = ObjectBoxDatabase() + INSTANCE = instance + instance + } + } + } + + fun close() { + boxStore.close() + } +} diff --git a/app/src/main/java/me/grey/picquery/data/dao/AlbumDao.kt b/app/src/main/java/me/grey/picquery/data/dao/AlbumDao.kt index ace9c4e..d69d7da 100644 --- a/app/src/main/java/me/grey/picquery/data/dao/AlbumDao.kt +++ b/app/src/main/java/me/grey/picquery/data/dao/AlbumDao.kt @@ -35,4 +35,4 @@ interface AlbumDao { @Delete fun deleteAll(albums: List) -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/data/dao/EmbeddingDao.kt b/app/src/main/java/me/grey/picquery/data/dao/EmbeddingDao.kt index 5d38483..ba537d2 100644 --- a/app/src/main/java/me/grey/picquery/data/dao/EmbeddingDao.kt +++ b/app/src/main/java/me/grey/picquery/data/dao/EmbeddingDao.kt @@ -49,4 +49,4 @@ interface EmbeddingDao { @Delete fun deleteAll(embeddings: List) -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/data/dao/ImageSimilarityDao.kt b/app/src/main/java/me/grey/picquery/data/dao/ImageSimilarityDao.kt index f4beaf6..efc6198 100644 --- a/app/src/main/java/me/grey/picquery/data/dao/ImageSimilarityDao.kt +++ b/app/src/main/java/me/grey/picquery/data/dao/ImageSimilarityDao.kt @@ -27,7 +27,9 @@ interface ImageSimilarityDao { suspend fun deleteByBasePhotoId(photoId: Long) // 添加一个 查询,查找相似度 在某个范围内的数据 - @Query("SELECT * FROM image_similarity WHERE similarity_score > :minSimilarityScore AND similarity_score < :maxSimilarityScore") + @Query( + "SELECT * FROM image_similarity WHERE similarity_score > :minSimilarityScore AND similarity_score < :maxSimilarityScore" + ) suspend fun getSimilaritiesInRange(minSimilarityScore: Float, maxSimilarityScore: Float): List @Query("SELECT * FROM image_similarity LIMIT :pageSize OFFSET :offset") @@ -42,4 +44,4 @@ interface ImageSimilarityDao { offset += pageSize } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/data/dao/ObjectBoxEmbeddingDao.kt b/app/src/main/java/me/grey/picquery/data/dao/ObjectBoxEmbeddingDao.kt new file mode 100644 index 0000000..04fc95f --- /dev/null +++ b/app/src/main/java/me/grey/picquery/data/dao/ObjectBoxEmbeddingDao.kt @@ -0,0 +1,167 @@ +package me.grey.picquery.data.dao + +import io.objectbox.Box +import io.objectbox.kotlin.query +import io.objectbox.query.ObjectWithScore +import me.grey.picquery.common.calculateSimilarity +import me.grey.picquery.data.model.ObjectBoxEmbedding +import me.grey.picquery.data.model.ObjectBoxEmbedding_ +import timber.log.Timber + +class ObjectBoxEmbeddingDao(private val embeddingBox: Box) { + fun getAll(): List { + return embeddingBox.all + } + + // 分页查询所有嵌入向量 + fun getEmbeddingsPaginated(limit: Int, offset: Int): List { + return embeddingBox.query { + orderDesc(ObjectBoxEmbedding_.photoId) + }.find(offset.toLong(), limit.toLong()) + } + + // 按相册ID分页查询嵌入向量 + fun getEmbeddingsByAlbumIdPaginated(albumId: Long, limit: Int, offset: Int): List { + return embeddingBox.query { + equal(ObjectBoxEmbedding_.albumId, albumId) + orderDesc(ObjectBoxEmbedding_.photoId) + }.find(offset.toLong(), limit.toLong()) + } + + // 按多个相册ID分页查询嵌入向量 + fun getEmbeddingsByAlbumIdsPaginated(albumIds: List, limit: Int, offset: Int): List { + return embeddingBox.query { + `in`(ObjectBoxEmbedding_.albumId, albumIds.toLongArray()) + orderDesc(ObjectBoxEmbedding_.photoId) + }.find(offset.toLong(), limit.toLong()) + } + + // 获取分页查询的总数 + fun getEmbeddingsCountByAlbumIds(albumIds: List): Long { + return embeddingBox.query { + `in`(ObjectBoxEmbedding_.albumId, albumIds.toLongArray()) + orderDesc(ObjectBoxEmbedding_.photoId) + }.count() + } + + fun getEmbeddingByPhotoId(photoId: Long): ObjectBoxEmbedding? { + return embeddingBox + .query { equal(ObjectBoxEmbedding_.photoId, photoId) } + .findFirst() + } + + fun getAllByPhotoIds(photoIds: LongArray): List { + return embeddingBox.query { + `in`(ObjectBoxEmbedding_.photoId, photoIds) + orderDesc(ObjectBoxEmbedding_.photoId) + }.find() + } + + // 获取总数 + fun getTotalCount(): Long { + return embeddingBox.count() + } + + // 根据相册ID获取嵌入向量(精确匹配) + fun getAllByAlbumId(albumId: Long): List { + return embeddingBox.query { + equal(ObjectBoxEmbedding_.albumId, albumId) + orderDesc(ObjectBoxEmbedding_.photoId) + }.find() + } + + // 根据相册ID列表获取嵌入向量 + fun getByAlbumIdList(albumIds: List): List { + return embeddingBox.query { + `in`(ObjectBoxEmbedding_.albumId, albumIds.toLongArray()) + orderDesc(ObjectBoxEmbedding_.photoId) + }.find() + } + + // 根据相册ID列表分页获取嵌入向量 + fun getByAlbumIdList(albumIds: List, limit: Int, offset: Int): List { + return embeddingBox.query { + `in`(ObjectBoxEmbedding_.albumId, albumIds.toLongArray()) + orderDesc(ObjectBoxEmbedding_.photoId) + }.find(offset.toLong(), limit.toLong()) + } + + // 根据指定相册ID删除嵌入向量 + fun removeByAlbumId(albumId: Long) { + embeddingBox.query { + equal(ObjectBoxEmbedding_.albumId, albumId) + }.remove() + } + + // 批量更新或插入嵌入向量 + fun upsertAll(embeddings: List) { + embeddingBox.put(embeddings) + } + + // 删除单个嵌入向量 + fun delete(embedding: ObjectBoxEmbedding) { + embeddingBox.remove(embedding) + } + + // 批量删除嵌入向量 + fun deleteAll(embeddings: List) { + embeddingBox.remove(embeddings) + } + + fun searchNearestVectors( + queryVector: FloatArray, + topK: Int = 10, + similarityThreshold: Float = 0.7f, + albumIds: List? = null + ): List> { + val query = + embeddingBox + .query() + .nearestNeighbors(ObjectBoxEmbedding_.data, queryVector, topK) + .build() + + val results = query.findWithScores().filter { result -> + val cosineSimilarity = 1.0 - result.score + cosineSimilarity > similarityThreshold + } + + results.forEachIndexed { index, result -> + Timber.d("Result $index:") + Timber.d("Photo ID: ${result.get().photoId}") + Timber.d("Score: ${result.score}") + Timber.d("Cosine Similarity: ${calculateSimilarity(queryVector, result.get().data)}") + } + + return results + } + + fun searchNearestVectors2( + queryVector: FloatArray, + topK: Int = 10, + similarityThreshold: Float = 0.95f, + albumIds: List? = null + ): List> { + val query = + embeddingBox + .query() + .nearestNeighbors(ObjectBoxEmbedding_.data, queryVector, topK) + .build() + + val results = query.findWithScores() + .filter { result -> + + val cosineSimilarity = 1.0 - result.score + + Timber.d("Photo ID: ${result.get().photoId}") + Timber.d("Score: ${result.score}") + Timber.d("Cosine Similarity: $cosineSimilarity") + Timber.d("Similarity Condition: ${cosineSimilarity >= similarityThreshold}") + + cosineSimilarity >= similarityThreshold + } + + Timber.d("Filtered Results Count: ${results.size}") + + return results + } +} diff --git a/app/src/main/java/me/grey/picquery/data/data_source/AlbumRepository.kt b/app/src/main/java/me/grey/picquery/data/data_source/AlbumRepository.kt index 335c75f..79762f8 100644 --- a/app/src/main/java/me/grey/picquery/data/data_source/AlbumRepository.kt +++ b/app/src/main/java/me/grey/picquery/data/data_source/AlbumRepository.kt @@ -37,7 +37,7 @@ class AlbumRepository( albumCollection, albumProjection, null, - null, + null ) val albumList = mutableListOf() queryAlbums.use { cursor: Cursor? -> @@ -59,7 +59,7 @@ class AlbumRepository( label = photo.albumLabel, coverPath = photo.path, timestamp = photo.timestamp, - count = 1, + count = 1 ) ) } else { @@ -96,4 +96,4 @@ class AlbumRepository( fun removeSearchableAlbum(singleAlbum: Album) { database.albumDao().delete(singleAlbum) } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/data/data_source/EmbeddingRepository.kt b/app/src/main/java/me/grey/picquery/data/data_source/EmbeddingRepository.kt index c5249d5..97f7db4 100644 --- a/app/src/main/java/me/grey/picquery/data/data_source/EmbeddingRepository.kt +++ b/app/src/main/java/me/grey/picquery/data/data_source/EmbeddingRepository.kt @@ -1,11 +1,11 @@ package me.grey.picquery.data.data_source +import java.util.concurrent.LinkedBlockingDeque import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import me.grey.picquery.data.dao.EmbeddingDao import me.grey.picquery.data.model.Album import me.grey.picquery.data.model.Embedding -import java.util.concurrent.LinkedBlockingDeque class EmbeddingRepository( private val dataSource: EmbeddingDao @@ -88,7 +88,7 @@ class EmbeddingRepository( return dataSource.upsertAll(list) } - fun removeByAlbum(album: Album){ + fun removeByAlbum(album: Album) { return dataSource.removeByAlbumId(album.id) } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/data/data_source/IAlbumQuery.kt b/app/src/main/java/me/grey/picquery/data/data_source/IAlbumQuery.kt index a4456fb..de70148 100644 --- a/app/src/main/java/me/grey/picquery/data/data_source/IAlbumQuery.kt +++ b/app/src/main/java/me/grey/picquery/data/data_source/IAlbumQuery.kt @@ -3,5 +3,5 @@ package me.grey.picquery.data.data_source import me.grey.picquery.data.model.Photo fun interface IAlbumQuery { - fun onAlbumQuery(photos:List) -} \ No newline at end of file + fun onAlbumQuery(photos: List) +} diff --git a/app/src/main/java/me/grey/picquery/data/data_source/ObjectBoxEmbeddingRepository.kt b/app/src/main/java/me/grey/picquery/data/data_source/ObjectBoxEmbeddingRepository.kt new file mode 100644 index 0000000..d73b471 --- /dev/null +++ b/app/src/main/java/me/grey/picquery/data/data_source/ObjectBoxEmbeddingRepository.kt @@ -0,0 +1,137 @@ +package me.grey.picquery.data.data_source + +import io.objectbox.query.ObjectWithScore +import java.util.concurrent.LinkedBlockingDeque +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import me.grey.picquery.data.dao.ObjectBoxEmbeddingDao +import me.grey.picquery.data.model.Album +import me.grey.picquery.data.model.ObjectBoxEmbedding +import me.grey.picquery.data.model.toFloatArray + +class ObjectBoxEmbeddingRepository( + private val dataSource: ObjectBoxEmbeddingDao +) { + companion object { + private const val TAG = "ObjectBoxEmbeddingRepo" + } + + fun getAll(): List { + return dataSource.getAll() + } + + suspend fun getEmbeddingByPhotoId(photoId: Long): ObjectBoxEmbedding? { + return withContext(Dispatchers.IO) { + dataSource.getEmbeddingByPhotoId(photoId) + } + } + + fun getAllEmbeddingsPaginated(pageSize: Int): Flow> = flow { + var offset = 0 + var hasMore = true + + while (hasMore) { + val embeddings = dataSource.getEmbeddingsPaginated(pageSize, offset) + if (embeddings.isEmpty()) { + hasMore = false + } else { + emit(embeddings) + offset += pageSize + } + } + } + + fun getTotalCount(): Long { + return dataSource.getTotalCount() + } + + fun getByPhotoIds(photoIds: LongArray): List { + return dataSource.getAllByPhotoIds(photoIds) + } + + fun getByAlbumId(albumId: Long): List { + return dataSource.getAllByAlbumId(albumId) + } + + fun getByAlbumList(albumList: List): List { + return dataSource.getByAlbumIdList(albumList.map { it.id }) + } + + fun getEmbeddingsByAlbumIdsPaginated(albumIds: List, batchSize: Int): Flow> = flow { + var offset = 0 + while (true) { + val embeddings = dataSource.getByAlbumIdList(albumIds, batchSize, offset) + if (embeddings.isEmpty()) { + emit(emptyList()) + break + } + emit(embeddings) + offset += batchSize + } + } + + fun update(emb: ObjectBoxEmbedding) { + return dataSource.upsertAll(listOf(emb)) + } + + private val cacheLinkedBlockingDeque = LinkedBlockingDeque() + + fun updateList(e: ObjectBoxEmbedding) { + cacheLinkedBlockingDeque.add(e) + if (cacheLinkedBlockingDeque.size >= 300) { + val toUpdate = cacheLinkedBlockingDeque.toList() + cacheLinkedBlockingDeque.clear() + return dataSource.upsertAll(toUpdate) + } + } + + fun updateCache() { + if (cacheLinkedBlockingDeque.isNotEmpty()) { + val toUpdate = cacheLinkedBlockingDeque.toList() + cacheLinkedBlockingDeque.clear() + return dataSource.upsertAll(toUpdate) + } + } + + fun updateAll(list: List) { + return dataSource.upsertAll(list) + } + + fun removeByAlbum(album: Album) { + return dataSource.removeByAlbumId(album.id) + } + + fun searchByVector(vector: ByteArray): List> { + return dataSource.searchNearestVectors(vector.toFloatArray()) + } + + fun searchNearestVectors( + queryVector: FloatArray, + topK: Int = 10, + similarityThreshold: Float = 0.7f, + albumIds: List? = null + ): List> { + return dataSource.searchNearestVectors( + queryVector, + topK, + similarityThreshold, + albumIds + ) + } + + fun findSimilarEmbeddings( + queryVector: FloatArray, + topK: Int = 30, + similarityThreshold: Float = 0.95f, + albumIds: List? = null + ): List> { + return dataSource.searchNearestVectors2( + queryVector, + topK, + similarityThreshold, + albumIds + ) + } +} 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 6e82f0d..e215a19 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 @@ -9,11 +9,10 @@ import android.net.Uri import android.os.Bundle import android.provider.MediaStore import android.util.Log +import java.io.InputStream import kotlinx.coroutines.flow.flow import me.grey.picquery.data.CursorUtil import me.grey.picquery.data.model.Photo -import java.io.InputStream - class PhotoRepository(private val context: Context) { @@ -29,7 +28,7 @@ class PhotoRepository(private val context: Context) { MediaStore.Images.Media.SIZE, // in Bytes MediaStore.Images.Media.DATE_MODIFIED, MediaStore.Images.Media.BUCKET_ID, - MediaStore.Images.Media.BUCKET_DISPLAY_NAME, + MediaStore.Images.Media.BUCKET_DISPLAY_NAME ) private val imageCollection: Uri = @@ -37,10 +36,7 @@ class PhotoRepository(private val context: Context) { MediaStore.VOLUME_EXTERNAL ) - private fun getPhotoListByAlbumIdFlow( - albumId: Long, - pageSize: Int = DEFAULT_PAGE_SIZE - ) = flow { + private fun getPhotoListByAlbumIdFlow(albumId: Long, pageSize: Int = DEFAULT_PAGE_SIZE) = flow { var pageIndex = 0 while (true) { val photos = getPhotoListByPage(albumId, pageIndex, pageSize) @@ -55,7 +51,7 @@ class PhotoRepository(private val context: Context) { suspend fun getPhotoListByAlbumId(albumId: Long): List { val result = mutableListOf() getPhotoListByAlbumIdFlow(albumId).collect { - result.addAll(it) + result.addAll(it) } return result @@ -100,7 +96,7 @@ class PhotoRepository(private val context: Context) { imageProjection, "${MediaStore.Images.Media._ID} = ?", arrayOf(id.toString()), - null, + null ) return queryPhotoById.use { cursor: Cursor? -> cursor?.moveToFirst() @@ -137,7 +133,7 @@ class PhotoRepository(private val context: Context) { imageProjection, "${MediaStore.Images.Media._ID} IN (${ids.joinToString(",")})", arrayOf(), - null, + null ) val result = query.use { cursor: Cursor? -> when (cursor?.count) { @@ -162,7 +158,6 @@ class PhotoRepository(private val context: Context) { return result } - fun getBitmapFromUri(uri: Uri): Bitmap? { return try { // 打开输入流 @@ -181,10 +176,7 @@ class PhotoRepository(private val context: Context) { * @param pageSize 每批照片的数量 * @return Flow> 照片列表流 */ - fun getPhotoListByAlbumIdPaginated( - albumId: Long, - pageSize: Int = DEFAULT_PAGE_SIZE - ) = flow { + fun getPhotoListByAlbumIdPaginated(albumId: Long, pageSize: Int = DEFAULT_PAGE_SIZE) = flow { var pageIndex = 0 while (true) { val photos = getPhotoListByPage(albumId, pageIndex, pageSize) @@ -195,5 +187,4 @@ class PhotoRepository(private val context: Context) { pageIndex++ } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/data/model/Album.kt b/app/src/main/java/me/grey/picquery/data/model/Album.kt index 2bbf4e3..901161e 100644 --- a/app/src/main/java/me/grey/picquery/data/model/Album.kt +++ b/app/src/main/java/me/grey/picquery/data/model/Album.kt @@ -30,6 +30,4 @@ data class Album( override fun hashCode(): Int { return id.hashCode() } - } - diff --git a/app/src/main/java/me/grey/picquery/data/model/Converters.kt b/app/src/main/java/me/grey/picquery/data/model/Converters.kt index 11df96f..a43cd85 100644 --- a/app/src/main/java/me/grey/picquery/data/model/Converters.kt +++ b/app/src/main/java/me/grey/picquery/data/model/Converters.kt @@ -21,4 +21,4 @@ fun ByteArray.toFloatArray(): FloatArray { floatArray[i] = buffer.float } return floatArray -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/data/model/Embedding.kt b/app/src/main/java/me/grey/picquery/data/model/Embedding.kt index a664a88..8b09e8d 100644 --- a/app/src/main/java/me/grey/picquery/data/model/Embedding.kt +++ b/app/src/main/java/me/grey/picquery/data/model/Embedding.kt @@ -7,7 +7,8 @@ import java.io.Serializable @Entity data class Embedding( - @PrimaryKey @ColumnInfo(name = "photo_id") + @PrimaryKey + @ColumnInfo(name = "photo_id") val photoId: Long, @ColumnInfo(name = "album_id") diff --git a/app/src/main/java/me/grey/picquery/data/model/ImageSimilarity.kt b/app/src/main/java/me/grey/picquery/data/model/ImageSimilarity.kt index 92a67dc..95f66af 100644 --- a/app/src/main/java/me/grey/picquery/data/model/ImageSimilarity.kt +++ b/app/src/main/java/me/grey/picquery/data/model/ImageSimilarity.kt @@ -11,7 +11,6 @@ data class ImageSimilarity( @ColumnInfo(name = "photo_id") val photoId: Long, - @ColumnInfo(name = "similarity_score") val similarityScore: Float -) \ No newline at end of file +) diff --git a/app/src/main/java/me/grey/picquery/data/model/ObjectBoxEmbedding.kt b/app/src/main/java/me/grey/picquery/data/model/ObjectBoxEmbedding.kt new file mode 100644 index 0000000..ae996d8 --- /dev/null +++ b/app/src/main/java/me/grey/picquery/data/model/ObjectBoxEmbedding.kt @@ -0,0 +1,49 @@ +package me.grey.picquery.data.model + +import io.objectbox.annotation.Entity +import io.objectbox.annotation.HnswIndex +import io.objectbox.annotation.Id +import io.objectbox.annotation.Index +import io.objectbox.annotation.VectorDistanceType +import java.io.Serializable + +@Entity +data class ObjectBoxEmbedding( + @Id + var id: Long = 0, + + @Index + val photoId: Long, + + @Index + val albumId: Long, + + @HnswIndex( + dimensions = 512, + distanceType = VectorDistanceType.COSINE + ) + val data: FloatArray // Vector data as FloatArray +) : Serializable { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ObjectBoxEmbedding + + if (photoId != other.photoId) return false + if (albumId != other.albumId) return false + + return true + } + + override fun hashCode(): Int { + var result = photoId.hashCode() + result = 31 * result + albumId.hashCode() + return result + } + + companion object { + const val PHOTO_ID = "photoId" + const val ALBUM_ID = "albumId" + } +} diff --git a/app/src/main/java/me/grey/picquery/data/model/Photo.kt b/app/src/main/java/me/grey/picquery/data/model/Photo.kt index e310c6d..30c5e69 100644 --- a/app/src/main/java/me/grey/picquery/data/model/Photo.kt +++ b/app/src/main/java/me/grey/picquery/data/model/Photo.kt @@ -2,6 +2,7 @@ package me.grey.picquery.data.model import android.net.Uri import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable @Immutable data class Photo( @@ -11,7 +12,7 @@ data class Photo( val path: String, val timestamp: Long, // 最后修改的日期,时间戳 val albumID: Long, - val albumLabel: String, + val albumLabel: String ) { override fun toString(): String { return "Photo(id=$id, label='$label', uri=$uri, path='$path', timestamp=$timestamp, albumID=$albumID, albumLabel=$albumLabel)" @@ -21,3 +22,18 @@ data class Photo( return PhotoResult(id, label, uri, path, timestamp, albumID, albumLabel, score) } } + +@Serializable +data class PhotoItem( + val id: Long, + val label: String, + val uri: String, + val path: String, + val timestamp: Long, + val albumID: Long, + val albumLabel: String +) { + override fun toString(): String { + return "Photo(id=$id, label='$label', uri=$uri, path='$path', timestamp=$timestamp, albumID=$albumID, albumLabel=$albumLabel)" + } +} diff --git a/app/src/main/java/me/grey/picquery/data/model/PhotoResult.kt b/app/src/main/java/me/grey/picquery/data/model/PhotoResult.kt index 50d88d0..6e4adbc 100644 --- a/app/src/main/java/me/grey/picquery/data/model/PhotoResult.kt +++ b/app/src/main/java/me/grey/picquery/data/model/PhotoResult.kt @@ -17,10 +17,9 @@ data class PhotoResult( val albumLabel: String, // Similarity Score, for Ranking - val score: Float, + val score: Float ) { override fun toString(): String { return "PhotoResult(score=$score, id=$id, label='$label', albumLabel=$albumLabel path=$path)" } } - 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 959d992..b89d681 100644 --- a/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt +++ b/app/src/main/java/me/grey/picquery/domain/AlbumManager.kt @@ -6,6 +6,7 @@ import android.util.Log import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import com.google.accompanist.permissions.ExperimentalPermissionsApi +import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -27,9 +28,8 @@ import me.grey.picquery.data.data_source.AlbumRepository import me.grey.picquery.data.data_source.EmbeddingRepository import me.grey.picquery.data.data_source.PhotoRepository import me.grey.picquery.data.model.Album -import me.grey.picquery.ui.albums.IndexingAlbumState +import me.grey.picquery.ui.albums.EncodingState import timber.log.Timber -import java.util.concurrent.atomic.AtomicInteger class AlbumManager( private val albumRepository: AlbumRepository, @@ -42,10 +42,10 @@ class AlbumManager( private const val TAG = "AlbumViewModel" } - val indexingAlbumState = mutableStateOf(IndexingAlbumState()) + val encodingState = mutableStateOf(EncodingState()) val isEncoderBusy: Boolean - get() = indexingAlbumState.value.isBusy + get() = encodingState.value.isBusy private val albumList = mutableStateListOf() private val _searchableAlbumList = MutableStateFlow>(emptyList()) @@ -64,11 +64,11 @@ class AlbumManager( private val managerScope = CoroutineScope( SupervisorJob() + - Dispatchers.Default + - CoroutineExceptionHandler { _, exception -> - // 处理协程异常 - Timber.tag("AlbumManager").e(exception, "Coroutine error") - } + Dispatchers.Default + + CoroutineExceptionHandler { _, exception -> + // 处理协程异常 + Timber.tag("AlbumManager").e(exception, "Coroutine error") + } ) fun processAlbums(snapshot: List) { @@ -78,7 +78,6 @@ class AlbumManager( } } - suspend fun initAllAlbumList() { if (initialized) return withContext(ioDispatcher) { @@ -96,12 +95,12 @@ class AlbumManager( // 从数据库中检索已经索引的相册 // 有些相册可能已经索引但已被删除,因此要从全部相册中筛选,而不能直接返回数据库的结果 val res = it.toMutableList().sortedByDescending { album: Album -> album.count } - _searchableAlbumList.update{res} + _searchableAlbumList.update { res } Timber.tag(TAG).d("Searchable albums: ${it.size}") // 从全部相册减去已经索引的ID,就是未索引的相册 val unsearchable = albumList.filter { all -> !it.contains(all) } - _unsearchableAlbumList.update{(unsearchable.toMutableList().sortedByDescending { album: Album -> album.count })} + _unsearchableAlbumList.update { (unsearchable.toMutableList().sortedByDescending { album: Album -> album.count }) } Timber.tag(TAG).d("Unsearchable albums: ${unsearchable.size}") } } @@ -145,8 +144,8 @@ class AlbumManager( return } - indexingAlbumState.value = - IndexingAlbumState(status = IndexingAlbumState.Status.Loading) + encodingState.value = + EncodingState(status = EncodingState.Status.Loading) try { val totalPhotos = getTotalPhotoCount(albums) @@ -158,11 +157,11 @@ class AlbumManager( val chunkSuccess = imageSearcher.encodePhotoListV2(photoChunk) { cur, total, cost -> Log.d(TAG, "Encoded $cur/$total photos, cost: $cost") processedPhotos.addAndGet(cur) - indexingAlbumState.value = indexingAlbumState.value.copy( + encodingState.value = encodingState.value.copy( current = processedPhotos.get(), total = totalPhotos, cost = cost, - status = IndexingAlbumState.Status.Indexing + status = EncodingState.Status.Indexing ) } @@ -177,26 +176,26 @@ class AlbumManager( withContext(ioDispatcher) { albumRepository.addAllSearchableAlbum(albums) } - indexingAlbumState.value = indexingAlbumState.value.copy( - status = IndexingAlbumState.Status.Finish + encodingState.value = encodingState.value.copy( + status = EncodingState.Status.Finish ) } else { Log.w(TAG, "encodePhotoList failed! Maybe too much request.") } } catch (e: Exception) { Log.e(TAG, "Error encoding albums", e) - indexingAlbumState.value = indexingAlbumState.value.copy( - status = IndexingAlbumState.Status.Error + encodingState.value = encodingState.value.copy( + status = EncodingState.Status.Error ) } } fun clearIndexingState() { - indexingAlbumState.value = IndexingAlbumState() + encodingState.value = EncodingState() } fun removeSingleAlbumIndex(album: Album) { embeddingRepository.removeByAlbum(album) albumRepository.removeSearchableAlbum(album) } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/domain/EmbeddingUtils.kt b/app/src/main/java/me/grey/picquery/domain/EmbeddingUtils.kt index dcbe031..ebdc057 100644 --- a/app/src/main/java/me/grey/picquery/domain/EmbeddingUtils.kt +++ b/app/src/main/java/me/grey/picquery/domain/EmbeddingUtils.kt @@ -1,15 +1,13 @@ package me.grey.picquery.domain -import android.util.Log - +import java.lang.Float.max +import kotlin.system.measureTimeMillis import kotlinx.coroutines.coroutineScope import me.grey.picquery.data.data_source.EmbeddingRepository -import me.grey.picquery.data.model.Embedding +import me.grey.picquery.data.data_source.ObjectBoxEmbeddingRepository import me.grey.picquery.data.model.PhotoBitmap -import me.grey.picquery.data.model.toByteArray import me.grey.picquery.feature.base.ImageEncoder -import java.lang.Float.max -import kotlin.system.measureTimeMillis +import timber.log.Timber object EmbeddingUtils { @@ -17,32 +15,31 @@ object EmbeddingUtils { suspend fun saveBitmapsToEmbedding( items: List, imageEncoder: ImageEncoder, - embeddingRepository: EmbeddingRepository + embeddingRepository: EmbeddingRepository, + embeddingObjectRepository: ObjectBoxEmbeddingRepository ) { coroutineScope { - Log.d(TAG, "saveBitmapsToEmbeddings Start encoding for embedding...") + Timber.tag(TAG).d("saveBitmapsToEmbeddings Start encoding for embedding...") - Log.d(TAG, "Start encoding for embedding...") - Log.d(TAG, "${System.currentTimeMillis()} Start encoding image...") + Timber.tag(TAG).d("Start encoding for embedding...") + Timber.tag(TAG).d("${System.currentTimeMillis()} Start encoding image...") val time = measureTimeMillis { val embeddings = imageEncoder.encodeBatch(items.map { it!!.bitmap }) - Log.d(TAG, "${System.currentTimeMillis()} end encoding image...") + Timber.tag(TAG).d("${System.currentTimeMillis()} end encoding image...") embeddings.forEachIndexed { index, feat -> - embeddingRepository.updateList( - Embedding( + + embeddingObjectRepository.update( + me.grey.picquery.data.model.ObjectBoxEmbedding( photoId = items[index]!!.photo.id, albumId = items[index]!!.photo.albumID, - data = feat.toByteArray() + data = feat ) ) } } val costSec = max(time / 1000f, 0.1f) - Log.d( - TAG, - "Encode[v2] done! cost: $costSec s, speed: ${items.size / costSec} pic/s" - ) + Timber.tag(TAG) + .d("Encode[v2] done! cost: $costSec s, speed: ${items.size / costSec} pic/s") } } - -} \ No newline at end of file +} 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 00cd053..c894c07 100644 --- a/app/src/main/java/me/grey/picquery/domain/ImageSearcher.kt +++ b/app/src/main/java/me/grey/picquery/domain/ImageSearcher.kt @@ -12,6 +12,11 @@ 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 @@ -35,6 +40,7 @@ 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.model.Album import me.grey.picquery.data.model.Photo import me.grey.picquery.data.model.PhotoBitmap @@ -44,21 +50,16 @@ import me.grey.picquery.feature.base.ImageEncoder import me.grey.picquery.feature.base.TextEncoder import timber.log.Timber -import java.util.Collections -import java.util.SortedMap -import java.util.TreeMap -import java.util.concurrent.atomic.AtomicInteger -import kotlin.system.measureTimeMillis - 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), + Text(R.string.search_target_text, Icons.Outlined.Translate) } 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 ) { @@ -88,7 +89,9 @@ class ImageSearcher( } suspend fun getBaseLine(): FloatArray { - val whiteBenchmark = ResourcesCompat.getDrawable(context.resources,R.drawable.white_benchmark,null)?.toBitmap()!! + val whiteBenchmark = + ResourcesCompat.getDrawable(context.resources, R.drawable.white_benchmark, null) + ?.toBitmap()!! return imageEncoder.encodeBatch(listOf(whiteBenchmark)).first() } @@ -116,7 +119,7 @@ class ImageSearcher( @OptIn(ExperimentalCoroutinesApi::class) suspend fun encodePhotoListV2( photos: List, - progressCallback: encodeProgressCallback? = null, + progressCallback: encodeProgressCallback? = null ): Boolean { if (encodingLock) { Timber.tag(TAG).w("encodePhotoListV2: Already encoding!") @@ -141,7 +144,6 @@ class ImageSearcher( } .filterNotNull() .buffer(1000) - .chunked(100) .onEach { Timber.tag(TAG).d("onEach: ${it.size}") } .onCompletion { @@ -155,12 +157,13 @@ class ImageSearcher( val deferreds = (0 until loops).map { index -> async { val start = index * batchSize - if (start>= it.size) return@async + if (start >= it.size) return@async val end = start + batchSize saveBitmapsToEmbedding( it.slice(start until end), imageEncoder, - embeddingRepository + embeddingRepository, + objectBoxEmbeddingRepository ) } } @@ -171,9 +174,9 @@ class ImageSearcher( progressCallback?.invoke( cur.get(), photos.size, - cost / it.size, + cost / it.size ) - Timber.tag(TAG).d("cost: ${cost}") + Timber.tag(TAG).d("cost: $cost") } } return true @@ -182,7 +185,7 @@ class ImageSearcher( suspend fun search( text: String, range: List = searchRange, - onSuccess: suspend (MutableSet>) -> Unit, + onSuccess: suspend (MutableSet>) -> Unit ) { translator.translate( text, @@ -199,7 +202,31 @@ class ImageSearcher( } Timber.tag("MLTranslator").e("中文->英文翻译出错!\n${it.message}") showToast("翻译模型出错,请反馈给开发者!") + } + ) + } + + 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("翻译模型出错,请反馈给开发者!") + } ) } @@ -221,8 +248,8 @@ class ImageSearcher( suspend fun searchWithRange( image: Bitmap, range: List = searchRange, - onSuccess: suspend (MutableSet>) -> Unit, - ) { + onSuccess: suspend (MutableSet>) -> Unit + ) { return withContext(dispatcher) { if (searchingLock) { return@withContext @@ -234,6 +261,76 @@ class ImageSearcher( } } + 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 + } + } + 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 = DEFAULT_TOP_K, + 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 @@ -249,14 +346,17 @@ class ImageSearcher( 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) + 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") @@ -270,7 +370,7 @@ class ImageSearcher( Timber.tag(TAG).d("Search result: found ${threadSafeSortedMap.size} pics") searchResultIds.clear() - mutableSetOf>().apply { + mutableSetOf>().apply { addAll(threadSafeSortedMap.entries) searchResultIds.addAll(threadSafeSortedMap.values) Timber.tag(TAG).d("Search result: ${joinToString(",")}") @@ -304,7 +404,7 @@ class ImageSearcher( Timber.tag(TAG).d( "Search configuration updated: " + - "matchThreshold=${_matchThreshold.floatValue}, topK=${_topK.intValue}" + "matchThreshold=${_matchThreshold.floatValue}, topK=${_topK.intValue}" ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/domain/MLKitTranslator.kt b/app/src/main/java/me/grey/picquery/domain/MLKitTranslator.kt index 558e002..4afe7b3 100644 --- a/app/src/main/java/me/grey/picquery/domain/MLKitTranslator.kt +++ b/app/src/main/java/me/grey/picquery/domain/MLKitTranslator.kt @@ -6,9 +6,9 @@ import com.google.android.gms.tasks.Task import com.google.mlkit.nl.translate.TranslateLanguage import com.google.mlkit.nl.translate.Translation import com.google.mlkit.nl.translate.TranslatorOptions +import java.io.File import me.grey.picquery.PicQueryApplication import me.grey.picquery.common.AssetUtil -import java.io.File class MLKitTranslator { @@ -38,16 +38,11 @@ class MLKitTranslator { AssetUtil.copyAssetsFolder( context, ASSET_MODEL_PATH, - targetModelDirectory, + targetModelDirectory ) } - - suspend fun translate( - text: String, - onSuccess: (String) -> Unit, - onError: (Exception) -> Unit, - ): Task { + suspend fun translate(text: String, onSuccess: (String) -> Unit, onError: (Exception) -> Unit): Task { if (shouldCopyModel) { copyModelsFromAssets() } @@ -64,4 +59,4 @@ class MLKitTranslator { // ... } } -} \ No newline at end of file +} 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 66c8059..c3c8c43 100644 --- a/app/src/main/java/me/grey/picquery/domain/SimilarityManager.kt +++ b/app/src/main/java/me/grey/picquery/domain/SimilarityManager.kt @@ -1,5 +1,8 @@ package me.grey.picquery.domain +import java.util.Collections +import java.util.SortedSet +import java.util.TreeSet import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers @@ -20,9 +23,6 @@ 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 -import java.util.Collections -import java.util.SortedSet -import java.util.TreeSet class GroupSimilarPhotosUseCase( private val embeddingRepository: EmbeddingRepository, @@ -35,14 +35,18 @@ class GroupSimilarPhotosUseCase( val photoId: Long, val similarity: Float ) : Comparable { - override fun compareTo(other: SimilarityNode): Int = - compareValuesBy(this, other, - { it.similarity }, - { it.photoId } - ) + override fun compareTo(other: SimilarityNode): Int = compareValuesBy( + this, + other, + { it.similarity }, + { it.photoId } + ) } - private suspend fun execute(photos: List, dispatcher: CoroutineDispatcher=Dispatchers.IO): List> { + private suspend fun execute( + photos: List, + dispatcher: CoroutineDispatcher = Dispatchers.IO + ): List> { val defers = mutableListOf>>>() val visit = Collections.synchronizedSet(mutableSetOf()) val remainingPhotos = TreeSet( @@ -74,7 +78,12 @@ class GroupSimilarPhotosUseCase( if (similarPhotos.size >= minGroupSize) { val inputs = similarPhotos.sortedBy { it.photoId } // use doubleCheckSimilarity can also group similar photos - val lists = async(dispatcher) { unionFindSimilarityGroups(inputs,similarityThreshold) } + val lists = async(dispatcher) { + unionFindSimilarityGroups( + inputs, + similarityThreshold + ) + } defers.add(lists) } } @@ -90,16 +99,14 @@ class GroupSimilarPhotosUseCase( */ private fun unionFindSimilarityGroups( photos: List, - similarityThreshold: Float = 0.95f, + similarityThreshold: Float = 0.95f ): List> { - val embeddings = embeddingRepository.getByPhotoIds(photos.map { it.photoId }.toLongArray()) val unionFind = UnionFind(photos.size) for (i in photos.indices) { for (j in i + 1 until photos.size) { - val similarity = calculateSimilarity( embeddings[i].data.toFloatArray(), embeddings[j].data.toFloatArray() @@ -118,76 +125,7 @@ class GroupSimilarPhotosUseCase( } } - - /** - * Double check the similarity of the photos in the group. - * @param similarGroup The group of photos to check for similarity. - * @param similarityThreshold The threshold to consider two photos as similar. - * @return A list of similar photo groups. - */ - @Suppress("unused") - private fun doubleCheckSimilarity( - similarGroup: List, - similarityThreshold: Float = 0.98f - ): List> { - val embeddings = - embeddingRepository.getByPhotoIds(similarGroup.map { it.photoId }.toLongArray()) - .sortedBy { it.photoId } - - // Create an adjacency list representation of the graph - val graph = mutableMapOf>() - - // Build graph edges based on similarity - for (i in similarGroup.indices) { - for (j in i + 1 until similarGroup.size) { - val similarity = calculateSimilarity( - embeddings[i].data.toFloatArray(), - embeddings[j].data.toFloatArray() - ) - - if (similarity > similarityThreshold.toDouble()) { - val photoId1 = similarGroup[i].photoId - val photoId2 = similarGroup[j].photoId - - graph.getOrPut(photoId1) { mutableSetOf() }.add(photoId2) - graph.getOrPut(photoId2) { mutableSetOf() }.add(photoId1) - } - } - } - - // Find connected components (similar photo groups) - val visited = mutableSetOf() - val similarGroups = mutableListOf>() - - fun dfs(photoId: Long, currentGroup: MutableList) { - visited.add(photoId) - currentGroup.add(similarGroup.first { it.photoId == photoId }) - - graph[photoId]?.forEach { neighborId -> - if (neighborId !in visited) { - dfs(neighborId, currentGroup) - } - } - } - - // Perform DFS to find connected components - for (node in similarGroup) { - if (node.photoId !in visited) { - val currentGroup = mutableListOf() - dfs(node.photoId, currentGroup) - - // Only add groups with at least 2 photos - if (currentGroup.size > 1) { - similarGroups.add(currentGroup) - } - } - } - - return similarGroups - } - - suspend operator fun invoke(photos: List): List> = - execute(photos) + suspend operator fun invoke(photos: List): List> = execute(photos) companion object { @@ -226,7 +164,7 @@ class SimilarityManager( newSimilarityThreshold?.let { similarityThreshold = it } newSimilarityDelta?.let { similarityDelta = it } newMinGroupSize?.let { minGroupSize = it } - + // Recreate the use case with updated parameters groupSimilarPhotosUseCase = GroupSimilarPhotosUseCase( embeddingRepository, @@ -315,4 +253,4 @@ class SimilarityManager( return if (isFullyLoaded) _cachedSimilarityGroups.toList() else emptyList() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/domain/UnionFind.kt b/app/src/main/java/me/grey/picquery/domain/UnionFind.kt index 788ab2b..ea77cc6 100644 --- a/app/src/main/java/me/grey/picquery/domain/UnionFind.kt +++ b/app/src/main/java/me/grey/picquery/domain/UnionFind.kt @@ -35,4 +35,4 @@ class UnionFind(private val n: Int) { } return groups.values.filter { it.size > 1 } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/domain/worker/ImageSimilarityCalculationWorker.kt b/app/src/main/java/me/grey/picquery/domain/worker/ImageSimilarityCalculationWorker.kt deleted file mode 100644 index 6d6f735..0000000 --- a/app/src/main/java/me/grey/picquery/domain/worker/ImageSimilarityCalculationWorker.kt +++ /dev/null @@ -1,92 +0,0 @@ -package me.grey.picquery.domain.worker - -import android.content.Context -import android.util.Log -import androidx.work.CoroutineWorker - -import androidx.work.WorkerParameters -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flatMapMerge -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.withContext -import me.grey.picquery.common.calculateSimilarity -import me.grey.picquery.data.AppDatabase -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.ImageSearcher -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import timber.log.Timber - -/** - * Worker responsible for calculating image similarities. - */ -class ImageSimilarityCalculationWorker( - private val context: Context, - params: WorkerParameters, -) : CoroutineWorker(context, params), KoinComponent { - - private val database: AppDatabase by inject() - private val embeddingRepository by inject() - private val imageSearcher by inject() - private val imageSimilarityDao by lazy { database.imageSimilarityDao() } - private val dispatcher: CoroutineDispatcher = Dispatchers.IO - - @OptIn(ExperimentalCoroutinesApi::class) - override suspend fun doWork(): Result = withContext(dispatcher) { - try { - Timber.tag("ImageSimilarity").d("Calculating similarities") - - val baseline = imageSearcher.getBaseLine() - val calculatedSet = HashSet() - var offset = 0 - val pageSize = 1000 - - - while (true) { - val existingSimilarities = imageSimilarityDao.getSimilaritiesPaginated(pageSize, offset) - if (existingSimilarities.isEmpty()) break - - calculatedSet.addAll(existingSimilarities.map { it.photoId }) - offset += pageSize - } - - embeddingRepository.getAllEmbeddingsPaginated(pageSize) - .flatMapMerge { embeddingsPage -> - flow { - val similarities = embeddingsPage - .filterNot { calculatedSet.contains(it.photoId) } - .map { comparedEmbedding -> - val similarityScore = calculateSimilarity( - baseline, - comparedEmbedding.data.toFloatArray() - ) - - Timber.tag("ImageSimilarity") - .d("Similarity between ${comparedEmbedding.photoId} is $similarityScore") - - ImageSimilarity( - photoId = comparedEmbedding.photoId, - similarityScore = similarityScore.toFloat() - ) - } - - emit(similarities) - } - } - .collect { similarities -> - if (similarities.isNotEmpty()) { - imageSimilarityDao.insertAll(similarities) - } - } - - Result.success() - } catch (e: Exception) { - Log.e("ImageSimilarity", "Error calculating similarities", e) - Result.failure() - } - } -} diff --git a/app/src/main/java/me/grey/picquery/feature/BPETokenizer.kt b/app/src/main/java/me/grey/picquery/feature/BPETokenizer.kt index a3cbe83..58a2eaa 100644 --- a/app/src/main/java/me/grey/picquery/feature/BPETokenizer.kt +++ b/app/src/main/java/me/grey/picquery/feature/BPETokenizer.kt @@ -1,15 +1,14 @@ package me.grey.picquery.feature -import java.util.regex.Pattern - import android.content.Context -import me.grey.picquery.common.AssetUtil -import me.grey.picquery.feature.base.Tokenizer import java.io.BufferedReader import java.io.File import java.io.FileInputStream import java.io.InputStreamReader +import java.util.regex.Pattern import java.util.zip.GZIPInputStream +import me.grey.picquery.common.AssetUtil +import me.grey.picquery.feature.base.Tokenizer private fun createCharDict(): Map { val bytesList = mutableListOf() @@ -52,15 +51,14 @@ private fun whitespaceClean(text: String): String { return cleanedText } - -class BPETokenizer(context: Context, bpePath: String = "bpe_vocab_gz"): Tokenizer() { +class BPETokenizer(context: Context, bpePath: String = "bpe_vocab_gz") : Tokenizer() { companion object { private const val START_TOKEN = "<|startoftext|>" private const val END_TOKEN = "<|endoftext|>" private const val WORD_END = "" - + private val PATTERN = Pattern.compile( - "$START_TOKEN|$END_TOKEN|'s|'t|'re|'ve|'m|'ll|'d|[\\p{L}]+|[\\p{N}]|[^\\s\\p{L}\\p{N}]+", + "$START_TOKEN|$END_TOKEN|'s|'t|'re|'ve|'m|'ll|'d|[\\p{L}]+|[\\p{N}]|[^\\s\\p{L}\\p{N}]+" ) } @@ -92,49 +90,49 @@ class BPETokenizer(context: Context, bpePath: String = "bpe_vocab_gz"): Tokenize .map { it.value to it.key }.toMap() bpeRanks = merges.mapIndexed { index, pair -> pair to index }.toMap() cache = mutableMapOf( - START_TOKEN to START_TOKEN, + START_TOKEN to START_TOKEN, END_TOKEN to END_TOKEN ) } -private fun bpe(token: String): String { - cache[token]?.let { return it } - - var word = token.dropLast(1).map { it.toString() }.toMutableList().apply { - add(token.last().toString() + WORD_END) - } - var pairs = getPairs(word) - - if (pairs.isEmpty()) return "$token$WORD_END" - - while (true) { - val bigram = pairs.minByOrNull { bpeRanks[it] ?: Int.MAX_VALUE } ?: break - if (bigram !in bpeRanks) break + private fun bpe(token: String): String { + cache[token]?.let { return it } - val (first, second) = bigram - val newWord = mutableListOf() - var i = 0 - - while (i < word.size) { - val j = word.subList(i, word.size).indexOf(first).takeIf { it != -1 }?.plus(i) ?: word.size - newWord.addAll(word.subList(i, j)) - i = j - - if (i < word.size - 1 && word[i] == first && word[i + 1] == second) { - newWord.add(first + second) - i += 2 - } else if (i < word.size) { - newWord.add(word[i]) - i++ + var word = token.dropLast(1).map { it.toString() }.toMutableList().apply { + add(token.last().toString() + WORD_END) + } + var pairs = getPairs(word) + + if (pairs.isEmpty()) return "$token$WORD_END" + + while (true) { + val bigram = pairs.minByOrNull { bpeRanks[it] ?: Int.MAX_VALUE } ?: break + if (bigram !in bpeRanks) break + + val (first, second) = bigram + val newWord = mutableListOf() + var i = 0 + + while (i < word.size) { + val j = word.subList(i, word.size).indexOf(first).takeIf { it != -1 }?.plus(i) ?: word.size + newWord.addAll(word.subList(i, j)) + i = j + + if (i < word.size - 1 && word[i] == first && word[i + 1] == second) { + newWord.add(first + second) + i += 2 + } else if (i < word.size) { + newWord.add(word[i]) + i++ + } } + word = newWord + if (word.size == 1) break + pairs = getPairs(word) } - word = newWord - if (word.size == 1) break - pairs = getPairs(word) - } - return word.joinToString(" ").also { cache[token] = it } -} + return word.joinToString(" ").also { cache[token] = it } + } private fun encode(text: String): List { val cleanedText = whitespaceClean(text).lowercase() @@ -159,11 +157,7 @@ private fun bpe(token: String): String { return bpeTokens } - - override fun tokenize( - text: String - ): Pair { - + override fun tokenize(text: String): Pair { val sotToken: Int = encoder.getValue(START_TOKEN) val eotToken: Int = encoder.getValue(END_TOKEN) val tokens: MutableList = ArrayList() @@ -176,17 +170,19 @@ private fun bpe(token: String): String { val truncatedTokens = tokens.subList(0, contextLength) truncatedTokens[contextLength - 1] = eotToken } else { - throw java.lang.RuntimeException("Input $text is too long for context length $contextLength") + throw java.lang.RuntimeException( + "Input $text is too long for context length $contextLength" + ) } } val result = IntArray(contextLength) { - if (it < tokens.size) + if (it < tokens.size) { tokens[it] - else 0 + } else { + 0 + } } val shape = longArrayOf(1, contextLength.toLong()) return Pair(result, shape) } - - -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/feature/ImageEncoderONNX.kt b/app/src/main/java/me/grey/picquery/feature/ImageEncoderONNX.kt index a8ee61e..d689cd7 100644 --- a/app/src/main/java/me/grey/picquery/feature/ImageEncoderONNX.kt +++ b/app/src/main/java/me/grey/picquery/feature/ImageEncoderONNX.kt @@ -6,13 +6,13 @@ import ai.onnxruntime.OrtSession import android.content.Context import android.graphics.Bitmap import android.util.Log +import java.nio.FloatBuffer +import java.util.Collections import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import me.grey.picquery.common.AssetUtil import me.grey.picquery.feature.base.ImageEncoder import me.grey.picquery.feature.base.Preprocessor -import java.nio.FloatBuffer -import java.util.Collections open class ImageEncoderONNX( private val dim: Long, @@ -47,37 +47,36 @@ open class ImageEncoderONNX( Log.d(TAG, "Init $TAG") } - override suspend fun encodeBatch(bitmaps: List): List = - withContext(dispatcher) { - Log.d(TAG, "${this@ImageEncoderONNX} Start encoding image...") + override suspend fun encodeBatch(bitmaps: List): List = withContext(dispatcher) { + Log.d(TAG, "${this@ImageEncoderONNX} Start encoding image...") - val floatBuffer = preprocessor.preprocessBatch(bitmaps) as FloatBuffer + val floatBuffer = preprocessor.preprocessBatch(bitmaps) as FloatBuffer - val inputName = ortSession?.inputNames?.iterator()?.next() - val shape: LongArray = longArrayOf(bitmaps.size.toLong(), 3, dim, dim) - ortEnv.use { env -> - val tensor = OnnxTensor.createTensor(env, floatBuffer, shape) - val output: OrtSession.Result? = - ortSession?.run(Collections.singletonMap(inputName, tensor)) - val resultBuffer = output?.get(0) as OnnxTensor - Log.d(TAG, "Finish encoding image!") + val inputName = ortSession?.inputNames?.iterator()?.next() + val shape: LongArray = longArrayOf(bitmaps.size.toLong(), 3, dim, dim) + ortEnv.use { env -> + val tensor = OnnxTensor.createTensor(env, floatBuffer, shape) + val output: OrtSession.Result? = + ortSession?.run(Collections.singletonMap(inputName, tensor)) + val resultBuffer = output?.get(0) as OnnxTensor + Log.d(TAG, "Finish encoding image!") - val feat = resultBuffer.floatBuffer - val embeddingSize = 512 - val numEmbeddings = feat.capacity() / embeddingSize - val embeddings = mutableListOf() + val feat = resultBuffer.floatBuffer + val embeddingSize = 512 + val numEmbeddings = feat.capacity() / embeddingSize + val embeddings = mutableListOf() - for (i in 0 until numEmbeddings) { - val start = i * embeddingSize - val embeddingArray = FloatArray(embeddingSize) - feat.position(start) - for (j in 0 until embeddingSize) { - embeddingArray[j] = feat[start + j] - } - embeddings.add(embeddingArray) + for (i in 0 until numEmbeddings) { + val start = i * embeddingSize + val embeddingArray = FloatArray(embeddingSize) + feat.position(start) + for (j in 0 until embeddingSize) { + embeddingArray[j] = feat[start + j] } - - return@withContext embeddings + embeddings.add(embeddingArray) } + + return@withContext embeddings } -} \ No newline at end of file + } +} diff --git a/app/src/main/java/me/grey/picquery/feature/TextEncoderONNX.kt b/app/src/main/java/me/grey/picquery/feature/TextEncoderONNX.kt index 8eda277..5b19b3d 100644 --- a/app/src/main/java/me/grey/picquery/feature/TextEncoderONNX.kt +++ b/app/src/main/java/me/grey/picquery/feature/TextEncoderONNX.kt @@ -5,10 +5,10 @@ import ai.onnxruntime.OrtEnvironment import ai.onnxruntime.OrtSession import android.content.Context import android.util.Log -import me.grey.picquery.common.AssetUtil -import me.grey.picquery.feature.base.TextEncoder import java.nio.IntBuffer import java.nio.LongBuffer +import me.grey.picquery.common.AssetUtil +import me.grey.picquery.feature.base.TextEncoder abstract class TextEncoderONNX(private val context: Context) : TextEncoder { private val TAG = this.javaClass.simpleName diff --git a/app/src/main/java/me/grey/picquery/feature/base/ImageEncoder.kt b/app/src/main/java/me/grey/picquery/feature/base/ImageEncoder.kt index 50574bc..e9c65d0 100644 --- a/app/src/main/java/me/grey/picquery/feature/base/ImageEncoder.kt +++ b/app/src/main/java/me/grey/picquery/feature/base/ImageEncoder.kt @@ -4,4 +4,4 @@ import android.graphics.Bitmap fun interface ImageEncoder { suspend fun encodeBatch(bitmaps: List): List -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/feature/base/Preprocessor.kt b/app/src/main/java/me/grey/picquery/feature/base/Preprocessor.kt index 53daff6..c943f49 100644 --- a/app/src/main/java/me/grey/picquery/feature/base/Preprocessor.kt +++ b/app/src/main/java/me/grey/picquery/feature/base/Preprocessor.kt @@ -6,4 +6,4 @@ interface Preprocessor { suspend fun preprocessBatch(input: List): Any suspend fun preprocess(input: Bitmap): Any -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/feature/base/TextEncoder.kt b/app/src/main/java/me/grey/picquery/feature/base/TextEncoder.kt index 40ab097..78635d3 100644 --- a/app/src/main/java/me/grey/picquery/feature/base/TextEncoder.kt +++ b/app/src/main/java/me/grey/picquery/feature/base/TextEncoder.kt @@ -2,4 +2,4 @@ package me.grey.picquery.feature.base fun interface TextEncoder { fun encode(input: String): FloatArray -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/feature/base/Tokenizer.kt b/app/src/main/java/me/grey/picquery/feature/base/Tokenizer.kt index f5e453c..d9b1763 100644 --- a/app/src/main/java/me/grey/picquery/feature/base/Tokenizer.kt +++ b/app/src/main/java/me/grey/picquery/feature/base/Tokenizer.kt @@ -2,10 +2,8 @@ package me.grey.picquery.feature.base abstract class Tokenizer( protected var contextLength: Int = 77, - protected var truncate: Boolean = false, + protected var truncate: Boolean = false ) { - abstract fun tokenize( - text: String, - ): Pair -} \ No newline at end of file + abstract fun tokenize(text: String): Pair +} diff --git a/app/src/main/java/me/grey/picquery/feature/clip/ImageEncoderCLIP.kt b/app/src/main/java/me/grey/picquery/feature/clip/ImageEncoderCLIP.kt index c887ace..c3b3fd0 100644 --- a/app/src/main/java/me/grey/picquery/feature/clip/ImageEncoderCLIP.kt +++ b/app/src/main/java/me/grey/picquery/feature/clip/ImageEncoderCLIP.kt @@ -3,14 +3,22 @@ package me.grey.picquery.feature.clip import ai.onnxruntime.OnnxTensor import android.content.Context import android.graphics.Bitmap -import kotlinx.coroutines.CoroutineDispatcher -import me.grey.picquery.feature.ImageEncoderONNX import java.nio.FloatBuffer import java.util.Collections +import kotlinx.coroutines.CoroutineDispatcher +import me.grey.picquery.feature.ImageEncoderONNX -class ImageEncoderCLIP(context: Context, private val preprocessor: PreprocessorCLIP, private val dispatcher: CoroutineDispatcher) : +class ImageEncoderCLIP( + context: Context, + private val preprocessor: PreprocessorCLIP, + private val dispatcher: CoroutineDispatcher +) : ImageEncoderONNX( - 224, "clip-image-int8.ort", context, preprocessor, dispatcher + 224, + "clip-image-int8.ort", + context, + preprocessor, + dispatcher ) { companion object { @@ -30,7 +38,9 @@ class ImageEncoderCLIP(context: Context, private val preprocessor: PreprocessorC for (i in bitmaps.indices) { val tensor = OnnxTensor.createTensor(ortEnv, buffers[i], shape) val output = ortSession?.run(Collections.singletonMap(inputName, tensor)) - @Suppress("UNCHECKED_CAST") val rawOutput = + + @Suppress("UNCHECKED_CAST") + val rawOutput = ((output?.get(0)?.value) as Array)[0] res.add(rawOutput) } diff --git a/app/src/main/java/me/grey/picquery/feature/clip/ModuleCLIP.kt b/app/src/main/java/me/grey/picquery/feature/clip/ModuleCLIP.kt index 174b4ad..6183af8 100644 --- a/app/src/main/java/me/grey/picquery/feature/clip/ModuleCLIP.kt +++ b/app/src/main/java/me/grey/picquery/feature/clip/ModuleCLIP.kt @@ -2,7 +2,6 @@ package me.grey.picquery.feature.clip import me.grey.picquery.feature.base.ImageEncoder import me.grey.picquery.feature.base.TextEncoder - import org.koin.dsl.module val modulesCLIP = module { diff --git a/app/src/main/java/me/grey/picquery/feature/clip/PreprocessorCLIP.kt b/app/src/main/java/me/grey/picquery/feature/clip/PreprocessorCLIP.kt index a225306..bbfd49a 100644 --- a/app/src/main/java/me/grey/picquery/feature/clip/PreprocessorCLIP.kt +++ b/app/src/main/java/me/grey/picquery/feature/clip/PreprocessorCLIP.kt @@ -1,9 +1,8 @@ package me.grey.picquery.feature.clip import android.graphics.Bitmap -import me.grey.picquery.feature.base.Preprocessor import java.nio.FloatBuffer - +import me.grey.picquery.feature.base.Preprocessor class PreprocessorCLIP : Preprocessor { @@ -69,6 +68,4 @@ class PreprocessorCLIP : Preprocessor { combinedBuffer.flip() return combinedBuffer } - - -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/feature/clip/TextEncoderCLIP.kt b/app/src/main/java/me/grey/picquery/feature/clip/TextEncoderCLIP.kt index acadd8e..ef4522b 100644 --- a/app/src/main/java/me/grey/picquery/feature/clip/TextEncoderCLIP.kt +++ b/app/src/main/java/me/grey/picquery/feature/clip/TextEncoderCLIP.kt @@ -3,7 +3,7 @@ package me.grey.picquery.feature.clip import android.content.Context import me.grey.picquery.feature.TextEncoderONNX -class TextEncoderCLIP(context: Context): TextEncoderONNX(context) { +class TextEncoderCLIP(context: Context) : TextEncoderONNX(context) { override val modelPath: String = "clip-text-int8.ort" override val modelType: Int = 0 -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/feature/mobileclip/ImageEncoderMobileCLIP.kt b/app/src/main/java/me/grey/picquery/feature/mobileclip/ImageEncoderMobileCLIP.kt index 29ac4d5..7776251 100644 --- a/app/src/main/java/me/grey/picquery/feature/mobileclip/ImageEncoderMobileCLIP.kt +++ b/app/src/main/java/me/grey/picquery/feature/mobileclip/ImageEncoderMobileCLIP.kt @@ -4,10 +4,17 @@ import android.content.Context import kotlinx.coroutines.CoroutineDispatcher import me.grey.picquery.feature.ImageEncoderONNX - -class ImageEncoderMobileCLIP(context: Context, preprocessor: PreprocessorMobileCLIP,dispatcher: CoroutineDispatcher) : +class ImageEncoderMobileCLIP( + context: Context, + preprocessor: PreprocessorMobileCLIP, + dispatcher: CoroutineDispatcher +) : ImageEncoderONNX( - INPUT.toLong(), "vision_model.ort", context, preprocessor, dispatcher + INPUT.toLong(), + "vision_model.ort", + context, + preprocessor, + dispatcher ) { companion object { const val INPUT = 256 diff --git a/app/src/main/java/me/grey/picquery/feature/mobileclip/PreprocessorMobileCLIP.kt b/app/src/main/java/me/grey/picquery/feature/mobileclip/PreprocessorMobileCLIP.kt index f4e9b9c..4ed29d3 100644 --- a/app/src/main/java/me/grey/picquery/feature/mobileclip/PreprocessorMobileCLIP.kt +++ b/app/src/main/java/me/grey/picquery/feature/mobileclip/PreprocessorMobileCLIP.kt @@ -1,8 +1,8 @@ package me.grey.picquery.feature.mobileclip import android.graphics.Bitmap -import me.grey.picquery.feature.base.Preprocessor import java.nio.FloatBuffer +import me.grey.picquery.feature.base.Preprocessor class PreprocessorMobileCLIP : Preprocessor { @@ -56,4 +56,4 @@ class PreprocessorMobileCLIP : Preprocessor { combinedBuffer.flip() return combinedBuffer } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/feature/mobileclip/TextEncoderMobileCLIP.kt b/app/src/main/java/me/grey/picquery/feature/mobileclip/TextEncoderMobileCLIP.kt index 9dd93a3..86686ef 100644 --- a/app/src/main/java/me/grey/picquery/feature/mobileclip/TextEncoderMobileCLIP.kt +++ b/app/src/main/java/me/grey/picquery/feature/mobileclip/TextEncoderMobileCLIP.kt @@ -3,7 +3,7 @@ package me.grey.picquery.feature.mobileclip import android.content.Context import me.grey.picquery.feature.TextEncoderONNX -class TextEncoderMobileCLIP(context: Context): TextEncoderONNX(context) { +class TextEncoderMobileCLIP(context: Context) : TextEncoderONNX(context) { override val modelPath: String = "text_model.ort" override val modelType: Int = 1 -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/feature/mobileclip2/ImageEncoderMobileCLIPv2.kt b/app/src/main/java/me/grey/picquery/feature/mobileclip2/ImageEncoderMobileCLIPv2.kt index 5acf2cc..cd6c3c7 100644 --- a/app/src/main/java/me/grey/picquery/feature/mobileclip2/ImageEncoderMobileCLIPv2.kt +++ b/app/src/main/java/me/grey/picquery/feature/mobileclip2/ImageEncoderMobileCLIPv2.kt @@ -3,6 +3,9 @@ package me.grey.picquery.feature.mobileclip2 import android.content.Context import android.graphics.Bitmap import android.util.Log +import java.io.FileNotFoundException +import java.nio.FloatBuffer +import kotlin.system.measureTimeMillis import me.grey.picquery.common.AssetUtil import me.grey.picquery.feature.base.ImageEncoder import org.tensorflow.lite.DataType @@ -10,19 +13,13 @@ import org.tensorflow.lite.Interpreter import org.tensorflow.lite.gpu.CompatibilityList import org.tensorflow.lite.gpu.GpuDelegate import org.tensorflow.lite.gpu.GpuDelegateFactory - import org.tensorflow.lite.support.tensorbuffer.TensorBuffer import timber.log.Timber -import java.io.FileNotFoundException -import java.nio.FloatBuffer -import kotlin.system.measureTimeMillis - class ImageEncoderMobileCLIPv2(context: Context, private val preprocessor: PreprocessorMobileCLIPv2) : ImageEncoder { private val interpreter: Interpreter - companion object { private const val TAG = "ImageEncoderLiteRT" private const val IMAGE_SIZE = 256 @@ -85,4 +82,4 @@ class ImageEncoderMobileCLIPv2(context: Context, private val preprocessor: Prepr return output } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/feature/mobileclip2/PreprocessorMobileCLIPv2.kt b/app/src/main/java/me/grey/picquery/feature/mobileclip2/PreprocessorMobileCLIPv2.kt index 44ff8ac..71ef900 100644 --- a/app/src/main/java/me/grey/picquery/feature/mobileclip2/PreprocessorMobileCLIPv2.kt +++ b/app/src/main/java/me/grey/picquery/feature/mobileclip2/PreprocessorMobileCLIPv2.kt @@ -32,9 +32,10 @@ class PreprocessorMobileCLIPv2 : Preprocessor { tensorImage = imageProcessor.process(tensorImage) val tensor = TensorBuffer.createFixedSize( - intArrayOf(1, 3, IMAGE_SIZE, IMAGE_SIZE), DataType.FLOAT32 + intArrayOf(1, 3, IMAGE_SIZE, IMAGE_SIZE), + DataType.FLOAT32 ) tensor.loadBuffer(tensorImage.buffer) return tensor } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/feature/mobileclip2/TextEncoderMobileCLIPv2.kt b/app/src/main/java/me/grey/picquery/feature/mobileclip2/TextEncoderMobileCLIPv2.kt index e58c1fb..d779320 100644 --- a/app/src/main/java/me/grey/picquery/feature/mobileclip2/TextEncoderMobileCLIPv2.kt +++ b/app/src/main/java/me/grey/picquery/feature/mobileclip2/TextEncoderMobileCLIPv2.kt @@ -6,4 +6,4 @@ import me.grey.picquery.feature.TextEncoderONNX class TextEncoderMobileCLIPv2(context: Context) : TextEncoderONNX(context) { override val modelPath: String = "text_model.ort" override val modelType: Int = 1 -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/theme/Color.kt b/app/src/main/java/me/grey/picquery/theme/Color.kt index ba7ef53..77af987 100644 --- a/app/src/main/java/me/grey/picquery/theme/Color.kt +++ b/app/src/main/java/me/grey/picquery/theme/Color.kt @@ -68,7 +68,6 @@ val md_theme_dark_surfaceTint = Color(0xFFA4C9FF) val md_theme_dark_outlineVariant = Color(0xFF43474E) val md_theme_dark_scrim = Color(0xFF000000) - val LightColors = lightColorScheme( primary = md_theme_light_primary, onPrimary = md_theme_light_onPrimary, @@ -98,10 +97,9 @@ val LightColors = lightColorScheme( inversePrimary = md_theme_light_inversePrimary, surfaceTint = md_theme_light_surfaceTint, outlineVariant = md_theme_light_outlineVariant, - scrim = md_theme_light_scrim, + scrim = md_theme_light_scrim ) - val DarkColors = darkColorScheme( primary = md_theme_dark_primary, onPrimary = md_theme_dark_onPrimary, @@ -131,5 +129,5 @@ val DarkColors = darkColorScheme( inversePrimary = md_theme_dark_inversePrimary, surfaceTint = md_theme_dark_surfaceTint, outlineVariant = md_theme_dark_outlineVariant, - scrim = md_theme_dark_scrim, + scrim = md_theme_dark_scrim ) diff --git a/app/src/main/java/me/grey/picquery/theme/ThemeM3.kt b/app/src/main/java/me/grey/picquery/theme/ThemeM3.kt index bf8199e..8a42b2b 100644 --- a/app/src/main/java/me/grey/picquery/theme/ThemeM3.kt +++ b/app/src/main/java/me/grey/picquery/theme/ThemeM3.kt @@ -7,7 +7,6 @@ import androidx.compose.material3.Typography import androidx.compose.runtime.Composable import com.google.accompanist.systemuicontroller.rememberSystemUiController - /** * Material Theme Builder * @@ -20,10 +19,7 @@ val AppShapes = Shapes() val AppTypography = Typography() @Composable -fun PicQueryThemeM3( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit, -) { +fun PicQueryThemeM3(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val colorScheme = if (darkTheme) { DarkColors } else { @@ -32,7 +28,7 @@ fun PicQueryThemeM3( val systemUiController = rememberSystemUiController() systemUiController.setSystemBarsColor( - color = colorScheme.background, + color = colorScheme.background ) MaterialTheme( @@ -41,4 +37,4 @@ fun PicQueryThemeM3( shapes = AppShapes, content = content ) -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/AppNavHost.kt b/app/src/main/java/me/grey/picquery/ui/AppNavHost.kt index 0f358a2..8e9e106 100644 --- a/app/src/main/java/me/grey/picquery/ui/AppNavHost.kt +++ b/app/src/main/java/me/grey/picquery/ui/AppNavHost.kt @@ -2,6 +2,12 @@ package me.grey.picquery.ui import android.net.Uri import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -13,18 +19,12 @@ import me.grey.picquery.common.Animation.popInAnimation import me.grey.picquery.common.Routes import me.grey.picquery.ui.display.DisplayScreen import me.grey.picquery.ui.home.HomeScreen -import me.grey.picquery.ui.photoDetail.PhotoDetailScreen import me.grey.picquery.ui.indexmgr.IndexMgrScreen +import me.grey.picquery.ui.photoDetail.PhotoDetailScreen import me.grey.picquery.ui.search.SearchScreen import me.grey.picquery.ui.setting.SettingScreen -import me.grey.picquery.ui.simlilar.SimilarPhotosScreen -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.saveable.Saver import me.grey.picquery.ui.simlilar.LocalSimilarityConfig +import me.grey.picquery.ui.simlilar.SimilarPhotosScreen import me.grey.picquery.ui.simlilar.SimilarityConfiguration import timber.log.Timber @@ -32,14 +32,16 @@ import timber.log.Timber fun AppNavHost( modifier: Modifier = Modifier, navController: NavHostController = rememberNavController(), - startDestination: String = Routes.Home.name, + startDestination: String = Routes.Home.name ) { val similarityConfigSaver = Saver>( - save = { listOf( - it.searchImageSimilarityThreshold, - it.similarityGroupDelta, - it.minSimilarityGroupSize.toFloat() - )}, + save = { + listOf( + it.searchImageSimilarityThreshold, + it.similarityGroupDelta, + it.minSimilarityGroupSize.toFloat() + ) + }, restore = { saved -> SimilarityConfiguration( searchImageSimilarityThreshold = saved[0], @@ -65,20 +67,20 @@ fun AppNavHost( navController, startDestination = startDestination, enterTransition = { navigateInAnimation }, - exitTransition = { navigateUpAnimation }, + exitTransition = { navigateUpAnimation } ) { composable(Routes.Home.name) { HomeScreen( modifier = modifier, navigateToSearch = { query -> - navController.navigate("${Routes.Search.name}/${query}") + navController.navigate("${Routes.Search.name}/$query") }, navigateToSearchWitImage = { val query = Uri.encode(it.toString()) - navController.navigate("${Routes.Search.name}/${query}") + navController.navigate("${Routes.Search.name}/$query") }, navigateToSetting = { navController.navigate(Routes.Setting.name) }, - navigateToSimilar = { navController.navigate(Routes.Similar.name) }, + navigateToSimilar = { navController.navigate(Routes.Similar.name) } ) } composable("${Routes.Search.name}/{query}") { @@ -88,32 +90,34 @@ fun AppNavHost( onNavigateBack = { navController.popBackStack() }, onClickPhoto = { _, index -> Timber.tag("AppNavHost").d("onClickPhoto: $index") - navController.navigate("${Routes.Display.name}/${index}") - }, + navController.navigate("${Routes.Display.name}/$index") + } ) } composable( - "${Routes.Display.name}/{index}", + "${Routes.Display.name}/{index}" ) { val initialIndex: Int = it.arguments?.getString("index")?.toInt() ?: 0 DisplayScreen( initialPage = initialIndex, onNavigateBack = { navController.popBackStack() - }, + } ) } composable(Routes.IndexMgr.name) { IndexMgrScreen( - onNavigateBack = { navController.popBackStack() }, + onNavigateBack = { navController.popBackStack() } ) } composable(Routes.Similar.name) { CompositionLocalProvider(LocalSimilarityConfig provides similarityConfig) { SimilarPhotosScreen( onNavigateBack = { navController.popBackStack() }, - onPhotoClick = { groupIndex, _ -> - navController.navigate("${Routes.PhotoDetail.name}/${groupIndex}") + onPhotoClick = { groupIndex, photoIndex, _ -> + navController.navigate( + "${Routes.PhotoDetail.name}/$groupIndex/$photoIndex" + ) }, onConfigUpdate = { newSearchThreshold, newSimilarityDelta, newMinGroupSize -> similarityConfig = SimilarityConfiguration( @@ -129,7 +133,7 @@ fun AppNavHost( Routes.Setting.name, enterTransition = { popInAnimation }, popEnterTransition = { popInAnimation }, - exitTransition = { navigateUpAnimation }, + exitTransition = { navigateUpAnimation } ) { SettingScreen( onNavigateBack = { navController.popBackStack() }, @@ -139,14 +143,15 @@ fun AppNavHost( ) } - composable(Routes.PhotoDetail.name + "/{groupIndex}") { backStackEntry -> + composable(Routes.PhotoDetail.name + "/{groupIndex}/{photoIndex}") { backStackEntry -> val groupIndex = backStackEntry.arguments?.getString("groupIndex")?.toIntOrNull() ?: 0 + val photoIndex = backStackEntry.arguments?.getString("photoIndex")?.toIntOrNull() ?: 0 PhotoDetailScreen( onNavigateBack = { navController.popBackStack() }, - initialPage = groupIndex + initialPage = photoIndex, + groupIndex = groupIndex ) } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/MainActivity.kt b/app/src/main/java/me/grey/picquery/ui/MainActivity.kt index 5bc82f1..c15c0bd 100644 --- a/app/src/main/java/me/grey/picquery/ui/MainActivity.kt +++ b/app/src/main/java/me/grey/picquery/ui/MainActivity.kt @@ -91,7 +91,7 @@ class MainActivity : ComponentActivity() { }) { Text(text = stringResource(id = R.string.privacy_statement_decline)) } - }, + } ) } } 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 94da320..8cb9b35 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 @@ -25,16 +25,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage -import me.grey.picquery.data.model.Album import java.io.File - +import me.grey.picquery.data.model.Album @Composable -fun AlbumCard( - album: Album, - selected: Boolean, - onItemClick: (Album) -> Unit, -) { +fun AlbumCard(album: Album, selected: Boolean, onItemClick: (Album) -> Unit) { val interactionSource = remember { MutableInteractionSource() } val padding: Dp by animateDpAsState(if (selected) 10.dp else 6.dp, label = "") @@ -48,7 +43,7 @@ fun AlbumCard( interactionSource = interactionSource, indication = rememberRipple(), onClick = { onItemClick(album) } - ), + ) ) { Box { AlbumCover(album = album) @@ -67,7 +62,7 @@ fun AlbumCard( overflow = TextOverflow.Ellipsis, maxLines = 1, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.colorScheme.onSurface ) Text( text = album.count.toString(), @@ -80,12 +75,9 @@ fun AlbumCard( } } - @OptIn(ExperimentalGlideComposeApi::class, ExperimentalFoundationApi::class) @Composable -private fun AlbumCover( - album: Album -) { +private fun AlbumCover(album: Album) { GlideImage( modifier = Modifier .aspectRatio(1f) @@ -93,6 +85,6 @@ private fun AlbumCover( .clip(MaterialTheme.shapes.medium), model = File(album.coverPath), contentDescription = album.label, - contentScale = ContentScale.Crop, + contentScale = ContentScale.Crop ) -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/albums/EncodingState.kt b/app/src/main/java/me/grey/picquery/ui/albums/EncodingState.kt index f8e372c..5326aa4 100644 --- a/app/src/main/java/me/grey/picquery/ui/albums/EncodingState.kt +++ b/app/src/main/java/me/grey/picquery/ui/albums/EncodingState.kt @@ -1,11 +1,10 @@ package me.grey.picquery.ui.albums - -data class IndexingAlbumState( +data class EncodingState( val status: Status = Status.None, val total: Int = 0, val current: Int = 0, - val cost: Long = 0, // Time cost for encoding each item + val cost: Long = 0 // Time cost for encoding each item ) { enum class Status { None, Loading, Indexing, Finish, Error @@ -20,7 +19,7 @@ data class IndexingAlbumState( if (this === other) return true if (javaClass != other?.javaClass) return false - other as IndexingAlbumState + other as EncodingState if (status != other.status) return false if (total != other.total) return false @@ -35,4 +34,4 @@ data class IndexingAlbumState( result = 31 * result + current return result } -} \ No newline at end of file +} 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 e7d7a29..7193f6c 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 @@ -52,17 +52,15 @@ class AppBottomSheetState( } 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 - ) - } - ) + 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/BackButton.kt b/app/src/main/java/me/grey/picquery/ui/common/BackButton.kt index e00da43..95ab2a6 100644 --- a/app/src/main/java/me/grey/picquery/ui/common/BackButton.kt +++ b/app/src/main/java/me/grey/picquery/ui/common/BackButton.kt @@ -14,4 +14,4 @@ fun BackButton(onClick: () -> Unit) { contentDescription = "Back" ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/common/Loading.kt b/app/src/main/java/me/grey/picquery/ui/common/Loading.kt index e38c92e..293e575 100644 --- a/app/src/main/java/me/grey/picquery/ui/common/Loading.kt +++ b/app/src/main/java/me/grey/picquery/ui/common/Loading.kt @@ -17,4 +17,4 @@ fun CentralLoadingProgressBar() { ) { LinearProgressIndicator(modifier = Modifier.padding(20.dp)) } -} \ No newline at end of file +} 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 0fb62c7..7e5c46f 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 @@ -48,11 +48,12 @@ fun LogoText(modifier: Modifier = Modifier, size: Float = DEFAULT_LOGO_SIZE) { Row(modifier = modifier) { Text(text = stringResource(R.string.logo_part1_pic), style = textStyle) Text( - text = stringResource(R.string.logo_part2_query), style = textStyle.copy( + text = stringResource(R.string.logo_part2_query), + style = textStyle.copy( fontWeight = FontWeight.Bold, fontSize = (size - 1).sp, color = MaterialTheme.colorScheme.primary ) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/display/DisplayScreen.kt b/app/src/main/java/me/grey/picquery/ui/display/DisplayScreen.kt index e23e0c8..72ac9e9 100644 --- a/app/src/main/java/me/grey/picquery/ui/display/DisplayScreen.kt +++ b/app/src/main/java/me/grey/picquery/ui/display/DisplayScreen.kt @@ -44,20 +44,16 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage +import java.io.File import me.grey.picquery.R import me.grey.picquery.data.model.Photo import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable import org.koin.androidx.compose.koinViewModel -import java.io.File @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable -fun DisplayScreen( - initialPage: Int, - onNavigateBack: () -> Unit, - displayViewModel: DisplayViewModel = koinViewModel() -) { +fun DisplayScreen(initialPage: Int, onNavigateBack: () -> Unit, displayViewModel: DisplayViewModel = koinViewModel()) { val photoList by displayViewModel.photoList.collectAsState() val pagerState = rememberPagerState( initialPage = 0, @@ -95,15 +91,12 @@ fun DisplayScreen( } } ) - } - } ) { it.apply { } HorizontalPager(state = pagerState) { index -> ZoomablePagerImage(photo = photoList[index]) { - } } } @@ -120,7 +113,7 @@ private fun TopPhotoInfoBar(currentPhoto: Photo) { text = currentPhoto.label, style = titleStyle, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.Ellipsis ) Row( verticalAlignment = Alignment.CenterVertically @@ -147,27 +140,20 @@ private fun TopPhotoInfoBar(currentPhoto: Photo) { currentPhoto.timestamp * 1000, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, - DateUtils.FORMAT_SHOW_TIME, + DateUtils.FORMAT_SHOW_TIME ).toString(), - style = bodyStyle, + style = bodyStyle ) } - } } - - @OptIn( - ExperimentalFoundationApi::class, ExperimentalGlideComposeApi::class, + ExperimentalFoundationApi::class, + ExperimentalGlideComposeApi::class ) @Composable -fun ZoomablePagerImage( - modifier: Modifier = Modifier, - photo: Photo, - maxScale: Float = 5f, - onItemClick: () -> Unit -) { +fun ZoomablePagerImage(modifier: Modifier = Modifier, photo: Photo, maxScale: Float = 5f, onItemClick: () -> Unit) { val zoomState = rememberZoomState(maxScale = maxScale) val context = LocalContext.current var showDialog by remember { mutableStateOf(false) } @@ -188,7 +174,6 @@ fun ZoomablePagerImage( interactionSource = remember { MutableInteractionSource() }, indication = null, onDoubleClick = { - }, onClick = onItemClick, onLongClick = { showDialog = true } @@ -196,18 +181,13 @@ fun ZoomablePagerImage( .zoomable(zoomState = zoomState), model = File(photo.path), contentDescription = photo.label, - contentScale = ContentScale.Fit, + contentScale = ContentScale.Fit ) } } @Composable -private fun openWithExternalApp( - callback:() -> Unit, - photo: Photo, - context: Context -) { - +private fun openWithExternalApp(callback: () -> Unit, photo: Photo, context: Context) { AlertDialog( onDismissRequest = { callback() }, title = { Text(stringResource(R.string.open_with_external_app)) }, @@ -230,5 +210,4 @@ private fun openWithExternalApp( } } ) - -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/display/DisplayViewModel.kt b/app/src/main/java/me/grey/picquery/ui/display/DisplayViewModel.kt index 1f5e30e..1bbf43e 100644 --- a/app/src/main/java/me/grey/picquery/ui/display/DisplayViewModel.kt +++ b/app/src/main/java/me/grey/picquery/ui/display/DisplayViewModel.kt @@ -11,16 +11,16 @@ import me.grey.picquery.domain.ImageSearcher class DisplayViewModel( private val photoRepository: PhotoRepository, - private val imageSearcher: ImageSearcher, + private val imageSearcher: ImageSearcher ) : ViewModel() { private val _photoList = MutableStateFlow>(mutableListOf()) - val photoList:StateFlow> = _photoList + val photoList: StateFlow> = _photoList fun loadPhotos() { viewModelScope.launch { val ids = imageSearcher.searchResultIds - val list = reorderList(photoRepository.getPhotoListByIds(ids),ids) + val list = reorderList(photoRepository.getPhotoListByIds(ids), ids) _photoList.emit(list.toMutableList()) } } @@ -29,4 +29,4 @@ class DisplayViewModel( val photoMap = originalList.associateBy { it.id } return orderList.mapNotNull { id -> photoMap[id] } } -} \ No newline at end of file +} 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 0b5002a..8de4f79 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 @@ -53,18 +53,19 @@ fun AddAlbumBottomSheet( ModalBottomSheet( onDismissRequest = { closeSheet() }, - sheetState = sheetState.sheetState, + sheetState = sheetState.sheetState ) { val list by albumManager.unsearchableAlbumList.collectAsState() if (list.isEmpty()) { EmptyAlbumTips( - onClose = { closeSheet() }, + onClose = { closeSheet() } ) } else { val selectedList = remember { albumManager.albumsToEncode } val noAlbumTips = stringResource(R.string.no_album_selected) AlbumSelectionList( - list, selectedList, + list, + selectedList, onStartIndexing = { val snapshot = albumManager.albumsToEncode.toList() albumManager.albumsToEncode.clear() @@ -88,9 +89,7 @@ fun AddAlbumBottomSheet( } @Composable -fun EmptyAlbumTips( - onClose: () -> Unit -) { +fun EmptyAlbumTips(onClose: () -> Unit) { Column( Modifier .height(180.dp) @@ -111,14 +110,13 @@ fun EmptyAlbumTips( } } - @Composable fun AlbumSelectionList( list: List, selectedList: List, onStartIndexing: () -> Unit, onToggleSelectAll: () -> Unit, - onSelectItem: (Album) -> Unit, + onSelectItem: (Album) -> Unit ) { var selectedPhotoCount = 0L selectedList.forEach { @@ -147,10 +145,11 @@ fun AlbumSelectionList( Row { TextButton(onClick = { onToggleSelectAll() }) { Text( - text = if (list.size == selectedList.size) + text = if (list.size == selectedList.size) { stringResource(R.string.unselect_all) - else + } else { stringResource(R.string.select_all) + } ) } Box(modifier = Modifier.width(5.dp)) @@ -170,7 +169,7 @@ fun AlbumSelectionList( content = { items( list.size, - key = { list[it].id }, + key = { list[it].id } ) { index -> val selected = selectedList.contains(list[index]) val item = list[index] @@ -179,9 +178,9 @@ fun AlbumSelectionList( selected = selected, onItemClick = { onSelectItem(item) - }, + } ) } } ) -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/home/EncodingProgressBar.kt b/app/src/main/java/me/grey/picquery/ui/home/EncodingProgressBar.kt index 53e1a5e..72e03b9 100644 --- a/app/src/main/java/me/grey/picquery/ui/home/EncodingProgressBar.kt +++ b/app/src/main/java/me/grey/picquery/ui/home/EncodingProgressBar.kt @@ -23,23 +23,21 @@ import androidx.compose.ui.unit.dp import me.grey.picquery.R import me.grey.picquery.common.calculateRemainingTime import me.grey.picquery.domain.AlbumManager -import me.grey.picquery.ui.albums.IndexingAlbumState +import me.grey.picquery.ui.albums.EncodingState import org.koin.compose.koinInject @Composable -fun EncodingProgressBar( - albumManager: AlbumManager = koinInject(), -) { - val state by remember { albumManager.indexingAlbumState } +fun EncodingProgressBar(albumManager: AlbumManager = koinInject()) { + val state by remember { albumManager.encodingState } var progress = (state.current.toDouble() / state.total).toFloat() if (progress.isNaN()) progress = 0.0f - val finished = state.status == IndexingAlbumState.Status.Finish + val finished = state.status == EncodingState.Status.Finish fun onClickOk() { albumManager.clearIndexingState() } - AnimatedVisibility(visible = state.status != IndexingAlbumState.Status.None) { + AnimatedVisibility(visible = state.status != EncodingState.Status.None) { BottomAppBar { Column( Modifier @@ -49,7 +47,7 @@ fun EncodingProgressBar( Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource( @@ -68,18 +66,21 @@ fun EncodingProgressBar( enabled = finished ) { Text( - text = if (finished) stringResource(R.string.finish_button) - else stringResource(R.string.estimate_remain_time) + + text = if (finished) { + stringResource(R.string.finish_button) + } else { + stringResource(R.string.estimate_remain_time) + " ${DateUtils.formatElapsedTime(remain)}" + } ) } } Box(modifier = Modifier.height(4.dp)) LinearProgressIndicator( progress = { progress }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/home/HomeBottomActions.kt b/app/src/main/java/me/grey/picquery/ui/home/HomeBottomActions.kt index fa86239..3e6f965 100644 --- a/app/src/main/java/me/grey/picquery/ui/home/HomeBottomActions.kt +++ b/app/src/main/java/me/grey/picquery/ui/home/HomeBottomActions.kt @@ -24,15 +24,11 @@ import androidx.compose.ui.unit.dp import me.grey.picquery.R @Composable -fun HomeBottomActions( - onClickManageAlbum: () -> Unit, - navigateToSetting: () -> Unit, - navigateToSimilar: () -> Unit, -) { +fun HomeBottomActions(onClickManageAlbum: () -> Unit, navigateToSetting: () -> Unit, navigateToSimilar: () -> Unit) { Row( modifier = Modifier.padding(bottom = 15.dp), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.CenterVertically ) { IndexAlbumButton(onClick = onClickManageAlbum) VerticalDivider( @@ -54,8 +50,9 @@ fun HomeBottomActions( private fun SettingButton(onClick: () -> Unit) { IconButton(onClick = onClick) { Icon( - imageVector = Icons.Default.Settings, contentDescription = "Settings", - tint = MaterialTheme.colorScheme.primary, + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.primary ) } } @@ -63,9 +60,9 @@ private fun SettingButton(onClick: () -> Unit) { @Composable private fun IndexAlbumButton(onClick: () -> Unit) { TextButton( - onClick = { onClick() }, + onClick = { onClick() } ) { - Icon( imageVector = Icons.Default.Photo, contentDescription = "") + Icon(imageVector = Icons.Default.Photo, contentDescription = "") Box(modifier = Modifier.width(5.dp)) Text(text = stringResource(R.string.index_album_btn)) } @@ -74,14 +71,16 @@ private fun IndexAlbumButton(onClick: () -> Unit) { @Composable private fun SimilarPhotoButton(onClick: () -> Unit) { TextButton( - onClick = { onClick() }, + onClick = { onClick() } ) { - Icon(modifier = Modifier - .height(24.dp) - .width(24.dp), + Icon( + modifier = Modifier + .height(24.dp) + .width(24.dp), painter = painterResource(id = R.drawable.ic_similar), - contentDescription = "") + contentDescription = "" + ) Box(modifier = Modifier.width(5.dp)) Text(text = stringResource(R.string.similar_photos)) } -} \ No newline at end of file +} 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 9e4d87e..11efa4e 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 @@ -67,7 +67,7 @@ fun HomeScreen( navigateToSearch: (String) -> Unit, navigateToSearchWitImage: (Uri) -> Unit, navigateToSetting: () -> Unit, - navigateToSimilar: () -> Unit, + navigateToSimilar: () -> Unit ) { InitPermissions() @@ -79,7 +79,7 @@ fun HomeScreen( if (albumListSheetState.isVisible) { AddAlbumBottomSheet( sheetState = albumListSheetState, - onStartIndexing = { homeViewModel.doneIndexAlbum() }, + onStartIndexing = { homeViewModel.doneIndexAlbum() } ) } @@ -124,7 +124,7 @@ private fun MainContent( .padding(padding) .fillMaxHeight(0.75f), verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + horizontalAlignment = Alignment.CenterHorizontally ) { SearchSection( userGuideVisible = userGuideVisible, @@ -152,10 +152,10 @@ private fun SearchSection( AnimatedVisibility(visible = !userGuideVisible) { Column( verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + horizontalAlignment = Alignment.CenterHorizontally ) { LogoRow(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) - + val searchText by homeViewModel.searchText.collectAsState() SearchInput( queryText = searchText, @@ -169,7 +169,7 @@ private fun SearchSection( if (uri.toString().isNotEmpty()) { navigateToSearchWitImage(uri) } - }, + } ) } } @@ -186,13 +186,13 @@ private fun GuideSection( val scope = rememberCoroutineScope() val currentStep = remember { homeViewModel.currentGuideState } val mediaPermissions = rememberMediaPermissions() - + UserGuide( modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), onRequestPermission = { mediaPermissions.launchMultiplePermissionRequest() }, onOpenAlbum = { scope.launch { albumListSheetState.show() } }, onFinish = { homeViewModel.finishGuide() }, - state = currentStep.value, + state = currentStep.value ) } } @@ -201,7 +201,7 @@ private fun GuideSection( @Composable fun rememberMediaPermissions( homeViewModel: HomeViewModel = koinViewModel(), - albumManager: AlbumManager = koinInject(), + albumManager: AlbumManager = koinInject() ): MultiplePermissionsState { val scope = rememberCoroutineScope() return rememberMultiplePermissionsState( @@ -211,7 +211,7 @@ fun rememberMediaPermissions( homeViewModel.doneRequestPermission() scope.launch { albumManager.initAllAlbumList() } } - }, + } ) } @@ -335,9 +335,9 @@ private fun HomeTopBar( onDismiss = { showSearchFilterBottomSheet = false } ) } - if (showSearchRangeBottomSheet){ + if (showSearchRangeBottomSheet) { SearchRangeBottomSheet(dismiss = { showSearchRangeBottomSheet = false }) } -} \ No newline at end of file +} 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 0bc6134..20a295f 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 @@ -11,14 +11,14 @@ import me.grey.picquery.domain.ImageSearcher data class UserGuideTaskState( val permissionDone: Boolean = false, - val indexDone: Boolean = false, + val indexDone: Boolean = false ) { val allFinished: Boolean get() = permissionDone && indexDone } class HomeViewModel( - private val imageSearcher: ImageSearcher, + private val imageSearcher: ImageSearcher ) : ViewModel() { companion object { @@ -62,5 +62,4 @@ class HomeViewModel( fun finishGuide() { userGuideVisible.value = false } - -} \ No newline at end of file +} 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 cd66afc..02b7104 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 @@ -7,13 +7,9 @@ import me.grey.picquery.domain.AlbumManager import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject - @OptIn(ExperimentalPermissionsApi::class) @Composable -fun InitPermissions( - homeViewModel: HomeViewModel = koinViewModel(), - albumManager: AlbumManager = koinInject() -) { +fun InitPermissions(homeViewModel: HomeViewModel = koinViewModel(), albumManager: AlbumManager = koinInject()) { val mediaPermissions = rememberMediaPermissions() InitializeEffect { if (mediaPermissions.allPermissionsGranted) { @@ -22,4 +18,4 @@ fun InitPermissions( homeViewModel.showUserGuide() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/home/UserGuide.kt b/app/src/main/java/me/grey/picquery/ui/home/UserGuide.kt index a017dca..1d500b1 100644 --- a/app/src/main/java/me/grey/picquery/ui/home/UserGuide.kt +++ b/app/src/main/java/me/grey/picquery/ui/home/UserGuide.kt @@ -31,10 +31,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.accompanist.permissions.ExperimentalPermissionsApi +import java.lang.Float.min import me.grey.picquery.R import me.grey.picquery.common.InitializeEffect import org.koin.androidx.compose.koinViewModel -import java.lang.Float.min @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -47,7 +47,7 @@ fun UserGuide( homeViewModel: HomeViewModel = koinViewModel() ) { val mediaPermissions = rememberMediaPermissions() - InitializeEffect() { + InitializeEffect { if (mediaPermissions.allPermissionsGranted) { homeViewModel.doneRequestPermission() } @@ -67,7 +67,7 @@ fun UserGuide( style = TextStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold) ) }, - supportingContent = { Text(text = stringResource(R.string.user_guide_tips)) }, + supportingContent = { Text(text = stringResource(R.string.user_guide_tips)) } ) // Step 1 @@ -99,7 +99,6 @@ fun UserGuide( onClick = { onFinish() } ) - Box(modifier = Modifier.height(15.dp)) Button( enabled = state.permissionDone, @@ -107,11 +106,13 @@ fun UserGuide( modifier = Modifier.align(Alignment.CenterHorizontally) ) { val text = - if (state.allFinished) stringResource(R.string.i_got_it) - else stringResource(R.string.skip) + if (state.allFinished) { + stringResource(R.string.i_got_it) + } else { + stringResource(R.string.skip) + } Text(text = text) } - } } @@ -122,7 +123,7 @@ private fun StepListItem( title: String, subtitle: String, icon: ImageVector, - onClick: () -> Unit, + onClick: () -> Unit ) { val background = when { @@ -149,7 +150,7 @@ private fun StepListItem( OutlinedCard( modifier = Modifier .padding(vertical = 4.dp), - border = BorderStroke(1.dp, background.copy(alpha = min(background.alpha + 0.1f, 1f))), + border = BorderStroke(1.dp, background.copy(alpha = min(background.alpha + 0.1f, 1f))) ) { ListItem( modifier = Modifier.clickable(enabled = enabled) { onClick() }, @@ -163,7 +164,7 @@ private fun StepListItem( Icon( imageVector = icon, contentDescription = null, - tint = color, + tint = color ) } }, @@ -173,7 +174,7 @@ private fun StepListItem( style = textStyle.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold) ) }, - supportingContent = { Text(text = subtitle, style = textStyle) }, + supportingContent = { Text(text = subtitle, style = textStyle) } ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/indexmgr/IndexMgrScreen.kt b/app/src/main/java/me/grey/picquery/ui/indexmgr/IndexMgrScreen.kt index b0408d3..96491ea 100644 --- a/app/src/main/java/me/grey/picquery/ui/indexmgr/IndexMgrScreen.kt +++ b/app/src/main/java/me/grey/picquery/ui/indexmgr/IndexMgrScreen.kt @@ -31,6 +31,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -40,9 +43,6 @@ import me.grey.picquery.data.model.Album import me.grey.picquery.domain.AlbumManager import me.grey.picquery.ui.common.BackButton import org.koin.compose.koinInject -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale const val FlagAlbumStatusNormal = 0 const val FlagAlbumStatusInvalid = 1 @@ -50,11 +50,7 @@ const val FlagAlbumStatusUpdateNeeded = 2 @OptIn(ExperimentalMaterial3Api::class) @Composable -fun IndexMgrScreen( - onNavigateBack: () -> Unit, - albumManager: AlbumManager = koinInject(), -) { - +fun IndexMgrScreen(onNavigateBack: () -> Unit, albumManager: AlbumManager = koinInject()) { val indexedAlbum = albumManager.searchableAlbumList.collectAsState().value.toMutableStateList() val allAlbum = albumManager.getAlbumList() @@ -62,7 +58,7 @@ fun IndexMgrScreen( topBar = { TopAppBar( title = { Text(stringResource(R.string.index_mgr_title)) }, - navigationIcon = { BackButton { onNavigateBack() } }, + navigationIcon = { BackButton { onNavigateBack() } } ) }, modifier = Modifier.padding(horizontal = 5.dp) @@ -71,16 +67,22 @@ fun IndexMgrScreen( repeat(indexedAlbum.size) { index -> val album = indexedAlbum[index] if (allAlbum.any { a -> a.id == album.id }) { - val albumNow = allAlbum.find{ item -> item.id == album.id } + val albumNow = allAlbum.find { item -> item.id == album.id } if (albumNow!!.count != album.count || albumNow.timestamp != album.timestamp) { - item { AlbumItem(indexedAlbum, album, albumManager, FlagAlbumStatusUpdateNeeded) } + item { + AlbumItem( + indexedAlbum, + album, + albumManager, + FlagAlbumStatusUpdateNeeded + ) + } } else { item { AlbumItem(indexedAlbum, album, albumManager, FlagAlbumStatusNormal) } } } else { - item{ AlbumItem(indexedAlbum, album, albumManager, FlagAlbumStatusInvalid) } + item { AlbumItem(indexedAlbum, album, albumManager, FlagAlbumStatusInvalid) } } - } } } @@ -88,9 +90,9 @@ fun IndexMgrScreen( @Composable private fun AlbumItem( - indexedAlbum: SnapshotStateList, - album: Album, - albumManager: AlbumManager, + indexedAlbum: SnapshotStateList, + album: Album, + albumManager: AlbumManager, albumStatusEnum: Int ) { var isLoading by remember { mutableStateOf(false) } @@ -116,19 +118,15 @@ private fun AlbumItem( ListItem( colors = AlbumItemColors(albumStatusEnum), - leadingContent = { - AlbumItemLeadingIcon(albumStatusEnum) - }, - headlineContent = { - AlbumItemHeadline(album.label) - }, - supportingContent = { + leadingContent = { AlbumItemLeadingIcon(albumStatusEnum) }, + headlineContent = { AlbumItemHeadline(album.label) }, + supportingContent = { AlbumItemSupportingContent( - albumStatusEnum = albumStatusEnum, - album = album, - isLoading = isLoading, + albumStatusEnum = albumStatusEnum, + album = album, + isLoading = isLoading, isDone = isDone - ) + ) }, modifier = Modifier .padding(horizontal = 8.dp, vertical = 4.dp) @@ -141,11 +139,7 @@ private fun AlbumItem( } @Composable -private fun AlbumIndexDeletionDialog( - showDialog: Boolean, - onDismiss: () -> Unit, - onConfirm: () -> Unit -) { +private fun AlbumIndexDeletionDialog(showDialog: Boolean, onDismiss: () -> Unit, onConfirm: () -> Unit) { if (showDialog) { AlertDialog( onDismissRequest = onDismiss, @@ -187,25 +181,20 @@ private fun AlbumItemLeadingIcon(albumStatusEnum: Int) { @Composable private fun AlbumItemHeadline(label: String) { Text( - text = label, + text = label, style = MaterialTheme.typography.titleMedium ) } @Composable -private fun AlbumItemSupportingContent( - albumStatusEnum: Int, - album: Album, - isLoading: Boolean, - isDone: Boolean -) { +private fun AlbumItemSupportingContent(albumStatusEnum: Int, album: Album, isLoading: Boolean, isDone: Boolean) { val dateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) val dateStr = dateFmt.format(Date(album.timestamp * 1000)) val descriptionText = buildAnnotatedString { append("${stringResource(R.string.album_photo_count)}: ${album.count}\n") append("${stringResource(R.string.album_date)}: $dateStr\n") - + when (albumStatusEnum) { FlagAlbumStatusInvalid -> append(stringResource(R.string.album_invalid_desc)) FlagAlbumStatusUpdateNeeded -> append(stringResource(R.string.album_update_needed_desc)) @@ -232,7 +221,9 @@ private fun AlbumItemSupportingContent( private fun AlbumItemColors(albumStatusEnum: Int) = ListItemDefaults.colors( containerColor = when (albumStatusEnum) { FlagAlbumStatusInvalid -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f) - FlagAlbumStatusUpdateNeeded -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f) + FlagAlbumStatusUpdateNeeded -> MaterialTheme.colorScheme.tertiaryContainer.copy( + alpha = 0.3f + ) else -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.2f) }, headlineColor = when (albumStatusEnum) { @@ -247,9 +238,13 @@ private fun AlbumItemColors(albumStatusEnum: Int) = ListItemDefaults.colors( } ) -private suspend fun removeIndexByAlbum ( album: Album, albumManager: AlbumManager,dispatcher: CoroutineDispatcher = Dispatchers.IO) { +private suspend fun removeIndexByAlbum( + album: Album, + albumManager: AlbumManager, + dispatcher: CoroutineDispatcher = Dispatchers.IO +) { withContext(dispatcher) { albumManager.removeSingleAlbumIndex(album) albumManager.initDataFlow() } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/photoDetail/PhotoDetailScreen.kt b/app/src/main/java/me/grey/picquery/ui/photoDetail/PhotoDetailScreen.kt index f363592..39ce34f 100644 --- a/app/src/main/java/me/grey/picquery/ui/photoDetail/PhotoDetailScreen.kt +++ b/app/src/main/java/me/grey/picquery/ui/photoDetail/PhotoDetailScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -28,31 +29,44 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage +import java.io.File +import kotlin.collections.isNotEmpty import me.grey.picquery.R +import me.grey.picquery.data.model.Photo +import me.grey.picquery.ui.simlilar.SimilarPhotosViewModel import org.koin.androidx.compose.koinViewModel import timber.log.Timber -import java.io.File -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalMaterial3Api::class, ExperimentalGlideComposeApi::class ) @Composable fun PhotoDetailScreen( onNavigateBack: () -> Unit, initialPage: Int = 0, - photoDetailViewModel: PhotoDetailViewModel = koinViewModel() + groupIndex: Int = 0, + photoDetailViewModel: SimilarPhotosViewModel = koinViewModel() ) { - - LaunchedEffect(initialPage) { - photoDetailViewModel.loadPhotosFromGroup(initialPage) + val photoList by photoDetailViewModel.selectedPhotos.collectAsState() + LaunchedEffect(groupIndex) { + photoDetailViewModel.getPhotosFromGroup(groupIndex) } - val photoList by photoDetailViewModel.photoList.collectAsState() + val safeInitialPage = if (initialPage < photoList.size) initialPage else 0 val pagerState = rememberPagerState( - initialPage = 0, + initialPage = safeInitialPage, pageCount = { photoList.size } ) + // Ensure pagerState.currentPage is always within bounds of photoList + LaunchedEffect(photoList.size) { + if (photoList.isNotEmpty() && pagerState.currentPage >= photoList.size) { + pagerState.animateScrollToPage(0) + } + } + val externalAlbumLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { result -> @@ -63,50 +77,93 @@ fun PhotoDetailScreen( } } + PhotoDetailScreenContent( + photoList = photoList, + pagerState = pagerState, + onNavigateBack = onNavigateBack, + externalAlbumLauncher = externalAlbumLauncher + ) +} + +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalMaterial3Api::class, + ExperimentalGlideComposeApi::class +) +@Composable +private fun PhotoDetailScreenContent( + photoList: List, + pagerState: PagerState, + onNavigateBack: () -> Unit, + externalAlbumLauncher: androidx.activity.result.ActivityResultLauncher +) { + val currentPhoto = if (photoList.isNotEmpty() && pagerState.currentPage < photoList.size) { + photoList[pagerState.currentPage] + } else { + null + } + Scaffold( topBar = { - val currentPhoto = if (photoList.isNotEmpty()) photoList[pagerState.currentPage] else null - TopAppBar( - title = { - Text( - text = if (currentPhoto != null) { - stringResource(R.string.photo_details_with_id, currentPhoto.id) - } else { - stringResource(R.string.photo_details) - } - ) - }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - actions = { - - IconButton( - onClick = { - val editIntent = Intent(Intent.ACTION_EDIT).apply { - setDataAndType(currentPhoto!!.uri, "image/*") - } - externalAlbumLauncher.launch(editIntent) - } - ) { - Icon(Icons.Filled.OpenWith, contentDescription = "") + PhotoDetailTopBar( + currentPhoto = currentPhoto, + onNavigateBack = onNavigateBack, + onOpenExternal = { + val editIntent = Intent(Intent.ACTION_EDIT).apply { + setDataAndType(currentPhoto?.uri, "image/*") } + externalAlbumLauncher.launch(editIntent) } ) } ) { paddingValues -> Box(modifier = Modifier.padding(paddingValues)) { - HorizontalPager(state = pagerState) { index -> - val photo = photoList[index] - GlideImage( - model = File(photo.path), - contentDescription = photo.label, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit - ) + PhotoPager( + photoList = photoList, + pagerState = pagerState + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PhotoDetailTopBar(currentPhoto: Photo?, onNavigateBack: () -> Unit, onOpenExternal: () -> Unit) { + TopAppBar( + title = { + Text( + text = if (currentPhoto != null) { + stringResource(R.string.photo_details_with_id, currentPhoto.id) + } else { + stringResource(R.string.photo_details) + } + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = onOpenExternal) { + Icon(Icons.Filled.OpenWith, contentDescription = "Open in external app") } } + ) +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalGlideComposeApi::class) +@Composable +private fun PhotoPager(photoList: List, pagerState: PagerState) { + HorizontalPager(state = pagerState) { index -> + if (index < photoList.size) { + val photo = photoList[index] + GlideImage( + model = File(photo.path), + contentDescription = photo.label, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/photoDetail/PhotoDetailViewModel.kt b/app/src/main/java/me/grey/picquery/ui/photoDetail/PhotoDetailViewModel.kt index 36b07a5..b5a58c9 100644 --- a/app/src/main/java/me/grey/picquery/ui/photoDetail/PhotoDetailViewModel.kt +++ b/app/src/main/java/me/grey/picquery/ui/photoDetail/PhotoDetailViewModel.kt @@ -15,7 +15,7 @@ import me.grey.picquery.domain.SimilarityManager class PhotoDetailViewModel( private val similarityManager: SimilarityManager, private val photoRepository: PhotoRepository, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO + private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { private val _photoList = MutableStateFlow>(emptyList()) val photoList: StateFlow> = _photoList @@ -32,7 +32,9 @@ class PhotoDetailViewModel( _photoList.update { photos } } else { similarityManager.groupSimilarPhotos().collect { - val updatedSimilarGroup = similarityManager.getSimilarityGroupByIndex(groupIndex) + val updatedSimilarGroup = similarityManager.getSimilarityGroupByIndex( + groupIndex + ) if (updatedSimilarGroup != null) { val photos = updatedSimilarGroup.mapNotNull { photoNode -> photoRepository.getPhotoById(photoNode.photoId) @@ -45,4 +47,4 @@ class PhotoDetailViewModel( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/search/ConfidenceTag.kt b/app/src/main/java/me/grey/picquery/ui/search/ConfidenceTag.kt index 7f79048..00db265 100644 --- a/app/src/main/java/me/grey/picquery/ui/search/ConfidenceTag.kt +++ b/app/src/main/java/me/grey/picquery/ui/search/ConfidenceTag.kt @@ -20,17 +20,14 @@ import androidx.compose.ui.unit.dp import me.grey.picquery.R @Composable -fun ConfidenceTag( - confidenceLevel: SearchResult.ConfidenceLevel, - modifier: Modifier = Modifier -) { +fun ConfidenceTag(confidenceLevel: SearchResult.ConfidenceLevel, modifier: Modifier = Modifier) { val (text, color) = when (confidenceLevel) { SearchResult.ConfidenceLevel.LOW -> - stringResource(R.string.confidence_low) to Color(0xFFFF9800) // Deep Red + stringResource(R.string.confidence_low) to Color(0xFFFF9800) // Deep Red SearchResult.ConfidenceLevel.MEDIUM -> - stringResource(R.string.confidence_medium) to Color(0xFFB3FF00) // Amber + stringResource(R.string.confidence_medium) to Color(0xFFB3FF00) // Amber SearchResult.ConfidenceLevel.HIGH -> - stringResource(R.string.confidence_high) to Color(0xFF388E3C) // Dark Green + stringResource(R.string.confidence_high) to Color(0xFF388E3C) // Dark Green } Row( @@ -51,4 +48,4 @@ fun ConfidenceTag( style = MaterialTheme.typography.labelSmall ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/search/SearchConfigBottomSheet.kt b/app/src/main/java/me/grey/picquery/ui/search/SearchConfigBottomSheet.kt index 92c7154..cc3d779 100644 --- a/app/src/main/java/me/grey/picquery/ui/search/SearchConfigBottomSheet.kt +++ b/app/src/main/java/me/grey/picquery/ui/search/SearchConfigBottomSheet.kt @@ -22,10 +22,7 @@ import me.grey.picquery.domain.ImageSearcher @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SearchConfigBottomSheet( - imageSearcher: ImageSearcher, - onDismiss: () -> Unit -) { +fun SearchConfigBottomSheet(imageSearcher: ImageSearcher, onDismiss: () -> Unit) { var matchThreshold by remember { mutableStateOf(imageSearcher.matchThreshold.value) } var topK by remember { mutableStateOf(imageSearcher.topK.value) } @@ -39,7 +36,7 @@ fun SearchConfigBottomSheet( Text( text = stringResource(R.string.match_threshold_title) + - ": ${String.format("%.2f", matchThreshold)}", + ": ${String.format("%.2f", matchThreshold)}", style = MaterialTheme.typography.bodyMedium ) Text( @@ -56,7 +53,7 @@ fun SearchConfigBottomSheet( Text( text = stringResource(R.string.top_k_results_title) + - ": $topK", + ": $topK", style = MaterialTheme.typography.bodyMedium ) Text( @@ -84,4 +81,4 @@ fun SearchConfigBottomSheet( } } } -} \ No newline at end of file +} 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 98ea745..e2cd4f4 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 @@ -43,7 +43,7 @@ import org.koin.compose.koinInject fun SearchFilterBottomSheet( sheetState: AppBottomSheetState, imageSearcher: ImageSearcher = koinInject(), - albumManager: AlbumManager = koinInject(), + albumManager: AlbumManager = koinInject() ) { val scope = rememberCoroutineScope() val candidates = albumManager.searchableAlbumList.value.toMutableStateList() @@ -68,7 +68,7 @@ fun SearchFilterBottomSheet( ModalBottomSheet( onDismissRequest = { closeFilter() }, - sheetState = sheetState.sheetState, + sheetState = sheetState.sheetState ) { ListItem( headlineContent = { @@ -96,7 +96,7 @@ fun SearchFilterBottomSheet( trailingContent = { Switch( checked = searchAll.value, - onCheckedChange = { searchAll.value = it }, + onCheckedChange = { searchAll.value = it } ) } ) @@ -105,12 +105,12 @@ fun SearchFilterBottomSheet( EmptyAlbumTips(onClose = { closeFilter() }) } else { Box(modifier = Modifier.padding(bottom = 55.dp)) { - SearchAbleAlbums( + SearchFilterAlbums( enabled = !searchAll.value, candidates = candidates, selectedList = selectedList, onAdd = { selectedList.add(it) }, - onRemove = { selectedList.remove(it) }, + onRemove = { selectedList.remove(it) } ) } } @@ -122,14 +122,13 @@ fun SearchFilterBottomSheet( ExperimentalMaterial3Api::class ) @Composable -private fun SearchAbleAlbums( +private fun SearchFilterAlbums( enabled: Boolean, candidates: List, selectedList: List, onAdd: (Album) -> Unit, - onRemove: (Album) -> Unit, + onRemove: (Album) -> Unit ) { - FlowRow( Modifier.padding(horizontal = 12.dp) ) { @@ -141,7 +140,7 @@ private fun SearchAbleAlbums( iconColor = MaterialTheme.colorScheme.primary, selectedContainerColor = MaterialTheme.colorScheme.primary, selectedLabelColor = MaterialTheme.colorScheme.onPrimary, - selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimary, + selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimary ) ElevatedFilterChip( @@ -156,7 +155,7 @@ private fun SearchAbleAlbums( } else { Icons.Outlined.AddCircleOutline }, - contentDescription = "", + contentDescription = "" ) }, selected = selected.value, @@ -175,4 +174,4 @@ private fun SearchAbleAlbums( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/search/SearchInput.kt b/app/src/main/java/me/grey/picquery/ui/search/SearchInput.kt index f96ad16..49a5582 100644 --- a/app/src/main/java/me/grey/picquery/ui/search/SearchInput.kt +++ b/app/src/main/java/me/grey/picquery/ui/search/SearchInput.kt @@ -50,7 +50,7 @@ fun SearchInput( onImageSearch: (Uri) -> Unit, onQueryChange: (String) -> Unit, onNavigateBack: (() -> Unit)? = null, - showBackButton: Boolean = false, + showBackButton: Boolean = false ) { val keyboard = LocalSoftwareKeyboardController.current val textStyle = TextStyle(color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)) @@ -61,14 +61,14 @@ fun SearchInput( .padding(horizontal = 14.dp), query = queryText, onQueryChange = { onQueryChange(it) }, - onSearch = { + onSearch = { onStartSearch(queryText) keyboard?.hide() }, active = false, onActiveChange = { }, placeholder = { SearchPlaceholder() }, - leadingIcon = { + leadingIcon = { if (showBackButton && onNavigateBack != null) { BackButton(onClick = onNavigateBack) } else { @@ -85,7 +85,7 @@ fun SearchInput( }, onImageSearch = onImageSearch ) - }, + } ) {} } @@ -107,7 +107,7 @@ private fun SearchTrailingIcons( queryText: String, textStyle: TextStyle, onClearText: () -> Unit, - onImageSearch: (Uri) -> Unit, + onImageSearch: (Uri) -> Unit ) { Row { // Clear text button @@ -133,22 +133,18 @@ private fun ClearTextButton(onClear: () -> Unit) { } @Composable -private fun rememberImagePicker( - onImageSearch: (Uri) -> Unit -) = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - if (uri != null) { - Timber.tag("PhotoPicker").d("Selected URI: $uri") - onImageSearch(uri) - } else { - Timber.tag("PhotoPicker").d("No media selected") +private fun rememberImagePicker(onImageSearch: (Uri) -> Unit) = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) { + Timber.tag("PhotoPicker").d("Selected URI: $uri") + onImageSearch(uri) + } else { + Timber.tag("PhotoPicker").d("No media selected") + } } -} @Composable -private fun ImageSearchButton( - textStyle: TextStyle, - onClick: () -> Unit -) { +private fun ImageSearchButton(textStyle: TextStyle, onClick: () -> Unit) { IconButton(onClick = onClick) { Icon( imageVector = Icons.Default.PhotoCamera, @@ -169,13 +165,9 @@ private fun BackButton(onClick: () -> Unit) { } } - @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SearchSettingsSection( - textStyle: TextStyle, - onImageSearch: (Uri) -> Unit, -) { +private fun SearchSettingsSection(textStyle: TextStyle, onImageSearch: (Uri) -> Unit) { var showPickerBottomSheet by remember { mutableStateOf(false) } // System Picker @@ -218,7 +210,11 @@ private fun SearchSettingsSection( icon = Icons.Outlined.Image, text = stringResource(R.string.system_album), onClick = { - photoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + photoPicker.launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) showPickerBottomSheet = false } ) @@ -251,11 +247,7 @@ private fun SearchSettingsSection( } @Composable -private fun PickerOptionItem( - icon: ImageVector, - text: String, - onClick: () -> Unit -) { +private fun PickerOptionItem(icon: ImageVector, text: String, onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/me/grey/picquery/ui/search/SearchRangeBottomSheet.kt b/app/src/main/java/me/grey/picquery/ui/search/SearchRangeBottomSheet.kt index 0f9b650..e5a58f7 100644 --- a/app/src/main/java/me/grey/picquery/ui/search/SearchRangeBottomSheet.kt +++ b/app/src/main/java/me/grey/picquery/ui/search/SearchRangeBottomSheet.kt @@ -52,21 +52,21 @@ fun SearchRangeBottomSheet( val candidates by albumManager.searchableAlbumList.collectAsState() val selectedList = remember { mutableStateListOf() } selectedList.addAll(imageSearcher.searchRange.toList()) - var searchAll by imageSearcher.isSearchAll + val searchAll = remember { mutableStateOf(imageSearcher.isSearchAll.value) } val canSave = remember { - derivedStateOf { searchAll || selectedList.isNotEmpty() } + derivedStateOf { searchAll.value || selectedList.isNotEmpty() } } fun saveFilter() { scope.launch { - imageSearcher.updateRange(selectedList, searchAll) + imageSearcher.updateRange(selectedList, searchAll.value) dismiss() } } ModalBottomSheet( - onDismissRequest = dismiss, + onDismissRequest = dismiss ) { ListItem( headlineContent = { @@ -89,12 +89,12 @@ fun SearchRangeBottomSheet( ) ListItem( - modifier = Modifier.clickable { searchAll = !searchAll }, + modifier = Modifier.clickable { searchAll.value = !searchAll.value }, headlineContent = { Text(text = stringResource(R.string.all_albums)) }, trailingContent = { Switch( - checked = searchAll, - onCheckedChange = { searchAll = it }, + checked = searchAll.value, + onCheckedChange = { searchAll.value = it } ) } ) @@ -103,12 +103,12 @@ fun SearchRangeBottomSheet( EmptyAlbumTips(onClose = dismiss) } else { Box(modifier = Modifier.padding(bottom = 55.dp)) { - SearchAbleAlbums( - enabled = !searchAll, + SearchRangeAlbums( + enabled = !searchAll.value, candidates = candidates, selectedList = selectedList, onAdd = { selectedList.add(it) }, - onRemove = { selectedList.remove(it) }, + onRemove = { selectedList.remove(it) } ) } } @@ -117,29 +117,29 @@ fun SearchRangeBottomSheet( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun SearchAbleAlbums( +private fun SearchRangeAlbums( enabled: Boolean, candidates: List, selectedList: List, onAdd: (Album) -> Unit, - onRemove: (Album) -> Unit, + onRemove: (Album) -> Unit ) { FlowRow(modifier = Modifier.padding(horizontal = 12.dp)) { val colors = FilterChipDefaults.filterChipColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, // 浅色背景 - labelColor = MaterialTheme.colorScheme.onSurface, // 文字颜色 - iconColor = MaterialTheme.colorScheme.onSurfaceVariant, // 图标颜色 + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + labelColor = MaterialTheme.colorScheme.onSurface, + iconColor = MaterialTheme.colorScheme.onSurfaceVariant, selectedContainerColor = MaterialTheme.colorScheme.primary, selectedLabelColor = MaterialTheme.colorScheme.onPrimary, - selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimary, + selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimary ) repeat(candidates.size) { index -> val album = candidates[index] - val selected by remember { mutableStateOf(selectedList.contains(album)) } - AlbumFilterChip( + val selected = remember { mutableStateOf(selectedList.contains(album)) } + AlbumRangeFilterChip( album = album, enabled = enabled, - isSelected = selected, + isSelected = selected.value, onAdd = onAdd, onRemove = onRemove, colors = colors @@ -149,7 +149,7 @@ private fun SearchAbleAlbums( } @Composable -private fun AlbumFilterChip( +private fun AlbumRangeFilterChip( album: Album, enabled: Boolean, isSelected: Boolean, @@ -165,7 +165,7 @@ private fun AlbumFilterChip( if (enabled) { selected = !selected if (selected) onAdd(album) else onRemove(album) - }else{ + } else { Toast.makeText(context, "Please turn off select all first!", Toast.LENGTH_SHORT).show() } }, @@ -179,7 +179,9 @@ private fun AlbumFilterChip( modifier = Modifier.size(FilterChipDefaults.IconSize) ) } - } else null, + } else { + null + }, modifier = Modifier.padding(8.dp) ) -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/search/SearchResult.kt b/app/src/main/java/me/grey/picquery/ui/search/SearchResult.kt index 2876fcd..275ac05 100644 --- a/app/src/main/java/me/grey/picquery/ui/search/SearchResult.kt +++ b/app/src/main/java/me/grey/picquery/ui/search/SearchResult.kt @@ -14,4 +14,4 @@ data class SearchResult( similarityScore < 0.8 -> ConfidenceLevel.MEDIUM else -> ConfidenceLevel.HIGH } -} \ No newline at end of file +} 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 636f3b6..643f213 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 @@ -1,6 +1,5 @@ package me.grey.picquery.ui.search -import android.util.Log import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -18,7 +17,6 @@ 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.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -30,11 +28,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage +import java.io.File import me.grey.picquery.R import me.grey.picquery.data.model.Photo import me.grey.picquery.ui.common.CentralLoadingProgressBar -import java.io.File - +import timber.log.Timber @OptIn(ExperimentalFoundationApi::class, ExperimentalGlideComposeApi::class) @Composable @@ -42,9 +40,8 @@ fun SearchResultGrid( resultList: List, state: SearchState, resultMap: Map, - onClickPhoto: (Photo, Int) -> Unit, + onClickPhoto: (Photo, Int) -> Unit ) { - when (state) { SearchState.NO_INDEX -> UnReadyText() SearchState.LOADING -> CentralLoadingProgressBar() @@ -60,30 +57,29 @@ fun SearchResultGrid( content = { val padding = Modifier.padding(3.dp) - item(span = { GridItemSpan(3) }) { Box(padding) { PhotoResultRecommend( photo = resultList[0], - onItemClick = { onClickPhoto(resultList[0], 0) }, + onItemClick = { onClickPhoto(resultList[0], 0) } ) } } if (resultList.size > 1) { items( resultList.size - 1, - key = { resultList[it].id }, + key = { resultList[it].id } ) { index -> - Log.e("SearchResultGrid", "index: $index") + Timber.tag("SearchResultGrid").e("index: $index") Box(padding) { val photo = resultList[index + 1] PhotoResultItem( photo, - resultMap[photo.id]!!.toFloat(), + resultMap[photo.id]?.toFloat() ?: 0f, onItemClick = { - Log.e("SearchResultGrid", "click: $index") + Timber.tag("SearchResultGrid").e("click: $index") onClickPhoto(resultList[index + 1], index + 1) - }, + } ) } } @@ -92,11 +88,9 @@ fun SearchResultGrid( ) } } - } } - @Composable private fun UnReadyText() { Box( @@ -105,7 +99,7 @@ private fun UnReadyText() { .fillMaxWidth(), contentAlignment = Alignment.Center ) { - ElevatedButton(onClick = { /*TODO*/ }) { + ElevatedButton(onClick = { }) { Text(text = stringResource(id = R.string.start_index_album)) } } @@ -152,7 +146,7 @@ private fun PhotoResultRecommend(photo: Photo, onItemClick: (photo: Photo) -> Un ), model = File(photo.path), contentDescription = photo.label, - contentScale = ContentScale.Crop, + contentScale = ContentScale.Crop ) } @@ -165,7 +159,6 @@ fun PhotoResultItem( onItemClick: (photo: Photo) -> Unit, modifier: Modifier = Modifier ) { - val searchResult = remember { SearchResult(similarity) } Box(modifier = modifier.clickable { onItemClick(photo) }) { Column { Box(modifier = Modifier.aspectRatio(1f)) { @@ -178,9 +171,9 @@ fun PhotoResultItem( contentScale = ContentScale.Crop ) - // FIXME: ConfidenceTag positioned at the top-right corner + // Confidence tag implementation needed // ConfidenceTag( -// confidenceLevel =searchResult.confidenceLevel, +// confidenceLevel = 0f, // modifier = Modifier // .align(Alignment.TopEnd) // .padding(8.dp) @@ -189,10 +182,10 @@ fun PhotoResultItem( // Optional: Similarity score text // Text( -// text = "Similarity: ${String.format("%.2f", searchResult.similarityScore)}", +// text = "Similarity: ${String.format("%.2f", similarity)}", // style = MaterialTheme.typography.bodySmall, // modifier = Modifier.padding(top = 4.dp) // ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/search/SearchScreen.kt b/app/src/main/java/me/grey/picquery/ui/search/SearchScreen.kt index 3d62a89..9efedbe 100644 --- a/app/src/main/java/me/grey/picquery/ui/search/SearchScreen.kt +++ b/app/src/main/java/me/grey/picquery/ui/search/SearchScreen.kt @@ -24,7 +24,7 @@ fun SearchScreen( searchViewModel: SearchViewModel = koinViewModel() ) { Log.d("SearchScreen", "initialQuery: $initialQuery") - + val resultList by searchViewModel.resultList.collectAsState() val searchState by searchViewModel.searchState.collectAsState() val resultMap by searchViewModel.resultMap.collectAsState() @@ -55,7 +55,7 @@ fun SearchScreen( queryText = queryText, onNavigateBack = onNavigateBack, onQueryChange = { searchViewModel.onQueryChange(it) }, - showBackButton = searchState == SearchState.FINISHED, + showBackButton = searchState == SearchState.FINISHED ) SearchResultGrid( @@ -67,5 +67,3 @@ fun SearchScreen( } } } - - diff --git a/app/src/main/java/me/grey/picquery/ui/search/SearchViewModel.kt b/app/src/main/java/me/grey/picquery/ui/search/SearchViewModel.kt index 125a88a..95dbc38 100644 --- a/app/src/main/java/me/grey/picquery/ui/search/SearchViewModel.kt +++ b/app/src/main/java/me/grey/picquery/ui/search/SearchViewModel.kt @@ -20,6 +20,7 @@ import me.grey.picquery.common.showToast import me.grey.picquery.data.data_source.PhotoRepository import me.grey.picquery.data.model.Photo import me.grey.picquery.domain.ImageSearcher +import timber.log.Timber enum class SearchState { NO_INDEX, // 没有索引 @@ -70,21 +71,23 @@ class SearchViewModel( fun startSearch(text: String) { if (text.trim().isEmpty()) { showToast(context.getString(R.string.empty_search_content_toast)) - Log.w(TAG, "搜索字段为空") + Timber.tag(TAG).w("搜索字段为空") return } _searchText.value = text viewModelScope.launch(ioDispatcher) { _searchState.value = SearchState.SEARCHING - imageSearcher.search(text) { entries -> - if (entries.isNotEmpty()) { - val ids = entries.map { it.value } - val photos = repo.getPhotoListByIds(ids) + imageSearcher.searchV2(text) { ids -> + Timber.tag(TAG).d("searchV2 ids: $ids") + if (ids.isNotEmpty()) { + + val photos = repo.getPhotoListByIds(ids.map { it.first }) + _resultList.value = reOrderList(photos, ids.map { it.first }) _resultMap.update { - entries.associate { it.value to it.key }.toMutableMap() + ids.associate { it.first to (1.0-it.second) }.toMutableMap() } - // reorder by id - _resultList.value = reOrderList(photos, ids) + Timber.tag(TAG).d("searchV2 photos re-orders: ${_resultList.value.size}") + } _searchState.value = SearchState.FINISHED } @@ -101,14 +104,16 @@ class SearchViewModel( } viewModelScope.launch(ioDispatcher) { _searchState.value = SearchState.SEARCHING - imageSearcher.searchWithRange(photo) { entries -> - if (entries.isNotEmpty()) { - val ids = entries.map { it.value } - val photos = repo.getPhotoListByIds(ids) + imageSearcher.searchWithRangeV2(photo) { ids -> + if (ids.isNotEmpty()) { + + val photos = repo.getPhotoListByIds(ids.map { it.first }) + _resultList.value = reOrderList(photos, ids.map { it.first }) _resultMap.update { - entries.associate { it.value to it.key }.toMutableMap() + ids.associate { it.first to (1.0-it.second) }.toMutableMap() } - _resultList.value = reOrderList(photos, ids) + Timber.tag(TAG).d("searchV2 photos re-orders: ${_resultList.value.size}") + } _searchState.value = SearchState.FINISHED } diff --git a/app/src/main/java/me/grey/picquery/ui/setting/SettingScreen.kt b/app/src/main/java/me/grey/picquery/ui/setting/SettingScreen.kt index 226acdc..5361cd9 100644 --- a/app/src/main/java/me/grey/picquery/ui/setting/SettingScreen.kt +++ b/app/src/main/java/me/grey/picquery/ui/setting/SettingScreen.kt @@ -39,20 +39,16 @@ import me.grey.picquery.common.Constants.SOURCE_REPO_URL import me.grey.picquery.ui.common.BackButton import org.koin.androidx.compose.koinViewModel - @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingScreen( - onNavigateBack: () -> Unit, - navigateToIndexMgr: () -> Unit, -) { +fun SettingScreen(onNavigateBack: () -> Unit, navigateToIndexMgr: () -> Unit) { Scaffold( topBar = { TopAppBar( title = { Text(stringResource(R.string.settings_title)) }, - navigationIcon = { BackButton { onNavigateBack() } }, + navigationIcon = { BackButton { onNavigateBack() } } ) - }, + } ) { LazyColumn(modifier = Modifier.padding(it)) { item { @@ -83,14 +79,13 @@ private fun UploadLogSettingItem(settingViewModel: SettingViewModel = koinViewMo checked = enable.value, onCheckedChange = { enabled -> settingViewModel.setEnableUploadLog(enabled) - }, + } ) }, modifier = Modifier.clickable { settingViewModel.setEnableUploadLog(!enable.value) } ) } - @Composable private fun InformationRow() { val context = LocalContext.current @@ -105,14 +100,17 @@ private fun InformationRow() { .padding(bottom = 15.dp) .fillMaxWidth(), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.CenterVertically ) { TextButton(onClick = { launchURL(PRIVACY_URL) }) { Text(text = stringResource(R.string.privacy_policy)) } Divider() TextButton(onClick = { launchURL(SOURCE_REPO_URL) }) { - Icon(imageVector = Icons.Default.Code, contentDescription = stringResource(R.string.github)) + Icon( + imageVector = Icons.Default.Code, + contentDescription = stringResource(R.string.github) + ) Box(modifier = Modifier.width(5.dp)) Text(text = stringResource(R.string.github)) } @@ -141,4 +139,4 @@ private fun AlbumIndexManagerUIItem(navigateToIndexMgr: () -> Unit) { supportingContent = { Text(text = stringResource(R.string.album_index_manager_ui_desc)) }, modifier = Modifier.clickable { navigateToIndexMgr() } ) -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/setting/SettingViewModel.kt b/app/src/main/java/me/grey/picquery/ui/setting/SettingViewModel.kt index 2391a04..75245d7 100644 --- a/app/src/main/java/me/grey/picquery/ui/setting/SettingViewModel.kt +++ b/app/src/main/java/me/grey/picquery/ui/setting/SettingViewModel.kt @@ -19,4 +19,4 @@ class SettingViewModel( } showToast("将在下次启动APP时生效") } -} \ No newline at end of file +} 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 4e3ef48..957b8bc 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 @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings @@ -27,26 +28,26 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage +import java.io.File import me.grey.picquery.R import me.grey.picquery.data.model.Photo import me.grey.picquery.ui.common.BackButton import org.koin.androidx.compose.koinViewModel -import java.io.File @OptIn(ExperimentalMaterial3Api::class) @Composable fun SimilarPhotosScreen( onNavigateBack: () -> Unit, - onPhotoClick: (Int, List) -> Unit, + onPhotoClick: (Int, Int, List) -> Unit, onConfigUpdate: (Float, Float, Int) -> Unit, modifier: Modifier = Modifier, - similarPhotosViewModel: SimilarPhotosViewModel = koinViewModel(), + similarPhotosViewModel: SimilarPhotosViewModel = koinViewModel() ) { val uiState by similarPhotosViewModel.uiState.collectAsState() val configuration = LocalSimilarityConfig.current var showConfigBottomSheet by remember { mutableStateOf(false) } - var lastConfiguration by remember { + val lastConfiguration by remember { mutableStateOf( SimilarityConfiguration( searchImageSimilarityThreshold = configuration.searchImageSimilarityThreshold, @@ -62,16 +63,15 @@ fun SimilarPhotosScreen( if (configuration.searchImageSimilarityThreshold != lastConfiguration.searchImageSimilarityThreshold || configuration.similarityGroupDelta != lastConfiguration.similarityGroupDelta ) { - similarPhotosViewModel.resetState() - similarPhotosViewModel.updateSimilarityConfiguration( - searchImageSimilarityThreshold = configuration.searchImageSimilarityThreshold, - similarityDelta = configuration.similarityGroupDelta - ) - lastConfiguration = SimilarityConfiguration( - searchImageSimilarityThreshold = configuration.searchImageSimilarityThreshold, - similarityGroupDelta = configuration.similarityGroupDelta - ) +// similarPhotosViewModel.updateSimilarityConfiguration( +// searchImageSimilarityThreshold = configuration.searchImageSimilarityThreshold, +// similarityDelta = configuration.similarityGroupDelta +// ) +// lastConfiguration = SimilarityConfiguration( +// searchImageSimilarityThreshold = configuration.searchImageSimilarityThreshold, +// similarityGroupDelta = configuration.similarityGroupDelta +// ) } } @@ -79,7 +79,7 @@ fun SimilarPhotosScreen( LaunchedEffect(similarPhotosViewModel) { if (uiState is SimilarPhotosUiState.Loading && !hasLoadedInitially.value) { - similarPhotosViewModel.loadSimilarPhotos() + similarPhotosViewModel.findSimilarPhotos() hasLoadedInitially.value = true } } @@ -119,12 +119,13 @@ fun SimilarPhotosScreen( } is SimilarPhotosUiState.Success -> { + val handlePhotoClick: (Int, Int, List) -> Unit = { groupIndex, photoIndex, photoGroup -> + onPhotoClick(groupIndex, photoIndex, photoGroup) + } SimilarPhotosGroup( modifier = Modifier.fillMaxSize(), photos = state.similarPhotoGroups, - onPhotoClick = { groupIndex, photo -> - onPhotoClick(groupIndex, photo) - } + onPhotoClick = handlePhotoClick ) } @@ -172,7 +173,7 @@ private fun ErrorStateView(state: SimilarPhotosUiState.Error) { fun SimilarPhotosGroup( modifier: Modifier = Modifier, photos: List>, - onPhotoClick: (Int, List) -> Unit + onPhotoClick: (Int, Int, List) -> Unit ) { LazyColumn( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), @@ -190,7 +191,7 @@ fun SimilarPhotosGroup( it.timestamp * 1000, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, - DateUtils.FORMAT_SHOW_TIME, + DateUtils.FORMAT_SHOW_TIME ).toString() } ?: "Group ${groupIndex + 1}" @@ -220,7 +221,7 @@ fun SimilarPhotosGroup( horizontalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(horizontal = 4.dp) ) { - items(photoGroup) { photo -> + itemsIndexed(photoGroup) { photoIndex, photo -> PhotoGroupItem( photo = photo, modifier = Modifier @@ -229,7 +230,7 @@ fun SimilarPhotosGroup( elevation = 4.dp, shape = RoundedCornerShape(2.dp) ), - onClick = { onPhotoClick(groupIndex, photoGroup) } + onClick = { onPhotoClick(groupIndex, photoIndex, photoGroup) } ) } } @@ -239,11 +240,7 @@ fun SimilarPhotosGroup( @OptIn(ExperimentalFoundationApi::class, ExperimentalGlideComposeApi::class) @Composable -fun PhotoGroupItem( - photo: Photo, - modifier: Modifier = Modifier, - onClick: () -> Unit -) { +fun PhotoGroupItem(photo: Photo, modifier: Modifier = Modifier, onClick: () -> Unit) { val interactionSource = remember { MutableInteractionSource() } Box( @@ -325,10 +322,7 @@ fun ErrorView( } @Composable -fun EmptyStateView( - message: String = "No photos to display", - icon: ImageVector = Icons.Outlined.ImageNotSupported -) { +fun EmptyStateView(message: String = "No photos to display", icon: ImageVector = Icons.Outlined.ImageNotSupported) { Box( modifier = Modifier .fillMaxSize() @@ -358,13 +352,10 @@ fun EmptyStateView( } @Composable -fun GenericErrorView( - message: String? = "An unexpected error occurred", - onRetry: (() -> Unit)? = null -) { +fun GenericErrorView(message: String? = "An unexpected error occurred", onRetry: (() -> Unit)? = null) { ErrorView( title = "Oops! Something went wrong", message = message, onRetry = onRetry ) -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarPhotosViewModel.kt b/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarPhotosViewModel.kt index afb8107..52da057 100644 --- a/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarPhotosViewModel.kt +++ b/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarPhotosViewModel.kt @@ -5,29 +5,31 @@ import androidx.lifecycle.viewModelScope import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import me.grey.picquery.data.data_source.ObjectBoxEmbeddingRepository import me.grey.picquery.data.data_source.PhotoRepository import me.grey.picquery.data.model.Photo import me.grey.picquery.domain.SimilarityManager -import me.grey.picquery.domain.worker.ImageSimilarityCalculationWorker import timber.log.Timber - enum class ErrorType { - WORKER_TIMEOUT, - CALCULATION_FAILED, - NO_SIMILAR_PHOTOS, - UNKNOWN + WORKER_TIMEOUT, CALCULATION_FAILED, NO_SIMILAR_PHOTOS, UNKNOWN } + // Sealed interface for Similar Photos UI State sealed interface SimilarPhotosUiState { data object Loading : SimilarPhotosUiState @@ -36,106 +38,95 @@ sealed interface SimilarPhotosUiState { val type: ErrorType = ErrorType.UNKNOWN, val message: String? = null ) : SimilarPhotosUiState + data class Success(val similarPhotoGroups: List>) : SimilarPhotosUiState } - class SimilarPhotosViewModel( private val coroutineScope: CoroutineDispatcher, private val photoRepository: PhotoRepository, + private val objectBoxEmbeddingRepository: ObjectBoxEmbeddingRepository, private val similarityManager: SimilarityManager, private val workManager: WorkManager ) : ViewModel() { private val _uiState = MutableStateFlow(SimilarPhotosUiState.Loading) val uiState: StateFlow = _uiState + val similarPhotoIds = mutableSetOf() - // Add a method to update similarity configuration - fun updateSimilarityConfiguration( - searchImageSimilarityThreshold: Float = 0.96f, - similarityDelta: Float = 0.02f, - minSimilarityGroupSize: Int = 2 - ) { - Timber.tag("updateSimilarityConfiguration").d("updateSimilarityConfiguration") - // Update SimilarityManager configuration - similarityManager.updateConfiguration( - newSimilarityThreshold = searchImageSimilarityThreshold, - newSimilarityDelta = similarityDelta, - newMinGroupSize = minSimilarityGroupSize - ) - // Reset UI state to trigger recalculation - _uiState.update { SimilarPhotosUiState.Loading } - // Reload similar photos with new configuration - loadSimilarPhotos() - } - - private fun calculateSimilarities(): kotlinx.coroutines.flow.Flow { - val similarityCalculationWork = - OneTimeWorkRequestBuilder().build() - workManager.enqueue(similarityCalculationWork) + private val _selectedPhotos = MutableStateFlow>(mutableListOf()) + val selectedPhotos = _selectedPhotos.asStateFlow() - return workManager.getWorkInfoByIdFlow(similarityCalculationWork.id) - .transform { workInfo -> - Timber.tag("SimilarPhotosViewModel").d("Worker state: ${workInfo.state}") - emit(workInfo.state) - if (workInfo.state.isFinished) return@transform + fun getPhotosFromGroup(groupIndex: Int) { + uiState.value.let { state -> + val photos = if (state is SimilarPhotosUiState.Success) { + state.similarPhotoGroups.getOrNull(groupIndex) ?: emptyList() + } else { + emptyList() } + _selectedPhotos.update { photos.toMutableList() } + } } - fun loadSimilarPhotos() { - viewModelScope.launch(coroutineScope) { - _uiState.update { SimilarPhotosUiState.Loading } - try { - // Wait for worker to complete with a timeout - withTimeout(30_000) { - calculateSimilarities() - .first { it == WorkInfo.State.SUCCEEDED || it == WorkInfo.State.FAILED } - } - } catch (e: Exception) { - Timber.tag("SimilarPhotosViewModel") - .e(e, "Error waiting for similarity calculation") - _uiState.update { - SimilarPhotosUiState.Error( - type = ErrorType.WORKER_TIMEOUT, - message = "Similarity calculation timed out after 30 seconds" - ) - } - return@launch - } + fun findSimilarPhotos() = viewModelScope.launch { + Timber.tag("SimilarPhotosViewModel") + .d("start findSimilarPhotos${System.currentTimeMillis()}") + val processedPhotoIds = ConcurrentHashMap.newKeySet() + val similarGroups = mutableListOf>() - val similarGroups = mutableListOf>() - val start = System.currentTimeMillis() - similarityManager.groupSimilarPhotos() - .catch { e -> - Timber.tag("SimilarPhotosViewModel").e(e, "Error loading similar photos") - _uiState.update { - SimilarPhotosUiState.Error( - type = ErrorType.CALCULATION_FAILED, - message = e.localizedMessage ?: "Failed to group similar photos" - ) - } - } - .onCompletion { - val end = System.currentTimeMillis() - Timber.tag("SimilarPhotosViewModel").d("%sms", (end - start).toString()) - } - .collect { similarGroup -> - val photoGroup = similarGroup.mapNotNull { node -> - photoRepository.getPhotoById(node.photoId) + objectBoxEmbeddingRepository.getAllEmbeddingsPaginated(2000).collect { embeddingBatch -> + // 使用 Flow 处理批次 + flow { + embeddingBatch + // 过滤未处理的照片 + .filter { embedding -> + embedding.photoId !in processedPhotoIds + }.forEach { baseEmbedding -> + // 防止重复处理 + processedPhotoIds.add(baseEmbedding.photoId) + + // 查找相似图片 + val similarEmbeddings = + objectBoxEmbeddingRepository.findSimilarEmbeddings( + queryVector = baseEmbedding.data, + topK = 30, + similarityThreshold = 0.95f + ) + + // 获取照片并标记为已处理 + val photos = + photoRepository.getPhotoListByIds( + similarEmbeddings.map { it.get().photoId } + ) + + // 标记为已处理 + processedPhotoIds.addAll(similarEmbeddings.map { it.get().photoId }) + + // 只发出超过1张的相似组 + if (photos.size > 1) { + emit(photos) + } } + }.flowOn(Dispatchers.IO).onCompletion { + Timber.tag("SimilarPhotosViewModel") + .d("end findSimilarPhotos${System.currentTimeMillis()}") + }.collect { similarPhotos -> + // 去重 + val uniqueSimilarPhotos = similarPhotos.distinctBy { it.id } - if (photoGroup.isNotEmpty()) { - similarGroups.add(photoGroup) - _uiState.update { SimilarPhotosUiState.Success(similarGroups.toList()) } + // 仅添加唯一的相似组 + if (uniqueSimilarPhotos.size > 1) { + val existingGroup = similarGroups.find { + it.map { photo -> photo.id } + .intersect(uniqueSimilarPhotos.map { it.id }.toSet()) + .isNotEmpty() } - } - if (similarGroups.isEmpty()) { - Timber.tag("SimilarPhotosViewModel").d("No similar photos found") - _uiState.update { - SimilarPhotosUiState.Error( - type = ErrorType.NO_SIMILAR_PHOTOS, - message = "No similar photos could be found" - ) + if (existingGroup == null) { + similarGroups.add(uniqueSimilarPhotos) + _uiState.update { + SimilarPhotosUiState.Success(similarGroups.toList()) + } + } } } } @@ -147,4 +138,4 @@ class SimilarPhotosViewModel( // Reset UI state to Loading _uiState.update { SimilarPhotosUiState.Loading } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarityConfigBottomSheet.kt b/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarityConfigBottomSheet.kt index f5ba537..4992968 100644 --- a/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarityConfigBottomSheet.kt +++ b/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarityConfigBottomSheet.kt @@ -10,15 +10,15 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import me.grey.picquery.R import java.util.Locale +import me.grey.picquery.R @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -28,19 +28,16 @@ fun SimilarityConfigBottomSheet( onConfigUpdate: (Float, Float, Int) -> Unit ) { val similarityConfiguration = LocalSimilarityConfig.current - var searchThreshold by rememberSaveable { - mutableStateOf(similarityConfiguration.searchImageSimilarityThreshold) - } + var searchThreshold by rememberSaveable { mutableStateOf(similarityConfiguration.searchImageSimilarityThreshold) } var similarityGroupDelta by rememberSaveable { mutableStateOf(similarityConfiguration.similarityGroupDelta) } val minGroupSize by rememberSaveable { - mutableStateOf(initialMinGroupSize) + mutableStateOf(initialMinGroupSize) } - ModalBottomSheet(onDismissRequest = onDismiss) { Column(modifier = Modifier.padding(16.dp)) { Text( @@ -51,7 +48,7 @@ fun SimilarityConfigBottomSheet( Text( text = stringResource(R.string.search_image_similarity_threshold) + - ": ${"%.2f".format(Locale.getDefault(), searchThreshold)}", + ": ${"%.2f".format(Locale.getDefault(), searchThreshold)}", style = MaterialTheme.typography.bodyMedium ) Text( @@ -68,7 +65,7 @@ fun SimilarityConfigBottomSheet( Text( text = stringResource(R.string.similarity_group_delta) + - ": ${"%.2f".format(Locale.US, similarityGroupDelta)}", + ": ${"%.2f".format(Locale.US, similarityGroupDelta)}", style = MaterialTheme.typography.bodyMedium ) @@ -86,7 +83,7 @@ fun SimilarityConfigBottomSheet( Text( text = stringResource(R.string.min_group_size) + - ": $minGroupSize", + ": $minGroupSize", style = MaterialTheme.typography.bodyMedium ) @@ -107,4 +104,4 @@ fun SimilarityConfigBottomSheet( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarityConfiguration.kt b/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarityConfiguration.kt index e174c97..4567ef4 100644 --- a/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarityConfiguration.kt +++ b/app/src/main/java/me/grey/picquery/ui/simlilar/SimilarityConfiguration.kt @@ -9,4 +9,4 @@ data class SimilarityConfiguration( val similarityGroupDelta: Float = 0.02f, val minSimilarityGroupSize: Int = 2 ) -val LocalSimilarityConfig = compositionLocalOf { SimilarityConfiguration() } \ No newline at end of file +val LocalSimilarityConfig = compositionLocalOf { SimilarityConfiguration() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f46ec83..165601b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,6 +48,7 @@ Sure Don\'t send Share analytics data + Error occurred during search For improving your experience. Not containing any personal data Later… [External Storage] diff --git a/app/src/test/java/me/grey/picquery/ExampleUnitTest.kt b/app/src/test/java/me/grey/picquery/ExampleUnitTest.kt index 518a60a..a61791b 100644 --- a/app/src/test/java/me/grey/picquery/ExampleUnitTest.kt +++ b/app/src/test/java/me/grey/picquery/ExampleUnitTest.kt @@ -1,8 +1,7 @@ package me.grey.picquery -import org.junit.Test - import org.junit.Assert.* +import org.junit.Test /** * Example local unit test, which will execute on the development machine (host). @@ -14,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/app/src/test/java/me/grey/picquery/TestPhotoRepository.kt b/app/src/test/java/me/grey/picquery/TestPhotoRepository.kt index b625ae6..b8f3d38 100644 --- a/app/src/test/java/me/grey/picquery/TestPhotoRepository.kt +++ b/app/src/test/java/me/grey/picquery/TestPhotoRepository.kt @@ -1,17 +1,12 @@ package me.grey.picquery -import android.util.Log -import org.junit.Test - fun main() { println(calculateRemainingTime(120, 3087, 35)) } -fun calculateRemainingTime( - current: Int, total: Int, costPerItem: Long -): Long { +fun calculateRemainingTime(current: Int, total: Int, costPerItem: Long): Long { if (costPerItem.toInt() == 0) return 0L val remainItem = (total - current) val res = (remainItem * (costPerItem) / 1000) return res -} \ No newline at end of file +} diff --git a/build.gradle.kts b/build.gradle.kts index 331d6bd..4817a1d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,11 +8,13 @@ buildscript { } } mavenCentral() + maven { url = uri("https://plugins.gradle.org/m2/") } } dependencies { classpath(libs.google.oss.licenses.plugin) { exclude(group = "com.google.protobuf") } + classpath("io.objectbox:objectbox-gradle-plugin:${libs.versions.objectboxGradlePlugin.get()}") } } @@ -21,4 +23,29 @@ plugins { alias(libs.plugins.android.library).apply(false) alias(libs.plugins.kotlin.android).apply(false) alias(libs.plugins.ksp).apply(false) -} \ No newline at end of file + // 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 +} + +// Apply ktlint to all projects +subprojects { + apply(plugin = "org.jlleitschuh.gradle.ktlint") + + // Configure ktlint + configure { + debug.set(true) + android.set(true) + outputToConsole.set(true) + outputColorName.set("RED") + ignoreFailures.set(false) + enableExperimentalRules.set(true) + filter { + exclude("**/generated/**") + include("**/kotlin/**") + } + } +} + +// Remove the temporary .editorconfig approach since we now have a permanent file \ No newline at end of file diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..9df5d6c --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,770 @@ +build: + maxIssues: 0 + excludeCorrectable: false + weights: + # complexity: 2 + # LongParameterList: 1 + # style: 1 + # comments: 1 + +config: + validation: true + warningsAsErrors: false + checkExhaustiveness: false + # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' + excludes: '' + +processors: + active: true + exclude: + - 'DetektProgressListener' + # - 'KtFileCountProcessor' + # - 'PackageCountProcessor' + # - 'ClassCountProcessor' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ProjectComplexityProcessor' + # - 'ProjectCognitiveComplexityProcessor' + # - 'ProjectLLOCProcessor' + # - 'ProjectCLOCProcessor' + # - 'ProjectLOCProcessor' + # - 'ProjectSLOCProcessor' + # - 'LicenseHeaderLoaderExtension' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FindingsReport' + - 'FileBasedFindingsReport' + # - 'LiteFindingsReport' + +output-reports: + active: true + exclude: + # - 'TxtOutputReport' + # - 'XmlOutputReport' + # - 'HtmlOutputReport' + # - 'MdOutputReport' + # - 'SarifOutputReport' + +comments: + active: true + AbsentOrWrongFileLicense: + active: false + licenseTemplateFile: 'license.template' + licenseTemplateIsRegex: false + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + DeprecatedBlockTag: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + KDocReferencesNonPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + OutdatedDocumentation: + active: false + matchTypeParameters: true + matchDeclarationsOrder: true + allowParamOnConstructorProperties: false + UndocumentedPublicClass: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + searchInProtectedClass: false + UndocumentedPublicFunction: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedFunction: false + UndocumentedPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedProperty: false + +complexity: + active: true + CognitiveComplexMethod: + active: true + threshold: 20 + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + ignoreOverloaded: false + CyclomaticComplexMethod: + active: true + threshold: 20 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + nestingFunctions: + - 'also' + - 'apply' + - 'forEach' + - 'isNotNull' + - 'ifNull' + - 'let' + - 'run' + - 'use' + - 'with' + LabeledExpression: + active: false + ignoredLabels: [] + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 100 + LongParameterList: + active: true + functionThreshold: 8 + constructorThreshold: 9 + ignoreDefaultParameters: false + ignoreDataClasses: true + ignoreAnnotatedParameter: [] + MethodOverloading: + active: false + threshold: 6 + NamedArguments: + active: false + threshold: 3 + ignoreArgumentsMatchingNames: false + NestedBlockDepth: + active: true + threshold: 5 + NestedScopeFunctions: + active: false + threshold: 1 + functions: + - 'kotlin.apply' + - 'kotlin.run' + - 'kotlin.with' + - 'kotlin.let' + - 'kotlin.also' + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + thresholdInFiles: 20 + thresholdInClasses: 20 + thresholdInInterfaces: 15 + thresholdInObjects: 15 + thresholdInEnums: 10 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + +coroutines: + active: true + GlobalCoroutineUsage: + active: false + InjectDispatcher: + active: true + dispatcherNames: + - 'IO' + - 'Default' + - 'Unconfined' + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunSwallowedCancellation: + active: false + SuspendFunWithCoroutineScopeReceiver: + active: false + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: + - 'equals' + - 'finalize' + - 'hashCode' + - 'toString' + InstanceOfCheckForException: + active: false + NotImplementedDeclaration: + active: false + ObjectExtendsThrowable: + active: false + PrintStackTrace: + active: false + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + ignoreLabeled: false + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptions: + - 'ArrayIndexOutOfBoundsException' + - 'Exception' + - 'IllegalArgumentException' + - 'IllegalMonitorStateException' + - 'IllegalStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: false + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: false + allowedPattern: '^(is|has|are)' + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [] + FunctionMaxLength: + active: false + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + functionPattern: '([a-z][a-zA-Z0-9]*)|(`.*`)' + excludeClassPattern: '$^' + ignoreOverridden: true + ignoreAnnotated: ['Composable'] + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + InvalidPackageDeclaration: + active: true + rootPackage: '' + requireRootInDeclaration: false + LambdaParameterNaming: + active: false + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: false + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: false + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: false + TopLevelPropertyNaming: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: false + threshold: 3 + ForEachOnRange: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + SpreadOperator: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnnecessaryPartOfBinaryExpression: + active: false + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + AvoidReferentialEquality: + active: true + forbiddenTypePatterns: + - 'kotlin.String' + CastNullableToNonNullableType: + active: false + CastToNullableType: + active: false + Deprecation: + active: false + DontDowncastCollectionTypes: + active: false + DoubleMutabilityForCollection: + active: true + mutableTypes: + - 'kotlin.collections.MutableList' + - 'kotlin.collections.MutableMap' + - 'kotlin.collections.MutableSet' + - 'java.util.ArrayList' + - 'java.util.LinkedHashSet' + - 'java.util.HashSet' + - 'java.util.LinkedHashMap' + - 'java.util.HashMap' + ElseCaseInsteadOfExhaustiveWhen: + active: false + ignoredSubjectTypes: [] + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: false + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + restrictToConfig: true + returnValueAnnotations: + - 'CheckResult' + - '*.CheckResult' + - 'CheckReturnValue' + - '*.CheckReturnValue' + ignoreReturnValueAnnotations: + - 'CanIgnoreReturnValue' + - '*.CanIgnoreReturnValue' + returnValueTypes: + - 'kotlin.sequences.Sequence' + - 'kotlinx.coroutines.flow.*Flow' + - 'java.util.stream.*Stream' + ignoreFunctionCall: [] + ImplicitDefaultLocale: + active: false + ImplicitUnitReturnType: + active: false + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: false + excludes: ['**/*.kts'] + NullCheckOnMutableProperty: + active: false + NullableToStringCall: + active: false + PropertyUsedBeforeDeclaration: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnnecessaryNotNullCheck: + active: false + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnsafeCast: + active: true + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + AlsoCouldBeApply: + active: false + BracesOnIfStatements: + active: false + singleLine: 'never' + multiLine: 'always' + BracesOnWhenStatements: + active: false + singleLine: 'necessary' + multiLine: 'consistent' + CanBeNonNullable: + active: false + CascadingCallWrapping: + active: false + includeElvis: true + ClassOrdering: + active: false + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: + - 'to' + DataClassShouldBeImmutable: + active: false + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 3 + DoubleNegativeLambda: + active: false + negativeFunctions: + - reason: 'Use `takeIf` instead.' + value: 'takeUnless' + - reason: 'Use `all` instead.' + value: 'none' + negativeFunctionNameParts: + - 'not' + - 'non' + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: true + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenAnnotation: + active: false + annotations: + - reason: 'it is a java annotation. Use `Suppress` instead.' + value: 'java.lang.SuppressWarnings' + - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' + value: 'java.lang.Deprecated' + - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' + value: 'java.lang.annotation.Documented' + - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' + value: 'java.lang.annotation.Target' + - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' + value: 'java.lang.annotation.Retention' + - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' + value: 'java.lang.annotation.Repeatable' + - reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265' + value: 'java.lang.annotation.Inherited' + ForbiddenComment: + active: false + comments: + - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' + value: 'FIXME:' + - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' + value: 'STOPSHIP:' + - reason: 'Forbidden TODO todo marker in comment, please do the changes.' + value: 'TODO:' + allowedPatterns: '' + ForbiddenImport: + active: false + imports: [] + forbiddenPatterns: '' + ForbiddenMethodCall: + active: false + methods: + - reason: 'print does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.print' + - reason: 'println does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.println' + ForbiddenSuppress: + active: false + rules: [] + ForbiddenVoid: + active: true + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: [] + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts'] + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: true + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + MandatoryBracesLoops: + active: false + MaxChainedCallsOnSameLine: + active: false + maxChainedCalls: 5 + MaxLineLength: + active: false + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + excludeRawStrings: true + MayBeConst: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: false + MultilineRawStringIndentation: + active: false + indentSize: 4 + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + NullableBooleanCheck: + active: false + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + OptionalWhenBraces: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: false + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 3 + excludedFunctions: + - 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingBetweenPackageAndImports: + active: false + StringShouldBeRawString: + active: false + maxEscapedCharacterCount: 2 + ignoredCharacters: [] + ThrowsCount: + active: true + max: 2 + excludeGuardClauses: false + TrailingWhitespace: + active: false + TrimMultilineRawString: + active: false + UnderscoresInNumericLiterals: + active: false + acceptableLength: 4 + allowNonStandardGrouping: false + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: false + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: false + UnnecessaryBracesAroundTrailingLambda: + active: false + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + allowForUnclearPrecedence: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: false + UnusedParameter: + active: true + allowedNames: 'ignored|expected' + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: false + allowedNames: '' + ignoreAnnotated: ['Preview'] + UnusedPrivateProperty: + active: false + allowedNames: '_|ignored|expected|serialVersionUID' + UseAnyOrNoneInsteadOfFind: + active: true + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: false + allowVars: false + UseEmptyCounterpart: + active: false + UseIfEmptyOrIfBlank: + active: false + UseIfInsteadOfWhen: + active: false + UseIsNullOrEmpty: + active: true + UseLet: + active: false + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UseSumOfInsteadOfFlatMapSize: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + ignoreLateinitVar: false + WildcardImport: + active: false + excludeImports: + - 'java.util.*' diff --git a/gradle.properties b/gradle.properties index dfac7f3..f71f4ef 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,6 +22,7 @@ kotlin.code.style=official # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true android.enableJetifier=true -org.gradle.unsafe.configuration-cache=true +org.gradle.unsafe.configuration-cache=false android.defaults.buildfeatures.buildconfig=true -android.nonFinalResIds=false \ No newline at end of file +android.nonFinalResIds=false +android.suppressUnsupportedCompileSdk=36 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 25475fd..017ddb5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] # Android and Kotlin -android-gradle-plugin = "8.5.2" +android-gradle-plugin = "8.4.2" kotlin = "1.9.22" ksp = "1.9.22-1.0.17" kotlin-serialization = "1.9.22" @@ -31,6 +31,8 @@ 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" # Navigation @@ -46,6 +48,7 @@ androidx-test-ext = "1.1.3" androidx-datastore = "1.0.0" androidx-work = "2.9.0" androidxDataStore = "1.1.1" +objectboxGradlePlugin = "4.3.0" # Logging timber = "5.0.1" @@ -58,7 +61,7 @@ glide-compose = "1.0.0-alpha.1" workRuntime = "2.9.0" zoomable = "1.5.0" permissionx = "1.7.1" -kotlinx-serialization = "1.7.2" +kotlinx-serialization = "1.6.3" # AI & ML onnx = "1.16.1" @@ -121,6 +124,8 @@ litert = { module = "com.google.ai.edge.litert:litert", version.ref = "litert" } litert-gpu = { module = "com.google.ai.edge.litert:litert-gpu", version.ref = "litert-gpu" } litert-gpu-api = { module = "com.google.ai.edge.litert:litert-gpu-api", version.ref = "litert-gpu-api" } litert-support = { module = "com.google.ai.edge.litert:litert-support", version.ref = "litert-support" } +objectbox-android = { module = "io.objectbox:objectbox-android", version.ref = "objectboxAndroid" } +objectbox-android-objectbrowser = { module = "io.objectbox:objectbox-android-objectbrowser", version.ref = "objectboxAndroidObjectbrowser" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } @@ -156,3 +161,5 @@ android-library = { id = "com.android.library", version.ref = "android-gradle-pl kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-serialization" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } + +#objectbox = { module = "io.objectbox:objectbox-gradle-plugin", version.ref = "objectboxGradlePlugin" } diff --git a/scripts/install-hooks.bat b/scripts/install-hooks.bat new file mode 100644 index 0000000..fb90c89 --- /dev/null +++ b/scripts/install-hooks.bat @@ -0,0 +1,8 @@ +@echo off +echo Installing Git hooks... + +:: Create pre-commit hook that calls our PowerShell script +echo @echo off > "%~dp0..\.git\hooks\pre-commit" +echo powershell.exe -ExecutionPolicy Bypass -NoProfile -File "%~dp0pre-commit.ps1" >> "%~dp0..\.git\hooks\pre-commit" + +echo Git hooks installed successfully! diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100644 index 0000000..1f15093 --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,22 @@ +#!/bin/sh + +echo "Running ktlint check..." +./gradlew ktlintCheck --daemon + +RESULT=$? +if [ $RESULT -ne 0 ]; then + echo "ktlint check failed, please fix the above issues before committing" + exit 1 +fi + +echo "Running detekt..." +./gradlew detekt --daemon + +RESULT=$? +if [ $RESULT -ne 0 ]; then + echo "detekt check failed, please fix the above issues before committing" + exit 1 +fi + +echo "All checks passed!" +exit 0 diff --git a/scripts/pre-commit.ps1 b/scripts/pre-commit.ps1 new file mode 100644 index 0000000..69daf8e --- /dev/null +++ b/scripts/pre-commit.ps1 @@ -0,0 +1,21 @@ +#!/usr/bin/env pwsh +# PowerShell pre-commit hook for Git + +Write-Host "Running ktlint check..." -ForegroundColor Cyan +& ./gradlew.bat ktlintCheck --daemon + +if ($LASTEXITCODE -ne 0) { + Write-Host "ktlint check failed, please fix the above issues before committing" -ForegroundColor Red + exit 1 +} + +Write-Host "Running detekt..." -ForegroundColor Cyan +& ./gradlew.bat detekt --daemon + +if ($LASTEXITCODE -ne 0) { + Write-Host "detekt check failed, please fix the above issues before committing" -ForegroundColor Red + exit 1 +} + +Write-Host "All checks passed!" -ForegroundColor Green +exit 0