diff --git a/app/build.gradle b/app/build.gradle index b84fdeb..d097304 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation 'org.nanohttpd:nanohttpd:2.2.0' implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' implementation 'androidx.preference:preference:1.1.1' + implementation 'com.github.vmichalak:sonos-controller:v.0.1' testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3fec34c..e5da728 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,7 +19,8 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/AppTheme"> + android:theme="@style/AppTheme" + android:usesCleartextTraffic="true"> diff --git a/app/src/main/java/io/bartek/ttsserver/tts/TTS.kt b/app/src/main/java/io/bartek/ttsserver/tts/TTS.kt index 80c86d4..0724a05 100644 --- a/app/src/main/java/io/bartek/ttsserver/tts/TTS.kt +++ b/app/src/main/java/io/bartek/ttsserver/tts/TTS.kt @@ -5,14 +5,45 @@ import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener import io.bartek.ttsserver.exception.TTSException import java.io.BufferedInputStream +import java.io.File import java.io.FileInputStream import java.io.InputStream +import java.security.MessageDigest import java.util.* data class SpeechData(val stream: InputStream, val size: Long) -class TTS(context: Context, initListener: TextToSpeech.OnInitListener) { +class TTS(private val context: Context, initListener: TextToSpeech.OnInitListener) { private val tts = TextToSpeech(context, initListener) + private val messageDigest = MessageDigest.getInstance("SHA-256") + + fun createTTSFile(text: String, language: Locale): File { + val digest = hash(text, language) + val filename = "tts_$digest.wav" + val file = File(context.cacheDir, filename) + + val uuid = UUID.randomUUID().toString() + val lock = Lock() + tts.setOnUtteranceProgressListener(TTSProcessListener(uuid, lock)) + + synchronized(lock) { + tts.language = language + tts.synthesizeToFile(text, null, file, uuid) + lock.wait() + } + + if(!lock.success) { + throw TTSException() + } + + return file + } + + private fun hash(text: String, language: Locale): String { + val bytes = "$text$language".toByteArray() + val digest = messageDigest.digest(bytes) + return digest.fold("", { str, it -> str + "%02x".format(it) }) + } fun fetchTTSStream(text: String, language: Locale): SpeechData { val file = createTempFile("tmp_tts_server", ".wav") diff --git a/app/src/main/java/io/bartek/ttsserver/web/Endpoints.kt b/app/src/main/java/io/bartek/ttsserver/web/Endpoints.kt new file mode 100644 index 0000000..7f213fc --- /dev/null +++ b/app/src/main/java/io/bartek/ttsserver/web/Endpoints.kt @@ -0,0 +1,29 @@ +package io.bartek.ttsserver.web + +import android.content.UriMatcher +import android.net.Uri + +enum class Endpoint(val uri: String, val id: Int) { + UNKNOWN("/", 1), + SAY("/say", 2), + WAVE("/wave", 3), + SONOS("/sonos", 4), + SONOS_CACHE("/sonos/*", 5); + + companion object { + fun of(id: Int) = values().firstOrNull { it.id == id } ?: UNKNOWN + } +} + +class Endpoints { + private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH) + + init { + Endpoint.values().forEach { + uriMatcher.addURI("", it.uri, it.id) + } + } + + fun match(uri: String) = + Endpoint.of(uriMatcher.match(Uri.parse("content://$uri"))) +} \ No newline at end of file diff --git a/app/src/main/java/io/bartek/ttsserver/web/WebServer.kt b/app/src/main/java/io/bartek/ttsserver/web/WebServer.kt index af48c11..b1adcef 100644 --- a/app/src/main/java/io/bartek/ttsserver/web/WebServer.kt +++ b/app/src/main/java/io/bartek/ttsserver/web/WebServer.kt @@ -2,6 +2,7 @@ package io.bartek.ttsserver.web import android.content.Context import android.content.Intent +import android.net.Uri import android.speech.tts.TextToSpeech import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.preference.PreferenceManager @@ -12,6 +13,9 @@ import io.bartek.ttsserver.service.ForegroundService import io.bartek.ttsserver.service.ServiceState import io.bartek.ttsserver.tts.TTS import org.json.JSONObject +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream import java.util.* private data class TTSRequestData(val text: String, val language: Locale) @@ -21,14 +25,17 @@ class WebServer(port: Int, private val context: Context) : NanoHTTPD(port), TextToSpeech.OnInitListener { private val preferences = PreferenceManager.getDefaultSharedPreferences(context) private val tts = TTS(context, this) + private val endpoints = Endpoints() override fun serve(session: IHTTPSession?): Response { try { session?.let { - return when (it.uri) { - "/wave" -> wave(it) - "/say" -> say(it) - else -> throw ResponseException(NOT_FOUND, "") + return when (endpoints.match(it.uri)) { + Endpoint.SAY -> say(it) + Endpoint.WAVE -> wave(it) + Endpoint.SONOS -> sonos(it) + Endpoint.SONOS_CACHE -> sonosCache(it) + Endpoint.UNKNOWN -> throw ResponseException(NOT_FOUND, "") } } @@ -40,6 +47,40 @@ class WebServer(port: Int, private val context: Context) : NanoHTTPD(port), } } + private fun sonos(session: IHTTPSession): Response { +// SonosDiscovery.discover().firstOrNull { it.zoneGroupState.name == "Salon" } +// ?.play() + + if (session.method != Method.POST) { + throw ResponseException(METHOD_NOT_ALLOWED, "") + } + + if (session.headers[CONTENT_TYPE]?.let { it != MIME_JSON } != false) { + throw ResponseException(BAD_REQUEST, "") + } + + val (text, language) = getRequestData(session) + val file = tts.createTTSFile(text, language) + return newFixedLengthResponse(file.toString()) + } + + private fun sonosCache(session: IHTTPSession): Response { + if (session.method != Method.GET) { + throw ResponseException(METHOD_NOT_ALLOWED, "") + } + + val filename = Uri.parse(session.uri).lastPathSegment ?: throw ResponseException(BAD_REQUEST, "") + val file = File(context.cacheDir, filename) + + if(!file.exists()) { + throw ResponseException(NOT_FOUND, "") + } + + val stream = BufferedInputStream(FileInputStream(file)) + val size = file.length() + return newFixedLengthResponse(OK, MIME_WAVE, stream, size) + } + private fun wave(session: IHTTPSession): Response { if (!preferences.getBoolean(PreferenceKey.ENABLE_WAVE_ENDPOINT, true)) { throw ResponseException(NOT_FOUND, "") diff --git a/build.gradle b/build.gradle index 84b783e..cb9ba6a 100644 --- a/build.gradle +++ b/build.gradle @@ -20,7 +20,7 @@ allprojects { repositories { google() jcenter() - + maven { url 'https://jitpack.io' } } }