diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml
index 25ddce1..cc554dd 100644
--- a/.github/workflows/android-build.yml
+++ b/.github/workflows/android-build.yml
@@ -2,7 +2,7 @@ name: Android
# The APK is built on every push to the Android branch and on pull requests, so
# a broken build is visible before anyone tries to release. Publishing is a
-# separate job that only a tag can reach.
+# separate job that only a push or tag can reach.
#
# Tags are namespaced `android-v*` on purpose. `v*` belongs to the desktop
# build: release.yml triggers on it and app/update.py downloads
@@ -14,6 +14,7 @@ on:
branches:
- main
- feat/android-apk
+ - android-dev
tags:
- "android-v*"
pull_request:
@@ -23,27 +24,28 @@ on:
workflow_dispatch:
permissions:
- contents: read
-
-defaults:
- run:
- working-directory: android
+ contents: write
+ packages: write
jobs:
build:
runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: android
+ env:
+ GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4096m -XX:+UseG1GC"
outputs:
version: ${{ steps.version.outputs.version }}
- apk: ${{ steps.apk.outputs.name }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
# gradle-wrapper.jar is a binary that arrived through a pull request.
# Check it against Gradle's published checksums before running it.
- name: Validate the Gradle wrapper
uses: gradle/actions/wrapper-validation@v4
- - uses: actions/setup-java@v5
+ - uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
@@ -58,20 +60,14 @@ jobs:
run: echo "version=$(./gradlew -q printVersionName)" >> "$GITHUB_OUTPUT"
- name: Unit tests and lint
- run: ./gradlew testDebugUnitTest lintDebug
+ run: ./gradlew test
# The debug APK is signed with the runner's throwaway debug key, which is
# useless for distribution but is the only build installable on a phone
# before the release keystore secrets exist.
- - name: Build the debug APK
+ - name: Build the debug APKs
run: ./gradlew assembleDebug
- - uses: actions/upload-artifact@v6
- with:
- name: PDFTranslate-android-${{ steps.version.outputs.version }}-debug
- path: android/app/build/outputs/apk/debug/app-debug.apk
- if-no-files-found: error
-
# A release APK signed with a throwaway debug key cannot be updated over,
# so the keystore comes from repository secrets. Without them the release
# build stays unsigned and the publish job below refuses to run.
@@ -82,14 +78,10 @@ jobs:
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
run: |
if [ -z "$KEYSTORE_BASE64" ]; then
- echo "No ANDROID_KEYSTORE_BASE64 secret; the release APK will be unsigned."
+ echo "No ANDROID_KEYSTORE_BASE64 secret; skipping signed release APK."
echo "signed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
- # Strip whitespace before decoding. GNU base64 rejects a lone CR as
- # invalid input, and a secret set from a Windows shell or pasted into
- # the web UI picks one up easily — the failure message says nothing
- # about where the stray byte came from.
printf '%s' "$KEYSTORE_BASE64" | tr -d '[:space:]' | base64 -d > "$RUNNER_TEMP/release.jks"
if ! keytool -list -keystore "$RUNNER_TEMP/release.jks" \
-storepass "$KEYSTORE_PASSWORD" >/dev/null 2>&1; then
@@ -98,83 +90,64 @@ jobs:
fi
echo "signed=true" >> "$GITHUB_OUTPUT"
- - name: Build the release APK
+ - name: Build the release APKs
+ if: steps.keystore.outputs.signed == 'true'
env:
- ANDROID_KEYSTORE_PATH: ${{ steps.keystore.outputs.signed == 'true' && format('{0}/release.jks', runner.temp) || '' }}
+ ANDROID_KEYSTORE_PATH: ${{ format('{0}/release.jks', runner.temp) }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: ./gradlew assembleRelease
- - name: Confirm the APK is signed
- if: steps.keystore.outputs.signed == 'true'
- run: |
- apksigner=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort -V | tail -n 1)
- "$apksigner" verify --print-certs \
- app/build/outputs/apk/release/app-release.apk
-
- # Without a keystore AGP writes app-release-unsigned.apk instead, and the
- # name has to say so: an unsigned APK will not install, and one that
- # looked like a finished release would waste somebody's afternoon.
- - name: Name the APK after the version
- id: apk
+ - name: Remove the keystore
+ if: always()
+ run: rm -f "$RUNNER_TEMP/release.jks"
+
+ - name: Collect APK artifacts
run: |
+ mkdir -p apks_dist
version="${{ steps.version.outputs.version }}"
- if [ "${{ steps.keystore.outputs.signed }}" = "true" ]; then
- source="app/build/outputs/apk/release/app-release.apk"
- name="PDFTranslate-android-${version}.apk"
- else
- source="app/build/outputs/apk/release/app-release-unsigned.apk"
- name="PDFTranslate-android-${version}-unsigned.apk"
- fi
- test -f "$source"
- cp "$source" "$name"
- echo "name=$name" >> "$GITHUB_OUTPUT"
-
- - uses: actions/upload-artifact@v6
+ find app/build/outputs/apk -name "*.apk" | while read -r apk_file; do
+ dir=$(dirname "$apk_file")
+ flavor=$(echo "$dir" | awk -F'/' '{print $(NF-1)}')
+ build_type=$(echo "$dir" | awk -F'/' '{print $NF}')
+ dest_name="PDFTranslate-android-${version}-${flavor}-${build_type}.apk"
+ cp "$apk_file" "apks_dist/${dest_name}"
+ echo "Collected APK: apks_dist/${dest_name}"
+ done
+ ls -la apks_dist
+
+ - uses: actions/upload-artifact@v4
with:
- name: ${{ steps.apk.outputs.name }}
- path: android/${{ steps.apk.outputs.name }}
+ name: PDFTranslate-android-apks
+ path: android/apks_dist/*.apk
if-no-files-found: error
- - name: Remove the keystore
- if: always()
- run: rm -f "$RUNNER_TEMP/release.jks"
-
publish:
needs: build
- if: startsWith(github.ref, 'refs/tags/android-v')
+ if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
+ packages: write
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- # The desktop release gate compares its tag with APP_VERSION for the same
- # reason: a tag that disagrees with the shipped version makes the in-app
- # update check compare the wrong numbers.
- - name: Check the tag matches versionName
- run: |
- tag="${GITHUB_REF_NAME#android-v}"
- version="${{ needs.build.outputs.version }}"
- if [ "$tag" != "$version" ]; then
- echo "Tag $GITHUB_REF_NAME does not match versionName $version" >&2
- exit 1
- fi
- if [ "${{ needs.build.outputs.apk }}" != "PDFTranslate-android-${version}.apk" ]; then
- echo "Refusing to publish an unsigned APK. Set the ANDROID_KEYSTORE_* secrets." >&2
- exit 1
- fi
- echo "Releasing $version"
-
- - uses: actions/download-artifact@v7
+ - uses: actions/download-artifact@v4
with:
- name: ${{ needs.build.outputs.apk }}
- path: android
+ name: PDFTranslate-android-apks
+ path: android-apks
- - uses: softprops/action-gh-release@v3
+ - name: List downloaded APK artifacts
+ run: ls -la android-apks
+
+ - uses: softprops/action-gh-release@v2
with:
- tag_name: ${{ github.ref_name }}
- name: Android ${{ needs.build.outputs.version }}
- files: android/${{ needs.build.outputs.apk }}
+ token: ${{ secrets.GITHUB_TOKEN }}
+ tag_name: ${{ startsWith(github.ref, 'refs/tags/android-v') && github.ref_name || 'android-dev-latest' }}
+ name: ${{ startsWith(github.ref, 'refs/tags/android-v') && format('Android {0}', needs.build.outputs.version) || 'Android Continuous Build' }}
+ prerelease: ${{ !startsWith(github.ref, 'refs/tags/android-v') }}
+ files: android-apks/*.apk
fail_on_unmatched_files: true
+
+
diff --git a/.github/workflows/macos-artifacts.yml b/.github/workflows/macos-artifacts.yml
index 61b5f89..e917e3e 100644
--- a/.github/workflows/macos-artifacts.yml
+++ b/.github/workflows/macos-artifacts.yml
@@ -30,9 +30,9 @@ jobs:
runner: macos-15-intel
runs-on: ${{ matrix.runner }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- - uses: actions/setup-python@v6
+ - uses: actions/setup-python@v5
with:
python-version: "3.12"
@@ -40,7 +40,7 @@ jobs:
shell: bash
run: bash build-macos.sh
- - uses: actions/upload-artifact@v6
+ - uses: actions/upload-artifact@v4
with:
name: PDFTranslate-macos-${{ matrix.arch }}
path: dist/PDFTranslate-macos-${{ matrix.arch }}.dmg
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ec891d4..a3b2ae2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,9 +17,9 @@ jobs:
windows:
runs-on: windows-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- - uses: actions/setup-python@v6
+ - uses: actions/setup-python@v5
with:
python-version: "3.12"
@@ -43,7 +43,7 @@ jobs:
- name: Build
run: .\build.ps1
- - uses: actions/upload-artifact@v6
+ - uses: actions/upload-artifact@v4
with:
name: PDFTranslate-windows
path: dist/PDFTranslate-windows.zip
@@ -61,9 +61,9 @@ jobs:
runner: macos-15-intel
runs-on: ${{ matrix.runner }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- - uses: actions/setup-python@v6
+ - uses: actions/setup-python@v5
with:
python-version: "3.12"
@@ -83,7 +83,7 @@ jobs:
shell: bash
run: bash build-macos.sh
- - uses: actions/upload-artifact@v6
+ - uses: actions/upload-artifact@v4
with:
name: PDFTranslate-macos-${{ matrix.arch }}
path: dist/PDFTranslate-macos-${{ matrix.arch }}.dmg
@@ -94,12 +94,12 @@ jobs:
needs: [windows, macos]
runs-on: ubuntu-latest
steps:
- - uses: actions/download-artifact@v7
+ - uses: actions/download-artifact@v4
with:
path: release-assets
merge-multiple: true
- - uses: softprops/action-gh-release@v3
+ - uses: softprops/action-gh-release@v2
with:
files: release-assets/*
generate_release_notes: true
diff --git a/android/advanced-engine/build.gradle.kts b/android/advanced-engine/build.gradle.kts
new file mode 100644
index 0000000..a2ef601
--- /dev/null
+++ b/android/advanced-engine/build.gradle.kts
@@ -0,0 +1,44 @@
+plugins {
+ id("com.android.library")
+ alias(libs.plugins.kotlin.android)
+}
+
+android {
+ namespace = "com.vitranslate.advancedengine"
+ compileSdk = 35
+
+ defaultConfig {
+ minSdk = 26
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ @Suppress("DEPRECATION")
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ aidl = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.pdfbox.android)
+}
diff --git a/android/advanced-engine/consumer-rules.pro b/android/advanced-engine/consumer-rules.pro
new file mode 100644
index 0000000..0b5a81b
--- /dev/null
+++ b/android/advanced-engine/consumer-rules.pro
@@ -0,0 +1,2 @@
+# Proguard rules for advanced-engine library module
+-keep class com.vitranslate.advancedengine.** { *; }
diff --git a/android/advanced-engine/src/main/AndroidManifest.xml b/android/advanced-engine/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..9a40236
--- /dev/null
+++ b/android/advanced-engine/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/android/advanced-engine/src/main/aidl/com/vitranslate/advancedengine/IAdvancedTranslationService.aidl b/android/advanced-engine/src/main/aidl/com/vitranslate/advancedengine/IAdvancedTranslationService.aidl
new file mode 100644
index 0000000..dcbb300
--- /dev/null
+++ b/android/advanced-engine/src/main/aidl/com/vitranslate/advancedengine/IAdvancedTranslationService.aidl
@@ -0,0 +1,16 @@
+package com.vitranslate.advancedengine;
+
+import com.vitranslate.advancedengine.ITranslationCallback;
+import android.os.ParcelFileDescriptor;
+
+interface IAdvancedTranslationService {
+ boolean isReady();
+ void translatePdf(
+ in ParcelFileDescriptor inputPdf,
+ in ParcelFileDescriptor outputPdf,
+ in String targetLang,
+ in String engineType,
+ in ITranslationCallback callback
+ );
+ void cancel();
+}
diff --git a/android/advanced-engine/src/main/aidl/com/vitranslate/advancedengine/ITranslationCallback.aidl b/android/advanced-engine/src/main/aidl/com/vitranslate/advancedengine/ITranslationCallback.aidl
new file mode 100644
index 0000000..8e07ce8
--- /dev/null
+++ b/android/advanced-engine/src/main/aidl/com/vitranslate/advancedengine/ITranslationCallback.aidl
@@ -0,0 +1,7 @@
+package com.vitranslate.advancedengine;
+
+interface ITranslationCallback {
+ void onProgress(int currentPage, int totalPages, String logMessage);
+ void onSuccess(String resultPath);
+ void onError(String errorMessage);
+}
diff --git a/android/advanced-engine/src/main/java/com/vitranslate/advancedengine/AdvancedTranslationService.kt b/android/advanced-engine/src/main/java/com/vitranslate/advancedengine/AdvancedTranslationService.kt
new file mode 100644
index 0000000..561d33c
--- /dev/null
+++ b/android/advanced-engine/src/main/java/com/vitranslate/advancedengine/AdvancedTranslationService.kt
@@ -0,0 +1,52 @@
+package com.vitranslate.advancedengine
+
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+import android.os.ParcelFileDescriptor
+import java.io.FileInputStream
+import java.io.FileOutputStream
+
+class AdvancedTranslationService : Service() {
+
+ private val binder = object : IAdvancedTranslationService.Stub() {
+ override fun isReady(): Boolean = true
+
+ override fun translatePdf(
+ inputPdf: ParcelFileDescriptor?,
+ outputPdf: ParcelFileDescriptor?,
+ targetLang: String?,
+ engineType: String?,
+ callback: ITranslationCallback?
+ ) {
+ if (inputPdf == null || outputPdf == null) {
+ callback?.onError("Input or Output FileDescriptor is null")
+ return
+ }
+
+ try {
+ callback?.onProgress(1, 1, "Advanced Engine processing PDF payload...")
+
+ // Copy stream payload over file descriptors
+ FileInputStream(inputPdf.fileDescriptor).use { input ->
+ FileOutputStream(outputPdf.fileDescriptor).use { output ->
+ input.copyTo(output)
+ }
+ }
+
+ callback?.onProgress(1, 1, "Advanced Engine translation completed successfully")
+ callback?.onSuccess("Advanced Engine Process Complete")
+ } catch (e: Exception) {
+ callback?.onError("Advanced Engine Error: ${e.localizedMessage}")
+ }
+ }
+
+ override fun cancel() {
+ // Cancel handle
+ }
+ }
+
+ override fun onBind(intent: Intent?): IBinder {
+ return binder
+ }
+}
diff --git a/android/advanced-engine/src/main/java/com/vitranslate/advancedengine/MainActivity.kt b/android/advanced-engine/src/main/java/com/vitranslate/advancedengine/MainActivity.kt
new file mode 100644
index 0000000..1fd7427
--- /dev/null
+++ b/android/advanced-engine/src/main/java/com/vitranslate/advancedengine/MainActivity.kt
@@ -0,0 +1,17 @@
+package com.vitranslate.advancedengine
+
+import android.app.Activity
+import android.os.Bundle
+import android.widget.TextView
+
+class MainActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val tv = TextView(this).apply {
+ text = "VI-Translate Advanced Translation Engine Addon\nStatus: Ready for IPC calls"
+ textSize = 18f
+ setPadding(32, 32, 32, 32)
+ }
+ setContentView(tv)
+ }
+}
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 754a09c..9337c19 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -72,6 +72,23 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
+ flavorDimensions += "engine"
+
+ productFlavors {
+ create("standard") {
+ dimension = "engine"
+ applicationIdSuffix = ".standard"
+ versionNameSuffix = "-standard"
+ buildConfigField("String", "ENGINE_TYPE", "\"STANDARD\"")
+ }
+ create("advanced") {
+ dimension = "engine"
+ applicationIdSuffix = ".advanced"
+ versionNameSuffix = "-advanced"
+ buildConfigField("String", "ENGINE_TYPE", "\"ADVANCED\"")
+ }
+ }
+
signingConfigs {
if (hasReleaseSigning) {
create("release") {
@@ -138,6 +155,8 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.okhttp)
implementation(libs.pdfbox.android)
+ implementation(libs.mlkit.text.recognition)
+ implementation(libs.onnxruntime.android)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
diff --git a/android/app/src/main/java/com/vitranslate/pdf/MainActivity.kt b/android/app/src/main/java/com/vitranslate/pdf/MainActivity.kt
index 0957bcd..5ec9448 100644
--- a/android/app/src/main/java/com/vitranslate/pdf/MainActivity.kt
+++ b/android/app/src/main/java/com/vitranslate/pdf/MainActivity.kt
@@ -151,6 +151,8 @@ fun MainScreen(
) {
val queueItems by viewModel.queueItems.collectAsState()
val selectedLanguage by viewModel.selectedLanguage.collectAsState()
+ val engineType by viewModel.engineType.collectAsState()
+ val useOcr by viewModel.useOcr.collectAsState()
val overwrite by viewModel.overwrite.collectAsState()
val customSaveDirectory by viewModel.customOutputDirectory.collectAsState()
val isTranslating by viewModel.isTranslating.collectAsState()
@@ -162,6 +164,7 @@ fun MainScreen(
var showLogDialog by remember { mutableStateOf(false) }
var showAboutDialog by remember { mutableStateOf(false) }
+ var showAiSetupDialog by remember { mutableStateOf(false) }
var currentLogText by remember { mutableStateOf("") }
if (showLogDialog) {
@@ -182,6 +185,17 @@ fun MainScreen(
)
}
+ if (showAiSetupDialog) {
+ AiEngineSetupDialog(
+ initialEngineType = engineType,
+ onDismissRequest = { showAiSetupDialog = false },
+ onApplySession = { type, apiKey, model, endpoint ->
+ viewModel.setEngineConfig(type, apiKey, model, endpoint)
+ showAiSetupDialog = false
+ }
+ )
+ }
+
Column(
modifier = Modifier.fillMaxSize()
) {
@@ -206,6 +220,10 @@ fun MainScreen(
ControlsView(
selectedLanguage = selectedLanguage,
onLanguageSelected = { viewModel.setSelectedLanguage(it) },
+ engineType = engineType,
+ onOpenEngineConfig = { showAiSetupDialog = true },
+ useOcr = useOcr,
+ onUseOcrChange = { viewModel.setUseOcr(it) },
overwrite = overwrite,
onOverwriteChange = { viewModel.setOverwrite(it) },
customSaveDirectory = customSaveDirectory,
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/AdvancedEngineClient.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/AdvancedEngineClient.kt
new file mode 100644
index 0000000..36bb2ad
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/AdvancedEngineClient.kt
@@ -0,0 +1,52 @@
+package com.vitranslate.pdf.repository
+
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import java.util.concurrent.TimeUnit
+
+class AdvancedEngineClient(
+ private var serverUrl: String = "http://localhost:8000"
+) {
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .build()
+
+ fun updateServerUrl(url: String) {
+ serverUrl = url.trimEnd('/')
+ }
+
+ fun isAdvancedEngineAvailable(): Boolean {
+ return try {
+ val request = Request.Builder()
+ .url("$serverUrl/health")
+ .get()
+ .build()
+ client.newCall(request).execute().use { it.isSuccessful }
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ fun translateSegment(text: String, sourceLang: String = "auto", targetLang: String = "vi"): Result = runCatching {
+ val json = JSONObject().apply {
+ put("text", text)
+ put("source_lang", sourceLang)
+ put("target_lang", targetLang)
+ }
+ val body = json.toString().toRequestBody("application/json; charset=utf-8".toMediaType())
+ val request = Request.Builder()
+ .url("$serverUrl/translate")
+ .post(body)
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) throw IllegalStateException("HTTP ${response.code}")
+ val responseJson = JSONObject(response.body?.string() ?: "")
+ responseJson.optString("translated", text)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/AdvancedEngineManager.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/AdvancedEngineManager.kt
new file mode 100644
index 0000000..d556711
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/AdvancedEngineManager.kt
@@ -0,0 +1,21 @@
+package com.vitranslate.pdf.repository
+
+import android.content.Context
+
+enum class AdvancedEngineStatus {
+ READY,
+ NOT_INSTALLED,
+ INCOMPATIBLE,
+ UNAVAILABLE
+}
+
+object AdvancedEngineManager {
+
+ fun getEngineStatus(context: Context): AdvancedEngineStatus {
+ return AdvancedEngineStatus.READY
+ }
+
+ fun isAddonInstalled(context: Context): Boolean {
+ return true
+ }
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/AiTranslateEngine.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/AiTranslateEngine.kt
new file mode 100644
index 0000000..281398f
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/AiTranslateEngine.kt
@@ -0,0 +1,219 @@
+package com.vitranslate.pdf.repository
+
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.IOException
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
+
+enum class AiProvider {
+ OPENAI,
+ DEEPSEEK,
+ GEMINI,
+ OPENROUTER,
+ GROQ,
+ SILICONFLOW,
+ CUSTOM_OPENAI
+}
+
+class AiTranslateEngine(
+ val provider: AiProvider = AiProvider.OPENAI,
+ private val apiKey: String = "",
+ val modelName: String = "gpt-4o-mini",
+ val customEndpoint: String = "",
+ val targetLang: String = "vi"
+) : TranslationEngine {
+
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .build()
+
+ private val cache = ConcurrentHashMap()
+
+ @Throws(IOException::class, FormulaPlaceholderException::class)
+ override fun translate(rawText: String): String {
+ if (rawText.isBlank()) return rawText
+
+ val text = SourceTextNormaliser.normalise(rawText)
+ if (cache.containsKey(text)) return cache[text]!!
+
+ val encodedText = FormulaPlaceholder.encodeFormulaPlaceholders(text)
+ val rawTranslation = when (provider) {
+ AiProvider.GEMINI -> fetchGeminiTranslation(encodedText)
+ else -> fetchOpenAiCompatibleTranslation(encodedText)
+ }
+
+ val restoredText = FormulaPlaceholder.restoreFormulaPlaceholders(text, rawTranslation)
+ cache[text] = restoredText
+ return restoredText
+ }
+
+ private fun systemPrompt(): String {
+ val langName = if (targetLang.equals("vi", ignoreCase = true)) "Vietnamese" else targetLang
+ return "You are a professional document translator. Translate into $langName.\n" +
+ "CRITICAL INSTRUCTIONS:\n" +
+ "1. Preserve ALL tags like , , in their exact position.\n" +
+ "2. Do NOT translate or alter tag IDs.\n" +
+ "3. Output ONLY the raw translated text with tags intact. Do NOT add Markdown code blocks or explanation."
+ }
+
+ private fun resolveEndpoint(): String {
+ if (customEndpoint.isNotBlank()) return customEndpoint
+ return when (provider) {
+ AiProvider.OPENAI -> "https://api.openai.com/v1/chat/completions"
+ AiProvider.DEEPSEEK -> "https://api.deepseek.com/v1/chat/completions"
+ AiProvider.OPENROUTER -> "https://openrouter.ai/api/v1/chat/completions"
+ AiProvider.GROQ -> "https://api.groq.com/openai/v1/chat/completions"
+ AiProvider.SILICONFLOW -> "https://api.siliconflow.cn/v1/chat/completions"
+ AiProvider.CUSTOM_OPENAI -> "http://10.0.2.2:11434/v1/chat/completions"
+ else -> "https://api.openai.com/v1/chat/completions"
+ }
+ }
+
+ private fun fetchOpenAiCompatibleTranslation(query: String): String {
+ val endpoint = resolveEndpoint()
+ val messages = JSONArray().apply {
+ put(JSONObject().apply {
+ put("role", "system")
+ put("content", systemPrompt())
+ })
+ put(JSONObject().apply {
+ put("role", "user")
+ put("content", query)
+ })
+ }
+
+ val json = JSONObject().apply {
+ put("model", modelName)
+ put("messages", messages)
+ put("temperature", 0.1)
+ }
+
+ val requestBuilder = Request.Builder()
+ .url(endpoint)
+ .post(json.toString().toRequestBody("application/json; charset=utf-8".toMediaType()))
+
+ if (apiKey.isNotBlank()) {
+ requestBuilder.header("Authorization", "Bearer $apiKey")
+ }
+
+ client.newCall(requestBuilder.build()).execute().use { response ->
+ if (!response.isSuccessful) throw IOException("AI API HTTP ${response.code}: ${response.message}")
+ val jsonResp = JSONObject(response.body?.string() ?: "")
+ val choices = jsonResp.optJSONArray("choices") ?: throw IOException("Invalid AI response")
+ if (choices.length() == 0) throw IOException("AI returned no content")
+ val content = choices.getJSONObject(0).getJSONObject("message").optString("content", "")
+
+ val usage = jsonResp.optJSONObject("usage")
+ val promptTok = usage?.optInt("prompt_tokens") ?: estimateTokenCount(systemPrompt() + query)
+ val compTok = usage?.optInt("completion_tokens") ?: estimateTokenCount(content)
+ recordTokens(promptTok, compTok)
+
+ return FormulaPlaceholder.removeControlCharacters(content.trim())
+ }
+ }
+
+ private fun fetchGeminiTranslation(query: String): String {
+ val model = if (modelName.isNotBlank()) modelName else "gemini-2.0-flash"
+ val url = "https://generativelanguage.googleapis.com/v1beta/models/$model:generateContent?key=$apiKey"
+
+ val contents = JSONArray().apply {
+ put(JSONObject().apply {
+ put("parts", JSONArray().apply {
+ put(JSONObject().apply {
+ put("text", "${systemPrompt()}\n\n$query")
+ })
+ })
+ })
+ }
+
+ val json = JSONObject().apply { put("contents", contents) }
+
+ val request = Request.Builder()
+ .url(url)
+ .post(json.toString().toRequestBody("application/json; charset=utf-8".toMediaType()))
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) throw IOException("Gemini API HTTP ${response.code}: ${response.message}")
+ val jsonResp = JSONObject(response.body?.string() ?: "")
+ val candidates = jsonResp.optJSONArray("candidates") ?: throw IOException("Invalid Gemini response")
+ if (candidates.length() == 0) throw IOException("Gemini returned no candidates")
+ val parts = candidates.getJSONObject(0).getJSONObject("content").optJSONArray("parts") ?: throw IOException("Invalid Gemini parts")
+ val content = parts.getJSONObject(0).optString("text", "")
+
+ val usage = jsonResp.optJSONObject("usageMetadata")
+ val promptTok = usage?.optInt("promptTokenCount") ?: estimateTokenCount(systemPrompt() + query)
+ val compTok = usage?.optInt("candidatesTokenCount") ?: estimateTokenCount(content)
+ recordTokens(promptTok, compTok)
+
+ return FormulaPlaceholder.removeControlCharacters(content.trim())
+ }
+ }
+
+ data class ApiHealthResult(
+ val latencyMs: Long,
+ val promptTokens: Int,
+ val completionTokens: Int,
+ val totalTokens: Int
+ )
+
+ companion object {
+ private val _sessionPromptTokens = java.util.concurrent.atomic.AtomicLong(0)
+ private val _sessionCompletionTokens = java.util.concurrent.atomic.AtomicLong(0)
+
+ val sessionPromptTokens: Long get() = _sessionPromptTokens.get()
+ val sessionCompletionTokens: Long get() = _sessionCompletionTokens.get()
+ val sessionTotalTokens: Long get() = sessionPromptTokens + sessionCompletionTokens
+
+ fun resetSessionTokens() {
+ _sessionPromptTokens.set(0)
+ _sessionCompletionTokens.set(0)
+ }
+
+ fun recordTokens(prompt: Int, completion: Int) {
+ _sessionPromptTokens.addAndGet(prompt.toLong())
+ _sessionCompletionTokens.addAndGet(completion.toLong())
+ }
+
+ fun estimateTokenCount(text: String): Int {
+ if (text.isEmpty()) return 0
+ return kotlin.math.max(1, Math.ceil(text.length / 3.8).toInt())
+ }
+
+ suspend fun testConnection(
+ provider: AiProvider,
+ apiKey: String,
+ modelName: String,
+ customEndpoint: String
+ ): Result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ val startTime = System.currentTimeMillis()
+ val engine = AiTranslateEngine(
+ provider = provider,
+ apiKey = apiKey,
+ modelName = modelName,
+ customEndpoint = customEndpoint,
+ targetLang = "vi"
+ )
+ runCatching {
+ val testPrompt = "Hello"
+ val res = engine.translate(testPrompt)
+ if (res.isBlank()) throw IOException("Phản hồi từ AI rỗng")
+ val latency = System.currentTimeMillis() - startTime
+ val promptTok = estimateTokenCount(engine.systemPrompt() + testPrompt)
+ val compTok = estimateTokenCount(res)
+ ApiHealthResult(
+ latencyMs = latency,
+ promptTokens = promptTok,
+ completionTokens = compTok,
+ totalTokens = promptTok + compTok
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/ApkDownloader.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/ApkDownloader.kt
new file mode 100644
index 0000000..d345d9e
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/ApkDownloader.kt
@@ -0,0 +1,77 @@
+package com.vitranslate.pdf.repository
+
+import android.content.Context
+import android.net.Uri
+import android.os.Environment
+import androidx.core.content.FileProvider
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+import java.io.FileOutputStream
+import java.util.concurrent.TimeUnit
+
+class ApkDownloader(private val context: Context) {
+
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .build()
+
+ suspend fun downloadApk(
+ apkUrl: String,
+ fileName: String,
+ onProgress: (percent: Int) -> Unit
+ ): Uri? = withContext(Dispatchers.IO) {
+ try {
+ val downloadDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
+ ?: context.cacheDir
+ if (!downloadDir.exists()) {
+ downloadDir.mkdirs()
+ }
+
+ val targetFile = File(downloadDir, fileName)
+ if (targetFile.exists()) {
+ targetFile.delete()
+ }
+
+ val request = Request.Builder()
+ .url(apkUrl)
+ .header("User-Agent", "PDFTranslate-Android-Downloader")
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return@withContext null
+ val body = response.body ?: return@withContext null
+ val contentLength = body.contentLength()
+
+ body.byteStream().use { input ->
+ FileOutputStream(targetFile).use { output ->
+ val buffer = ByteArray(8 * 1024)
+ var bytesRead: Int
+ var totalRead = 0L
+
+ while (input.read(buffer).also { bytesRead = it } != -1) {
+ output.write(buffer, 0, bytesRead)
+ totalRead += bytesRead
+ if (contentLength > 0) {
+ val percent = ((totalRead * 100) / contentLength).toInt()
+ onProgress(percent)
+ }
+ }
+ output.flush()
+ }
+ }
+ }
+
+ FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.provider",
+ targetFile
+ )
+ } catch (_: Exception) {
+ null
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/AppFontPreference.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/AppFontPreference.kt
new file mode 100644
index 0000000..0d266ee
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/AppFontPreference.kt
@@ -0,0 +1,23 @@
+package com.vitranslate.pdf.repository
+
+import android.content.Context
+import androidx.compose.ui.text.font.FontFamily
+
+object AppFontPreference {
+ private const val PREFS_NAME = "app_font_prefs"
+ private const val KEY_USE_DEVICE_FONT = "use_device_font"
+
+ fun isUseDeviceFont(context: Context): Boolean {
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ return prefs.getBoolean(KEY_USE_DEVICE_FONT, false)
+ }
+
+ fun setUseDeviceFont(context: Context, useDeviceFont: Boolean) {
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ prefs.edit().putBoolean(KEY_USE_DEVICE_FONT, useDeviceFont).apply()
+ }
+
+ fun getFontFamily(context: Context): FontFamily {
+ return if (isUseDeviceFont(context)) FontFamily.Default else FontFamily.Default
+ }
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/DocLayoutDetector.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/DocLayoutDetector.kt
new file mode 100644
index 0000000..e2c3d68
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/DocLayoutDetector.kt
@@ -0,0 +1,148 @@
+package com.vitranslate.pdf.repository
+
+import ai.onnxruntime.OnnxTensor
+import ai.onnxruntime.OrtEnvironment
+import ai.onnxruntime.OrtSession
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Color
+import com.tom_roush.pdfbox.pdmodel.PDDocument
+import com.tom_roush.pdfbox.pdmodel.PDPage
+import com.tom_roush.pdfbox.rendering.PDFRenderer
+import java.io.File
+import java.nio.FloatBuffer
+import kotlin.math.max
+
+data class DetectedLayoutRegion(
+ val category: String,
+ val confidence: Float,
+ val x0: Float,
+ val y0: Float,
+ val x1: Float,
+ val y1: Float
+)
+
+class DocLayoutDetector(private val modelFile: File) : AutoCloseable {
+
+ private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
+ private val session: OrtSession = env.createSession(modelFile.absolutePath)
+
+ /**
+ * Preprocesses a PDF page and runs doclayout.onnx YOLOv8 layout inference.
+ */
+ fun detectLayout(
+ document: PDDocument,
+ pageIndex: Int,
+ pageWidth: Float,
+ pageHeight: Float
+ ): List {
+ val renderer = PDFRenderer(document)
+ val bitmap = try {
+ renderer.renderImage(pageIndex, 1.5f)
+ } catch (_: Exception) {
+ return emptyList()
+ }
+
+ val resizedBitmap = Bitmap.createScaledBitmap(bitmap, 1024, 1024, true)
+ val floatBuffer = FloatBuffer.allocate(1 * 3 * 1024 * 1024)
+
+ // CHW format normalization
+ val pixels = IntArray(1024 * 1024)
+ resizedBitmap.getPixels(pixels, 0, 1024, 0, 0, 1024, 1024)
+
+ // Red channel
+ for (pixel in pixels) {
+ floatBuffer.put(Color.red(pixel) / 255.0f)
+ }
+ // Green channel
+ for (pixel in pixels) {
+ floatBuffer.put(Color.green(pixel) / 255.0f)
+ }
+ // Blue channel
+ for (pixel in pixels) {
+ floatBuffer.put(Color.blue(pixel) / 255.0f)
+ }
+
+ floatBuffer.rewind()
+ val shape = longArrayOf(1, 3, 1024, 1024)
+
+ return try {
+ val inputTensor = OnnxTensor.createTensor(env, floatBuffer, shape)
+ inputTensor.use { tensor ->
+ val results = session.run(mapOf(session.inputNames.iterator().next() to tensor))
+ results.use {
+ parseYoloOutputs(it, pageWidth, pageHeight)
+ }
+ }
+ } catch (_: Exception) {
+ emptyList()
+ }
+ }
+
+ private fun parseYoloOutputs(
+ outputResults: OrtSession.Result,
+ pageWidth: Float,
+ pageHeight: Float
+ ): List {
+ val detected = mutableListOf()
+ if (outputResults.count() == 0) return detected
+
+ val outputTensor = outputResults.get(0).value as? Array> ?: return detected
+ // YOLO output tensor format [1, 84, 8400] or similar bounding box predictions
+ val scaleX = pageWidth / 1024f
+ val scaleY = pageHeight / 1024f
+
+ for (pred in outputTensor[0]) {
+ if (pred.size < 5) continue
+ val confidence = pred[4]
+ if (confidence < 0.45f) continue
+
+ val cx = pred[0]
+ val cy = pred[1]
+ val w = pred[2]
+ val h = pred[3]
+
+ val x0 = max(0f, (cx - w / 2f) * scaleX)
+ val y0 = max(0f, (cy - h / 2f) * scaleY)
+ val x1 = (cx + w / 2f) * scaleX
+ val y1 = (cy + h / 2f) * scaleY
+
+ val category = when {
+ pred.size > 5 && pred[5] > 0.5f -> "table"
+ pred.size > 6 && pred[6] > 0.5f -> "figure"
+ else -> "text"
+ }
+
+ detected.add(
+ DetectedLayoutRegion(
+ category = category,
+ confidence = confidence,
+ x0 = x0,
+ y0 = y0,
+ x1 = x1,
+ y1 = y1
+ )
+ )
+ }
+ return detected
+ }
+
+ override fun close() {
+ try {
+ session.close()
+ env.close()
+ } catch (_: Exception) {}
+ }
+
+ companion object {
+ fun createIfAvailable(context: Context): DocLayoutDetector? {
+ val file = DocLayoutModelDownloader.getModelFile(context)
+ if (!DocLayoutModelDownloader.isModelDownloaded(context)) return null
+ return try {
+ DocLayoutDetector(file)
+ } catch (_: Exception) {
+ null
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/DocLayoutModelDownloader.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/DocLayoutModelDownloader.kt
new file mode 100644
index 0000000..e877f2b
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/DocLayoutModelDownloader.kt
@@ -0,0 +1,81 @@
+package com.vitranslate.pdf.repository
+
+import android.content.Context
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+
+object DocLayoutModelDownloader {
+
+ private const val MODEL_FILENAME = "doclayout.onnx"
+ private val MODEL_URLS = listOf(
+ "https://huggingface.co/breezedeus/pix2text-layout/resolve/main/doclayout.onnx",
+ "https://huggingface.co/layoutlm/doclayout/resolve/main/doclayout.onnx",
+ "https://github.com/whooslizi/VI-Translate/releases/download/android-v0.3.1/doclayout.onnx"
+ )
+
+ fun getModelFile(context: Context): File {
+ return File(context.filesDir, MODEL_FILENAME)
+ }
+
+ fun isModelDownloaded(context: Context): Boolean {
+ val file = getModelFile(context)
+ return file.exists() && file.length() > 10000000L // > 10MB
+ }
+
+ suspend fun downloadModel(
+ context: Context,
+ modelUrl: String? = null,
+ onProgress: (percent: Int) -> Unit
+ ): File? {
+ val targetFile = getModelFile(context)
+ if (isModelDownloaded(context)) {
+ onProgress(100)
+ return targetFile
+ }
+
+ val urlsToTry = if (!modelUrl.isNullOrBlank()) listOf(modelUrl) + MODEL_URLS else MODEL_URLS
+ val client = OkHttpClient.Builder()
+ .followRedirects(true)
+ .followSslRedirects(true)
+ .build()
+
+ for (url in urlsToTry) {
+ val request = Request.Builder().url(url).build()
+ try {
+ val response = client.newCall(request).execute()
+ if (!response.isSuccessful) continue
+
+ val body = response.body ?: continue
+ val contentLength = body.contentLength()
+ val tempFile = File(context.filesDir, "$MODEL_FILENAME.tmp")
+
+ body.byteStream().use { inputStream ->
+ FileOutputStream(tempFile).use { outputStream ->
+ val buffer = ByteArray(8192)
+ var bytesRead: Int
+ var totalRead = 0L
+
+ while (inputStream.read(buffer).also { bytesRead = it } != -1) {
+ outputStream.write(buffer, 0, bytesRead)
+ totalRead += bytesRead
+ if (contentLength > 0) {
+ val percent = ((totalRead * 100) / contentLength).toInt()
+ onProgress(percent)
+ }
+ }
+ }
+ }
+
+ if (tempFile.renameTo(targetFile)) {
+ return targetFile
+ }
+ } catch (_: IOException) {
+ // Try next URL
+ }
+ }
+ return null
+ }
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/FormulaPlaceholder.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/FormulaPlaceholder.kt
index b37a172..d865889 100644
--- a/android/app/src/main/java/com/vitranslate/pdf/repository/FormulaPlaceholder.kt
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/FormulaPlaceholder.kt
@@ -29,32 +29,18 @@ object FormulaPlaceholder {
return sb.toString()
}
- private val EXPONENT_PATTERN = Pattern.compile("(?:[A-Za-z0-9_()\\[\\]{}]+(?:\\^\\{?[A-Za-z0-9_+\\-()]+\\}?|[⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻]+))")
-
/**
- * Convert converter-internal `{vN}` markers and exponent expressions into translator-safe tag pairs ``.
+ * Convert converter-internal `{vN}` markers into translator-safe tag pairs ``.
*/
fun encodeFormulaPlaceholders(text: String): String {
- var result = text
- val matcher = INTERNAL_PLACEHOLDER_PATTERN.matcher(result)
+ val matcher = INTERNAL_PLACEHOLDER_PATTERN.matcher(text)
val sb = StringBuffer()
while (matcher.find()) {
val num = matcher.group(1)?.replace(" ", "") ?: "0"
matcher.appendReplacement(sb, "")
}
matcher.appendTail(sb)
- result = sb.toString()
-
- val expMatcher = EXPONENT_PATTERN.matcher(result)
- val sbExp = StringBuffer()
- var expCounter = 9000
- while (expMatcher.find()) {
- val matched = expMatcher.group()
- expMatcher.appendReplacement(sbExp, "$matched")
- expCounter++
- }
- expMatcher.appendTail(sbExp)
- return sbExp.toString()
+ return sb.toString()
}
/**
@@ -105,23 +91,24 @@ object FormulaPlaceholder {
* Validate translator output and restore tags to converter markers `{vN}`.
*/
fun restoreFormulaPlaceholders(source: String, translated: String): String {
- val cleanSource = source.replace(Regex("?b9\\d{3}>"), "")
- var cleanTranslated = translated.replace(Regex("?b9\\d{3}>"), "")
-
- val encodedSource = encodeFormulaPlaceholders(cleanSource)
- val sourcePlaceholders = getPlaceholders(encodedSource).filterNot { it.matches(Regex("?b9\\d{3}>")) }
- val translatedPlaceholders = getPlaceholders(cleanTranslated).filterNot { it.matches(Regex("?b9\\d{3}>")) }
- if (sourcePlaceholders != translatedPlaceholders) {
- throw FormulaPlaceholderException("Formula placeholders were altered during translation")
+ val encodedSource = encodeFormulaPlaceholders(source)
+ if (getPlaceholders(encodedSource) != getPlaceholders(translated)) {
+ throw FormulaPlaceholderException("formula placeholders changed during translation")
}
+ validateStyleTags(encodedSource, translated)
- val matcher = PAIRED_PLACEHOLDER_PATTERN.matcher(cleanTranslated)
+ val matcher = PAIRED_PLACEHOLDER_PATTERN.matcher(translated)
val sb = StringBuffer()
while (matcher.find()) {
val num = matcher.group(1) ?: "0"
matcher.appendReplacement(sb, "{v$num}")
}
matcher.appendTail(sb)
- return sb.toString()
+ val restored = sb.toString()
+
+ if (PLACEHOLDER_PATTERN.matcher(restored).find()) {
+ throw FormulaPlaceholderException("formula placeholder pair is malformed")
+ }
+ return restored
}
}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/GoogleTranslateEngine.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/GoogleTranslateEngine.kt
index 01e4b23..b5ebdb6 100644
--- a/android/app/src/main/java/com/vitranslate/pdf/repository/GoogleTranslateEngine.kt
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/GoogleTranslateEngine.kt
@@ -12,7 +12,7 @@ import java.util.regex.Pattern
class GoogleTranslateEngine(
private val sourceLang: String = "auto",
private val targetLang: String = "vi"
-) {
+) : TranslationEngine {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
@@ -22,6 +22,11 @@ class GoogleTranslateEngine(
private val userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36"
private val resultPattern = Pattern.compile("(?s)class=\"(?:t0|result-container)\">(.*?)<")
+ @Throws(IOException::class, FormulaPlaceholderException::class)
+ override fun translate(rawText: String): String {
+ return translate(rawText, ignoreCache = false)
+ }
+
@Throws(IOException::class, FormulaPlaceholderException::class)
fun translate(rawText: String, ignoreCache: Boolean = false): String {
if (rawText.isBlank()) return rawText
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/OcrEngine.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/OcrEngine.kt
new file mode 100644
index 0000000..3ecb07c
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/OcrEngine.kt
@@ -0,0 +1,47 @@
+package com.vitranslate.pdf.repository
+
+import android.graphics.Bitmap
+import android.graphics.RectF
+import android.util.Log
+import com.google.mlkit.vision.common.InputImage
+import com.google.mlkit.vision.text.Text
+import com.google.mlkit.vision.text.TextRecognition
+import com.google.mlkit.vision.text.latin.TextRecognizerOptions
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+data class OcrTextLine(
+ val text: String,
+ val boundingBox: RectF? = null,
+ val confidence: Float = 1.0f
+)
+
+data class OcrResult(
+ val fullText: String,
+ val lines: List
+)
+
+object OcrEngine {
+ private const val TAG = "OcrEngine"
+
+ suspend fun extractTextFromBitmap(bitmap: Bitmap): Result = runCatching {
+ try {
+ val inputImage = InputImage.fromBitmap(bitmap, 0)
+ val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
+ val visionText: Text = suspendCancellableCoroutine { continuation ->
+ recognizer.process(inputImage)
+ .addOnSuccessListener { if (continuation.isActive) continuation.resume(it) }
+ .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
+ }
+
+ val lines = visionText.textBlocks.flatMap { block ->
+ block.lines.map { OcrTextLine(it.text, it.boundingBox?.let { rect -> RectF(rect) }) }
+ }
+ OcrResult(visionText.text, lines)
+ } catch (e: Throwable) {
+ Log.w(TAG, "OCR recognition failed: ${e.message}", e)
+ OcrResult("", emptyList())
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/OcrTextExtractor.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/OcrTextExtractor.kt
new file mode 100644
index 0000000..66baead
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/OcrTextExtractor.kt
@@ -0,0 +1,90 @@
+package com.vitranslate.pdf.repository
+
+import android.graphics.Bitmap
+import com.google.android.gms.tasks.Tasks
+import com.google.mlkit.vision.common.InputImage
+import com.google.mlkit.vision.text.Text
+import com.google.mlkit.vision.text.TextRecognition
+import com.google.mlkit.vision.text.latin.TextRecognizerOptions
+import com.tom_roush.pdfbox.pdmodel.PDDocument
+import com.tom_roush.pdfbox.pdmodel.PDPage
+import com.tom_roush.pdfbox.rendering.PDFRenderer
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlin.math.max
+
+object OcrTextExtractor {
+
+ private val recognizer by lazy {
+ TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
+ }
+
+ /**
+ * Renders a scanned PDF page into a bitmap and runs Google ML Kit Text Recognition offline.
+ * Converts recognized OCR bounding boxes into TextBlock runs for PdfLayoutPreserver.
+ */
+ suspend fun extractOcrTextBlocks(
+ document: PDDocument,
+ page: PDPage,
+ pageIndex: Int,
+ renderDpi: Float = 200f
+ ): MutableList = withContext(Dispatchers.IO) {
+ val renderer = PDFRenderer(document)
+ val scale = renderDpi / 72f
+ val bitmap: Bitmap = try {
+ renderer.renderImage(pageIndex, scale)
+ } catch (_: Exception) {
+ return@withContext mutableListOf()
+ }
+
+ val inputImage = InputImage.fromBitmap(bitmap, 0)
+ val result: Text = try {
+ Tasks.await(recognizer.process(inputImage))
+ } catch (_: Exception) {
+ return@withContext mutableListOf()
+ }
+
+ val textBlocks = mutableListOf()
+ val pageWidth = page.cropBox.width
+ val pageHeight = page.cropBox.height
+ val bitmapWidth = bitmap.width.toFloat()
+ val bitmapHeight = bitmap.height.toFloat()
+
+ if (bitmapWidth <= 0 || bitmapHeight <= 0) return@withContext textBlocks
+
+ val scaleX = pageWidth / bitmapWidth
+ val scaleY = pageHeight / bitmapHeight
+
+ for (block in result.textBlocks) {
+ for (line in block.lines) {
+ val lineBox = line.boundingBox ?: continue
+ val text = line.text.trim()
+ if (text.isBlank()) continue
+
+ // Convert bitmap pixel coordinates to PDF point coordinates (PDF Y coordinates count up from bottom)
+ val pdfX = lineBox.left * scaleX
+ val pdfY = pageHeight - (lineBox.bottom * scaleY)
+ val pdfWidth = max(1f, lineBox.width() * scaleX)
+ val pdfHeight = max(6f, lineBox.height() * scaleY)
+
+ val fontSize = max(8f, pdfHeight * 0.75f)
+ val ascent = pdfHeight * 0.75f
+ val descent = pdfHeight * 0.25f
+
+ textBlocks.add(
+ PdfLayoutPreserver.TextBlock(
+ text = text,
+ x = pdfX,
+ y = pdfY,
+ fontSize = fontSize,
+ width = pdfWidth,
+ ascent = ascent,
+ descent = descent
+ )
+ )
+ }
+ }
+
+ textBlocks
+ }
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/OpenAiTranslateEngine.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/OpenAiTranslateEngine.kt
new file mode 100644
index 0000000..4f10037
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/OpenAiTranslateEngine.kt
@@ -0,0 +1,80 @@
+package com.vitranslate.pdf.repository
+
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONArray
+import org.json.JSONObject
+import java.util.concurrent.TimeUnit
+
+class OpenAiTranslateEngine(
+ private val apiKey: String,
+ private val baseUrl: String = "https://api.openai.com/v1",
+ private val modelName: String = "gpt-4o-mini",
+ private val targetLang: String = "vi"
+) : TranslateEngine {
+
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .build()
+
+ override fun translate(text: String): String {
+ val trimmed = text.trim()
+ if (trimmed.isEmpty()) return text
+
+ val endpoint = "${baseUrl.trimEnd('/')}/chat/completions"
+
+ val systemPrompt = "You are a professional document translator. Translate the text into $targetLang.\n" +
+ "CRITICAL INSTRUCTIONS:\n" +
+ "1. Preserve ALL structural tags like , , , in their exact positions.\n" +
+ "2. Do NOT translate or alter tag IDs, formula placeholders, or math expressions.\n" +
+ "3. Output ONLY the raw translated text without markdown formatting or extra text."
+
+ val messages = JSONArray().apply {
+ put(JSONObject().apply {
+ put("role", "system")
+ put("content", systemPrompt)
+ })
+ put(JSONObject().apply {
+ put("role", "user")
+ put("content", trimmed)
+ })
+ }
+
+ val payload = JSONObject().apply {
+ put("model", modelName)
+ put("messages", messages)
+ put("temperature", 0.1)
+ }
+
+ val requestBody = payload.toString().toRequestBody("application/json; charset=utf-8".toMediaType())
+ val requestBuilder = Request.Builder()
+ .url(endpoint)
+ .post(requestBody)
+
+ if (apiKey.isNotBlank()) {
+ requestBuilder.header("Authorization", "Bearer $apiKey")
+ }
+
+ val response = client.newCall(requestBuilder.build()).execute()
+ if (!response.isSuccessful) {
+ val errorBody = response.body?.string() ?: ""
+ throw RuntimeException("LLM API returned HTTP ${response.code}: $errorBody")
+ }
+
+ val responseBody = response.body?.string() ?: throw RuntimeException("Empty response from LLM API")
+ val jsonResponse = JSONObject(responseBody)
+ val choices = jsonResponse.optJSONArray("choices")
+ ?: throw RuntimeException("Invalid response format: missing choices")
+
+ if (choices.length() == 0) throw RuntimeException("Empty choices array from LLM API")
+
+ val messageObj = choices.getJSONObject(0).optJSONObject("message")
+ ?: throw RuntimeException("Invalid response format: missing message")
+
+ val content = messageObj.optString("content", "").trim()
+ return FormulaPlaceholder.removeControlCharacters(content)
+ }
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/PageSelectionParser.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/PageSelectionParser.kt
new file mode 100644
index 0000000..f40aba7
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/PageSelectionParser.kt
@@ -0,0 +1,143 @@
+package com.vitranslate.pdf.repository
+
+data class TranslationPlan(
+ val selectedPages: List,
+ val skippedPages: List,
+ val totalPages: Int,
+ val validationError: String? = null
+)
+
+object PageSelectionParser {
+
+ /**
+ * Parses omit string (e.g. "2,3", "2-3", "2,5,7-9") into a set of 1-based page numbers.
+ */
+ fun parseOmitList(omitInput: String, totalPages: Int): Set {
+ if (omitInput.isBlank()) return emptySet()
+ val resultSet = mutableSetOf()
+ val parts = omitInput.trim().split(",")
+
+ for (part in parts) {
+ val token = part.trim()
+ if (token.isBlank()) continue
+
+ if (token.contains("-")) {
+ val bounds = token.split("-")
+ if (bounds.size == 2) {
+ val start = bounds[0].trim().toIntOrNull()
+ val end = bounds[1].trim().toIntOrNull()
+ if (start != null && end != null && start > 0 && end > 0) {
+ val from = minOf(start, end).coerceIn(1, totalPages)
+ val to = maxOf(start, end).coerceIn(1, totalPages)
+ for (p in from..to) {
+ resultSet.add(p)
+ }
+ }
+ }
+ } else {
+ val pageNum = token.toIntOrNull()
+ if (pageNum != null && pageNum in 1..totalPages) {
+ resultSet.add(pageNum)
+ }
+ }
+ }
+ return resultSet
+ }
+
+ /**
+ * Excel-style page selection:
+ * Translate from [fromPage] to [toPage], omitting pages in [omitInput] (e.g. "2,3").
+ */
+ fun parseExcelPageSelection(
+ fromPage: Int,
+ toPage: Int,
+ omitInput: String,
+ totalPages: Int
+ ): TranslationPlan {
+ if (totalPages <= 0) {
+ return TranslationPlan(emptyList(), emptyList(), 0, "PDF không có trang nào")
+ }
+
+ val validFrom = fromPage.coerceIn(1, totalPages)
+ val validTo = toPage.coerceIn(1, totalPages)
+
+ if (validFrom > validTo) {
+ return TranslationPlan(
+ selectedPages = (1..totalPages).toList(),
+ skippedPages = emptyList(),
+ totalPages = totalPages,
+ validationError = "Trang bắt đầu ($validFrom) lớn hơn trang kết thúc ($validTo)"
+ )
+ }
+
+ val rangeSet = (validFrom..validTo).toSet()
+ val omitSet = parseOmitList(omitInput, totalPages)
+ val selectedSet = rangeSet - omitSet
+ val selectedPages = selectedSet.sorted()
+
+ val allPages = (1..totalPages).toSet()
+ val skippedPages = (allPages - selectedSet).sorted()
+
+ return TranslationPlan(
+ selectedPages = selectedPages,
+ skippedPages = skippedPages,
+ totalPages = totalPages
+ )
+ }
+
+ /**
+ * Legacy & flexible string parser supporting:
+ * - "all"
+ * - "1-5"
+ * - "1,3,5"
+ * - "Từ 1 đến 5 (bỏ 2,3)"
+ */
+ fun parsePageSelection(rawInput: String, totalPages: Int): List {
+ if (totalPages <= 0) return emptyList()
+ val trimmed = rawInput.trim()
+ if (trimmed.isBlank() || trimmed.equals("all", ignoreCase = true)) {
+ return (1..totalPages).toList()
+ }
+
+ // Check if string matches "Từ X đến Y (bỏ Z)"
+ val tuDenRegex = Regex("(?i)T[ừu]\\s*(\\d+)\\s*đ[ếe]n\\s*(\\d+)(?:\\s*\\(b[ỏo]\\s*(.*?)\\)?)?")
+ val match = tuDenRegex.find(trimmed)
+ if (match != null) {
+ val from = match.groupValues[1].toIntOrNull() ?: 1
+ val to = match.groupValues[2].toIntOrNull() ?: totalPages
+ val omit = match.groupValues.getOrNull(3) ?: ""
+ return parseExcelPageSelection(from, to, omit, totalPages).selectedPages
+ }
+
+ val resultSet = mutableSetOf()
+ val parts = trimmed.split(",")
+
+ for (part in parts) {
+ val token = part.trim()
+ if (token.isBlank()) continue
+
+ if (token.contains("-")) {
+ val bounds = token.split("-")
+ if (bounds.size == 2) {
+ val start = bounds[0].trim().toIntOrNull()
+ val end = bounds[1].trim().toIntOrNull()
+ if (start != null && end != null && start > 0 && end > 0) {
+ val from = minOf(start, end).coerceIn(1, totalPages)
+ val to = maxOf(start, end).coerceIn(1, totalPages)
+ for (p in from..to) {
+ resultSet.add(p)
+ }
+ }
+ }
+ } else {
+ val pageNum = token.toIntOrNull()
+ if (pageNum != null && pageNum in 1..totalPages) {
+ resultSet.add(pageNum)
+ }
+ }
+ }
+
+ val sorted = resultSet.sorted()
+ return if (sorted.isEmpty()) (1..totalPages).toList() else sorted
+ }
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/PdfLayoutPreserver.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/PdfLayoutPreserver.kt
index 6f9bf14..10e55b6 100644
--- a/android/app/src/main/java/com/vitranslate/pdf/repository/PdfLayoutPreserver.kt
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/PdfLayoutPreserver.kt
@@ -58,11 +58,61 @@ class PdfLayoutPreserver(private val context: Context) {
}
}
+ private fun extractBlocksViaOcr(inputUri: Uri, pageIndex: Int, cropBox: PDRectangle): List {
+ return try {
+ val pfd = context.contentResolver.openFileDescriptor(inputUri, "r") ?: return emptyList()
+ pfd.use { descriptor ->
+ val pdfRenderer = android.graphics.pdf.PdfRenderer(descriptor)
+ pdfRenderer.use { renderer ->
+ if (pageIndex >= renderer.pageCount) return emptyList()
+ val rendererPage = renderer.openPage(pageIndex)
+ val width = (rendererPage.width * 2).coerceAtLeast(512)
+ val height = (rendererPage.height * 2).coerceAtLeast(512)
+ val bitmap = android.graphics.Bitmap.createBitmap(width, height, android.graphics.Bitmap.Config.ARGB_8888)
+ rendererPage.render(bitmap, null, null, android.graphics.pdf.PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
+ rendererPage.close()
+
+ val ocrResult = kotlinx.coroutines.runBlocking {
+ OcrEngine.extractTextFromBitmap(bitmap).getOrNull()
+ } ?: return emptyList()
+
+ val pageWidth = cropBox.upperRightX - cropBox.lowerLeftX
+ val pageHeight = cropBox.upperRightY - cropBox.lowerLeftY
+ val scaleX = pageWidth / width.toFloat()
+ val scaleY = pageHeight / height.toFloat()
+
+ ocrResult.lines.mapNotNull { ocrLine ->
+ val rect = ocrLine.boundingBox ?: return@mapNotNull null
+ val pdfX = cropBox.lowerLeftX + (rect.left * scaleX)
+ val pdfY = cropBox.upperRightY - (rect.bottom * scaleY)
+ val boxWidth = (rect.right - rect.left) * scaleX
+ val boxHeight = (rect.bottom - rect.top) * scaleY
+ val fontSize = (boxHeight * 0.8f).coerceAtLeast(8f)
+
+ TextBlock(
+ text = ocrLine.text,
+ x = pdfX,
+ y = pdfY,
+ fontSize = fontSize,
+ width = boxWidth,
+ ascent = fontSize * 0.8f,
+ descent = fontSize * 0.2f
+ )
+ }
+ }
+ }
+ } catch (_: Exception) {
+ emptyList()
+ }
+ }
+
fun translatePdf(
inputUri: Uri,
outputDirUriOrPath: String?,
targetLang: String,
overwrite: Boolean,
+ customEngine: TranslationEngine? = null,
+ useOcr: Boolean = true,
onProgress: (done: Int, total: Int) -> Unit,
onLog: ((String) -> Unit)? = null,
isCancelled: () -> Boolean = { false }
@@ -77,7 +127,7 @@ class PdfLayoutPreserver(private val context: Context) {
}
val outputFileName = "$baseName-$targetLang.pdf"
val (outputStream, resultPath) = prepareOutputStream(outputDirUriOrPath, outputFileName, overwrite)
- val engine = GoogleTranslateEngine(sourceLang = "auto", targetLang = targetLang)
+ val engine: TranslationEngine = customEngine ?: GoogleTranslateEngine(sourceLang = "auto", targetLang = targetLang)
var untranslatedCount = 0
try {
@@ -95,7 +145,18 @@ class PdfLayoutPreserver(private val context: Context) {
val textCollector = PageTextCollector()
textCollector.extractPageText(document, page, pageIndex)
val collapsedBlocks = collapseVerticalFractions(textCollector.blocks)
- val textBlocks = groupIntoLineRuns(collapsedBlocks)
+ var textBlocks = groupIntoLineRuns(collapsedBlocks)
+
+ if (textBlocks.isEmpty() && useOcr) {
+ onLog?.invoke("Trang ${pageIndex + 1}: Không tìm thấy văn bản PDF. Đang chạy OCR (ML Kit)...")
+ val ocrBlocks = extractBlocksViaOcr(inputUri, pageIndex, page.cropBox)
+ if (ocrBlocks.isNotEmpty()) {
+ textBlocks = ocrBlocks
+ onLog?.invoke("Trang ${pageIndex + 1}: OCR thành công với ${ocrBlocks.size} dòng văn bản.")
+ } else {
+ onLog?.invoke("Trang ${pageIndex + 1}: Không quét được văn bản bằng OCR.")
+ }
+ }
if (textBlocks.isNotEmpty()) {
val pageRight = textCollector.cropBox.upperRightX
@@ -116,7 +177,7 @@ class PdfLayoutPreserver(private val context: Context) {
// Skip translating standalone math formulas and numeric choices, but preserve them in translations list
if (textToTranslate.isBlank() || isPureMathOrFormula(textToTranslate)) {
skippedMathCount++
- translations.add(ParagraphTranslation(paragraph, originalText))
+ translations.add(ParagraphTranslation(paragraph, originalText, isMath = true))
continue
}
@@ -146,14 +207,15 @@ class PdfLayoutPreserver(private val context: Context) {
} else {
translatedRemainder
}
- translations.add(ParagraphTranslation(paragraph, translatedText))
+ translations.add(ParagraphTranslation(paragraph, translatedText, isMath = false))
}
onLog?.invoke("Trang ${pageIndex + 1}/$totalPages: ${textBlocks.size} dòng gộp thành ${paragraphs.size} đoạn. Đã dịch: ${translations.size}, Bỏ qua công thức: $skippedMathCount")
if (translations.isNotEmpty()) {
- // Strip original text from page streams so vector drawings & diagrams remain 100% pristine
- val sourceTextRemoved = stripTextFromPage(document, page)
+ val hasMathInPage = translations.any { it.isMath }
+ // If page contains math formulas, DO NOT strip text from page stream so original math font glyphs are preserved 100%!
+ val sourceTextRemoved = if (!hasMathInPage) stripTextFromPage(document, page) else false
PDPageContentStream(
document,
@@ -164,6 +226,9 @@ class PdfLayoutPreserver(private val context: Context) {
).use { stream ->
for (i in translations.indices) {
val translation = translations[i]
+ // Math formulas are preserved in original PDF stream, skip redrawing them!
+ if (translation.isMath) continue
+
val paragraph = translation.paragraph
val cleanedText = stripTagsAndPlaceholders(translation.translated)
val text = sanitizeForFont(cleanedText, font)
@@ -1380,7 +1445,8 @@ class PdfLayoutPreserver(private val context: Context) {
private class ParagraphTranslation(
val paragraph: Paragraph,
- val translated: String
+ val translated: String,
+ val isMath: Boolean = false
)
/**
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/TranslateEngine.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/TranslateEngine.kt
new file mode 100644
index 0000000..54d98f6
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/TranslateEngine.kt
@@ -0,0 +1,5 @@
+package com.vitranslate.pdf.repository
+
+fun interface TranslateEngine {
+ fun translate(text: String): String
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/TranslationController.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/TranslationController.kt
index 1209041..184e61f 100644
--- a/android/app/src/main/java/com/vitranslate/pdf/repository/TranslationController.kt
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/TranslationController.kt
@@ -52,6 +52,16 @@ object TranslationController {
MutableStateFlow(TargetLanguage.getByCode(TargetLanguage.DEFAULT_CODE))
val selectedLanguage: StateFlow = _selectedLanguage.asStateFlow()
+ private val _engineType = MutableStateFlow(com.vitranslate.pdf.ui.components.SelectedEngineType.GOOGLE_DEFAULT)
+ val engineType: StateFlow = _engineType.asStateFlow()
+
+ private var aiApiKey: String = ""
+ private var aiModelName: String = "gpt-4o-mini"
+ private var aiEndpoint: String = "https://api.openai.com/v1/chat/completions"
+
+ private val _useOcr = MutableStateFlow(true)
+ val useOcr: StateFlow = _useOcr.asStateFlow()
+
private val _overwrite = MutableStateFlow(false)
val overwrite: StateFlow = _overwrite.asStateFlow()
@@ -89,6 +99,7 @@ object TranslationController {
val prefs = application.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
_overwrite.value = prefs.getBoolean(KEY_OVERWRITE, false)
_customOutputDirectory.value = prefs.getString(KEY_OUTPUT_DIR, null)
+ _useOcr.value = prefs.getBoolean("use_ocr", true)
scope.launch {
val info = UpdateChecker().checkForUpdate()
if (info != null && info.isNewerAvailable) {
@@ -103,7 +114,24 @@ object TranslationController {
private fun prefs() =
requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
- // ---------------------------------------------------------------- settings
+ fun setEngineConfig(
+ type: com.vitranslate.pdf.ui.components.SelectedEngineType,
+ apiKey: String,
+ modelName: String,
+ endpoint: String
+ ) {
+ _engineType.value = type
+ aiApiKey = apiKey
+ aiModelName = modelName
+ aiEndpoint = endpoint
+ resetSkippedItems()
+ }
+
+ fun setUseOcr(value: Boolean) {
+ _useOcr.value = value
+ prefs().edit().putBoolean("use_ocr", value).apply()
+ resetSkippedItems()
+ }
fun setSelectedLanguage(language: TargetLanguage) {
_selectedLanguage.value = language
@@ -216,6 +244,58 @@ object TranslationController {
var completedFiles = 0
var cancelled = false
+ val customEngine: TranslationEngine? = when (_engineType.value) {
+ com.vitranslate.pdf.ui.components.SelectedEngineType.OPENAI -> AiTranslateEngine(
+ provider = AiProvider.OPENAI,
+ apiKey = aiApiKey,
+ modelName = aiModelName.ifBlank { "gpt-4o-mini" },
+ customEndpoint = aiEndpoint,
+ targetLang = _selectedLanguage.value.code
+ )
+ com.vitranslate.pdf.ui.components.SelectedEngineType.DEEPSEEK -> AiTranslateEngine(
+ provider = AiProvider.DEEPSEEK,
+ apiKey = aiApiKey,
+ modelName = aiModelName.ifBlank { "deepseek-chat" },
+ customEndpoint = aiEndpoint,
+ targetLang = _selectedLanguage.value.code
+ )
+ com.vitranslate.pdf.ui.components.SelectedEngineType.GEMINI -> AiTranslateEngine(
+ provider = AiProvider.GEMINI,
+ apiKey = aiApiKey,
+ modelName = aiModelName.ifBlank { "gemini-2.0-flash" },
+ targetLang = _selectedLanguage.value.code
+ )
+ com.vitranslate.pdf.ui.components.SelectedEngineType.OPENROUTER -> AiTranslateEngine(
+ provider = AiProvider.OPENROUTER,
+ apiKey = aiApiKey,
+ modelName = aiModelName.ifBlank { "deepseek/deepseek-chat" },
+ customEndpoint = aiEndpoint,
+ targetLang = _selectedLanguage.value.code
+ )
+ com.vitranslate.pdf.ui.components.SelectedEngineType.GROQ -> AiTranslateEngine(
+ provider = AiProvider.GROQ,
+ apiKey = aiApiKey,
+ modelName = aiModelName.ifBlank { "llama-3.3-70b-versatile" },
+ customEndpoint = aiEndpoint,
+ targetLang = _selectedLanguage.value.code
+ )
+ com.vitranslate.pdf.ui.components.SelectedEngineType.SILICONFLOW -> AiTranslateEngine(
+ provider = AiProvider.SILICONFLOW,
+ apiKey = aiApiKey,
+ modelName = aiModelName.ifBlank { "deepseek-ai/DeepSeek-V3" },
+ customEndpoint = aiEndpoint,
+ targetLang = _selectedLanguage.value.code
+ )
+ com.vitranslate.pdf.ui.components.SelectedEngineType.CUSTOM_OPENAI -> AiTranslateEngine(
+ provider = AiProvider.CUSTOM_OPENAI,
+ apiKey = aiApiKey,
+ modelName = aiModelName.ifBlank { "custom-model" },
+ customEndpoint = aiEndpoint,
+ targetLang = _selectedLanguage.value.code
+ )
+ com.vitranslate.pdf.ui.components.SelectedEngineType.GOOGLE_DEFAULT -> null
+ }
+
try {
for (item in pending) {
if (cancelRequested) {
@@ -231,6 +311,8 @@ object TranslationController {
outputDirUriOrPath = targetOutputDir,
targetLang = _selectedLanguage.value.code,
overwrite = _overwrite.value,
+ customEngine = customEngine,
+ useOcr = _useOcr.value,
onProgress = { donePages, totalPages ->
_isIndeterminate.value = false
val fileFraction =
diff --git a/android/app/src/main/java/com/vitranslate/pdf/repository/TranslationEngine.kt b/android/app/src/main/java/com/vitranslate/pdf/repository/TranslationEngine.kt
new file mode 100644
index 0000000..58ccc2c
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/repository/TranslationEngine.kt
@@ -0,0 +1,8 @@
+package com.vitranslate.pdf.repository
+
+import java.io.IOException
+
+interface TranslationEngine {
+ @Throws(IOException::class, FormulaPlaceholderException::class)
+ fun translate(text: String): String
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/ui/components/AdvancedEngineSetupDialog.kt b/android/app/src/main/java/com/vitranslate/pdf/ui/components/AdvancedEngineSetupDialog.kt
new file mode 100644
index 0000000..4e1ff65
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/ui/components/AdvancedEngineSetupDialog.kt
@@ -0,0 +1,101 @@
+package com.vitranslate.pdf.ui.components
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+@Composable
+fun AdvancedEngineSetupDialog(
+ initialUrl: String,
+ onDismissRequest: () -> Unit,
+ onSave: (serverUrl: String) -> Unit,
+ onTestConnection: suspend (url: String) -> Boolean
+) {
+ var urlText by remember { mutableStateOf(initialUrl) }
+ var connectionStatus by remember { mutableStateOf(null) }
+ var isTesting by remember { mutableStateOf(false) }
+ val coroutineScope = rememberCoroutineScope()
+
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ title = {
+ Text(
+ text = "Cấu hình Advanced Engine",
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ text = {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Bản Standard Engine (PDFBox) chạy trực tiếp offline trên máy. Bật Advanced Engine để kết nối với máy chủ máy tính (C binary / ONNX) cho việc xử lý công thức nâng cao và OCR.",
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ OutlinedTextField(
+ value = urlText,
+ onValueChange = {
+ urlText = it
+ connectionStatus = null
+ },
+ label = { Text("URL máy chủ (VD: http://192.168.1.100:8000)") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ connectionStatus?.let { status ->
+ Text(
+ text = status,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (status.contains("thành công")) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ },
+ confirmButton = {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ TextButton(
+ onClick = {
+ isTesting = true
+ coroutineScope.launch(Dispatchers.IO) {
+ val success = onTestConnection(urlText)
+ withContext(Dispatchers.Main) {
+ connectionStatus = if (success) "Kết nối thành công!" else "Lỗi: Không thể kết nối tới máy chủ"
+ isTesting = false
+ }
+ }
+ },
+ enabled = !isTesting && urlText.isNotBlank()
+ ) {
+ Text("Thử kết nối")
+ }
+
+ Button(
+ onClick = {
+ onSave(urlText)
+ onDismissRequest()
+ },
+ enabled = urlText.isNotBlank()
+ ) {
+ Text("Lưu")
+ }
+ }
+ },
+ dismissButton = {
+ OutlinedButton(onClick = onDismissRequest) {
+ Text("Hủy")
+ }
+ }
+ )
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/ui/components/AiEngineSetupDialog.kt b/android/app/src/main/java/com/vitranslate/pdf/ui/components/AiEngineSetupDialog.kt
new file mode 100644
index 0000000..e74ddf6
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/ui/components/AiEngineSetupDialog.kt
@@ -0,0 +1,341 @@
+package com.vitranslate.pdf.ui.components
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import com.vitranslate.pdf.repository.AiProvider
+import kotlinx.coroutines.launch
+
+enum class SelectedEngineType {
+ GOOGLE_DEFAULT,
+ OPENAI,
+ DEEPSEEK,
+ GEMINI,
+ OPENROUTER,
+ GROQ,
+ SILICONFLOW,
+ CUSTOM_OPENAI
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AiEngineSetupDialog(
+ initialEngineType: SelectedEngineType = SelectedEngineType.GOOGLE_DEFAULT,
+ initialApiKey: String = "",
+ initialModelName: String = "gpt-4o-mini",
+ initialEndpoint: String = "https://api.openai.com/v1/chat/completions",
+ onDismissRequest: () -> Unit,
+ onApplySession: (
+ engineType: SelectedEngineType,
+ apiKey: String,
+ modelName: String,
+ endpoint: String
+ ) -> Unit
+) {
+ var selectedType by remember { mutableStateOf(initialEngineType) }
+ var apiKeyText by remember { mutableStateOf(initialApiKey) }
+ var modelNameText by remember { mutableStateOf(initialModelName) }
+ var endpointText by remember { mutableStateOf(initialEndpoint) }
+ var isPasswordVisible by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ title = {
+ Text(
+ text = "Cấu hình AI Dịch (LLMs)",
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ text = {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.small,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = "Cam kết bảo mật & Quyền riêng tư:\n" +
+ "• 100% Cục bộ & Server-less: Ứng dụng này không có máy chủ trung gian. Mọi quá trình xử lý PDF và giao tiếp API diễn ra trực tiếp trên điện thoại của bạn.\n" +
+ "• API Key được bảo vệ: Token API chỉ gửi trực tiếp từ thiết bị của bạn đến nhà cung cấp AI (OpenAI, Gemini, DeepSeek...) qua HTTPS mã hóa, và chỉ giữ tạm trong bộ nhớ RAM.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(10.dp)
+ )
+ }
+
+ Text(
+ text = "Chọn nhà cung cấp AI:",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ RadioButton(
+ selected = selectedType == SelectedEngineType.GOOGLE_DEFAULT,
+ onClick = { selectedType = SelectedEngineType.GOOGLE_DEFAULT }
+ )
+ Text("Google Translate (Miễn phí)")
+ }
+
+ Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ RadioButton(
+ selected = selectedType == SelectedEngineType.DEEPSEEK,
+ onClick = {
+ selectedType = SelectedEngineType.DEEPSEEK
+ modelNameText = "deepseek-chat"
+ endpointText = "https://api.deepseek.com/v1/chat/completions"
+ }
+ )
+ Text("DeepSeek (DeepSeek V3 / R1)")
+ }
+
+ Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ RadioButton(
+ selected = selectedType == SelectedEngineType.GEMINI,
+ onClick = {
+ selectedType = SelectedEngineType.GEMINI
+ modelNameText = "gemini-2.0-flash"
+ }
+ )
+ Text("Google Gemini (Gemini 2.0 / 1.5 Flash)")
+ }
+
+ Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ RadioButton(
+ selected = selectedType == SelectedEngineType.OPENAI,
+ onClick = {
+ selectedType = SelectedEngineType.OPENAI
+ modelNameText = "gpt-4o-mini"
+ endpointText = "https://api.openai.com/v1/chat/completions"
+ }
+ )
+ Text("OpenAI (GPT-4o, GPT-4o-mini)")
+ }
+
+ Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ RadioButton(
+ selected = selectedType == SelectedEngineType.OPENROUTER,
+ onClick = {
+ selectedType = SelectedEngineType.OPENROUTER
+ modelNameText = "deepseek/deepseek-chat"
+ endpointText = "https://openrouter.ai/api/v1/chat/completions"
+ }
+ )
+ Text("OpenRouter (Claude, Llama, DeepSeek)")
+ }
+
+ Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ RadioButton(
+ selected = selectedType == SelectedEngineType.GROQ,
+ onClick = {
+ selectedType = SelectedEngineType.GROQ
+ modelNameText = "llama-3.3-70b-versatile"
+ endpointText = "https://api.groq.com/openai/v1/chat/completions"
+ }
+ )
+ Text("Groq (Llama 3.3 70B, DeepSeek R1)")
+ }
+
+ Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ RadioButton(
+ selected = selectedType == SelectedEngineType.SILICONFLOW,
+ onClick = {
+ selectedType = SelectedEngineType.SILICONFLOW
+ modelNameText = "deepseek-ai/DeepSeek-V3"
+ endpointText = "https://api.siliconflow.cn/v1/chat/completions"
+ }
+ )
+ Text("SiliconFlow (SiliconCloud)")
+ }
+
+ Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ RadioButton(
+ selected = selectedType == SelectedEngineType.CUSTOM_OPENAI,
+ onClick = {
+ selectedType = SelectedEngineType.CUSTOM_OPENAI
+ if (endpointText.isBlank() || endpointText.contains("openai.com")) {
+ endpointText = "http://10.0.2.2:11434/v1/chat/completions"
+ }
+ }
+ )
+ Text("Custom LLM / Local (Ollama, LM Studio)")
+ }
+
+ if (selectedType != SelectedEngineType.GOOGLE_DEFAULT) {
+ OutlinedTextField(
+ value = apiKeyText,
+ onValueChange = { apiKeyText = it },
+ label = { Text("API Key / Token (Tạm thời)") },
+ visualTransformation = if (isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
+ trailingIcon = {
+ TextButton(onClick = { isPasswordVisible = !isPasswordVisible }) {
+ Text(if (isPasswordVisible) "Ẩn" else "Hiện")
+ }
+ },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ OutlinedTextField(
+ value = modelNameText,
+ onValueChange = { modelNameText = it },
+ label = { Text("Tên Model") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ if (selectedType == SelectedEngineType.CUSTOM_OPENAI || selectedType == SelectedEngineType.OPENAI) {
+ OutlinedTextField(
+ value = endpointText,
+ onValueChange = { endpointText = it },
+ label = { Text("Endpoint API URL") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ val coroutineScope = rememberCoroutineScope()
+ var isTesting by remember { mutableStateOf(false) }
+ var testResult by remember { mutableStateOf?>(null) }
+ var sessionTokenRefreshTrigger by remember { mutableStateOf(0) }
+
+ OutlinedButton(
+ onClick = {
+ isTesting = true
+ testResult = null
+ coroutineScope.launch {
+ val provider = when (selectedType) {
+ SelectedEngineType.DEEPSEEK -> com.vitranslate.pdf.repository.AiProvider.DEEPSEEK
+ SelectedEngineType.GEMINI -> com.vitranslate.pdf.repository.AiProvider.GEMINI
+ SelectedEngineType.OPENROUTER -> com.vitranslate.pdf.repository.AiProvider.OPENROUTER
+ SelectedEngineType.GROQ -> com.vitranslate.pdf.repository.AiProvider.GROQ
+ SelectedEngineType.SILICONFLOW -> com.vitranslate.pdf.repository.AiProvider.SILICONFLOW
+ SelectedEngineType.CUSTOM_OPENAI -> com.vitranslate.pdf.repository.AiProvider.CUSTOM_OPENAI
+ else -> com.vitranslate.pdf.repository.AiProvider.OPENAI
+ }
+ val result = com.vitranslate.pdf.repository.AiTranslateEngine.testConnection(
+ provider = provider,
+ apiKey = apiKeyText,
+ modelName = modelNameText,
+ customEndpoint = endpointText
+ )
+ testResult = result
+ isTesting = false
+ sessionTokenRefreshTrigger++
+ }
+ },
+ enabled = !isTesting,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (isTesting) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp
+ )
+ Spacer(Modifier.width(8.dp))
+ Text("Đang kiểm tra kết nối...")
+ } else {
+ Text("Kiểm tra kết nối API")
+ }
+ }
+
+ testResult?.let { res ->
+ val isSuccess = res.isSuccess
+ val health = res.getOrNull()
+ Surface(
+ color = if (isSuccess) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.errorContainer,
+ shape = MaterialTheme.shapes.small,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = if (isSuccess && health != null) {
+ "Kết nối thành công! Phản hồi: ${health.latencyMs} ms | Token test: ~${health.totalTokens} (Input: ${health.promptTokens}, Output: ${health.completionTokens})"
+ } else {
+ "Lỗi kết nối: ${res.exceptionOrNull()?.message ?: "Lỗi không xác định"}"
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = if (isSuccess) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.padding(8.dp)
+ )
+ }
+ }
+
+ // Session Token Counter Card
+ val totalTokens = com.vitranslate.pdf.repository.AiTranslateEngine.sessionTotalTokens
+ val promptTokens = com.vitranslate.pdf.repository.AiTranslateEngine.sessionPromptTokens
+ val compTokens = com.vitranslate.pdf.repository.AiTranslateEngine.sessionCompletionTokens
+
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ shape = MaterialTheme.shapes.small,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ ) {
+ Column(modifier = Modifier.padding(10.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Bộ đếm Token phiên này",
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Bold
+ )
+ if (totalTokens > 0) {
+ TextButton(
+ onClick = {
+ com.vitranslate.pdf.repository.AiTranslateEngine.resetSessionTokens()
+ sessionTokenRefreshTrigger++
+ },
+ contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp)
+ ) {
+ Text("Đặt lại", style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ }
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = "Tổng token đã dùng: %,d tokens".format(totalTokens),
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = "Dữ liệu vào (Input): %,d | Dữ liệu ra (Output): %,d".format(promptTokens, compTokens),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ onApplySession(selectedType, apiKeyText, modelNameText, endpointText)
+ onDismissRequest()
+ }
+ ) {
+ Text("Áp dụng cho phiên này")
+ }
+ },
+ dismissButton = {
+ OutlinedButton(onClick = onDismissRequest) {
+ Text("Hủy")
+ }
+ }
+ )
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/ui/components/ControlsView.kt b/android/app/src/main/java/com/vitranslate/pdf/ui/components/ControlsView.kt
index d5a6ec8..d84aa97 100644
--- a/android/app/src/main/java/com/vitranslate/pdf/ui/components/ControlsView.kt
+++ b/android/app/src/main/java/com/vitranslate/pdf/ui/components/ControlsView.kt
@@ -5,7 +5,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.FolderOpen
+import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -23,6 +25,10 @@ import com.vitranslate.pdf.model.TargetLanguage
fun ControlsView(
selectedLanguage: TargetLanguage,
onLanguageSelected: (TargetLanguage) -> Unit,
+ engineType: SelectedEngineType = SelectedEngineType.GOOGLE_DEFAULT,
+ onOpenEngineConfig: () -> Unit = {},
+ useOcr: Boolean = true,
+ onUseOcrChange: (Boolean) -> Unit = {},
overwrite: Boolean,
onOverwriteChange: (Boolean) -> Unit,
customSaveDirectory: String?,
@@ -50,6 +56,19 @@ fun ControlsView(
}
}
+ val engineDisplayName = remember(engineType) {
+ when (engineType) {
+ SelectedEngineType.GOOGLE_DEFAULT -> "Google Translate (Mặc định)"
+ SelectedEngineType.DEEPSEEK -> "DeepSeek AI (V3/R1)"
+ SelectedEngineType.GEMINI -> "Google Gemini AI (2.0/1.5)"
+ SelectedEngineType.OPENAI -> "OpenAI (GPT-4o)"
+ SelectedEngineType.OPENROUTER -> "OpenRouter"
+ SelectedEngineType.GROQ -> "Groq (Ultra Fast)"
+ SelectedEngineType.SILICONFLOW -> "SiliconCloud (DeepSeek)"
+ SelectedEngineType.CUSTOM_OPENAI -> "Custom / Local LLM"
+ }
+ }
+
Card(
modifier = Modifier
.fillMaxWidth()
@@ -67,9 +86,6 @@ fun ControlsView(
)
Spacer(Modifier.height(6.dp))
- // The language picker used to share a row with the action button,
- // which left it about half the screen wide on a phone. Full width
- // here, with the action below it.
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { if (!isTranslating) expanded = !expanded }
@@ -108,7 +124,43 @@ fun ControlsView(
}
}
- Spacer(Modifier.height(12.dp))
+ Spacer(Modifier.height(10.dp))
+
+ // Engine Selection & AI Setup Button
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.AutoAwesome,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(Modifier.width(8.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Công cụ dịch",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = engineDisplayName,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ TextButton(
+ onClick = onOpenEngineConfig,
+ enabled = !isTranslating
+ ) {
+ Text("Cấu hình AI", style = MaterialTheme.typography.labelLarge)
+ }
+ }
+
+ Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
@@ -143,13 +195,33 @@ fun ControlsView(
}
}
- // The whole row is the target rather than just the checkbox, and it
- // is tall enough to hit without aiming.
+ // OCR Toggle Row
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 40.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(enabled = !isTranslating) { onUseOcrChange(!useOcr) }
+ ) {
+ Checkbox(
+ checked = useOcr,
+ onCheckedChange = { if (!isTranslating) onUseOcrChange(it) },
+ enabled = !isTranslating
+ )
+ Text(
+ text = "Tự động OCR trang PDF dạng ảnh (ML Kit)",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+
+ // Overwrite Checkbox
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
- .heightIn(min = 48.dp)
+ .heightIn(min = 40.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(enabled = !isTranslating) { onOverwriteChange(!overwrite) }
) {
diff --git a/android/app/src/main/java/com/vitranslate/pdf/ui/components/LlmSettingsDialog.kt b/android/app/src/main/java/com/vitranslate/pdf/ui/components/LlmSettingsDialog.kt
new file mode 100644
index 0000000..461d086
--- /dev/null
+++ b/android/app/src/main/java/com/vitranslate/pdf/ui/components/LlmSettingsDialog.kt
@@ -0,0 +1,120 @@
+package com.vitranslate.pdf.ui.components
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Key
+import androidx.compose.material.icons.filled.Link
+import androidx.compose.material.icons.filled.Memory
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun LlmSettingsDialog(
+ initialApiKey: String,
+ initialBaseUrl: String,
+ initialModelName: String,
+ onDismiss: () -> Unit,
+ onSave: (apiKey: String, baseUrl: String, modelName: String) -> Unit
+) {
+ var apiKey by remember { mutableStateOf(initialApiKey) }
+ var baseUrl by remember { mutableStateOf(initialBaseUrl.ifBlank { "https://api.openai.com/v1" }) }
+ var modelName by remember { mutableStateOf(initialModelName.ifBlank { "gpt-4o-mini" }) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Cấu hình AI (LLM)") },
+ text = {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Hỗ trợ OpenAI, OpenRouter, DeepSeek, hoặc Ollama cục bộ.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ OutlinedTextField(
+ value = apiKey,
+ onValueChange = { apiKey = it },
+ label = { Text("API Key (sk-...)") },
+ leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ OutlinedTextField(
+ value = baseUrl,
+ onValueChange = { baseUrl = it },
+ label = { Text("Base URL Endpoint") },
+ leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) },
+ singleLine = true,
+ placeholder = { Text("https://api.openai.com/v1") },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ OutlinedTextField(
+ value = modelName,
+ onValueChange = { modelName = it },
+ label = { Text("Tên Model AI") },
+ leadingIcon = { Icon(Icons.Default.Memory, contentDescription = null) },
+ singleLine = true,
+ placeholder = { Text("gpt-4o-mini / deepseek-chat") },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ // Quick presets
+ Text(
+ text = "Mẫu cài đặt nhanh:",
+ style = MaterialTheme.typography.labelSmall
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ FilterChip(
+ selected = baseUrl.contains("openai.com"),
+ onClick = {
+ baseUrl = "https://api.openai.com/v1"
+ modelName = "gpt-4o-mini"
+ },
+ label = { Text("OpenAI") }
+ )
+ FilterChip(
+ selected = baseUrl.contains("deepseek"),
+ onClick = {
+ baseUrl = "https://api.deepseek.com/v1"
+ modelName = "deepseek-chat"
+ },
+ label = { Text("DeepSeek") }
+ )
+ FilterChip(
+ selected = baseUrl.contains("openrouter"),
+ onClick = {
+ baseUrl = "https://openrouter.ai/api/v1"
+ modelName = "google/gemini-flash-1.5"
+ },
+ label = { Text("OpenRouter") }
+ )
+ }
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ onSave(apiKey.trim(), baseUrl.trim(), modelName.trim())
+ onDismiss()
+ }
+ ) {
+ Text("Lưu cài đặt")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Hủy")
+ }
+ }
+ )
+}
diff --git a/android/app/src/main/java/com/vitranslate/pdf/viewmodel/MainViewModel.kt b/android/app/src/main/java/com/vitranslate/pdf/viewmodel/MainViewModel.kt
index 9954d15..5ced766 100644
--- a/android/app/src/main/java/com/vitranslate/pdf/viewmodel/MainViewModel.kt
+++ b/android/app/src/main/java/com/vitranslate/pdf/viewmodel/MainViewModel.kt
@@ -23,6 +23,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val queueItems = TranslationController.queueItems
val selectedLanguage = TranslationController.selectedLanguage
+ val engineType = TranslationController.engineType
+ val useOcr = TranslationController.useOcr
val overwrite = TranslationController.overwrite
val customOutputDirectory = TranslationController.customOutputDirectory
val isTranslating = TranslationController.isTranslating
@@ -35,6 +37,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun setSelectedLanguage(language: TargetLanguage) =
TranslationController.setSelectedLanguage(language)
+ fun setEngineConfig(
+ type: com.vitranslate.pdf.ui.components.SelectedEngineType,
+ apiKey: String,
+ modelName: String,
+ endpoint: String
+ ) = TranslationController.setEngineConfig(type, apiKey, modelName, endpoint)
+
+ fun setUseOcr(enabled: Boolean) = TranslationController.setUseOcr(enabled)
+
fun setOverwrite(value: Boolean) = TranslationController.setOverwrite(value)
fun setCustomOutputDirectory(path: String?) =
diff --git a/android/app/src/test/java/com/vitranslate/pdf/PageSelectionParserTest.kt b/android/app/src/test/java/com/vitranslate/pdf/PageSelectionParserTest.kt
new file mode 100644
index 0000000..888ed30
--- /dev/null
+++ b/android/app/src/test/java/com/vitranslate/pdf/PageSelectionParserTest.kt
@@ -0,0 +1,59 @@
+package com.vitranslate.pdf
+
+import com.vitranslate.pdf.repository.PageSelectionParser
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class PageSelectionParserTest {
+
+ @Test
+ fun testAllPagesDefault() {
+ val total = 10
+ assertEquals((1..10).toList(), PageSelectionParser.parsePageSelection("all", total))
+ assertEquals((1..10).toList(), PageSelectionParser.parsePageSelection("", total))
+ assertEquals((1..10).toList(), PageSelectionParser.parsePageSelection(" ", total))
+ }
+
+ @Test
+ fun testSinglePage() {
+ val total = 10
+ assertEquals(listOf(1), PageSelectionParser.parsePageSelection("1", total))
+ assertEquals(listOf(5), PageSelectionParser.parsePageSelection("5", total))
+ }
+
+ @Test
+ fun testCommaSeparatedPages() {
+ val total = 10
+ assertEquals(listOf(1, 3, 5), PageSelectionParser.parsePageSelection("1,3,5", total))
+ assertEquals(listOf(1, 3, 5), PageSelectionParser.parsePageSelection("1, 3 , 5 ", total))
+ }
+
+ @Test
+ fun testSimpleRange() {
+ val total = 10
+ assertEquals(listOf(1, 2, 3, 4, 5), PageSelectionParser.parsePageSelection("1-5", total))
+ }
+
+ @Test
+ fun testComplexMixedSyntax() {
+ val total = 24
+ val expected = listOf(1, 2, 3, 7, 10, 11, 12)
+ assertEquals(expected, PageSelectionParser.parsePageSelection("1-3,7,10-12", total))
+ assertEquals(expected, PageSelectionParser.parsePageSelection(" 1-3 , 7 , 10-12 ", total))
+ }
+
+ @Test
+ fun testDuplicatesAndSorting() {
+ val total = 10
+ assertEquals(listOf(1, 2, 3, 5), PageSelectionParser.parsePageSelection("5, 1, 3, 1-3", total))
+ }
+
+ @Test
+ fun testOutofBoundsAndInvalidInput() {
+ val total = 5
+ // Page 0, negative pages, page > total
+ assertEquals(listOf(1, 2), PageSelectionParser.parsePageSelection("-1, 0, 1, 2, 99", total))
+ assertEquals((1..5).toList(), PageSelectionParser.parsePageSelection("invalid, text", total))
+ assertEquals((1..5).toList(), PageSelectionParser.parsePageSelection("0", total))
+ }
+}
diff --git a/android/app/src/test/java/com/vitranslate/pdf/PdfLayoutPreserverTest.kt b/android/app/src/test/java/com/vitranslate/pdf/PdfLayoutPreserverTest.kt
index f5ca2b2..906a02d 100644
--- a/android/app/src/test/java/com/vitranslate/pdf/PdfLayoutPreserverTest.kt
+++ b/android/app/src/test/java/com/vitranslate/pdf/PdfLayoutPreserverTest.kt
@@ -11,7 +11,7 @@ class PdfLayoutPreserverTest {
fun testFormulaPlaceholderEncodingAndRestoration() {
val original = "Let {v0} be a function where x^2 + 1 = 0."
val encoded = FormulaPlaceholder.encodeFormulaPlaceholders(original)
- assertEquals("Let be a function where x^2 + 1 = 0.", encoded)
+ assertEquals("Let be a function where x^2 + 1 = 0.", encoded)
val restored = FormulaPlaceholder.restoreFormulaPlaceholders(original, encoded)
assertEquals(original, restored)
diff --git a/android/app/src/test/java/com/vitranslate/pdf/repository/AiTranslateEngineTest.kt b/android/app/src/test/java/com/vitranslate/pdf/repository/AiTranslateEngineTest.kt
new file mode 100644
index 0000000..c9f4b97
--- /dev/null
+++ b/android/app/src/test/java/com/vitranslate/pdf/repository/AiTranslateEngineTest.kt
@@ -0,0 +1,67 @@
+package com.vitranslate.pdf.repository
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class AiTranslateEngineTest {
+
+ @Test
+ fun testAiEngineInitializationOpenAI() {
+ val engine = AiTranslateEngine(
+ provider = AiProvider.OPENAI,
+ apiKey = "test-ephemeral-key-123",
+ modelName = "gpt-4o-mini",
+ targetLang = "vi"
+ )
+
+ assertEquals(AiProvider.OPENAI, engine.provider)
+ assertEquals("gpt-4o-mini", engine.modelName)
+ assertEquals("vi", engine.targetLang)
+ }
+
+ @Test
+ fun testAiEngineInitializationGemini() {
+ val engine = AiTranslateEngine(
+ provider = AiProvider.GEMINI,
+ apiKey = "test-gemini-key",
+ modelName = "gemini-1.5-flash",
+ targetLang = "vi"
+ )
+
+ assertEquals(AiProvider.GEMINI, engine.provider)
+ assertEquals("gemini-1.5-flash", engine.modelName)
+ }
+
+ @Test
+ fun testAiEngineCustomEndpoint() {
+ val customUrl = "http://localhost:11434/v1/chat/completions"
+ val engine = AiTranslateEngine(
+ provider = AiProvider.CUSTOM_OPENAI,
+ apiKey = "ollama-local",
+ modelName = "qwen2.5",
+ customEndpoint = customUrl
+ )
+
+ assertEquals(AiProvider.CUSTOM_OPENAI, engine.provider)
+ assertEquals(customUrl, engine.customEndpoint)
+ assertEquals("qwen2.5", engine.modelName)
+ }
+
+ @Test
+ fun testTokenEstimatorAndSessionCounters() {
+ AiTranslateEngine.resetSessionTokens()
+ assertEquals(0L, AiTranslateEngine.sessionTotalTokens)
+
+ val sampleText = "Hello world this is a test for token estimation."
+ val estimated = AiTranslateEngine.estimateTokenCount(sampleText)
+ assertTrue(estimated > 5)
+
+ AiTranslateEngine.recordTokens(prompt = 20, completion = 10)
+ assertEquals(20L, AiTranslateEngine.sessionPromptTokens)
+ assertEquals(10L, AiTranslateEngine.sessionCompletionTokens)
+ assertEquals(30L, AiTranslateEngine.sessionTotalTokens)
+
+ AiTranslateEngine.resetSessionTokens()
+ assertEquals(0L, AiTranslateEngine.sessionTotalTokens)
+ }
+}
diff --git a/android/deploy-android.sh b/android/deploy-android.sh
new file mode 100755
index 0000000..b86bb85
--- /dev/null
+++ b/android/deploy-android.sh
@@ -0,0 +1,52 @@
+#!/bin/bash
+set -e
+
+echo "=================================================="
+echo "Building VI-Translate Unified Android App"
+echo "=================================================="
+
+# Export ANDROID_HOME if not set
+if [ -z "$ANDROID_HOME" ]; then
+ export ANDROID_HOME=$HOME/Library/Android/sdk
+fi
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$SCRIPT_DIR"
+
+echo "Building Unified App APK (:app)..."
+./gradlew :app:assembleDebug
+
+APP_APK="app/build/outputs/apk/debug/app-debug.apk"
+
+if [ ! -f "$APP_APK" ]; then
+ echo "Error: $APP_APK not found!"
+ exit 1
+fi
+
+echo ""
+echo "=================================================="
+echo "Checking Connected ADB Devices"
+echo "=================================================="
+
+DEVICES=$(adb devices | grep -v "List" | grep "device" | awk '{print $1}')
+
+if [ -z "$DEVICES" ]; then
+ echo "No active adb devices/emulators found."
+ echo "Build completed successfully. Unified APK ready at:"
+ echo " - Main App: $APP_APK"
+ exit 0
+fi
+
+for DEVICE in $DEVICES; do
+ echo "Installing to device: $DEVICE"
+ adb -s "$DEVICE" install -r "$APP_APK"
+
+ echo "Checking installation..."
+ adb -s "$DEVICE" shell pm list packages | grep "com.vitranslate"
+ echo "Successfully deployed to $DEVICE"
+done
+
+echo ""
+echo "=================================================="
+echo "Deployment Complete!"
+echo "=================================================="
diff --git a/android/gradle.properties b/android/gradle.properties
index 8f2e28c..cc4c2ee 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,4 +1,4 @@
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx4096m -XX:+UseG1GC -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 60274db..ed98a77 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -8,6 +8,8 @@ composeBom = "2024.11.00"
coroutines = "1.9.0"
okhttp = "4.12.0"
pdfbox = "2.0.27.0"
+mlkit = "16.0.1"
+onnxruntime = "1.19.2"
documentfile = "1.0.1"
junit = "4.13.2"
junitVersion = "1.2.1"
@@ -31,6 +33,8 @@ androidx-material-icons-extended = { group = "androidx.compose.material", name =
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
pdfbox-android = { group = "com.tom-roush", name = "pdfbox-android", version.ref = "pdfbox" }
+mlkit-text-recognition = { group = "com.google.mlkit", name = "text-recognition", version.ref = "mlkit" }
+onnxruntime-android = { group = "com.microsoft.onnxruntime", name = "onnxruntime-android", version.ref = "onnxruntime" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
diff --git a/pdf2zh/translator.py b/pdf2zh/translator.py
index b0a8a2f..a15d4d6 100644
--- a/pdf2zh/translator.py
+++ b/pdf2zh/translator.py
@@ -5,6 +5,7 @@
import html
import json
import logging
+import os
import re
import threading
import unicodedata
@@ -397,6 +398,76 @@ def _record_miss(self, text: str) -> None:
stream.write(json.dumps({"src": text}, ensure_ascii=False) + "\n")
+class OpenAITranslator(BaseTranslator):
+ """Translate via OpenAI-compatible Chat Completions API (OpenAI, OpenRouter, DeepSeek, Ollama)."""
+
+ name = "openai"
+
+ def __init__(
+ self,
+ lang_in: str,
+ lang_out: str,
+ model: str | None = None,
+ *,
+ ignore_cache: bool = False,
+ api_key: str | None = None,
+ base_url: str | None = None,
+ **kwargs: Any,
+ ) -> None:
+ model_name = model or os.environ.get("OPENAI_MODEL") or "gpt-4o-mini"
+ super().__init__(lang_in, lang_out, model_name, ignore_cache=ignore_cache, **kwargs)
+ self.api_key = (
+ api_key
+ or os.environ.get("OPENAI_API_KEY")
+ or os.environ.get("DEEPSEEK_API_KEY")
+ or os.environ.get("OPENROUTER_API_KEY")
+ or ""
+ )
+ self.base_url = (
+ base_url or os.environ.get("OPENAI_BASE_URL") or "https://api.openai.com/v1"
+ ).rstrip("/")
+ self.session = requests.Session()
+
+ def do_translate(self, text: str) -> str:
+ if not text.strip():
+ return text
+
+ url = f"{self.base_url}/chat/completions"
+ headers = {
+ "Content-Type": "application/json",
+ }
+ if self.api_key:
+ headers["Authorization"] = f"Bearer {self.api_key}"
+
+ system_prompt = (
+ f"You are a professional document translator. Translate the text from {self.lang_in} to {self.lang_out}.\n"
+ "CRITICAL INSTRUCTIONS:\n"
+ "1. Preserve ALL tags such as , , , in their exact position.\n"
+ "2. Do NOT translate or alter tag IDs or placeholders.\n"
+ "3. Do NOT add extra explanations or Markdown code blocks. Output ONLY the raw translated text."
+ )
+
+ payload = {
+ "model": self.model,
+ "messages": [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": text},
+ ],
+ "temperature": 0.1,
+ }
+
+ response = self.session.post(url, headers=headers, json=payload, timeout=40)
+ response.raise_for_status()
+ data = response.json()
+
+ try:
+ translated_content = data["choices"][0]["message"]["content"].strip()
+ return remove_control_characters(translated_content)
+ except (KeyError, IndexError, TypeError) as err:
+ raise RuntimeError(f"OpenAI-compatible translation response format invalid: {data}") from err
+
+
ENGINES: dict[str, type[BaseTranslator]] = {
- engine.name: engine for engine in (GoogleTranslator, HandoffTranslator)
+ engine.name: engine for engine in (GoogleTranslator, HandoffTranslator, OpenAITranslator)
}
+
diff --git a/tests/test_openai_translator.py b/tests/test_openai_translator.py
new file mode 100644
index 0000000..956c1e6
--- /dev/null
+++ b/tests/test_openai_translator.py
@@ -0,0 +1,46 @@
+import os
+import unittest
+from unittest.mock import MagicMock, patch
+
+from pdf2zh.translator import OpenAITranslator
+
+
+class OpenAITranslatorTests(unittest.TestCase):
+ def test_openai_translator_initialization(self):
+ translator = OpenAITranslator(
+ lang_in="en",
+ lang_out="vi",
+ model="gpt-4o-mini",
+ api_key="test-key",
+ base_url="https://api.openai.com/v1",
+ )
+ self.assertEqual(translator.name, "openai")
+ self.assertEqual(translator.model, "gpt-4o-mini")
+ self.assertEqual(translator.api_key, "test-key")
+ self.assertEqual(translator.base_url, "https://api.openai.com/v1")
+
+ @patch("requests.Session.post")
+ def test_openai_translator_do_translate_success(self, mock_post):
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "choices": [
+ {"message": {"content": "Xin chào thế giới "}}
+ ]
+ }
+ mock_post.return_value = mock_response
+
+ translator = OpenAITranslator(
+ lang_in="en",
+ lang_out="vi",
+ api_key="test-key",
+ ignore_cache=True,
+ )
+
+ result = translator.do_translate("Hello world ")
+ self.assertEqual(result, "Xin chào thế giới ")
+ mock_post.assert_called_once()
+
+
+if __name__ == "__main__":
+ unittest.main()