Today's

길을 나서지 않으면 그 길에서 만날 수 있는 사람을 만날 수 없다

모바일 앱(안드로이드)

안드로이드 앱 만들기 : ML translation 에러 메시지를 번역 해 보자

Billcorea 2023. 1. 21. 11:39
반응형

Translation

번역을 시도하는 방법에는 여러 가지가 있습니다.  그중에는 비용이 들어가는 방법도 있고, 일부 무료인 방법도 있습니다.

  • 카카오 번역  API : 월 10,000자 까지는 무료 이후 1000자 단위로 18원 발
  • 구글 cloud 번역 API : 월 최대 500,000자 까지는 무료 이후부터는 비용 추가 (기본 옵션 선택 시)
  • 네이버 papago text 번역 : 1,000,000 단위 과금  20,000원 (과금 단위는 글자를 항상 올림)

등의 방법을 찾을 수 있습니다. 

 

오늘 하고 싶은 이야기는 간단한 문구를 그냥 번역해 보는 방법입니다. 

 

문서의 길이가 길고, 중요한 문서라고 한다면 비용이 들여서라도 번역은 정확하게 하는 것이 맞을 것 같습니다.  단지,  앱에서 사용하는 API들이 꼬부랑말(대부분 영어)로 되어 있는 것들이라서 API가 제공하는 오류 메시지를 그대로 보여 주는 것은 사용자 편의를 고려하지 않은 것이라 생각하게 되어 간편 번역을 해 보기로 했습니다. 

 

구글에서 제공하는 ML Kit에 보면 여러 가지가 있지만 오늘은 그중에서 Translation의 사용하는 방법에 대한 예시를 적어 두고자 합니다. 

 

https://developers.google.com/ml-kit/language/translation/android

 

Android에서 ML Kit를 사용하여 텍스트 번역  |  Google Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Switch to English Android에서 ML Kit를 사용하여 텍스트 번역 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.

developers.google.com

 

IOS 개발은 아직 해 보지 않기 때문에... 안드로이드로 개발하는 부분만 말해 볼까 합니다.  이 글은 위에 있는 개발 가이드의 내용을 참조하여 작성하였습니다.

Gradle

앱 수준의 gradle 파일에 한 줄 넣어 주세요.

// ML Kit translate
implementation 'com.google.mlkit:translate:17.0.1'

 

Viewmodel

다음은 가이드에 나와 있는 내용들을 다 정리하기가 너무 힘들어서 가이드의 예시용 sourcecode에서 viewModel 코드 파일을 그냥 복사했습니다.

 

/*
 * Copyright 2019 Google Inc. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */

import android.app.Application
import android.util.LruCache
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.billcoreatech.remotepayment0119.R
import com.google.android.gms.tasks.OnCompleteListener
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.common.model.DownloadConditions
import com.google.mlkit.common.model.RemoteModelManager
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.Translator
import com.google.mlkit.nl.translate.TranslatorOptions

import java.util.Locale

/**
 * Model class for tracking available models and performing live translations
 */
class TranslateViewModel(application: Application) : AndroidViewModel(application) {

  companion object {
    // This specifies the number of translators instance we want to keep in our LRU cache.
    // Each instance of the translator is built with different options based on the source
    // language and the target language, and since we want to be able to manage the number of
    // translator instances to keep around, an LRU cache is an easy way to achieve this.
    private const val NUM_TRANSLATORS = 3
  }

  private val modelManager: RemoteModelManager = RemoteModelManager.getInstance()
  private val pendingDownloads: HashMap<String, Task<Void>> = hashMapOf()
  private val translators =
    object : LruCache<TranslatorOptions, Translator>(NUM_TRANSLATORS) {
      override fun create(options: TranslatorOptions): Translator {
        return Translation.getClient(options)
      }
      override fun entryRemoved(
        evicted: Boolean,
        key: TranslatorOptions,
        oldValue: Translator,
        newValue: Translator?,
      ) {
        oldValue.close()
      }
    }
  val sourceLang = MutableLiveData<Language>()
  val targetLang = MutableLiveData<Language>()
  val sourceText = MutableLiveData<String>()
  val translatedText = MediatorLiveData<ResultOrError>()
  val availableModels = MutableLiveData<List<String>>()

  // Gets a list of all available translation languages.
  val availableLanguages: List<Language> = TranslateLanguage.getAllLanguages().map { Language(it) }

  init {
    // Create a translation result or error object.
    val processTranslation =
      OnCompleteListener<String> { task ->
        if (task.isSuccessful) {
          translatedText.value = ResultOrError(task.result, null)
        } else {
          translatedText.value = ResultOrError(null, task.exception)
        }
        // Update the list of downloaded models as more may have been
        // automatically downloaded due to requested translation.
        fetchDownloadedModels()
      }
    // Start translation if any of the following change: input text, source lang, target lang.
    translatedText.addSource(sourceText) { translate().addOnCompleteListener(processTranslation) }
    val languageObserver =
      Observer<Language> { translate().addOnCompleteListener(processTranslation) }
    translatedText.addSource(sourceLang, languageObserver)
    translatedText.addSource(targetLang, languageObserver)

    // Update the list of downloaded models.
    fetchDownloadedModels()
  }

  private fun getModel(languageCode: String): TranslateRemoteModel {
    return TranslateRemoteModel.Builder(languageCode).build()
  }

  // Updates the list of downloaded models available for local translation.
  private fun fetchDownloadedModels() {
    modelManager.getDownloadedModels(TranslateRemoteModel::class.java).addOnSuccessListener {
      remoteModels ->
      availableModels.value = remoteModels.sortedBy { it.language }.map { it.language }
    }
  }

  // Starts downloading a remote model for local translation.
  internal fun downloadLanguage(language: Language) {
    val model = getModel(TranslateLanguage.fromLanguageTag(language.code)!!)
    var downloadTask: Task<Void>?
    if (pendingDownloads.containsKey(language.code)) {
      downloadTask = pendingDownloads[language.code]
      // found existing task. exiting
      if (downloadTask != null && !downloadTask.isCanceled) {
        return
      }
    }
    downloadTask =
      modelManager.download(model, DownloadConditions.Builder().build()).addOnCompleteListener {
        pendingDownloads.remove(language.code)
        fetchDownloadedModels()
      }
    pendingDownloads[language.code] = downloadTask
  }

  // Returns if a new model download task should be started.
  fun requiresModelDownload(
    lang: Language,
    downloadedModels: List<String?>?,
  ): Boolean {
    return if (downloadedModels == null) {
      true
    } else !downloadedModels.contains(lang.code) && !pendingDownloads.containsKey(lang.code)
  }

  // Deletes a locally stored translation model.
  internal fun deleteLanguage(language: Language) {
    val model = getModel(TranslateLanguage.fromLanguageTag(language.code)!!)
    modelManager.deleteDownloadedModel(model).addOnCompleteListener { fetchDownloadedModels() }
    pendingDownloads.remove(language.code)
  }

  fun translate(): Task<String> {
    val text = sourceText.value
    val source = sourceLang.value
    val target = targetLang.value
    if (source == null || target == null || text == null || text.isEmpty()) {
      return Tasks.forResult("")
    }
    val sourceLangCode = TranslateLanguage.fromLanguageTag(source.code)!!
    val targetLangCode = TranslateLanguage.fromLanguageTag(target.code)!!
    val options =
      TranslatorOptions.Builder()
        .setSourceLanguage(sourceLangCode)
        .setTargetLanguage(targetLangCode)
        .build()
    return translators[options].downloadModelIfNeeded().continueWithTask { task ->
      if (task.isSuccessful) {
        translators[options].translate(text)
      } else {
        Tasks.forException<String>(
          task.exception
            ?: Exception(getApplication<Application>().getString(R.string.unknown_error))
        )
      }
    }
  }

  /** Holds the result of the translation or any error. */
  inner class ResultOrError(var result: String?, var error: Exception?)

  /**
   * Holds the language code (i.e. "en") and the corresponding localized full language name (i.e.
   * "English")
   */
  class Language(val code: String) : Comparable<Language> {

    private val displayName: String
      get() = Locale(code).displayName

    override fun equals(other: Any?): Boolean {
      if (other === this) {
        return true
      }

      if (other !is Language) {
        return false
      }

      val otherLang = other as Language?
      return otherLang!!.code == code
    }

    override fun toString(): String {
      return "$code - $displayName"
    }

    override fun compareTo(other: Language): Int {
      return this.displayName.compareTo(other.displayName)
    }

    override fun hashCode(): Int {
      return code.hashCode()
    }
  }

  override fun onCleared() {
    super.onCleared()
    // Each new instance of a translator needs to be closed appropriately. Here we utilize the
    // ViewModel's onCleared() to clear our LruCache and close each Translator instance when
    // this ViewModel is no longer used and destroyed.
    translators.evictAll()
  }
}

 

 

MainActivity

예시에 앱에서는 콤보 박스를 이용해서 source 언어와 번역 후 사용할 target 언어를 선택하도록 하였으나, 만들고 있는 앱에서는 영어(en)를 한국어(ko)로 번역하는 것만 사용할 생각 이기 때문에 다음과 같은 부분들을 MainActivity에 추가해 주었습니다. 

 

import android.app.Activity
.....
import java.security.MessageDigest

class MainActivity : ComponentActivity() {

    private val translateView : TranslateViewModel by viewModels()
    
    .....

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 간단한 문서 번역 ML
        translateView.sourceLang.value = TranslateViewModel.Language("en")
        translateView.targetLang.value = TranslateViewModel.Language("ko")
        if (!sp.getBoolean("isDownloadKR", false)) {
            translateView.downloadLanguage(TranslateViewModel.Language("ko"))
        }
        // 한국어 모델은 download 을 한 번 받아야 해서 
        translateView.availableModels.observe(
            this@MainActivity
        ) {result ->
            for (lang in result) {
                Log.e("", "lang=${lang}")
                if (lang == "kr") {
... // 다운로드 받은 걸 표시 해 두었다가 앱을 실행 할 때 마다 받는 건 방지 하도록 구현
                }
            }
        }

        ...

        setContent {

            val scrollableState = rememberScrollState()

            RemotePayment0119Theme {
                // A surface container using the 'background' color from the theme
                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(30.dp)
                        .verticalScroll(scrollableState),
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.Center
                ) {
                    DestinationsNavHost(navGraph = NavGraphs.root) {
                        composable(JoinUserScreenDestination) {
                            JoinUserScreen(
                                navigator = destinationsNavigator,
                                doSignup = { emailId, password ->
                                    doSignupUser(emailId, password)
                                }
                            )
                        }
                        composable(LoginOptionsDestination) {
                            LoginOptions(
                                doLogin = { email, password ->
                                    doEmailLogin(email, password)
                                },
                                doForGotPassword = { email ->
                                    doForgotPassword(email)
                                },
                                doGoogleLogin = {
                                    doGoogleLogin()
                                },
                                doFacebookLogin = {
                                    doFacebookLogin()
                                },
                                doRegisterUser = {
                                    destinationsNavigator.navigate(JoinUserScreenDestination)
                                },
                                doTranslateDownload = {
                                // 화면에서 버튼 클릭을 통해 일부로 받을 수 있도록 구현 할 수 도 있음.
                                    translateView.downloadLanguage(TranslateViewModel.Language("ko"))
                                    doToastMake(R.string.msgBeginDownload)
                                },
                                doEmailLoginWithLink = { emailid ->
                                    doEmailLoginWithLink(emailid)
                                }
                            )
                        }
                    }
                }
            }
        }
    }

    private fun doEmailLoginWithLink(emailId: MutableState<String>) {

    }

    private fun doSignupUser(emailId: MutableState<String>, password: MutableState<String>) {

    }

    private fun doFacebookLogin() {
        
    }

    private fun doGoogleLogin() {
        
    }

    private fun doForgotPassword(email: MutableState<String>) {

    }

    private fun doEmailLogin(email: MutableState<String>, password: MutableState<String>) {

        if (email.value == "") {
            doToastMake(R.string.EnterEmail)
            return
        }
        if (password.value == "") {
            doToastMake(R.string.EnterPassword)
            return
        }

        auth.signInWithEmailAndPassword(email.value, password.value)
            .addOnSuccessListener {
                doToastMake(R.string.msgSigninCompleted)
            }
            .addOnFailureListener {
                doToastMakeAppend(R.string.msgUserIdOrPasswordError, it.localizedMessage)
            }
    }

    private fun doToastMake(msgResource: Int) {
        Toast.makeText(this@MainActivity, getString(msgResource), Toast.LENGTH_SHORT).show()
    }

    private fun doToastMakeAppend(msgResource: Int, localizedMessage: String?) {

        // 에러 메시지가 영어로 오기 때문에 그것을 번역해 본다.
        // 번역이 된 이후에 toast 가 나오도록 구현
        translateView.sourceText.value = "$localizedMessage"
        translateView.translatedText.observe(
            this@MainActivity
        ) { result ->
            Log.e("", "result = ${result.error} ${result.result}")
            resultString = result.result.toString()

            Toast.makeText(this@MainActivity, "${getString(msgResource)}:${resultString}", Toast.LENGTH_SHORT).show()
        }

    }

}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    RemotePayment0119Theme {
        DestinationsNavHost(navGraph = NavGraphs.root)
    }
}

코드를 살펴보면 onCreate에서 먼저 사용할 언어를 정했습니다. 영어와 한국어로... 여기서 실수를 했던 부분은 한국어를 kr이라고 했던 건데,  무관심하게 kr 이라고 할 수 있으나, 예제 앱에서도 볼 수 있지만, ko라고 해 주어야 됩니다. (다들 아시는 것이라 생각하고 있습니다.)

 

그리고는 doToastMakeAppend 함수에서 인수로 받은 오류 메시지를 번역해 보여 주는 방식으로 코드를 작성했습니다. 아직 잘 알지 못하는 부분은 번역을 위해 전달한 문장이 2줄인 경우 응답이 2번으로 나누어져 오는 것 같다는 것입니다.  그래서 Toast 가 2번 발생됩니다.  그걸 해소하는 건 이 글을 보시는 분들의 몫으로 남겨 둡니다. 

 

아무튼 이렇게 하면 어렵지 않게 영어 오류 메시지를 한글로 번역해서 보여 줄 수 있습니다. 저처럼 firebase의 기능을 활용하시는 분들께 한 가지 팁이 될지 바랍니다.

 

 

활용예시

이렇게 구현된 앱 샘플입니다.  firebase에 email을 통해 로그인하기 위해서 사용자 가입을 시도해 봅니다.  그때 입력된 password의 길이가 6자리 미만의 경우 오류 처리가 됩니다. 

private fun doSignupUser(emailId: MutableState<String>, password: MutableState<String>) {

    if (emailId.value == "") {
        doToastMake(R.string.EnterEmail)
        return
    }
    if (password.value == "") {
        doToastMake(R.string.EnterPassword)
        return
    }
    auth.createUserWithEmailAndPassword(emailId.value, password.value)
        .addOnSuccessListener {
            doToastMake(R.string.msgCreateUserCompleted)
        }
        .addOnFailureListener {
            doToastMakeAppend(R.string.msgCreateUserFailure, it.localizedMessage)
        }
}

이때 나오는 오류 메시지를 doToastMakeAppend 함수 호출을 통해 번역을 시도해 보는 것입니다. 

 

간편 번역 샘플

 

이상으로 ML Translation에 대해 알아보았습니다.

 

읽어 주셔서 감사합니다.

 

 

반응형