diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd30f27a..0d6399c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: [ "master", "dep/**" ] pull_request: - branches: [ "master", "dep/**" ] + branches: [ "master", "dep/**", "refactor/**" ] jobs: build: @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' @@ -25,7 +25,7 @@ jobs: - name: Build with Gradle run: ./gradlew assembleDevDebug - name: Upload debug APK - uses: actions/upload-artifact@v3.1.3 + uses: actions/upload-artifact@v4 with: name: main-dev-debug.apk path: main/build/outputs/apk/dev/debug/main-dev-debug.apk diff --git a/.gitignore b/.gitignore index d093678d..1abf2ccd 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ google-services.json !main/src/dev/google-services.json config_ad.xml !main/src/main/res/values/config_ad.xml +.kotlin/ diff --git a/build.gradle b/build.gradle index 552fdc34..81dcb03f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,18 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.nav_version = "2.6.0" // require SDK 34 from 2.7.0 + ext.nav_version = "2.9.0" // require SDK 34 from 2.7.0 + ext.hilt_version = "2.56.2" + ext.coroutines_version = "1.10.2" + ext.lifecycle_version = "2.9.0" + ext.text_recognition_version = "16.0.1" + ext.kotpref_version = "2.13.2" + ext.retrofit2_version = "3.0.0" + ext.moshi_version = "1.15.2" + repositories { google() + gradlePluginPortal() mavenCentral() maven { url "https://jitpack.io" @@ -13,20 +22,28 @@ buildscript { } } dependencies { - classpath 'com.android.tools.build:gradle:8.1.3' + classpath 'com.android.tools.build:gradle:8.9.1' // 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 'com.google.gms:google-services:4.4.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.21" classpath 'com.google.firebase:perf-plugin:1.4.2' - classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9' + classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.3' // Refactor classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version" + + // Detekt + classpath "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.23.8" } } +plugins { + id 'com.google.dagger.hilt.android' version '2.56.2' apply false + id 'com.google.devtools.ksp' version '2.1.21-2.0.1' 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/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ac72c34e..e2847c82 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/main/build.gradle b/main/build.gradle index a757aef0..ecc2bc91 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -1,11 +1,14 @@ plugins { id 'com.android.application' id 'kotlin-android' - id 'kotlin-kapt' + id 'com.google.devtools.ksp' id 'androidx.navigation.safeargs.kotlin' 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" + id("org.jetbrains.kotlin.plugin.compose") version "2.1.21" } def buildParams = getGradle().getStartParameter().toString().toLowerCase() @@ -20,15 +23,14 @@ task ensureFiles { } android { - compileSdk 33 - buildToolsVersion = "33.0.1" + compileSdk 35 defaultConfig { applicationId "tw.firemaples.onscreenocr" minSdkVersion 21 - targetSdkVersion 33 - versionCode 117 - versionName "3.1.25" + targetSdkVersion 35 + versionCode 128 + versionName "4.0.10" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -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.15" } packagingOptions { jniLibs { @@ -101,26 +107,29 @@ android { } dependencies { + detektPlugins "io.nlopez.compose.rules:detekt:0.4.22" + implementation fileTree(include: ['*.jar', '*.aar'], dir: 'libs') - implementation 'androidx.core:core-ktx:1.10.1' - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.material:material:1.9.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.core:core-ktx:1.16.0' + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.constraintlayout:constraintlayout:2.2.1' 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-livedata-ktx:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version" implementation "androidx.preference:preference-ktx:1.2.1" - implementation 'androidx.webkit:webkit:1.7.0' + implementation 'androidx.webkit:webkit:1.13.0' //noinspection GradleDynamicVersion testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" // For using coroutines in tests // For runBlockingTest, CoroutineDispatcher etc. - testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" // For InstantTaskExecutorRule testImplementation "androidx.arch.core:core-testing:2.2.0" @@ -136,10 +145,27 @@ dependencies { androidTestImplementation "androidx.navigation:navigation-testing:$nav_version" // required to avoid crash on Android 12 API 31 - implementation 'androidx.work:work-runtime-ktx:2.8.1' + implementation 'androidx.work:work-runtime-ktx:2.10.1' + + // Compose + def composeBom = platform('androidx.compose:compose-bom:2025.05.00') + 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:$hilt_version" + ksp "com.google.dagger:hilt-compiler:$hilt_version" // Firebase - implementation platform('com.google.firebase:firebase-bom:32.6.0') + implementation platform('com.google.firebase:firebase-bom:33.13.0') implementation 'com.google.firebase:firebase-crashlytics' implementation 'com.google.firebase:firebase-analytics' // implementation 'com.google.firebase:firebase-core' @@ -148,49 +174,52 @@ dependencies { // Google MLKit - Text recognition v2 // To recognize Latin script - implementation 'com.google.mlkit:text-recognition:16.0.0' + implementation "com.google.mlkit:text-recognition:$text_recognition_version" // To recognize Chinese script - implementation 'com.google.mlkit:text-recognition-chinese:16.0.0' + implementation "com.google.mlkit:text-recognition-chinese:$text_recognition_version" // To recognize Devanagari script - implementation 'com.google.mlkit:text-recognition-devanagari:16.0.0' + implementation "com.google.mlkit:text-recognition-devanagari:$text_recognition_version" // To recognize Japanese script - implementation 'com.google.mlkit:text-recognition-japanese:16.0.0' + implementation "com.google.mlkit:text-recognition-japanese:$text_recognition_version" // To recognize Korean script - implementation 'com.google.mlkit:text-recognition-korean:16.0.0' + implementation "com.google.mlkit:text-recognition-korean:$text_recognition_version" // Google MLKit - Text translate - implementation 'com.google.mlkit:translate:17.0.2' + implementation 'com.google.mlkit:translate:17.0.3' // Google MLKit - Identify Languages - implementation 'com.google.mlkit:language-id:17.0.4' + implementation 'com.google.mlkit:language-id:17.0.6' // Kotpref // core - implementation 'com.chibatching.kotpref:kotpref:2.13.2' + implementation "com.chibatching.kotpref:kotpref:$kotpref_version" // optional, auto initialization module - implementation 'com.chibatching.kotpref:initializer:2.13.2' + implementation "com.chibatching.kotpref:initializer:$kotpref_version" // optional, support saving enum value and ordinal - implementation 'com.chibatching.kotpref:enum-support:2.13.2' + implementation "com.chibatching.kotpref:enum-support:$kotpref_version" // optional, support saving json string through Gson - implementation 'com.chibatching.kotpref:gson-support:2.13.2' - implementation 'com.google.code.gson:gson:2.10.1' + implementation "com.chibatching.kotpref:gson-support:$kotpref_version" + implementation 'com.google.code.gson:gson:2.13.1' // optional, support LiveData observable preference - implementation 'com.chibatching.kotpref:livedata-support:2.13.2' + implementation "com.chibatching.kotpref:livedata-support:$kotpref_version" // implementation 'androidx.lifecycle:lifecycle-livedata:2.2.0' // // experimental, preference screen build dsl // implementation 'com.chibatching.kotpref:preference-screen-dsl:2.13.1' // Retrofit - implementation 'com.squareup.retrofit2:retrofit:2.9.0' - implementation 'com.squareup.retrofit2:converter-moshi:2.9.0' - implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' + implementation "com.squareup.retrofit2:retrofit:$retrofit2_version" + implementation "com.squareup.retrofit2:converter-moshi:$retrofit2_version" + implementation "com.squareup.okhttp3:logging-interceptor:4.12.0" + // Moshi + ksp("com.squareup.moshi:moshi-kotlin-codegen:$moshi_version") + implementation "com.squareup.moshi:moshi-kotlin:$moshi_version" // Tesseract - implementation 'cz.adaptech.tesseract4android:tesseract4android:4.5.0' + implementation 'cz.adaptech.tesseract4android:tesseract4android:4.8.0' // implementation 'cz.adaptech:tesseract4android:4.1.0' // koral--/android-gif-drawable - implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.28' + implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.29' // // Ad network mediation - Mopub // implementation('com.mopub:mopub-sdk:+@aar') { @@ -210,9 +239,9 @@ dependencies { // implementation 'com.mopub.mediation:adcolony:4.5.0.1' // Admob - implementation 'com.google.android.gms:play-services-ads:22.5.0' + implementation 'com.google.android.gms:play-services-ads:23.6.0' // Facebook Ad network - implementation 'com.google.ads.mediation:facebook:6.16.0.0' + implementation 'com.google.ads.mediation:facebook:6.19.0.0' // Adcolony Ad network implementation 'com.google.ads.mediation:adcolony:4.8.0.2' // To fix build failed with issue @@ -221,5 +250,5 @@ dependencies { implementation 'com.facebook.infer.annotation:infer-annotation:0.18.0' // TouchImageView - implementation 'com.github.MikeOrtiz:TouchImageView:3.6' + implementation 'com.github.MikeOrtiz:TouchImageView:3.7.1' } diff --git a/main/src/main/AndroidManifest.xml b/main/src/main/AndroidManifest.xml index 8282b884..256c14fb 100644 --- a/main/src/main/AndroidManifest.xml +++ b/main/src/main/AndroidManifest.xml @@ -3,7 +3,9 @@ xmlns:tools="http://schemas.android.com/tools" android:installLocation="auto"> + + + + @@ -70,4 +77,4 @@ - \ No newline at end of file + 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/api/ApiHub.kt b/main/src/main/java/tw/firemaples/onscreenocr/api/ApiHub.kt index 3cd23db9..46ac5b0b 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/api/ApiHub.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/api/ApiHub.kt @@ -1,15 +1,27 @@ package tw.firemaples.onscreenocr.api import android.content.Context +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory import tw.firemaples.onscreenocr.utils.Utils import java.io.File object ApiHub { private val context: Context by lazy { Utils.context } + private val moshi: Moshi by lazy { + Moshi.Builder() + .addLast(KotlinJsonAdapterFactory()) + .build() + } + private val retrofit: Retrofit by lazy { - Retrofit.Builder().baseUrl("http://localhost/").build() + Retrofit.Builder() + .baseUrl("http://localhost/") + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() } val tessDataTempFile: File by lazy { 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..62217e6a --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/PreferenceRepository.kt @@ -0,0 +1,37 @@ +package tw.firemaples.onscreenocr.data.repo + +import android.graphics.Point +import android.graphics.Rect +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 + } + + fun getLastSelectedArea() = AppPref.lastSelectionArea + + fun setLastSelectedArea(rect: Rect) { + AppPref.lastSelectionArea = rect + } +} 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..d3c4fdb1 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/SettingRepository.kt @@ -0,0 +1,18 @@ +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 + + fun limitResultViewMaxWidth(): Boolean = + SettingManager.limitResultViewMaxWidth + + fun rememberLastSelectionArea(): Boolean = + SettingManager.saveLastSelectionArea +} 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/GetLastSelectedAreaUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetLastSelectedAreaUseCase.kt new file mode 100644 index 00000000..584fd596 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetLastSelectedAreaUseCase.kt @@ -0,0 +1,10 @@ +package tw.firemaples.onscreenocr.data.usecase + +import tw.firemaples.onscreenocr.data.repo.PreferenceRepository +import javax.inject.Inject + +class GetLastSelectedAreaUseCase @Inject constructor( + private val preferenceRepository: PreferenceRepository, +) { + operator fun invoke() = preferenceRepository.getLastSelectedArea() +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetLimitResultViewMaxWidthUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetLimitResultViewMaxWidthUseCase.kt new file mode 100644 index 00000000..24aa9a34 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetLimitResultViewMaxWidthUseCase.kt @@ -0,0 +1,10 @@ +package tw.firemaples.onscreenocr.data.usecase + +import tw.firemaples.onscreenocr.data.repo.SettingRepository +import javax.inject.Inject + +class GetLimitResultViewMaxWidthUseCase @Inject constructor( + private val settingRepository: SettingRepository, +) { + operator fun invoke(): Boolean = settingRepository.limitResultViewMaxWidth() +} 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/GetRememberLastSelectionAreaUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetRememberLastSelectionAreaUseCase.kt new file mode 100644 index 00000000..f9daea7d --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetRememberLastSelectionAreaUseCase.kt @@ -0,0 +1,10 @@ +package tw.firemaples.onscreenocr.data.usecase + +import tw.firemaples.onscreenocr.data.repo.SettingRepository +import javax.inject.Inject + +class GetRememberLastSelectionAreaUseCase @Inject constructor( + private val settingRepository: SettingRepository, +) { + operator fun invoke() = settingRepository.rememberLastSelectionArea() +} 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/SetLastSelectedAreaUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SetLastSelectedAreaUseCase.kt new file mode 100644 index 00000000..9df40678 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SetLastSelectedAreaUseCase.kt @@ -0,0 +1,13 @@ +package tw.firemaples.onscreenocr.data.usecase + +import android.graphics.Rect +import tw.firemaples.onscreenocr.data.repo.PreferenceRepository +import javax.inject.Inject + +class SetLastSelectedAreaUseCase @Inject constructor( + private val preferenceRepository: PreferenceRepository, +) { + operator fun invoke(selectedRect: Rect) { + preferenceRepository.setLastSelectedArea(selectedRect) + } +} 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/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..974b5c64 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeFloatingView.kt @@ -0,0 +1,368 @@ +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.View +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.theme.AppTheme +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 lateinit var rootView: View + private fun createView(): View = + 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 + } + + rootView = createView() + 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() + + with(lifecycleOwner) { + handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + handleLifecycleEvent(Lifecycle.Event.ON_STOP) + } + + windowManager.removeView(rootView) + + 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..8de11767 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeUtils.kt @@ -0,0 +1,119 @@ +package tw.firemaples.onscreenocr.floatings.compose.base + +import android.content.res.Configuration +import android.graphics.Rect +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +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() } + +fun Modifier.clickableWithoutRipple( + interactionSource: MutableInteractionSource, + onClick: () -> Unit +) = then( + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = { onClick() } + ) +) + +fun Modifier.calculateOffset( + anchor: Rect, + offset: MutableState, + viewPadding: Float = 0f, + verticalSpacing: Float = 0f, +): Modifier = onGloballyPositioned { coordinates -> + val parent = coordinates.parentLayoutCoordinates?.size ?: return@onGloballyPositioned + val current = coordinates.size + + val leftAnchor = maxOf(anchor.left, viewPadding.toInt()) + val rightAnchor = minOf(anchor.right, parent.width - viewPadding.toInt()) + + val x = when { + leftAnchor + current.width + viewPadding < parent.width -> { + // Align left + anchor.left - viewPadding.toInt() + } + + rightAnchor - current.width - viewPadding >= 0 -> { + // Align right + rightAnchor - current.width - viewPadding.toInt() + } + + else -> { + // No horizontal alignment + 0 + } + } + + val topAnchor = anchor.bottom + verticalSpacing + val bottomAnchor = anchor.top - verticalSpacing + + val y = when { + topAnchor + current.height + viewPadding < parent.height -> { + // Display at bottom + (topAnchor - viewPadding).toInt() + } + + bottomAnchor - current.height - viewPadding >= 0 -> { + // Display at top + (bottomAnchor - current.height - viewPadding).toInt() + } + + else -> { + // Display middle vertically + val middleAnchor = (parent.height - current.height) / 2 + (middleAnchor - viewPadding).toInt() + } + } + + offset.value = IntOffset(x, y) +} + +/** + * A MultiPreview annotation for desplaying a @[Composable] method using light and dark themes. + * + * Note that the app theme should support dark and light modes for these previews to be different. + */ +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.ANNOTATION_CLASS, + AnnotationTarget.FUNCTION +) +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +annotation class PreviewThemes 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..75aeeb79 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarContent.kt @@ -0,0 +1,240 @@ +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.MaterialTheme +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.draw.alpha +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.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.theme.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 + .alpha(if (state.drawMainBar) 1f else 0f) + .background( + color = MaterialTheme.colorScheme.surface, + 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, + ) + } + } +} + +@Composable +private fun LanguageBlock( + langText: String, + translatorIcon: Int? = null, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .height(32.dp) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(4.dp), + ) + .clickable(onClick = onClick) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = langText, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + if (translatorIcon != null) { + Image( + painter = painterResource(id = translatorIcon), + contentDescription = "", + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + ) + } + } +} + +@Composable +private fun MainBarButton( + @DrawableRes + icon: Int, + onClick: () -> Unit, +) { + Image( + modifier = Modifier + .size(32.dp) + .clickable(onClick = onClick) + .background(color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(4.dp)) + .padding(4.dp), + painter = painterResource(id = icon), + contentDescription = "", + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary), + ) +} + +@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(MaterialTheme.colorScheme.onSurface), + 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 + } + + 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..c89db9b2 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarFloatingView.kt @@ -0,0 +1,149 @@ +package tw.firemaples.onscreenocr.floatings.compose.mainbar + +import android.content.Context +import android.graphics.Point +import android.graphics.Rect +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import dagger.hilt.android.qualifiers.ApplicationContext +import tw.firemaples.onscreenocr.R +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.compose.menu.MenuFloatingView +import tw.firemaples.onscreenocr.floatings.compose.menu.MenuItem +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, + private val menuFloatingView: MenuFloatingView, +) : 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) + + MainBarAction.ShowMenu ->{ + val anchor = + Rect(params.x, params.y, params.x + rootView.width, params.y + rootView.height) + menuFloatingView.getMenuViewDelegate().setAnchor(anchor) + menuFloatingView.attachToScreen() + } + + MainBarAction.HideMenu -> + menuFloatingView.detachFromScreen() + } + } + + val menuItems: List = listOf( + MenuItem( + key = MainBarMenuConst.MENU_SETTING, + text = stringResource(id = R.string.menu_setting), + ), + MenuItem( + key = MainBarMenuConst.MENU_PRIVACY_POLICY, + text = stringResource(id = R.string.menu_privacy_policy), + ), + MenuItem( + key = MainBarMenuConst.MENU_ABOUT, + text = stringResource(id = R.string.menu_about), + ), + MenuItem( + key = MainBarMenuConst.MENU_VERSION_HISTORY, + text = stringResource(id = R.string.menu_version_history), + ), + MenuItem( + key = MainBarMenuConst.MENU_README, + text = stringResource(id = R.string.menu_readme), + ), + MenuItem( + key = MainBarMenuConst.MENU_HIDE, + text = stringResource(id = R.string.menu_hide), + ), + MenuItem( + key = MainBarMenuConst.MENU_EXIT, + text = stringResource(id = R.string.menu_exit), + ), + ) + with(menuFloatingView.getMenuViewDelegate()) { + setMenuData(menuItems) + setOnMenuItemClickedListener { key -> + viewModel.onMenuItemClicked(key) + } + } + + 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/MainBarMenuConst.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarMenuConst.kt new file mode 100644 index 00000000..2415f0d8 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarMenuConst.kt @@ -0,0 +1,11 @@ +package tw.firemaples.onscreenocr.floatings.compose.mainbar + +object MainBarMenuConst { + const val MENU_SETTING = "SETTING" + const val MENU_PRIVACY_POLICY = "PRIVACY_POLICY" + const val MENU_ABOUT = "ABOUT" + const val MENU_VERSION_HISTORY = "VERSION_HISTORY" + const val MENU_README = "README" + const val MENU_HIDE = "HIDE" + const val MENU_EXIT = "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..1801af4a --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarViewModel.kt @@ -0,0 +1,282 @@ +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 onSelectClicked() + fun onTranslateClicked() + fun onCloseClicked() + fun onMenuButtonClicked() + fun onAttachedToScreen() + fun onDragEnd(x: Int, y: Int) + fun onLanguageBlockClicked() + fun onMenuItemClicked(key: String?) +} + +data class MainBarState( + val drawMainBar: Boolean = true, + 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 + data object ShowMenu : MainBarAction + data object HideMenu : 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 { + val drawMainBar = when (navState) { + NavState.Idle, + NavState.ScreenCircling, + is NavState.ScreenCircled -> true + + else -> false + } + + it.copy( + drawMainBar = drawMainBar, + 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 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) + action.emit(MainBarAction.ShowMenu) + 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 onMenuItemClicked(key: String?) { + scope.launch { + state.update { + it.copy( + displayMainBarMenu = false, + ) + } + + action.emit(MainBarAction.HideMenu) + action.emit(MainBarAction.RescheduleFadeOut) + + when (key) { + MainBarMenuConst.MENU_SETTING -> + action.emit(MainBarAction.OpenSettings) + + MainBarMenuConst.MENU_PRIVACY_POLICY -> + action.emit(MainBarAction.OpenBrowser(RemoteConfigManager.privacyPolicyUrl)) + + MainBarMenuConst.MENU_ABOUT -> + action.emit(MainBarAction.OpenBrowser(RemoteConfigManager.aboutUrl)) + + MainBarMenuConst.MENU_VERSION_HISTORY -> + action.emit(MainBarAction.OpenVersionHistory) + + MainBarMenuConst.MENU_README -> + action.emit(MainBarAction.OpenReadme) + + MainBarMenuConst.MENU_HIDE -> + action.emit(MainBarAction.HideMainBar) + + MainBarMenuConst.MENU_EXIT -> + action.emit(MainBarAction.ExitApp) + } + } + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/menu/MenuContent.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/menu/MenuContent.kt new file mode 100644 index 00000000..668c04d9 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/menu/MenuContent.kt @@ -0,0 +1,130 @@ +package tw.firemaples.onscreenocr.floatings.compose.menu + +import android.graphics.Rect +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow +import tw.firemaples.onscreenocr.R +import tw.firemaples.onscreenocr.floatings.compose.base.PreviewThemes +import tw.firemaples.onscreenocr.floatings.compose.base.calculateOffset +import tw.firemaples.onscreenocr.floatings.compose.base.clickableWithoutRipple +import tw.firemaples.onscreenocr.floatings.compose.base.dpToPx +import tw.firemaples.onscreenocr.theme.AppTheme + +@Composable +fun MenuContent(viewModel: MenuViewModel) { + val state by viewModel.state.collectAsState() + val emptyInteractionSource = remember { MutableInteractionSource() } + + val offset = remember { + mutableStateOf(IntOffset(20, 0)) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(color = colorResource(id = R.color.dialogOutside)) + .clickableWithoutRipple( + interactionSource = emptyInteractionSource, + onClick = viewModel::onMenuOutsideClicked, + ), + ) { + Column( + modifier = Modifier + .calculateOffset( + anchor = state.anchor, + offset = offset, + verticalSpacing = 4.dp.dpToPx(), + ) + .offset { offset.value } + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(8.dp), + ) + .width(IntrinsicSize.Max) + .animateContentSize(), + ) { + state.menuItems.forEach { item -> + Row( + modifier = Modifier + .clickable(onClick = { viewModel.onMenuClicked(item.key) }) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (item.selected) { + Icon( + painter = painterResource(id = R.drawable.ic_check), + contentDescription = "", + tint = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.size(2.dp)) + } + Text( + modifier = Modifier.fillMaxWidth(), + text = item.text, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +@PreviewThemes +@Composable +private fun MenuContentPreview() { + val state = MenuState( + menuItems = listOf( + MenuItem( + key = "1", text = "Menu Item 1", selected = true, + ), + MenuItem( + key = "2", text = "Menu Item 2", selected = false, + ), + MenuItem( + key = "3", text = "Menu Item 3", selected = false, + ), + ) + ) + + val viewModel = object : MenuViewModel { + override val state = MutableStateFlow(state) + override fun onMenuClicked(key: String) = Unit + override fun onMenuOutsideClicked() = Unit + override fun setOnMenuItemClickedListener(onClicked: (key: String?) -> Unit) = Unit + override fun setMenuData(menuItems: List) = Unit + override fun setAnchor(anchor: Rect) = Unit + } + + AppTheme { + MenuContent(viewModel = viewModel) + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/menu/MenuFloatingView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/menu/MenuFloatingView.kt new file mode 100644 index 00000000..0584343e --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/menu/MenuFloatingView.kt @@ -0,0 +1,28 @@ +package tw.firemaples.onscreenocr.floatings.compose.menu + +import android.content.Context +import android.view.WindowManager +import androidx.compose.runtime.Composable +import dagger.hilt.android.qualifiers.ApplicationContext +import tw.firemaples.onscreenocr.floatings.compose.base.ComposeFloatingView +import javax.inject.Inject + +class MenuFloatingView @Inject constructor( + @ApplicationContext context: Context, + private val viewModel: MenuViewModel, +) : ComposeFloatingView(context) { + + override val layoutWidth: Int + get() = WindowManager.LayoutParams.MATCH_PARENT + + override val layoutHeight: Int + get() = WindowManager.LayoutParams.MATCH_PARENT + + fun getMenuViewDelegate(): MenuViewDelegate = + this.viewModel + + @Composable + override fun RootContent() { + MenuContent(viewModel = viewModel) + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/menu/MenuModule.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/menu/MenuModule.kt new file mode 100644 index 00000000..963021d0 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/menu/MenuModule.kt @@ -0,0 +1,13 @@ +package tw.firemaples.onscreenocr.floatings.compose.menu + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface MenuModule { + @Binds + fun bindMenuViewModel(menuViewModelImpl: MenuViewModelImpl): MenuViewModel +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/menu/MenuViewModel.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/menu/MenuViewModel.kt new file mode 100644 index 00000000..18c00973 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/menu/MenuViewModel.kt @@ -0,0 +1,61 @@ +package tw.firemaples.onscreenocr.floatings.compose.menu + +import android.graphics.Rect +import androidx.compose.runtime.Stable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +interface MenuViewModel : MenuViewDelegate { + val state: StateFlow + fun onMenuClicked(key: String) + fun onMenuOutsideClicked() +} + +interface MenuViewDelegate { + fun setOnMenuItemClickedListener(onClicked: (key: String?) -> Unit) + fun setMenuData(menuItems: List) + fun setAnchor(anchor: Rect) +} + +data class MenuItem(val key: String, val text: String, val selected: Boolean = false) + +@Stable +data class MenuState( + val anchor: Rect = Rect(), + val menuItems: List = listOf(), +) + +class MenuViewModelImpl @Inject constructor() : MenuViewModel { + + override val state = MutableStateFlow(MenuState()) + + private var onMenuItemClicked: ((key: String?) -> Unit)? = null + + override fun setOnMenuItemClickedListener(onClicked: (key: String?) -> Unit) { + this.onMenuItemClicked = onClicked + } + + override fun setMenuData(menuItems: List) { + state.update { + it.copy( + menuItems = menuItems, + ) + } + } + + override fun setAnchor(anchor: Rect) { + state.update { + it.copy(anchor = anchor) + } + } + + override fun onMenuClicked(key: String) { + onMenuItemClicked?.invoke(key) + } + + override fun onMenuOutsideClicked() { + onMenuItemClicked?.invoke(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..8f00350a --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewContent.kt @@ -0,0 +1,466 @@ +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.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +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.height +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.layout.widthIn +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.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.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.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.calculateOffset +import tw.firemaples.onscreenocr.floatings.compose.base.clickableWithoutRipple +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 tw.firemaples.onscreenocr.theme.AppTheme +import tw.firemaples.onscreenocr.theme.FontSize +import java.util.Locale + +@Composable +fun ResultViewContent( + viewModel: ResultViewModel, + requestRootLocationOnScreen: () -> Rect, +) { + val state by viewModel.state.collectAsState() + val emptyInteractionSource = remember { MutableInteractionSource() } + + LaunchedEffect(Unit) { + val rootLocation = requestRootLocationOnScreen.invoke() + viewModel.onRootViewPositioned( + xOffset = rootLocation.left, + yOffset = rootLocation.top, + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(colorResource(id = R.color.dialogOutside)) + .clickableWithoutRipple( + interactionSource = emptyInteractionSource, + 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", + ) + + val panelPadding = 16.dp + + ResultPanel( + modifier = Modifier + .padding(panelPadding) + .run { + if (state.limitMaxWidth) + widthIn(max = 300.dp) + else this + } + .calculateOffset( + anchor = state.highlightUnion, + offset = targetOffset, + viewPadding = panelPadding.dpToPx(), + verticalSpacing = 4.dp.dpToPx(), + ) + .offset { animOffset } + .animateContentSize(), + viewModel = viewModel, + textSearchEnabled = state.textSearchEnabled, + fontSize = state.fontSize, + ocrState = state.ocrState, + translationState = state.translationState, + ) + } +} + +@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 = MaterialTheme.colorScheme.surface, + 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 = MaterialTheme.colorScheme.onSurface, + ) + +// Image(painter = painterResource(id = R.drawable.ic_play), contentDescription = "") + + Spacer(modifier = Modifier.size(4.dp)) + + val textSearchTintColor = if (textSearchEnabled) + MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface + 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 = LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.onSurface, + fontSize = fontSize.sp, + ), + selectedSpanStyle = SpanStyle( + color = MaterialTheme.colorScheme.onSecondary, + background = MaterialTheme.colorScheme.secondary, + ), + onTextSelected = onTextSelected, + ) + } else { + Text( + modifier = Modifier + .sizeIn(maxHeight = 150.dp) + .verticalScroll(rememberScrollState()), + text = ocrText, + color = MaterialTheme.colorScheme.onSurface, + 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 = MaterialTheme.colorScheme.onSurface, + ) + + 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 = MaterialTheme.colorScheme.onSurface, + 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 = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + maxLines = 1, + ) + } + + if (translationProviderIcon != null) { + Image( + modifier = Modifier + .align(Alignment.End) + .height(16.dp), + 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, + limitMaxWidth = true, + textSearchEnabled = true, + ocrState = OCRState( + showProcessing = false, + ocrText = "Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text Test OCR text 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..385da715 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/resultview/ResultViewModel.kt @@ -0,0 +1,407 @@ +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.GetLimitResultViewMaxWidthUseCase +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.pages.setting.SettingManager +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 limitMaxWidth: Boolean = true, + 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 getLimitResultViewMaxWidthUseCase: GetLimitResultViewMaxWidthUseCase, + 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 + } + + state.update { + it.copy( + limitMaxWidth = getLimitResultViewMaxWidthUseCase.invoke(), + ) + } + + 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, + ) + ) + } + + if (SettingManager.autoCopyOCRResult) { + val recognizedText = navState.recognitionResult.result + scope.launch { + Utils.copyToClipboard( + label = LABEL_RECOGNIZED_TEXT, + text = recognizedText, + ) + } + } + } + + + 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/screencircling/ScreenCirclingContent.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/screencircling/ScreenCirclingContent.kt new file mode 100644 index 00000000..8cf93d4f --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/screencircling/ScreenCirclingContent.kt @@ -0,0 +1,205 @@ +package tw.firemaples.onscreenocr.floatings.compose.screencircling + +import android.content.res.Configuration +import android.graphics.Rect +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.coroutines.flow.MutableStateFlow +import tw.firemaples.onscreenocr.R +import tw.firemaples.onscreenocr.floatings.screenCircling.CirclingView +import tw.firemaples.onscreenocr.floatings.screenCircling.HelperTextView +import tw.firemaples.onscreenocr.floatings.screenCircling.ProgressBorderView +import tw.firemaples.onscreenocr.theme.AppTheme +import tw.firemaples.onscreenocr.utils.getViewRect +import tw.firemaples.onscreenocr.utils.onViewPrepared + +@Composable +fun ScreenCirclingContent(viewModel: ScreenCirclingViewModel) { + val state by viewModel.state.collectAsState() + val helperTextView = remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.onViewDisplayed() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(colorResource(id = R.color.dialogOutside)) + ) { + ProgressBorderView() + + CirclingView( + selectedArea = state.selectedArea, + helperTextView = helperTextView, + onViewPrepared = viewModel::onCirclingViewPrepared, + onAreaSelected = viewModel::onAreaSelected, + ) + + HelperTextView(helperTextView = helperTextView) + } +} + +@Composable +private fun ProgressBorderView() { + var run by remember { mutableStateOf(true) } + + DisposableEffect(Unit) { + onDispose { + run = false + } + } + + // TODO refactor to compose + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + ProgressBorderView(context = context).also { view -> + if (run) { + view.start() + } + } + }, + update = { view -> + if (!run) { + view.stop() + } + } + ) +} + +@Composable +private fun CirclingView( + selectedArea: Rect?, + helperTextView: MutableState, + onViewPrepared: (viewRect: Rect) -> Unit, + onAreaSelected: (selected: Rect) -> Unit, +) { + // TODO refactor to compose + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + CirclingView( + context = context, + ).apply { + this.selectedBox = selectedArea + + this.onAreaSelected = { selected -> + onAreaSelected.invoke(selected) + } + + onViewPrepared { + onViewPrepared.invoke(getViewRect()) + } + } + }, + update = { view -> + view.helperTextView = helperTextView.value + }, + ) +} + +@Composable +private fun HelperTextView(helperTextView: MutableState) { + // TODO refactor to compose + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + HelperTextView(context = context).also { view -> + helperTextView.value = view + } + }, + ) +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ScreenCircleContentPreview() { + val viewModel = object : ScreenCirclingViewModel { + override val state = MutableStateFlow(ScreenCirclingState()) + override fun onViewDisplayed() = Unit + override fun onTranslateClicked() = Unit + override fun onCloseClick() = Unit + override fun onCirclingViewPrepared(viewRect: Rect) = Unit + override fun onAreaSelected(selected: Rect) = Unit + } + + AppTheme { + ScreenCirclingContent( + viewModel = viewModel, + ) + } +} + +//@Composable +//private fun CirclingView() { +// var startPoint: Offset? by remember { mutableStateOf(null) } +// var endPoint: Offset by remember { mutableStateOf(Offset(0f, 0f)) } +// +// Box( +// modifier = Modifier +// .fillMaxSize() +// .pointerInput(Unit) { +// detectDragGestures( +// onDragStart = { offset: Offset -> +// composeDebug("onDragStart()") +// startPoint = offset +// endPoint = offset +// }, +// onDragEnd = {}, +// onDragCancel = {}, +// onDrag = { change: PointerInputChange, dragAmount: Offset -> +// endPoint += dragAmount +// } +// ) +//// detectTransformGestures( +//// onGesture = { centroid: Offset, pan: Offset, zoom: Float, rotation: Float -> +//// composeDebug("onGesture(), centroid: $centroid, pan: $pan, zoom: $zoom, rotation: $rotation") +//// var start = startPoint +//// if (start == null) { +//// startPoint = centroid +//// return@detectTransformGestures +//// } +////// var start = startPoint ?: return@detectTransformGestures +//// var end = centroid +//// +//// start += pan +//// end += pan +//// +//// startPoint = start +//// endPoint = end +//// } +//// ) +// } +// .drawBehind { +// val start = startPoint ?: return@drawBehind +// val end = endPoint +// val left = min(start.x, end.x) +// val top = min(start.y, end.y) +// val width = abs(start.x - end.x) +// val height = abs(start.y - end.y) +// +// drawRect( +// color = Color.Green, +// topLeft = Offset(left, top), +// size = Size(width, height), +// style = Stroke(width = 2.dp.toPx()), +// ) +// } +// ) +//} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/screencircling/ScreenCirclingFloatingView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/screencircling/ScreenCirclingFloatingView.kt new file mode 100644 index 00000000..4330bd1d --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/screencircling/ScreenCirclingFloatingView.kt @@ -0,0 +1,34 @@ +package tw.firemaples.onscreenocr.floatings.compose.screencircling + +import android.content.Context +import android.view.WindowManager +import androidx.compose.runtime.Composable +import dagger.hilt.android.qualifiers.ApplicationContext +import tw.firemaples.onscreenocr.floatings.compose.base.ComposeFloatingView +import javax.inject.Inject + + +class ScreenCirclingFloatingView @Inject constructor( + @ApplicationContext context: Context, + private val viewModel: ScreenCirclingViewModel, +) : ComposeFloatingView(context) { + + override val fullscreenMode: Boolean + get() = true + + 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() { + ScreenCirclingContent( + viewModel = viewModel, + ) + } +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/screencircling/ScreenCirclingModule.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/screencircling/ScreenCirclingModule.kt new file mode 100644 index 00000000..3a05ce02 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/screencircling/ScreenCirclingModule.kt @@ -0,0 +1,13 @@ +package tw.firemaples.onscreenocr.floatings.compose.screencircling + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface ScreenCirclingModule { + @Binds + fun bindScreenCirclingViewModel(impl: ScreenCirclingViewModelImpl): ScreenCirclingViewModel +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/screencircling/ScreenCirclingViewModel.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/screencircling/ScreenCirclingViewModel.kt new file mode 100644 index 00000000..d09e5e90 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/screencircling/ScreenCirclingViewModel.kt @@ -0,0 +1,125 @@ +package tw.firemaples.onscreenocr.floatings.compose.screencircling + +import android.graphics.Rect +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import tw.firemaples.onscreenocr.data.usecase.GetCurrentOCRLangUseCase +import tw.firemaples.onscreenocr.data.usecase.GetLastSelectedAreaUseCase +import tw.firemaples.onscreenocr.data.usecase.GetRememberLastSelectionAreaUseCase +import tw.firemaples.onscreenocr.data.usecase.SetLastSelectedAreaUseCase +import tw.firemaples.onscreenocr.di.MainImmediateCoroutineScope +import tw.firemaples.onscreenocr.floatings.manager.NavigationAction +import tw.firemaples.onscreenocr.floatings.manager.StateNavigator +import tw.firemaples.onscreenocr.utils.Logger +import javax.inject.Inject + +interface ScreenCirclingViewModel { + val state: StateFlow + fun onViewDisplayed() + fun onTranslateClicked() + fun onCloseClick() + fun onCirclingViewPrepared(viewRect: Rect) + fun onAreaSelected(selected: Rect) +} + +data class ScreenCirclingState( + val selectedArea: Rect? = null, +) + +class ScreenCirclingViewModelImpl @Inject constructor( + @MainImmediateCoroutineScope + private val scope: CoroutineScope, + private val stateNavigator: StateNavigator, + private val getRememberLastSelectionAreaUseCase: GetRememberLastSelectionAreaUseCase, + private val getCurrentOCRLangUseCase: GetCurrentOCRLangUseCase, + private val getLastSelectedAreaUseCase: GetLastSelectedAreaUseCase, + private val setLastSelectedAreaUseCase: SetLastSelectedAreaUseCase, +) : ScreenCirclingViewModel { + private val logger = Logger(ScreenCirclingViewModel::class) + + override val state = MutableStateFlow(ScreenCirclingState()) + + private var viewRectFlow = MutableSharedFlow() + private var selectedRectFlow = MutableSharedFlow() + + init { + combine( + flow = viewRectFlow, + flow2 = selectedRectFlow, + transform = ::onScreenCircled, + ).launchIn(scope) + } + + override fun onViewDisplayed() { + logger.debug("onViewDisplayed()") + if (getRememberLastSelectionAreaUseCase.invoke()) { + val lastSelectedArea = getLastSelectedAreaUseCase.invoke() + state.update { + it.copy( + selectedArea = lastSelectedArea, + ) + } + + if (lastSelectedArea != null) { + scope.launch { + selectedRectFlow.emit(lastSelectedArea) + } + } + } + } + + override fun onTranslateClicked() { + scope.launch { + val (ocrProvider, ocrLang) = getCurrentOCRLangUseCase.invoke().first() + stateNavigator.navigate( + NavigationAction.NavigateToScreenCapturing( + ocrLang = ocrLang, + ocrProvider = ocrProvider, + ) + ) + } + } + + override fun onCloseClick() { + scope.launch { + stateNavigator.navigate(NavigationAction.CancelScreenCircling) + } + } + + override fun onCirclingViewPrepared(viewRect: Rect) { + logger.debug("onCirclingViewPrepared(), $viewRect") + scope.launch { + viewRectFlow.emit(viewRect) + } + } + + override fun onAreaSelected(selected: Rect) { + logger.debug("onAreaSelected(), $selected") + scope.launch { + setLastSelectedAreaUseCase.invoke(selected) + selectedRectFlow.emit(selected) + state.update { + it.copy(selectedArea = selected) + } + } + } + + private fun onScreenCircled(viewRect: Rect, selectedRect: Rect) { + logger.debug("onScreenCircled(), parent: $viewRect, selected: $selectedRect") + scope.launch { + stateNavigator.navigate( + NavigationAction.NavigateToScreenCircled( + parentRect = viewRect, + selectedRect = selectedRect, + ) + ) + } + } +} 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..36417870 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/wigets/WordSelectionText.kt @@ -0,0 +1,129 @@ +package tw.firemaples.onscreenocr.floatings.compose.wigets + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.LocalTextStyle +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 = LocalTextStyle.current, + 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..08008dc1 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/FloatingViewCoordinator.kt @@ -0,0 +1,104 @@ +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.compose.screencircling.ScreenCirclingFloatingView +import tw.firemaples.onscreenocr.floatings.dialog.showErrorDialog +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 screenCirclingFloatingView: ScreenCirclingFloatingView, + 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.ShowScreenCirclingView -> + screenCirclingFloatingView.attachToScreen() + + StateOperatorAction.HideScreenCirclingView -> + screenCirclingFloatingView.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) + 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..1807f265 --- /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 object NavigateToIdle : 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..4e5182c8 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/manager/StateOperator.kt @@ -0,0 +1,479 @@ +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 = 200L + } +} + +@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() + + 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) + + delay(SCREENSHOT_DELAY) + + var bitmap: Bitmap? = null + try { + FirebaseEvent.logStartCaptureScreen() + val croppedBitmap = ScreenExtractor.extractBitmapFromScreen( + parentRect = parentRect, + cropRect = selectedRect, + ).also { + bitmap = it + } + FirebaseEvent.logCaptureScreenFinished() + + stateNavigator.navigate( + NavigationAction.NavigateToTextRecognition( + ocrLang = ocrLang, + ocrProvider = ocrProvider, + croppedBitmap = croppedBitmap, + parentRect = parentRect, + selectedRect = selectedRect, + ) + ) + } catch (t: TimeoutCancellationException) { + logger.debug(t = t) + FirebaseEvent.logCaptureScreenFailed(t) + showError(context.getString(R.string.error_capture_screen_timeout)) + bitmap?.setReusable() + } catch (t: Throwable) { + logger.debug(t = t) + FirebaseEvent.logCaptureScreenFailed(t) + val errorMsg = + t.message ?: context.getString(R.string.error_unknown_error_capturing_screen) + showError(errorMsg) + 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() = scope.launch { + if (currentNavState != NavState.Idle) + stateNavigator.updateState(NavState.Idle) + + action.emit(StateOperatorAction.HideResultView) + + currentNavState.getBitmap()?.setReusable() + } + + private fun NavState.getBitmap(): Bitmap? = + (this as? BitmapIncluded)?.bitmap +} + +sealed interface StateOperatorAction { + data object TopMainBar : 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/screenCircling/ScreenCirclingView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/screenCircling/ScreenCirclingView.kt index fcd06985..ba1bcc3f 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/floatings/screenCircling/ScreenCirclingView.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/screenCircling/ScreenCirclingView.kt @@ -1,66 +1,66 @@ -package tw.firemaples.onscreenocr.floatings.screenCircling - -import android.content.Context -import android.graphics.Rect -import android.view.WindowManager -import tw.firemaples.onscreenocr.R -import tw.firemaples.onscreenocr.floatings.base.FloatingView -import tw.firemaples.onscreenocr.utils.getViewRect - -class ScreenCirclingView(context: Context) : FloatingView(context) { - override val layoutId: Int - get() = R.layout.floating_screen_circling - - //TODO check this - override val fullscreenMode: Boolean - get() = true - - override val layoutWidth: Int - get() = WindowManager.LayoutParams.MATCH_PARENT - override val layoutHeight: Int - get() = WindowManager.LayoutParams.MATCH_PARENT - - private val circlingView: CirclingView = rootView.findViewById(R.id.view_circlingView) - private val progressBorderView: ProgressBorderView = - rootView.findViewById(R.id.view_progressBorder) - private val helperTextView: HelperTextView = rootView.findViewById(R.id.view_helperText) - - private val viewModel: ScreenCirclingViewModel by lazy { ScreenCirclingViewModel(viewScope) } - - var onAreaSelected: ((parent: Rect, selected: Rect) -> Unit)? = null - - init { - setViews() - } - - private fun setViews() { - circlingView.helperTextView = helperTextView - circlingView.onAreaSelected = { selected -> - viewModel.onAreaSelected(selected) - onAreaSelected?.invoke(circlingView.getViewRect(), selected) - } - - viewModel.lastSelectedArea.observe(lifecycleOwner) { selected -> - selected ?: return@observe - if (circlingView.getViewRect().contains(selected)) { - circlingView.selectedBox = selected - onAreaSelected?.invoke(circlingView.getViewRect(), selected) - } - } - } - - override fun attachToScreen() { - super.attachToScreen() - - progressBorderView.start() - - viewModel.onAttached() - } - - override fun detachFromScreen() { - super.detachFromScreen() - - circlingView.clear() - progressBorderView.stop() - } -} +//package tw.firemaples.onscreenocr.floatings.screenCircling +// +//import android.content.Context +//import android.graphics.Rect +//import android.view.WindowManager +//import tw.firemaples.onscreenocr.R +//import tw.firemaples.onscreenocr.floatings.base.FloatingView +//import tw.firemaples.onscreenocr.utils.getViewRect +// +//class ScreenCirclingView(context: Context) : FloatingView(context) { +// override val layoutId: Int +// get() = R.layout.floating_screen_circling +// +// //TODO check this +// override val fullscreenMode: Boolean +// get() = true +// +// override val layoutWidth: Int +// get() = WindowManager.LayoutParams.MATCH_PARENT +// override val layoutHeight: Int +// get() = WindowManager.LayoutParams.MATCH_PARENT +// +// private val circlingView: CirclingView = rootView.findViewById(R.id.view_circlingView) +// private val progressBorderView: ProgressBorderView = +// rootView.findViewById(R.id.view_progressBorder) +// private val helperTextView: HelperTextView = rootView.findViewById(R.id.view_helperText) +// +// private val viewModel: ScreenCirclingViewModel by lazy { ScreenCirclingViewModel(viewScope) } +// +// var onAreaSelected: ((parent: Rect, selected: Rect) -> Unit)? = null +// +// init { +// setViews() +// } +// +// private fun setViews() { +// circlingView.helperTextView = helperTextView +// circlingView.onAreaSelected = { selected -> +// viewModel.onAreaSelected(selected) +// onAreaSelected?.invoke(circlingView.getViewRect(), selected) +// } +// +// viewModel.lastSelectedArea.observe(lifecycleOwner) { selected -> +// selected ?: return@observe +// if (circlingView.getViewRect().contains(selected)) { +// circlingView.selectedBox = selected +// onAreaSelected?.invoke(circlingView.getViewRect(), selected) +// } +// } +// } +// +// override fun attachToScreen() { +// super.attachToScreen() +// +// progressBorderView.start() +// +// viewModel.onAttached() +// } +// +// override fun detachFromScreen() { +// super.detachFromScreen() +// +// circlingView.clear() +// progressBorderView.stop() +// } +//} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/screenCircling/ScreenCirclingViewModel.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/screenCircling/ScreenCirclingViewModel.kt index d81744a6..03d0f7c4 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/floatings/screenCircling/ScreenCirclingViewModel.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/screenCircling/ScreenCirclingViewModel.kt @@ -1,34 +1,34 @@ -package tw.firemaples.onscreenocr.floatings.screenCircling - -import android.graphics.Rect -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.floatings.base.FloatingViewModel -import tw.firemaples.onscreenocr.repo.GeneralRepository - -class ScreenCirclingViewModel(viewScope: CoroutineScope) : FloatingViewModel(viewScope) { - private val generalRepo = GeneralRepository() - - private val _lastSelectedArea = MutableLiveData() - val lastSelectedArea: LiveData = _lastSelectedArea - - fun onAttached() { - viewScope.launch { - if (generalRepo.isRememberLastSelection().first()) { - val lastSelection = generalRepo.getLastRememberedSelectionArea().first() - if (lastSelection != null) { - _lastSelectedArea.value = lastSelection - } - } - } - } - - fun onAreaSelected(selected: Rect) { - viewScope.launch { - generalRepo.setLastRememberedSelectionArea(selected) - } - } -} +//package tw.firemaples.onscreenocr.floatings.screenCircling +// +//import android.graphics.Rect +//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.floatings.base.FloatingViewModel +//import tw.firemaples.onscreenocr.repo.GeneralRepository +// +//class ScreenCirclingViewModel(viewScope: CoroutineScope) : FloatingViewModel(viewScope) { +// private val generalRepo = GeneralRepository() +// +// private val _lastSelectedArea = MutableLiveData() +// val lastSelectedArea: LiveData = _lastSelectedArea +// +// fun onAttached() { +// viewScope.launch { +// if (generalRepo.isRememberLastSelection().first()) { +// val lastSelection = generalRepo.getLastRememberedSelectionArea().first() +// if (lastSelection != null) { +// _lastSelectedArea.value = lastSelection +// } +// } +// } +// } +// +// fun onAreaSelected(selected: Rect) { +// viewScope.launch { +// generalRepo.setLastRememberedSelectionArea(selected) +// } +// } +//} 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/floatings/translationSelectPanel/TranslationSelectPanelViewModel.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/translationSelectPanel/TranslationSelectPanelViewModel.kt index 7bcbd17b..e46c7da4 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/floatings/translationSelectPanel/TranslationSelectPanelViewModel.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/translationSelectPanel/TranslationSelectPanelViewModel.kt @@ -216,7 +216,7 @@ class TranslationSelectPanelViewModel(viewScope: CoroutineScope) : fun onTranslationProviderClicked() { viewScope.launch { - _displayTranslationProviders.value = translationRepo.getAllProviders().first() + _displayTranslationProviders.value = translationRepo.getAllProviders().first()!! } } diff --git a/main/src/main/java/tw/firemaples/onscreenocr/pages/launch/LaunchActivity.kt b/main/src/main/java/tw/firemaples/onscreenocr/pages/launch/LaunchActivity.kt index af4a9625..ff0f1f50 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/pages/launch/LaunchActivity.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/pages/launch/LaunchActivity.kt @@ -1,13 +1,16 @@ package tw.firemaples.onscreenocr.pages.launch +import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import tw.firemaples.onscreenocr.databinding.ActivityLaunchBinding import tw.firemaples.onscreenocr.remoteconfig.RemoteConfigManager import tw.firemaples.onscreenocr.utils.AdManager import tw.firemaples.onscreenocr.utils.DeviceInfoChecker +import tw.firemaples.onscreenocr.utils.fitCutoutInsets class LaunchActivity : AppCompatActivity() { @@ -16,15 +19,26 @@ class LaunchActivity : AppCompatActivity() { Intent(context, LaunchActivity::class.java).apply { flags += Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } + + fun getLaunchPendingIntent(context: Context): PendingIntent = + PendingIntent.getActivity( + context, + 1, + getLaunchIntent(context = context), + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) } private lateinit var binding: ActivityLaunchBinding override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) binding = ActivityLaunchBinding.inflate(layoutInflater) setContentView(binding.root) + binding.root.fitCutoutInsets() + AdManager.loadBanner(binding.admobAd.root) // MoPubAdManager.loadPermissionPageBanner(this, findViewById(R.id.ad_permissionPage)) diff --git a/main/src/main/java/tw/firemaples/onscreenocr/pages/launch/permissions/PermissionCaptureScreenFragment.kt b/main/src/main/java/tw/firemaples/onscreenocr/pages/launch/permissions/PermissionCaptureScreenFragment.kt index 9a1a4637..cc63b4a9 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/pages/launch/permissions/PermissionCaptureScreenFragment.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/pages/launch/permissions/PermissionCaptureScreenFragment.kt @@ -1,14 +1,22 @@ package tw.firemaples.onscreenocr.pages.launch.permissions +import android.Manifest import android.app.Activity import android.content.Context +import android.content.pm.PackageManager import android.media.projection.MediaProjectionManager +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AlertDialog +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment +import tw.firemaples.onscreenocr.R import tw.firemaples.onscreenocr.databinding.PermissionCaptureScreenFragmentBinding import tw.firemaples.onscreenocr.floatings.ViewHolderService import tw.firemaples.onscreenocr.pages.setting.SettingManager @@ -35,7 +43,7 @@ class PermissionCaptureScreenFragment : Fragment() { setViews() if (ScreenExtractor.isGranted) { - startService() + requestNotificationPermissionOrStartService() } } @@ -59,9 +67,56 @@ class PermissionCaptureScreenFragment : Fragment() { intent = intent, keepMediaProjection = SettingManager.keepMediaProjectionResources, ) + requestNotificationPermissionOrStartService() + } + } + + private fun requestNotificationPermissionOrStartService() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + startService() + } else { + requestNotificationPermission { startService() } } + } + + private val notificationResultLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { _ -> + startService() + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun requestNotificationPermission( + onResult: () -> Unit, + ) { + val permission = Manifest.permission.POST_NOTIFICATIONS + when { + ContextCompat.checkSelfPermission( + requireContext(), permission + ) == PackageManager.PERMISSION_GRANTED -> { + onResult.invoke() + } + + ActivityCompat.shouldShowRequestPermissionRationale(requireActivity(), permission) -> { + AlertDialog.Builder(requireContext()) + .setMessage(getString(R.string.msg_grant_posting_notification_permission_rationale)) + .setPositiveButton(getString(R.string.btn_request_again)) { _, _ -> + notificationResultLauncher.launch(permission) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + onResult.invoke() + } + .show() + } + + else -> { + // You can directly ask for the permission. + // The registered ActivityResultCallback gets the result of this request. + notificationResultLauncher.launch(permission) + } + } + } private fun startService() { ViewHolderService.showViews(requireActivity()) diff --git a/main/src/main/java/tw/firemaples/onscreenocr/pages/setting/SettingActivity.kt b/main/src/main/java/tw/firemaples/onscreenocr/pages/setting/SettingActivity.kt index c2f34177..24901cfd 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/pages/setting/SettingActivity.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/pages/setting/SettingActivity.kt @@ -3,9 +3,11 @@ package tw.firemaples.onscreenocr.pages.setting import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import tw.firemaples.onscreenocr.databinding.ActivitySettingBinding import tw.firemaples.onscreenocr.utils.AdManager +import tw.firemaples.onscreenocr.utils.fitCutoutInsets class SettingActivity : AppCompatActivity() { companion object { @@ -20,10 +22,13 @@ class SettingActivity : AppCompatActivity() { private lateinit var binding: ActivitySettingBinding override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) binding = ActivitySettingBinding.inflate(layoutInflater) setContentView(binding.root) + binding.root.fitCutoutInsets() + AdManager.loadBanner(binding.admobAd) // MoPubAdManager.loadSettingPageBanner(this, findViewById(R.id.ad_settingPage)) diff --git a/main/src/main/java/tw/firemaples/onscreenocr/pages/setting/SettingManager.kt b/main/src/main/java/tw/firemaples/onscreenocr/pages/setting/SettingManager.kt index ad8b1f97..eca7d0b5 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/pages/setting/SettingManager.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/pages/setting/SettingManager.kt @@ -33,6 +33,7 @@ object SettingManager { private const val PREF_AUTO_COPY_OCR_RESULT = "pref_auto_copy_ocr_result" private const val PREF_HIDE_RECOGNIZED_RESULT_AFTER_TRANSLATED = "pref_hide_recognized_result_after_translated" + private const val PREF_LIMIT_RESULT_VIEW_MAX_WIDTH = "pref_limit_result_view_max_width" private const val PREF_SAVE_LAST_SELECTION_AREA = "pref_save_last_selection_area" private const val PREF_EXIT_APP_WHILE_SPEN_INSERTED = "pref_exit_app_while_spen_inserted" @@ -108,6 +109,9 @@ object SettingManager { val hideRecognizedResultAfterTranslated: Boolean get() = preferences.getBoolean(PREF_HIDE_RECOGNIZED_RESULT_AFTER_TRANSLATED, false) + val limitResultViewMaxWidth: Boolean + get() = preferences.getBoolean(PREF_LIMIT_RESULT_VIEW_MAX_WIDTH, true) + val saveLastSelectionArea: Boolean get() = preferences.getBoolean(PREF_SAVE_LAST_SELECTION_AREA, true) 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/screenshot/ScreenExtractor.kt b/main/src/main/java/tw/firemaples/onscreenocr/screenshot/ScreenExtractor.kt index 8950d642..33f6f0cb 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/screenshot/ScreenExtractor.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/screenshot/ScreenExtractor.kt @@ -60,6 +60,23 @@ object ScreenExtractor { private val screenDensityDpi: Int get() = UIUtils.displayMetrics.densityDpi + private val mediaProjectionCallback = object : MediaProjection.Callback() { + override fun onStop() { + super.onStop() + logger.debug("MPCallback, onStop()") + } + + override fun onCapturedContentResize(width: Int, height: Int) { + super.onCapturedContentResize(width, height) + logger.debug("MPCallback, onCapturedContentResize(): ${width}x$height") + } + + override fun onCapturedContentVisibilityChanged(isVisible: Boolean) { + super.onCapturedContentVisibilityChanged(isVisible) + logger.debug("MPCallback, onCapturedContentVisibilityChanged(): $isVisible") + } + } + fun onMediaProjectionGranted(intent: Intent, keepMediaProjection: Boolean) { releaseAllResources() mediaProjectionIntent = intent.clone() as Intent @@ -116,6 +133,9 @@ object ScreenExtractor { logger.debug("Create MediaProjection") projection = mpManager.getMediaProjection(Activity.RESULT_OK, mpIntent.clone() as Intent) + .apply { + registerCallback(mediaProjectionCallback, handler) + } } val projection = projection diff --git a/main/src/main/java/tw/firemaples/onscreenocr/theme/Color.kt b/main/src/main/java/tw/firemaples/onscreenocr/theme/Color.kt new file mode 100644 index 00000000..a98efc17 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/theme/Color.kt @@ -0,0 +1,67 @@ +package tw.firemaples.onscreenocr.theme +import androidx.compose.ui.graphics.Color + +val md_theme_light_primary = Color(0xFF365AB0) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_primaryContainer = Color(0xFFDAE2FF) +val md_theme_light_onPrimaryContainer = Color(0xFF001848) +val md_theme_light_secondary = Color(0xFF375CA8) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFD9E2FF) +val md_theme_light_onSecondaryContainer = Color(0xFF001945) +val md_theme_light_tertiary = Color(0xFF874589) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFFFFD6FA) +val md_theme_light_onTertiaryContainer = Color(0xFF37003C) +val md_theme_light_error = Color(0xFFBA1A1A) +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_onErrorContainer = Color(0xFF410002) +val md_theme_light_background = Color(0xFFFEFBFF) +val md_theme_light_onBackground = Color(0xFF1B1B1F) +val md_theme_light_surface = Color(0xFFFEFBFF) +val md_theme_light_onSurface = Color(0xFF1B1B1F) +val md_theme_light_surfaceVariant = Color(0xFFE1E2EC) +val md_theme_light_onSurfaceVariant = Color(0xFF45464F) +val md_theme_light_outline = Color(0xFF757780) +val md_theme_light_inverseOnSurface = Color(0xFFF2F0F4) +val md_theme_light_inverseSurface = Color(0xFF303034) +val md_theme_light_inversePrimary = Color(0xFFB2C5FF) +val md_theme_light_shadow = Color(0xFF000000) +val md_theme_light_surfaceTint = Color(0xFF365AB0) +val md_theme_light_outlineVariant = Color(0xFFC5C6D0) +val md_theme_light_scrim = Color(0xFF000000) + +val md_theme_dark_primary = Color(0xFFB2C5FF) +val md_theme_dark_onPrimary = Color(0xFF002B74) +val md_theme_dark_primaryContainer = Color(0xFF174197) +val md_theme_dark_onPrimaryContainer = Color(0xFFDAE2FF) +val md_theme_dark_secondary = Color(0xFFB0C6FF) +val md_theme_dark_onSecondary = Color(0xFF002D6F) +val md_theme_dark_secondaryContainer = Color(0xFF19438F) +val md_theme_dark_onSecondaryContainer = Color(0xFFD9E2FF) +val md_theme_dark_tertiary = Color(0xFFFBACF8) +val md_theme_dark_onTertiary = Color(0xFF521457) +val md_theme_dark_tertiaryContainer = Color(0xFF6C2D70) +val md_theme_dark_onTertiaryContainer = Color(0xFFFFD6FA) +val md_theme_dark_error = Color(0xFFFFB4AB) +val md_theme_dark_errorContainer = Color(0xFF93000A) +val md_theme_dark_onError = Color(0xFF690005) +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val md_theme_dark_background = Color(0xFF1B1B1F) +val md_theme_dark_onBackground = Color(0xFFE4E2E6) +val md_theme_dark_surface = Color(0xFF1B1B1F) +val md_theme_dark_onSurface = Color(0xFFE4E2E6) +val md_theme_dark_surfaceVariant = Color(0xFF45464F) +val md_theme_dark_onSurfaceVariant = Color(0xFFC5C6D0) +val md_theme_dark_outline = Color(0xFF8F909A) +val md_theme_dark_inverseOnSurface = Color(0xFF1B1B1F) +val md_theme_dark_inverseSurface = Color(0xFFE4E2E6) +val md_theme_dark_inversePrimary = Color(0xFF365AB0) +val md_theme_dark_shadow = Color(0xFF000000) +val md_theme_dark_surfaceTint = Color(0xFFB2C5FF) +val md_theme_dark_outlineVariant = Color(0xFF45464F) +val md_theme_dark_scrim = Color(0xFF000000) + + +val seed = Color(0xFF365AB0) diff --git a/main/src/main/java/tw/firemaples/onscreenocr/theme/Font.kt b/main/src/main/java/tw/firemaples/onscreenocr/theme/Font.kt new file mode 100644 index 00000000..909ffab1 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/theme/Font.kt @@ -0,0 +1,8 @@ +package tw.firemaples.onscreenocr.theme + + +import androidx.compose.ui.unit.sp + +object FontSize { + val Small = 14.sp +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/theme/Theme.kt b/main/src/main/java/tw/firemaples/onscreenocr/theme/Theme.kt new file mode 100644 index 00000000..a955c0c6 --- /dev/null +++ b/main/src/main/java/tw/firemaples/onscreenocr/theme/Theme.kt @@ -0,0 +1,99 @@ +package tw.firemaples.onscreenocr.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextDirection + + +private val LightColors = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, +) + + +private val DarkColors = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, +) + +@Composable +fun AppTheme( + useDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colors = if (!useDarkTheme) { + LightColors + } else { + DarkColors + } + + MaterialTheme( + colorScheme = colors, + ) { + val textStyle = TextStyle( + textDirection = TextDirection.Content, + ) + ProvideTextStyle(value = textStyle) { + content() + } + } +} 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/azure/MicrosoftAzureAPIService.kt b/main/src/main/java/tw/firemaples/onscreenocr/translator/azure/MicrosoftAzureAPIService.kt index b8fb2470..427889ea 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/translator/azure/MicrosoftAzureAPIService.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/translator/azure/MicrosoftAzureAPIService.kt @@ -2,6 +2,7 @@ package tw.firemaples.onscreenocr.translator.azure import androidx.annotation.Keep import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import retrofit2.Response import retrofit2.http.Body import retrofit2.http.Header @@ -30,6 +31,7 @@ interface MicrosoftAzureAPIService { ): Response } +@JsonClass(generateAdapter = true) @Keep data class TranslateRequest( @Json(name = "Text") @@ -40,6 +42,7 @@ data class TranslateRequest( } } +@JsonClass(generateAdapter = true) @Keep data class TranslateResponse( @Json(name = "detectedLanguage") @@ -48,6 +51,7 @@ data class TranslateResponse( val translations: List, ) +@JsonClass(generateAdapter = true) @Keep data class DetectedLanguage( @Json(name = "language") @@ -56,6 +60,7 @@ data class DetectedLanguage( val score: Float, ) +@JsonClass(generateAdapter = true) @Keep data class Translation( @Json(name = "text") 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/translator/mymemory/MyMemoryAPIService.kt b/main/src/main/java/tw/firemaples/onscreenocr/translator/mymemory/MyMemoryAPIService.kt index bce8581f..35da571b 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/translator/mymemory/MyMemoryAPIService.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/translator/mymemory/MyMemoryAPIService.kt @@ -2,6 +2,7 @@ package tw.firemaples.onscreenocr.translator.mymemory import androidx.annotation.Keep import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import retrofit2.Response import retrofit2.http.GET import retrofit2.http.Query @@ -15,6 +16,7 @@ interface MyMemoryAPIService { ): Response } +@JsonClass(generateAdapter = true) @Keep data class TranslateResponse( @Json(name = "responseData") @@ -36,6 +38,7 @@ data class TranslateResponse( fun isSuccess(): Boolean = responseStatus.toString().toDoubleOrNull() == 200.0 } +@JsonClass(generateAdapter = true) @Keep data class ResponseData( @Json(name = "translatedText") diff --git a/main/src/main/java/tw/firemaples/onscreenocr/utils/BitmapCache.kt b/main/src/main/java/tw/firemaples/onscreenocr/utils/BitmapCache.kt index 7f8d949a..2ee33a5b 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/utils/BitmapCache.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/utils/BitmapCache.kt @@ -83,7 +83,7 @@ object BitmapCache { return false } - val targetConfig = config ?: this.config + val targetConfig = config ?: this.config ?: return false val byteCount = width * height * targetConfig.getBytesPerPixel() return byteCount <= this.allocationByteCount } diff --git a/main/src/main/java/tw/firemaples/onscreenocr/utils/Logger.kt b/main/src/main/java/tw/firemaples/onscreenocr/utils/Logger.kt index 3d93cc58..25f0175f 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/utils/Logger.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/utils/Logger.kt @@ -4,32 +4,38 @@ import android.util.Log import tw.firemaples.onscreenocr.BuildConfig import kotlin.reflect.KClass +fun composeDebug(msg: String? = null, t: Throwable? = null) { + Logger.log(Logger.COMPOSE_LOGGER, Logger.DEBUG, msg, t) +} + class Logger(clazz: KClass<*>) { companion object { - private const val DEBUG = 0 - private const val INFO = 1 - private const val WARN = 2 - private const val ERROR = 3 - } - - private val tag: String = clazz.java.simpleName + const val DEBUG = 0 + const val INFO = 1 + const val WARN = 2 + const val ERROR = 3 - fun debug(msg: String? = null, t: Throwable? = null) = log(DEBUG, msg, t) - fun info(msg: String? = null, t: Throwable? = null) = log(INFO, msg, t) - fun warn(msg: String? = null, t: Throwable? = null) = log(WARN, msg, t) - fun error(msg: String? = null, t: Throwable? = null) = log(ERROR, msg, t) + const val COMPOSE_LOGGER = "ComposeLogger" - private fun log(level: Int, msg: String?, t: Throwable?) { - if (BuildConfig.DISABLE_LOGGING) return + fun log(tag: String, level: Int, msg: String?, t: Throwable?) { + if (BuildConfig.DISABLE_LOGGING) return - val threadName = Thread.currentThread().name - val message = if (msg != null) "[$threadName] $msg" else "[$threadName]" + val threadName = Thread.currentThread().name + val message = if (msg != null) "[$threadName] $msg" else "[$threadName]" - when (level) { - DEBUG -> Log.d(tag, message, t) - INFO -> Log.i(tag, message, t) - WARN -> Log.w(tag, message, t) - ERROR -> Log.e(tag, message, t) + when (level) { + DEBUG -> Log.d(tag, message, t) + INFO -> Log.i(tag, message, t) + WARN -> Log.w(tag, message, t) + ERROR -> Log.e(tag, message, t) + } } } + + private val tag: String = clazz.java.simpleName + + fun debug(msg: String? = null, t: Throwable? = null) = log(tag, DEBUG, msg, t) + fun info(msg: String? = null, t: Throwable? = null) = log(tag, INFO, msg, t) + fun warn(msg: String? = null, t: Throwable? = null) = log(tag, WARN, msg, t) + fun error(msg: String? = null, t: Throwable? = null) = log(tag, ERROR, msg, t) } 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..3260e4f0 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,25 @@ import android.os.IBinder import android.service.quicksettings.Tile import android.service.quicksettings.TileService import androidx.annotation.RequiresApi -import kotlinx.coroutines.* -import tw.firemaples.onscreenocr.floatings.manager.FloatingStateManager +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +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,13 +50,17 @@ 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)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivityAndCollapse(LaunchActivity.getLaunchPendingIntent(this)) + } else { + startActivityAndCollapse(LaunchActivity.getLaunchIntent(this)) + } } } } @@ -56,7 +70,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/SamsungSpenInsertedReceiver.kt b/main/src/main/java/tw/firemaples/onscreenocr/utils/SamsungSpenInsertedReceiver.kt index dcb792b6..3b4f2d62 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/utils/SamsungSpenInsertedReceiver.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/utils/SamsungSpenInsertedReceiver.kt @@ -4,6 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import androidx.core.content.ContextCompat import tw.firemaples.onscreenocr.CoreApplication import tw.firemaples.onscreenocr.floatings.ViewHolderService @@ -28,9 +29,11 @@ class SamsungSpenInsertedReceiver : BroadcastReceiver() { receiver = SamsungSpenInsertedReceiver() } - context.registerReceiver( + ContextCompat.registerReceiver( + context, receiver, - IntentFilter(ACTION_SAMSUNG_SPEN_INSERT) + IntentFilter(ACTION_SAMSUNG_SPEN_INSERT), + ContextCompat.RECEIVER_EXPORTED, ) isRegistered = true @@ -68,4 +71,4 @@ class SamsungSpenInsertedReceiver : BroadcastReceiver() { } } } -} \ No newline at end of file +} diff --git a/main/src/main/java/tw/firemaples/onscreenocr/utils/UIUtils.kt b/main/src/main/java/tw/firemaples/onscreenocr/utils/UIUtils.kt index 8ae4f437..a5b90855 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/utils/UIUtils.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/utils/UIUtils.kt @@ -13,6 +13,7 @@ import android.view.WindowManager import android.widget.TextView import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding object UIUtils { private val context by lazy { Utils.context } @@ -187,3 +188,19 @@ fun View.showKeyboard() = fun View.hideKeyboard() = ViewCompat.getWindowInsetsController(this) ?.hide(WindowInsetsCompat.Type.ime()) + +fun View.fitCutoutInsets() { + ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets -> + val bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() + or WindowInsetsCompat.Type.displayCutout() + ) + v.updatePadding( + left = bars.left, + top = bars.top, + right = bars.right, + bottom = bars.bottom, + ) + WindowInsetsCompat.CONSUMED + } +} 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/utils/WordBoundary.kt b/main/src/main/java/tw/firemaples/onscreenocr/utils/WordBoundary.kt index dace567f..975945c5 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/utils/WordBoundary.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/utils/WordBoundary.kt @@ -16,14 +16,14 @@ object WordBoundary { // Skip punctuations and blanks } else { if (boundaries.size >= 2 && boundaries.last().word.isDash()) { - val sb = StringBuilder(boundaries.removeLast().word) - val lastWord = boundaries.removeLast() + val sb = StringBuilder(boundaries.removeAt(boundaries.lastIndex).word) + val lastWord = boundaries.removeAt(boundaries.lastIndex) sb.insert(0, lastWord.word) sb.append(word) boundaries.add(Boundary(sb.toString(), lastWord.start, end)) } else { if (boundaries.isNotEmpty() && boundaries.last().word.isDash()) { - boundaries.removeLast() + boundaries.removeAt(boundaries.lastIndex) } if (word.isDash() && previousWord?.isBlank() == true) { // Skip dash following blank pattern. " -" 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/java/tw/firemaples/onscreenocr/wigets/HomeButtonWatcher.kt b/main/src/main/java/tw/firemaples/onscreenocr/wigets/HomeButtonWatcher.kt index a89b234b..1dbb7c3a 100644 --- a/main/src/main/java/tw/firemaples/onscreenocr/wigets/HomeButtonWatcher.kt +++ b/main/src/main/java/tw/firemaples/onscreenocr/wigets/HomeButtonWatcher.kt @@ -4,6 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import androidx.core.content.ContextCompat import tw.firemaples.onscreenocr.utils.Logger /** @@ -30,7 +31,12 @@ class HomeButtonWatcher( fun startWatch() { if (watching) return - context.registerReceiver(receiver, filter) + ContextCompat.registerReceiver( + context, + receiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) watching = true } diff --git a/main/src/main/res/values-zh-rCN/strings.xml b/main/src/main/res/values-zh-rCN/strings.xml index 4465ca4d..1fb69413 100644 --- a/main/src/main/res/values-zh-rCN/strings.xml +++ b/main/src/main/res/values-zh-rCN/strings.xml @@ -66,6 +66,8 @@ 发现暂时性错误,请再试一次(1) (Image reader format) 此翻译引擎不支援选择的辨识语言 + 授予通知权限以快速显示/隐藏浮动视窗 + 重新请求 版本资讯 错误 使用说明 @@ -112,4 +114,7 @@ 维持相关资源直到重启程式 每次使用后释放相关资源 萤幕截图 - \ No newline at end of file + 限制最大宽度 + 限制最大宽度至 300dp + 依内容自动调整宽度 + diff --git a/main/src/main/res/values-zh-rTW/strings.xml b/main/src/main/res/values-zh-rTW/strings.xml index ede4603a..e76f2b9a 100644 --- a/main/src/main/res/values-zh-rTW/strings.xml +++ b/main/src/main/res/values-zh-rTW/strings.xml @@ -66,6 +66,8 @@ 發現暫時性錯誤,請再試一次(1) (Image reader format) 此翻譯引擎不支援選擇的辨識語言 + 授予通知權限以快速顯示/隱藏浮動視窗 + 重新請求 版本資訊 錯誤 使用說明 @@ -112,4 +114,7 @@ 維持相關資源直到重啟程式 每次使用後釋放相關資源 螢幕截圖 - \ No newline at end of file + 限制最大寬度 + 限制最大寬度至 300dp + 不限制寬度 + diff --git a/main/src/main/res/values/strings.xml b/main/src/main/res/values/strings.xml index 2c6cc7e2..17732f1a 100644 --- a/main/src/main/res/values/strings.xml +++ b/main/src/main/res/values/strings.xml @@ -50,6 +50,10 @@ Screenshot Keep the resources until restart Release the resources after used + Keep MediaProjection resources + Limit the max width + Limit the max width to 300dp + Dynamic width based on the content Setting Privacy Policy @@ -74,6 +78,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. @@ -85,6 +90,8 @@ Found temporary error, please try it again. (Image reader format) This translation provider does not support the selected recognition language. Please restart the app to take effect + Grant posting notification permission to restore/hide the app easier + Request Again Version History Error @@ -116,5 +123,4 @@ Space None - Keep MediaProjection resources - \ No newline at end of file + diff --git a/main/src/main/res/xml/perference.xml b/main/src/main/res/xml/perference.xml index 69fed756..4a620d6b 100644 --- a/main/src/main/res/xml/perference.xml +++ b/main/src/main/res/xml/perference.xml @@ -96,6 +96,12 @@ android:defaultValue="false" android:key="pref_hide_recognized_result_after_translated" android:title="@string/pref_hide_recognized_result_after_translated" /> + @@ -112,4 +118,4 @@ android:key="pref_exit_app_while_spen_inserted" android:title="@string/pref_exit_app_while_spen_inserted" /> - \ No newline at end of file +