feat: Warn the user if the posting language might be incorrect (#792)
The user has to specify the language they're posting in, and sometimes they might get it wrong (e.g., replying to a post that also had the language set incorrectly, forgetfulness, etc). This has accessiblity issues (only following statuses in a given language fails, translation can fail, etc). Prevent this by trying to detect the language the status is written in when the user tries to post it. If the detected language and the set language do not match, and the detection is 60+% confident, warn the user the status language might be incorrect, and offer to correct it before posting. How this works differs by device and API level. - API 23 - 28, fdroid and github build flavours - Not supported. A no-op language detector is used. - API 29 and above, fdroid and github build flavours - Uses Android TextClassifier to detect the likely language - AP 23 and above, google build flavour - Uses ML Kit language identification To do this: - Add `LanguageIdentifier`, with methods to do the identification, and `LanguageIdentifier.Factory` to create the identifiers. - Inject the factory in `ComposeActivity` - Detect the language when the user posts, showing a dialog if there's a sufficiently large discrepancy. The ML Kit dependencies (language models) will be installed by the Play libraries, so there's some machinery to check that they're installed, and kick off the installation if not. If they can't be installed then the language check is bypassed. Update the privacy policy, as the ML Kit libraries may send some data to Google.
This commit is contained in:
parent
511107a36f
commit
4fc52f9bc2
14
PRIVACY.md
14
PRIVACY.md
|
@ -28,8 +28,22 @@ You can not delete your Mastodon account using the application. Deleting your ac
|
|||
|
||||
## Data sharing
|
||||
|
||||
### If you have installed Pachli from F-Droid or GitHub releases
|
||||
|
||||
**None of the data used by the application is shared with the application developers or unrelated third parties.** Your data is only ever sent to your server, and handled in accordance with your server's privacy policy.
|
||||
|
||||
### If you have installed Pachli from Google Play
|
||||
|
||||
Pachli uses Google's [ML Kit](https://developers.google.com/ml-kit) to provide features that:
|
||||
|
||||
- Warn you if the language you have selected when editing a post appears to be different from the language you used
|
||||
|
||||
When Pachli does this all of the data you have provided (e.g., the content you are posting) is processed on your device, and **ML Kit does not send that data to Google servers**.
|
||||
|
||||
The ML Kit APIs may contact Google servers from time to time in order to receive things like bug fixes, updated models and hardware accelerator compatibility information. The ML Kit APIs also send metrics about the performance and utilization of the APIs in your app to Google. Google uses this metrics data to measure performance, debug, maintain and improve the APIs, and detect misuse or abuse, as further described in Google's [Privacy Policy](https://policies.google.com/privacy).
|
||||
|
||||
The specific data collected by ML Kit is in Google's [data disclosure](https://developers.google.com/ml-kit/android-data-disclosure) description.
|
||||
|
||||
## Data types
|
||||
|
||||
The application processes the following types of data.
|
||||
|
|
|
@ -179,6 +179,11 @@ dependencies {
|
|||
googleImplementation(libs.app.update)
|
||||
googleImplementation(libs.app.update.ktx)
|
||||
|
||||
// Language detection
|
||||
googleImplementation(libs.play.services.base)
|
||||
googleImplementation(libs.mlkit.language.id)
|
||||
googleImplementation(libs.kotlinx.coroutines.play.services)
|
||||
|
||||
implementation(libs.semver)
|
||||
|
||||
debugImplementation(libs.leakcanary)
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.di
|
||||
|
||||
import android.content.Context
|
||||
import app.pachli.core.common.di.ApplicationScope
|
||||
import app.pachli.languageidentification.DefaultLanguageIdentifierFactory
|
||||
import app.pachli.languageidentification.LanguageIdentifier
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
object LanguageIdentifierFactoryModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesLanguageIdentifierFactory(
|
||||
@ApplicationScope externalScope: CoroutineScope,
|
||||
@ApplicationContext context: Context,
|
||||
): LanguageIdentifier.Factory = DefaultLanguageIdentifierFactory(externalScope, context)
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.di
|
||||
|
||||
import android.content.Context
|
||||
import app.pachli.core.common.di.ApplicationScope
|
||||
import app.pachli.languageidentification.DefaultLanguageIdentifierFactory
|
||||
import app.pachli.languageidentification.LanguageIdentifier
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
object LanguageIdentifierFactoryModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesLanguageIdentifierFactory(
|
||||
@ApplicationScope externalScope: CoroutineScope,
|
||||
@ApplicationContext context: Context,
|
||||
): LanguageIdentifier.Factory = DefaultLanguageIdentifierFactory(externalScope, context)
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.di
|
||||
|
||||
import android.content.Context
|
||||
import app.pachli.core.common.di.ApplicationScope
|
||||
import app.pachli.languageidentification.LanguageIdentifier
|
||||
import app.pachli.languageidentification.MlKitLanguageIdentifier
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
object LanguageIdentifierFactoryModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesLanguageIdentifierFactory(
|
||||
@ApplicationScope externalScope: CoroutineScope,
|
||||
@ApplicationContext context: Context,
|
||||
): LanguageIdentifier.Factory = MlKitLanguageIdentifier.Factory(externalScope, context)
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.languageidentification
|
||||
|
||||
import android.content.Context
|
||||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.coroutines.runSuspendCatching
|
||||
import com.github.michaelbull.result.mapError
|
||||
import com.google.android.gms.common.moduleinstall.ModuleInstall
|
||||
import com.google.android.gms.common.moduleinstall.ModuleInstallRequest
|
||||
import com.google.mlkit.nl.languageid.LanguageIdentification
|
||||
import com.google.mlkit.nl.languageid.LanguageIdentifier as GoogleLanguageIdentifier
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* [LanguageIdentifier] that uses Google's ML Kit to perform the language
|
||||
* identification.
|
||||
*/
|
||||
class MlKitLanguageIdentifier private constructor() : LanguageIdentifier {
|
||||
private var client: GoogleLanguageIdentifier? = null
|
||||
|
||||
init {
|
||||
client = LanguageIdentification.getClient()
|
||||
}
|
||||
|
||||
override suspend fun identifyPossibleLanguages(text: String): Result<List<IdentifiedLanguage>, LanguageIdentifierError> {
|
||||
return client?.let { client ->
|
||||
// May throw an MlKitException, so catch and map error
|
||||
runSuspendCatching {
|
||||
client.identifyPossibleLanguages(text).await().map {
|
||||
IdentifiedLanguage(
|
||||
confidence = it.confidence,
|
||||
languageTag = it.languageTag,
|
||||
)
|
||||
}
|
||||
}.mapError { LanguageIdentifierError.Unknown(it) }
|
||||
} ?: Err(LanguageIdentifierError.UseAfterClose)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
client?.close()
|
||||
client = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for LanguageIdentifer based on Google's ML Kit.
|
||||
*
|
||||
* When the factory is constructed a [com.google.android.gms.tasks.Task]
|
||||
* to check and install the language module is launched, increasing the
|
||||
* chances the module will be installed before it is first used.
|
||||
*/
|
||||
class Factory(
|
||||
private val externalScope: CoroutineScope,
|
||||
private val context: Context,
|
||||
) : LanguageIdentifier.Factory() {
|
||||
private val moduleInstallClient = ModuleInstall.getClient(context)
|
||||
|
||||
init {
|
||||
LanguageIdentification.getClient().use { langIdClient ->
|
||||
val moduleInstallRequest = ModuleInstallRequest.newBuilder()
|
||||
.addApi(langIdClient)
|
||||
.build()
|
||||
|
||||
moduleInstallClient.installModules(moduleInstallRequest)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [MlKitLanguageIdentifier] if the relevant modules are
|
||||
* installed, defers to [DefaultLanguageIdentifierFactory] if not.
|
||||
*/
|
||||
override suspend fun newInstance(): LanguageIdentifier {
|
||||
LanguageIdentification.getClient().use { langIdClient ->
|
||||
val modulesAreAvailable = moduleInstallClient
|
||||
.areModulesAvailable(langIdClient)
|
||||
.await()
|
||||
.areModulesAvailable()
|
||||
|
||||
return if (modulesAreAvailable) {
|
||||
Timber.d("mlkit langid module available")
|
||||
MlKitLanguageIdentifier()
|
||||
} else {
|
||||
Timber.d("mlkit langid module *not* available")
|
||||
DefaultLanguageIdentifierFactory(externalScope, context)
|
||||
.newInstance()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -25,6 +25,10 @@
|
|||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||
android:value="langid" />
|
||||
|
||||
<activity
|
||||
android:name=".feature.login.LoginActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
|
|
|
@ -27,6 +27,15 @@ import app.pachli.util.modernLanguageCode
|
|||
import com.google.android.material.color.MaterialColors
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Display a list of [Locale] in a spinner.
|
||||
*
|
||||
* At rest the locale is represented by the uppercase 2-3 character language code without
|
||||
* any subcategories ("EN", "DE", "ZH") etc.
|
||||
*
|
||||
* In the menu the locale is presented as "Local name (name)". E.g,. when the current
|
||||
* locale is English the German locale is displayed as "German (Deutsch)".
|
||||
*/
|
||||
class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : ArrayAdapter<Locale>(context, resource, locales) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
return (super.getView(position, convertView, parent) as TextView).apply {
|
||||
|
|
|
@ -93,9 +93,12 @@ import app.pachli.core.network.model.Status
|
|||
import app.pachli.core.preferences.AppTheme
|
||||
import app.pachli.core.preferences.PrefKeys
|
||||
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||
import app.pachli.core.ui.extensions.await
|
||||
import app.pachli.core.ui.extensions.getErrorString
|
||||
import app.pachli.core.ui.makeIcon
|
||||
import app.pachli.databinding.ActivityComposeBinding
|
||||
import app.pachli.languageidentification.LanguageIdentifier
|
||||
import app.pachli.languageidentification.UNDETERMINED_LANGUAGE_TAG
|
||||
import app.pachli.util.PickMediaFiles
|
||||
import app.pachli.util.getInitialLanguages
|
||||
import app.pachli.util.getLocaleList
|
||||
|
@ -107,6 +110,7 @@ import app.pachli.util.setDrawableTint
|
|||
import com.canhub.cropper.CropImage
|
||||
import com.canhub.cropper.CropImageContract
|
||||
import com.canhub.cropper.options
|
||||
import com.github.michaelbull.result.getOrElse
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
|
@ -119,6 +123,7 @@ import java.io.File
|
|||
import java.io.IOException
|
||||
import java.text.DecimalFormat
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
@ -161,6 +166,12 @@ class ComposeActivity :
|
|||
|
||||
private var maxUploadMediaNumber = DEFAULT_MAX_MEDIA_ATTACHMENTS
|
||||
|
||||
/** List of locales the user can choose from when posting. */
|
||||
private lateinit var locales: List<Locale>
|
||||
|
||||
@Inject
|
||||
lateinit var languageIdentifierFactory: LanguageIdentifier.Factory
|
||||
|
||||
private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
if (success) {
|
||||
pickMedia(photoUploadUri!!)
|
||||
|
@ -602,7 +613,8 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
binding.composePostLanguageButton.apply {
|
||||
adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguages))
|
||||
locales = getLocaleList(initialLanguages)
|
||||
adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, locales)
|
||||
setSelection(0)
|
||||
}
|
||||
}
|
||||
|
@ -950,7 +962,9 @@ class ComposeActivity :
|
|||
return binding.composeScheduleView.verifyScheduledTime(binding.composeScheduleView.getDateTime(viewModel.scheduledAt.value))
|
||||
}
|
||||
|
||||
private fun onSendClicked() {
|
||||
private fun onSendClicked() = lifecycleScope.launch {
|
||||
confirmStatusLanguage()
|
||||
|
||||
if (verifyScheduledTime()) {
|
||||
sendStatus()
|
||||
} else {
|
||||
|
@ -958,6 +972,82 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the status' language.
|
||||
*
|
||||
* Try and identify the language the status is written in, and compare that with
|
||||
* the language the user selected. If the selected language is not in the top three
|
||||
* detected languages, and the language was detected at 60+% confidence then prompt
|
||||
* the user to change the language before posting.
|
||||
*/
|
||||
private suspend fun confirmStatusLanguage() {
|
||||
// Note: There's some dancing around here because the language identifiers
|
||||
// are BCP-47 codes (potentially with multiple components) and the Mastodon API wants ISO 639.
|
||||
// See https://github.com/mastodon/mastodon/issues/23541
|
||||
|
||||
// Null check. Shouldn't be necessary
|
||||
val currentLang = viewModel.language ?: return
|
||||
|
||||
// Try and identify the language the status is written in. Limit to the
|
||||
// first three possibilities. Don't show errors to the user, just bail,
|
||||
// as there's nothing they can do to resolve any error.
|
||||
val languages = languageIdentifierFactory.newInstance().use {
|
||||
it.identifyPossibleLanguages(binding.composeEditField.text.toString())
|
||||
.getOrElse {
|
||||
Timber.d("error when identifying languages: %s", it)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no matches then bail
|
||||
// Note: belt and braces, shouldn't happen per documented behaviour, as at
|
||||
// least one item should always be returned.
|
||||
if (languages.isEmpty()) return
|
||||
|
||||
// Ignore results where the language could not be determined.
|
||||
if (languages.first().languageTag == UNDETERMINED_LANGUAGE_TAG) return
|
||||
|
||||
// If the current language is any of the ones detected then it's OK.
|
||||
if (languages.any { it.languageTag.startsWith(currentLang) }) return
|
||||
|
||||
// Warn the user about the language mismatch only if 60+% sure of the guess.
|
||||
val detectedLang = languages.first()
|
||||
if (detectedLang.confidence < 0.6) return
|
||||
|
||||
// Viewmodel's language tag has just the first component (e.g., "zh"), the
|
||||
// guessed language might more (e.g,. "-Hant"), so trim to just the first.
|
||||
val detectedLangTruncatedTag = detectedLang.languageTag.split('-', limit = 2)[0]
|
||||
|
||||
val localeList = getLocaleList(emptyList()).associateBy { it.modernLanguageCode }
|
||||
|
||||
val detectedLocale = localeList[detectedLangTruncatedTag] ?: return
|
||||
val detectedDisplayLang = detectedLocale.displayLanguage
|
||||
val currentDisplayLang = localeList[viewModel.language]?.displayLanguage ?: return
|
||||
|
||||
// Otherwise, show the dialog.
|
||||
val dialog = AlertDialog.Builder(this@ComposeActivity)
|
||||
.setTitle(R.string.compose_warn_language_dialog_title)
|
||||
.setMessage(
|
||||
getString(
|
||||
R.string.compose_warn_language_dialog_fmt,
|
||||
currentDisplayLang,
|
||||
detectedDisplayLang,
|
||||
),
|
||||
)
|
||||
.create()
|
||||
.await(
|
||||
getString(R.string.compose_warn_language_dialog_change_language_fmt, detectedDisplayLang),
|
||||
getString(R.string.compose_warn_language_dialog_accept_language_fmt, currentDisplayLang),
|
||||
)
|
||||
|
||||
if (dialog == AlertDialog.BUTTON_POSITIVE) {
|
||||
viewModel.onLanguageChanged(detectedLangTruncatedTag)
|
||||
locales.indexOf(detectedLocale).takeIf { it != -1 }?.let {
|
||||
binding.composePostLanguageButton.setSelection(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** This is for the fancy keyboards which can insert images and stuff, and drag&drop etc */
|
||||
override fun onReceiveContent(view: View, contentInfo: ContentInfoCompat): ContentInfoCompat? {
|
||||
if (contentInfo.clip.description.hasMimeType("image/*")) {
|
||||
|
|
|
@ -0,0 +1,157 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.languageidentification
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.view.textclassifier.TextClassificationManager
|
||||
import android.view.textclassifier.TextClassifier
|
||||
import android.view.textclassifier.TextLanguage
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.annotation.StringRes
|
||||
import app.pachli.core.common.PachliError
|
||||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.toResultOr
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.plus
|
||||
|
||||
/** The BCP 47 language tag for "undetermined language". */
|
||||
const val UNDETERMINED_LANGUAGE_TAG = "und"
|
||||
|
||||
/**
|
||||
* A language identified by [LanguageIdentifier.identifyPossibleLanguages].
|
||||
*/
|
||||
data class IdentifiedLanguage(
|
||||
/** Confidence score associated with the identification. */
|
||||
val confidence: Float,
|
||||
/** BCP 47 language tag for the identified language. */
|
||||
val languageTag: String,
|
||||
)
|
||||
|
||||
sealed class LanguageIdentifierError(
|
||||
@StringRes override val resourceId: Int = -1,
|
||||
override val formatArgs: Array<out String>? = null,
|
||||
override val cause: PachliError? = null,
|
||||
) : PachliError {
|
||||
|
||||
/**
|
||||
* Called [LanguageIdentifier.identifyPossibleLanguages] after calling
|
||||
* [LanguageIdentifier.close].
|
||||
*/
|
||||
data object UseAfterClose : LanguageIdentifierError()
|
||||
data class Unknown(val throwable: Throwable) : LanguageIdentifierError()
|
||||
}
|
||||
|
||||
interface LanguageIdentifier : AutoCloseable {
|
||||
/**
|
||||
* Identifies the language in [text] and returns a list of possible
|
||||
* languages.
|
||||
*
|
||||
* @return A non-empty list of identified languages in [text]. If no
|
||||
* languages were identified a list with an item with the languageTag
|
||||
* set to [UNDETERMINED_LANGUAGE_TAG] is used.
|
||||
*/
|
||||
suspend fun identifyPossibleLanguages(text: String): Result<List<IdentifiedLanguage>, LanguageIdentifierError>
|
||||
|
||||
// Language identifiers may consume a lot of resources while in use, so they
|
||||
// cannot be treated as singletons that can be injected and can persist as long
|
||||
// as an activity remains. They may also require resource cleanup, which is
|
||||
// impossible to guarantee using activity lifecycle methods (onDestroy etc).
|
||||
//
|
||||
// So instead of injecting the language identifier, inject a factory for creating
|
||||
// language identifiers. It is the responsibility of the calling code to use the
|
||||
// factory to create the language identifier, and then close the language
|
||||
// identifier when finished.
|
||||
abstract class Factory {
|
||||
abstract suspend fun newInstance(): LanguageIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [LanguageIdentifier] that uses Android's [TextClassificationManager], available
|
||||
* on API 29 and above, to identify the language.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
class Api29LanguageIdentifier(
|
||||
private val externalScope: CoroutineScope,
|
||||
val context: Context,
|
||||
) : LanguageIdentifier {
|
||||
private val textClassificationManager: TextClassificationManager = context.getSystemService(TextClassificationManager::class.java)
|
||||
private var textClassifier: TextClassifier? = textClassificationManager.textClassifier
|
||||
|
||||
override suspend fun identifyPossibleLanguages(text: String): Result<List<IdentifiedLanguage>, LanguageIdentifierError> = (externalScope + Dispatchers.IO).async {
|
||||
val textRequest = TextLanguage.Request.Builder(text).build()
|
||||
|
||||
textClassifier?.detectLanguage(textRequest)?.let { detectedLanguage ->
|
||||
buildList {
|
||||
for (i in 0 until detectedLanguage.localeHypothesisCount) {
|
||||
val localeDetected = detectedLanguage.getLocale(i)
|
||||
val confidence = detectedLanguage.getConfidenceScore(localeDetected)
|
||||
|
||||
add(
|
||||
IdentifiedLanguage(
|
||||
confidence = confidence,
|
||||
languageTag = localeDetected.toLanguageTag(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}.toResultOr { LanguageIdentifierError.UseAfterClose }
|
||||
}.await()
|
||||
|
||||
override fun close() {
|
||||
textClassifier = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [LanguageIdentifier] that always returns [UNDETERMINED_LANGUAGE_TAG].
|
||||
*
|
||||
* Use when no other language identifier is available.
|
||||
*/
|
||||
object NopLanguageIdentifier : LanguageIdentifier {
|
||||
private var closed = false
|
||||
override suspend fun identifyPossibleLanguages(text: String): Result<List<IdentifiedLanguage>, LanguageIdentifierError> = if (closed) {
|
||||
Err(LanguageIdentifierError.UseAfterClose)
|
||||
} else {
|
||||
Ok(listOf(IdentifiedLanguage(confidence = 1f, languageTag = UNDETERMINED_LANGUAGE_TAG)))
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [LanguageIdentifier.Factory] that creates [Api29LanguageIdentifier] if available,
|
||||
* [NopLanguageIdentifier] otherwise.
|
||||
*/
|
||||
class DefaultLanguageIdentifierFactory(
|
||||
private val externalScope: CoroutineScope,
|
||||
val context: Context,
|
||||
) : LanguageIdentifier.Factory() {
|
||||
override suspend fun newInstance(): LanguageIdentifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
Api29LanguageIdentifier(externalScope, context)
|
||||
} else {
|
||||
NopLanguageIdentifier
|
||||
}
|
||||
}
|
|
@ -693,4 +693,8 @@
|
|||
<string name="error_filter_missing_context">At least one filter context is required</string>
|
||||
<string name="error_filter_missing_title">Title is required</string>
|
||||
|
||||
<string name="compose_warn_language_dialog_title">Check post\'s language</string>
|
||||
<string name="compose_warn_language_dialog_fmt">The post\'s language is set to %1$s but you might have written it in %2$s.</string>
|
||||
<string name="compose_warn_language_dialog_change_language_fmt">Change language to "%1$s" and post</string>
|
||||
<string name="compose_warn_language_dialog_accept_language_fmt">Post as-is (%1$s)</string>
|
||||
</resources>
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.di
|
||||
|
||||
import android.content.Context
|
||||
import app.pachli.core.common.di.ApplicationScope
|
||||
import app.pachli.languageidentification.DefaultLanguageIdentifierFactory
|
||||
import app.pachli.languageidentification.LanguageIdentifier
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [LanguageIdentifierFactoryModule::class],
|
||||
)
|
||||
@Module
|
||||
class FakeLanguageIdentifierFactoryModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesLanguageIdentifierFactory(
|
||||
@ApplicationScope externalScope: CoroutineScope,
|
||||
@ApplicationContext context: Context,
|
||||
): LanguageIdentifier.Factory = DefaultLanguageIdentifierFactory(externalScope, context)
|
||||
}
|
|
@ -34,6 +34,7 @@ auto-service-ksp = "1.2.0"
|
|||
bouncycastle = "1.70"
|
||||
conscrypt = "2.5.2"
|
||||
coroutines = "1.8.1"
|
||||
coroutines-play-services = "1.4.1"
|
||||
desugar_jdk_libs = "2.0.4"
|
||||
diffx = "1.1.1"
|
||||
emoji2 = "1.3.0"
|
||||
|
@ -54,6 +55,7 @@ material = "1.12.0"
|
|||
material-drawer = "9.0.2"
|
||||
material-iconics = "5.5.0-compose01"
|
||||
material-typeface = "4.0.0.3-kotlin"
|
||||
mlkit-language-id = "17.0.0"
|
||||
mockito-inline = "5.2.0"
|
||||
mockito-kotlin = "5.3.1"
|
||||
moshi = "1.15.1"
|
||||
|
@ -61,6 +63,7 @@ moshix = "0.27.1"
|
|||
networkresult-calladapter = "1.0.0"
|
||||
okhttp = "4.12.0"
|
||||
okio = "3.9.0"
|
||||
play-services-base = "18.5.0"
|
||||
quadrant = "1.9.1"
|
||||
retrofit = "2.11.0"
|
||||
robolectric = "4.12.2"
|
||||
|
@ -178,6 +181,7 @@ junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", vers
|
|||
kotlin-result = { module = "com.michael-bull.kotlin-result:kotlin-result", version.ref = "kotlin-result" }
|
||||
kotlin-result-coroutines = { module = "com.michael-bull.kotlin-result:kotlin-result-coroutines", version.ref = "kotlin-result" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "coroutines-play-services" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
||||
image-cropper = { module = "com.github.CanHub:Android-Image-Cropper", version.ref = "image-cropper" }
|
||||
|
@ -190,6 +194,7 @@ material-drawer-core = { module = "com.mikepenz:materialdrawer", version.ref = "
|
|||
material-drawer-iconics = { module = "com.mikepenz:materialdrawer-iconics", version.ref = "material-drawer" }
|
||||
material-iconics = { module = "com.mikepenz:iconics-core", version.ref="material-iconics" }
|
||||
material-typeface = { module = "com.mikepenz:google-material-typeface", version.ref = "material-typeface" }
|
||||
mlkit-language-id = { module = "com.google.android.gms:play-services-mlkit-language-id", version.ref = "mlkit-language-id" }
|
||||
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" }
|
||||
mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" }
|
||||
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
|
||||
|
@ -203,6 +208,7 @@ okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
|||
okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||
okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
|
||||
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
|
||||
play-services-base = { module = "com.google.android.gms:play-services-base", version.ref = "play-services-base" }
|
||||
retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
|
||||
retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
||||
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
|
||||
|
|
Loading…
Reference in New Issue