diff --git a/provider/android/.gitignore b/provider/android/.gitignore
new file mode 100644
index 0000000..aa724b7
--- /dev/null
+++ b/provider/android/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/provider/android/app/.gitignore b/provider/android/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/provider/android/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/provider/android/app/build.gradle.kts b/provider/android/app/build.gradle.kts
new file mode 100644
index 0000000..eec0f3c
--- /dev/null
+++ b/provider/android/app/build.gradle.kts
@@ -0,0 +1,94 @@
+// Module-level build file
+// build.gradle.kts (Module: app)
+
+plugins {
+ // Apply the plugins declared in the root build.gradle.kts
+ // or directly by their ID if not using the root declaration pattern
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ // If you had defined an alias for compose, e.g., in libs.versions.toml as
+ // kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+ // You might also add: id("org.jetbrains.kotlin.plugin.compose")
+ // However, with modern AGP and composeOptions, it's often implicitly handled.
+}
+
+android {
+ namespace = "com.star.provider"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.star.provider"
+ minSdk = 32 // Ensure this is appropriate for the APIs used
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false // Set to true for actual releases
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ composeOptions {
+ // Ensure this version is compatible with your Kotlin and Compose BOM versions
+ // Refer to: https://developer.android.com/jetpack/androidx/releases/compose-kotlin
+ kotlinCompilerExtensionVersion = "1.5.3" // Example, update to your required version
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ // Core Android & Jetpack Compose Dependencies (from version catalog)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom)) // Ensure this BOM version is up-to-date
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation("androidx.compose.material3:material3:1.2.1") // Or your current M3 version
+ implementation("androidx.compose.material:material-icons-core:1.6.7") // Or latest
+ implementation("androidx.compose.material:material-icons-extended:1.6.7") // Or latest - for all icons
+ // Added Dependencies for STAR Provider Service (explicitly versioned for clarity, or add to TOML)
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.json:json:20231013")
+ implementation("com.google.code.gson:gson:2.10.1")
+
+ // Testing Dependencies (from version catalog)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom)) // For Compose testing
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+}
\ No newline at end of file
diff --git a/provider/android/app/proguard-rules.pro b/provider/android/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/provider/android/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/provider/android/app/src/androidTest/java/com/star/provider/ExampleInstrumentedTest.kt b/provider/android/app/src/androidTest/java/com/star/provider/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..47e44d6
--- /dev/null
+++ b/provider/android/app/src/androidTest/java/com/star/provider/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.star.provider
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.star.provider", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/provider/android/app/src/main/AndroidManifest.xml b/provider/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..93d7db2
--- /dev/null
+++ b/provider/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/provider/android/app/src/main/java/com/star/provider/MainActivity.kt b/provider/android/app/src/main/java/com/star/provider/MainActivity.kt
new file mode 100644
index 0000000..58d5280
--- /dev/null
+++ b/provider/android/app/src/main/java/com/star/provider/MainActivity.kt
@@ -0,0 +1,473 @@
+package com.star.provider // Ensure this matches your package name
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.content.SharedPreferences
+import android.os.Build
+import android.os.Bundle
+import android.os.IBinder
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Build
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.core.content.ContextCompat
+import com.star.provider.ui.theme.StarProviderTheme
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.util.Locale
+import com.star.provider.ServiceStateListener
+import com.star.provider.ServiceLogListener
+import com.star.provider.PersistedVoiceConfig
+import com.star.provider.DialogVoiceInfo
+import com.star.provider.DialogEngineInfo
+
+// SharedPreferences Keys
+const val PREFS_NAME = "StarProviderPrefs"
+const val KEY_SERVER_URLS = "server_urls" // MODIFIED
+const val KEY_PROVIDER_NAME = "provider_name"
+
+class MainActivity : ComponentActivity(), ServiceStateListener, ServiceLogListener {
+
+ private var starProviderService: StarProviderService? = null
+ private var serviceBinder: StarProviderService.LocalBinder? = null
+ private var isBound = false
+ private lateinit var sharedPreferences: SharedPreferences
+
+ // MODIFIED: From single URL to list of URLs
+ private val _serverUrls = mutableStateListOf()
+ private val _providerName = mutableStateOf("MyAndroidTTS")
+ private val _currentStatus = mutableStateOf("Status: Disconnected")
+ private val _logMessages = mutableStateOf("Logs will appear here...")
+ private val _isServiceRunningState = mutableStateOf(false)
+ private val _showVoiceConfigDialog = mutableStateOf(false)
+ private val _showEngineConfigDialog = mutableStateOf(false) // NEW
+
+ private val connection = object : ServiceConnection {
+ override fun onServiceConnected(className: ComponentName, service: IBinder) {
+ serviceBinder = service as StarProviderService.LocalBinder
+ starProviderService = serviceBinder?.getService()
+ isBound = true
+ Log.d("MainActivity", "Service connected")
+ serviceBinder?.registerStateListener(this@MainActivity)
+ serviceBinder?.registerLogListener(this@MainActivity)
+ starProviderService?.requestCurrentStatus()
+ }
+
+ override fun onServiceDisconnected(arg0: ComponentName) {
+ if (isBound) {
+ serviceBinder?.unregisterStateListener(this@MainActivity)
+ serviceBinder?.unregisterLogListener(this@MainActivity)
+ isBound = false
+ serviceBinder = null
+ starProviderService = null
+ Log.d("MainActivity", "Service disconnected")
+ }
+ _currentStatus.value = "Status: Service Unbound"
+ _isServiceRunningState.value = false
+ }
+ }
+
+ private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
+ if (isGranted) { Log.i("MainActivity", "Notification permission granted.") }
+ else { Log.w("MainActivity", "Notification permission denied.") }
+ }
+
+ override fun onStatusUpdate(status: String, isRunning: Boolean) { CoroutineScope(Dispatchers.Main).launch { _currentStatus.value = status; _isServiceRunningState.value = isRunning } }
+ override fun onLogMessage(message: String) { CoroutineScope(Dispatchers.Main).launch { _logMessages.value = (_logMessages.value + "\n" + message).takeLast(2000) } }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ sharedPreferences = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ // MODIFIED: Load a set of URLs, provide a default if empty
+ val savedUrls = sharedPreferences.getStringSet(KEY_SERVER_URLS, null)
+ if (savedUrls.isNullOrEmpty()) {
+ _serverUrls.add("ws://localhost:8765")
+ } else {
+ _serverUrls.addAll(savedUrls)
+ }
+
+ _providerName.value = sharedPreferences.getString(KEY_PROVIDER_NAME, "MyAndroidTTS") ?: "MyAndroidTTS"
+
+ setContent {
+ StarProviderTheme {
+ StarProviderScreen(
+ serverUrls = _serverUrls,
+ onAddServerUrl = { url ->
+ val trimmedUrl = url.trim()
+ if (trimmedUrl.isNotBlank() && !_serverUrls.contains(trimmedUrl)) {
+ _serverUrls.add(trimmedUrl)
+ saveUrls()
+ }
+ },
+ onRemoveServerUrl = { url -> _serverUrls.remove(url); saveUrls() },
+ providerName = _providerName.value,
+ onProviderNameChange = { _providerName.value = it; sharedPreferences.edit().putString(KEY_PROVIDER_NAME, it).apply() },
+ currentStatus = _currentStatus.value,
+ logMessages = _logMessages.value,
+ isServiceRunning = _isServiceRunningState.value,
+ onStartStopClick = { toggleService() },
+ onConfigureVoicesClick = { _showVoiceConfigDialog.value = true },
+ onConfigureEnginesClick = { _showEngineConfigDialog.value = true } // NEW
+ )
+ if (_showVoiceConfigDialog.value) {
+ VoiceConfigurationDialog(onDismiss = { _showVoiceConfigDialog.value = false }, starProviderService = starProviderService)
+ }
+ if (_showEngineConfigDialog.value) {
+ EngineConfigurationDialog(onDismiss = { _showEngineConfigDialog.value = false }, starProviderService = starProviderService)
+ }
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { requestNotificationPermission() }
+ }
+
+ private fun saveUrls() {
+ sharedPreferences.edit().putStringSet(KEY_SERVER_URLS, _serverUrls.toSet()).apply()
+ }
+
+ override fun onStart() {
+ super.onStart()
+ Intent(this, StarProviderService::class.java).also { intent ->
+ try {
+ bindService(intent, connection, Context.BIND_AUTO_CREATE)
+ } catch (e: SecurityException) {
+ Log.e("MainActivity", "Failed to bind to service", e)
+ _currentStatus.value = "Error: Cannot bind to service."
+ }
+ }
+ }
+
+ override fun onStop() {
+ super.onStop()
+ if (isBound) {
+ serviceBinder?.unregisterStateListener(this)
+ serviceBinder?.unregisterLogListener(this)
+ unbindService(connection)
+ isBound = false
+ }
+ }
+
+ private fun requestNotificationPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
+ private fun startProviderService() {
+ saveUrls() // Ensure latest URLs are saved before starting
+ sharedPreferences.edit().putString(KEY_PROVIDER_NAME, _providerName.value).apply()
+
+ // MODIFIED: Pass a list of URLs
+ val serviceIntent = Intent(this, StarProviderService::class.java).apply {
+ putStringArrayListExtra(StarProviderService.EXTRA_SERVER_URLS, ArrayList(_serverUrls))
+ putExtra("PROVIDER_NAME", _providerName.value)
+ }
+ try {
+ ContextCompat.startForegroundService(this, serviceIntent)
+ if (!isBound) {
+ bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE)
+ }
+ } catch (e: Exception) {
+ Log.e("MainActivity", "Error starting service", e)
+ _currentStatus.value = "Error: Could not start service. ${e.message}"
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is SecurityException) {
+ _currentStatus.value = "Error: FG Service start restricted by OS."
+ }
+ }
+ }
+
+ private fun stopProviderService() {
+ starProviderService?.stopServiceInternal() ?: run {
+ val serviceIntent = Intent(this, StarProviderService::class.java)
+ try {
+ stopService(serviceIntent)
+ Log.i("MainActivity", "stopService() called directly.")
+ } catch (e: Exception) {
+ Log.e("MainActivity", "Error calling stopService()", e)
+ }
+ }
+ if (!isBound) {
+ _currentStatus.value = "Status: Disconnected"
+ _isServiceRunningState.value = false
+ }
+ }
+
+ private fun toggleService() {
+ if (_isServiceRunningState.value) {
+ stopProviderService()
+ } else {
+ startProviderService()
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun StarProviderScreen(
+ serverUrls: List, onAddServerUrl: (String) -> Unit, onRemoveServerUrl: (String) -> Unit,
+ providerName: String, onProviderNameChange: (String) -> Unit,
+ currentStatus: String, logMessages: String, isServiceRunning: Boolean,
+ onStartStopClick: () -> Unit, onConfigureVoicesClick: () -> Unit, onConfigureEnginesClick: () -> Unit
+) {
+ val logScrollState = rememberScrollState()
+ val columnScrollState = rememberScrollState()
+ LaunchedEffect(logMessages) { logScrollState.animateScrollTo(logScrollState.maxValue) }
+
+ Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp)
+ .verticalScroll(columnScrollState),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text("STAR Android TTS Provider", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 16.dp))
+
+ Text("Coagulator Hosts", style = MaterialTheme.typography.titleMedium, modifier = Modifier.fillMaxWidth())
+ Spacer(Modifier.height(8.dp))
+ Column(Modifier.fillMaxWidth().heightIn(max = 150.dp)) {
+ LazyColumn(modifier = Modifier.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha=0.2f)).padding(horizontal = 8.dp).fillMaxWidth()) {
+ items(serverUrls, key = { it }) { url ->
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 4.dp)) {
+ Text(url, modifier = Modifier.weight(1f))
+ IconButton(onClick = { onRemoveServerUrl(url) }, modifier = Modifier.size(36.dp)) {
+ Icon(Icons.Default.Delete, contentDescription = "Remove host")
+ }
+ }
+ HorizontalDivider()
+ }
+ }
+ }
+ var newHostUrl by remember { mutableStateOf("ws://") }
+ val focusManager = LocalFocusManager.current
+ OutlinedTextField(
+ value = newHostUrl, onValueChange = { newHostUrl = it },
+ label = { Text("Add new host URL") }, singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { onAddServerUrl(newHostUrl); newHostUrl="ws://"; focusManager.clearFocus() }),
+ modifier = Modifier.fillMaxWidth().padding(top=4.dp),
+ trailingIcon = { IconButton(onClick = { onAddServerUrl(newHostUrl); newHostUrl="ws://"; focusManager.clearFocus() }) { Icon(Icons.Default.Add, "Add Host") } }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(value = providerName, onValueChange = onProviderNameChange, label = { Text("Provider Name") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Button(onClick = onStartStopClick, modifier = Modifier.weight(1f)) {
+ Text(if (isServiceRunning) "Stop Provider" else "Start Provider")
+ }
+ Spacer(Modifier.width(8.dp))
+ IconButton(onClick = onConfigureEnginesClick) { Icon(Icons.Filled.Build, contentDescription = "Configure Engines") }
+ IconButton(onClick = onConfigureVoicesClick) { Icon(Icons.Filled.Settings, contentDescription = "Configure Voices") }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp)); Text(currentStatus, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.fillMaxWidth())
+ Spacer(modifier = Modifier.height(16.dp)); Text("Logs:", style = MaterialTheme.typography.titleSmall, modifier = Modifier.fillMaxWidth())
+ Box(modifier = Modifier.fillMaxWidth().height(200.dp).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)).padding(8.dp)) {
+ Text(text = logMessages, modifier = Modifier.fillMaxSize().verticalScroll(logScrollState), style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp))
+ }
+ }
+ }
+}
+
+@Composable
+fun EngineConfigurationDialog(onDismiss: () -> Unit, starProviderService: StarProviderService?) {
+ var engineConfigItems by remember { mutableStateOf>(emptyList()) }
+ var isLoading by remember { mutableStateOf(true) }
+
+ LaunchedEffect(starProviderService) {
+ isLoading = true
+ if (starProviderService != null) {
+ // This can be called even if the service isn't "running" (connected) yet, as engine discovery happens on create.
+ engineConfigItems = starProviderService.getSystemEnginesForConfiguration()
+ }
+ isLoading = false
+ }
+
+ Dialog(onDismissRequest = onDismiss) {
+ Surface(modifier = Modifier.fillMaxWidth(0.95f).fillMaxHeight(0.85f), shape = MaterialTheme.shapes.medium, tonalElevation = AlertDialogDefaults.TonalElevation) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text("Configure TTS Engines", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 16.dp))
+ if (isLoading) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
+ } else if (engineConfigItems.isEmpty()) {
+ Text("No TTS engines found or service not ready.", modifier = Modifier.align(Alignment.CenterHorizontally).padding(16.dp))
+ } else {
+ LazyColumn(modifier = Modifier.weight(1f)) {
+ items(engineConfigItems, key = { it.packageName }) { item ->
+ Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(item.label, style = MaterialTheme.typography.bodyLarge)
+ Text(item.packageName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Switch(
+ checked = item.isEnabled,
+ onCheckedChange = { newEnabled ->
+ engineConfigItems = engineConfigItems.map {
+ if (it.packageName == item.packageName) it.copy(isEnabled = newEnabled) else it
+ }
+ }
+ )
+ }
+ HorizontalDivider()
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ TextButton(onClick = onDismiss) { Text("Cancel") }
+ Spacer(modifier = Modifier.width(8.dp))
+ Button(onClick = {
+ val configsToSave = engineConfigItems.associate { it.packageName to it.isEnabled }
+ starProviderService?.saveAndReloadEngineConfigs(configsToSave)
+ onDismiss()
+ }, enabled = !isLoading && engineConfigItems.isNotEmpty()) {
+ Text("Save & Apply")
+ }
+ }
+ }
+ }
+ }
+}
+
+
+data class VoiceConfigItem(
+ val id: String, // Composite ID format: "engineName:originalName"
+ val displayName: String,
+ var alias: String,
+ var isEnabled: Boolean,
+ val locale: String,
+ val isNetwork: Boolean
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun VoiceConfigurationDialog(
+ onDismiss: () -> Unit,
+ starProviderService: StarProviderService?
+) {
+ // *** START OF FIX ***
+ // REMOVED the `if (starProviderService?.isRunning() != true)` check.
+ // The dialog should open if the service is bound, not necessarily "running".
+ // The isLoading state handles the rest.
+
+ var voiceConfigItems by remember { mutableStateOf>(emptyList()) }
+ var isLoading by remember { mutableStateOf(true) }
+
+ // Simplified the LaunchedEffect key. It only needs to know about the service instance.
+ LaunchedEffect(starProviderService) {
+ isLoading = true
+ if (starProviderService != null) {
+ val systemVoicesData = starProviderService.getSystemVoicesForConfiguration()
+ voiceConfigItems = systemVoicesData.map { dialogInfo ->
+ val engineShortName = dialogInfo.engineName.split('.').lastOrNull() ?: dialogInfo.engineName
+ VoiceConfigItem(
+ id = "${dialogInfo.engineName}:${dialogInfo.originalName}",
+ displayName = "${dialogInfo.originalName} (${dialogInfo.locale.toLanguageTag()}, Engine: $engineShortName)",
+ alias = dialogInfo.currentAlias,
+ isEnabled = dialogInfo.currentIsEnabled,
+ locale = dialogInfo.locale.toLanguageTag(),
+ isNetwork = dialogInfo.isNetwork
+ )
+ }
+ }
+ isLoading = false
+ }
+
+ Dialog(onDismissRequest = onDismiss) {
+ Surface(modifier = Modifier.fillMaxWidth(0.95f).fillMaxHeight(0.85f), shape = MaterialTheme.shapes.medium, tonalElevation = AlertDialogDefaults.TonalElevation) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text("Configure Voices", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 16.dp))
+ if (isLoading) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
+ } else if (voiceConfigItems.isEmpty()) {
+ Text("No voices available. Check TTS engine configuration or logs.", modifier = Modifier.align(Alignment.CenterHorizontally).padding(16.dp))
+ } else {
+ LazyColumn(modifier = Modifier.weight(1f)) {
+ items(voiceConfigItems, key = { it.id }) { item ->
+ VoiceConfigRow(
+ item = item,
+ onAliasChange = { newAlias -> voiceConfigItems = voiceConfigItems.map { if (it.id == item.id) it.copy(alias = newAlias) else it } },
+ onEnabledChange = { newEnabled -> voiceConfigItems = voiceConfigItems.map { if (it.id == item.id) it.copy(isEnabled = newEnabled) else it } }
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ TextButton(onClick = onDismiss) { Text("Cancel") }
+ Spacer(modifier = Modifier.width(8.dp))
+ Button(
+ onClick = {
+ val configsToSave = voiceConfigItems.mapNotNull { item ->
+ val idParts = item.id.split(":", limit = 2)
+ if (idParts.size == 2) {
+ PersistedVoiceConfig(originalName = idParts[1], engineName = idParts[0], starLabel = item.alias, isEnabled = item.isEnabled)
+ } else {
+ Log.w("MainActivity", "Skipping invalid voice config item with ID: ${item.id}")
+ null
+ }
+ }
+ starProviderService?.savePersistedVoiceConfigs(configsToSave)
+ onDismiss()
+ },
+ enabled = !isLoading && voiceConfigItems.isNotEmpty()
+ ) { Text("Save & Apply") }
+ }
+ }
+ }
+ }
+ // *** END OF FIX ***
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun VoiceConfigRow(item: VoiceConfigItem, onAliasChange: (String) -> Unit, onEnabledChange: (Boolean) -> Unit) {
+ Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(item.displayName, style = MaterialTheme.typography.bodyLarge)
+ OutlinedTextField(
+ value = item.alias,
+ onValueChange = onAliasChange,
+ label = { Text("STAR Label (Alias)") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Switch(checked = item.isEnabled, onCheckedChange = onEnabledChange)
+ }
+}
\ No newline at end of file
diff --git a/provider/android/app/src/main/java/com/star/provider/starProviderService.kt b/provider/android/app/src/main/java/com/star/provider/starProviderService.kt
new file mode 100644
index 0000000..8d03fec
--- /dev/null
+++ b/provider/android/app/src/main/java/com/star/provider/starProviderService.kt
@@ -0,0 +1,785 @@
+package com.star.provider
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.os.Binder
+import android.os.Build
+import android.os.Bundle
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.speech.tts.TextToSpeech
+import android.speech.tts.UtteranceProgressListener
+import android.speech.tts.Voice
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import okhttp3.*
+import okhttp3.Credentials
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okio.ByteString
+import okio.ByteString.Companion.toByteString
+import org.json.JSONArray
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CopyOnWriteArrayList
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.math.pow
+import com.star.provider.R
+
+
+interface ServiceStateListener {
+ fun onStatusUpdate(status: String, isRunning: Boolean)
+}
+
+interface ServiceLogListener {
+ fun onLogMessage(message: String)
+}
+
+private data class CoagulatorConnection(
+ val hostUrl: String,
+ var webSocket: WebSocket? = null,
+ var status: String = "Idle",
+ var reconnectAttempts: Int = 0,
+ var isManuallyStopped: Boolean = false,
+ val handler: Handler = Handler(Looper.getMainLooper()),
+ val client: OkHttpClient
+)
+
+data class PersistedVoiceConfig(
+ val originalName: String,
+ val engineName: String,
+ var starLabel: String,
+ var isEnabled: Boolean
+)
+
+data class StarVoiceConfig(
+ val originalName: String,
+ val engineName: String,
+ val androidVoice: Voice,
+ var starLabel: String,
+ var isEnabled: Boolean = true,
+ val systemLocaleTag: String = androidVoice.locale.toLanguageTag(),
+ val systemIsNetwork: Boolean = androidVoice.isNetworkConnectionRequired
+)
+
+data class DialogVoiceInfo(
+ val originalName: String,
+ val engineName: String,
+ val locale: Locale,
+ val isNetwork: Boolean,
+ val currentAlias: String,
+ val currentIsEnabled: Boolean
+)
+
+data class DialogEngineInfo(
+ val packageName: String,
+ val label: String,
+ val isEnabled: Boolean
+)
+
+class StarProviderService : Service() {
+
+ private val binder = LocalBinder()
+ private val ttsEngines = mutableMapOf()
+ private val ttsInitializedEngines = mutableSetOf()
+ private var ttsDiscoveryInstance: TextToSpeech? = null
+ private var allEnginesDiscovered = false
+ private val engineLabels = mutableMapOf()
+ private var allSystemVoices: MutableList = mutableListOf()
+ private var activeStarVoices: Map = mapOf()
+ private val connections = ConcurrentHashMap()
+ private val synthesisQueue = ConcurrentHashMap()
+ private val utteranceContexts = ConcurrentHashMap()
+ private val NOTIFICATION_ID = 101
+ private val CHANNEL_ID = "StarProviderServiceChannel"
+ private var providerNameInternal: String = "AndroidProvider"
+ private val providerRevision = 4
+ private var currentStatus: String = "Service Idle"
+ private var isServiceCurrentlyRunning = false
+ private val canceledRequests = ConcurrentHashMap.newKeySet()
+ private var isManuallyStopped = false
+ private val stateListeners = CopyOnWriteArrayList()
+ private val logListeners = CopyOnWriteArrayList()
+ private lateinit var sharedPreferences: SharedPreferences
+ private val VOICE_CONFIG_PREF_KEY = "voice_configurations"
+ private val ENGINE_CONFIG_PREF_KEY = "engine_configurations"
+ private val LOG_FILE_NAME = "star_provider_service.log"
+ private val BACKUP_LOG_FILE_NAME = "star_provider_service.log.1"
+ private val MAX_LOG_SIZE_BYTES = 1 * 1024 * 1024
+ private var logFile: File? = null
+
+ private data class SynthesisRequest(
+ val starRequestId: String,
+ val text: String,
+ val voiceStarLabel: String,
+ val rate: Float,
+ val pitch: Float
+ )
+
+ inner class LocalBinder : Binder() {
+ fun getService(): StarProviderService = this@StarProviderService
+ fun registerStateListener(listener: ServiceStateListener) {
+ stateListeners.add(listener)
+ listener.onStatusUpdate(currentStatus, isServiceCurrentlyRunning)
+ }
+ fun unregisterStateListener(listener: ServiceStateListener) { stateListeners.remove(listener) }
+ fun registerLogListener(listener: ServiceLogListener) { logListeners.add(listener) }
+ fun unregisterLogListener(listener: ServiceLogListener) { logListeners.remove(listener) }
+ }
+
+ override fun onBind(intent: Intent): IBinder = binder
+
+ override fun onCreate() {
+ super.onCreate()
+ Log.d("StarProviderService", "onCreate")
+ sharedPreferences = getSharedPreferences("StarProviderPrefs", Context.MODE_PRIVATE)
+ initializeLogFile()
+ logToFile("Service onCreate")
+ ttsDiscoveryInstance = TextToSpeech(this) { status ->
+ if (status == TextToSpeech.SUCCESS) {
+ discoverAllSystemEnginesAndInitialize()
+ } else {
+ val errorMsg = "TTS Discovery Engine failed to initialize. Status: $status"
+ logToFile("CRITICAL: $errorMsg")
+ updateStatus(errorMsg, false); stopSelf()
+ }
+ }
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ Log.d("StarProviderService", "onStartCommand received")
+ isManuallyStopped = false
+ val hostUrls = intent?.getStringArrayListExtra(EXTRA_SERVER_URLS)
+ providerNameInternal = intent?.getStringExtra("PROVIDER_NAME") ?: "MyAndroidTTS"
+
+ if (hostUrls.isNullOrEmpty()) {
+ val errorMsg = "Error: Server URLs are missing."
+ logToFile(errorMsg); updateStatus(errorMsg, false); stopSelf()
+ return START_NOT_STICKY
+ }
+ logToFile("Service starting with ${hostUrls.size} hosts: ${hostUrls.joinToString()}")
+ startForegroundService()
+ isServiceCurrentlyRunning = true
+ updateStatus("Service Starting, Initializing...", true)
+
+ connections.clear()
+ hostUrls.forEach { url ->
+ val parseableUrl = url.replaceFirst("ws://", "http://", true).replaceFirst("wss://", "https://", true)
+ val httpUrl = parseableUrl.toHttpUrlOrNull()
+ if (httpUrl == null) {
+ logToActivity("Invalid host URL format: $url. Skipping.")
+ logToFile("ERROR: Invalid host URL format '$url'. Could not parse.")
+ return@forEach
+ }
+ val user = httpUrl.username
+ val pass = httpUrl.password
+ val clientBuilder = OkHttpClient.Builder().readTimeout(0, TimeUnit.MILLISECONDS).pingInterval(30, TimeUnit.SECONDS)
+ if (user.isNotBlank()) {
+ val credential = Credentials.basic(user, pass)
+ clientBuilder.authenticator { _, response -> response.request.newBuilder().header("Authorization", credential).build() }
+ }
+ connections[url] = CoagulatorConnection(hostUrl = url, client = clientBuilder.build())
+ logToFile("Configured connection for: $url")
+ }
+
+ if (connections.isEmpty()) {
+ val errorMsg = "No valid host URLs provided."
+ logToFile(errorMsg); updateStatus(errorMsg, false); stopSelf()
+ return START_NOT_STICKY
+ }
+
+ if (isTtsReady()) {
+ connections.keys.forEach { connectWebSocket(it) }
+ } else {
+ logToFile("TTS not ready, connections will be attempted after initialization.")
+ }
+ return START_STICKY
+ }
+
+ private fun startForegroundService() {
+ val notificationIntent = Intent(this, MainActivity::class.java)
+ val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_UPDATE_CURRENT
+ val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, pendingIntentFlags)
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("STAR Provider Active").setContentText("Initializing...")
+ .setSmallIcon(R.drawable.ic_stat_name).setContentIntent(pendingIntent)
+ .setOngoing(true).build()
+ startForeground(NOTIFICATION_ID, notification)
+ }
+
+ private fun discoverAllSystemEnginesAndInitialize() {
+ logToFile("Starting initial discovery of all system TTS engines...")
+ val allSystemEngineInfos = ttsDiscoveryInstance?.engines ?: run {
+ updateStatus("Error: Could not query TTS engines.", false); stopSelf(); return
+ }
+ if (allSystemEngineInfos.isEmpty()) {
+ updateStatus("No TTS engines found on this device.", false); stopSelf(); return
+ }
+ engineLabels.clear()
+ allSystemEngineInfos.forEach { engineInfo -> engineLabels[engineInfo.name] = engineInfo.label }
+ allEnginesDiscovered = true
+ logToFile("Discovered ${engineLabels.size} total engines: ${engineLabels.values.joinToString()}")
+ initializeEnabledEngines()
+ }
+
+ private fun initializeEnabledEngines(onComplete: (() -> Unit)? = null) {
+ logToFile("Initializing enabled engines...")
+ val enabledEngineConfigs = loadEngineConfigurations()
+ val enginesToInitialize = engineLabels.filter { (packageName, _) -> enabledEngineConfigs.getOrDefault(packageName, true) }
+ logToFile("Engines to be initialized: ${enginesToInitialize.values.joinToString()}")
+
+ if (enginesToInitialize.isEmpty()) {
+ logToActivity("No engines are enabled.")
+ populateAvailableVoices()
+ onComplete?.invoke()
+ return
+ }
+
+ val initializationCounter = AtomicInteger(enginesToInitialize.size)
+ enginesToInitialize.forEach { (enginePackageName, engineLabel) ->
+ try {
+ val ttsInstance = TextToSpeech(this, { status ->
+ if (status == TextToSpeech.SUCCESS) {
+ ttsInitializedEngines.add(enginePackageName)
+ ttsEngines[enginePackageName]?.setOnUtteranceProgressListener(StarUtteranceListener())
+ logToFile("TTS Engine initialized successfully: $engineLabel")
+ } else {
+ logToFile("TTS Engine failed to initialize: $engineLabel, Status: $status")
+ }
+ if (initializationCounter.decrementAndGet() == 0) {
+ logToFile("All requested engines have finished initialization process.")
+ populateAvailableVoices()
+ onComplete?.invoke()
+ }
+ }, enginePackageName)
+ ttsEngines[enginePackageName] = ttsInstance
+ } catch (e: Exception) {
+ logToFile("Failed to instantiate TTS engine $engineLabel: ${e.message}")
+ if (initializationCounter.decrementAndGet() == 0) {
+ logToFile("All requested engines have finished initialization process (with errors).")
+ populateAvailableVoices()
+ onComplete?.invoke()
+ }
+ }
+ }
+ }
+
+ fun saveAndReloadEngineConfigs(newConfigs: Map) {
+ // 1. Save the new configuration. This is now the single source of truth.
+ sharedPreferences.edit().putString(ENGINE_CONFIG_PREF_KEY, JSONObject(newConfigs as Map<*, *>).toString()).apply()
+ logToFile("Saved updated engine configurations. Performing full state refresh.")
+ logToActivity("Applying new engine configuration...")
+
+ // 2. Define the action to take AFTER the new state is fully initialized.
+ val onRefreshComplete = {
+ logToActivity("Engine refresh complete. Repopulating voice list from active engines.")
+ populateAvailableVoices() // This will now use the new, correct list of running engines.
+
+ // The original code re-registered on the existing connection, which the Python
+ // coagulator does not handle as a "refresh". We must force a disconnect/reconnect.
+ logToActivity("Forcing reconnection to servers to apply new engine configuration.")
+ val hostsToReconnect = connections.keys.toList() // Iterate over a copy
+ hostsToReconnect.forEach { hostUrl ->
+ connections[hostUrl]?.let { conn ->
+ conn.webSocket?.close(1000, "Re-registering new engine configuration")
+ conn.webSocket = null
+ conn.handler.removeCallbacksAndMessages(null)
+ conn.reconnectAttempts = 0
+ logToFile("Force-reconnecting to $hostUrl to apply engine changes.")
+ connectWebSocket(hostUrl)
+ }
+ }
+ }
+
+ // 3. SHUT DOWN ALL currently running TTS engines. This is the crucial "tear down" step.
+ // It ensures we start from a completely clean slate, preventing any state corruption.
+ logToFile("Shutting down all current TTS engines before re-initialization.")
+ // We iterate over a copy of the values to avoid concurrent modification issues.
+ ttsEngines.values.toList().forEach { engine ->
+ try {
+ // IMPORTANT: We do NOT clear engineLabels. This is the master list
+ // for the configuration dialog and must be preserved.
+ engine.shutdown()
+ } catch (e: Exception) {
+ logToFile("Error during engine shutdown: ${e.message}")
+ }
+ }
+ // Clear the maps/sets that track the RUNNING state.
+ ttsEngines.clear()
+ ttsInitializedEngines.clear()
+ logToFile("All running engines shut down and active state cleared.")
+
+ // 4. Re-initialize from scratch using the new configuration we just saved.
+ // The `initializeEnabledEngines` function is perfect for this. We pass our
+ // final action as the completion handler.
+ logToFile("Starting re-initialization of enabled engines...")
+ initializeEnabledEngines(onComplete = onRefreshComplete)
+ }
+
+ fun getSystemEnginesForConfiguration(): List {
+ if (!allEnginesDiscovered) return emptyList()
+ val persistedConfigs = loadEngineConfigurations()
+ return engineLabels.map { (packageName, label) ->
+ DialogEngineInfo(packageName, label, persistedConfigs.getOrDefault(packageName, true))
+ }.sortedBy { it.label }
+ }
+
+ private fun loadEngineConfigurations(): Map {
+ val jsonString = sharedPreferences.getString(ENGINE_CONFIG_PREF_KEY, null)
+ return if (jsonString != null) {
+ try {
+ val json = JSONObject(jsonString)
+ json.keys().asSequence().associateWith { key -> json.getBoolean(key) }
+ } catch (e: Exception) {
+ Log.e("StarProviderService", "Error parsing engine configurations", e); emptyMap()
+ }
+ } else { emptyMap() }
+ }
+
+ fun getSystemVoicesForConfiguration(): List {
+ if (!isTtsReady() && ttsInitializedEngines.isEmpty()) return emptyList()
+ val persistedConfigs = loadVoiceConfigurations().associateBy { "${it.engineName}:${it.originalName}" }
+ return ttsInitializedEngines.flatMap { engineName ->
+ val tts = ttsEngines[engineName] ?: return@flatMap emptyList()
+ try {
+ tts.voices?.mapNotNull { voice ->
+ val configKey = "$engineName:${voice.name}"
+ val persisted = persistedConfigs[configKey]
+ val shortEngineName = getShortEngineName(engineName)
+ val defaultAlias = "${shortEngineName}_${voice.name}".replace(Regex("[^a-zA-Z0-9_\\-]"), "_")
+ DialogVoiceInfo(voice.name, engineName, voice.locale, voice.isNetworkConnectionRequired,
+ persisted?.starLabel ?: defaultAlias, persisted?.isEnabled ?: true)
+ } ?: emptyList()
+ } catch (e: Exception) {
+ Log.e("StarProviderService", "Error getting voices for engine $engineName: ${e.message}", e); emptyList()
+ }
+ }
+ }
+
+ fun savePersistedVoiceConfigs(configs: List) {
+ sharedPreferences.edit().putString(VOICE_CONFIG_PREF_KEY, convertPersistedVoiceConfigListToJson(configs)).apply()
+ logToFile("Saved updated voice configurations from MainActivity.")
+ logToActivity("Applying new voice settings...")
+
+ // First, update the service's internal list of what voices *should* be active.
+ populateAvailableVoices()
+
+ // The original code re-registered on the existing connection. However, the Python
+ // coagulator does not clear the old voice list on re-registration; it only
+ // clears voices when a provider disconnects.
+ // The correct logic, to mimic the Python provider being restarted, is to
+ // force a disconnect and reconnect. This makes the coagulator drop the old
+ // voice list and receive a fresh one on the new connection.
+
+ logToActivity("Forcing reconnection to servers to apply new voice list.")
+ val hostsToReconnect = connections.keys.toList() // Avoid ConcurrentModificationException by iterating over a copy.
+ hostsToReconnect.forEach { hostUrl ->
+ connections[hostUrl]?.let { conn ->
+ // Close the existing socket cleanly.
+ conn.webSocket?.close(1000, "Re-registering new voice configuration")
+ conn.webSocket = null // Ensure the connection is seen as closed immediately.
+
+ // Clear any pending automatic reconnect tasks to avoid conflicts.
+ conn.handler.removeCallbacksAndMessages(null)
+
+ // Reset the reconnect backoff counter for a clean slate.
+ conn.reconnectAttempts = 0
+
+ // Initiate a new connection attempt immediately.
+ logToFile("Force-reconnecting to $hostUrl to apply voice changes.")
+ connectWebSocket(hostUrl)
+ }
+ }
+ }
+
+ private fun loadVoiceConfigurations(): List {
+ val jsonString = sharedPreferences.getString(VOICE_CONFIG_PREF_KEY, null)
+ return if (jsonString != null) {
+ try { parsePersistedVoiceConfigListFromJson(jsonString)
+ } catch (e: Exception) { Log.e("StarProviderService", "Error parsing voice configurations from JSON", e); emptyList() }
+ } else { emptyList() }
+ }
+
+ private fun convertPersistedVoiceConfigListToJson(configs: List): String {
+ val jsonArray = JSONArray()
+ configs.forEach { config ->
+ val jsonObj = JSONObject()
+ jsonObj.put("originalName", config.originalName); jsonObj.put("engineName", config.engineName)
+ jsonObj.put("starLabel", config.starLabel); jsonObj.put("isEnabled", config.isEnabled)
+ jsonArray.put(jsonObj)
+ }
+ return jsonArray.toString()
+ }
+
+ private fun parsePersistedVoiceConfigListFromJson(jsonString: String): List {
+ val configs = mutableListOf()
+ try {
+ val jsonArray = JSONArray(jsonString)
+ for (i in 0 until jsonArray.length()) {
+ val jsonObj = jsonArray.getJSONObject(i)
+ configs.add(PersistedVoiceConfig(
+ originalName = jsonObj.getString("originalName"), engineName = jsonObj.getString("engineName"),
+ starLabel = jsonObj.getString("starLabel"), isEnabled = jsonObj.getBoolean("isEnabled")
+ ))
+ }
+ } catch (e: JSONException) { Log.e("StarProviderService", "Manual JSON parsing error for voice configs: ${e.message}", e) }
+ return configs
+ }
+
+ private fun getShortEngineName(engineName: String): String {
+ return when {
+ engineName.contains("google") -> "google"
+ engineName.contains("rhvoice") -> "rhvoice"
+ engineName.contains("espeak") -> "espeak"
+ engineName.contains("dectalk") -> "dectalk"
+ engineName.contains("samsung") -> "samsung"
+ else -> engineName.split(".").lastOrNull() ?: "eng"
+ }
+ }
+
+ private fun populateAvailableVoices() {
+ allSystemVoices.clear()
+ val persistedVoiceConfigs = loadVoiceConfigurations().associateBy { "${it.engineName}:${it.originalName}" }
+ var voiceDetailsLog = "Populating available voices based on active engines...\n"
+ val activeEngines = ttsInitializedEngines.toList()
+ voiceDetailsLog += "Active initialized engines: ${activeEngines.joinToString { engineLabels[it] ?: it }}\n"
+ activeEngines.forEach { engineName ->
+ val tts = ttsEngines[engineName] ?: return@forEach
+ val engineLabel = engineLabels[engineName] ?: "Unknown"
+ try {
+ tts.voices?.forEach { voice ->
+ val configKey = "$engineName:${voice.name}"
+ val persistedConfig = persistedVoiceConfigs[configKey]
+ val shortEngineName = getShortEngineName(engineName)
+ val defaultStarLabel = "${shortEngineName}_${voice.name}".replace(Regex("[^a-zA-Z0-9_\\-]"), "_")
+ val starLabel = persistedConfig?.starLabel ?: defaultStarLabel
+ val isEnabled = persistedConfig?.isEnabled ?: true
+ val voiceConfig = StarVoiceConfig(originalName = voice.name, engineName = engineName, androidVoice = voice, starLabel = starLabel, isEnabled = isEnabled)
+ allSystemVoices.add(voiceConfig)
+ voiceDetailsLog += " Found voice: ${voice.name} (Engine: $engineLabel), STAR Label: ${voiceConfig.starLabel}, Enabled: ${voiceConfig.isEnabled}\n"
+ }
+ } catch (e: Exception) {
+ voiceDetailsLog += " Error populating voices for $engineName: ${e.message}\n"
+ }
+ }
+ val finalActiveVoices = mutableMapOf()
+ val usedLabels = mutableSetOf()
+ allSystemVoices.filter { it.isEnabled }.forEach { config ->
+ var currentLabel = config.starLabel; var counter = 1
+ while(usedLabels.contains(currentLabel)) { currentLabel = "${config.starLabel}_${counter++}" }
+ usedLabels.add(currentLabel)
+ finalActiveVoices[currentLabel] = config.copy(starLabel = currentLabel)
+ }
+ activeStarVoices = finalActiveVoices
+ logToFile(voiceDetailsLog.trim())
+ if (activeStarVoices.isEmpty()) {
+ logToActivity("Warning: No active TTS voices found or configured.")
+ }
+ updateCombinedStatus()
+ }
+
+ private fun connectWebSocket(hostUrl: String) {
+ val connection = connections[hostUrl]
+ if (connection == null) { logToFile("Error: Attempted to connect to an unconfigured host: $hostUrl"); return }
+ if (connection.webSocket != null) { logToActivity("WebSocket for $hostUrl already connected or connecting."); return }
+ val connectMsg = "Attempting to connect to: $hostUrl (Attempt: ${connection.reconnectAttempts + 1})"
+ logToFile(connectMsg)
+ val request = Request.Builder().url(hostUrl).build()
+ connection.client.newWebSocket(request, StarWebSocketListener(hostUrl))
+ connection.status = "Connecting... (Attempt ${connection.reconnectAttempts + 1})"
+ updateCombinedStatus()
+ }
+
+ private fun scheduleReconnect(hostUrl: String) {
+ val connection = connections[hostUrl] ?: return
+ val maxReconnectAttempts = 10
+ val initialReconnectDelayMs = 1000L
+ if (connection.isManuallyStopped || connection.reconnectAttempts >= maxReconnectAttempts) {
+ logToFile("Max reconnects for $hostUrl or manually stopped. Giving up.")
+ connection.status = "Disconnected (Max retries)"; updateCombinedStatus(); return
+ }
+ val delay = (initialReconnectDelayMs * 2.0.pow(connection.reconnectAttempts.toDouble())).toLong()
+ connection.reconnectAttempts++
+ logToFile("Scheduling reconnect for $hostUrl in ${delay}ms.")
+ logToActivity("Connection to $hostUrl lost. Reconnecting in ${delay/1000}s...")
+ connection.status = "Reconnecting (Attempt ${connection.reconnectAttempts})..."
+ updateCombinedStatus()
+ connection.handler.postDelayed({
+ if (!connection.isManuallyStopped) {
+ logToFile("Executing reconnect for $hostUrl.")
+ connectWebSocket(hostUrl)
+ } else {
+ logToFile("Reconnect for $hostUrl cancelled.")
+ }
+ }, delay)
+ }
+
+ private fun registerWithCoagulator(webSocket: WebSocket) {
+ if (!isTtsReady() && activeStarVoices.isEmpty()) {
+ logToActivity("TTS not fully ready, but sending available voices (${activeStarVoices.size})")
+ }
+ val registrationPayload = JSONObject().apply {
+ put("provider", providerRevision)
+ put("provider_name", providerNameInternal)
+ put("voices", JSONArray(activeStarVoices.keys.toList()))
+ }.toString()
+ logToFile("TX Registration to ${webSocket.request().url}: $registrationPayload")
+ val sent = webSocket.send(registrationPayload)
+ if (sent) { logToActivity("Registration sent to ${webSocket.request().url.host}.") }
+ else { logToActivity("Failed to send registration to ${webSocket.request().url.host}.") }
+ }
+
+ private inner class StarWebSocketListener(private val hostUrl: String) : WebSocketListener() {
+ override fun onOpen(webSocket: WebSocket, response: Response) {
+ val connection = connections[hostUrl] ?: return
+ connection.webSocket = webSocket; connection.reconnectAttempts = 0; connection.status = "Connected"
+ val msg = "WebSocket Connected to $hostUrl"
+ Log.i("StarProviderService", msg); logToActivity(msg); updateCombinedStatus(); registerWithCoagulator(webSocket)
+ }
+ override fun onMessage(webSocket: WebSocket, text: String) {
+ Log.d("StarProviderService", "RX Text from ${webSocket.request().url.host}: $text"); logToActivity("RX: $text")
+ try {
+ val json = JSONObject(text)
+ if (json.has("abort")) {
+ val requestIdToCancel = json.optString("abort"); if (requestIdToCancel.isNotEmpty()) {
+ canceledRequests.add(requestIdToCancel); logToActivity("Abort request for ID: $requestIdToCancel.")
+ }; return
+ }
+ if (!json.has("voice") || !json.has("text") || !json.has("id")) {
+ logToActivity("Invalid request (missing fields): $text"); return
+ }
+ handleSynthesisRequest(json.getString("voice"), json.getString("text"), json.getString("id"),
+ json.optDouble("rate", 1.0).toFloat(), json.optDouble("pitch", 1.0).toFloat(), webSocket)
+ } catch (e: JSONException) { logToActivity("Error parsing JSON from server: ${e.message}") }
+ }
+ override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
+ val msg = "WebSocket Closing for $hostUrl: $code / $reason"
+ Log.i("StarProviderService", msg); logToActivity(msg); cleanupWebSocket(hostUrl)
+ if (code != 1000 && !(connections[hostUrl]?.isManuallyStopped ?: false)) { scheduleReconnect(hostUrl)
+ } else { connections[hostUrl]?.status = "Disconnected"; updateCombinedStatus() }
+ }
+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
+ val msg = "WebSocket Failure for $hostUrl: ${t.message}"
+ Log.e("StarProviderService", msg, t); logToActivity(msg); cleanupWebSocket(hostUrl)
+ if (!(connections[hostUrl]?.isManuallyStopped ?: false)) { scheduleReconnect(hostUrl)
+ } else { connections[hostUrl]?.status = "Disconnected (Failure)"; updateCombinedStatus() }
+ }
+ override fun onMessage(webSocket: WebSocket, bytes: ByteString) { val msg = "RX Binary from ${webSocket.request().url.host}: ${bytes.hex()}"; Log.d("StarProviderService", msg); logToActivity(msg) }
+ }
+
+ private fun cleanupWebSocket(hostUrl: String) {
+ logToFile("Cleaning up WebSocket for $hostUrl.")
+ val connection = connections[hostUrl]; connection?.webSocket?.close(1000, "Client closing"); connection?.webSocket = null; updateCombinedStatus()
+ }
+
+ private fun handleSynthesisRequest(voiceStarLabel: String, textToSpeak: String, starRequestId: String, rate: Float, pitch: Float, sourceSocket: WebSocket) {
+ logToFile("Handling synthesis ID: $starRequestId from ${sourceSocket.request().url.host}, Voice: $voiceStarLabel, Text: \"${textToSpeak.take(50)}...\"")
+ if (canceledRequests.contains(starRequestId)) {
+ canceledRequests.remove(starRequestId); logToActivity("Synthesis for ID $starRequestId canceled by client before starting.")
+ sendError(starRequestId, "Request canceled by client.", sourceSocket); return
+ }
+ if (!isTtsReady()) { sendError(starRequestId, "TTS not ready on Android provider for request $starRequestId.", sourceSocket); return }
+ val voiceConfig = activeStarVoices[voiceStarLabel]
+ if (voiceConfig == null) {
+ val errorMsg = "Voice '$voiceStarLabel' not found or not active. Active: ${activeStarVoices.keys.joinToString()}"
+ sendError(starRequestId, errorMsg, sourceSocket); logToActivity(errorMsg); return
+ }
+ val tts = ttsEngines[voiceConfig.engineName]
+ if (tts == null) {
+ val errorMsg = "TTS engine '${voiceConfig.engineName}' for voice '$voiceStarLabel' is not available or failed to initialize."
+ sendError(starRequestId, errorMsg, sourceSocket); logToActivity(errorMsg); return
+ }
+ val utteranceId = UUID.randomUUID().toString()
+ synthesisQueue[utteranceId] = SynthesisRequest(starRequestId, textToSpeak, voiceStarLabel, rate, pitch)
+ utteranceContexts[utteranceId] = sourceSocket
+ tts.voice = voiceConfig.androidVoice; tts.setSpeechRate(rate.coerceIn(0.1f, 4.0f)); tts.setPitch(pitch.coerceIn(0.1f, 4.0f))
+ val tempAudioFile = File(cacheDir, "$utteranceId.wav")
+ logToActivity("Synthesizing for $starRequestId (utt: $utteranceId) using Engine: ${engineLabels[voiceConfig.engineName] ?: "Unknown"}, Voice: ${voiceConfig.androidVoice.name}")
+ val result = tts.synthesizeToFile(textToSpeak, Bundle(), tempAudioFile, utteranceId)
+ if (result != TextToSpeech.SUCCESS) {
+ val errorMsg = "TTS synthesizeToFile failed for $utteranceId (STAR ID: $starRequestId). Code: $result"
+ Log.e("StarProviderService", errorMsg); logToFile(errorMsg)
+ synthesisQueue.remove(utteranceId); utteranceContexts.remove(utteranceId)
+ sendError(starRequestId, "Android TTS synthesis command failed (code: $result).", sourceSocket)
+ tempAudioFile.delete()
+ }
+ }
+
+ private inner class StarUtteranceListener : UtteranceProgressListener() {
+ override fun onStart(utteranceId: String?) {
+ Log.d("TTSListener", "Utterance started: $utteranceId")
+ synthesisQueue[utteranceId]?.let { logToActivity("TTS synthesis started for STAR ID: ${it.starRequestId} (Utt: $utteranceId)") }
+ }
+ override fun onDone(utteranceId: String?) {
+ Log.d("TTSListener", "Utterance done: $utteranceId")
+ val requestDetails = synthesisQueue.remove(utteranceId)
+ val sourceSocket = utteranceContexts.remove(utteranceId)
+ if (utteranceId != null && requestDetails != null && sourceSocket != null) {
+ if (canceledRequests.contains(requestDetails.starRequestId)) {
+ canceledRequests.remove(requestDetails.starRequestId)
+ logToActivity("Synthesis for ID ${requestDetails.starRequestId} completed but was canceled. Audio not sent.")
+ File(cacheDir, "$utteranceId.wav").delete(); return
+ }
+ val audioFile = File(cacheDir, "$utteranceId.wav")
+ if (audioFile.exists() && audioFile.length() > 0) {
+ logToActivity("TTS synthesis done for ${requestDetails.starRequestId}, sending audio (${audioFile.length()} bytes).")
+ sendAudioData(requestDetails.starRequestId, audioFile, sourceSocket)
+ } else {
+ sendError(requestDetails.starRequestId, "Synthesized audio file not found or was empty.", sourceSocket)
+ }
+ audioFile.delete()
+ }
+ }
+ override fun onError(utteranceId: String?, errorCode: Int) {
+ val errorDetail = ttsErrorToString(errorCode)
+ Log.e("TTSListener", "TTSListener: onError for $utteranceId, code: $errorCode ($errorDetail)")
+ handleTtsError(utteranceId, errorDetail)
+ }
+ private fun handleTtsError(utteranceId: String?, errorMessage: String) {
+ val requestDetails = synthesisQueue.remove(utteranceId)
+ val sourceSocket = utteranceContexts.remove(utteranceId)
+ if (utteranceId != null && requestDetails != null && sourceSocket != null) {
+ if (!canceledRequests.remove(requestDetails.starRequestId)) {
+ val detailedError = "Android TTS Error for ${requestDetails.starRequestId}: $errorMessage"
+ sendError(requestDetails.starRequestId, detailedError, sourceSocket)
+ }
+ }
+ File(cacheDir, "$utteranceId.wav").delete()
+ }
+ @Deprecated("deprecated", replaceWith = ReplaceWith("onError(utteranceId, errorCode)"))
+ override fun onError(utteranceId: String?) { onError(utteranceId, -1) }
+ private fun ttsErrorToString(errorCode: Int): String = when (errorCode) {
+ TextToSpeech.ERROR_SYNTHESIS -> "Synthesis error"; TextToSpeech.ERROR_SERVICE -> "Service error (TTS engine)"
+ TextToSpeech.ERROR_OUTPUT -> "Output error"; TextToSpeech.ERROR_NETWORK -> "Network error"
+ TextToSpeech.ERROR_NETWORK_TIMEOUT -> "Network timeout"; TextToSpeech.ERROR_INVALID_REQUEST -> "Invalid request"
+ TextToSpeech.ERROR_NOT_INSTALLED_YET -> "TTS data not installed yet"; else -> "Unknown TTS error ($errorCode)"
+ }
+ }
+
+ private fun sendAudioData(starRequestId: String, audioFile: File, destinationSocket: WebSocket) {
+ val audioBytes = audioFile.readBytes()
+ val metadataJsonString = JSONObject().apply { put("id", starRequestId); put("extension", "wav") }.toString()
+ val metadataBytes = metadataJsonString.toByteArray(Charsets.UTF_8)
+ val metadataLength = metadataBytes.size.toShort()
+ val packetBuffer = ByteBuffer.allocate(2 + metadataBytes.size + audioBytes.size)
+ packetBuffer.order(ByteOrder.LITTLE_ENDIAN); packetBuffer.putShort(metadataLength); packetBuffer.put(metadataBytes); packetBuffer.put(audioBytes)
+ val combinedPacket = packetBuffer.array().toByteString(0, packetBuffer.position())
+ val sent = destinationSocket.send(combinedPacket)
+ if (sent) logToActivity("Sent audio for $starRequestId to ${destinationSocket.request().url.host}")
+ else logToActivity("Failed to send audio packet for $starRequestId to ${destinationSocket.request().url.host}")
+ }
+
+ private fun sendError(starRequestId: String, errorMessage: String, destinationSocket: WebSocket) {
+ val errorPayload = JSONObject().apply {
+ put("provider", providerRevision); put("id", starRequestId)
+ put("status", errorMessage.take(200)); put("abort", true)
+ }.toString()
+ logToFile("TX Error for $starRequestId to ${destinationSocket.request().url.host}: $errorMessage")
+ destinationSocket.send(errorPayload)
+ }
+
+ private fun updateCombinedStatus() {
+ val connectedCount = connections.values.count { it.webSocket != null && it.status == "Connected" }
+ val totalCount = connections.size
+ val isAnyConnecting = connections.values.any { it.status.contains("Connecting") || it.status.contains("Reconnecting") }
+ val summary = if (totalCount > 0) "Connected to $connectedCount of $totalCount hosts. Voices: ${activeStarVoices.size}"
+ else "No hosts configured. Voices: ${activeStarVoices.size}"
+ val isRunning = connectedCount > 0 || isAnyConnecting
+ updateStatus(summary, isRunning)
+ }
+
+ private fun updateStatus(newStatus: String, isRunning: Boolean) {
+ currentStatus = newStatus; isServiceCurrentlyRunning = isRunning
+ Log.i("StarProviderService", "Status Update: $newStatus, IsRunning: $isRunning")
+ updateNotification(newStatus); stateListeners.forEach { it.onStatusUpdate(newStatus, isRunning) }
+ }
+
+ internal fun stopServiceInternal() {
+ isManuallyStopped = true
+ logToActivity("Service stop requested by MainActivity.")
+ connections.values.forEach { it.isManuallyStopped = true; it.handler.removeCallbacksAndMessages(null); cleanupWebSocket(it.hostUrl) }
+ connections.clear(); ttsEngines.values.forEach { try { it.stop() } catch (e: Exception) { /* ignore */ } }
+ stopForeground(STOP_FOREGROUND_REMOVE); stopSelf()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ isManuallyStopped = true; updateStatus("Service Stopped", false)
+ logToFile("Service onDestroy. Cleaning up all resources.")
+ ttsDiscoveryInstance?.shutdown()
+ ttsEngines.values.forEach { try { it.shutdown() } catch(e: Exception) { /* ignore */ } }
+ ttsEngines.clear(); ttsInitializedEngines.clear()
+ connections.values.forEach { it.handler.removeCallbacksAndMessages(null) }
+ connections.clear()
+ logToFile("--- Log session ended: ${getCurrentTimestamp()} ---", false)
+ stateListeners.clear(); logListeners.clear()
+ }
+
+ companion object { const val EXTRA_SERVER_URLS = "com.star.provider.EXTRA_SERVER_URLS" }
+ private fun isTtsReady(): Boolean = ttsInitializedEngines.isNotEmpty() || activeStarVoices.isNotEmpty()
+ fun isRunning(): Boolean = isServiceCurrentlyRunning
+ fun requestCurrentStatus() { stateListeners.forEach { it.onStatusUpdate(currentStatus, isServiceCurrentlyRunning) } }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val serviceChannel = NotificationChannel(CHANNEL_ID, "STAR Provider Service", NotificationManager.IMPORTANCE_LOW)
+ getSystemService(NotificationManager::class.java)?.createNotificationChannel(serviceChannel);
+ }
+ }
+
+ private fun updateNotification(message: String) {
+ val notificationIntent = Intent(this, MainActivity::class.java)
+ val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_UPDATE_CURRENT
+ val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, pendingIntentFlags)
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("STAR Provider").setContentText(message)
+ .setSmallIcon(R.drawable.ic_stat_name).setContentIntent(pendingIntent)
+ .setOngoing(true).setOnlyAlertOnce(true).build()
+ (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification)
+ }
+
+ private fun logToActivity(message: String) { Log.d("StarProviderServiceLog", message); logToFile(message); logListeners.forEach { it.onLogMessage(message) } }
+
+ private fun initializeLogFile() {
+ try {
+ val logsDir = File(getExternalFilesDir(null), "logs"); if (!logsDir.exists()) logsDir.mkdirs()
+ logFile = File(logsDir, LOG_FILE_NAME); if (!logFile!!.exists()) { logFile!!.createNewFile() }
+ logToFile("--- Log session started: ${getCurrentTimestamp()} ---", false)
+ } catch (e: Exception) { Log.e("StarProviderService", "Error initializing log file", e); logFile = null }
+ }
+
+ private fun getCurrentTimestamp(): String = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()).format(Date())
+
+ private fun logToFile(message: String, includeTimestamp: Boolean = true) {
+ if (logFile == null) initializeLogFile()
+ logFile?.let { file ->
+ try {
+ if (file.length() > MAX_LOG_SIZE_BYTES) {
+ val backupFile = File(file.parentFile, BACKUP_LOG_FILE_NAME); if (backupFile.exists()) backupFile.delete()
+ file.renameTo(backupFile); if (file.createNewFile()) {
+ FileOutputStream(file, true).bufferedWriter().use { it.append("${getCurrentTimestamp()} - Log file rotated.\n") }
+ }
+ }
+ FileOutputStream(file, true).bufferedWriter().use { it.append(if(includeTimestamp) "${getCurrentTimestamp()} - $message\n" else "$message\n") }
+ } catch (e: IOException) { Log.e("StarProviderService", "Error writing to log file", e) }
+ }
+ }
+}
\ No newline at end of file
diff --git a/provider/android/app/src/main/java/com/star/provider/ui/theme/Color.kt b/provider/android/app/src/main/java/com/star/provider/ui/theme/Color.kt
new file mode 100644
index 0000000..975c557
--- /dev/null
+++ b/provider/android/app/src/main/java/com/star/provider/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.star.provider.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/provider/android/app/src/main/java/com/star/provider/ui/theme/Theme.kt b/provider/android/app/src/main/java/com/star/provider/ui/theme/Theme.kt
new file mode 100644
index 0000000..900d590
--- /dev/null
+++ b/provider/android/app/src/main/java/com/star/provider/ui/theme/Theme.kt
@@ -0,0 +1,58 @@
+package com.star.provider.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun StarProviderTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/provider/android/app/src/main/java/com/star/provider/ui/theme/Type.kt b/provider/android/app/src/main/java/com/star/provider/ui/theme/Type.kt
new file mode 100644
index 0000000..3119680
--- /dev/null
+++ b/provider/android/app/src/main/java/com/star/provider/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package com.star.provider.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/provider/android/app/src/main/res/drawable/ic_launcher_background.xml b/provider/android/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/provider/android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/provider/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/provider/android/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/provider/android/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/provider/android/app/src/main/res/drawable/ic_stat_name.xml b/provider/android/app/src/main/res/drawable/ic_stat_name.xml
new file mode 100644
index 0000000..1670c07
--- /dev/null
+++ b/provider/android/app/src/main/res/drawable/ic_stat_name.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/provider/android/app/src/main/res/drawable/ic_voice_config.xml b/provider/android/app/src/main/res/drawable/ic_voice_config.xml
new file mode 100644
index 0000000..1670c07
--- /dev/null
+++ b/provider/android/app/src/main/res/drawable/ic_voice_config.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/provider/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/provider/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/provider/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/provider/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/provider/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/provider/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/provider/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/provider/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/provider/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/provider/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/provider/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/provider/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/provider/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/provider/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/provider/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/provider/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/provider/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/provider/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/provider/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/provider/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/provider/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/provider/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/provider/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/provider/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/provider/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/provider/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/provider/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/provider/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/provider/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/provider/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/provider/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/provider/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/provider/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/provider/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/provider/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/provider/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/provider/android/app/src/main/res/values/colors.xml b/provider/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f8c6127
--- /dev/null
+++ b/provider/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/provider/android/app/src/main/res/values/strings.xml b/provider/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..867e083
--- /dev/null
+++ b/provider/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ star provider
+
\ No newline at end of file
diff --git a/provider/android/app/src/main/res/values/themes.xml b/provider/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..e65b4e1
--- /dev/null
+++ b/provider/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/provider/android/app/src/main/res/xml/backup_rules.xml b/provider/android/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..fa0f996
--- /dev/null
+++ b/provider/android/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/provider/android/app/src/main/res/xml/data_extraction_rules.xml b/provider/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/provider/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/provider/android/app/src/test/java/com/star/provider/ExampleUnitTest.kt b/provider/android/app/src/test/java/com/star/provider/ExampleUnitTest.kt
new file mode 100644
index 0000000..5aef7bc
--- /dev/null
+++ b/provider/android/app/src/test/java/com/star/provider/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.star.provider
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/provider/android/build.gradle.kts b/provider/android/build.gradle.kts
new file mode 100644
index 0000000..f955773
--- /dev/null
+++ b/provider/android/build.gradle.kts
@@ -0,0 +1,33 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+// build.gradle.kts (Project: YourProjectRoot)
+
+plugins {
+ // It's common to declare these plugins here with 'apply false'
+ // so that modules can apply them. The actual plugin versions are
+ // managed in your libs.versions.toml file.
+
+ // Assuming these aliases are defined in your libs.versions.toml
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ // alias(libs.plugins.android.library) apply false // Add if you plan to have library modules
+}
+
+// Optional: Define tasks or configurations common to all projects
+// For example, a common clean task (though modern AGP handles this well)
+/*
+tasks.register("clean", Delete::class) {
+ delete(rootProject.buildDir)
+}
+*/
+
+// It's also common to configure repositories for all projects here,
+// though this can also be in settings.gradle.kts's dependencyResolutionManagement
+/*
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ // Add other repositories here if needed, e.g., JitPack
+ }
+}
+*/
\ No newline at end of file
diff --git a/provider/android/building.md b/provider/android/building.md
new file mode 100644
index 0000000..02673a4
--- /dev/null
+++ b/provider/android/building.md
@@ -0,0 +1,10 @@
+# building the android provider.
+## requirements
+you will need the android sdk, jdk 17, and an active internet connection to download gradle files.
+There are a few environment variables you need to set before building. These are the `ANDROID_HOME`, and `JAVA_HOME` variables which help locate the Android development tools for anything that needs them.
+## actually building the provider.
+the provider is very easy to build, here are the stepps.
+1. clone the star repo: git clone https://github.com/samtupy/star
+2. cd to the provider/android directory within the star repo
+3. if you are on windows, run gradlew assembledebug to build.
+On platforms other than Windows you may need to run `./gradlew` instead of `gradlew`.
\ No newline at end of file
diff --git a/provider/android/gradle.properties b/provider/android/gradle.properties
new file mode 100644
index 0000000..20e2a01
--- /dev/null
+++ b/provider/android/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/provider/android/gradle/libs.versions.toml b/provider/android/gradle/libs.versions.toml
new file mode 100644
index 0000000..2f6c8cb
--- /dev/null
+++ b/provider/android/gradle/libs.versions.toml
@@ -0,0 +1,45 @@
+[versions]
+agp = "8.6.0"
+kotlin = "1.9.10" # For Kotlin 1.9.0, a common Compose Compiler version is 1.5.1 or 1.5.2. Double check compatibility.
+# composeCompiler = "1.5.1" # Example: Add this line. Check compatibility with Kotlin 1.9.0
+coreKtx = "1.15.0"
+lifecycleRuntimeKtx = "2.8.7"
+activityCompose = "1.10.0"
+composeBom = "2024.04.01" # This BOM will manage versions for androidx-ui, -material3 etc.
+
+junit = "4.13.2"
+junitVersion = "1.2.1" # This is for androidx.test.ext:junit
+espressoCore = "3.6.1"
+
+# Added for STAR Provider
+okhttp = "4.12.0" # Or latest stable
+orgJson = "20231013" # Or latest stable
+
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" } # Version managed by BOM
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } # Version managed by BOM
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } # Version managed by BOM, for debug
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } # Version managed by BOM
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } # Version managed by BOM, for debug
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } # Version managed by BOM, for androidTest
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" } # Version managed by BOM
+
+junit = { group = "junit", name = "junit", version.ref = "junit" } # For unit tests
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } # For instrumented tests
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } # For instrumented tests
+
+# Added for STAR Provider
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+org-json = { group = "org.json", name = "json", version.ref = "orgJson" }
+
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+# If you want to manage the compose compiler plugin explicitly via TOML (optional for modern setups)
+# kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } # Version usually same as kotlin plugin
\ No newline at end of file
diff --git a/provider/android/gradle/wrapper/gradle-wrapper.jar b/provider/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e644113
Binary files /dev/null and b/provider/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/provider/android/gradle/wrapper/gradle-wrapper.properties b/provider/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..b82aa23
--- /dev/null
+++ b/provider/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.7-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/provider/android/gradlew b/provider/android/gradlew
new file mode 100644
index 0000000..1aa94a4
--- /dev/null
+++ b/provider/android/gradlew
@@ -0,0 +1,249 @@
+#!/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.
+#
+
+##############################################################################
+#
+# 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/subprojects/plugins/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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || 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/provider/android/gradlew.bat b/provider/android/gradlew.bat
new file mode 100644
index 0000000..25da30d
--- /dev/null
+++ b/provider/android/gradlew.bat
@@ -0,0 +1,92 @@
+@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
+
+@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/provider/android/settings.gradle.kts b/provider/android/settings.gradle.kts
new file mode 100644
index 0000000..9f857ce
--- /dev/null
+++ b/provider/android/settings.gradle.kts
@@ -0,0 +1,31 @@
+// settings.gradle.kts
+
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal() // For Gradle plugins
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) // Recommended practice
+ repositories {
+ google()
+ mavenCentral()
+ // Add other repositories here if needed, e.g., JitPack
+ // maven { url = uri("https://jitpack.io") }
+ }
+ // This enables the version catalog feature for your project
+ // versionCatalogs {
+ // create("libs") {
+ // from(files("gradle/libs.versions.toml"))
+ // }
+ // }
+ // For newer Gradle versions (7.4+), enabling version catalogs might be automatic if libs.versions.toml exists,
+ // or more simply done via:
+ // Or may not be needed if libs.versions.toml is present
+}
+
+rootProject.name = "StarProvider" // Or your desired project name
+include(":app") // Includes your 'app' module
\ No newline at end of file