diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml
new file mode 100644
index 0000000..f041e77
--- /dev/null
+++ b/.gitea/workflows/android.yml
@@ -0,0 +1,75 @@
+name: Android
+
+on:
+ push:
+ pull_request:
+ release:
+ types: [published]
+ workflow_dispatch:
+
+jobs:
+ build-android:
+ runs-on: debian-latest
+ permissions:
+ contents: read
+ releases: write
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 21
+ - uses: android-actions/setup-android@v3
+ - uses: gradle/actions/setup-gradle@v4
+ with:
+ cache-read-only: ${{ gitea.event_name == 'pull_request' }}
+ - name: Install Android SDK packages
+ run: sdkmanager "platforms;android-35" "build-tools;35.0.0"
+ - name: Lint and test
+ working-directory: android
+ run: ./gradlew --no-daemon lintDebug testDebugUnitTest
+ - name: Build debug APK
+ working-directory: android
+ run: ./gradlew --no-daemon assembleDebug
+ - name: Build signed release APK
+ working-directory: android
+ env:
+ ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
+ ORG_GRADLE_PROJECT_transuiStorePassword: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+ ORG_GRADLE_PROJECT_transuiKeyAlias: ${{ secrets.ANDROID_KEY_ALIAS }}
+ ORG_GRADLE_PROJECT_transuiKeyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }}
+ run: |
+ if [ -z "$ANDROID_KEYSTORE_BASE64" ]; then
+ echo "Release signing secrets are not configured; skipping release APK"
+ exit 0
+ fi
+ keystore="$RUNNER_TEMP/transui-release.jks"
+ printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$keystore"
+ export ORG_GRADLE_PROJECT_transuiKeystore="$keystore"
+ ./gradlew --no-daemon assembleRelease
+ - name: Prepare APK artifacts
+ working-directory: android
+ run: |
+ mkdir -p artifacts
+ cp app/build/outputs/apk/debug/app-debug.apk artifacts/transui-android-debug.apk
+ if [ -f app/build/outputs/apk/release/app-release.apk ]; then
+ cp app/build/outputs/apk/release/app-release.apk artifacts/transui-android-release.apk
+ fi
+ cd artifacts
+ sha256sum ./*.apk > SHA256SUMS-android
+ - name: Attach APKs to the Gitea release
+ if: gitea.event_name == 'release'
+ env:
+ ARTIFACT_DIR: android/artifacts
+ GITEA_SERVER_URL: ${{ gitea.server_url }}
+ GITEA_REPOSITORY: ${{ gitea.repository }}
+ GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+ GITEA_RELEASE_TAG: ${{ gitea.event.release.tag_name }}
+ run: scripts/publish-release-assets.sh
+ - name: Upload APKs
+ if: always()
+ uses: christopherhx/gitea-upload-artifact@v4
+ with:
+ name: transui-android
+ path: android/artifacts/
+ if-no-files-found: error
diff --git a/.gitignore b/.gitignore
index 1f7d35e..51c64b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,7 @@
/build/
/artifacts/
+/android/.gradle/
+/android/build/
+/android/local.properties
+/android/app/build/
+/android/artifacts/
diff --git a/android/app/build.gradle b/android/app/build.gradle
new file mode 100644
index 0000000..18e8197
--- /dev/null
+++ b/android/app/build.gradle
@@ -0,0 +1,53 @@
+plugins {
+ id 'com.android.application'
+}
+
+android {
+ namespace 'io.github.transui.android'
+ compileSdk 35
+
+ defaultConfig {
+ applicationId 'io.github.transui.android'
+ minSdk 26
+ targetSdk 35
+ versionCode 1
+ versionName '0.1.0'
+
+ testInstrumentationRunner 'android.test.InstrumentationTestRunner'
+ }
+
+ signingConfigs {
+ if (project.hasProperty('transuiKeystore')) {
+ release {
+ storeFile file(project.property('transuiKeystore'))
+ storePassword project.property('transuiStorePassword')
+ keyAlias project.property('transuiKeyAlias')
+ keyPassword project.property('transuiKeyPassword')
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ if (project.hasProperty('transuiKeystore')) {
+ signingConfig signingConfigs.release
+ }
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ lint {
+ abortOnError true
+ checkReleaseBuilds true
+ warningsAsErrors true
+ }
+}
+
+dependencies {
+ testImplementation 'junit:junit:4.13.2'
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..4238672
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/io/github/transui/android/AppConfig.java b/android/app/src/main/java/io/github/transui/android/AppConfig.java
new file mode 100644
index 0000000..0eeabd0
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/AppConfig.java
@@ -0,0 +1,36 @@
+package io.github.transui.android;
+
+final class AppConfig {
+ final String remoteEndpoint;
+ final String remoteModel;
+ final String remoteKey;
+ final String localEndpoint;
+ final String localModel;
+ final String localKey;
+ final String prompt;
+ final boolean remoteFirst;
+
+ AppConfig(
+ String remoteEndpoint,
+ String remoteModel,
+ String remoteKey,
+ String localEndpoint,
+ String localModel,
+ String localKey,
+ String prompt,
+ boolean remoteFirst) {
+ this.remoteEndpoint = remoteEndpoint;
+ this.remoteModel = remoteModel;
+ this.remoteKey = remoteKey;
+ this.localEndpoint = localEndpoint;
+ this.localModel = localModel;
+ this.localKey = localKey;
+ this.prompt = prompt;
+ this.remoteFirst = remoteFirst;
+ }
+
+ boolean hasUsableEndpoint() {
+ if (remoteFirst && !remoteEndpoint.isEmpty()) return true;
+ return !localEndpoint.isEmpty();
+ }
+}
diff --git a/android/app/src/main/java/io/github/transui/android/ConfigRepository.java b/android/app/src/main/java/io/github/transui/android/ConfigRepository.java
new file mode 100644
index 0000000..427e91f
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/ConfigRepository.java
@@ -0,0 +1,53 @@
+package io.github.transui.android;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+
+final class ConfigRepository {
+ private static final String PREFS = "settings";
+ private final SharedPreferences preferences;
+
+ ConfigRepository(Context context) {
+ preferences = context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ }
+
+ AppConfig load() {
+ String remoteKey = decryptOrEmpty(preferences.getString("remote_key", ""));
+ String localKey = decryptOrEmpty(preferences.getString("local_key", ""));
+ return new AppConfig(
+ preferences.getString("remote_endpoint", ""),
+ preferences.getString("remote_model", ""),
+ remoteKey,
+ preferences.getString("local_endpoint", "http://localhost:8080"),
+ preferences.getString("local_model", ""),
+ localKey,
+ preferences.getString("prompt", ""),
+ preferences.getBoolean("remote_first", true));
+ }
+
+ void save(AppConfig config) throws GeneralSecurityException, IOException {
+ String remoteKey = SecretStore.encrypt(config.remoteKey);
+ String localKey = SecretStore.encrypt(config.localKey);
+ preferences.edit()
+ .putString("remote_endpoint", config.remoteEndpoint)
+ .putString("remote_model", config.remoteModel)
+ .putString("remote_key", remoteKey)
+ .putString("local_endpoint", config.localEndpoint)
+ .putString("local_model", config.localModel)
+ .putString("local_key", localKey)
+ .putString("prompt", config.prompt)
+ .putBoolean("remote_first", config.remoteFirst)
+ .apply();
+ }
+
+ private static String decryptOrEmpty(String value) {
+ try {
+ return SecretStore.decrypt(value);
+ } catch (GeneralSecurityException | IOException | IllegalArgumentException ignored) {
+ return "";
+ }
+ }
+}
diff --git a/android/app/src/main/java/io/github/transui/android/MainActivity.java b/android/app/src/main/java/io/github/transui/android/MainActivity.java
new file mode 100644
index 0000000..78d1629
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/MainActivity.java
@@ -0,0 +1,136 @@
+package io.github.transui.android;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.provider.Settings;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.Switch;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+
+public final class MainActivity extends Activity {
+ private static final int MICROPHONE_REQUEST = 1;
+
+ private EditText remoteEndpoint;
+ private EditText remoteModel;
+ private EditText remoteKey;
+ private EditText localEndpoint;
+ private EditText localModel;
+ private EditText localKey;
+ private EditText prompt;
+ private Switch remoteFirst;
+ private TextView providerStatus;
+ private Button microphone;
+ private ConfigRepository repository;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+ repository = new ConfigRepository(this);
+ bindViews();
+ showConfig(repository.load());
+
+ findViewById(R.id.save).setOnClickListener(view -> saveConfig());
+ microphone.setOnClickListener(view -> requestMicrophone());
+ findViewById(R.id.choose_provider).setOnClickListener(view -> {
+ if (saveConfig()) openVoiceInputSettings();
+ });
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ updateStatus();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] results) {
+ super.onRequestPermissionsResult(requestCode, permissions, results);
+ if (requestCode == MICROPHONE_REQUEST) updateStatus();
+ }
+
+ private void bindViews() {
+ remoteEndpoint = findViewById(R.id.remote_endpoint);
+ remoteModel = findViewById(R.id.remote_model);
+ remoteKey = findViewById(R.id.remote_key);
+ localEndpoint = findViewById(R.id.local_endpoint);
+ localModel = findViewById(R.id.local_model);
+ localKey = findViewById(R.id.local_key);
+ prompt = findViewById(R.id.prompt);
+ remoteFirst = findViewById(R.id.remote_first);
+ providerStatus = findViewById(R.id.provider_status);
+ microphone = findViewById(R.id.microphone);
+ }
+
+ private void showConfig(AppConfig config) {
+ remoteEndpoint.setText(config.remoteEndpoint);
+ remoteModel.setText(config.remoteModel);
+ remoteKey.setText(config.remoteKey);
+ localEndpoint.setText(config.localEndpoint);
+ localModel.setText(config.localModel);
+ localKey.setText(config.localKey);
+ prompt.setText(config.prompt);
+ remoteFirst.setChecked(config.remoteFirst);
+ }
+
+ private boolean saveConfig() {
+ AppConfig config = new AppConfig(
+ value(remoteEndpoint).trim(),
+ value(remoteModel).trim(),
+ value(remoteKey).trim(),
+ value(localEndpoint).trim(),
+ value(localModel).trim(),
+ value(localKey).trim(),
+ value(prompt),
+ remoteFirst.isChecked());
+ try {
+ repository.save(config);
+ Toast.makeText(this, R.string.settings_saved, Toast.LENGTH_SHORT).show();
+ return true;
+ } catch (GeneralSecurityException | IOException error) {
+ Toast.makeText(this, R.string.settings_save_failed, Toast.LENGTH_LONG).show();
+ return false;
+ }
+ }
+
+ private void requestMicrophone() {
+ if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
+ requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, MICROPHONE_REQUEST);
+ }
+ }
+
+ private void openVoiceInputSettings() {
+ try {
+ startActivity(new Intent(Settings.ACTION_VOICE_INPUT_SETTINGS));
+ } catch (ActivityNotFoundException error) {
+ Toast.makeText(this, R.string.voice_settings_unavailable, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private void updateStatus() {
+ ComponentName service = new ComponentName(this, TransuiRecognitionService.class);
+ String selected = Settings.Secure.getString(getContentResolver(), "voice_recognition_service");
+ ComponentName selectedService = selected == null ? null : ComponentName.unflattenFromString(selected);
+ providerStatus.setText(service.equals(selectedService)
+ ? R.string.status_selected
+ : R.string.status_not_selected);
+ boolean allowed = checkSelfPermission(Manifest.permission.RECORD_AUDIO)
+ == PackageManager.PERMISSION_GRANTED;
+ microphone.setText(allowed ? R.string.microphone_allowed : R.string.allow_microphone);
+ microphone.setEnabled(!allowed);
+ }
+
+ private static String value(EditText field) {
+ return field.getText().toString();
+ }
+}
diff --git a/android/app/src/main/java/io/github/transui/android/SecretStore.java b/android/app/src/main/java/io/github/transui/android/SecretStore.java
new file mode 100644
index 0000000..03f300b
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/SecretStore.java
@@ -0,0 +1,62 @@
+package io.github.transui.android;
+
+import android.security.keystore.KeyGenParameterSpec;
+import android.security.keystore.KeyProperties;
+import android.util.Base64;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.util.Arrays;
+
+import javax.crypto.Cipher;
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
+
+final class SecretStore {
+ private static final String KEY_ALIAS = "transui_api_keys";
+ private static final String KEYSTORE = "AndroidKeyStore";
+
+ private SecretStore() {}
+
+ static String encrypt(String value) throws GeneralSecurityException, IOException {
+ if (value.isEmpty()) return "";
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey());
+ byte[] ciphertext = cipher.doFinal(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ return Base64.encodeToString(cipher.getIV(), Base64.NO_WRAP)
+ + "."
+ + Base64.encodeToString(ciphertext, Base64.NO_WRAP);
+ }
+
+ static String decrypt(String value) throws GeneralSecurityException, IOException {
+ if (value == null || value.isEmpty()) return "";
+ String[] parts = value.split("\\.", 2);
+ if (parts.length != 2) throw new GeneralSecurityException("Invalid encrypted value");
+ byte[] iv = Base64.decode(parts[0], Base64.NO_WRAP);
+ byte[] ciphertext = Base64.decode(parts[1], Base64.NO_WRAP);
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), new GCMParameterSpec(128, iv));
+ byte[] plaintext = cipher.doFinal(ciphertext);
+ String result = new String(plaintext, java.nio.charset.StandardCharsets.UTF_8);
+ Arrays.fill(plaintext, (byte) 0);
+ return result;
+ }
+
+ private static SecretKey getOrCreateKey() throws GeneralSecurityException, IOException {
+ KeyStore keyStore = KeyStore.getInstance(KEYSTORE);
+ keyStore.load(null);
+ java.security.Key key = keyStore.getKey(KEY_ALIAS, null);
+ if (key instanceof SecretKey) return (SecretKey) key;
+
+ KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE);
+ generator.init(new KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build());
+ return generator.generateKey();
+ }
+}
diff --git a/android/app/src/main/java/io/github/transui/android/TranscriptCleaner.java b/android/app/src/main/java/io/github/transui/android/TranscriptCleaner.java
new file mode 100644
index 0000000..b266eb4
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/TranscriptCleaner.java
@@ -0,0 +1,16 @@
+package io.github.transui.android;
+
+final class TranscriptCleaner {
+ private static final String QWEN_LANGUAGE_PREFIX = "language ";
+ private static final String QWEN_TEXT_MARKER = "";
+
+ private TranscriptCleaner() {}
+
+ static String clean(String transcript) {
+ // Qwen3-ASR prefixes transcripts with the detected language and this marker.
+ if (!transcript.startsWith(QWEN_LANGUAGE_PREFIX)) return transcript;
+ int marker = transcript.indexOf(QWEN_TEXT_MARKER, QWEN_LANGUAGE_PREFIX.length());
+ if (marker < 0) return transcript;
+ return transcript.substring(marker + QWEN_TEXT_MARKER.length());
+ }
+}
diff --git a/android/app/src/main/java/io/github/transui/android/TranscriptionClient.java b/android/app/src/main/java/io/github/transui/android/TranscriptionClient.java
new file mode 100644
index 0000000..9a02ac3
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/TranscriptionClient.java
@@ -0,0 +1,162 @@
+package io.github.transui.android;
+
+import android.speech.SpeechRecognizer;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+final class TranscriptionClient {
+ private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
+ private volatile HttpURLConnection activeConnection;
+
+ Result transcribe(AppConfig config, byte[] wav) {
+ Result remoteResult = null;
+ if (config.remoteFirst && !config.remoteEndpoint.isEmpty()) {
+ remoteResult = request(
+ config.remoteEndpoint, config.remoteModel, config.remoteKey, config.prompt, wav);
+ if (remoteResult.text != null) return remoteResult;
+ }
+
+ if (!config.localEndpoint.isEmpty()) {
+ Result localResult = request(
+ config.localEndpoint, config.localModel, config.localKey, config.prompt, wav);
+ if (localResult.text != null || remoteResult == null) return localResult;
+ return Result.error(
+ remoteResult.error + "; local: " + localResult.error,
+ localResult.errorCode);
+ }
+
+ return remoteResult != null
+ ? remoteResult
+ : Result.error("No transcription endpoint is configured", SpeechRecognizer.ERROR_CLIENT);
+ }
+
+ void cancel() {
+ HttpURLConnection connection = activeConnection;
+ if (connection != null) connection.disconnect();
+ }
+
+ private Result request(
+ String endpoint,
+ String model,
+ String key,
+ String prompt,
+ byte[] wav) {
+ HttpURLConnection connection = null;
+ try {
+ String boundary = "transui-" + UUID.randomUUID();
+ byte[] requestBody = multipartBody(boundary, model, prompt, wav);
+ connection = (HttpURLConnection) new URL(endpointUrl(endpoint)).openConnection();
+ activeConnection = connection;
+ connection.setConnectTimeout(12_000);
+ connection.setReadTimeout(300_000);
+ connection.setRequestMethod("POST");
+ connection.setDoOutput(true);
+ connection.setFixedLengthStreamingMode(requestBody.length);
+ connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
+ connection.setRequestProperty("Accept", "application/json");
+ if (!key.isEmpty()) connection.setRequestProperty("Authorization", "Bearer " + key);
+
+ try (java.io.OutputStream output = connection.getOutputStream()) {
+ output.write(requestBody);
+ }
+
+ int status = connection.getResponseCode();
+ InputStream stream = status >= 200 && status < 300
+ ? connection.getInputStream()
+ : connection.getErrorStream();
+ String response = stream == null ? "" : readResponse(stream);
+ if (status < 200 || status >= 300) {
+ return Result.error("Server returned HTTP " + status, SpeechRecognizer.ERROR_SERVER);
+ }
+ JSONObject object = new JSONObject(response);
+ if (!object.has("text") || object.isNull("text")) {
+ return Result.error("Response does not contain a text field", SpeechRecognizer.ERROR_SERVER);
+ }
+ return Result.success(TranscriptCleaner.clean(object.getString("text")));
+ } catch (IOException error) {
+ return Result.error("Network request failed", SpeechRecognizer.ERROR_NETWORK);
+ } catch (JSONException error) {
+ return Result.error("Server returned invalid JSON", SpeechRecognizer.ERROR_SERVER);
+ } finally {
+ if (connection != null) connection.disconnect();
+ if (activeConnection == connection) activeConnection = null;
+ }
+ }
+
+ private static String endpointUrl(String endpoint) {
+ String result = endpoint;
+ while (result.endsWith("/")) result = result.substring(0, result.length() - 1);
+ if (result.contains("/v1/audio/transcriptions")) return result;
+ return result + "/v1/audio/transcriptions";
+ }
+
+ private static byte[] multipartBody(String boundary, String model, String prompt, byte[] wav)
+ throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream(wav.length + 1024);
+ try (DataOutputStream output = new DataOutputStream(bytes)) {
+ writeField(output, boundary, "response_format", "json");
+ if (!model.isEmpty()) writeField(output, boundary, "model", model);
+ if (!prompt.isEmpty()) writeField(output, boundary, "prompt", prompt);
+ output.writeBytes("--" + boundary + "\r\n");
+ output.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"recording.wav\"\r\n");
+ output.writeBytes("Content-Type: audio/wav\r\n\r\n");
+ output.write(wav);
+ output.writeBytes("\r\n--" + boundary + "--\r\n");
+ }
+ return bytes.toByteArray();
+ }
+
+ private static void writeField(DataOutputStream output, String boundary, String name, String value)
+ throws IOException {
+ output.writeBytes("--" + boundary + "\r\n");
+ output.writeBytes("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n");
+ output.write(value.getBytes(StandardCharsets.UTF_8));
+ output.writeBytes("\r\n");
+ }
+
+ private static String readResponse(InputStream stream) throws IOException {
+ try (BufferedInputStream input = new BufferedInputStream(stream);
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ byte[] buffer = new byte[4096];
+ int total = 0;
+ int count;
+ while ((count = input.read(buffer)) != -1) {
+ total += count;
+ if (total > MAX_RESPONSE_BYTES) throw new IOException("Response is too large");
+ output.write(buffer, 0, count);
+ }
+ return output.toString(StandardCharsets.UTF_8.name());
+ }
+ }
+
+ static final class Result {
+ final String text;
+ final String error;
+ final int errorCode;
+
+ private Result(String text, String error, int errorCode) {
+ this.text = text;
+ this.error = error;
+ this.errorCode = errorCode;
+ }
+
+ static Result success(String text) {
+ return new Result(text, null, 0);
+ }
+
+ static Result error(String message, int errorCode) {
+ return new Result(null, message, errorCode);
+ }
+ }
+}
diff --git a/android/app/src/main/java/io/github/transui/android/TransuiRecognitionService.java b/android/app/src/main/java/io/github/transui/android/TransuiRecognitionService.java
new file mode 100644
index 0000000..80cafff
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/TransuiRecognitionService.java
@@ -0,0 +1,285 @@
+package io.github.transui.android;
+
+import android.Manifest;
+import android.annotation.SuppressLint;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.media.AudioFormat;
+import android.media.AudioRecord;
+import android.media.MediaRecorder;
+import android.os.Bundle;
+import android.os.RemoteException;
+import android.os.SystemClock;
+import android.speech.RecognitionService;
+import android.speech.RecognitionSupport;
+import android.speech.RecognizerIntent;
+import android.speech.SpeechRecognizer;
+import android.util.Log;
+
+import java.io.ByteArrayOutputStream;
+import java.util.ArrayList;
+import java.util.Locale;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public final class TransuiRecognitionService extends RecognitionService {
+ private static final String TAG = "TransuiRecognition";
+ private static final int SAMPLE_RATE = 16_000;
+ private static final long MAX_RECORDING_MS = 5 * 60_000;
+
+ private final Object sessionLock = new Object();
+ private final ExecutorService executor = Executors.newSingleThreadExecutor();
+ private Session activeSession;
+
+ @Override
+ protected void onStartListening(Intent recognizerIntent, Callback callback) {
+ if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
+ sendError(callback, SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS);
+ return;
+ }
+ AppConfig config = new ConfigRepository(this).load();
+ if (!config.hasUsableEndpoint()) {
+ sendError(callback, SpeechRecognizer.ERROR_CLIENT);
+ return;
+ }
+
+ synchronized (sessionLock) {
+ if (activeSession != null) {
+ sendError(callback, SpeechRecognizer.ERROR_RECOGNIZER_BUSY);
+ return;
+ }
+ activeSession = new Session(callback, config);
+ executor.execute(activeSession);
+ }
+ }
+
+ @Override
+ protected void onStopListening(Callback callback) {
+ Session session;
+ synchronized (sessionLock) {
+ session = activeSession;
+ }
+ if (session != null) session.stop();
+ }
+
+ @Override
+ protected void onCancel(Callback callback) {
+ Session session;
+ synchronized (sessionLock) {
+ session = activeSession;
+ }
+ if (session != null) session.cancel();
+ }
+
+ @Override
+ @SuppressLint("NewApi")
+ public void onCheckRecognitionSupport(Intent recognizerIntent, SupportCallback callback) {
+ String language = recognizerIntent.getStringExtra(RecognizerIntent.EXTRA_LANGUAGE);
+ if (language == null || language.isEmpty()) language = Locale.getDefault().toLanguageTag();
+ callback.onSupportResult(new RecognitionSupport.Builder().addOnlineLanguage(language).build());
+ }
+
+ @Override
+ public void onDestroy() {
+ Session session;
+ synchronized (sessionLock) {
+ session = activeSession;
+ }
+ if (session != null) session.cancel();
+ executor.shutdownNow();
+ super.onDestroy();
+ }
+
+ private void clearSession(Session session) {
+ synchronized (sessionLock) {
+ if (activeSession == session) activeSession = null;
+ }
+ }
+
+ private static void sendError(Callback callback, int error) {
+ try {
+ callback.error(error);
+ } catch (RemoteException ignored) {
+ // The caller has already gone away.
+ }
+ }
+
+ private final class Session implements Runnable {
+ private final Callback callback;
+ private final AppConfig config;
+ private final AtomicBoolean stopRequested = new AtomicBoolean();
+ private final AtomicBoolean cancelled = new AtomicBoolean();
+ private final TranscriptionClient client = new TranscriptionClient();
+
+ Session(Callback callback, AppConfig config) {
+ this.callback = callback;
+ this.config = config;
+ }
+
+ @Override
+ public void run() {
+ try {
+ byte[] pcm = captureAudio();
+ if (pcm == null || cancelled.get()) return;
+ sendEndOfSpeech();
+ if (cancelled.get()) return;
+ TranscriptionClient.Result result = client.transcribe(
+ config, WavEncoder.encodePcm16Mono(pcm, SAMPLE_RATE));
+ if (cancelled.get()) return;
+ if (result.text == null) {
+ Log.w(TAG, result.error);
+ sendError(callback, result.errorCode);
+ return;
+ }
+ sendResults(result.text);
+ } finally {
+ clearSession(this);
+ }
+ }
+
+ void stop() {
+ stopRequested.set(true);
+ }
+
+ void cancel() {
+ cancelled.set(true);
+ stopRequested.set(true);
+ client.cancel();
+ }
+
+ private byte[] captureAudio() {
+ int minimum = AudioRecord.getMinBufferSize(
+ SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
+ if (minimum <= 0) {
+ sendError(callback, SpeechRecognizer.ERROR_AUDIO);
+ return null;
+ }
+ int bufferBytes = Math.max(minimum, 4096);
+ AudioRecord recorder;
+ try {
+ recorder = new AudioRecord.Builder()
+ .setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
+ .setAudioFormat(new AudioFormat.Builder()
+ .setSampleRate(SAMPLE_RATE)
+ .setChannelMask(AudioFormat.CHANNEL_IN_MONO)
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
+ .build())
+ .setBufferSizeInBytes(bufferBytes)
+ .build();
+ } catch (IllegalArgumentException | SecurityException error) {
+ sendError(callback, SpeechRecognizer.ERROR_AUDIO);
+ return null;
+ }
+ if (recorder.getState() != AudioRecord.STATE_INITIALIZED) {
+ recorder.release();
+ sendError(callback, SpeechRecognizer.ERROR_AUDIO);
+ return null;
+ }
+
+ ByteArrayOutputStream pcm = new ByteArrayOutputStream(SAMPLE_RATE * 8);
+ short[] samples = new short[bufferBytes / 2];
+ boolean beginningSent = false;
+ long startedAt = SystemClock.elapsedRealtime();
+ try {
+ recorder.startRecording();
+ sendReady();
+ while (!cancelled.get()) {
+ int count = recorder.read(samples, 0, samples.length, AudioRecord.READ_BLOCKING);
+ if (count <= 0) {
+ if (!cancelled.get()) sendError(callback, SpeechRecognizer.ERROR_AUDIO);
+ return null;
+ }
+ appendSamples(pcm, samples, count);
+ sendRms(rmsDb(samples, count));
+ if (!beginningSent) {
+ beginningSent = true;
+ sendBeginningOfSpeech();
+ }
+
+ if (stopRequested.get()) break;
+ if (SystemClock.elapsedRealtime() - startedAt >= MAX_RECORDING_MS) break;
+ }
+ } catch (IllegalStateException | SecurityException error) {
+ if (!cancelled.get()) sendError(callback, SpeechRecognizer.ERROR_AUDIO);
+ return null;
+ } finally {
+ try {
+ recorder.stop();
+ } catch (IllegalStateException ignored) {
+ // The recorder did not reach its recording state.
+ }
+ recorder.release();
+ }
+ if (cancelled.get()) return null;
+ if (pcm.size() == 0) {
+ sendError(callback, SpeechRecognizer.ERROR_NO_MATCH);
+ return null;
+ }
+ return pcm.toByteArray();
+ }
+
+ private void sendReady() {
+ try {
+ callback.readyForSpeech(new Bundle());
+ } catch (RemoteException error) {
+ cancel();
+ }
+ }
+
+ private void sendBeginningOfSpeech() {
+ try {
+ callback.beginningOfSpeech();
+ } catch (RemoteException error) {
+ cancel();
+ }
+ }
+
+ private void sendRms(float rms) {
+ try {
+ callback.rmsChanged(rms);
+ } catch (RemoteException error) {
+ cancel();
+ }
+ }
+
+ private void sendEndOfSpeech() {
+ try {
+ callback.endOfSpeech();
+ } catch (RemoteException error) {
+ cancel();
+ }
+ }
+
+ private void sendResults(String text) {
+ Bundle bundle = new Bundle();
+ ArrayList results = new ArrayList<>();
+ results.add(text);
+ bundle.putStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION, results);
+ bundle.putFloatArray(SpeechRecognizer.CONFIDENCE_SCORES, new float[]{-1.0f});
+ try {
+ callback.results(bundle);
+ } catch (RemoteException ignored) {
+ // The caller has already gone away.
+ }
+ }
+ }
+
+ private static void appendSamples(ByteArrayOutputStream output, short[] samples, int count) {
+ for (int index = 0; index < count; index++) {
+ int sample = samples[index];
+ output.write(sample & 0xff);
+ output.write((sample >>> 8) & 0xff);
+ }
+ }
+
+ private static float rmsDb(short[] samples, int count) {
+ double sum = 0;
+ for (int index = 0; index < count; index++) {
+ double normalized = samples[index] / 32768.0;
+ sum += normalized * normalized;
+ }
+ double rms = Math.sqrt(sum / count);
+ return rms <= 0 ? -100.0f : (float) (20.0 * Math.log10(rms));
+ }
+}
diff --git a/android/app/src/main/java/io/github/transui/android/WavEncoder.java b/android/app/src/main/java/io/github/transui/android/WavEncoder.java
new file mode 100644
index 0000000..d19140b
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/WavEncoder.java
@@ -0,0 +1,44 @@
+package io.github.transui.android;
+
+import java.io.ByteArrayOutputStream;
+
+final class WavEncoder {
+ private static final int HEADER_SIZE = 44;
+
+ private WavEncoder() {}
+
+ static byte[] encodePcm16Mono(byte[] pcm, int sampleRate) {
+ ByteArrayOutputStream output = new ByteArrayOutputStream(HEADER_SIZE + pcm.length);
+ writeAscii(output, "RIFF");
+ writeInt(output, 36 + pcm.length);
+ writeAscii(output, "WAVE");
+ writeAscii(output, "fmt ");
+ writeInt(output, 16);
+ writeShort(output, 1);
+ writeShort(output, 1);
+ writeInt(output, sampleRate);
+ writeInt(output, sampleRate * 2);
+ writeShort(output, 2);
+ writeShort(output, 16);
+ writeAscii(output, "data");
+ writeInt(output, pcm.length);
+ output.write(pcm, 0, pcm.length);
+ return output.toByteArray();
+ }
+
+ private static void writeAscii(ByteArrayOutputStream output, String value) {
+ for (int index = 0; index < value.length(); index++) output.write(value.charAt(index));
+ }
+
+ private static void writeShort(ByteArrayOutputStream output, int value) {
+ output.write(value & 0xff);
+ output.write((value >>> 8) & 0xff);
+ }
+
+ private static void writeInt(ByteArrayOutputStream output, int value) {
+ output.write(value & 0xff);
+ output.write((value >>> 8) & 0xff);
+ output.write((value >>> 16) & 0xff);
+ output.write((value >>> 24) & 0xff);
+ }
+}
diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..2672d66
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/panel.xml b/android/app/src/main/res/drawable/panel.xml
new file mode 100644
index 0000000..aa41d25
--- /dev/null
+++ b/android/app/src/main/res/drawable/panel.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..a8397c4
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..95c3903
--- /dev/null
+++ b/android/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,9 @@
+
+
+ #EAF1F2
+ #A8B7BC
+ #42C9C1
+ #11191D
+ #192329
+ #34444B
+
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..ad015b4
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f93eb21
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,9 @@
+
+
+ #142027
+ #56676F
+ #008D87
+ #F2F6F7
+ #FFFFFF
+ #D5E0E3
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..5387ebc
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,26 @@
+
+
+ Transui
+ Transui speech recognition
+ Transui voice input
+ Selected as voice input
+ Select Transui as voice input
+ REMOTE
+ LOCAL
+ REQUEST
+ Remote endpoint
+ Remote model
+ Remote API key
+ Local endpoint
+ Local model
+ Local API key
+ Prompt
+ Remote first, local fallback
+ Save settings
+ Choose voice input
+ Allow microphone
+ Microphone allowed
+ Settings saved
+ Could not save API keys
+ Voice input settings are unavailable
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..c573823
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..ce0b324
--- /dev/null
+++ b/android/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..224515d
--- /dev/null
+++ b/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/recognition_service.xml b/android/app/src/main/res/xml/recognition_service.xml
new file mode 100644
index 0000000..291f62a
--- /dev/null
+++ b/android/app/src/main/res/xml/recognition_service.xml
@@ -0,0 +1,3 @@
+
+
diff --git a/android/app/src/test/java/io/github/transui/android/TranscriptCleanerTest.java b/android/app/src/test/java/io/github/transui/android/TranscriptCleanerTest.java
new file mode 100644
index 0000000..7d15ab7
--- /dev/null
+++ b/android/app/src/test/java/io/github/transui/android/TranscriptCleanerTest.java
@@ -0,0 +1,24 @@
+package io.github.transui.android;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+public final class TranscriptCleanerTest {
+ @Test
+ public void removesQwenLanguagePrefix() {
+ assertEquals(
+ "What do you think?",
+ TranscriptCleaner.clean("language EnglishWhat do you think?"));
+ }
+
+ @Test
+ public void keepsOrdinaryTranscript() {
+ assertEquals("Language matters.", TranscriptCleaner.clean("Language matters."));
+ }
+
+ @Test
+ public void keepsIncompleteQwenPrefix() {
+ assertEquals("language English", TranscriptCleaner.clean("language English"));
+ }
+}
diff --git a/android/app/src/test/java/io/github/transui/android/WavEncoderTest.java b/android/app/src/test/java/io/github/transui/android/WavEncoderTest.java
new file mode 100644
index 0000000..c3185b3
--- /dev/null
+++ b/android/app/src/test/java/io/github/transui/android/WavEncoderTest.java
@@ -0,0 +1,35 @@
+package io.github.transui.android;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+
+import java.nio.charset.StandardCharsets;
+
+import org.junit.Test;
+
+public final class WavEncoderTest {
+ @Test
+ public void writesPcm16MonoHeaderAndPayload() {
+ byte[] pcm = new byte[]{1, 2, 3, 4};
+ byte[] wav = WavEncoder.encodePcm16Mono(pcm, 16_000);
+
+ assertEquals("RIFF", ascii(wav, 0, 4));
+ assertEquals(40, littleEndianInt(wav, 4));
+ assertEquals("WAVE", ascii(wav, 8, 4));
+ assertEquals(16_000, littleEndianInt(wav, 24));
+ assertEquals(32_000, littleEndianInt(wav, 28));
+ assertEquals(4, littleEndianInt(wav, 40));
+ assertArrayEquals(pcm, new byte[]{wav[44], wav[45], wav[46], wav[47]});
+ }
+
+ private static String ascii(byte[] bytes, int offset, int length) {
+ return new String(bytes, offset, length, StandardCharsets.US_ASCII);
+ }
+
+ private static int littleEndianInt(byte[] bytes, int offset) {
+ return (bytes[offset] & 0xff)
+ | ((bytes[offset + 1] & 0xff) << 8)
+ | ((bytes[offset + 2] & 0xff) << 16)
+ | ((bytes[offset + 3] & 0xff) << 24);
+ }
+}
diff --git a/android/build.gradle b/android/build.gradle
new file mode 100644
index 0000000..ccfe716
--- /dev/null
+++ b/android/build.gradle
@@ -0,0 +1,3 @@
+plugins {
+ id 'com.android.application' version '8.9.2' apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..e658494
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
+org.gradle.parallel=false
+android.useAndroidX=false
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e2847c8
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..f5feea6
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..9b42019
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle b/android/settings.gradle
new file mode 100644
index 0000000..c92b739
--- /dev/null
+++ b/android/settings.gradle
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "TransuiAndroid"
+include ':app'
diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh
index 5028c94..205697c 100755
--- a/scripts/publish-release-assets.sh
+++ b/scripts/publish-release-assets.sh
@@ -35,7 +35,7 @@ python3 -c 'import json,sys; [print(a["name"]) for a in json.load(sys.stdin).get
< "$metadata" > "$existing_names"
shopt -s nullglob
-assets=("$artifact_dir"/*.deb)
+assets=("$artifact_dir"/*.deb "$artifact_dir"/*.apk)
checksums=("$artifact_dir"/SHA256SUMS*)
assets+=("${checksums[@]}")
((${#assets[@]} > 0)) || {