Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Currently Dicio answers questions about:
- **media**: play, pause, previous, next song
- **translation**: translate from/to any language with **Lingva** - _How do I say Football in German?_
- **wake word control**: turn on/off the wakeword - _Stop listening_
- **unit conversion**: convert currencies, distances, volumes, mass, and more - _Convert 5 liters to gallons_
- **cryptocurrency prices**: get the latest price of Bitcoin, Ethereum, Cardano, and more - _What is the price of Bitcoin?_

## Speech to text

Expand Down
4 changes: 4 additions & 0 deletions app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import org.stypox.dicio.skills.timer.TimerInfo
import org.stypox.dicio.skills.translation.TranslationInfo
import org.stypox.dicio.skills.weather.WeatherInfo
import org.stypox.dicio.skills.joke.JokeInfo
import org.stypox.dicio.skills.unit_conversion.UnitConversionInfo
import org.stypox.dicio.skills.crypto_price.CryptoPriceInfo
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -55,6 +57,8 @@ class SkillHandler @Inject constructor(
JokeInfo,
ListeningInfo(dataStore),
TranslationInfo,
UnitConversionInfo,
CryptoPriceInfo,
)

private val fallbackSkillInfoList = listOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.stypox.dicio.skills.crypto_price

import android.content.Context
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import org.dicio.skill.context.SkillContext
import org.dicio.skill.skill.Skill
import org.dicio.skill.skill.SkillInfo
import org.stypox.dicio.R
import org.stypox.dicio.sentences.Sentences

data object CryptoPriceInfo : SkillInfo("crypto_price") {
override fun name(context: Context) =
context.getString(R.string.skill_name_crypto_price)

override fun sentenceExample(context: Context) =
context.getString(R.string.skill_sentence_example_crypto_price)

@Composable
override fun icon() =
rememberVectorPainter(Icons.Default.CurrencyBitcoin)

override fun isAvailable(ctx: SkillContext): Boolean =
Sentences.CryptoPrice[ctx.sentencesLanguage] != null

override fun build(ctx: SkillContext): Skill<*> =
CryptoPriceSkill(
correspondingSkillInfo = this,
data = Sentences.CryptoPrice[ctx.sentencesLanguage]!!
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package org.stypox.dicio.skills.crypto_price

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.dicio.skill.context.SkillContext
import org.dicio.skill.skill.SkillOutput
import org.stypox.dicio.R
import org.stypox.dicio.io.graphical.Body
import org.stypox.dicio.io.graphical.Headline
import org.stypox.dicio.io.graphical.HeadlineSpeechSkillOutput
import org.stypox.dicio.io.graphical.Subtitle
import org.stypox.dicio.util.getString

sealed interface CryptoPriceOutput : SkillOutput {
data class Success(
val cryptoName: String,
val cryptoSymbol: String,
val price: String
) : CryptoPriceOutput {
override fun getSpeechOutput(ctx: SkillContext): String {
return ctx.getString(R.string.skill_crypto_price_result, cryptoName, formatPrice(price))
}

@Composable
override fun GraphicalOutput(ctx: SkillContext) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Headline(text = "$cryptoName ($cryptoSymbol)")
Spacer(modifier = Modifier.height(4.dp))
Headline(text = "$${formatPrice(price)} USD")
}
}

private fun formatPrice(price: String): String {
val priceValue = price.toDoubleOrNull() ?: return price
val decimalPlaces = price.substringAfter('.', "").length

return if (decimalPlaces < 2) {
String.format("%.2f", priceValue)
} else {
price
}
}
}

data class UnknownCryptocurrency(
val crypto: String,
val errorMessage: String
) : CryptoPriceOutput, HeadlineSpeechSkillOutput {
override fun getSpeechOutput(ctx: SkillContext): String = errorMessage
}

data class NetworkError(
val errorMessage: String
) : CryptoPriceOutput, HeadlineSpeechSkillOutput {
override fun getSpeechOutput(ctx: SkillContext): String = errorMessage
}

data class InvalidResponse(
val errorMessage: String
) : CryptoPriceOutput, HeadlineSpeechSkillOutput {
override fun getSpeechOutput(ctx: SkillContext): String = errorMessage
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package org.stypox.dicio.skills.crypto_price

import org.dicio.skill.context.SkillContext
import org.dicio.skill.skill.SkillInfo
import org.dicio.skill.skill.SkillOutput
import org.dicio.skill.standard.StandardRecognizerData
import org.dicio.skill.standard.StandardRecognizerSkill
import org.json.JSONException
import org.stypox.dicio.R
import org.stypox.dicio.sentences.Sentences.CryptoPrice
import org.stypox.dicio.util.ConnectionUtils
import java.io.IOException

class CryptoPriceSkill(
correspondingSkillInfo: SkillInfo,
data: StandardRecognizerData<CryptoPrice>
) : StandardRecognizerSkill<CryptoPrice>(correspondingSkillInfo, data) {

data class Cryptocurrency(
val symbol: String,
val name: String
)

companion object {
private val SUPPORTED_CRYPTOCURRENCIES = listOf(
Cryptocurrency("BTC", "Bitcoin"),
Cryptocurrency("ETH", "Ethereum"),
Cryptocurrency("LTC", "Litecoin"),
Cryptocurrency("XRP", "Ripple"),
Cryptocurrency("SOL", "Solana"),
Cryptocurrency("DOGE", "Dogecoin"),
Cryptocurrency("ADA", "Cardano")
)
}

override suspend fun generateOutput(
ctx: SkillContext,
inputData: CryptoPrice
): SkillOutput {
return when (inputData) {
is CryptoPrice.Price -> {
val cryptoInput = inputData.crypto?.trim() ?: ""

if (cryptoInput.isBlank()) {
return CryptoPriceOutput.UnknownCryptocurrency(
crypto = "",
errorMessage = ctx.android.getString(R.string.skill_crypto_price_unknown_crypto, "")
)
}

// Find matching cryptocurrency
val crypto = findCryptocurrency(cryptoInput)
if (crypto == null) {
return CryptoPriceOutput.UnknownCryptocurrency(
crypto = cryptoInput,
errorMessage = ctx.android.getString(R.string.skill_crypto_price_unknown_crypto, cryptoInput)
)
}

// Fetch price from OKX API
fetchCryptoPrice(ctx, crypto)
}
}
}

private fun findCryptocurrency(input: String): Cryptocurrency? {
val normalizedInput = input.lowercase().trim()

// First try exact match
val exactMatch = SUPPORTED_CRYPTOCURRENCIES.find { crypto ->
crypto.symbol.lowercase() == normalizedInput ||
crypto.name.lowercase() == normalizedInput
}
if (exactMatch != null) return exactMatch

// Try fuzzy matching for STT errors (e.g., "like coin" -> "litecoin")
val inputNoSpaces = normalizedInput.replace(" ", "")

// Find best match using Levenshtein distance
var bestMatch: Cryptocurrency? = null
var bestScore = Int.MAX_VALUE

for (crypto in SUPPORTED_CRYPTOCURRENCIES) {
val nameNoSpaces = crypto.name.lowercase().replace(" ", "")
val symbolNoSpaces = crypto.symbol.lowercase().replace(" ", "")

val nameDistance = levenshteinDistance(inputNoSpaces, nameNoSpaces)
val symbolDistance = levenshteinDistance(inputNoSpaces, symbolNoSpaces)
val minDistance = minOf(nameDistance, symbolDistance)

// Accept if distance is small enough (allow ~50% error rate for STT corrections)
val maxLen = maxOf(inputNoSpaces.length, nameNoSpaces.length, symbolNoSpaces.length)
val threshold = (maxLen * 0.5).toInt().coerceAtLeast(3)
if (minDistance < bestScore && minDistance <= threshold) {
bestScore = minDistance
bestMatch = crypto
}
}

return bestMatch
}

private fun levenshteinDistance(s1: String, s2: String): Int {
val m = s1.length
val n = s2.length
val dp = Array(m + 1) { IntArray(n + 1) }

for (i in 0..m) dp[i][0] = i
for (j in 0..n) dp[0][j] = j

for (i in 1..m) {
for (j in 1..n) {
val cost = if (s1[i - 1] == s2[j - 1]) 0 else 1
dp[i][j] = minOf(
dp[i - 1][j] + 1, // deletion
dp[i][j - 1] + 1, // insertion
dp[i - 1][j - 1] + cost // substitution
)
}
}

return dp[m][n]
}

private fun fetchCryptoPrice(ctx: SkillContext, crypto: Cryptocurrency): CryptoPriceOutput {
return try {
val url = "https://www.okx.com/api/v5/market/ticker?instId=${crypto.symbol}-USD"
val json = ConnectionUtils.getPageJson(url)

// Validate response structure
if (!json.has("code") || !json.has("data")) {
return CryptoPriceOutput.InvalidResponse(
errorMessage = ctx.android.getString(R.string.skill_crypto_price_invalid_response)
)
}

val code = json.getString("code")
if (code != "0") {
return CryptoPriceOutput.InvalidResponse(
errorMessage = ctx.android.getString(R.string.skill_crypto_price_invalid_response)
)
}

val dataArray = json.getJSONArray("data")
if (dataArray.length() == 0) {
return CryptoPriceOutput.InvalidResponse(
errorMessage = ctx.android.getString(R.string.skill_crypto_price_invalid_response)
)
}

val tickerData = dataArray.getJSONObject(0)
if (!tickerData.has("last")) {
return CryptoPriceOutput.InvalidResponse(
errorMessage = ctx.android.getString(R.string.skill_crypto_price_invalid_response)
)
}

val price = tickerData.getString("last")

// Validate that price is a valid number
try {
price.toDouble()
} catch (e: NumberFormatException) {
return CryptoPriceOutput.InvalidResponse(
errorMessage = ctx.android.getString(R.string.skill_crypto_price_invalid_response)
)
}

CryptoPriceOutput.Success(
cryptoName = crypto.name,
cryptoSymbol = crypto.symbol,
price = price
)

} catch (e: IOException) {
CryptoPriceOutput.NetworkError(
errorMessage = ctx.android.getString(R.string.skill_crypto_price_network_error)
)
} catch (e: JSONException) {
CryptoPriceOutput.InvalidResponse(
errorMessage = ctx.android.getString(R.string.skill_crypto_price_invalid_response)
)
}
}
}
Loading