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:
Nik Clayton 2024-07-02 20:22:17 +02:00 committed by GitHub
parent 511107a36f
commit 4fc52f9bc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 566 additions and 2 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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()
}
}
}
}
}

View File

@ -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"

View File

@ -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 {

View File

@ -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/*")) {

View File

@ -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
}
}

View File

@ -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>

View File

@ -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)
}

View File

@ -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" }