1 parent
00a4c9c25a
commit
e3dd14cef6
33 files changed
+1678
-1
No files matched your search
@@ -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
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
/build/
|
/build/
|
||||||
/artifacts/
|
/artifacts/
|
||||||
|
/android/.gradle/
|
||||||
|
/android/build/
|
||||||
|
/android/local.properties
|
||||||
|
/android/app/build/
|
||||||
|
/android/artifacts/
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@drawable/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".TransuiRecognitionService"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/provider_name"
|
||||||
|
tools:ignore="ExportedService">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.speech.RecognitionService" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.speech"
|
||||||
|
android:resource="@xml/recognition_service" />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = "<astext>";
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#008D87"
|
||||||
|
android:pathData="M54,4A50,50 0,1 0,54 104A50,50 0,1 0,54 4" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M25,49h8v10h-8zM38,38h8v32h-8zM51,27h8v54h-8zM64,38h8v32h-8zM77,49h8v10h-8z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<solid android:color="@color/paper" />
|
||||||
|
<stroke android:width="1dp" android:color="@color/line" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="20dp"
|
||||||
|
android:paddingTop="28dp"
|
||||||
|
android:paddingEnd="20dp"
|
||||||
|
android:paddingBottom="32dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/title"
|
||||||
|
android:textColor="@color/ink"
|
||||||
|
android:textSize="27sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="18dp"
|
||||||
|
android:background="@drawable/panel"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="5dp"
|
||||||
|
android:layout_height="54dp"
|
||||||
|
android:background="@color/signal" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/provider_status"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:textColor="@color/ink"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
style="@style/SectionLabel"
|
||||||
|
android:text="@string/remote" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/remote_endpoint"
|
||||||
|
style="@style/Field"
|
||||||
|
android:hint="@string/remote_endpoint"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:inputType="textUri" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/remote_model"
|
||||||
|
style="@style/Field"
|
||||||
|
android:hint="@string/remote_model"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:inputType="text" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/remote_key"
|
||||||
|
style="@style/Field"
|
||||||
|
android:hint="@string/remote_api_key"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:inputType="textPassword" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
style="@style/SectionLabel"
|
||||||
|
android:text="@string/local" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/local_endpoint"
|
||||||
|
style="@style/Field"
|
||||||
|
android:hint="@string/local_endpoint"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:inputType="textUri" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/local_model"
|
||||||
|
style="@style/Field"
|
||||||
|
android:hint="@string/local_model"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:inputType="text" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/local_key"
|
||||||
|
style="@style/Field"
|
||||||
|
android:hint="@string/local_api_key"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:inputType="textPassword" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
style="@style/SectionLabel"
|
||||||
|
android:text="@string/request" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/prompt"
|
||||||
|
style="@style/Field"
|
||||||
|
android:hint="@string/prompt"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:inputType="textCapSentences|textMultiLine"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:singleLine="false" />
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
android:id="@+id/remote_first"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="14dp"
|
||||||
|
android:checked="true"
|
||||||
|
android:text="@string/remote_first"
|
||||||
|
android:textColor="@color/ink" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/save"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:text="@string/save" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/microphone"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/allow_microphone" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/choose_provider"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/choose_provider" />
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ink">#EAF1F2</color>
|
||||||
|
<color name="slate">#A8B7BC</color>
|
||||||
|
<color name="signal">#42C9C1</color>
|
||||||
|
<color name="mist">#11191D</color>
|
||||||
|
<color name="paper">#192329</color>
|
||||||
|
<color name="line">#34444B</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
<item name="android:statusBarColor">@color/mist</item>
|
||||||
|
<item name="android:navigationBarColor">@color/paper</item>
|
||||||
|
<item name="android:windowActionModeOverlay">true</item>
|
||||||
|
<item name="android:colorAccent">@color/signal</item>
|
||||||
|
<item name="android:windowBackground">@color/mist</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ink">#142027</color>
|
||||||
|
<color name="slate">#56676F</color>
|
||||||
|
<color name="signal">#008D87</color>
|
||||||
|
<color name="mist">#F2F6F7</color>
|
||||||
|
<color name="paper">#FFFFFF</color>
|
||||||
|
<color name="line">#D5E0E3</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Transui</string>
|
||||||
|
<string name="provider_name">Transui speech recognition</string>
|
||||||
|
<string name="title">Transui voice input</string>
|
||||||
|
<string name="status_selected">Selected as voice input</string>
|
||||||
|
<string name="status_not_selected">Select Transui as voice input</string>
|
||||||
|
<string name="remote">REMOTE</string>
|
||||||
|
<string name="local">LOCAL</string>
|
||||||
|
<string name="request">REQUEST</string>
|
||||||
|
<string name="remote_endpoint">Remote endpoint</string>
|
||||||
|
<string name="remote_model">Remote model</string>
|
||||||
|
<string name="remote_api_key">Remote API key</string>
|
||||||
|
<string name="local_endpoint">Local endpoint</string>
|
||||||
|
<string name="local_model">Local model</string>
|
||||||
|
<string name="local_api_key">Local API key</string>
|
||||||
|
<string name="prompt">Prompt</string>
|
||||||
|
<string name="remote_first">Remote first, local fallback</string>
|
||||||
|
<string name="save">Save settings</string>
|
||||||
|
<string name="choose_provider">Choose voice input</string>
|
||||||
|
<string name="allow_microphone">Allow microphone</string>
|
||||||
|
<string name="microphone_allowed">Microphone allowed</string>
|
||||||
|
<string name="settings_saved">Settings saved</string>
|
||||||
|
<string name="settings_save_failed">Could not save API keys</string>
|
||||||
|
<string name="voice_settings_unavailable">Voice input settings are unavailable</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
|
<item name="android:statusBarColor">@color/mist</item>
|
||||||
|
<item name="android:navigationBarColor">@color/paper</item>
|
||||||
|
<item name="android:windowActionModeOverlay">true</item>
|
||||||
|
<item name="android:colorAccent">@color/signal</item>
|
||||||
|
<item name="android:windowBackground">@color/mist</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Field">
|
||||||
|
<item name="android:layout_width">match_parent</item>
|
||||||
|
<item name="android:layout_height">wrap_content</item>
|
||||||
|
<item name="android:layout_marginTop">4dp</item>
|
||||||
|
<item name="android:singleLine">true</item>
|
||||||
|
<item name="android:textColor">@color/ink</item>
|
||||||
|
<item name="android:textColorHint">@color/slate</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="SectionLabel">
|
||||||
|
<item name="android:layout_width">match_parent</item>
|
||||||
|
<item name="android:layout_height">wrap_content</item>
|
||||||
|
<item name="android:layout_marginTop">22dp</item>
|
||||||
|
<item name="android:textColor">@color/signal</item>
|
||||||
|
<item name="android:textSize">12sp</item>
|
||||||
|
<item name="android:textStyle">bold</item>
|
||||||
|
<item name="android:letterSpacing">0.12</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<full-backup-content>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</full-backup-content>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<recognition-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:settingsActivity="io.github.transui.android.MainActivity" />
|
||||||
@@ -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 English<astext>What 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.9.2' apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=false
|
||||||
|
android.useAndroidX=false
|
||||||
BIN
Binary file not shown.
@@ -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
|
||||||
+252
@@ -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" "$@"
|
||||||
Vendored
+94
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -35,7 +35,7 @@ python3 -c 'import json,sys; [print(a["name"]) for a in json.load(sys.stdin).get
|
|||||||
< "$metadata" > "$existing_names"
|
< "$metadata" > "$existing_names"
|
||||||
|
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
assets=("$artifact_dir"/*.deb)
|
assets=("$artifact_dir"/*.deb "$artifact_dir"/*.apk)
|
||||||
checksums=("$artifact_dir"/SHA256SUMS*)
|
checksums=("$artifact_dir"/SHA256SUMS*)
|
||||||
assets+=("${checksums[@]}")
|
assets+=("${checksums[@]}")
|
||||||
((${#assets[@]} > 0)) || {
|
((${#assets[@]} > 0)) || {
|
||||||
|
|||||||
Reference in new issue
Block a user