diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 59b35f36..73d01bc1 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -36,7 +36,7 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 with: - packages: 'platforms;android-34 build-tools;34.0.0' + packages: 'platforms;android-36 build-tools;36.0.0' # sdkmanager has no bare "ndk" package — must install "ndk;X.Y.Z". Pick the # newest side-by-side NDK from the package list. @@ -79,13 +79,13 @@ jobs: python -m tn --language zh --overwrite_cache --cache_dir "$assets" python -m itn --language zh --overwrite_cache --cache_dir "$assets" - - name: Build APK + - name: Build release APK working-directory: runtime/android - run: ./gradlew :app:assembleDebug -PabiFilters=arm64-v8a --no-daemon + run: ./gradlew :app:assembleRelease -PabiFilters=arm64-v8a --no-daemon - - name: Upload APK + - name: Upload release APK uses: actions/upload-artifact@v4 with: - name: app-debug-arm64-v8a - path: runtime/android/app/build/outputs/apk/debug/app-debug.apk + name: app-release-arm64-v8a + path: runtime/android/app/build/outputs/apk/release/app-release.apk if-no-files-found: error diff --git a/runtime/CMakePresets.json b/runtime/CMakePresets.json index 90619bd9..53d6a260 100644 --- a/runtime/CMakePresets.json +++ b/runtime/CMakePresets.json @@ -19,7 +19,7 @@ "generator": "Ninja", "cacheVariables": { "CMAKE_TOOLCHAIN_FILE": "$env{ANDROID_NDK_HOME}/build/cmake/android.toolchain.cmake", - "ANDROID_PLATFORM": "android-21", + "ANDROID_PLATFORM": "android-30", "CMAKE_BUILD_TYPE": "Release" } }, diff --git a/runtime/android/.gitignore b/runtime/android/.gitignore index 31d4fb7f..feef8c03 100644 --- a/runtime/android/.gitignore +++ b/runtime/android/.gitignore @@ -1,5 +1,6 @@ *.iml .gradle +.kotlin/ /local.properties /.idea/caches /.idea/libraries diff --git a/runtime/android/app/build.gradle b/runtime/android/app/build.gradle deleted file mode 100644 index f42b01a3..00000000 --- a/runtime/android/app/build.gradle +++ /dev/null @@ -1,68 +0,0 @@ -plugins { - alias(libs.plugins.android.application) -} - -def nativeAbis() { - return (project.findProperty('abiFilters') ?: 'armeabi-v7a,arm64-v8a,x86,x86_64') - .split(',').collect { it.trim() }.findAll { it } -} - -android { - namespace = "com.wenet.WeTextProcessing" - lint { - abortOnError = false - } - signingConfigs { - release { - storeFile file('wenet.keystore') - storePassword '123456' - keyAlias 'wenet' - keyPassword '123456' - } - } - compileSdk = 34 - - defaultConfig { - applicationId = "com.wenet.WeTextProcessing" - minSdk = 21 - targetSdk = 34 - versionCode = 1 - versionName = "1.0" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - ndk { - abiFilters.addAll(nativeAbis()) - } - } - - buildTypes { - release { - minifyEnabled = false - signingConfig = signingConfigs.release - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } -} - -dependencies { - implementation libs.androidx.appcompat - implementation libs.material - implementation libs.androidx.constraintlayout - testImplementation libs.junit - androidTestImplementation libs.androidx.junit - androidTestImplementation libs.androidx.espresso.core -} - -// Native libs are NOT built by Gradle. Build them beforehand with CMake presets -// (from repo root) so the .so files exist in app/src/main/jniLibs//: -// export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/ -// cd runtime -// cmake --preset android-arm64-v8a -// cmake --build --preset android-arm64-v8a -// cmake --install build/aarch64-linux-android --component jni -// Gradle only packages whatever is already present under jniLibs. diff --git a/runtime/android/app/build.gradle.kts b/runtime/android/app/build.gradle.kts new file mode 100644 index 00000000..aa2a64f1 --- /dev/null +++ b/runtime/android/app/build.gradle.kts @@ -0,0 +1,75 @@ +plugins { + // AGP 9 has built-in Kotlin support (it bundles KGP and enables it by default), + // so the org.jetbrains.kotlin.android plugin must NOT be applied here. + alias(libs.plugins.android.application) + // Compose compiler plugin (required since Kotlin 2.0+). + alias(libs.plugins.compose.compiler) +} + +fun nativeAbis(): List = + (project.findProperty("abiFilters") as String? ?: "armeabi-v7a,arm64-v8a,x86,x86_64") + .split(",").map { it.trim() }.filter { it.isNotEmpty() } + +android { + namespace = "com.wenet.WeTextProcessing" + lint { + abortOnError = false + } + signingConfigs { + create("release") { + storeFile = file("wenet.keystore") + storePassword = "123456" + keyAlias = "wenet" + keyPassword = "123456" + } + } + compileSdk = 36 + + defaultConfig { + applicationId = "com.wenet.WeTextProcessing" + minSdk = 30 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + ndk { + abiFilters.addAll(nativeAbis()) + } + } + + buildTypes { + release { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("release") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + // Built-in Kotlin defaults jvmTarget to compileOptions.targetCompatibility (17). + buildFeatures { + compose = true + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + debugImplementation(libs.androidx.compose.ui.tooling) +} + +// Native libs are NOT built by Gradle. Build them beforehand with CMake presets +// (from repo root) so the .so files exist in app/src/main/jniLibs//: +// export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/ +// cd runtime +// cmake --preset android-arm64-v8a +// cmake --build --preset android-arm64-v8a +// cmake --install build/aarch64-linux-android --component jni +// Gradle only packages whatever is already present under jniLibs. diff --git a/runtime/android/app/src/androidTest/java/com/wenet/WeTextProcessing/ExampleInstrumentedTest.java b/runtime/android/app/src/androidTest/java/com/wenet/WeTextProcessing/ExampleInstrumentedTest.java deleted file mode 100644 index 480682ca..00000000 --- a/runtime/android/app/src/androidTest/java/com/wenet/WeTextProcessing/ExampleInstrumentedTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.wenet.WeTextProcessing; - -import android.content.Context; - -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.*; - -/** - * Instrumented test, which will execute on an Android device. - * - * @see Testing documentation - */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - @Test - public void useAppContext() { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); - assertEquals("com.wenet.WeTextProcessing", appContext.getPackageName()); - } -} diff --git a/runtime/android/app/src/main/AndroidManifest.xml b/runtime/android/app/src/main/AndroidManifest.xml index b3979eeb..74eb590b 100644 --- a/runtime/android/app/src/main/AndroidManifest.xml +++ b/runtime/android/app/src/main/AndroidManifest.xml @@ -1,13 +1,11 @@ - + resource = Arrays.asList( - "zh_tn_tagger.fst", "zh_tn_verbalizer.fst", "zh_itn_tagger.fst", "zh_itn_verbalizer.fst" - ); - - public static void assetsInit(Context context) throws IOException { - AssetManager assetMgr = context.getAssets(); - // Unzip all files in resource from assets to context. - // Note: Uninstall the APP will remove the resource files in the context. - for (String file : assetMgr.list("")) { - if (resource.contains(file)) { - File dst = new File(context.getFilesDir(), file); - if (!dst.exists() || dst.length() == 0) { - Log.i(LOG_TAG, "Unzipping " + file + " to " + dst.getAbsolutePath()); - InputStream is = assetMgr.open(file); - OutputStream os = new FileOutputStream(dst); - byte[] buffer = new byte[4 * 1024]; - int read; - while ((read = is.read(buffer)) != -1) { - os.write(buffer, 0, read); - } - os.flush(); - } - } - } - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - try { - assetsInit(this); - } catch (IOException e) { - Log.e(LOG_TAG, "Error process asset files to file path"); - } - - TextView textView = findViewById(R.id.textView); - textView.setText(""); - WeTextProcessing.init(getFilesDir().getPath()); - - EditText editInput = findViewById(R.id.editTextInput); - - Button buttonNormalize = findViewById(R.id.button); - buttonNormalize.setOnClickListener(view -> { - String result = WeTextProcessing.normalize(editInput.getText().toString()); - textView.setText(result); - }); - - Button buttonInverseNormalize = findViewById(R.id.button2); - buttonInverseNormalize.setOnClickListener(view -> { - String result = WeTextProcessing.inverse_normalize(editInput.getText().toString()); - textView.setText(result); - }); - } -} diff --git a/runtime/android/app/src/main/java/com/wenet/WeTextProcessing/MainActivity.kt b/runtime/android/app/src/main/java/com/wenet/WeTextProcessing/MainActivity.kt new file mode 100644 index 00000000..a2530657 --- /dev/null +++ b/runtime/android/app/src/main/java/com/wenet/WeTextProcessing/MainActivity.kt @@ -0,0 +1,140 @@ +package com.wenet.WeTextProcessing + +import android.content.Context +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import java.io.File +import java.io.IOException + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + try { + assetsInit(this) + WeTextProcessing.init(filesDir.path) + } catch (e: IOException) { + Log.e(LOG_TAG, "Error processing asset files to file path", e) + } + setContent { + WenetTheme { + WeTextProcessingScreen() + } + } + } + + companion object { + private const val LOG_TAG = "WETEXTPROCESSING" + private val resource = listOf( + "zh_tn_tagger.fst", "zh_tn_verbalizer.fst", "zh_itn_tagger.fst", "zh_itn_verbalizer.fst" + ) + + // Unzip the FST models bundled in assets into the app's files dir on first run. + // Note: uninstalling the app removes these files. + @Throws(IOException::class) + fun assetsInit(context: Context) { + val assetMgr = context.assets + assetMgr.list("")?.forEach { file -> + if (file in resource) { + val dst = File(context.filesDir, file) + if (!dst.exists() || dst.length() == 0L) { + Log.i(LOG_TAG, "Unzipping $file to ${dst.absolutePath}") + assetMgr.open(file).use { input -> + dst.outputStream().use { output -> input.copyTo(output) } + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WeTextProcessingScreen(modifier: Modifier = Modifier) { + var input by remember { mutableStateOf("") } + var result by remember { mutableStateOf("") } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { TopAppBar(title = { Text("WeTextProcessing") }) } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Input text") }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Button( + onClick = { result = WeTextProcessing.normalize(input) }, + modifier = Modifier.weight(1f) + ) { + Text("Normalize") + } + Button( + onClick = { result = WeTextProcessing.inverse_normalize(input) }, + modifier = Modifier.weight(1f) + ) { + Text("Inverse normalize") + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Text( + text = result, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun WeTextProcessingScreenPreview() { + WenetTheme { + WeTextProcessingScreen() + } +} diff --git a/runtime/android/app/src/main/java/com/wenet/WeTextProcessing/Theme.kt b/runtime/android/app/src/main/java/com/wenet/WeTextProcessing/Theme.kt new file mode 100644 index 00000000..4f18ef94 --- /dev/null +++ b/runtime/android/app/src/main/java/com/wenet/WeTextProcessing/Theme.kt @@ -0,0 +1,46 @@ +package com.wenet.WeTextProcessing + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +// Brand colors taken from the logo gradient: indigo (#4C5BF5) -> cyan (#2DD4D4). +private val BrandIndigo = Color(0xFF4C5BF5) +private val BrandCyan = Color(0xFF2DD4D4) + +private val LightColors = lightColorScheme( + primary = BrandIndigo, + secondary = BrandCyan, +) + +private val DarkColors = darkColorScheme( + // Lightened indigo for sufficient contrast on dark surfaces. + primary = Color(0xFF9AA5FF), + secondary = BrandCyan, +) + +@Composable +fun WenetTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Material You dynamic color (Android 12+) takes priority; the brand colors + // that match the logo are the fallback on devices without dynamic color. + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColors + else -> LightColors + } + MaterialTheme(colorScheme = colorScheme, content = content) +} diff --git a/runtime/android/app/src/main/java/com/wenet/WeTextProcessing/WeTextProcessing.java b/runtime/android/app/src/main/java/com/wenet/WeTextProcessing/WeTextProcessing.java deleted file mode 100644 index 70f0555c..00000000 --- a/runtime/android/app/src/main/java/com/wenet/WeTextProcessing/WeTextProcessing.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.wenet.WeTextProcessing; - -public class WeTextProcessing { - - static { - System.loadLibrary("wetextprocessing"); - } - - public static native void init(String modelDir); - public static native String normalize(String input); - public static native String inverse_normalize(String input); -} diff --git a/runtime/android/app/src/main/java/com/wenet/WeTextProcessing/WeTextProcessing.kt b/runtime/android/app/src/main/java/com/wenet/WeTextProcessing/WeTextProcessing.kt new file mode 100644 index 00000000..46619207 --- /dev/null +++ b/runtime/android/app/src/main/java/com/wenet/WeTextProcessing/WeTextProcessing.kt @@ -0,0 +1,17 @@ +package com.wenet.WeTextProcessing + +object WeTextProcessing { + + init { + System.loadLibrary("wetextprocessing") + } + + @JvmStatic + external fun init(modelDir: String) + + @JvmStatic + external fun normalize(input: String): String + + @JvmStatic + external fun inverse_normalize(input: String): String +} diff --git a/runtime/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/runtime/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d11..00000000 --- a/runtime/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/runtime/android/app/src/main/res/drawable/ic_launcher_background.xml b/runtime/android/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9c..b78db3e6 100644 --- a/runtime/android/app/src/main/res/drawable/ic_launcher_background.xml +++ b/runtime/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,170 +1,21 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/runtime/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/runtime/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..9f673793 --- /dev/null +++ b/runtime/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/runtime/android/app/src/main/res/layout/activity_main.xml b/runtime/android/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 195d8f4d..00000000 --- a/runtime/android/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - -