diff --git a/build.gradle b/build.gradle index 552fdc34..74841978 100644 --- a/build.gradle +++ b/build.gradle @@ -4,6 +4,7 @@ buildscript { ext.nav_version = "2.6.0" // require SDK 34 from 2.7.0 repositories { google() + gradlePluginPortal() mavenCentral() maven { url "https://jitpack.io" @@ -18,15 +19,22 @@ buildscript { // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files classpath 'com.google.gms:google-services:4.4.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20" classpath 'com.google.firebase:perf-plugin:1.4.2' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9' // Refactor classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version" + + // Detekt + classpath "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.23.3" } } +plugins { + id 'com.google.dagger.hilt.android' version '2.48' apply false +} + allprojects { repositories { google() diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 00000000..63c721c0 --- /dev/null +++ b/detekt.yml @@ -0,0 +1,85 @@ +# From https://mrmans0n.github.io/compose-rules/detekt/ +Compose: + ComposableAnnotationNaming: + active: true + ComposableNaming: + active: true + # -- You can optionally disable the checks in this rule for regex matches against the composable name (e.g. molecule presenters) + # allowedComposableFunctionNames: .*Presenter,.*MoleculePresenter + ComposableParamOrder: + active: true + # -- You can optionally have a list of types to be treated as lambdas (e.g. typedefs or fun interfaces not picked up automatically) + # treatAsLambda: MyLambdaType + CompositionLocalAllowlist: + active: true + # -- You can optionally define a list of CompositionLocals that are allowed here + # allowedCompositionLocals: LocalSomething,LocalSomethingElse + CompositionLocalNaming: + active: true + ContentEmitterReturningValues: + active: true + # -- You can optionally add your own composables here + # contentEmitters: MyComposable,MyOtherComposable + DefaultsVisibility: + active: true + ModifierClickableOrder: + active: true + # -- You can optionally add your own Modifier types + # customModifiers: BananaModifier,PotatoModifier + ModifierComposable: + active: true + # -- You can optionally add your own Modifier types + # customModifiers: BananaModifier,PotatoModifier + ModifierMissing: + active: true + # -- You can optionally control the visibility of which composables to check for here + # -- Possible values are: `only_public`, `public_and_internal` and `all` (default is `only_public`) + # checkModifiersForVisibility: only_public + # -- You can optionally add your own Modifier types + # customModifiers: BananaModifier,PotatoModifier + ModifierNaming: + active: true + # -- You can optionally add your own Modifier types + # customModifiers: BananaModifier,PotatoModifier + ModifierNotUsedAtRoot: + active: true + # -- You can optionally add your own composables here + # contentEmitters: MyComposable,MyOtherComposable + # -- You can optionally add your own Modifier types + # customModifiers: BananaModifier,PotatoModifier + ModifierReused: + active: true + # -- You can optionally add your own Modifier types + # customModifiers: BananaModifier,PotatoModifier + ModifierWithoutDefault: + active: true + MultipleEmitters: + active: true + # -- You can optionally add your own composables here that will count as content emitters + # contentEmitters: MyComposable,MyOtherComposable + # -- You can add composables here that you don't want to count as content emitters (e.g. custom dialogs or modals) + # contentEmittersDenylist: MyNonEmitterComposable + MutableParams: + active: true + MutableStateParam: + active: true + PreviewAnnotationNaming: + active: true + PreviewPublic: + active: true + RememberMissing: + active: true + RememberContentMissing: + active: true + UnstableCollections: + active: true + ViewModelForwarding: + active: true + # -- You can optionally use this rule on things other than types ending in "ViewModel" or "Presenter" (which are the defaults). You can add your own via a regex here: + # allowedStateHolderNames: .*ViewModel,.*Presenter + # -- You can optionally add an allowlist for Composable names that won't be affected by this rule + # allowedForwarding: .*Content,.*FancyStuff + ViewModelInjection: + active: true + # -- You can optionally add your own ViewModel factories here + # viewModelFactories: hiltViewModel,potatoViewModel diff --git a/main/build.gradle b/main/build.gradle index a757aef0..38369b70 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -6,6 +6,8 @@ plugins { id 'com.google.gms.google-services' id 'com.google.firebase.crashlytics' id 'com.google.firebase.firebase-perf' + id 'com.google.dagger.hilt.android' + id "io.gitlab.arturbosch.detekt" } def buildParams = getGradle().getStartParameter().toString().toLowerCase() @@ -91,7 +93,11 @@ android { jvmTarget = '17' } buildFeatures { - viewBinding true + viewBinding true //TODO remove after full migrating to compose + compose true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.4" } packagingOptions { jniLibs { @@ -100,7 +106,13 @@ android { } } +kapt { + correctErrorTypes true +} + dependencies { + detektPlugins "io.nlopez.compose.rules:detekt:0.3.9" + implementation fileTree(include: ['*.jar', '*.aar'], dir: 'libs') implementation 'androidx.core:core-ktx:1.10.1' @@ -110,6 +122,7 @@ dependencies { implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.2' implementation "androidx.preference:preference-ktx:1.2.1" implementation 'androidx.webkit:webkit:1.7.0' //noinspection GradleDynamicVersion @@ -138,6 +151,23 @@ dependencies { // required to avoid crash on Android 12 API 31 implementation 'androidx.work:work-runtime-ktx:2.8.1' + // Compose + def composeBom = platform('androidx.compose:compose-bom:2023.06.01') + implementation composeBom + androidTestImplementation composeBom + // Material Design 3 + implementation 'androidx.compose.material3:material3' + // Android Studio Preview support + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + // UI Tests + androidTestImplementation 'androidx.compose.ui:ui-test-junit4' + debugImplementation 'androidx.compose.ui:ui-test-manifest' + + // Hilt + implementation "com.google.dagger:hilt-android:2.48" + kapt "com.google.dagger:hilt-compiler:2.48" + // Firebase implementation platform('com.google.firebase:firebase-bom:32.6.0') implementation 'com.google.firebase:firebase-crashlytics' diff --git a/main/src/main/java/tw/firemaples/onscreenocr/CoreApplication.kt b/main/src/main/java/tw/firemaples/onscreenocr/CoreApplication.kt index 58247809..8a06ea38 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/CoreApplication.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/CoreApplication.kt @@ -1,11 +1,13 @@ package tw.firemaples.onscreenocr import android.app.Application +import dagger.hilt.android.HiltAndroidApp import tw.firemaples.onscreenocr.log.FirebaseEvent import tw.firemaples.onscreenocr.log.UserInfoUtils import tw.firemaples.onscreenocr.remoteconfig.RemoteConfigManager import tw.firemaples.onscreenocr.utils.AdManager +@HiltAndroidApp class CoreApplication : Application() { companion object { lateinit var instance: Application diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/repo/PreferenceRepository.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/PreferenceRepository.kt new file mode 100644 index 00000000..b397f3b0 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/PreferenceRepository.kt @@ -0,0 +1,30 @@ +package tw.firemaples.onscreenocr.data.repo + +import android.graphics.Point +import androidx.lifecycle.asFlow +import com.chibatching.kotpref.livedata.asLiveData +import tw.firemaples.onscreenocr.pref.AppPref +import javax.inject.Inject + +class PreferenceRepository @Inject constructor() { + fun saveLastMainBarPosition(x: Int, y: Int) { + AppPref.lastMainBarPosition = Point(x, y) + } + + fun getLastMainBarPosition(): Point = + AppPref.lastMainBarPosition + + fun getShowTextSelectionOnResultView() = + AppPref.asLiveData(AppPref::displaySelectedTextOnResultWindow).asFlow() + + fun setShowTextSelectionOnResultView(show: Boolean) { + AppPref.displaySelectedTextOnResultWindow = show + } + + fun getResultViewFontSize() = + AppPref.asLiveData(AppPref::resultWindowFontSize).asFlow() + + fun setResultViewFontSize(fontSize: Float) { + AppPref.resultWindowFontSize = fontSize + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/repo/RecognitionRepository.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/RecognitionRepository.kt new file mode 100644 index 00000000..dd859e7b --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/RecognitionRepository.kt @@ -0,0 +1,22 @@ +package tw.firemaples.onscreenocr.data.repo + +import androidx.lifecycle.asFlow +import com.chibatching.kotpref.livedata.asLiveData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import tw.firemaples.onscreenocr.pref.AppPref +import tw.firemaples.onscreenocr.recognition.TextRecognitionProviderType +import tw.firemaples.onscreenocr.utils.Constants +import javax.inject.Inject + +class RecognitionRepository @Inject constructor() { + val ocrLanguage: Flow + get() = AppPref.asLiveData(AppPref::selectedOCRLang).asFlow() + + val ocrProvider: Flow + get() = AppPref.asLiveData(AppPref::selectedOCRProviderKey).asFlow() + .map { key -> + TextRecognitionProviderType.entries.firstOrNull { it.key == key } + ?: Constants.DEFAULT_OCR_PROVIDER + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/repo/SettingRepository.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/SettingRepository.kt new file mode 100644 index 00000000..48e3e093 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/SettingRepository.kt @@ -0,0 +1,12 @@ +package tw.firemaples.onscreenocr.data.repo + +import tw.firemaples.onscreenocr.pages.setting.SettingManager +import javax.inject.Inject + +class SettingRepository @Inject constructor() { + fun shouldRestoreMainBarPosition(): Boolean = + SettingManager.restoreMainBarPosition + + fun hideOCRAreaAfterTranslated(): Boolean = + SettingManager.hideRecognizedResultAfterTranslated +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/repo/TranslatorRepository.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/TranslatorRepository.kt new file mode 100644 index 00000000..d47d623a --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/TranslatorRepository.kt @@ -0,0 +1,17 @@ +package tw.firemaples.onscreenocr.data.repo + +import androidx.lifecycle.asFlow +import com.chibatching.kotpref.livedata.asLiveData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import tw.firemaples.onscreenocr.pref.AppPref +import tw.firemaples.onscreenocr.translator.TranslationProviderType +import javax.inject.Inject + +class TranslatorRepository @Inject constructor() { + val currentProviderType: Flow + get() = AppPref.asLiveData(AppPref::selectedTranslationProvider).asFlow() + .map { TranslationProviderType.fromKey(it) } + val currentTranslationLang: Flow + get() = AppPref.asLiveData(AppPref::selectedTranslationLang).asFlow() +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentOCRDisplayLangCodeUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentOCRDisplayLangCodeUseCase.kt new file mode 100644 index 00000000..32e8cadc --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentOCRDisplayLangCodeUseCase.kt @@ -0,0 +1,16 @@ +package tw.firemaples.onscreenocr.data.usecase + +import kotlinx.coroutines.flow.combine +import tw.firemaples.onscreenocr.data.repo.RecognitionRepository +import tw.firemaples.onscreenocr.recognition.TextRecognizer +import javax.inject.Inject + +class GetCurrentOCRDisplayLangCodeUseCase @Inject constructor( + private val recognitionRepository: RecognitionRepository, +) { + operator fun invoke() = + recognitionRepository.ocrProvider + .combine(recognitionRepository.ocrLanguage) { provider, lang -> + TextRecognizer.getRecognizer(provider).parseToDisplayLangCode(lang) + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentOCRLangUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentOCRLangUseCase.kt new file mode 100644 index 00000000..a35723b8 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentOCRLangUseCase.kt @@ -0,0 +1,15 @@ +package tw.firemaples.onscreenocr.data.usecase + +import kotlinx.coroutines.flow.combine +import tw.firemaples.onscreenocr.data.repo.RecognitionRepository +import javax.inject.Inject + +class GetCurrentOCRLangUseCase @Inject constructor( + private val recognitionRepository: RecognitionRepository, +) { + operator fun invoke() = + combine( + recognitionRepository.ocrProvider, + recognitionRepository.ocrLanguage, + ) { provider, lang -> provider to lang } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentTranslationLangUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentTranslationLangUseCase.kt new file mode 100644 index 00000000..84137687 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentTranslationLangUseCase.kt @@ -0,0 +1,10 @@ +package tw.firemaples.onscreenocr.data.usecase + +import tw.firemaples.onscreenocr.data.repo.TranslatorRepository +import javax.inject.Inject + +class GetCurrentTranslationLangUseCase @Inject constructor( + private val translatorRepository: TranslatorRepository, +) { + operator fun invoke() = translatorRepository.currentTranslationLang +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentTranslatorTypeUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentTranslatorTypeUseCase.kt new file mode 100644 index 00000000..3bfe5d09 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentTranslatorTypeUseCase.kt @@ -0,0 +1,10 @@ +package tw.firemaples.onscreenocr.data.usecase + +import tw.firemaples.onscreenocr.data.repo.TranslatorRepository +import javax.inject.Inject + +class GetCurrentTranslatorTypeUseCase @Inject constructor( + private val translatorRepository: TranslatorRepository, +) { + operator fun invoke() = translatorRepository.currentProviderType +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetHidingOCRAreaAfterTranslatedUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetHidingOCRAreaAfterTranslatedUseCase.kt new file mode 100644 index 00000000..46aca954 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetHidingOCRAreaAfterTranslatedUseCase.kt @@ -0,0 +1,10 @@ +package tw.firemaples.onscreenocr.data.usecase + +import tw.firemaples.onscreenocr.data.repo.SettingRepository +import javax.inject.Inject + +class GetHidingOCRAreaAfterTranslatedUseCase @Inject constructor( + private val settingRepository: SettingRepository, +) { + operator fun invoke() = settingRepository.hideOCRAreaAfterTranslated() +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetMainBarInitialPositionUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetMainBarInitialPositionUseCase.kt new file mode 100644 index 00000000..4ce09432 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetMainBarInitialPositionUseCase.kt @@ -0,0 +1,16 @@ +package tw.firemaples.onscreenocr.data.usecase + +import android.graphics.Point +import tw.firemaples.onscreenocr.data.repo.PreferenceRepository +import tw.firemaples.onscreenocr.data.repo.SettingRepository +import javax.inject.Inject + +class GetMainBarInitialPositionUseCase @Inject constructor( + private val settingRepository: SettingRepository, + private val preferenceRepository: PreferenceRepository, +) { + operator fun invoke() = + if (settingRepository.shouldRestoreMainBarPosition()) + preferenceRepository.getLastMainBarPosition() + else Point(0, 0) +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetResultViewFontSizeUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetResultViewFontSizeUseCase.kt new file mode 100644 index 00000000..783635d6 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetResultViewFontSizeUseCase.kt @@ -0,0 +1,10 @@ +package tw.firemaples.onscreenocr.data.usecase + +import tw.firemaples.onscreenocr.data.repo.PreferenceRepository +import javax.inject.Inject + +class GetResultViewFontSizeUseCase @Inject constructor( + private val preferenceRepository: PreferenceRepository, +) { + operator fun invoke() = preferenceRepository.getResultViewFontSize() +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetShowTextSelectorOnResultViewUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetShowTextSelectorOnResultViewUseCase.kt new file mode 100644 index 00000000..db215d9d --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetShowTextSelectorOnResultViewUseCase.kt @@ -0,0 +1,10 @@ +package tw.firemaples.onscreenocr.data.usecase + +import tw.firemaples.onscreenocr.data.repo.PreferenceRepository +import javax.inject.Inject + +class GetShowTextSelectorOnResultViewUseCase @Inject constructor( + private val preferenceRepository: PreferenceRepository, +) { + operator fun invoke() = preferenceRepository.getShowTextSelectionOnResultView() +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SaveLastMainBarPositionUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SaveLastMainBarPositionUseCase.kt new file mode 100644 index 00000000..c4ce9282 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SaveLastMainBarPositionUseCase.kt @@ -0,0 +1,11 @@ +package tw.firemaples.onscreenocr.data.usecase + +import tw.firemaples.onscreenocr.data.repo.PreferenceRepository +import javax.inject.Inject + +class SaveLastMainBarPositionUseCase @Inject constructor( + private val preferenceRepository: PreferenceRepository, +) { + operator fun invoke(x: Int, y: Int) = + preferenceRepository.saveLastMainBarPosition(x = x, y = y) +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SetShowTextSelectorOnResultViewUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SetShowTextSelectorOnResultViewUseCase.kt new file mode 100644 index 00000000..fb6c2bb1 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SetShowTextSelectorOnResultViewUseCase.kt @@ -0,0 +1,12 @@ +package tw.firemaples.onscreenocr.data.usecase + +import tw.firemaples.onscreenocr.data.repo.PreferenceRepository +import javax.inject.Inject + +class SetShowTextSelectorOnResultViewUseCase @Inject constructor( + private val preferenceRepository: PreferenceRepository, +) { + operator fun invoke(show: Boolean) { + preferenceRepository.setShowTextSelectionOnResultView(show = show) + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/di/CoroutinesDispatchersModule.kt b/main/src/main/java/tw/firemaples/onscreenocr/di/CoroutinesDispatchersModule.kt new file mode 100644 index 00000000..c22fccae --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/di/CoroutinesDispatchersModule.kt @@ -0,0 +1,52 @@ +package tw.firemaples.onscreenocr.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object CoroutinesDispatchersModule { + @DefaultDispatcher + @Provides + fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + @IoDispatcher + @Provides + fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @MainDispatcher + @Provides + fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main + + @MainImmediateDispatcher + @Provides + fun providesMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate + + @Singleton + @MainImmediateCoroutineScope + @Provides + fun provideMainImmediateCoroutineScope( + @MainImmediateDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Singleton + @MainCoroutineScope + @Provides + fun provideMainCoroutineScope( + @MainDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Singleton + @DefaultCoroutineScope + @Provides + fun provideDefaultCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/di/CoroutinesQualifiers.kt b/main/src/main/java/tw/firemaples/onscreenocr/di/CoroutinesQualifiers.kt new file mode 100644 index 00000000..6fb4929b --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/di/CoroutinesQualifiers.kt @@ -0,0 +1,31 @@ +package tw.firemaples.onscreenocr.di + +import javax.inject.Qualifier + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class DefaultDispatcher + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class IoDispatcher + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class MainDispatcher + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class MainImmediateDispatcher + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class MainImmediateCoroutineScope + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class MainCoroutineScope + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class DefaultCoroutineScope diff --git a/main/src/main/java/tw/firemaples/onscreenocr/di/SingletonModule.kt b/main/src/main/java/tw/firemaples/onscreenocr/di/SingletonModule.kt new file mode 100644 index 00000000..c23ed573 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/di/SingletonModule.kt @@ -0,0 +1,21 @@ +package tw.firemaples.onscreenocr.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import tw.firemaples.onscreenocr.floatings.manager.StateNavigator +import tw.firemaples.onscreenocr.floatings.manager.StateNavigatorImpl +import tw.firemaples.onscreenocr.floatings.manager.StateOperator +import tw.firemaples.onscreenocr.floatings.manager.StateOperatorImpl + +@Module +@InstallIn(SingletonComponent::class) +interface SingletonModule { + + @Binds + fun bindStateNavigator(stateNavigatorImpl: StateNavigatorImpl): StateNavigator + + @Binds + fun bindStateOperator(stateOperatorImpl: StateOperatorImpl): StateOperator +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/ViewHolderService.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/ViewHolderService.kt index f749cb69..2bc2b4d9 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/floatings/ViewHolderService.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/ViewHolderService.kt @@ -1,6 +1,10 @@ package tw.firemaples.onscreenocr.floatings -import android.app.* +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo @@ -9,19 +13,22 @@ import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import tw.firemaples.onscreenocr.R -import tw.firemaples.onscreenocr.floatings.manager.FloatingStateManager +import tw.firemaples.onscreenocr.floatings.manager.FloatingViewCoordinator import tw.firemaples.onscreenocr.pages.launch.LaunchActivity import tw.firemaples.onscreenocr.pages.setting.SettingManager import tw.firemaples.onscreenocr.remoteconfig.RemoteConfigManager import tw.firemaples.onscreenocr.screenshot.ScreenExtractor import tw.firemaples.onscreenocr.utils.Logger import tw.firemaples.onscreenocr.utils.SamsungSpenInsertedReceiver +import javax.inject.Inject +@AndroidEntryPoint class ViewHolderService : Service() { companion object { private const val NOTIFICATION_CHANNEL_ID = "floating_view_notification_channel_v1" @@ -59,10 +66,13 @@ class ViewHolderService : Service() { private var floatingStateListenerJob: Job? = null + @Inject + lateinit var floatingViewCoordinator: FloatingViewCoordinator + override fun onCreate() { super.onCreate() floatingStateListenerJob = CoroutineScope(Dispatchers.Main).launch { - FloatingStateManager.showingStateChangedFlow.collect { startForeground() } + floatingViewCoordinator.showingStateChangedFlow.collect { startForeground() } } if (SettingManager.exitAppWhileSPenInserted) { SamsungSpenInsertedReceiver.start() @@ -101,14 +111,14 @@ class ViewHolderService : Service() { private fun showViews() { if (ScreenExtractor.isGranted) { - FloatingStateManager.showMainBar() + floatingViewCoordinator.showMainBar() } else { startActivity(LaunchActivity.getLaunchIntent(this)) } } private fun hideViews() { - FloatingStateManager.detachAllViews() + floatingViewCoordinator.detachAllViews() } private fun exit() { @@ -127,13 +137,13 @@ class ViewHolderService : Service() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( ONGOING_NOTIFICATION_ID, - createNotification(!FloatingStateManager.isMainBarAttached), + createNotification(!floatingViewCoordinator.isMainBarAttached), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION, ) } else { startForeground( ONGOING_NOTIFICATION_ID, - createNotification(!FloatingStateManager.isMainBarAttached), + createNotification(!floatingViewCoordinator.isMainBarAttached), ) } } diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/AppTheme.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/AppTheme.kt new file mode 100644 index 00000000..7c386f42 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/AppTheme.kt @@ -0,0 +1,28 @@ +package tw.firemaples.onscreenocr.floatings.compose.base + + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.sp + +lateinit var AppColorScheme: ColorScheme + +@Composable +fun AppTheme( + content: @Composable () -> Unit, +) { + AppColorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + + MaterialTheme( + colorScheme = AppColorScheme, + content = content, + ) +} + +object FontSize { + val Small = 14.sp +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/BackButtonTrackerView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/BackButtonTrackerView.kt new file mode 100644 index 00000000..32a62315 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/BackButtonTrackerView.kt @@ -0,0 +1,44 @@ +package tw.firemaples.onscreenocr.floatings.compose.base + +import android.content.Context +import android.util.AttributeSet +import android.view.KeyEvent +import android.widget.FrameLayout + +open class BackButtonTrackerView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, + var onAttachedToWindow: (() -> Unit)? = null, + var onDetachedFromWindow: (() -> Unit)? = null, + var onBackButtonPressed: (() -> Boolean)? = null, +) : FrameLayout(context, attrs, defStyleAttr) { + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + onAttachedToWindow?.invoke() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + onDetachedFromWindow?.invoke() + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_BACK && keyDispatcherState != null) { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + keyDispatcherState.startTracking(event, this) + + return true + } else if (event.action == KeyEvent.ACTION_UP) { + keyDispatcherState.handleUpEvent(event) + + if (event.isTracking && !event.isCanceled) { + if (onBackButtonPressed?.invoke() == true) { + return true + } + } + } + } + + return super.dispatchKeyEvent(event) + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeFloatingView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeFloatingView.kt new file mode 100644 index 00000000..14f97702 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeFloatingView.kt @@ -0,0 +1,365 @@ +package tw.firemaples.onscreenocr.floatings.compose.base + +import android.content.Context +import android.graphics.PixelFormat +import android.graphics.Point +import android.os.Build +import android.os.Bundle +import android.os.Looper +import android.view.Gravity +import android.view.OrientationEventListener +import android.view.WindowManager +import androidx.annotation.CallSuper +import androidx.annotation.MainThread +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelChildren +import tw.firemaples.onscreenocr.utils.Logger +import tw.firemaples.onscreenocr.utils.PermissionUtil +import tw.firemaples.onscreenocr.utils.UIUtils +import tw.firemaples.onscreenocr.wigets.HomeButtonWatcher +import java.io.Closeable +import kotlin.coroutines.CoroutineContext + +abstract class ComposeFloatingView(protected val context: Context) { + + companion object { + private val attachedFloatingViews: MutableList = mutableListOf() + + fun detachAllFloatingViews() { + attachedFloatingViews.toList().forEach { it.detachFromScreen() } + } + } + + private val logger: Logger by lazy { Logger(this::class) } + + private val windowManager: WindowManager by lazy { context.getSystemService(Context.WINDOW_SERVICE) as WindowManager } + + open val initialPosition: Point = Point(0, 0) + open val layoutWidth: Int = WindowManager.LayoutParams.WRAP_CONTENT + open val layoutHeight: Int = WindowManager.LayoutParams.WRAP_CONTENT + open val layoutFocusable: Boolean = false + open val layoutCanMoveOutsideScreen: Boolean = false + open val fullscreenMode: Boolean = false + open val layoutGravity: Int = Gravity.TOP or Gravity.LEFT + open val enableHomeButtonWatcher: Boolean = false + + protected val params: WindowManager.LayoutParams by lazy { + val type = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + else WindowManager.LayoutParams.TYPE_PHONE + + var flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + if (!layoutFocusable) + flags = flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + if (layoutCanMoveOutsideScreen) + flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + if (fullscreenMode) + flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + + WindowManager.LayoutParams(layoutWidth, layoutHeight, type, flags, PixelFormat.TRANSLUCENT) + .apply { + val initPoint = initialPosition + x = initPoint.x.fixXPosition() + y = initPoint.y.fixYPosition() + gravity = layoutGravity + } + } + + private val homeButtonWatcher: HomeButtonWatcher by lazy { + HomeButtonWatcher( + context = context, + onHomeButtonPressed = { + logger.debug("onHomeButtonPressed()") + onHomeButtonPressed() + }, + onHomeButtonLongPressed = { + logger.debug("onHomeButtonLongPressed()") + onHomeButtonLongPressed() + }, + ) + } + + private val viewModelStore = ViewModelStore() + private val viewModelStoreOwner = object : ViewModelStoreOwner { + override val viewModelStore: ViewModelStore + get() = this@ComposeFloatingView.viewModelStore + } + + @Composable + abstract fun RootContent() + + // abstract val layoutId: Int +// protected lateinit var rootLayout: View +// protected val rootView: BackButtonTrackerView by lazy { +// BackButtonTrackerView( +// context = context, +// onAttachedToWindow = { onAttachedToScreen() }, +// onDetachedFromWindow = { onDetachedFromScreen() }, +// onBackButtonPressed = { onBackButtonPressed() }, +// ).apply { +// rootLayout = ComposeView(context).apply { +// setContent { +// RootContent() +// } +// +// setViewTreeLifecycleOwner(lifecycleOwner) +// setViewTreeSavedStateRegistryOwner(lifecycleOwner) +// +// setViewTreeViewModelStoreOwner(viewModelStoreOwner) +// } +//// rootLayout = context.getThemedLayoutInflater().inflate(layoutId, null) +// addView( +// rootLayout, +// ViewGroup.LayoutParams( +// ViewGroup.LayoutParams.MATCH_PARENT, +// ViewGroup.LayoutParams.MATCH_PARENT +// ) +// ) +// } +// } + protected val rootView by lazy { + ComposeView(context).apply { + setOnKeyListener { v, keyCode, event -> //TODO check or remove + logger.debug("setOnKeyListener, keyCode: $keyCode, event: $event") + false + } + setContent { + AppTheme { + Box( + modifier = Modifier + .onKeyEvent { event -> //TODO check or remove + logger.debug("onKeyEvent: $event") + false + } + .onPreviewKeyEvent { event -> //TODO check or remove + logger.debug("onPreviewKeyEvent: $event") + false + } + ) { + LaunchedEffect(Unit) { + onAttachedToScreen() + } + + DisposableEffect(Unit) { + onDispose { + onDetachedFromScreen() + } + } + + RootContent() + } + } + } + + setViewTreeLifecycleOwner(lifecycleOwner) + setViewTreeSavedStateRegistryOwner(lifecycleOwner) + + setViewTreeViewModelStoreOwner(viewModelStoreOwner) + } + } + + private var lastScreenWidth: Int = -1 + open val enableDeviceDirectionTracker: Boolean = false + private val orientationEventListener = object : OrientationEventListener(context) { + override fun onOrientationChanged(orientation: Int) { + val screenWidth = UIUtils.screenSize[0] + if (screenWidth != lastScreenWidth) { + lastScreenWidth = screenWidth + onDeviceDirectionChanged() + } + } + } + + var attached: Boolean = false + private set + + var onAttached: (() -> Unit)? = null + var onDetached: (() -> Unit)? = null + + @MainThread + open fun attachToScreen() { + if (attached) return + if (!PermissionUtil.canDrawOverlays(context)) { + logger.warn("You should obtain the draw overlays permission first!") + return + } + if (Looper.myLooper() != Looper.getMainLooper()) { + logger.warn("attachToWindow() should be called in main thread") + return + } + + windowManager.addView(rootView, params) + + with(lifecycleOwner) { + handleLifecycleEvent(Lifecycle.Event.ON_START) + handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + attachedFloatingViews.add(this) + + if (enableDeviceDirectionTracker) + orientationEventListener.enable() + + attached = true + } + + @MainThread + open fun detachFromScreen() { + if (!attached) return + if (Looper.myLooper() != Looper.getMainLooper()) { + logger.warn("attachToWindow() should be called in main thread") + return + } + + if (enableHomeButtonWatcher) { + homeButtonWatcher.stopWatch() + } + + viewScope.coroutineContext.cancelChildren() + + windowManager.removeView(rootView) + + with(lifecycleOwner) { + handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + handleLifecycleEvent(Lifecycle.Event.ON_STOP) + } + + attachedFloatingViews.remove(this) + + if (enableDeviceDirectionTracker) + orientationEventListener.disable() + + attached = false + } + + open fun release() { + detachFromScreen() + with(lifecycleOwner) { + handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + } + } + + protected open fun onDeviceDirectionChanged() { + params.x = params.x.fixXPosition() + params.y = params.y.fixYPosition() + updateViewLayout() + } + + @CallSuper + protected open fun onAttachedToScreen() { + if (enableHomeButtonWatcher) { + homeButtonWatcher.startWatch() + } + + onAttached?.invoke() + } + + @CallSuper + protected open fun onDetachedFromScreen() { + onDetached?.invoke() + } + + fun changeViewPosition(x: Int, y: Int) { + params.x = x + params.y = y + updateViewLayout() + } + + private fun updateViewLayout() { + try { + windowManager.updateViewLayout(rootView, params) + } catch (e: Exception) { +// logger.warn(t = e) + } + } + + open fun onBackButtonPressed(): Boolean = false + + open fun onHomeButtonPressed() { + + } + + open fun onHomeButtonLongPressed() { + + } + + protected val lifecycleOwner: FloatingViewLifecycleOwner = + FloatingViewLifecycleOwner().apply { + performRestore(null) + handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + } + +// private val tasks = mutableListOf>() + + protected val viewScope: CoroutineScope by lazy { + FloatingViewCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).apply { +// tasks.add(WeakReference(this)) + } + } + + private class FloatingViewCoroutineScope(context: CoroutineContext) : + Closeable, CoroutineScope { + override val coroutineContext: CoroutineContext = context + + override fun close() { + coroutineContext.cancel() + } + } + + protected class FloatingViewLifecycleOwner : SavedStateRegistryOwner { + private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this) + private var savedStateRegistryController: SavedStateRegistryController = + SavedStateRegistryController.create(this) + + val isInitialized: Boolean + get() = true + + override val lifecycle: Lifecycle + get() = lifecycleRegistry + + fun handleLifecycleEvent(event: Lifecycle.Event) { + lifecycleRegistry.handleLifecycleEvent(event) + } + + override val savedStateRegistry: SavedStateRegistry + get() = savedStateRegistryController.savedStateRegistry + + fun performRestore(savedState: Bundle?) { + savedStateRegistryController.performRestore(savedState) + } + + fun performSave(outBundle: Bundle) { + savedStateRegistryController.performSave(outBundle) + } + } + + protected fun Int.fixXPosition(): Int = + this.coerceAtLeast(0) + .coerceAtMost(UIUtils.screenSize[0] - rootView.width) + + protected fun Int.fixYPosition(): Int = + this.coerceAtLeast(0) + .coerceAtMost(UIUtils.screenSize[1] - rootView.height) +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeMovableFloatingView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeMovableFloatingView.kt new file mode 100644 index 00000000..b93db8e2 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeMovableFloatingView.kt @@ -0,0 +1,181 @@ +package tw.firemaples.onscreenocr.floatings.compose.base + +import android.animation.ValueAnimator +import android.content.Context +import android.view.Gravity +import android.view.animation.OvershootInterpolator +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.core.animation.addListener +import tw.firemaples.onscreenocr.utils.Logger +import tw.firemaples.onscreenocr.utils.UIUtils +import tw.firemaples.onscreenocr.utils.dpToPx + +abstract class ComposeMovableFloatingView(context: Context) : ComposeFloatingView(context) { + companion object { + private const val moveToEdgeDuration: Long = 450 + + private const val fromAlpha: Float = 1f + private const val fadeOutAnimationDuration: Long = 800 + } + + protected val logger: Logger by lazy { Logger(this::class) } + + open val moveToEdgeAfterMoved: Boolean = false + open val moveToEdgeMarginInDP: Float = 0f + private val moveToEdgeMargin: Int by lazy { moveToEdgeMarginInDP.dpToPx() } + override val layoutCanMoveOutsideScreen: Boolean + get() = moveToEdgeAfterMoved + + open val fadeOutAfterMoved: Boolean = false + open val fadeOutDelay: Long = 1000L + open val fadeOutDestinationAlpha: Float = 0.2f + + override fun onAttachedToScreen() { + super.onAttachedToScreen() + moveToEdgeOrFadeOut() + } + + override fun onDeviceDirectionChanged() { + super.onDeviceDirectionChanged() + moveToEdgeOrFadeOut() + } + + val onDragStart: (Offset) -> Unit = { _ -> + cancelFadeOut() + } + val onDragEnd: () -> Unit = { + cancelFadeOut() + moveToEdgeOrFadeOut() + } + val onDragCancel: () -> Unit = { + cancelFadeOut() + moveToEdgeOrFadeOut() + } + val onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit = { change, dragAmount -> + cancelFadeOut() + + val nextX = (params.x + (if (isAlignParentLeft) dragAmount.x else -dragAmount.x)) + .toInt().fixXPosition() + val nextY = (params.y + (if (isAlignParentTop) dragAmount.y else -dragAmount.y)) + .toInt().fixYPosition() + + changeViewPosition(nextX, nextY) + } + + private val isAlignParentLeft: Boolean + get() = Gravity.getAbsoluteGravity(layoutGravity, rootView.layoutDirection) and + Gravity.HORIZONTAL_GRAVITY_MASK == Gravity.LEFT + + private val isAlignParentTop: Boolean + get() = layoutGravity and Gravity.VERTICAL_GRAVITY_MASK == Gravity.TOP + + private fun moveToEdgeOrFadeOut() { + when { + moveToEdgeAfterMoved -> moveToEdge() + fadeOutAfterMoved -> fadeOut() + else -> cancelFadeOut() + } + } + + //region Moving to edge + fun moveToEdgeIfEnabled() { + rootView.postDelayed({ if (moveToEdgeAfterMoved) moveToEdge() }, 100L) + } + + private fun moveToEdge() { + val params = params + + val edgePosition = getEdgePosition(params.x, params.y) + + moveTo(params.x, params.y, edgePosition[0], edgePosition[1], true) + } + + private fun getEdgePosition(currentX: Int, currentY: Int): IntArray { + val screenWidth = UIUtils.screenSize[0] + + val viewWidth = rootView.width + val viewCenterX = currentX + viewWidth / 2 + + val margin = moveToEdgeMargin + + val edgeX = + // near left + if (viewCenterX < screenWidth / 2) margin + // near right + else screenWidth - viewWidth - margin + + return intArrayOf(edgeX, currentY) + } + + private var moveEdgeAnimator: ValueAnimator? = null + + private fun moveTo( + currentX: Int, + currentY: Int, + destPositionX: Int, + destPositionY: Int, + withAnimation: Boolean + ) { + val currentParams = params + if (!withAnimation) { + if (currentParams.x != destPositionX || currentParams.y != destPositionY) { + changeViewPosition(destPositionX, destPositionY) + } + } else { + moveEdgeAnimator = ValueAnimator.ofInt(0, 100).apply { + addUpdateListener { animation -> + val progress = animation.animatedValue as Int / 100f + + val nextX = currentX + (destPositionX - currentX) * progress + val nextY = currentY + (destPositionY - currentY) * progress + + changeViewPosition(nextX.toInt(), nextY.toInt()) + } + + duration = moveToEdgeDuration + interpolator = OvershootInterpolator(1.25f) + addListener( + onEnd = { + if (fadeOutAfterMoved) fadeOut() + }, + ) + + start() + } + } + } + //endregion + + //region Fade-out + private var fadeOutAnimator: ValueAnimator? = null + + private fun fadeOut() { +// logger.debug("fadeOut()") + cancelFadeOut() + + fadeOutAnimator = ValueAnimator.ofFloat(fromAlpha, fadeOutDestinationAlpha).apply { + addUpdateListener { animation -> + rootView.alpha = animation.animatedValue as Float + } + duration = fadeOutAnimationDuration + startDelay = fadeOutDelay + + start() + } + } + + private fun cancelFadeOut() { +// logger.debug("cancelFadeOut(): fadeOutAnimator: $fadeOutAnimator") + fadeOutAnimator?.cancel() + + rootView.alpha = fromAlpha + } + + protected fun rescheduleFadeOut() { +// logger.debug("rescheduleFadeOut()") + cancelFadeOut() + if (fadeOutAfterMoved) fadeOut() + } + //endregion +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeUtils.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeUtils.kt new file mode 100644 index 00000000..47fbd088 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeUtils.kt @@ -0,0 +1,32 @@ +package tw.firemaples.onscreenocr.floatings.compose.base + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first + +@Composable +fun Flow.collectOnLifecycleResumed(state: (T) -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(this, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + this@collectOnLifecycleResumed.collect(state) + } + } +} + +suspend fun MutableSharedFlow.awaitForSubscriber() { + subscriptionCount.first { it > 0 } +} + +@Composable +fun Dp.dpToPx() = with(LocalDensity.current) { this@dpToPx.toPx() } + +@Composable +fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() } diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarContent.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarContent.kt new file mode 100644 index 00000000..7d2b5bb2 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarContent.kt @@ -0,0 +1,241 @@ +package tw.firemaples.onscreenocr.floatings.compose.mainbar + +import android.content.res.Configuration +import android.graphics.Point +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import tw.firemaples.onscreenocr.R +import tw.firemaples.onscreenocr.floatings.compose.base.AppColorScheme +import tw.firemaples.onscreenocr.floatings.compose.base.AppTheme + +@Composable +fun MainBarContent( + viewModel: MainBarViewModel, + onDragStart: (Offset) -> Unit = { }, + onDragEnd: () -> Unit = { }, + onDragCancel: () -> Unit = { }, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, +) { + val state by viewModel.state.collectAsState() + + Box( + modifier = Modifier + .background( + color = AppColorScheme.background, + shape = RoundedCornerShape(8.dp), + ), + ) { + Row( + modifier = Modifier + .padding(4.dp) + ) { + LanguageBlock( + langText = state.langText, + translatorIcon = state.translatorIcon, + onClick = viewModel::onLanguageBlockClicked, + ) + if (state.displaySelectButton) { + Spacer(modifier = Modifier.size(4.dp)) + MainBarButton( + icon = R.drawable.ic_selection, + onClick = viewModel::onSelectClicked, + ) + } + if (state.displayTranslateButton) { + Spacer(modifier = Modifier.size(4.dp)) + MainBarButton( + icon = R.drawable.ic_translate, + onClick = viewModel::onTranslateClicked, + ) + } + if (state.displayCloseButton) { + Spacer(modifier = Modifier.size(4.dp)) + MainBarButton( + icon = R.drawable.ic_close, + onClick = viewModel::onCloseClicked, + ) + } + Spacer(modifier = Modifier.size(4.dp)) + MenuButton( + onClick = viewModel::onMenuButtonClicked, + onDragStart = onDragStart, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, + onDrag = onDrag, + ) + MainBarMenu( + expanded = state.displayMainBarMenu, + onMenuOptionSelected = viewModel::onMenuOptionSelected, + ) + } + } +} + +@Composable +private fun LanguageBlock( + langText: String, + translatorIcon: Int? = null, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .height(32.dp) + .border( + width = 2.dp, + color = AppColorScheme.onBackground, + shape = RoundedCornerShape(4.dp), + ) + .clickable(onClick = onClick) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = langText, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = AppColorScheme.onBackground, + ) + if (translatorIcon != null) { + Image( + painter = painterResource(id = translatorIcon), + contentDescription = "", + colorFilter = ColorFilter.tint(AppColorScheme.onBackground), + ) + } + } +} + +@Composable +private fun MainBarButton( + @DrawableRes + icon: Int, + onClick: () -> Unit, +) { + Image( + modifier = Modifier + .size(32.dp) + .clickable(onClick = onClick) + .background(colorResource(id = R.color.md_blue_800), shape = RoundedCornerShape(4.dp)) + .padding(4.dp), + painter = painterResource(id = icon), + contentDescription = "", + ) +} + +@Composable +private fun MenuButton( + onClick: () -> Unit, + onDragStart: (Offset) -> Unit = { }, + onDragEnd: () -> Unit = { }, + onDragCancel: () -> Unit = { }, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, +) { + Image( + modifier = Modifier + .size(32.dp) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = onDragStart, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, + onDrag = onDrag, + ) + } + .clickable(onClick = onClick) + .padding(2.dp), + painter = painterResource(id = R.drawable.ic_menu_move), + colorFilter = ColorFilter.tint(AppColorScheme.onBackground), + contentDescription = "", + ) +} + +private class MainBarStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = listOf( + MainBarState( + langText = "en>", + translatorIcon = R.drawable.ic_google_translate_dark_grey, + displaySelectButton = true, + displayTranslateButton = true, + displayCloseButton = true, + ), + MainBarState( + langText = "en>tw", + translatorIcon = null, + displaySelectButton = true, + displayTranslateButton = true, + displayCloseButton = true, + ) + ).asSequence() + +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun MainBarContentPreview( + @PreviewParameter(MainBarStateProvider::class) state: MainBarState, +) { + val viewModel = object : MainBarViewModel { + override val state: StateFlow + get() = MutableStateFlow(state) + override val action: SharedFlow + get() = MutableSharedFlow() + + override fun getInitialPosition(): Point = Point() + override fun getFadeOutAfterMoved(): Boolean = false + override fun getFadeOutDelay(): Long = 0L + override fun getFadeOutDestinationAlpha(): Float = 0f + override fun onMenuItemClicked(key: String) = Unit + override fun onSelectClicked() = Unit + override fun onTranslateClicked() = Unit + override fun onCloseClicked() = Unit + override fun onMenuButtonClicked() = Unit + override fun onAttachedToScreen() = Unit + override fun onDragEnd(x: Int, y: Int) = Unit + override fun onLanguageBlockClicked() = Unit + override fun onMenuOptionSelected(mainBarMenuOption: MainBarMenuOption?) = Unit + } + + AppTheme { + MainBarContent(viewModel = viewModel, + onDragStart = { offset -> }, + onDragEnd = {}, + onDragCancel = {}, + onDrag = { change, dragAmount -> }) + } + +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarFloatingView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarFloatingView.kt new file mode 100644 index 00000000..f58c0e6d --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarFloatingView.kt @@ -0,0 +1,96 @@ +package tw.firemaples.onscreenocr.floatings.compose.mainbar + +import android.content.Context +import android.graphics.Point +import androidx.compose.runtime.Composable +import dagger.hilt.android.qualifiers.ApplicationContext +import tw.firemaples.onscreenocr.floatings.ViewHolderService +import tw.firemaples.onscreenocr.floatings.compose.base.ComposeMovableFloatingView +import tw.firemaples.onscreenocr.floatings.compose.base.collectOnLifecycleResumed +import tw.firemaples.onscreenocr.floatings.history.VersionHistoryView +import tw.firemaples.onscreenocr.floatings.readme.ReadmeView +import tw.firemaples.onscreenocr.floatings.translationSelectPanel.TranslationSelectPanel +import tw.firemaples.onscreenocr.pages.setting.SettingActivity +import tw.firemaples.onscreenocr.utils.Utils +import javax.inject.Inject + +class MainBarFloatingView @Inject constructor( + @ApplicationContext context: Context, + private val viewModel: MainBarViewModel, +) : ComposeMovableFloatingView(context) { + + override val initialPosition: Point + get() = viewModel.getInitialPosition() + + @Composable + override fun RootContent() { + viewModel.action.collectOnLifecycleResumed { action -> + when (action) { + MainBarAction.RescheduleFadeOut -> + rescheduleFadeOut() + + MainBarAction.MoveToEdgeIfEnabled -> + moveToEdgeIfEnabled() + + MainBarAction.OpenLanguageSelectionPanel -> { + rescheduleFadeOut() + // TODO wait to be refactored + TranslationSelectPanel(context).attachToScreen() + } + + is MainBarAction.OpenBrowser -> + // TODO wait to be refactored + Utils.openBrowser(action.url) + + MainBarAction.OpenReadme -> + // TODO wait to be refactored + ReadmeView(context).attachToScreen() + + MainBarAction.OpenSettings -> + // TODO wait to be refactored + SettingActivity.start(context) + + MainBarAction.OpenVersionHistory -> + // TODO wait to be refactored + VersionHistoryView(context).attachToScreen() + + MainBarAction.HideMainBar -> + // TODO wait to be refactored + ViewHolderService.hideViews(context) + + MainBarAction.ExitApp -> + // TODO wait to be refactored + ViewHolderService.exit(context) + } + } + + MainBarContent( + viewModel = viewModel, + onDragStart = onDragStart, + onDragEnd = { + onDragEnd.invoke() + viewModel.onDragEnd(params.x, params.y) + }, + onDragCancel = onDragCancel, + onDrag = onDrag, + ) + } + + override val enableDeviceDirectionTracker: Boolean + get() = true + + override val moveToEdgeAfterMoved: Boolean + get() = true + + override val fadeOutAfterMoved: Boolean + get() = viewModel.getFadeOutAfterMoved() + override val fadeOutDelay: Long + get() = viewModel.getFadeOutDelay() + override val fadeOutDestinationAlpha: Float + get() = viewModel.getFadeOutDestinationAlpha() + + override fun onAttachedToScreen() { + super.onAttachedToScreen() + viewModel.onAttachedToScreen() + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarMenu.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarMenu.kt new file mode 100644 index 00000000..061654fd --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarMenu.kt @@ -0,0 +1,42 @@ +package tw.firemaples.onscreenocr.floatings.compose.mainbar + +import androidx.annotation.StringRes +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import tw.firemaples.onscreenocr.R + +@Composable +fun MainBarMenu( + expanded: Boolean, + onMenuOptionSelected: (MainBarMenuOption?) -> Unit, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = { onMenuOptionSelected.invoke(null) }, + ) { + MainBarMenuOption.entries.forEach { option -> + DropdownMenuItem( + text = { + Text(text = stringResource(id = option.text)) + }, + onClick = { onMenuOptionSelected.invoke(option) } + ) + } + } +} + +enum class MainBarMenuOption( + @StringRes + val text: Int, +) { + SETTING(R.string.menu_setting), + PRIVACY_POLICY(R.string.menu_privacy_policy), + ABOUT(R.string.menu_about), + VERSION_HISTORY(R.string.menu_version_history), + README(R.string.menu_readme), + HIDE(R.string.menu_hide), + EXIT(R.string.menu_exit), +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarModule.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarModule.kt new file mode 100644 index 00000000..3c607f35 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarModule.kt @@ -0,0 +1,13 @@ +package tw.firemaples.onscreenocr.floatings.compose.mainbar + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface MainBarModule { + @Binds + fun bindMainBarViewModel(mainBarViewModelImpl: MainBarViewModelImpl): MainBarViewModel +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarViewModel.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarViewModel.kt new file mode 100644 index 00000000..b7bac95e --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarViewModel.kt @@ -0,0 +1,275 @@ +package tw.firemaples.onscreenocr.floatings.compose.mainbar + +import android.graphics.Point +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import tw.firemaples.onscreenocr.R +import tw.firemaples.onscreenocr.data.usecase.GetCurrentOCRDisplayLangCodeUseCase +import tw.firemaples.onscreenocr.data.usecase.GetCurrentOCRLangUseCase +import tw.firemaples.onscreenocr.data.usecase.GetCurrentTranslationLangUseCase +import tw.firemaples.onscreenocr.data.usecase.GetCurrentTranslatorTypeUseCase +import tw.firemaples.onscreenocr.data.usecase.GetMainBarInitialPositionUseCase +import tw.firemaples.onscreenocr.data.usecase.SaveLastMainBarPositionUseCase +import tw.firemaples.onscreenocr.di.MainImmediateCoroutineScope +import tw.firemaples.onscreenocr.floatings.compose.base.awaitForSubscriber +import tw.firemaples.onscreenocr.floatings.manager.NavState +import tw.firemaples.onscreenocr.floatings.manager.NavigationAction +import tw.firemaples.onscreenocr.floatings.manager.StateNavigator +import tw.firemaples.onscreenocr.pages.setting.SettingManager +import tw.firemaples.onscreenocr.remoteconfig.RemoteConfigManager +import tw.firemaples.onscreenocr.translator.TranslationProviderType +import tw.firemaples.onscreenocr.utils.Logger +import javax.inject.Inject + +interface MainBarViewModel { + val state: StateFlow + val action: SharedFlow + fun getInitialPosition(): Point + fun getFadeOutAfterMoved(): Boolean + fun getFadeOutDelay(): Long + fun getFadeOutDestinationAlpha(): Float + fun onMenuItemClicked(key: String) + fun onSelectClicked() + fun onTranslateClicked() + fun onCloseClicked() + fun onMenuButtonClicked() + fun onAttachedToScreen() + fun onDragEnd(x: Int, y: Int) + fun onLanguageBlockClicked() + fun onMenuOptionSelected(mainBarMenuOption: MainBarMenuOption?) +} + +data class MainBarState( + val langText: String = "", + val translatorIcon: Int? = null, + val displaySelectButton: Boolean = false, + val displayTranslateButton: Boolean = false, + val displayCloseButton: Boolean = false, + val displayMainBarMenu: Boolean = false, +) + +sealed interface MainBarAction { + data object RescheduleFadeOut : MainBarAction + data object MoveToEdgeIfEnabled : MainBarAction + data object OpenLanguageSelectionPanel : MainBarAction + data object OpenSettings : MainBarAction + data class OpenBrowser(val url: String) : MainBarAction + data object OpenVersionHistory : MainBarAction + data object OpenReadme : MainBarAction + data object HideMainBar : MainBarAction + data object ExitApp : MainBarAction +} + +@Suppress("LongParameterList", "TooManyFunctions") +class MainBarViewModelImpl @Inject constructor( + @MainImmediateCoroutineScope + private val scope: CoroutineScope, + private val stateNavigator: StateNavigator, + private val getCurrentOCRLangUseCase: GetCurrentOCRLangUseCase, + private val getCurrentOCRDisplayLangCodeUseCase: GetCurrentOCRDisplayLangCodeUseCase, + private val getCurrentTranslatorTypeUseCase: GetCurrentTranslatorTypeUseCase, + private val getCurrentTranslationLangUseCase: GetCurrentTranslationLangUseCase, + private val saveLastMainBarPositionUseCase: SaveLastMainBarPositionUseCase, + private val getMainBarInitialPositionUseCase: GetMainBarInitialPositionUseCase, +) : MainBarViewModel { + override val state = MutableStateFlow(MainBarState()) + override val action = MutableSharedFlow() + + private val logger: Logger by lazy { Logger(this::class) } + + init { + stateNavigator.currentNavState + .onEach { onNavigationStateChanges(it) } + .launchIn(scope) + subscribeLanguageStateChanges() + } + + private suspend fun onNavigationStateChanges(navState: NavState) { + state.update { + it.copy( + displaySelectButton = navState == NavState.Idle, + displayTranslateButton = navState is NavState.ScreenCircled, + displayCloseButton = + navState == NavState.ScreenCircling || navState is NavState.ScreenCircled, + ) + } + action.emit(MainBarAction.MoveToEdgeIfEnabled) + } + + private fun subscribeLanguageStateChanges() { + combine( + getCurrentOCRDisplayLangCodeUseCase.invoke(), + getCurrentTranslatorTypeUseCase.invoke(), + getCurrentTranslationLangUseCase.invoke(), + ) { ocrLang, translatorType, translationLang -> + updateLanguageStates( + ocrLang = ocrLang, + translationProviderType = translatorType, + translationLang = translationLang, + ) + }.launchIn(scope) + } + + private suspend fun updateLanguageStates( + ocrLang: String, + translationProviderType: TranslationProviderType, + translationLang: String, + ) { + val icon = when (translationProviderType) { + TranslationProviderType.GoogleTranslateApp -> R.drawable.ic_google_translate_dark_grey + TranslationProviderType.BingTranslateApp -> R.drawable.ic_microsoft_bing + TranslationProviderType.OtherTranslateApp -> R.drawable.ic_open_in_app + TranslationProviderType.MicrosoftAzure, + TranslationProviderType.GoogleMLKit, + TranslationProviderType.MyMemory, + TranslationProviderType.PapagoTranslateApp, + TranslationProviderType.YandexTranslateApp, + TranslationProviderType.OCROnly -> null + } + + val text = when (translationProviderType) { + TranslationProviderType.GoogleTranslateApp, + TranslationProviderType.BingTranslateApp, + TranslationProviderType.OtherTranslateApp -> "$ocrLang>" + + TranslationProviderType.YandexTranslateApp -> "$ocrLang > Y" + TranslationProviderType.PapagoTranslateApp -> "$ocrLang > P" + TranslationProviderType.OCROnly -> " $ocrLang " + TranslationProviderType.MicrosoftAzure, + TranslationProviderType.GoogleMLKit, + TranslationProviderType.MyMemory -> "$ocrLang>$translationLang" + } + + state.update { + it.copy( + langText = text, + translatorIcon = icon, + ) + } + action.emit(MainBarAction.MoveToEdgeIfEnabled) + } + + override fun getInitialPosition(): Point = + getMainBarInitialPositionUseCase.invoke() + + override fun getFadeOutAfterMoved(): Boolean { + val navState = stateNavigator.currentNavState.value + + return navState != NavState.ScreenCircling && navState !is NavState.ScreenCircled + && !state.value.displayMainBarMenu + && SettingManager.enableFadingOutWhileIdle //TODO move logic + } + + override fun getFadeOutDelay(): Long = + SettingManager.timeoutToFadeOut //TODO move logic + + override fun getFadeOutDestinationAlpha(): Float = + SettingManager.opaquePercentageToFadeOut //TODO move logic + + override fun onMenuItemClicked(key: String) { + scope.launch { + action.emit(MainBarAction.RescheduleFadeOut) + } + } + + override fun onSelectClicked() { + scope.launch { + action.emit(MainBarAction.RescheduleFadeOut) + stateNavigator.navigate(NavigationAction.NavigateToScreenCircling) + } + } + + override fun onTranslateClicked() { + scope.launch { + action.emit(MainBarAction.RescheduleFadeOut) + val (ocrProvider, ocrLang) = getCurrentOCRLangUseCase.invoke().first() + stateNavigator.navigate( + NavigationAction.NavigateToScreenCapturing( + ocrLang = ocrLang, + ocrProvider = ocrProvider, + ) + ) + } + } + + override fun onCloseClicked() { + scope.launch { + action.emit(MainBarAction.RescheduleFadeOut) + stateNavigator.navigate(NavigationAction.CancelScreenCircling) + } + } + + override fun onMenuButtonClicked() { + scope.launch { + action.emit(MainBarAction.RescheduleFadeOut) + state.update { + it.copy( + displayMainBarMenu = true, + ) + } + } + } + + override fun onAttachedToScreen() { + scope.launch { + action.awaitForSubscriber() + action.emit(MainBarAction.RescheduleFadeOut) + } + } + + override fun onDragEnd(x: Int, y: Int) { + scope.launch { + saveLastMainBarPositionUseCase.invoke(x = x, y = y) + } + } + + override fun onLanguageBlockClicked() { + scope.launch { + action.emit(MainBarAction.OpenLanguageSelectionPanel) + } + } + + override fun onMenuOptionSelected(mainBarMenuOption: MainBarMenuOption?) { + scope.launch { + state.update { + it.copy( + displayMainBarMenu = false, + ) + } + + when (mainBarMenuOption) { + MainBarMenuOption.SETTING -> + action.emit(MainBarAction.OpenSettings) + + MainBarMenuOption.PRIVACY_POLICY -> + action.emit(MainBarAction.OpenBrowser(RemoteConfigManager.privacyPolicyUrl)) + + MainBarMenuOption.ABOUT -> + action.emit(MainBarAction.OpenBrowser(RemoteConfigManager.aboutUrl)) + + MainBarMenuOption.VERSION_HISTORY -> + action.emit(MainBarAction.OpenVersionHistory) + + MainBarMenuOption.README -> + action.emit(MainBarAction.OpenReadme) + + MainBarMenuOption.HIDE -> + action.emit(MainBarAction.HideMainBar) + + MainBarMenuOption.EXIT -> + action.emit(MainBarAction.ExitApp) + + null -> {} + } + } + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewContent.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewContent.kt new file mode 100644 index 00000000..78a7c126 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewContent.kt @@ -0,0 +1,504 @@ +package tw.firemaples.onscreenocr.floatings.compose.resultview + +import android.content.res.Configuration +import android.graphics.Rect +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateIntOffsetAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import tw.firemaples.onscreenocr.R +import tw.firemaples.onscreenocr.floatings.compose.base.AppColorScheme +import tw.firemaples.onscreenocr.floatings.compose.base.AppTheme +import tw.firemaples.onscreenocr.floatings.compose.base.FontSize +import tw.firemaples.onscreenocr.floatings.compose.base.dpToPx +import tw.firemaples.onscreenocr.floatings.compose.base.pxToDp +import tw.firemaples.onscreenocr.floatings.compose.wigets.WordSelectionText +import java.util.Locale + +@Composable +fun ResultViewContent( + viewModel: ResultViewModel, + requestRootLocationOnScreen: () -> Rect, +) { + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit) { + val rootLocation = requestRootLocationOnScreen.invoke() + viewModel.onRootViewPositioned( + xOffset = rootLocation.left, + yOffset = rootLocation.top, + ) + } + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(colorResource(id = R.color.dialogOutside)) + .clickable(onClick = viewModel::onDialogOutsideClicked), + ) { + state.highlightArea.forEach { + TextHighlightBox( + highlightArea = it, + ) + } + + val targetOffset = remember { + mutableStateOf(IntOffset(state.highlightUnion.left, state.highlightUnion.top)) + } + + val animOffset by animateIntOffsetAsState( + targetValue = targetOffset.value, + label = "result panel position", + ) + + ResultPanel( + modifier = Modifier + .padding(16.dp) + .calculateOffset( + highlightUnion = state.highlightUnion, + offset = targetOffset, + padding = 16.dp.dpToPx(), + verticalSpacing = 4.dp.dpToPx(), + ) + .offset { animOffset } + .animateContentSize(), + viewModel = viewModel, + textSearchEnabled = state.textSearchEnabled, + fontSize = state.fontSize, + ocrState = state.ocrState, + translationState = state.translationState, + ) + } +} + +private fun Modifier.calculateOffset( + highlightUnion: Rect, + offset: MutableState, + padding: Float, + verticalSpacing: Float, +): Modifier = onGloballyPositioned { coordinates -> + val parent = coordinates.parentLayoutCoordinates?.size ?: return@onGloballyPositioned + val current = coordinates.size + + val leftAnchor = maxOf(highlightUnion.left, padding.toInt()) + val rightAnchor = minOf(highlightUnion.right, parent.width - padding.toInt()) + + val x = when { + leftAnchor + current.width + padding < parent.width -> { + // Align left + highlightUnion.left - padding.toInt() + } + + rightAnchor - current.width - padding >= 0 -> { + // Align right + rightAnchor - current.width - padding.toInt() + } + + else -> { + // No horizontal alignment + 0 + } + } + + val topAnchor = highlightUnion.bottom + verticalSpacing + val bottomAnchor = highlightUnion.top - verticalSpacing + + val y = when { + topAnchor + current.height + padding < parent.height -> { + // Display at bottom + (topAnchor - padding).toInt() + } + + bottomAnchor - current.height - padding >= 0 -> { + // Display at top + (bottomAnchor - current.height - padding).toInt() + } + + else -> { + // Display middle vertically + val middleAnchor = (parent.height - current.height) / 2 + (middleAnchor - padding).toInt() + } + } + + offset.value = IntOffset(x, y) +} + +@Composable +private fun TextHighlightBox(highlightArea: Rect) { + Box( + modifier = Modifier + .absoluteOffset( + x = highlightArea.left.pxToDp(), + y = highlightArea.top.pxToDp(), + ) + .size( + width = highlightArea + .width() + .pxToDp(), + height = highlightArea + .height() + .pxToDp(), + ) + .background( + color = colorResource(id = R.color.resultView_recognizedBoundingBoxes), + shape = RoundedCornerShape(2.dp), + ) + ) +} + +@Composable +private fun ResultPanel( + modifier: Modifier, + viewModel: ResultViewModel, + textSearchEnabled: Boolean, + fontSize: Float, + ocrState: OCRState, + translationState: TranslationState, +) { + Column( + modifier = modifier + .background( + color = AppColorScheme.background, + shape = RoundedCornerShape(8.dp), + ) + .clickable { } + .padding(horizontal = 6.dp, vertical = 4.dp), + ) { + if (ocrState.showRecognitionArea) { + OCRToolBar( + textSearchEnabled = textSearchEnabled, + onSearchClicked = viewModel::onTextSearchClicked, + onEditClicked = viewModel::onOCRTextEditClicked, + onCopyClicked = { viewModel.onCopyClicked(TextType.OCRText) }, + onFontSizeClicked = viewModel::onAdjustFontSizeClicked, + onGoogleTranslateClicked = { viewModel.onGoogleTranslateClicked(TextType.OCRText) }, + onExportClicked = viewModel::onShareOCRTextClicked, + ) + OCRTextArea( + fontSize = fontSize, + showProcessing = ocrState.showProcessing, + ocrText = ocrState.ocrText, + textSearchEnabled = textSearchEnabled, + onTextSelected = viewModel::onTextSearchWordSelected, + ) + } + + if (translationState.showTranslationArea) { + Spacer(modifier = Modifier.size(2.dp)) + TranslationToolBar( + onCopyClicked = { viewModel.onCopyClicked(TextType.TranslationResult) }, + onGoogleTranslateClicked = { viewModel.onGoogleTranslateClicked(TextType.TranslationResult) } + ) + TranslationTextArea( + fontSize = fontSize, + showProcessing = translationState.showProcessing, + translatedText = translationState.translatedText, + ) + TranslationProviderBar( + translationProviderText = translationState.providerText, + translationProviderIcon = translationState.providerIcon, + ) + } + } +} + +@Composable +private fun OCRToolBar( + textSearchEnabled: Boolean, + onSearchClicked: () -> Unit, + onEditClicked: () -> Unit, + onCopyClicked: () -> Unit, + onFontSizeClicked: () -> Unit, + onGoogleTranslateClicked: () -> Unit, + onExportClicked: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.text_ocr_text), + fontSize = FontSize.Small, + fontWeight = FontWeight.Bold, + color = AppColorScheme.onBackground, + ) + +// Image(painter = painterResource(id = R.drawable.ic_play), contentDescription = "") + + Spacer(modifier = Modifier.size(4.dp)) + + val textSearchTintColor = if (textSearchEnabled) + colorResource(id = R.color.md_blue_800) + else AppColorScheme.onBackground + Image( + modifier = Modifier.clickable(onClick = onSearchClicked), + painter = painterResource(id = R.drawable.ic_text_search), + contentDescription = "", + colorFilter = ColorFilter.tint(textSearchTintColor), + ) + + Spacer(modifier = Modifier.size(4.dp)) + + Image( + modifier = Modifier.clickable(onClick = onEditClicked), + painter = painterResource(id = R.drawable.ic_square_edit_outline), + contentDescription = "", + ) + + Spacer(modifier = Modifier.size(4.dp)) + + Image( + modifier = Modifier.clickable(onClick = onCopyClicked), + painter = painterResource(id = R.drawable.ic_copy), + contentDescription = "", + ) + + Spacer(modifier = Modifier.size(4.dp)) + + Image( + modifier = Modifier.clickable(onClick = onFontSizeClicked), + painter = painterResource(id = R.drawable.ic_font_size), + contentDescription = "", + ) + + Spacer(modifier = Modifier.size(4.dp)) + + Image( + modifier = Modifier.clickable(onClick = onGoogleTranslateClicked), + painter = painterResource(id = R.drawable.ic_google_translate), + contentDescription = "", + ) + + Spacer(modifier = Modifier.size(4.dp)) + + Image( + modifier = Modifier.clickable(onClick = onExportClicked), + painter = painterResource(id = R.drawable.ic_export), + contentDescription = "", + ) + } +} + +@Composable +private fun OCRTextArea( + showProcessing: Boolean, + ocrText: String, + fontSize: Float, + textSearchEnabled: Boolean, + onTextSelected: (String) -> Unit, +) { + if (showProcessing) { + ProgressIndicator() + } else { + if (textSearchEnabled) { + WordSelectionText( + modifier = Modifier + .sizeIn(maxHeight = 150.dp) + .verticalScroll(rememberScrollState()), + text = ocrText, + locale = Locale.US, + textStyle = TextStyle( + color = AppColorScheme.onBackground, + fontSize = fontSize.sp, + ), + selectedSpanStyle = SpanStyle( + color = AppColorScheme.onSecondary, + background = AppColorScheme.secondary, + ), + onTextSelected = onTextSelected, + ) + } else { + Text( + modifier = Modifier + .sizeIn(maxHeight = 150.dp) + .verticalScroll(rememberScrollState()), + text = ocrText, + color = AppColorScheme.onBackground, + fontSize = fontSize.sp, + ) + } + } + +} + +@Composable +private fun TranslationToolBar( + onCopyClicked: () -> Unit, + onGoogleTranslateClicked: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.text_translated_text), + fontSize = FontSize.Small, + fontWeight = FontWeight.Bold, + color = AppColorScheme.onBackground, + ) + + Spacer(modifier = Modifier.size(4.dp)) + + Image( + modifier = Modifier.clickable(onClick = onCopyClicked), + painter = painterResource(id = R.drawable.ic_copy), + contentDescription = "", + ) + + Spacer(modifier = Modifier.size(4.dp)) + + Image( + modifier = Modifier.clickable(onClick = onGoogleTranslateClicked), + painter = painterResource(id = R.drawable.ic_google_translate), + contentDescription = "", + ) + } +} + +@Composable +private fun TranslationTextArea( + showProcessing: Boolean, + translatedText: String, + fontSize: Float, +) { + + if (showProcessing) { + ProgressIndicator() + } else { + Text( + modifier = Modifier + .sizeIn(maxHeight = 150.dp) + .verticalScroll(rememberScrollState()), + text = translatedText, + color = AppColorScheme.onBackground, + fontSize = fontSize.sp, + ) + } + +} + +@Composable +private fun ColumnScope.TranslationProviderBar( + translationProviderText: String?, + translationProviderIcon: Int? +) { + if (translationProviderText != null || translationProviderIcon != null) { + Spacer(modifier = Modifier.size(2.dp)) + } + + if (translationProviderText != null) { + Text( + modifier = Modifier.align(Alignment.End), + text = translationProviderText, + color = AppColorScheme.secondary, + fontSize = 12.sp, + maxLines = 1, + ) + } + + if (translationProviderIcon != null) { + Image( + modifier = Modifier.align(Alignment.End), + painter = painterResource(id = translationProviderIcon), + contentDescription = "", + ) + } +} + +@Composable +private fun ProgressIndicator() { + CircularProgressIndicator( + modifier = Modifier.size(30.dp), + ) +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ResultViewContentPreview() { + val areaRect = Rect(10, 20, 80, 90) + val state = ResultViewState( + highlightArea = listOf(areaRect), + highlightUnion = areaRect, + textSearchEnabled = true, + ocrState = OCRState( + showProcessing = true, + ocrText = "Test OCR text", + ), + translationState = TranslationState( + showTranslationArea = true, + showProcessing = true, + translatedText = "Test result text", + providerText = "Test Translation Provider", + providerIcon = R.drawable.img_translated_by_google, + ), + ) + + val viewModel = object : ResultViewModel { + override val state: StateFlow + get() = MutableStateFlow(state) + override val action: SharedFlow + get() = MutableSharedFlow() + + override fun onRootViewPositioned(xOffset: Int, yOffset: Int) = Unit + override fun onDialogOutsideClicked() = Unit + override fun onHomeButtonPressed() = Unit + override fun onTextSearchClicked() = Unit + override fun onTextSearchWordSelected(word: String) = Unit + override fun onOCRTextEditClicked() = Unit + override fun onOCRTextEdited(text: String) = Unit + override fun onCopyClicked(textType: TextType) = Unit + override fun onAdjustFontSizeClicked() = Unit + override fun onGoogleTranslateClicked(textType: TextType) = Unit + override fun onShareOCRTextClicked() = Unit + } + + AppTheme { + ResultViewContent( + viewModel = viewModel, + requestRootLocationOnScreen = { Rect() } + ) + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewFloatingView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewFloatingView.kt new file mode 100644 index 00000000..58ccda8e --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewFloatingView.kt @@ -0,0 +1,95 @@ +package tw.firemaples.onscreenocr.floatings.compose.resultview + +import android.content.Context +import android.graphics.Bitmap +import android.view.WindowManager +import androidx.compose.runtime.Composable +import dagger.hilt.android.qualifiers.ApplicationContext +import tw.firemaples.onscreenocr.floatings.compose.base.ComposeFloatingView +import tw.firemaples.onscreenocr.floatings.compose.base.collectOnLifecycleResumed +import tw.firemaples.onscreenocr.floatings.recognizedTextEditor.RecognizedTextEditor +import tw.firemaples.onscreenocr.floatings.result.FontSizeAdjuster +import tw.firemaples.onscreenocr.floatings.textInfoSearch.TextInfoSearchView +import tw.firemaples.onscreenocr.translator.utils.GoogleTranslateUtils +import tw.firemaples.onscreenocr.utils.Logger +import tw.firemaples.onscreenocr.utils.Utils +import tw.firemaples.onscreenocr.utils.getViewRect +import javax.inject.Inject + +class ResultViewFloatingView @Inject constructor( + @ApplicationContext context: Context, + private val viewModel: ResultViewModel, +) : ComposeFloatingView(context) { + + private val logger: Logger by lazy { Logger(ResultViewFloatingView::class) } + + override val layoutWidth: Int + get() = WindowManager.LayoutParams.MATCH_PARENT + + override val layoutHeight: Int + get() = WindowManager.LayoutParams.MATCH_PARENT + + override val enableHomeButtonWatcher: Boolean + get() = true + + @Composable + override fun RootContent() { + viewModel.action.collectOnLifecycleResumed { action -> + when (action) { + is ResultViewAction.LaunchGoogleTranslator -> { + GoogleTranslateUtils.launchTranslator(action.text) + } + + is ResultViewAction.ShareText -> { + Utils.shareText(action.text) + } + + ResultViewAction.ShowFontSizeAdjuster -> + FontSizeAdjuster(context).attachToScreen() + + is ResultViewAction.ShowOCRTextEditor -> + showRecognizedTextEditor( + text = action.text, + croppedBitmap = action.croppedBitmap, + onTextEdited = viewModel::onOCRTextEdited, + ) + + is ResultViewAction.ShowTextInfoSearchView -> { + TextInfoSearchView( + context = context, + text = action.text, + sourceLang = action.sourceLang, + targetLang = action.targetLang, + ).attachToScreen() + } + } + } + + ResultViewContent( + viewModel = viewModel, + requestRootLocationOnScreen = rootView::getViewRect, + ) + } + + private fun showRecognizedTextEditor( + text: String, + croppedBitmap: Bitmap, + onTextEdited: (String) -> Unit, + ) { + RecognizedTextEditor( + context = context, + review = croppedBitmap, + text = text, + onSubmit = { + if (it.isNotBlank() && it.trim() != text) { + onTextEdited.invoke(it.trim()) + } + }, + ).attachToScreen() + } + + override fun onHomeButtonPressed() { + super.onHomeButtonPressed() + viewModel.onHomeButtonPressed() + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewModel.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewModel.kt new file mode 100644 index 00000000..95c5eca3 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewModel.kt @@ -0,0 +1,384 @@ +package tw.firemaples.onscreenocr.floatings.compose.resultview + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Rect +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import tw.firemaples.onscreenocr.R +import tw.firemaples.onscreenocr.data.usecase.GetCurrentTranslationLangUseCase +import tw.firemaples.onscreenocr.data.usecase.GetHidingOCRAreaAfterTranslatedUseCase +import tw.firemaples.onscreenocr.data.usecase.GetResultViewFontSizeUseCase +import tw.firemaples.onscreenocr.data.usecase.GetShowTextSelectorOnResultViewUseCase +import tw.firemaples.onscreenocr.data.usecase.SetShowTextSelectorOnResultViewUseCase +import tw.firemaples.onscreenocr.di.MainImmediateCoroutineScope +import tw.firemaples.onscreenocr.floatings.manager.BitmapIncluded +import tw.firemaples.onscreenocr.floatings.manager.NavState +import tw.firemaples.onscreenocr.floatings.manager.NavigationAction +import tw.firemaples.onscreenocr.floatings.manager.ResultInfo +import tw.firemaples.onscreenocr.floatings.manager.StateNavigator +import tw.firemaples.onscreenocr.recognition.RecognitionResult +import tw.firemaples.onscreenocr.translator.TranslationProviderType +import tw.firemaples.onscreenocr.utils.Constants +import tw.firemaples.onscreenocr.utils.Logger +import tw.firemaples.onscreenocr.utils.Utils +import javax.inject.Inject + +interface ResultViewModel { + val state: StateFlow + val action: SharedFlow + fun onRootViewPositioned(xOffset: Int, yOffset: Int) + fun onDialogOutsideClicked() + fun onHomeButtonPressed() + fun onTextSearchClicked() + fun onTextSearchWordSelected(word: String) + fun onOCRTextEditClicked() + fun onOCRTextEdited(text: String) + fun onCopyClicked(textType: TextType) + fun onAdjustFontSizeClicked() + fun onGoogleTranslateClicked(textType: TextType) + fun onShareOCRTextClicked() +} + +data class ResultViewState( + val textSearchEnabled: Boolean = false, + val fontSize: Float = Constants.DEFAULT_RESULT_WINDOW_FONT_SIZE, + val highlightArea: List = listOf(), + val highlightUnion: Rect = Rect(), + val ocrState: OCRState = OCRState(), + val translationState: TranslationState = TranslationState(), +) + +data class OCRState( + val showRecognitionArea: Boolean = true, + val showProcessing: Boolean = false, + val ocrText: String = "", +) + +data class TranslationState( + val showTranslationArea: Boolean = false, + val showProcessing: Boolean = false, + val translatedText: String = "", + val providerText: String? = null, + val providerIcon: Int? = null, +) + +sealed interface ResultViewAction { + data class ShowOCRTextEditor(val text: String, val croppedBitmap: Bitmap) : ResultViewAction + data object ShowFontSizeAdjuster : ResultViewAction + data class LaunchGoogleTranslator(val text: String) : ResultViewAction + data class ShareText(val text: String) : ResultViewAction + data class ShowTextInfoSearchView( + val text: String, + val sourceLang: String, + val targetLang: String, + ) : ResultViewAction +} + +enum class TextType { + OCRText, TranslationResult +} + +class ResultViewModelImpl @Inject constructor( + @ApplicationContext + private val context: Context, + @MainImmediateCoroutineScope + private val scope: CoroutineScope, + private val stateNavigator: StateNavigator, + getShowTextSelectorOnResultViewUseCase: GetShowTextSelectorOnResultViewUseCase, + private val setShowTextSelectorOnResultViewUseCase: SetShowTextSelectorOnResultViewUseCase, + getResultViewFontSizeUseCase: GetResultViewFontSizeUseCase, + private val getCurrentTranslationLangUseCase: GetCurrentTranslationLangUseCase, + private val getHidingOCRAreaAfterTranslatedUseCase: GetHidingOCRAreaAfterTranslatedUseCase, +) : ResultViewModel { + private val logger by lazy { Logger(this::class) } + + override val state = MutableStateFlow(ResultViewState()) + override val action = MutableSharedFlow() + + private var rootViewXOffset: Int = 0 + private var rootViewYOffset: Int = 0 + private var parentRect: Rect? = null + private var selectedRect: Rect? = null + private var croppedBitmap: Bitmap? = null + private var lastRecognitionResult: RecognitionResult? = null + + init { + stateNavigator.currentNavState + .onEach { navState -> + updateViewStateWithNavState(navState) + }.launchIn(scope) + + getShowTextSelectorOnResultViewUseCase.invoke() + .onEach { show -> + state.update { + it.copy( + textSearchEnabled = show, + ) + } + }.launchIn(scope) + + getResultViewFontSizeUseCase.invoke() + .onEach { fontSize -> + state.update { + it.copy( + fontSize = fontSize, + ) + } + }.launchIn(scope) + } + + private fun updateViewStateWithNavState(navState: NavState) = scope.launch { + if (navState is BitmapIncluded) { + this@ResultViewModelImpl.parentRect = navState.parentRect + this@ResultViewModelImpl.selectedRect = navState.selectedRect + this@ResultViewModelImpl.croppedBitmap = navState.bitmap + } + + when (navState) { + is NavState.TextRecognizing -> + state.update { + it.copy( + highlightArea = listOf(navState.selectedRect), + highlightUnion = navState.selectedRect, + ocrState = it.ocrState.copy( + showProcessing = true, + ) + ) + } + + is NavState.TextTranslating -> + state.update { + this@ResultViewModelImpl.lastRecognitionResult = navState.recognitionResult + + val needTranslate = !navState.translationProviderType.nonTranslation + val (textAreas, unionArea) = calculateTextAreas( + navState.recognitionResult.boundingBoxes, + navState.parentRect, + navState.selectedRect, + ) + + it.copy( + highlightArea = textAreas, + highlightUnion = unionArea, + ocrState = it.ocrState.copy( + showRecognitionArea = true, + showProcessing = false, + ocrText = navState.recognitionResult.result, + ), + translationState = it.translationState.copy( + showTranslationArea = needTranslate, + showProcessing = needTranslate, + ) + ) + } + + is NavState.TextTranslated -> { + when (val resultInfo = navState.resultInfo) { + is ResultInfo.Error -> + clearData() + + ResultInfo.OCROnly -> + state.update { + it.copy( + translationState = it.translationState.copy( + showTranslationArea = false, + ) + ) + } + + is ResultInfo.Translated -> { + val providerType = resultInfo.providerType + val needTranslate = !providerType.nonTranslation + val providerIcon = + if (providerType == TranslationProviderType.GoogleMLKit) + R.drawable.img_translated_by_google + else null + + val providerLabel = if (providerIcon == null) { + val providerName = context.getString(providerType.nameRes) + "${context.getString(R.string.text_translated_by)} $providerName" + } else null + val showRecognitionArea = getHidingOCRAreaAfterTranslatedUseCase.invoke().not() + + state.update { + it.copy( + ocrState = it.ocrState.copy( + showRecognitionArea = showRecognitionArea, + ), + translationState = it.translationState.copy( + showTranslationArea = needTranslate, + showProcessing = false, + translatedText = resultInfo.translatedText, + providerText = providerLabel, + providerIcon = providerIcon, + ) + ) + } + } + } + } + + NavState.Idle -> { + clearData() + } + + else -> { + clearData() + } + } + } + + private fun clearData() { + state.update { + it.copy( + highlightArea = listOf(), + highlightUnion = Rect(), + ocrState = OCRState(), + translationState = TranslationState(), + ) + } + } + + private fun calculateTextAreas( + boundingBoxes: List, + parent: Rect, + selected: Rect, + ): Pair, Rect> { + val topOffset = parent.top + selected.top - rootViewYOffset + val leftOffset = parent.left + selected.left - rootViewXOffset + val textAreas = boundingBoxes.map { + Rect( + it.left + leftOffset, + it.top + topOffset, + it.right + leftOffset, + it.bottom + topOffset + ) + } + val unionRect = Rect() + textAreas.forEach { unionRect.union(it) } + + return textAreas to unionRect + } + + override fun onRootViewPositioned(xOffset: Int, yOffset: Int) { + rootViewXOffset = xOffset + rootViewYOffset = yOffset + } + + override fun onDialogOutsideClicked() { + scope.launch { + stateNavigator.navigate(NavigationAction.NavigateToIdle()) + } + } + + override fun onHomeButtonPressed() { + scope.launch { + stateNavigator.navigate(NavigationAction.NavigateToIdle()) + } + } + + override fun onTextSearchClicked() { + scope.launch { + val show = state.value.textSearchEnabled.not() + setShowTextSelectorOnResultViewUseCase.invoke(show) + } + } + + override fun onTextSearchWordSelected(word: String) { + scope.launch { + val sourceLang = lastRecognitionResult?.langCode ?: return@launch + val targetLang = getCurrentTranslationLangUseCase.invoke().first() + action.emit( + ResultViewAction.ShowTextInfoSearchView( + text = word, + sourceLang = sourceLang, + targetLang = targetLang, + ) + ) + } + } + + override fun onOCRTextEditClicked() { + scope.launch { + val croppedBitmap = croppedBitmap ?: return@launch + val text = state.value.ocrState.ocrText + action.emit( + ResultViewAction.ShowOCRTextEditor( + text = text, + croppedBitmap = croppedBitmap, + ) + ) + } + } + + override fun onOCRTextEdited(text: String) { + scope.launch { + val parentRect = parentRect ?: return@launch + val selectedRect = selectedRect ?: return@launch + val croppedBitmap = croppedBitmap ?: return@launch + val recognitionResult = lastRecognitionResult ?: return@launch + stateNavigator.navigate( + NavigationAction.ReStartTranslation( + croppedBitmap = croppedBitmap, + parentRect = parentRect, + selectedRect = selectedRect, + recognitionResult = recognitionResult.copy(result = text), + ) + ) + } + } + + override fun onCopyClicked(textType: TextType) { + scope.launch { + val label = when (textType) { + TextType.OCRText -> LABEL_RECOGNIZED_TEXT + TextType.TranslationResult -> LABEL_TRANSLATED_TEXT + } + + Utils.copyToClipboard( + label = label, + text = textType.getTargetText() + ) + } + } + + override fun onAdjustFontSizeClicked() { + scope.launch { + action.emit(ResultViewAction.ShowFontSizeAdjuster) + } + } + + override fun onGoogleTranslateClicked(textType: TextType) { + scope.launch { + action.emit(ResultViewAction.LaunchGoogleTranslator(textType.getTargetText())) + stateNavigator.navigate(NavigationAction.NavigateToIdle()) + } + } + + override fun onShareOCRTextClicked() { + scope.launch { + action.emit(ResultViewAction.ShareText(state.value.ocrState.ocrText)) + stateNavigator.navigate(NavigationAction.NavigateToIdle()) + } + } + + private fun TextType.getTargetText(): String = when (this) { + TextType.OCRText -> state.value.ocrState.ocrText + TextType.TranslationResult -> state.value.translationState.translatedText + } + + companion object { + private const val LABEL_RECOGNIZED_TEXT = "Recognized text" + private const val LABEL_TRANSLATED_TEXT = "Translated text" + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewModule.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewModule.kt new file mode 100644 index 00000000..9080a236 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewModule.kt @@ -0,0 +1,13 @@ +package tw.firemaples.onscreenocr.floatings.compose.resultview + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface ResultViewModule { + @Binds + fun bindResultViewModel(resultViewModelImpl: ResultViewModelImpl): ResultViewModel +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/wigets/WordSelectionText.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/wigets/WordSelectionText.kt new file mode 100644 index 00000000..9c98d28b --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/wigets/WordSelectionText.kt @@ -0,0 +1,128 @@ +package tw.firemaples.onscreenocr.floatings.compose.wigets + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import tw.firemaples.onscreenocr.R +import tw.firemaples.onscreenocr.utils.WordBoundary +import java.util.Locale + +@Composable +fun WordSelectionText( + modifier: Modifier = Modifier, + text: String, + locale: Locale, + textStyle: TextStyle = TextStyle.Default, + selectedSpanStyle: SpanStyle = SpanStyle(), + onTextSelected: (String) -> Unit +) { + var selectedStart by remember { mutableStateOf(-1) } + val annotatedString = buildText( + fullText = text, + textAll = stringResource(id = R.string.text_all_text), + locale = locale, + selectedStart = selectedStart, + selectedSpanStyle = selectedSpanStyle, + ) + ClickableText( + modifier = modifier, + style = textStyle, + text = annotatedString, + onClick = { offset -> + val clicked = annotatedString.getStringAnnotations(offset, offset) + .firstOrNull() + if (clicked != null) { + selectedStart = clicked.start + onTextSelected.invoke(clicked.tag) + } + }, + ) +} + +private fun buildText( + fullText: String, + textAll: String, + locale: Locale, + selectedStart: Int, + selectedSpanStyle: SpanStyle +) = buildAnnotatedString { + if (fullText.isEmpty()) return@buildAnnotatedString + + val text = "$textAll $fullText" + + val boundaries = WordBoundary.breakWords(text = text, locale = locale) + if (boundaries.isEmpty()) { + append(text) + return@buildAnnotatedString + } + + val unselectedStyle = SpanStyle( + textDecoration = TextDecoration.Underline, + ) + val selectedStyle = selectedSpanStyle.copy( + textDecoration = TextDecoration.Underline + ) + + var textStart = 0 + var index = 0 + while (textStart < text.length || index < boundaries.size) { + val nextBoundary = boundaries.getOrNull(index) + if (nextBoundary == null) { + append(text.substring(textStart until text.length)) + textStart = text.length + } else if (textStart < nextBoundary.start) { + append(text.substring(textStart until nextBoundary.start)) + textStart = nextBoundary.start + } else if (textStart == nextBoundary.start) { + val style = if (textStart == selectedStart) + selectedStyle else unselectedStyle + + if (nextBoundary.start < textAll.length) { + while (true) { + val next = boundaries.getOrNull(index + 1) + if (next == null || next.start >= textAll.length) { + break + } + index++ + } + + withStyle(style = style) { + pushStringAnnotation(tag = fullText, annotation = textAll) + append(textAll) + } + textStart = textAll.length + } else { + val word = text.substring(nextBoundary.start until nextBoundary.end) + withStyle(style = style) { + pushStringAnnotation(tag = word, annotation = word) + append(word) + } + textStart = nextBoundary.end + } + index++ + } + } +} + +@Preview +@Composable +private fun WordBreakTextPreview() { + val text = " Hello world test! word-breaker ! " + val locale = Locale.US + WordSelectionText( + text = text, + locale = locale, + onTextSelected = {}, + ) +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/main/MainBar.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/main/MainBar.kt index 005cd6ec..d3b86c63 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/floatings/main/MainBar.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/main/MainBar.kt @@ -1,159 +1,168 @@ -package tw.firemaples.onscreenocr.floatings.main - -import android.content.Context -import android.graphics.Point -import tw.firemaples.onscreenocr.R -import tw.firemaples.onscreenocr.databinding.FloatingMainBarBinding -import tw.firemaples.onscreenocr.floatings.base.MovableFloatingView -import tw.firemaples.onscreenocr.floatings.history.VersionHistoryView -import tw.firemaples.onscreenocr.floatings.manager.FloatingStateManager -import tw.firemaples.onscreenocr.floatings.manager.State -import tw.firemaples.onscreenocr.floatings.menu.MenuView -import tw.firemaples.onscreenocr.floatings.readme.ReadmeView -import tw.firemaples.onscreenocr.floatings.translationSelectPanel.TranslationSelectPanel -import tw.firemaples.onscreenocr.log.FirebaseEvent -import tw.firemaples.onscreenocr.pages.setting.SettingActivity -import tw.firemaples.onscreenocr.pages.setting.SettingManager -import tw.firemaples.onscreenocr.pref.AppPref -import tw.firemaples.onscreenocr.utils.* - -class MainBar(context: Context) : MovableFloatingView(context) { - override val layoutId: Int - get() = R.layout.floating_main_bar - - override val initialPosition: Point - get() = - if (SettingManager.restoreMainBarPosition) AppPref.lastMainBarPosition - else Point(0, 0) - - override val enableDeviceDirectionTracker: Boolean - get() = true - - override val moveToEdgeAfterMoved: Boolean - get() = true - - override val fadeOutAfterMoved: Boolean - get() = !arrayOf(State.ScreenCircling, State.ScreenCircled) - .contains(FloatingStateManager.currentState) - && !menuView.attached - && SettingManager.enableFadingOutWhileIdle - override val fadeOutDelay: Long - get() = SettingManager.timeoutToFadeOut - override val fadeOutDestinationAlpha: Float - get() = SettingManager.opaquePercentageToFadeOut - - private val binding: FloatingMainBarBinding = FloatingMainBarBinding.bind(rootLayout) - - private val menuView: MenuView by lazy { - MenuView(context, false).apply { - setAnchor(binding.btMenu) - - onAttached = { rescheduleFadeOut() } - onDetached = { rescheduleFadeOut() } - onItemSelected = { view, key -> - view.detachFromScreen() - viewModel.onMenuItemClicked(key) - rescheduleFadeOut() - } - } - } - - private val viewModel: MainBarViewModel by lazy { MainBarViewModel(viewScope) } - - init { - binding.setViews() - setDragView(binding.btMenu) - } - - private fun FloatingMainBarBinding.setViews() { - btLangSelector.clickOnce { - rescheduleFadeOut() - TranslationSelectPanel(context).attachToScreen() - } - - btSelect.clickOnce { - FloatingStateManager.startScreenCircling() - } - - btTranslate.clickOnce { - FirebaseEvent.logClickTranslationStartButton() - FloatingStateManager.startScreenCapturing(viewModel.selectedOCRLang) - } - - btClose.clickOnce { - FloatingStateManager.cancelScreenCircling() - } - - btMenu.clickOnce { - viewModel.onMenuButtonClicked() - } - - viewModel.languageText.observe(lifecycleOwner) { - tvLang.text = it - moveToEdgeIfEnabled() - } - - viewModel.displayTranslatorIcon.observe(lifecycleOwner) { - if (it == null) { - ivGoogleTranslator.setImageDrawable(null) - ivGoogleTranslator.hide() - } else { - ivGoogleTranslator.setImageResource(it) - ivGoogleTranslator.show() - } - moveToEdgeIfEnabled() - } - - viewModel.displaySelectButton.observe(lifecycleOwner) { - btSelect.showOrHide(it) - moveToEdgeIfEnabled() - } - - viewModel.displayTranslateButton.observe(lifecycleOwner) { - btTranslate.showOrHide(it) - moveToEdgeIfEnabled() - } - - viewModel.displayCloseButton.observe(lifecycleOwner) { - btClose.showOrHide(it) - moveToEdgeIfEnabled() - } - - viewModel.displayMenuItems.observe(lifecycleOwner) { - with(menuView) { - updateData(it) - attachToScreen() - } - } - - viewModel.rescheduleFadeOut.observe(lifecycleOwner) { - rescheduleFadeOut() - } - - viewModel.showSettingPage.observe(lifecycleOwner) { - SettingActivity.start(context) - } - - viewModel.openBrowser.observe(lifecycleOwner) { - Utils.openBrowser(it) - } - - viewModel.showVersionHistory.observe(lifecycleOwner) { - VersionHistoryView(context).attachToScreen() - } - - viewModel.showReadme.observe(lifecycleOwner) { - ReadmeView(context).attachToScreen() - } - } - - override fun onAttachedToScreen() { - super.onAttachedToScreen() - viewModel.onAttachedToScreen() - } - - override fun onDetachedFromScreen() { - super.onDetachedFromScreen() - viewModel.saveLastPosition(params.x, params.y) - } -} +//package tw.firemaples.onscreenocr.floatings.main +// +//import android.content.Context +//import android.graphics.Point +//import dagger.hilt.android.qualifiers.ApplicationContext +//import tw.firemaples.onscreenocr.R +//import tw.firemaples.onscreenocr.databinding.FloatingMainBarBinding +//import tw.firemaples.onscreenocr.floatings.base.MovableFloatingView +//import tw.firemaples.onscreenocr.floatings.history.VersionHistoryView +//import tw.firemaples.onscreenocr.floatings.manager.NavState +//import tw.firemaples.onscreenocr.floatings.manager.StateNavigator +//import tw.firemaples.onscreenocr.floatings.menu.MenuView +//import tw.firemaples.onscreenocr.floatings.readme.ReadmeView +//import tw.firemaples.onscreenocr.floatings.translationSelectPanel.TranslationSelectPanel +//import tw.firemaples.onscreenocr.log.FirebaseEvent +//import tw.firemaples.onscreenocr.pages.setting.SettingActivity +//import tw.firemaples.onscreenocr.pages.setting.SettingManager +//import tw.firemaples.onscreenocr.pref.AppPref +//import tw.firemaples.onscreenocr.utils.Utils +//import tw.firemaples.onscreenocr.utils.clickOnce +//import tw.firemaples.onscreenocr.utils.hide +//import tw.firemaples.onscreenocr.utils.show +//import tw.firemaples.onscreenocr.utils.showOrHide +//import javax.inject.Inject +// +//class MainBar @Inject constructor( +// @ApplicationContext context: Context, +// private val stateNavigator: StateNavigator, +// private val viewModel: MainBarViewModel, +//) : MovableFloatingView(context) { +// +// override val layoutId: Int +// get() = R.layout.floating_main_bar +// +// override val initialPosition: Point +// get() = +// if (SettingManager.restoreMainBarPosition) AppPref.lastMainBarPosition +// else Point(0, 0) +// +// override val enableDeviceDirectionTracker: Boolean +// get() = true +// +// override val moveToEdgeAfterMoved: Boolean +// get() = true +// +// override val fadeOutAfterMoved: Boolean +// get() = !arrayOf(NavState.ScreenCircling, NavState.ScreenCircled) +// .contains(stateNavigator.currentNavState.value) +// && !menuView.attached +// && SettingManager.enableFadingOutWhileIdle +// override val fadeOutDelay: Long +// get() = SettingManager.timeoutToFadeOut +// override val fadeOutDestinationAlpha: Float +// get() = SettingManager.opaquePercentageToFadeOut +// +// private val binding: FloatingMainBarBinding = FloatingMainBarBinding.bind(rootLayout) +// +// private val menuView: MenuView by lazy { +// MenuView(context, false).apply { +// setAnchor(binding.btMenu) +// +// onAttached = { rescheduleFadeOut() } +// onDetached = { rescheduleFadeOut() } +// onItemSelected = { view, key -> +// view.detachFromScreen() +// viewModel.onMenuItemClicked(key) +// rescheduleFadeOut() +// } +// } +// } +// +// init { +// binding.setViews() +// setDragView(binding.btMenu) +// } +// +// private fun FloatingMainBarBinding.setViews() { +// btLangSelector.clickOnce { +// rescheduleFadeOut() +// TranslationSelectPanel(context).attachToScreen() +// } +// +// btSelect.clickOnce { +// viewModel.onSelectClicked() +// } +// +// btTranslate.clickOnce { +// FirebaseEvent.logClickTranslationStartButton() +// viewModel.onTranslateClicked() +// } +// +// btClose.clickOnce { +// viewModel.onCloseClicked() +// } +// +// btMenu.clickOnce { +// viewModel.onMenuButtonClicked() +// } +// +// viewModel.languageText.observe(lifecycleOwner) { +// tvLang.text = it +// moveToEdgeIfEnabled() +// } +// +// viewModel.displayTranslatorIcon.observe(lifecycleOwner) { +// if (it == null) { +// ivGoogleTranslator.setImageDrawable(null) +// ivGoogleTranslator.hide() +// } else { +// ivGoogleTranslator.setImageResource(it) +// ivGoogleTranslator.show() +// } +// moveToEdgeIfEnabled() +// } +// +// viewModel.displaySelectButton.observe(lifecycleOwner) { +// btSelect.showOrHide(it) +// moveToEdgeIfEnabled() +// } +// +// viewModel.displayTranslateButton.observe(lifecycleOwner) { +// btTranslate.showOrHide(it) +// moveToEdgeIfEnabled() +// } +// +// viewModel.displayCloseButton.observe(lifecycleOwner) { +// btClose.showOrHide(it) +// moveToEdgeIfEnabled() +// } +// +// viewModel.displayMenuItems.observe(lifecycleOwner) { +// with(menuView) { +// updateData(it) +// attachToScreen() +// } +// } +// +// viewModel.rescheduleFadeOut.observe(lifecycleOwner) { +// rescheduleFadeOut() +// } +// +// viewModel.showSettingPage.observe(lifecycleOwner) { +// SettingActivity.start(context) +// } +// +// viewModel.openBrowser.observe(lifecycleOwner) { +// Utils.openBrowser(it) +// } +// +// viewModel.showVersionHistory.observe(lifecycleOwner) { +// VersionHistoryView(context).attachToScreen() +// } +// +// viewModel.showReadme.observe(lifecycleOwner) { +// ReadmeView(context).attachToScreen() +// } +// } +// +// override fun onAttachedToScreen() { +// super.onAttachedToScreen() +// viewModel.onAttachedToScreen() +// } +// +// override fun onDetachedFromScreen() { +// super.onDetachedFromScreen() +// viewModel.saveLastPosition(params.x, params.y) +// } +//} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/main/MainBarViewModel.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/main/MainBarViewModel.kt index c4400d76..52438713 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/floatings/main/MainBarViewModel.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/main/MainBarViewModel.kt @@ -1,231 +1,261 @@ -package tw.firemaples.onscreenocr.floatings.main - -import android.content.Context -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import tw.firemaples.onscreenocr.R -import tw.firemaples.onscreenocr.floatings.ViewHolderService -import tw.firemaples.onscreenocr.floatings.base.FloatingViewModel -import tw.firemaples.onscreenocr.floatings.manager.FloatingStateManager -import tw.firemaples.onscreenocr.floatings.manager.State -import tw.firemaples.onscreenocr.pref.AppPref -import tw.firemaples.onscreenocr.recognition.TextRecognizer -import tw.firemaples.onscreenocr.remoteconfig.RemoteConfigManager -import tw.firemaples.onscreenocr.repo.GeneralRepository -import tw.firemaples.onscreenocr.repo.OCRRepository -import tw.firemaples.onscreenocr.repo.TranslationRepository -import tw.firemaples.onscreenocr.translator.TranslationProviderType -import tw.firemaples.onscreenocr.utils.Constants -import tw.firemaples.onscreenocr.utils.Logger -import tw.firemaples.onscreenocr.utils.SingleLiveEvent -import tw.firemaples.onscreenocr.utils.Utils - -class MainBarViewModel(viewScope: CoroutineScope) : FloatingViewModel(viewScope) { - companion object { - private const val MENU_SETTING = "setting" - private const val MENU_PRIVACY_POLICY = "privacy_policy" - private const val MENU_ABOUT = "about" - private const val MENU_VERSION_HISTORY = "version_history" - private const val MENU_README = "readme" - private const val MENU_HIDE = "hide" - private const val MENU_EXIT = "exit" - } - - private val _languageText = MutableLiveData() - val languageText: LiveData = _languageText - - private val _displayTranslatorIcon = MutableLiveData() - val displayTranslatorIcon: LiveData = _displayTranslatorIcon - - private val _displaySelectButton = MutableLiveData() - val displaySelectButton: LiveData = _displaySelectButton - - private val _displayTranslateButton = MutableLiveData() - val displayTranslateButton: LiveData = _displayTranslateButton - - private val _displayCloseButton = MutableLiveData() - val displayCloseButton: LiveData = _displayCloseButton - - private val _displayMenuItems = MutableLiveData>() - val displayMenuItems: LiveData> = _displayMenuItems - - private val _rescheduleFadeOut = MutableLiveData() - val rescheduleFadeOut: LiveData = _rescheduleFadeOut - - private val _showSettingPage = SingleLiveEvent() - val showSettingPage: LiveData = _showSettingPage - - private val _openBrowser = SingleLiveEvent() - val openBrowser: LiveData = _openBrowser - - private val _showVersionHistory = SingleLiveEvent() - val showVersionHistory: LiveData = _showVersionHistory - - private val _showReadme = SingleLiveEvent() - val showReadme: LiveData = _showReadme - - private val logger: Logger by lazy { Logger(MainBarViewModel::class) } - private val context: Context by lazy { Utils.context } - - private val menuItems = mapOf( - MENU_SETTING to context.getString(R.string.menu_setting), - MENU_PRIVACY_POLICY to context.getString(R.string.menu_privacy_policy), - MENU_ABOUT to context.getString(R.string.menu_about), - MENU_VERSION_HISTORY to context.getString(R.string.menu_version_history), - MENU_README to context.getString(R.string.menu_readme), - MENU_HIDE to context.getString(R.string.menu_hide), - MENU_EXIT to context.getString(R.string.menu_exit), - ) - - private val repo by lazy { GeneralRepository() } - private val ocrRepo by lazy { OCRRepository() } - private val translateRepo by lazy { TranslationRepository() } - - private var _selectedOCRLang: String = Constants.DEFAULT_OCR_LANG - val selectedOCRLang: String get() = _selectedOCRLang - - // private var selectedTranslationProvider: TranslationProvider = -// TranslationProvider.fromType(context, Constraints.DEFAULT_TRANSLATION_PROVIDER) - private var selectedTranslationProviderType: TranslationProviderType = - Constants.DEFAULT_TRANSLATION_PROVIDER - private var selectedTranslationLang: String = Constants.DEFAULT_TRANSLATION_LANG - - fun onAttachedToScreen() { - logger.debug("onAttachedToScreen()") - viewScope.launch { - logger.debug("register FloatingStateManager.onStateChanged") - FloatingStateManager.currentStateFlow.collect { onStateChanged(it) } - } - viewScope.launch { - ocrRepo.selectedOCRLangFlow.collect { onSelectedLangChanged(_ocrLang = it) } - } - viewScope.launch { - translateRepo.selectedProviderTypeFlow.collect { - onSelectedLangChanged(translationProviderType = it) - } - } - viewScope.launch { - translateRepo.selectedTranslationLangFlow.collect { - onSelectedLangChanged(translationLang = it) - } - } - viewScope.launch { - setupButtons(FloatingStateManager.currentState) - - if (!repo.isReadmeAlreadyShown().first()) { - _showReadme.value = true - } - - if (repo.showVersionHistory().first()) { - _showVersionHistory.value = true - } - } - } - - private suspend fun onStateChanged(state: State) { - logger.debug("onStateChanged(): $state") - setupButtons(state) - _rescheduleFadeOut.value = true - } - - @Suppress("RedundantSuspendModifier") - private suspend fun setupButtons(state: State) { - logger.debug("setupButtons(): $state") - _displaySelectButton.value = state == State.Idle - _displayTranslateButton.value = state == State.ScreenCircled - _displayCloseButton.value = - state == State.ScreenCircling || state == State.ScreenCircled - } - - @Suppress("RedundantSuspendModifier") - private suspend fun onSelectedLangChanged( - _ocrLang: String = _selectedOCRLang, - translationProviderType: TranslationProviderType = selectedTranslationProviderType, - translationLang: String = selectedTranslationLang, - ) { - this._selectedOCRLang = _ocrLang - this.selectedTranslationProviderType = translationProviderType - this.selectedTranslationLang = translationLang - - logger.debug("onSelectedLangChanged(), ocrLang: $_ocrLang, provider: $translationProviderType, translationLang: $translationLang") - - val ocrLang = TextRecognizer - .getRecognizer(AppPref.selectedOCRProvider) - .parseToDisplayLangCode(_ocrLang) - - _displayTranslatorIcon.value = when (translationProviderType) { - TranslationProviderType.GoogleTranslateApp -> R.drawable.ic_google_translate_dark_grey - TranslationProviderType.BingTranslateApp -> R.drawable.ic_microsoft_bing - TranslationProviderType.OtherTranslateApp -> R.drawable.ic_open_in_app - TranslationProviderType.MicrosoftAzure, - TranslationProviderType.GoogleMLKit, - TranslationProviderType.MyMemory, - TranslationProviderType.PapagoTranslateApp, - TranslationProviderType.YandexTranslateApp, - TranslationProviderType.OCROnly -> null - } - - _languageText.value = when (translationProviderType) { - TranslationProviderType.GoogleTranslateApp, - TranslationProviderType.BingTranslateApp, - TranslationProviderType.OtherTranslateApp -> "$ocrLang>" - - TranslationProviderType.YandexTranslateApp -> "$ocrLang > Y" - TranslationProviderType.PapagoTranslateApp -> "$ocrLang > P" - TranslationProviderType.OCROnly -> " $ocrLang " - TranslationProviderType.MicrosoftAzure, - TranslationProviderType.GoogleMLKit, - TranslationProviderType.MyMemory -> "$ocrLang>$translationLang" - } - } - - fun onMenuButtonClicked() { - viewScope.launch { - _rescheduleFadeOut.value = true - _displayMenuItems.value = menuItems - } - } - - fun onMenuItemClicked(action: String) { - logger.debug("onMenuItemClicked(), action: $action") - - when (action) { - MENU_SETTING -> { - _showSettingPage.value = true - } - - MENU_PRIVACY_POLICY -> { - _openBrowser.value = RemoteConfigManager.privacyPolicyUrl - } - - MENU_ABOUT -> { - _openBrowser.value = RemoteConfigManager.aboutUrl - } - - MENU_VERSION_HISTORY -> { - _showVersionHistory.value = true - } - - MENU_README -> { - _showReadme.value = true - } - - MENU_HIDE -> { - ViewHolderService.hideViews(context) - } - - MENU_EXIT -> { - ViewHolderService.exit(context) - } - } - } - - fun saveLastPosition(x: Int, y: Int) { - viewScope.launch { - repo.saveLastMainBarPosition(x, y) - } - } -} \ No newline at end of file +//package tw.firemaples.onscreenocr.floatings.main +// +//import android.content.Context +//import androidx.lifecycle.LiveData +//import androidx.lifecycle.MutableLiveData +//import kotlinx.coroutines.CoroutineScope +//import kotlinx.coroutines.flow.first +//import kotlinx.coroutines.flow.launchIn +//import kotlinx.coroutines.flow.onEach +//import kotlinx.coroutines.launch +//import tw.firemaples.onscreenocr.R +//import tw.firemaples.onscreenocr.di.MainImmediateCoroutineScope +//import tw.firemaples.onscreenocr.floatings.ViewHolderService +//import tw.firemaples.onscreenocr.floatings.base.FloatingViewModel +//import tw.firemaples.onscreenocr.floatings.manager.NavState +//import tw.firemaples.onscreenocr.floatings.manager.NavigationAction +//import tw.firemaples.onscreenocr.floatings.manager.StateNavigator +//import tw.firemaples.onscreenocr.pref.AppPref +//import tw.firemaples.onscreenocr.recognition.TextRecognizer +//import tw.firemaples.onscreenocr.remoteconfig.RemoteConfigManager +//import tw.firemaples.onscreenocr.repo.GeneralRepository +//import tw.firemaples.onscreenocr.repo.OCRRepository +//import tw.firemaples.onscreenocr.repo.TranslationRepository +//import tw.firemaples.onscreenocr.translator.TranslationProviderType +//import tw.firemaples.onscreenocr.utils.Constants +//import tw.firemaples.onscreenocr.utils.Logger +//import tw.firemaples.onscreenocr.utils.SingleLiveEvent +//import tw.firemaples.onscreenocr.utils.Utils +//import javax.inject.Inject +// +//class MainBarViewModel @Inject constructor( +// @MainImmediateCoroutineScope viewScope: CoroutineScope, +// private val stateNavigator: StateNavigator, +//) : FloatingViewModel(viewScope) { +// +// companion object { +// private const val MENU_SETTING = "setting" +// private const val MENU_PRIVACY_POLICY = "privacy_policy" +// private const val MENU_ABOUT = "about" +// private const val MENU_VERSION_HISTORY = "version_history" +// private const val MENU_README = "readme" +// private const val MENU_HIDE = "hide" +// private const val MENU_EXIT = "exit" +// } +// +// private val _languageText = MutableLiveData() +// val languageText: LiveData = _languageText +// +// private val _displayTranslatorIcon = MutableLiveData() +// val displayTranslatorIcon: LiveData = _displayTranslatorIcon +// +// private val _displaySelectButton = MutableLiveData() +// val displaySelectButton: LiveData = _displaySelectButton +// +// private val _displayTranslateButton = MutableLiveData() +// val displayTranslateButton: LiveData = _displayTranslateButton +// +// private val _displayCloseButton = MutableLiveData() +// val displayCloseButton: LiveData = _displayCloseButton +// +// private val _displayMenuItems = MutableLiveData>() +// val displayMenuItems: LiveData> = _displayMenuItems +// +// private val _rescheduleFadeOut = MutableLiveData() +// val rescheduleFadeOut: LiveData = _rescheduleFadeOut +// +// private val _showSettingPage = SingleLiveEvent() +// val showSettingPage: LiveData = _showSettingPage +// +// private val _openBrowser = SingleLiveEvent() +// val openBrowser: LiveData = _openBrowser +// +// private val _showVersionHistory = SingleLiveEvent() +// val showVersionHistory: LiveData = _showVersionHistory +// +// private val _showReadme = SingleLiveEvent() +// val showReadme: LiveData = _showReadme +// +// private val logger: Logger by lazy { Logger(MainBarViewModel::class) } +// private val context: Context by lazy { Utils.context } +// +// private val menuItems = mapOf( +// MENU_SETTING to context.getString(R.string.menu_setting), +// MENU_PRIVACY_POLICY to context.getString(R.string.menu_privacy_policy), +// MENU_ABOUT to context.getString(R.string.menu_about), +// MENU_VERSION_HISTORY to context.getString(R.string.menu_version_history), +// MENU_README to context.getString(R.string.menu_readme), +// MENU_HIDE to context.getString(R.string.menu_hide), +// MENU_EXIT to context.getString(R.string.menu_exit), +// ) +// +// private val repo by lazy { GeneralRepository() } +// private val ocrRepo by lazy { OCRRepository() } +// private val translateRepo by lazy { TranslationRepository() } +// +// private var _selectedOCRLang: String = Constants.DEFAULT_OCR_LANG +// val selectedOCRLang: String get() = _selectedOCRLang +// +// // private var selectedTranslationProvider: TranslationProvider = +//// TranslationProvider.fromType(context, Constraints.DEFAULT_TRANSLATION_PROVIDER) +// private var selectedTranslationProviderType: TranslationProviderType = +// Constants.DEFAULT_TRANSLATION_PROVIDER +// private var selectedTranslationLang: String = Constants.DEFAULT_TRANSLATION_LANG +// +// init { +// logger.debug("register FloatingStateManager.onStateChanged") +// stateNavigator.currentNavState +// .onEach { onStateChanged(it) } +// .launchIn(viewScope) +// } +// +// fun onAttachedToScreen() { +// logger.debug("onAttachedToScreen()") +// viewScope.launch { +// ocrRepo.selectedOCRLangFlow.collect { onSelectedLangChanged(_ocrLang = it) } +// } +// viewScope.launch { +// translateRepo.selectedProviderTypeFlow.collect { +// onSelectedLangChanged(translationProviderType = it) +// } +// } +// viewScope.launch { +// translateRepo.selectedTranslationLangFlow.collect { +// onSelectedLangChanged(translationLang = it) +// } +// } +// viewScope.launch { +//// setupButtons(floatingStateManager.currentState) +// +// if (!repo.isReadmeAlreadyShown().first()) { +// _showReadme.value = true +// } +// +// if (repo.showVersionHistory().first()) { +// _showVersionHistory.value = true +// } +// } +// } +// +// private suspend fun onStateChanged(state: NavState) { +// logger.debug("onStateChanged(): $state") +// setupButtons(state) +// _rescheduleFadeOut.value = true +// } +// +// @Suppress("RedundantSuspendModifier") +// private suspend fun setupButtons(state: NavState) { +// logger.debug("setupButtons(): $state") +// _displaySelectButton.value = state == NavState.Idle +// _displayTranslateButton.value = state == NavState.ScreenCircled +// _displayCloseButton.value = +// state == NavState.ScreenCircling || state == NavState.ScreenCircled +// } +// +// @Suppress("RedundantSuspendModifier") +// private suspend fun onSelectedLangChanged( +// _ocrLang: String = _selectedOCRLang, +// translationProviderType: TranslationProviderType = selectedTranslationProviderType, +// translationLang: String = selectedTranslationLang, +// ) { +// this._selectedOCRLang = _ocrLang +// this.selectedTranslationProviderType = translationProviderType +// this.selectedTranslationLang = translationLang +// +// logger.debug("onSelectedLangChanged(), ocrLang: $_ocrLang, provider: $translationProviderType, translationLang: $translationLang") +// +// val ocrLang = TextRecognizer +// .getRecognizer(AppPref.selectedOCRProvider) +// .parseToDisplayLangCode(_ocrLang) +// +// _displayTranslatorIcon.value = when (translationProviderType) { +// TranslationProviderType.GoogleTranslateApp -> R.drawable.ic_google_translate_dark_grey +// TranslationProviderType.BingTranslateApp -> R.drawable.ic_microsoft_bing +// TranslationProviderType.OtherTranslateApp -> R.drawable.ic_open_in_app +// TranslationProviderType.MicrosoftAzure, +// TranslationProviderType.GoogleMLKit, +// TranslationProviderType.MyMemory, +// TranslationProviderType.PapagoTranslateApp, +// TranslationProviderType.YandexTranslateApp, +// TranslationProviderType.OCROnly -> null +// } +// +// _languageText.value = when (translationProviderType) { +// TranslationProviderType.GoogleTranslateApp, +// TranslationProviderType.BingTranslateApp, +// TranslationProviderType.OtherTranslateApp -> "$ocrLang>" +// +// TranslationProviderType.YandexTranslateApp -> "$ocrLang > Y" +// TranslationProviderType.PapagoTranslateApp -> "$ocrLang > P" +// TranslationProviderType.OCROnly -> " $ocrLang " +// TranslationProviderType.MicrosoftAzure, +// TranslationProviderType.GoogleMLKit, +// TranslationProviderType.MyMemory -> "$ocrLang>$translationLang" +// } +// } +// +// fun onMenuButtonClicked() { +// viewScope.launch { +// _rescheduleFadeOut.value = true +// _displayMenuItems.value = menuItems +// } +// } +// +// fun onMenuItemClicked(action: String) { +// logger.debug("onMenuItemClicked(), action: $action") +// +// when (action) { +// MENU_SETTING -> { +// _showSettingPage.value = true +// } +// +// MENU_PRIVACY_POLICY -> { +// _openBrowser.value = RemoteConfigManager.privacyPolicyUrl +// } +// +// MENU_ABOUT -> { +// _openBrowser.value = RemoteConfigManager.aboutUrl +// } +// +// MENU_VERSION_HISTORY -> { +// _showVersionHistory.value = true +// } +// +// MENU_README -> { +// _showReadme.value = true +// } +// +// MENU_HIDE -> { +// ViewHolderService.hideViews(context) +// } +// +// MENU_EXIT -> { +// ViewHolderService.exit(context) +// } +// } +// } +// +// fun saveLastPosition(x: Int, y: Int) { +// viewScope.launch { +// repo.saveLastMainBarPosition(x, y) +// } +// } +// +// fun onSelectClicked() { +// viewScope.launch { +// stateNavigator.navigate(NavigationAction.NavigateToScreenCircling) +// } +// } +// +// fun onTranslateClicked() { +// viewScope.launch { +// stateNavigator.navigate(NavigationAction.NavigateToScreenCapturing) +// } +// } +// +// fun onCloseClicked() { +// viewScope.launch { +// stateNavigator.navigate(NavigationAction.CancelScreenCircling) +// } +// } +//} \ No newline at end of file diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/FloatingStateManager.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/FloatingStateManager.kt deleted file mode 100644 index fd694ded..00000000 --- a/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/FloatingStateManager.kt +++ /dev/null @@ -1,398 +0,0 @@ -package tw.firemaples.onscreenocr.floatings.manager - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Rect -import java.io.IOException -import kotlin.reflect.KClass -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import tw.firemaples.onscreenocr.R -import tw.firemaples.onscreenocr.floatings.base.FloatingView -import tw.firemaples.onscreenocr.floatings.dialog.showErrorDialog -import tw.firemaples.onscreenocr.floatings.main.MainBar -import tw.firemaples.onscreenocr.floatings.result.ResultView -import tw.firemaples.onscreenocr.floatings.screenCircling.ScreenCirclingView -import tw.firemaples.onscreenocr.log.FirebaseEvent -import tw.firemaples.onscreenocr.pages.setting.SettingManager -import tw.firemaples.onscreenocr.pref.AppPref -import tw.firemaples.onscreenocr.recognition.RecognitionResult -import tw.firemaples.onscreenocr.recognition.TextRecognitionProviderType -import tw.firemaples.onscreenocr.recognition.TextRecognizer -import tw.firemaples.onscreenocr.screenshot.ScreenExtractor -import tw.firemaples.onscreenocr.translator.azure.MicrosoftAzureTranslator -import tw.firemaples.onscreenocr.translator.TranslationProviderType -import tw.firemaples.onscreenocr.translator.TranslationResult -import tw.firemaples.onscreenocr.translator.Translator -import tw.firemaples.onscreenocr.utils.Constants -import tw.firemaples.onscreenocr.utils.Logger -import tw.firemaples.onscreenocr.utils.Utils -import tw.firemaples.onscreenocr.utils.setReusable - -object FloatingStateManager { - private val logger: Logger by lazy { Logger(FloatingStateManager::class) } - private val context: Context by lazy { Utils.context } - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - - val currentStateFlow = MutableStateFlow(State.Idle) - val currentState: State - get() = currentStateFlow.value - - private val mainBar: MainBar by lazy { MainBar(context) } - private val screenCirclingView: ScreenCirclingView by lazy { - ScreenCirclingView(context).apply { - onAreaSelected = { parent, selected -> - this@FloatingStateManager.onAreaSelected(parent, selected) - } - } - } - private val resultView: ResultView by lazy { - ResultView(context).apply { - onUserDismiss = { - this@FloatingStateManager.backToIdle() - } - } - } - - val showingStateChangedFlow = MutableStateFlow(false) - val isMainBarAttached: Boolean - get() = mainBar.attached - - private var selectedOCRLang: String = Constants.DEFAULT_OCR_LANG - private val selectedOCRProvider: TextRecognitionProviderType get() = AppPref.selectedOCRProvider - private var parentRect: Rect? = null - private var selectedRect: Rect? = null - private var croppedBitmap: Bitmap? = null - - fun showMainBar() { - if (isMainBarAttached) return - mainBar.attachToScreen() - scope.launch { - showingStateChangedFlow.emit(true) - } - } - - private fun hideMainBar() { - if (!isMainBarAttached) return - mainBar.detachFromScreen() - scope.launch { - showingStateChangedFlow.emit(false) - } - } - - private fun arrangeMainBarToTop() { - mainBar.detachFromScreen() - mainBar.attachToScreen() - } - - fun detachAllViews() { - backToIdle() - scope.launch { - hideMainBar() - FloatingView.detachAllFloatingViews() - } - } - - fun startScreenCircling() = stateIn(State.Idle::class) { - if (!Translator.getTranslator().checkEnvironment(scope)) { - return@stateIn - } - - logger.debug("startScreenCircling()") - changeState(State.ScreenCircling) - FirebaseEvent.logStartAreaSelection() - screenCirclingView.attachToScreen() - arrangeMainBarToTop() - } - - private fun onAreaSelected(parentRect: Rect, selectedRect: Rect) = - stateIn(State.ScreenCircling::class, State.ScreenCircled::class) { - logger.debug("onAreaSelected(), parentRect: $parentRect, selectedRect: $selectedRect, size: ${selectedRect.width()}x${selectedRect.height()}") - if (currentState != State.ScreenCircled) { - changeState(State.ScreenCircled) - } - this@FloatingStateManager.selectedRect = selectedRect - this@FloatingStateManager.parentRect = parentRect - } - - fun cancelScreenCircling() = stateIn(State.ScreenCircling::class, State.ScreenCircled::class) { - logger.debug("cancelScreenCircling()") - changeState(State.Idle) - screenCirclingView.detachFromScreen() - } - - fun startScreenCapturing(selectedOCRLang: String) = stateIn(State.ScreenCircled::class) { - if (!Translator.getTranslator().checkEnvironment(scope)) { - return@stateIn - } - - this@FloatingStateManager.selectedOCRLang = selectedOCRLang - val parent = parentRect ?: return@stateIn - val selected = selectedRect ?: return@stateIn - logger.debug("startScreenCapturing(), parentRect: $parent, selectedRect: $selected") - changeState(State.ScreenCapturing) - mainBar.detachFromScreen() - screenCirclingView.detachFromScreen() - - delay(100L) - - try { - FirebaseEvent.logStartCaptureScreen() - val croppedBitmap = - ScreenExtractor.extractBitmapFromScreen(parentRect = parent, cropRect = selected) - this@FloatingStateManager.croppedBitmap = croppedBitmap - FirebaseEvent.logCaptureScreenFinished() - - mainBar.attachToScreen() - - startRecognition(croppedBitmap, parent, selected) - } catch (t: TimeoutCancellationException) { - logger.debug(t = t) - showError(context.getString(R.string.error_capture_screen_timeout)) - FirebaseEvent.logCaptureScreenFailed(t) - } catch (t: Throwable) { - logger.debug(t = t) - showError(t.message ?: context.getString(R.string.error_unknown_error_capturing_screen)) - FirebaseEvent.logCaptureScreenFailed(t) - } -// screenCirclingView.detachFromScreen() // To test circled area - } - - private fun startRecognition(croppedBitmap: Bitmap, parent: Rect, selected: Rect) = - stateIn(State.ScreenCapturing::class) { - changeState(State.TextRecognizing) - try { - resultView.startRecognition() - val recognizer = TextRecognizer.getRecognizer(selectedOCRProvider) - FirebaseEvent.logStartOCR(recognizer.name) - var result = withContext(Dispatchers.Default) { - recognizer.recognize( - TextRecognizer.getLanguage(selectedOCRLang, selectedOCRProvider)!!, - croppedBitmap - ) - } - logger.debug("On text recognized: $result") -// croppedBitmap.recycle() // to be used in the text editor view - if (SettingManager.removeSpacesInCJK) { - val cjkLang = arrayOf("zh", "ja", "ko") - if (cjkLang.contains(selectedOCRLang.split("-").getOrNull(0))) { - result = result.copy( - result = result.result.replace(" ", "") - ) - } - logger.debug("Remove CJK spaces: $result") - } - FirebaseEvent.logOCRFinished(recognizer.name) - resultView.textRecognized(result, parent, selected, croppedBitmap) - startTranslation(result) - } catch (e: Exception) { - val error = - if (e.message?.contains(Constants.errorInputImageIsTooSmall) == true) { - context.getString(R.string.error_selected_area_too_small) - } else - e.message - ?: context.getString(R.string.error_an_unknown_error_found_while_recognition_text) - - logger.warn(t = e) - showError(error) - FirebaseEvent.logOCRFailed( - TextRecognizer.getRecognizer(selectedOCRProvider).name, e - ) - } - } - - fun startTranslation(recognitionResult: RecognitionResult) = - stateIn(State.TextRecognizing::class, State.ResultDisplaying::class) { - try { - changeState(State.TextTranslating) - - val translator = Translator.getTranslator() - - resultView.startTranslation(translator.type) - - FirebaseEvent.logStartTranslationText( - recognitionResult.result, - recognitionResult.langCode, - translator - ) - - val translationResult = translator - .translate(recognitionResult.result, recognitionResult.langCode) - - when (translationResult) { - TranslationResult.OuterTranslatorLaunched -> { - FirebaseEvent.logTranslationTextFinished(translator) - backToIdle() - } - - is TranslationResult.SourceLangNotSupport -> { - FirebaseEvent.logTranslationSourceLangNotSupport( - translator, recognitionResult.langCode, - ) - showResult( - Result.SourceLangNotSupport( - ocrText = recognitionResult.result, - boundingBoxes = recognitionResult.boundingBoxes, - providerType = translationResult.type, - ) - ) - } - - TranslationResult.OCROnlyResult -> { - FirebaseEvent.logTranslationTextFinished(translator) - showResult( - Result.OCROnly( - ocrText = recognitionResult.result, - boundingBoxes = recognitionResult.boundingBoxes, - ) - ) - } - - is TranslationResult.TranslatedResult -> { - FirebaseEvent.logTranslationTextFinished(translator) - showResult( - Result.Translated( - ocrText = recognitionResult.result, - boundingBoxes = recognitionResult.boundingBoxes, - translatedText = translationResult.result, - providerType = translationResult.type, - ) - ) - } - - is TranslationResult.TranslationFailed -> { - FirebaseEvent.logTranslationTextFailed(translator) - val error = translationResult.error - - if (error is MicrosoftAzureTranslator.Error) { - FirebaseEvent.logMicrosoftTranslationError(error) - } - - if (error is IOException) { - showError(context.getString(R.string.error_can_not_connect_to_translation_server)) - } else { - FirebaseEvent.logException(error) - showError( - error.localizedMessage - ?: context.getString(R.string.error_unknown) - ) - } - } - } - } catch (e: Exception) { - logger.warn(t = e) - FirebaseEvent.logException(e) - showError(e.message ?: "Unknown error found while translating") - } - } - - private fun showResult(result: Result) = - stateIn(State.TextTranslating::class) { - logger.debug("showResult(), $result") - changeState(State.ResultDisplaying) - - resultView.textTranslated(result) - } - - private fun showError(error: String) { - scope.launch { - changeState(State.ErrorDisplaying(error)) - logger.error(error) - context.showErrorDialog(error) - backToIdle() - } - } - - private fun backToIdle() = - scope.launch { - if (currentState != State.Idle) changeState(State.Idle) - croppedBitmap?.setReusable() - resultView.backToIdle() - showMainBar() - } - - private fun stateIn( - vararg states: KClass, - block: suspend CoroutineScope.() -> Unit - ) { - if (states.contains(currentState::class)) { - scope.launch { block.invoke(this) } - } else logger.error(t = IllegalStateException("The state should be in ${states.toList()}, current is $currentState")) - } - - private fun changeState(newState: State) { - val allowedNextStates = when (currentState) { - State.Idle -> arrayOf(State.ScreenCircling::class) - State.ScreenCircling -> arrayOf(State.Idle::class, State.ScreenCircled::class) - State.ScreenCircled -> arrayOf(State.Idle::class, State.ScreenCapturing::class) - State.ScreenCapturing -> - arrayOf( - State.Idle::class, State.TextRecognizing::class, State.ErrorDisplaying::class - ) - - State.TextRecognizing -> - arrayOf( - State.Idle::class, State.TextTranslating::class, State.ErrorDisplaying::class - ) - - State.TextTranslating -> - arrayOf( - State.ResultDisplaying::class, State.ErrorDisplaying::class, State.Idle::class - ) - - State.ResultDisplaying -> arrayOf(State.Idle::class, State.TextTranslating::class) - is State.ErrorDisplaying -> arrayOf(State.Idle::class) - } - - if (allowedNextStates.contains(newState::class)) { - logger.debug("Change state $currentState > $newState") - currentStateFlow.value = newState - } else { - logger.error("Change state from $currentState to $newState is not allowed") - } - } -} - -sealed class State { - override fun toString(): String { - return this::class.simpleName ?: super.toString() - } - - object Idle : State() - object ScreenCircling : State() - object ScreenCircled : State() - object ScreenCapturing : State() - object TextRecognizing : State() - object TextTranslating : State() - object ResultDisplaying : State() - data class ErrorDisplaying(val error: String) : State() -} - -sealed class Result( - open val ocrText: String, - open val boundingBoxes: List, -) { - data class Translated( - override val ocrText: String, - override val boundingBoxes: List, - val translatedText: String, - val providerType: TranslationProviderType, - ) : Result(ocrText, boundingBoxes) - - data class SourceLangNotSupport( - override val ocrText: String, - override val boundingBoxes: List, - val providerType: TranslationProviderType, - ) : Result(ocrText, boundingBoxes) - - data class OCROnly( - override val ocrText: String, - override val boundingBoxes: List, - ) : Result(ocrText, boundingBoxes) -} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/FloatingViewCoordinator.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/FloatingViewCoordinator.kt new file mode 100644 index 00000000..e863adda --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/FloatingViewCoordinator.kt @@ -0,0 +1,105 @@ +package tw.firemaples.onscreenocr.floatings.manager + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import tw.firemaples.onscreenocr.di.MainImmediateCoroutineScope +import tw.firemaples.onscreenocr.floatings.base.FloatingView +import tw.firemaples.onscreenocr.floatings.compose.mainbar.MainBarFloatingView +import tw.firemaples.onscreenocr.floatings.compose.resultview.ResultViewFloatingView +import tw.firemaples.onscreenocr.floatings.dialog.showErrorDialog +import tw.firemaples.onscreenocr.floatings.screenCircling.ScreenCirclingView +import tw.firemaples.onscreenocr.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FloatingViewCoordinator @Inject constructor( + @ApplicationContext private val context: Context, + @MainImmediateCoroutineScope private val scope: CoroutineScope, + private val stateNavigator: StateNavigator, + stateOperator: StateOperator, + private val mainBar: MainBarFloatingView, + private val resultView: ResultViewFloatingView, +) { + private val logger: Logger by lazy { Logger(FloatingViewCoordinator::class) } + + private val screenCirclingView: ScreenCirclingView by lazy { + ScreenCirclingView(context).apply { + onAreaSelected = { parent, selected -> + scope.launch { + stateNavigator.navigate( + NavigationAction.NavigateToScreenCircled( + parentRect = parent, + selectedRect = selected, + ) + ) + } + } + } + } + + val showingStateChangedFlow = MutableStateFlow(false) + val isMainBarAttached: Boolean + get() = mainBar.attached + + init { + stateOperator.action + .onEach { action -> + when (action) { + StateOperatorAction.TopMainBar -> arrangeMainBarToTop() + StateOperatorAction.HideMainBar -> hideMainBar() + StateOperatorAction.ShowMainBar -> showMainBar() + + StateOperatorAction.ShowScreenCirclingView -> + screenCirclingView.attachToScreen() + + StateOperatorAction.HideScreenCirclingView -> + screenCirclingView.detachFromScreen() + + StateOperatorAction.ShowResultView -> + resultView.attachToScreen() + + StateOperatorAction.HideResultView -> + resultView.detachFromScreen() + + is StateOperatorAction.ShowErrorDialog -> + context.showErrorDialog(action.error) + } + } + .launchIn(scope) + } + + fun showMainBar() { + if (isMainBarAttached) return + mainBar.attachToScreen() + scope.launch { + showingStateChangedFlow.emit(true) + } + } + + private fun hideMainBar() { + if (!isMainBarAttached) return + mainBar.detachFromScreen() + scope.launch { + showingStateChangedFlow.emit(false) + } + } + + private fun arrangeMainBarToTop() { + mainBar.detachFromScreen() + mainBar.attachToScreen() + } + + fun detachAllViews() { + scope.launch { + stateNavigator.navigate(NavigationAction.NavigateToIdle(showMainBar = false)) + hideMainBar() + FloatingView.detachAllFloatingViews() + } + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/StateNavigator.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/StateNavigator.kt new file mode 100644 index 00000000..3bf87d67 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/StateNavigator.kt @@ -0,0 +1,186 @@ +package tw.firemaples.onscreenocr.floatings.manager + +import android.graphics.Bitmap +import android.graphics.Rect +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import tw.firemaples.onscreenocr.floatings.compose.base.awaitForSubscriber +import tw.firemaples.onscreenocr.recognition.RecognitionResult +import tw.firemaples.onscreenocr.recognition.TextRecognitionProviderType +import tw.firemaples.onscreenocr.translator.TranslationProviderType +import tw.firemaples.onscreenocr.translator.TranslationResult +import tw.firemaples.onscreenocr.translator.Translator +import tw.firemaples.onscreenocr.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.reflect.KClass + +interface StateNavigator { + val navigationAction: SharedFlow + val currentNavState: StateFlow + suspend fun navigate(action: NavigationAction) + + fun allowedNextState(nextNavState: KClass): Boolean + + fun updateState(newNavState: NavState) +} + +@Singleton +class StateNavigatorImpl @Inject constructor() : StateNavigator { + private val logger: Logger by lazy { Logger(this::class) } + + override val navigationAction = MutableSharedFlow() + + override val currentNavState = MutableStateFlow(NavState.Idle) + + private val nextStates: Map, Set>> = mapOf( + NavState.Idle::class to setOf( + NavState.Idle::class, NavState.ScreenCircling::class, + ), + NavState.ScreenCircling::class to setOf( + NavState.Idle::class, NavState.ScreenCircled::class, + ), + NavState.ScreenCircled::class to setOf( + NavState.Idle::class, NavState.ScreenCapturing::class, NavState.ScreenCircled::class, + ), + NavState.ScreenCapturing::class to setOf( + NavState.Idle::class, NavState.TextRecognizing::class, + ), + NavState.TextRecognizing::class to setOf( + NavState.Idle::class, NavState.TextTranslating::class, + ), + NavState.TextTranslating::class to setOf( + NavState.TextTranslated::class, NavState.Idle::class, + ), + NavState.TextTranslated::class to setOf( + NavState.Idle::class, NavState.TextTranslating::class, + ), + ) + + override suspend fun navigate(action: NavigationAction) { + logger.debug("Receive NavigationAction: $action") + navigationAction.awaitForSubscriber() + navigationAction.emit(action) + } + + override fun allowedNextState(nextNavState: KClass): Boolean = + nextStates[currentNavState.value::class]?.contains(nextNavState) == true + + override fun updateState(newNavState: NavState) { + val allowedNextStates = nextStates[currentNavState.value::class] + + val transitionName = + "${currentNavState.value::class.simpleName} > ${newNavState::class.simpleName}" + val transitionInfo = "${currentNavState.value} > ${newNavState::class}" + + if (allowedNextStates?.contains(newNavState::class) == true) { + logger.debug("Change state $transitionName, info: $transitionInfo") + currentNavState.value = newNavState + } else { + logger.error("Change state from $transitionName is not allowed, info: $transitionInfo") + } + } +} + +sealed interface NavigationAction { + data class NavigateToIdle(val showMainBar: Boolean = true) : NavigationAction + + data object NavigateToScreenCircling : NavigationAction + + data class NavigateToScreenCircled( + val parentRect: Rect, + val selectedRect: Rect, + ) : NavigationAction + + data object CancelScreenCircling : NavigationAction + + data class NavigateToScreenCapturing( + val ocrLang: String, + val ocrProvider: TextRecognitionProviderType, + ) : NavigationAction + + data class NavigateToTextRecognition( + val ocrLang: String, + val ocrProvider: TextRecognitionProviderType, + val croppedBitmap: Bitmap, + val parentRect: Rect, + val selectedRect: Rect, + ) : NavigationAction + + data class NavigateToStartTranslation( + val croppedBitmap: Bitmap, + val parentRect: Rect, + val selectedRect: Rect, + val recognitionResult: RecognitionResult, + ) : NavigationAction + + data class ReStartTranslation( + val croppedBitmap: Bitmap, + val parentRect: Rect, + val selectedRect: Rect, + val recognitionResult: RecognitionResult, + ) : NavigationAction + + data class NavigateToTranslated( + val croppedBitmap: Bitmap, + val parentRect: Rect, + val selectedRect: Rect, + val recognitionResult: RecognitionResult, + val translator: Translator, + val translationResult: TranslationResult, + ) : NavigationAction + + data class ShowError( + val error: String, + ) : NavigationAction +} + +sealed class NavState { + override fun toString(): String { + return this::class.simpleName ?: super.toString() + } + + object Idle : NavState() + object ScreenCircling : NavState() + data class ScreenCircled(val parentRect: Rect, val selectedRect: Rect) : NavState() + object ScreenCapturing : NavState() + data class TextRecognizing( + override val parentRect: Rect, + override val selectedRect: Rect, + val croppedBitmap: Bitmap, + ) : NavState(), BitmapIncluded { + override val bitmap: Bitmap + get() = croppedBitmap + } + + data class TextTranslating( + override val parentRect: Rect, + override val selectedRect: Rect, + val croppedBitmap: Bitmap, + val recognitionResult: RecognitionResult, + val translationProviderType: TranslationProviderType, + ) : NavState(), BitmapIncluded { + override val bitmap: Bitmap + get() = croppedBitmap + } + + data class TextTranslated( + override val parentRect: Rect, + override val selectedRect: Rect, + val croppedBitmap: Bitmap, + val recognitionResult: RecognitionResult, + val resultInfo: ResultInfo, + ) : NavState(), BitmapIncluded { + override val bitmap: Bitmap + get() = croppedBitmap + } +// data class ErrorDisplaying(val error: String) : NavState() +} + +interface BitmapIncluded { + val parentRect: Rect + val selectedRect: Rect + val bitmap: Bitmap +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/StateOperator.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/StateOperator.kt new file mode 100644 index 00000000..62c72eb4 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/StateOperator.kt @@ -0,0 +1,487 @@ +package tw.firemaples.onscreenocr.floatings.manager + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Rect +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import tw.firemaples.onscreenocr.R +import tw.firemaples.onscreenocr.di.MainCoroutineScope +import tw.firemaples.onscreenocr.floatings.manager.StateOperator.Companion.SCREENSHOT_DELAY +import tw.firemaples.onscreenocr.log.FirebaseEvent +import tw.firemaples.onscreenocr.pages.setting.SettingManager +import tw.firemaples.onscreenocr.recognition.RecognitionResult +import tw.firemaples.onscreenocr.recognition.TextRecognitionProviderType +import tw.firemaples.onscreenocr.recognition.TextRecognizer +import tw.firemaples.onscreenocr.screenshot.ScreenExtractor +import tw.firemaples.onscreenocr.translator.TranslationProviderType +import tw.firemaples.onscreenocr.translator.TranslationResult +import tw.firemaples.onscreenocr.translator.Translator +import tw.firemaples.onscreenocr.translator.azure.MicrosoftAzureTranslator +import tw.firemaples.onscreenocr.utils.Constants +import tw.firemaples.onscreenocr.utils.Logger +import tw.firemaples.onscreenocr.utils.setReusable +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +interface StateOperator { + val action: SharedFlow + + companion object { + const val SCREENSHOT_DELAY = 100L + } +} + +@Singleton +class StateOperatorImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val stateNavigator: StateNavigator, + @MainCoroutineScope + private val scope: CoroutineScope, +) : StateOperator { + private val logger: Logger by lazy { Logger(this::class) } + + override val action = MutableSharedFlow() + + private val currentNavState: NavState + get() = stateNavigator.currentNavState.value + + init { + stateNavigator.navigationAction + .onEach { action -> + logger.debug("Receive navigationAction: $action") + when (action) { + NavigationAction.NavigateToScreenCircling -> + startScreenCircling() + + is NavigationAction.NavigateToScreenCircled -> + onAreaSelected( + parentRect = action.parentRect, + selectedRect = action.selectedRect, + ) + + NavigationAction.CancelScreenCircling -> + cancelScreenCircling() + + is NavigationAction.NavigateToScreenCapturing -> + startScreenCapturing( + ocrLang = action.ocrLang, + ocrProvider = action.ocrProvider, + ) + + is NavigationAction.ReStartTranslation -> { + startTranslation( + croppedBitmap = action.croppedBitmap, + parentRect = action.parentRect, + selectedRect = action.selectedRect, + recognitionResult = action.recognitionResult, + ) + } + + is NavigationAction.NavigateToIdle -> + backToIdle(showMainBar = action.showMainBar) + + is NavigationAction.NavigateToTextRecognition -> + startRecognition( + ocrLang = action.ocrLang, + ocrProvider = action.ocrProvider, + croppedBitmap = action.croppedBitmap, + parentRect = action.parentRect, + selectedRect = action.selectedRect, + ) + + is NavigationAction.NavigateToStartTranslation -> + startTranslation( + croppedBitmap = action.croppedBitmap, + parentRect = action.parentRect, + selectedRect = action.selectedRect, + recognitionResult = action.recognitionResult, + ) + + is NavigationAction.NavigateToTranslated -> + onTranslated( + croppedBitmap = action.croppedBitmap, + parentRect = action.parentRect, + selectedRect = action.selectedRect, + recognitionResult = action.recognitionResult, + translator = action.translator, + translationResult = action.translationResult, + ) + + is NavigationAction.ShowError -> + showError(action.error) + } + }.launchIn(scope) + } + + private fun startScreenCircling() = scope.launch { + if (!Translator.getTranslator().checkResources(scope)) { + return@launch + } + + logger.debug("startScreenCircling()") + stateNavigator.updateState(NavState.ScreenCircling) + FirebaseEvent.logStartAreaSelection() + + action.emit(StateOperatorAction.ShowScreenCirclingView) + action.emit(StateOperatorAction.TopMainBar) + } + + private fun onAreaSelected(parentRect: Rect, selectedRect: Rect) = scope.launch { + logger.debug( + "onAreaSelected(), parentRect: $parentRect, " + + "selectedRect: $selectedRect," + + "selectedSize: ${selectedRect.width()}x${selectedRect.height()}" + ) + + stateNavigator.updateState( + NavState.ScreenCircled( + parentRect = parentRect, selectedRect = selectedRect, + ) + ) + } + + private fun cancelScreenCircling() = scope.launch { + logger.debug("cancelScreenCircling()") + stateNavigator.updateState(NavState.Idle) + action.emit(StateOperatorAction.HideScreenCirclingView) + } + + private fun startScreenCapturing( + ocrLang: String, + ocrProvider: TextRecognitionProviderType, + ) = scope.launch { + if (!Translator.getTranslator().checkResources(scope)) { + return@launch + } + + val state = currentNavState + if (state !is NavState.ScreenCircled) { + val error = "State should be ScreenCircled but $state" + logger.error(t = IllegalStateException(error)) + showError(error) + return@launch + } + val parentRect = state.parentRect + val selectedRect = state.selectedRect + logger.debug( + "startScreenCapturing(), " + + "parentRect: $parentRect, selectedRect: $selectedRect" + ) + + stateNavigator.updateState(NavState.ScreenCapturing) + + action.emit(StateOperatorAction.HideScreenCirclingView) + action.emit(StateOperatorAction.HideMainBar) + + delay(SCREENSHOT_DELAY) + + var bitmap: Bitmap? = null + try { + FirebaseEvent.logStartCaptureScreen() + val croppedBitmap = ScreenExtractor.extractBitmapFromScreen( + parentRect = parentRect, + cropRect = selectedRect, + ).also { + bitmap = it + } + FirebaseEvent.logCaptureScreenFinished() + + action.emit(StateOperatorAction.ShowMainBar) + + stateNavigator.navigate( + NavigationAction.NavigateToTextRecognition( + ocrLang = ocrLang, + ocrProvider = ocrProvider, + croppedBitmap = croppedBitmap, + parentRect = parentRect, + selectedRect = selectedRect, + ) + ) + } catch (t: TimeoutCancellationException) { + logger.debug(t = t) + showError(context.getString(R.string.error_capture_screen_timeout)) + FirebaseEvent.logCaptureScreenFailed(t) + bitmap?.setReusable() + } catch (t: Throwable) { + logger.debug(t = t) + val errorMsg = + t.message ?: context.getString(R.string.error_unknown_error_capturing_screen) + showError(errorMsg) + FirebaseEvent.logCaptureScreenFailed(t) + bitmap?.setReusable() + } + } + + private fun startRecognition( + ocrLang: String, + ocrProvider: TextRecognitionProviderType, + croppedBitmap: Bitmap, + parentRect: Rect, + selectedRect: Rect, + ) = scope.launch { + stateNavigator.updateState( + NavState.TextRecognizing( + parentRect = parentRect, + selectedRect = selectedRect, + croppedBitmap = croppedBitmap, + ) + ) + + try { + action.emit(StateOperatorAction.ShowResultView) + + val recognizer = TextRecognizer.getRecognizer(ocrProvider) + val language = TextRecognizer.getLanguage(ocrLang, ocrProvider)!! + + FirebaseEvent.logStartOCR(recognizer.name) + var result = withContext(Dispatchers.Default) { + recognizer.recognize( + lang = language, + bitmap = croppedBitmap, + ) + } + logger.debug("On text recognized: $result") +// croppedBitmap.recycle() // to be used in the text editor view + + // TODO move logic + if (SettingManager.removeSpacesInCJK) { + val cjkLang = arrayOf("zh", "ja", "ko") + if (cjkLang.contains(ocrLang.split("-").getOrNull(0))) { + result = result.copy( + result = result.result.replace(" ", "") + ) + } + logger.debug("Remove CJK spaces: $result") + } + + FirebaseEvent.logOCRFinished(recognizer.name) + + stateNavigator.navigate( + NavigationAction.NavigateToStartTranslation( + croppedBitmap = croppedBitmap, + parentRect = parentRect, + selectedRect = selectedRect, + recognitionResult = result, + ) + ) + } catch (e: Exception) { + val error = + if (e.message?.contains(Constants.errorInputImageIsTooSmall) == true) { + context.getString(R.string.error_selected_area_too_small) + } else + e.message + ?: context.getString(R.string.error_an_unknown_error_found_while_recognition_text) + + logger.warn(t = e) + showError(error) + FirebaseEvent.logOCRFailed( + TextRecognizer.getRecognizer(ocrProvider).name, e + ) + } + } + + private fun startTranslation( + croppedBitmap: Bitmap, + parentRect: Rect, + selectedRect: Rect, + recognitionResult: RecognitionResult, + ) = scope.launch { + try { + val translator = Translator.getTranslator() + + stateNavigator.updateState( + NavState.TextTranslating( + parentRect = parentRect, + selectedRect = selectedRect, + croppedBitmap = croppedBitmap, + recognitionResult = recognitionResult, + translationProviderType = translator.type, + ) + ) + + FirebaseEvent.logStartTranslationText( + text = recognitionResult.result, + fromLang = recognitionResult.langCode, + translator = translator, + ) + + val translationResult = translator.translate( + text = recognitionResult.result, + sourceLangCode = recognitionResult.langCode, + ) + + stateNavigator.navigate( + NavigationAction.NavigateToTranslated( + croppedBitmap = croppedBitmap, + parentRect = parentRect, + selectedRect = selectedRect, + recognitionResult = recognitionResult, + translator = translator, + translationResult = translationResult, + ) + ) + } catch (e: Exception) { + logger.warn(t = e) + FirebaseEvent.logException(e) + showError(e.message ?: "Unknown error found while translating") + } + } + + private fun onTranslated( + croppedBitmap: Bitmap, + parentRect: Rect, + selectedRect: Rect, + recognitionResult: RecognitionResult, + translator: Translator, + translationResult: TranslationResult, + ) { + when (translationResult) { + TranslationResult.OuterTranslatorLaunched -> { + FirebaseEvent.logTranslationTextFinished(translator) + backToIdle() + } + + is TranslationResult.SourceLangNotSupport -> { + FirebaseEvent.logTranslationSourceLangNotSupport( + translator, recognitionResult.langCode, + ) + + showError(context.getString(R.string.msg_translator_provider_does_not_support_the_ocr_lang)) + } + + TranslationResult.OCROnlyResult -> { + FirebaseEvent.logTranslationTextFinished(translator) + + stateNavigator.updateState( + NavState.TextTranslated( + parentRect = parentRect, + selectedRect = selectedRect, + croppedBitmap = croppedBitmap, + recognitionResult = recognitionResult, + resultInfo = ResultInfo.OCROnly, + ) + ) + } + + is TranslationResult.TranslatedResult -> { + FirebaseEvent.logTranslationTextFinished(translator) + + stateNavigator.updateState( + NavState.TextTranslated( + parentRect = parentRect, + selectedRect = selectedRect, + croppedBitmap = croppedBitmap, + recognitionResult = recognitionResult, + resultInfo = ResultInfo.Translated( + translatedText = translationResult.result, + providerType = translationResult.type, + ), + ) + ) + } + + is TranslationResult.TranslationFailed -> { + FirebaseEvent.logTranslationTextFailed(translator) + val error = translationResult.error + + if (error is MicrosoftAzureTranslator.Error) { + FirebaseEvent.logMicrosoftTranslationError(error) + } + + if (error is IOException) { + showError(context.getString(R.string.error_can_not_connect_to_translation_server)) + } else { + FirebaseEvent.logException(error) + showError( + error.localizedMessage + ?: context.getString(R.string.error_unknown) + ) + } + } + } + } + + private fun showError(error: String) = scope.launch { + logger.error("showError(): $error") + backToIdle() + action.emit(StateOperatorAction.ShowErrorDialog(error)) + } + + private fun backToIdle(showMainBar: Boolean = true) = scope.launch { + if (currentNavState != NavState.Idle) + stateNavigator.updateState(NavState.Idle) + + action.emit(StateOperatorAction.HideResultView) + + if (showMainBar) + action.emit(StateOperatorAction.ShowMainBar) + + currentNavState.getBitmap()?.setReusable() + } + + private fun NavState.getBitmap(): Bitmap? = + (this as? BitmapIncluded)?.bitmap +} + +sealed interface StateOperatorAction { + data object TopMainBar : StateOperatorAction + data object HideMainBar : StateOperatorAction + data object ShowMainBar : StateOperatorAction + data object ShowScreenCirclingView : StateOperatorAction + data object HideScreenCirclingView : StateOperatorAction + data object ShowResultView : StateOperatorAction + data object HideResultView : StateOperatorAction + data class ShowErrorDialog(val error: String) : StateOperatorAction +} + +sealed class Result( + open val ocrText: String, + open val boundingBoxes: List, +) { + data class Translated( + override val ocrText: String, + override val boundingBoxes: List, + val translatedText: String, + val providerType: TranslationProviderType, + ) : Result(ocrText, boundingBoxes) + + data class SourceLangNotSupport( + override val ocrText: String, + override val boundingBoxes: List, + val providerType: TranslationProviderType, + ) : Result(ocrText, boundingBoxes) + + data class OCROnly( + override val ocrText: String, + override val boundingBoxes: List, + ) : Result(ocrText, boundingBoxes) +} + +sealed interface ResultInfo { + data class Translated( + val translatedText: String, + val providerType: TranslationProviderType, + ) : ResultInfo + + data class Error( + val providerType: TranslationProviderType, + val resultError: ResultError, + ) : ResultInfo + + data object OCROnly : ResultInfo +} + +enum class ResultError { + SourceLangNotSupport, +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/result/ResultView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/result/ResultView.kt index 44fbe846..3345d53d 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/floatings/result/ResultView.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/result/ResultView.kt @@ -1,263 +1,266 @@ -package tw.firemaples.onscreenocr.floatings.result - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Rect -import android.text.method.ScrollingMovementMethod -import android.util.TypedValue -import android.view.View -import android.view.WindowManager -import android.widget.RelativeLayout -import androidx.core.content.ContextCompat -import java.util.Locale -import tw.firemaples.onscreenocr.R -import tw.firemaples.onscreenocr.databinding.FloatingResultViewBinding -import tw.firemaples.onscreenocr.databinding.ViewResultPanelBinding -import tw.firemaples.onscreenocr.floatings.base.FloatingView -import tw.firemaples.onscreenocr.floatings.manager.Result -import tw.firemaples.onscreenocr.floatings.recognizedTextEditor.RecognizedTextEditor -import tw.firemaples.onscreenocr.floatings.textInfoSearch.TextInfoSearchView -import tw.firemaples.onscreenocr.recognition.RecognitionResult -import tw.firemaples.onscreenocr.translator.TranslationProviderType -import tw.firemaples.onscreenocr.translator.utils.GoogleTranslateUtils -import tw.firemaples.onscreenocr.utils.Logger -import tw.firemaples.onscreenocr.utils.UIUtils -import tw.firemaples.onscreenocr.utils.Utils -import tw.firemaples.onscreenocr.utils.clickOnce -import tw.firemaples.onscreenocr.utils.dpToPx -import tw.firemaples.onscreenocr.utils.getViewRect -import tw.firemaples.onscreenocr.utils.setReusable -import tw.firemaples.onscreenocr.utils.setTextOrGone -import tw.firemaples.onscreenocr.utils.showOrHide - -class ResultView(context: Context) : FloatingView(context) { - companion object { - private const val LABEL_RECOGNIZED_TEXT = "Recognized text" - private const val LABEL_TRANSLATED_TEXT = "Translated text" - } - - private val logger: Logger by lazy { Logger(ResultView::class) } - - override val layoutId: Int - get() = R.layout.floating_result_view - - override val layoutWidth: Int - get() = WindowManager.LayoutParams.MATCH_PARENT - - override val layoutHeight: Int - get() = WindowManager.LayoutParams.MATCH_PARENT - - override val enableHomeButtonWatcher: Boolean - get() = true - - private val viewModel: ResultViewModel by lazy { ResultViewModel(viewScope) } - - private val binding: FloatingResultViewBinding = FloatingResultViewBinding.bind(rootLayout) - - private val viewRoot: RelativeLayout = binding.viewRoot - - var onUserDismiss: (() -> Unit)? = null - - private val viewResultWindow: View = binding.viewResultWindow - - private var unionRect: Rect = Rect() - - private var croppedBitmap: Bitmap? = null - - init { - binding.resultPanel.setViews() - } - - private fun ViewResultPanelBinding.setViews() { - viewModel.displayOCROperationProgress.observe(lifecycleOwner) { - pbOcrOperating.showOrHide(it) - } - viewModel.displayTranslationProgress.observe(lifecycleOwner) { - pbTranslationOperating.showOrHide(it) - } - viewModel.displaySelectableText.observe(lifecycleOwner) { - textSelectable.isChecked = it - tvOcrText.showOrHide(!it) - tvWordBreakOcrText.showOrHide(it) - } - viewModel.ocrText.observe(lifecycleOwner) { - tvWordBreakOcrText.setContent(it?.text(), it?.locale() ?: Locale.getDefault()) - tvOcrText.text = it?.text() - } - viewModel.translatedText.observe(lifecycleOwner) { - if (it == null) { - tvTranslatedText.text = null - } else { - val (text, color) = it - tvTranslatedText.text = text - tvTranslatedText.setTextColor(ContextCompat.getColor(context, color)) - } - - reposition() - } - - viewModel.displayRecognitionBlock.observe(lifecycleOwner) { - groupRecognitionViews.showOrHide(it) - } - viewModel.displayTranslatedBlock.observe(lifecycleOwner) { - groupTranslationViews.showOrHide(it) - } - - viewModel.translationProviderText.observe(lifecycleOwner) { - tvTranslationProvider.setTextOrGone(it) - } - viewModel.displayTranslatedByGoogle.observe(lifecycleOwner) { - ivTranslatedByGoogle.showOrHide(it) - } - - viewModel.displayRecognizedTextAreas.observe(lifecycleOwner) { - val (boundingBoxes, unionRect) = it - binding.viewTextBoundingBoxView.boundingBoxes = boundingBoxes - updateSelectedAreas(unionRect) - } - - viewModel.copyRecognizedText.observe(lifecycleOwner) { - Utils.copyToClipboard(LABEL_RECOGNIZED_TEXT, it) - } - - viewModel.fontSize.observe(lifecycleOwner) { - tvOcrText.setTextSize(TypedValue.COMPLEX_UNIT_SP, it) - tvWordBreakOcrText.setTextSize(TypedValue.COMPLEX_UNIT_SP, it) - tvTranslatedText.setTextSize(TypedValue.COMPLEX_UNIT_SP, it) - } - - viewModel.displayTextInfoSearchView.observe(lifecycleOwner) { - TextInfoSearchView(context, it.text, it.sourceLang, it.targetLang) - .attachToScreen() - } - - textSelectable.setOnCheckedChangeListener { _, checked -> - viewModel.onTextSelectableChecked(checked) - } - tvWordBreakOcrText.onWordClicked = { word -> - if (word != null) { - viewModel.onWordSelected(word) - tvWordBreakOcrText.clearSelection() - } - } - tvOcrText.movementMethod = ScrollingMovementMethod() - tvTranslatedText.movementMethod = ScrollingMovementMethod() - viewRoot.clickOnce { onUserDismiss?.invoke() } - btEditOCRText.clickOnce { - showRecognizedTextEditor(viewModel.ocrText.value?.text() ?: "") - } - btCopyOCRText.clickOnce { - Utils.copyToClipboard(LABEL_RECOGNIZED_TEXT, viewModel.ocrText.value?.text() ?: "") - } - btCopyTranslatedText.clickOnce { - Utils.copyToClipboard(LABEL_TRANSLATED_TEXT, tvTranslatedText.text.toString()) - } - btTranslateOCRTextWithGoogleTranslate.clickOnce { - GoogleTranslateUtils.launchTranslator(viewModel.ocrText.value?.text() ?: "") - onUserDismiss?.invoke() - } - btTranslateTranslatedTextWithGoogleTranslate.clickOnce { - GoogleTranslateUtils.launchTranslator(tvTranslatedText.text.toString()) - onUserDismiss?.invoke() - } - btShareOCRText.clickOnce { - val ocrText = viewModel.ocrText.value?.text() ?: return@clickOnce - Utils.shareText(ocrText) - onUserDismiss?.invoke() - } - btAdjustFontSize.clickOnce { - FontSizeAdjuster(context).attachToScreen() - } - } - - private fun showRecognizedTextEditor(recognizedText: String) { - RecognizedTextEditor( - context = context, - review = croppedBitmap, - text = recognizedText, - onSubmit = { - if (it.isNotBlank() && it.trim() != recognizedText) { - viewModel.onOCRTextEdited(it.trim()) - } - }, - ).attachToScreen() - } - - override fun onAttachedToScreen() { - super.onAttachedToScreen() - viewResultWindow.visibility = View.INVISIBLE - } - - override fun onDetachedFromScreen() { - super.onDetachedFromScreen() - this.croppedBitmap?.setReusable() - this.croppedBitmap = null - } - - override fun onHomeButtonPressed() { - super.onHomeButtonPressed() - onUserDismiss?.invoke() - } - - fun startRecognition() { - attachToScreen() - viewModel.startRecognition() - } - - fun textRecognized( - result: RecognitionResult, - parent: Rect, - selected: Rect, - croppedBitmap: Bitmap - ) { - this.croppedBitmap = croppedBitmap - viewModel.textRecognized(result, parent, selected, rootView.getViewRect()) - } - - fun startTranslation(translationProviderType: TranslationProviderType) { - viewModel.startTranslation(translationProviderType) - } - - fun textTranslated(result: Result) { - viewModel.textTranslated(result) - } - - fun backToIdle() { - detachFromScreen() - } - - private fun updateSelectedAreas(unionRect: Rect) { - this.unionRect = unionRect - reposition() - } - - private fun reposition() { - rootView.post { - val parentRect = viewRoot.getViewRect() - val anchorRect = Rect(unionRect).apply { - top += parentRect.top - left += parentRect.left - bottom += parentRect.top - right += parentRect.left - } - val windowRect = viewResultWindow.getViewRect() - - val (leftMargin, topMargin) = UIUtils.countViewPosition( - anchorRect, parentRect, - windowRect.width(), windowRect.height(), 2.dpToPx(), - ) - - val layoutParams = - (viewResultWindow.layoutParams as RelativeLayout.LayoutParams).apply { - this.leftMargin = leftMargin - this.topMargin = topMargin - } - - viewRoot.updateViewLayout(viewResultWindow, layoutParams) - - viewRoot.post { - viewResultWindow.visibility = View.VISIBLE - } - } - } -} +//package tw.firemaples.onscreenocr.floatings.result +// +//import android.content.Context +//import android.graphics.Bitmap +//import android.graphics.Rect +//import android.text.method.ScrollingMovementMethod +//import android.util.TypedValue +//import android.view.View +//import android.view.WindowManager +//import android.widget.RelativeLayout +//import androidx.core.content.ContextCompat +//import dagger.hilt.android.qualifiers.ApplicationContext +//import tw.firemaples.onscreenocr.R +//import tw.firemaples.onscreenocr.databinding.FloatingResultViewBinding +//import tw.firemaples.onscreenocr.databinding.ViewResultPanelBinding +//import tw.firemaples.onscreenocr.floatings.base.FloatingView +//import tw.firemaples.onscreenocr.floatings.manager.Result +//import tw.firemaples.onscreenocr.floatings.recognizedTextEditor.RecognizedTextEditor +//import tw.firemaples.onscreenocr.floatings.textInfoSearch.TextInfoSearchView +//import tw.firemaples.onscreenocr.recognition.RecognitionResult +//import tw.firemaples.onscreenocr.translator.TranslationProviderType +//import tw.firemaples.onscreenocr.translator.utils.GoogleTranslateUtils +//import tw.firemaples.onscreenocr.utils.Logger +//import tw.firemaples.onscreenocr.utils.UIUtils +//import tw.firemaples.onscreenocr.utils.Utils +//import tw.firemaples.onscreenocr.utils.clickOnce +//import tw.firemaples.onscreenocr.utils.dpToPx +//import tw.firemaples.onscreenocr.utils.getViewRect +//import tw.firemaples.onscreenocr.utils.setReusable +//import tw.firemaples.onscreenocr.utils.setTextOrGone +//import tw.firemaples.onscreenocr.utils.showOrHide +//import java.util.Locale +//import javax.inject.Inject +// +//class ResultView @Inject constructor( +// @ApplicationContext context: Context, +// private val viewModel: ResultViewModel, +//) : FloatingView(context) { +// companion object { +// private const val LABEL_RECOGNIZED_TEXT = "Recognized text" +// private const val LABEL_TRANSLATED_TEXT = "Translated text" +// } +// +// private val logger: Logger by lazy { Logger(ResultView::class) } +// +// override val layoutId: Int +// get() = R.layout.floating_result_view +// +// override val layoutWidth: Int +// get() = WindowManager.LayoutParams.MATCH_PARENT +// +// override val layoutHeight: Int +// get() = WindowManager.LayoutParams.MATCH_PARENT +// +// override val enableHomeButtonWatcher: Boolean +// get() = true +// +// private val binding: FloatingResultViewBinding = FloatingResultViewBinding.bind(rootLayout) +// +// private val viewRoot: RelativeLayout = binding.viewRoot +// +// var onUserDismiss: (() -> Unit)? = null +// +// private val viewResultWindow: View = binding.viewResultWindow +// +// private var unionRect: Rect = Rect() +// +// private var croppedBitmap: Bitmap? = null +// +// init { +// binding.resultPanel.setViews() +// } +// +// private fun ViewResultPanelBinding.setViews() { +// viewModel.displayOCROperationProgress.observe(lifecycleOwner) { +// pbOcrOperating.showOrHide(it) +// } +// viewModel.displayTranslationProgress.observe(lifecycleOwner) { +// pbTranslationOperating.showOrHide(it) +// } +// viewModel.displaySelectableText.observe(lifecycleOwner) { +// textSelectable.isChecked = it +// tvOcrText.showOrHide(!it) +// tvWordBreakOcrText.showOrHide(it) +// } +// viewModel.ocrText.observe(lifecycleOwner) { +// tvWordBreakOcrText.setContent(it?.text(), it?.locale() ?: Locale.getDefault()) +// tvOcrText.text = it?.text() +// } +// viewModel.translatedText.observe(lifecycleOwner) { +// if (it == null) { +// tvTranslatedText.text = null +// } else { +// val (text, color) = it +// tvTranslatedText.text = text +// tvTranslatedText.setTextColor(ContextCompat.getColor(context, color)) +// } +// +// reposition() +// } +// +// viewModel.displayRecognitionBlock.observe(lifecycleOwner) { +// groupRecognitionViews.showOrHide(it) +// } +// viewModel.displayTranslatedBlock.observe(lifecycleOwner) { +// groupTranslationViews.showOrHide(it) +// } +// +// viewModel.translationProviderText.observe(lifecycleOwner) { +// tvTranslationProvider.setTextOrGone(it) +// } +// viewModel.displayTranslatedByGoogle.observe(lifecycleOwner) { +// ivTranslatedByGoogle.showOrHide(it) +// } +// +// viewModel.displayRecognizedTextAreas.observe(lifecycleOwner) { +// val (boundingBoxes, unionRect) = it +// binding.viewTextBoundingBoxView.boundingBoxes = boundingBoxes +// updateSelectedAreas(unionRect) +// } +// +// viewModel.copyRecognizedText.observe(lifecycleOwner) { +// Utils.copyToClipboard(LABEL_RECOGNIZED_TEXT, it) +// } +// +// viewModel.fontSize.observe(lifecycleOwner) { +// tvOcrText.setTextSize(TypedValue.COMPLEX_UNIT_SP, it) +// tvWordBreakOcrText.setTextSize(TypedValue.COMPLEX_UNIT_SP, it) +// tvTranslatedText.setTextSize(TypedValue.COMPLEX_UNIT_SP, it) +// } +// +// viewModel.displayTextInfoSearchView.observe(lifecycleOwner) { +// TextInfoSearchView(context, it.text, it.sourceLang, it.targetLang) +// .attachToScreen() +// } +// +// textSelectable.setOnCheckedChangeListener { _, checked -> +// viewModel.onTextSelectableChecked(checked) +// } +// tvWordBreakOcrText.onWordClicked = { word -> +// if (word != null) { +// viewModel.onWordSelected(word) +// tvWordBreakOcrText.clearSelection() +// } +// } +// tvOcrText.movementMethod = ScrollingMovementMethod() +// tvTranslatedText.movementMethod = ScrollingMovementMethod() +// viewRoot.clickOnce { onUserDismiss?.invoke() } +// btEditOCRText.clickOnce { +// showRecognizedTextEditor(viewModel.ocrText.value?.text() ?: "") +// } +// btCopyOCRText.clickOnce { +// Utils.copyToClipboard(LABEL_RECOGNIZED_TEXT, viewModel.ocrText.value?.text() ?: "") +// } +// btCopyTranslatedText.clickOnce { +// Utils.copyToClipboard(LABEL_TRANSLATED_TEXT, tvTranslatedText.text.toString()) +// } +// btTranslateOCRTextWithGoogleTranslate.clickOnce { +// GoogleTranslateUtils.launchTranslator(viewModel.ocrText.value?.text() ?: "") +// onUserDismiss?.invoke() +// } +// btTranslateTranslatedTextWithGoogleTranslate.clickOnce { +// GoogleTranslateUtils.launchTranslator(tvTranslatedText.text.toString()) +// onUserDismiss?.invoke() +// } +// btShareOCRText.clickOnce { +// val ocrText = viewModel.ocrText.value?.text() ?: return@clickOnce +// Utils.shareText(ocrText) +// onUserDismiss?.invoke() +// } +// btAdjustFontSize.clickOnce { +// FontSizeAdjuster(context).attachToScreen() +// } +// } +// +// private fun showRecognizedTextEditor(recognizedText: String) { +// RecognizedTextEditor( +// context = context, +// review = croppedBitmap, +// text = recognizedText, +// onSubmit = { +// if (it.isNotBlank() && it.trim() != recognizedText) { +// viewModel.onOCRTextEdited(it.trim()) +// } +// }, +// ).attachToScreen() +// } +// +// override fun onAttachedToScreen() { +// super.onAttachedToScreen() +// viewResultWindow.visibility = View.INVISIBLE +// } +// +// override fun onDetachedFromScreen() { +// super.onDetachedFromScreen() +// this.croppedBitmap?.setReusable() +// this.croppedBitmap = null +// } +// +// override fun onHomeButtonPressed() { +// super.onHomeButtonPressed() +// onUserDismiss?.invoke() +// } +// +// fun startRecognition() { +// attachToScreen() +// viewModel.startRecognition() +// } +// +// fun textRecognized( +// result: RecognitionResult, +// parent: Rect, +// selected: Rect, +// croppedBitmap: Bitmap +// ) { +// this.croppedBitmap = croppedBitmap +// viewModel.textRecognized(result, parent, selected, rootView.getViewRect()) +// } +// +// fun startTranslation(translationProviderType: TranslationProviderType) { +// viewModel.startTranslation(translationProviderType) +// } +// +// fun textTranslated(result: Result) { +// viewModel.textTranslated(result) +// } +// +// fun backToIdle() { +// detachFromScreen() +// } +// +// private fun updateSelectedAreas(unionRect: Rect) { +// this.unionRect = unionRect +// reposition() +// } +// +// private fun reposition() { +// rootView.post { +// val parentRect = viewRoot.getViewRect() +// val anchorRect = Rect(unionRect).apply { +// top += parentRect.top +// left += parentRect.left +// bottom += parentRect.top +// right += parentRect.left +// } +// val windowRect = viewResultWindow.getViewRect() +// +// val (leftMargin, topMargin) = UIUtils.countViewPosition( +// anchorRect, parentRect, +// windowRect.width(), windowRect.height(), 2.dpToPx(), +// ) +// +// val layoutParams = +// (viewResultWindow.layoutParams as RelativeLayout.LayoutParams).apply { +// this.leftMargin = leftMargin +// this.topMargin = topMargin +// } +// +// viewRoot.updateViewLayout(viewResultWindow, layoutParams) +// +// viewRoot.post { +// viewResultWindow.visibility = View.VISIBLE +// } +// } +// } +//} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/result/ResultViewModel.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/result/ResultViewModel.kt index cf062fa2..3008f4bd 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/floatings/result/ResultViewModel.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/result/ResultViewModel.kt @@ -1,228 +1,236 @@ -package tw.firemaples.onscreenocr.floatings.result - -import android.content.Context -import android.graphics.Rect -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import com.chibatching.kotpref.livedata.asLiveData -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import tw.firemaples.onscreenocr.R -import tw.firemaples.onscreenocr.floatings.base.FloatingViewModel -import tw.firemaples.onscreenocr.floatings.manager.FloatingStateManager -import tw.firemaples.onscreenocr.floatings.manager.Result -import tw.firemaples.onscreenocr.pref.AppPref -import tw.firemaples.onscreenocr.recognition.RecognitionResult -import tw.firemaples.onscreenocr.repo.GeneralRepository -import tw.firemaples.onscreenocr.translator.TranslationProviderType -import tw.firemaples.onscreenocr.utils.Constants -import tw.firemaples.onscreenocr.utils.Logger -import tw.firemaples.onscreenocr.utils.SingleLiveEvent -import tw.firemaples.onscreenocr.utils.Utils -import java.util.Locale - -typealias OCRText = Pair - -fun OCRText.text(): String = this.first -fun OCRText.locale(): Locale = Locale.forLanguageTag(this.second) -fun OCRText.langCode(): String = this.second - -class ResultViewModel(viewScope: CoroutineScope) : FloatingViewModel(viewScope) { - private val _displayOCROperationProgress = MutableLiveData() - val displayOCROperationProgress: LiveData = _displayOCROperationProgress - - private val _displayTranslationProgress = MutableLiveData() - val displayTranslationProgress: LiveData = _displayTranslationProgress - - private val _ocrText = MutableLiveData() - val ocrText: LiveData = _ocrText - - private val _translatedText = MutableLiveData?>() - val translatedText: LiveData?> = _translatedText - - val displaySelectableText: LiveData = - AppPref.asLiveData(AppPref::displaySelectedTextOnResultWindow) - - private val _displayRecognitionBlock = MutableLiveData() - val displayRecognitionBlock: LiveData = _displayRecognitionBlock - - private val _displayTranslationBlock = MutableLiveData() - val displayTranslatedBlock: LiveData = _displayTranslationBlock - - private val _translationProviderText = MutableLiveData() - val translationProviderText: LiveData = _translationProviderText - - private val _displayTranslatedByGoogle = MutableLiveData() - val displayTranslatedByGoogle: LiveData = _displayTranslatedByGoogle - - private val _displayRecognizedTextAreas = MutableLiveData, Rect>>() - val displayRecognizedTextAreas: LiveData, Rect>> = _displayRecognizedTextAreas - - private val _copyRecognizedText = SingleLiveEvent() - val copyRecognizedText: LiveData = _copyRecognizedText - - private val _displayTextInfoSearchView = SingleLiveEvent() - val displayTextInfoSearchView: LiveData = _displayTextInfoSearchView - - val fontSize: LiveData = AppPref.asLiveData(AppPref::resultWindowFontSize) - - private val logger: Logger by lazy { Logger(ResultViewModel::class) } - - private val context: Context by lazy { Utils.context } - - private val repo: GeneralRepository by lazy { GeneralRepository() } - - private var lastLangCode: String = Constants.DEFAULT_OCR_LANG - private var lastTextBoundingBoxes: List = listOf() - -// companion object { -// private const val STATE_RECOGNIZING = 0 -// private const val STATE_RECOGNIZED = 0 -// private const val STATE_TRANSLATING = 0 -// private const val STATE_TRANSLATED = 0 +//package tw.firemaples.onscreenocr.floatings.result +// +//import android.content.Context +//import android.graphics.Rect +//import androidx.lifecycle.LiveData +//import androidx.lifecycle.MutableLiveData +//import com.chibatching.kotpref.livedata.asLiveData +//import kotlinx.coroutines.CoroutineScope +//import kotlinx.coroutines.flow.first +//import kotlinx.coroutines.launch +//import tw.firemaples.onscreenocr.R +//import tw.firemaples.onscreenocr.floatings.base.FloatingViewModel +//import tw.firemaples.onscreenocr.floatings.manager.NavigationAction +//import tw.firemaples.onscreenocr.floatings.manager.Result +//import tw.firemaples.onscreenocr.floatings.manager.StateNavigator +//import tw.firemaples.onscreenocr.di.MainImmediateCoroutineScope +//import tw.firemaples.onscreenocr.pref.AppPref +//import tw.firemaples.onscreenocr.recognition.RecognitionResult +//import tw.firemaples.onscreenocr.repo.GeneralRepository +//import tw.firemaples.onscreenocr.translator.TranslationProviderType +//import tw.firemaples.onscreenocr.utils.Constants +//import tw.firemaples.onscreenocr.utils.Logger +//import tw.firemaples.onscreenocr.utils.SingleLiveEvent +//import tw.firemaples.onscreenocr.utils.Utils +//import java.util.Locale +//import javax.inject.Inject +// +//typealias OCRText = Pair +// +//fun OCRText.text(): String = this.first +//fun OCRText.locale(): Locale = Locale.forLanguageTag(this.second) +//fun OCRText.langCode(): String = this.second +// +//class ResultViewModel @Inject constructor( +// @MainImmediateCoroutineScope viewScope: CoroutineScope, +// private val stateNavigator: StateNavigator, +//) : FloatingViewModel(viewScope) { +// private val _displayOCROperationProgress = MutableLiveData() +// val displayOCROperationProgress: LiveData = _displayOCROperationProgress +// +// private val _displayTranslationProgress = MutableLiveData() +// val displayTranslationProgress: LiveData = _displayTranslationProgress +// +// private val _ocrText = MutableLiveData() +// val ocrText: LiveData = _ocrText +// +// private val _translatedText = MutableLiveData?>() +// val translatedText: LiveData?> = _translatedText +// +// val displaySelectableText: LiveData = +// AppPref.asLiveData(AppPref::displaySelectedTextOnResultWindow) +// +// private val _displayRecognitionBlock = MutableLiveData() +// val displayRecognitionBlock: LiveData = _displayRecognitionBlock +// +// private val _displayTranslationBlock = MutableLiveData() +// val displayTranslatedBlock: LiveData = _displayTranslationBlock +// +// private val _translationProviderText = MutableLiveData() +// val translationProviderText: LiveData = _translationProviderText +// +// private val _displayTranslatedByGoogle = MutableLiveData() +// val displayTranslatedByGoogle: LiveData = _displayTranslatedByGoogle +// +// private val _displayRecognizedTextAreas = MutableLiveData, Rect>>() +// val displayRecognizedTextAreas: LiveData, Rect>> = _displayRecognizedTextAreas +// +// private val _copyRecognizedText = SingleLiveEvent() +// val copyRecognizedText: LiveData = _copyRecognizedText +// +// private val _displayTextInfoSearchView = SingleLiveEvent() +// val displayTextInfoSearchView: LiveData = _displayTextInfoSearchView +// +// val fontSize: LiveData = AppPref.asLiveData(AppPref::resultWindowFontSize) +// +// private val logger: Logger by lazy { Logger(ResultViewModel::class) } +// +// private val context: Context by lazy { Utils.context } +// +// private val repo: GeneralRepository by lazy { GeneralRepository() } +// +// private var lastLangCode: String = Constants.DEFAULT_OCR_LANG +// private var lastTextBoundingBoxes: List = listOf() +// +//// companion object { +//// private const val STATE_RECOGNIZING = 0 +//// private const val STATE_RECOGNIZED = 0 +//// private const val STATE_TRANSLATING = 0 +//// private const val STATE_TRANSLATED = 0 +//// } +// +// fun startRecognition() { +// viewScope.launch { +// _displayRecognizedTextAreas.value = emptyList() to Rect() +// +// _displayOCROperationProgress.value = true +// _displayTranslationProgress.value = false +// +// _ocrText.value = null +// _translatedText.value = null +// +// _displayRecognitionBlock.value = true +// _displayTranslationBlock.value = false +// _translationProviderText.value = null +// _displayTranslatedByGoogle.value = false +// } // } - - fun startRecognition() { - viewScope.launch { - _displayRecognizedTextAreas.value = emptyList() to Rect() - - _displayOCROperationProgress.value = true - _displayTranslationProgress.value = false - - _ocrText.value = null - _translatedText.value = null - - _displayRecognitionBlock.value = true - _displayTranslationBlock.value = false - _translationProviderText.value = null - _displayTranslatedByGoogle.value = false - } - } - - fun textRecognized(result: RecognitionResult, parent: Rect, selected: Rect, viewRect: Rect) { - viewScope.launch { - this@ResultViewModel.lastLangCode = result.langCode - - _displayOCROperationProgress.value = false - _ocrText.value = result.result to result.langCode - - val topOffset = parent.top + selected.top - viewRect.top - val leftOffset = parent.left + selected.left - viewRect.left - this@ResultViewModel.lastTextBoundingBoxes = result.boundingBoxes.toList() - val textAreas = result.boundingBoxes.map { - Rect( - it.left + leftOffset, - it.top + topOffset, - it.right + leftOffset, - it.bottom + topOffset - ) - } - val unionRect = Rect() - textAreas.forEach { unionRect.union(it) } - _displayRecognizedTextAreas.value = textAreas to unionRect - - if (repo.isAutoCopyOCRResult().first()) { - _copyRecognizedText.value = result.result - } - } - } - - fun startTranslation(translationProviderType: TranslationProviderType) { - viewScope.launch { - if (!translationProviderType.nonTranslation) { - _displayTranslationBlock.value = true - _displayTranslationProgress.value = true - _displayTranslatedByGoogle.value = false - _translationProviderText.value = null - } - - when (translationProviderType) { - TranslationProviderType.MicrosoftAzure, - TranslationProviderType.MyMemory -> - _translationProviderText.value = - "${context.getString(R.string.text_translated_by)} " + - context.getString(translationProviderType.nameRes) - - TranslationProviderType.GoogleMLKit -> - _displayTranslatedByGoogle.value = true - - TranslationProviderType.GoogleTranslateApp, - TranslationProviderType.BingTranslateApp, - TranslationProviderType.PapagoTranslateApp, - TranslationProviderType.YandexTranslateApp, - TranslationProviderType.OtherTranslateApp, - TranslationProviderType.OCROnly -> { - } - } - } - } - - fun textTranslated(result: Result) { - viewScope.launch { - _displayTranslationProgress.value = false - - when (result) { - is Result.Translated -> { - _translatedText.value = result.translatedText to R.color.foregroundSecond - - if (repo.hideRecognizedTextAfterTranslated().first()) { - _displayRecognitionBlock.value = false - } - } - - is Result.SourceLangNotSupport -> { - _translatedText.value = - context.getString(R.string.msg_translator_provider_does_not_support_the_ocr_lang) to R.color.alert - } - - is Result.OCROnly -> { - } - } - } - } - - fun onOCRTextEdited(text: String) { - viewScope.launch { - val langCode = _ocrText.value!!.langCode() - _ocrText.value = text to langCode - -// val langCode = try { -// LanguageIdentify.identifyLanguage(text) -// } catch (e: Exception) { -// logger.debug(t = e) -// null -// } ?: lastLangCode - - FloatingStateManager.startTranslation( - RecognitionResult( - langCode = langCode, - result = text, - boundingBoxes = lastTextBoundingBoxes, - ) - ) - } - } - - fun onTextSelectableChecked(checked: Boolean) { - viewScope.launch { - AppPref.displaySelectedTextOnResultWindow = checked - } - } - - fun onWordSelected(word: String) { - viewScope.launch { - _displayTextInfoSearchView.value = TextInfoSearchViewData( - text = word, - sourceLang = AppPref.selectedOCRLang, - targetLang = AppPref.selectedTranslationLang, - ) - } - } - - data class TextInfoSearchViewData( - val text: String, - val sourceLang: String, - val targetLang: String, - ) -} +// +// fun textRecognized(result: RecognitionResult, parent: Rect, selected: Rect, viewRect: Rect) { +// viewScope.launch { +// this@ResultViewModel.lastLangCode = result.langCode +// +// _displayOCROperationProgress.value = false +// _ocrText.value = result.result to result.langCode +// +// val topOffset = parent.top + selected.top - viewRect.top +// val leftOffset = parent.left + selected.left - viewRect.left +// this@ResultViewModel.lastTextBoundingBoxes = result.boundingBoxes.toList() +// val textAreas = result.boundingBoxes.map { +// Rect( +// it.left + leftOffset, +// it.top + topOffset, +// it.right + leftOffset, +// it.bottom + topOffset +// ) +// } +// val unionRect = Rect() +// textAreas.forEach { unionRect.union(it) } +// _displayRecognizedTextAreas.value = textAreas to unionRect +// +// if (repo.isAutoCopyOCRResult().first()) { +// _copyRecognizedText.value = result.result +// } +// } +// } +// +// fun startTranslation(translationProviderType: TranslationProviderType) { +// viewScope.launch { +// if (!translationProviderType.nonTranslation) { +// _displayTranslationBlock.value = true +// _displayTranslationProgress.value = true +// _displayTranslatedByGoogle.value = false +// _translationProviderText.value = null +// } +// +// when (translationProviderType) { +// TranslationProviderType.MicrosoftAzure, +// TranslationProviderType.MyMemory -> +// _translationProviderText.value = +// "${context.getString(R.string.text_translated_by)} " + +// context.getString(translationProviderType.nameRes) +// +// TranslationProviderType.GoogleMLKit -> +// _displayTranslatedByGoogle.value = true +// +// TranslationProviderType.GoogleTranslateApp, +// TranslationProviderType.BingTranslateApp, +// TranslationProviderType.PapagoTranslateApp, +// TranslationProviderType.YandexTranslateApp, +// TranslationProviderType.OtherTranslateApp, +// TranslationProviderType.OCROnly -> { +// } +// } +// } +// } +// +// fun textTranslated(result: Result) { +// viewScope.launch { +// _displayTranslationProgress.value = false +// +// when (result) { +// is Result.Translated -> { +// _translatedText.value = result.translatedText to R.color.foregroundSecond +// +// if (repo.hideRecognizedTextAfterTranslated().first()) { +// _displayRecognitionBlock.value = false +// } +// } +// +// is Result.SourceLangNotSupport -> { +// _translatedText.value = +// context.getString(R.string.msg_translator_provider_does_not_support_the_ocr_lang) to R.color.alert +// } +// +// is Result.OCROnly -> { +// } +// } +// } +// } +// +// fun onOCRTextEdited(text: String) { +// viewScope.launch { +// val langCode = _ocrText.value!!.langCode() +// _ocrText.value = text to langCode +// +//// val langCode = try { +//// LanguageIdentify.identifyLanguage(text) +//// } catch (e: Exception) { +//// logger.debug(t = e) +//// null +//// } ?: lastLangCode +// +// stateNavigator.navigate( +// NavigationAction.NavigateToStartTranslation( +// RecognitionResult( +// langCode = langCode, +// result = text, +// boundingBoxes = lastTextBoundingBoxes, +// ) +// ) +// ) +// } +// } +// +// fun onTextSelectableChecked(checked: Boolean) { +// viewScope.launch { +// AppPref.displaySelectedTextOnResultWindow = checked +// } +// } +// +// fun onWordSelected(word: String) { +// viewScope.launch { +// _displayTextInfoSearchView.value = TextInfoSearchViewData( +// text = word, +// sourceLang = AppPref.selectedOCRLang, +// targetLang = AppPref.selectedTranslationLang, +// ) +// } +// } +// +// data class TextInfoSearchViewData( +// val text: String, +// val sourceLang: String, +// val targetLang: String, +// ) +//} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/textInfoSearch/TextInfoSearchView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/textInfoSearch/TextInfoSearchView.kt index 3de2f94b..ce0a2484 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/floatings/textInfoSearch/TextInfoSearchView.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/textInfoSearch/TextInfoSearchView.kt @@ -38,7 +38,7 @@ class TextInfoSearchView( get() = true private val viewModel: TextInfoSearchViewModel by lazy { - TextInfoSearchViewModel(viewScope, text, sourceLang) + TextInfoSearchViewModel(viewScope, text, sourceLang, targetLang) } private val binding: FloatingTextInfoSearchBinding = diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/textInfoSearch/TextInfoSearchViewModel.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/textInfoSearch/TextInfoSearchViewModel.kt index 4f66094d..b96d6a1d 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/floatings/textInfoSearch/TextInfoSearchViewModel.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/textInfoSearch/TextInfoSearchViewModel.kt @@ -1,5 +1,6 @@ package tw.firemaples.onscreenocr.floatings.textInfoSearch +import android.net.Uri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineScope @@ -7,18 +8,20 @@ import kotlinx.coroutines.launch import tw.firemaples.onscreenocr.floatings.base.FloatingViewModel import tw.firemaples.onscreenocr.pref.AppPref import tw.firemaples.onscreenocr.utils.Constants +import tw.firemaples.onscreenocr.utils.toGoogleTranslateLang import java.net.URLEncoder class TextInfoSearchViewModel( viewScope: CoroutineScope, private val text: String, - private val sourceLang: String + private val sourceLang: String, + private val targetLang: String, ) : FloatingViewModel(viewScope) { private val _loadUrl = MutableLiveData() val loadUrl: LiveData = _loadUrl private var lastPageType: PageType - get() = PageType.values().firstOrNull { it.id == AppPref.lastTextInfoSearchPage } + get() = PageType.entries.firstOrNull { it.id == AppPref.lastTextInfoSearchPage } ?: Constants.DEFAULT_TEXT_INFO_SEARCH_PAGE set(value) { AppPref.lastTextInfoSearchPage = value.id @@ -27,7 +30,7 @@ class TextInfoSearchViewModel( fun onLoad() { viewScope.launch { val page: Page = when (lastPageType) { - PageType.GoogleTranslate -> Page.GoogleTranslate(text, sourceLang) + PageType.GoogleTranslate -> Page.GoogleTranslate(text, sourceLang, targetLang) PageType.GoogleSearch -> Page.GoogleSearch(text, sourceLang) PageType.GoogleDefinition -> Page.GoogleDefinition(text, sourceLang) PageType.GoogleImageSearch -> Page.GoogleImageSearch(text, sourceLang) @@ -45,7 +48,7 @@ class TextInfoSearchViewModel( fun onGoogleTranslateClicked() { viewScope.launch { - loadPage(Page.GoogleTranslate(text, sourceLang)) + loadPage(Page.GoogleTranslate(text, sourceLang, targetLang)) } } @@ -88,18 +91,23 @@ class TextInfoSearchViewModel( sealed class Page(val text: String, val sourceLang: String, val pageType: PageType) { companion object { - fun default(text: String, sourceLang: String): Page { - return GoogleTranslate(text, sourceLang) + fun default(text: String, sourceLang: String, targetLang: String): Page { + return GoogleTranslate(text, sourceLang, targetLang) } } abstract val url: String val encodedText: String get() = URLEncoder.encode(text, "utf-8") - class GoogleTranslate(text: String, sourceLang: String) : + class GoogleTranslate(text: String, sourceLang: String, val targetLang: String) : Page(text, sourceLang, PageType.GoogleTranslate) { override val url: String - get() = "https://translate.google.com/?sl=$sourceLang&text=$encodedText&op=translate" + get() = Uri.parse("https://translate.google.com/?op=translate") + .buildUpon() + .appendQueryParameter("sl", sourceLang.toGoogleTranslateLang()) + .appendQueryParameter("tl", targetLang.toGoogleTranslateLang()) + .appendQueryParameter("text", text) + .toString() } class Wikipedia(text: String, sourceLang: String) : diff --git a/main/src/main/java/tw/firemaples/onscreenocr/pref/AppPref.kt b/main/src/main/java/tw/firemaples/onscreenocr/pref/AppPref.kt index b3a02c2f..210ce1c0 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/pref/AppPref.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/pref/AppPref.kt @@ -17,7 +17,7 @@ object AppPref : KotprefModel() { Kotpref.gson = Gson() } - private var selectedOCRProviderKey by stringPref( + var selectedOCRProviderKey by stringPref( default = Constants.DEFAULT_OCR_PROVIDER.key ) var selectedOCRProvider: TextRecognitionProviderType diff --git a/main/src/main/java/tw/firemaples/onscreenocr/translator/BaseAppTranslator.kt b/main/src/main/java/tw/firemaples/onscreenocr/translator/BaseAppTranslator.kt index 9ae4ca2b..4ddc6409 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/translator/BaseAppTranslator.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/translator/BaseAppTranslator.kt @@ -16,7 +16,7 @@ abstract class BaseAppTranslator : Translator { context.getString(type.nameRes) ) - override suspend fun checkEnvironment( + override suspend fun checkResources( coroutineScope: CoroutineScope ): Boolean = translatorUtils.checkIsInstalled() diff --git a/main/src/main/java/tw/firemaples/onscreenocr/translator/Translator.kt b/main/src/main/java/tw/firemaples/onscreenocr/translator/Translator.kt index 5a9f0e0a..19c17669 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/translator/Translator.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/translator/Translator.kt @@ -47,7 +47,11 @@ interface Translator { val defaultLanguage: String get() = Constants.DEFAULT_TRANSLATION_LANG - suspend fun checkEnvironment(coroutineScope: CoroutineScope): Boolean = true + /** + * Check the required resources is ready + * @return true if required resources are ready + */ + suspend fun checkResources(coroutineScope: CoroutineScope): Boolean = true suspend fun isLangSupport(): Boolean = supportedLanguages().any { it.code.firstPart() == AppPref.selectedOCRLang.firstPart() } diff --git a/main/src/main/java/tw/firemaples/onscreenocr/translator/googlemlkit/GoogleMLKitTranslator.kt b/main/src/main/java/tw/firemaples/onscreenocr/translator/googlemlkit/GoogleMLKitTranslator.kt index 2c2bd18a..4018102e 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/translator/googlemlkit/GoogleMLKitTranslator.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/translator/googlemlkit/GoogleMLKitTranslator.kt @@ -6,9 +6,6 @@ import com.google.mlkit.nl.translate.TranslateLanguage import com.google.mlkit.nl.translate.TranslateRemoteModel import com.google.mlkit.nl.translate.Translation import com.google.mlkit.nl.translate.TranslatorOptions -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import tw.firemaples.onscreenocr.R @@ -20,6 +17,9 @@ import tw.firemaples.onscreenocr.translator.TranslationProviderType import tw.firemaples.onscreenocr.translator.TranslationResult import tw.firemaples.onscreenocr.translator.Translator import tw.firemaples.onscreenocr.utils.firstPart +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine object GoogleMLKitTranslator : Translator { private const val DOWNLOAD_SITE = "GoogleMLKit" @@ -52,7 +52,7 @@ object GoogleMLKitTranslator : Translator { } } - override suspend fun checkEnvironment(coroutineScope: CoroutineScope): Boolean = + override suspend fun checkResources(coroutineScope: CoroutineScope): Boolean = checkTranslationResources(coroutineScope) override suspend fun translate(text: String, sourceLangCode: String): TranslationResult { diff --git a/main/src/main/java/tw/firemaples/onscreenocr/utils/QuickTileService.kt b/main/src/main/java/tw/firemaples/onscreenocr/utils/QuickTileService.kt index 62066f1a..02350353 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/utils/QuickTileService.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/utils/QuickTileService.kt @@ -6,15 +6,21 @@ import android.os.IBinder import android.service.quicksettings.Tile import android.service.quicksettings.TileService import androidx.annotation.RequiresApi +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* -import tw.firemaples.onscreenocr.floatings.manager.FloatingStateManager +import tw.firemaples.onscreenocr.floatings.manager.FloatingViewCoordinator import tw.firemaples.onscreenocr.pages.launch.LaunchActivity import tw.firemaples.onscreenocr.screenshot.ScreenExtractor +import javax.inject.Inject +@AndroidEntryPoint @RequiresApi(Build.VERSION_CODES.N) class QuickTileService : TileService() { private val logger: Logger by lazy { Logger(QuickTileService::class) } + @Inject + lateinit var floatingViewCoordinator: FloatingViewCoordinator + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var listeningJob: Job? = null @@ -40,11 +46,11 @@ class QuickTileService : TileService() { super.onClick() logger.debug("onClick()") - if (FloatingStateManager.isMainBarAttached) { - FloatingStateManager.detachAllViews() + if (floatingViewCoordinator.isMainBarAttached) { + floatingViewCoordinator.detachAllViews() } else { if (ScreenExtractor.isGranted) { - FloatingStateManager.showMainBar() + floatingViewCoordinator.showMainBar() } else { startActivityAndCollapse(LaunchActivity.getLaunchIntent(this)) } @@ -56,7 +62,7 @@ class QuickTileService : TileService() { logger.debug("onStartListening()") listeningJob = scope.launch { - FloatingStateManager.showingStateChangedFlow.collect { + floatingViewCoordinator.showingStateChangedFlow.collect { updateTileState(it) } } diff --git a/main/src/main/java/tw/firemaples/onscreenocr/utils/Utils.kt b/main/src/main/java/tw/firemaples/onscreenocr/utils/Utils.kt index 5b2504af..cb7afbb7 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/utils/Utils.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/utils/Utils.kt @@ -107,6 +107,19 @@ object Utils { fun String.firstPart(): String = split(":")[0].split("-")[0] +private val googleTranslateLang = mapOf( + "zh-TW" to setOf("zh-tw", "zh-hant", "zh"), + "zh-CN" to setOf("zh-cn", "zh-hans"), +) + +fun String.toGoogleTranslateLang(): String { + val target = this + googleTranslateLang.entries.forEach { (lang, set) -> + if (set.contains(target.lowercase())) return lang + } + return target.firstPart() +} + fun Context.getThemedLayoutInflater(theme: Int = R.style.Theme_EverTranslator): LayoutInflater = LayoutInflater.from(this) .cloneInContext(ContextThemeWrapper(this, theme)) diff --git a/main/src/main/java/tw/firemaples/onscreenocr/wigets/BackButtonTrackerView.kt b/main/src/main/java/tw/firemaples/onscreenocr/wigets/BackButtonTrackerView.kt index 6939eb9d..0d1b4d16 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/wigets/BackButtonTrackerView.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/wigets/BackButtonTrackerView.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.AttributeSet import android.view.KeyEvent import android.widget.FrameLayout +import tw.firemaples.onscreenocr.utils.Logger open class BackButtonTrackerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @@ -12,6 +13,8 @@ open class BackButtonTrackerView @JvmOverloads constructor( var onBackButtonPressed: (() -> Boolean)? = null, ) : FrameLayout(context, attrs) { + private val logger by lazy { Logger(this::class) } + override fun onAttachedToWindow() { super.onAttachedToWindow() onAttachedToWindow?.invoke() @@ -23,6 +26,7 @@ open class BackButtonTrackerView @JvmOverloads constructor( } override fun dispatchKeyEvent(event: KeyEvent): Boolean { + logger.debug("dispatchKeyEvent(), $event") if (event.keyCode == KeyEvent.KEYCODE_BACK && keyDispatcherState != null) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { keyDispatcherState.startTracking(event, this) diff --git a/main/src/main/res/values/strings.xml b/main/src/main/res/values/strings.xml index 2c6cc7e2..723ce1e2 100644 --- a/main/src/main/res/values/strings.xml +++ b/main/src/main/res/values/strings.xml @@ -74,6 +74,7 @@ Translation language selection is not available for OCR only mode Request permission Open in Browser + [All] EverTranslator needs the [Display over other apps] permission to show a floating window on the screen. EverTranslator needs the [Capture Screen] permission to find the text from the screen for you. @@ -117,4 +118,4 @@ None Keep MediaProjection resources - \ No newline at end of file +