diff --git a/README.md b/README.md index cb3b8fe0..97d2a4f5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt b/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt index 5c276aeb..f44bde90 100644 --- a/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt +++ b/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt @@ -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 @@ -55,6 +57,8 @@ class SkillHandler @Inject constructor( JokeInfo, ListeningInfo(dataStore), TranslationInfo, + UnitConversionInfo, + CryptoPriceInfo, ) private val fallbackSkillInfoList = listOf( diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/crypto_price/CryptoPriceInfo.kt b/app/src/main/kotlin/org/stypox/dicio/skills/crypto_price/CryptoPriceInfo.kt new file mode 100644 index 00000000..70905690 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/crypto_price/CryptoPriceInfo.kt @@ -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]!! + ) +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/crypto_price/CryptoPriceOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/crypto_price/CryptoPriceOutput.kt new file mode 100644 index 00000000..af3897f5 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/crypto_price/CryptoPriceOutput.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/crypto_price/CryptoPriceSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/crypto_price/CryptoPriceSkill.kt new file mode 100644 index 00000000..dcd8a0a5 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/crypto_price/CryptoPriceSkill.kt @@ -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 +) : StandardRecognizerSkill(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) + ) + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/unit_conversion/Unit.kt b/app/src/main/kotlin/org/stypox/dicio/skills/unit_conversion/Unit.kt new file mode 100644 index 00000000..a26a094c --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/unit_conversion/Unit.kt @@ -0,0 +1,374 @@ +package org.stypox.dicio.skills.unit_conversion + +import android.content.res.Resources +import android.icu.text.MeasureFormat +import android.icu.util.Measure +import android.icu.util.MeasureUnit +import android.icu.util.ULocale +import java.util.Currency +import java.util.Locale + +enum class UnitType { + LENGTH, MASS, TEMPERATURE, VOLUME, AREA, SPEED, TIME, + DIGITAL_STORAGE, ENERGY, POWER, PRESSURE, ANGLE, CURRENCY, + FREQUENCY, ACCELERATION, CONSUMPTION, DURATION +} + +enum class Unit(val type: UnitType) { + // LENGTH + PICOMETER(UnitType.LENGTH), NANOMETER(UnitType.LENGTH), MICROMETER(UnitType.LENGTH), + MILLIMETER(UnitType.LENGTH), CENTIMETER(UnitType.LENGTH), DECIMETER(UnitType.LENGTH), + METER(UnitType.LENGTH), KILOMETER(UnitType.LENGTH), + INCH(UnitType.LENGTH), FOOT(UnitType.LENGTH), YARD(UnitType.LENGTH), + FATHOM(UnitType.LENGTH), FURLONG(UnitType.LENGTH), MILE(UnitType.LENGTH), + MILE_SCANDINAVIAN(UnitType.LENGTH), NAUTICAL_MILE(UnitType.LENGTH), + LIGHT_YEAR(UnitType.LENGTH), ASTRONOMICAL_UNIT(UnitType.LENGTH), PARSEC(UnitType.LENGTH), + + // MASS + MICROGRAM(UnitType.MASS), MILLIGRAM(UnitType.MASS), GRAM(UnitType.MASS), + KILOGRAM(UnitType.MASS), METRIC_TON(UnitType.MASS), TONNE(UnitType.MASS), + OUNCE(UnitType.MASS), POUND(UnitType.MASS), STONE(UnitType.MASS), + TON(UnitType.MASS), OUNCE_TROY(UnitType.MASS), CARAT(UnitType.MASS), + + // TEMPERATURE + CELSIUS(UnitType.TEMPERATURE), FAHRENHEIT(UnitType.TEMPERATURE), KELVIN(UnitType.TEMPERATURE), + + // VOLUME + MILLILITER(UnitType.VOLUME), CENTILITER(UnitType.VOLUME), DECILITER(UnitType.VOLUME), + LITER(UnitType.VOLUME), HECTOLITER(UnitType.VOLUME), MEGALITER(UnitType.VOLUME), + CUBIC_CENTIMETER(UnitType.VOLUME), CUBIC_METER(UnitType.VOLUME), CUBIC_KILOMETER(UnitType.VOLUME), + CUBIC_INCH(UnitType.VOLUME), CUBIC_FOOT(UnitType.VOLUME), CUBIC_YARD(UnitType.VOLUME), CUBIC_MILE(UnitType.VOLUME), + TEASPOON(UnitType.VOLUME), TABLESPOON(UnitType.VOLUME), FLUID_OUNCE(UnitType.VOLUME), + CUP(UnitType.VOLUME), CUP_METRIC(UnitType.VOLUME), + PINT(UnitType.VOLUME), PINT_METRIC(UnitType.VOLUME), + QUART(UnitType.VOLUME), GALLON(UnitType.VOLUME), GALLON_IMPERIAL(UnitType.VOLUME), + BUSHEL(UnitType.VOLUME), ACRE_FOOT(UnitType.VOLUME), + + // AREA + SQUARE_CENTIMETER(UnitType.AREA), SQUARE_METER(UnitType.AREA), SQUARE_KILOMETER(UnitType.AREA), + HECTARE(UnitType.AREA), SQUARE_INCH(UnitType.AREA), SQUARE_FOOT(UnitType.AREA), + SQUARE_YARD(UnitType.AREA), ACRE(UnitType.AREA), SQUARE_MILE(UnitType.AREA), + + // SPEED + METER_PER_SECOND(UnitType.SPEED), KILOMETER_PER_HOUR(UnitType.SPEED), + MILE_PER_HOUR(UnitType.SPEED), KNOT(UnitType.SPEED), + + // TIME + NANOSECOND(UnitType.TIME), MICROSECOND(UnitType.TIME), MILLISECOND(UnitType.TIME), + SECOND(UnitType.TIME), MINUTE(UnitType.TIME), HOUR(UnitType.TIME), + DAY(UnitType.TIME), WEEK(UnitType.TIME), MONTH(UnitType.TIME), + YEAR(UnitType.TIME), DECADE(UnitType.TIME), CENTURY(UnitType.TIME), + + // DIGITAL STORAGE + BIT(UnitType.DIGITAL_STORAGE), BYTE(UnitType.DIGITAL_STORAGE), + KILOBIT(UnitType.DIGITAL_STORAGE), KILOBYTE(UnitType.DIGITAL_STORAGE), + MEGABIT(UnitType.DIGITAL_STORAGE), MEGABYTE(UnitType.DIGITAL_STORAGE), + GIGABIT(UnitType.DIGITAL_STORAGE), GIGABYTE(UnitType.DIGITAL_STORAGE), + TERABIT(UnitType.DIGITAL_STORAGE), TERABYTE(UnitType.DIGITAL_STORAGE), + PETABYTE(UnitType.DIGITAL_STORAGE), + + // ENERGY + JOULE(UnitType.ENERGY), KILOJOULE(UnitType.ENERGY), + CALORIE(UnitType.ENERGY), KILOCALORIE(UnitType.ENERGY), FOODCALORIE(UnitType.ENERGY), + KILOWATT_HOUR(UnitType.ENERGY), + + // POWER + MILLIWATT(UnitType.POWER), WATT(UnitType.POWER), KILOWATT(UnitType.POWER), + MEGAWATT(UnitType.POWER), GIGAWATT(UnitType.POWER), HORSEPOWER(UnitType.POWER), + + // PRESSURE + HECTOPASCAL(UnitType.PRESSURE), MILLIBAR(UnitType.PRESSURE), + ATMOSPHERE(UnitType.PRESSURE), POUND_PER_SQUARE_INCH(UnitType.PRESSURE), + INCH_HG(UnitType.PRESSURE), MILLIMETER_OF_MERCURY(UnitType.PRESSURE), + + // ANGLE + ARC_SECOND(UnitType.ANGLE), ARC_MINUTE(UnitType.ANGLE), + DEGREE(UnitType.ANGLE), RADIAN(UnitType.ANGLE), REVOLUTION_ANGLE(UnitType.ANGLE), + + // FREQUENCY + HERTZ(UnitType.FREQUENCY), KILOHERTZ(UnitType.FREQUENCY), + MEGAHERTZ(UnitType.FREQUENCY), GIGAHERTZ(UnitType.FREQUENCY), + + // ACCELERATION + METER_PER_SECOND_SQUARED(UnitType.ACCELERATION), G_FORCE(UnitType.ACCELERATION), + + // CURRENCY + USD(UnitType.CURRENCY), EUR(UnitType.CURRENCY), GBP(UnitType.CURRENCY), + JPY(UnitType.CURRENCY), CHF(UnitType.CURRENCY), CAD(UnitType.CURRENCY), + AUD(UnitType.CURRENCY), NZD(UnitType.CURRENCY), CNY(UnitType.CURRENCY), + INR(UnitType.CURRENCY), KRW(UnitType.CURRENCY), BRL(UnitType.CURRENCY), + ZAR(UnitType.CURRENCY), MXN(UnitType.CURRENCY), SGD(UnitType.CURRENCY), + HKD(UnitType.CURRENCY), SEK(UnitType.CURRENCY), NOK(UnitType.CURRENCY), + DKK(UnitType.CURRENCY), PLN(UnitType.CURRENCY), CZK(UnitType.CURRENCY), + HUF(UnitType.CURRENCY), RON(UnitType.CURRENCY), BGN(UnitType.CURRENCY), + TRY(UnitType.CURRENCY), ILS(UnitType.CURRENCY), THB(UnitType.CURRENCY), + IDR(UnitType.CURRENCY), MYR(UnitType.CURRENCY), PHP(UnitType.CURRENCY), + ISK(UnitType.CURRENCY); + + /** Returns ISO 4217 currency code or null for non-currency units. */ + val currencyCode: String? get() = if (type == UnitType.CURRENCY) name else null + + /** Returns corresponding MeasureUnit or null for currency units. */ + val measureUnit: MeasureUnit? get() = if (type == UnitType.CURRENCY) null else try { + MeasureUnit::class.java.getField(name).get(null) as? MeasureUnit + } catch (e: Exception) { null } + + companion object { + fun findUnit(text: String, resources: Resources): Unit? { + val normalizedText = text.lowercase().trim() + val locale = ULocale.forLocale(resources.configuration.locales[0]) + + return values().firstOrNull { unit -> + // Try MeasureUnit matching (non-currency) + unit.measureUnit?.let { measureUnit -> + listOf( + MeasureFormat.FormatWidth.SHORT, + MeasureFormat.FormatWidth.NARROW, + MeasureFormat.FormatWidth.WIDE + ).any { width -> + val format = MeasureFormat.getInstance(locale, width) + val unitName = format.getUnitDisplayName(measureUnit).lowercase() + + // Try exact match first + if (unitName == normalizedText) { + return@any true + } + + // Check if unit name appears as a word in the text + val regex = "\\b${Regex.escape(unitName)}\\b".toRegex() + if (regex.containsMatchIn(normalizedText)) { + return@any true + } + + // Also check formatted measures for singular and plural forms + val singularForm = format.format(Measure(1.0, measureUnit)).lowercase() + val pluralForm = format.format(Measure(2.0, measureUnit)).lowercase() + + // Extract just the unit part (remove the number) + val singularUnit = singularForm.replace(Regex("^[\\d.,\\s]+"), "").trim() + val pluralUnit = pluralForm.replace(Regex("^[\\d.,\\s]+"), "").trim() + + listOf(singularUnit, pluralUnit).any { formattedUnit -> + if (formattedUnit.isNotEmpty()) { + val formRegex = "\\b${Regex.escape(formattedUnit)}\\b".toRegex() + formRegex.containsMatchIn(normalizedText) + } else false + } + } + } ?: + // Try Currency matching + unit.currencyCode?.let { code -> + runCatching { + val currency = Currency.getInstance(code) + val javaLocale = Locale.forLanguageTag(locale.toLanguageTag()) + val displayName = currency.getDisplayName(javaLocale).lowercase() + val symbol = currency.symbol.lowercase() + val currencyCode = currency.currencyCode.lowercase() + + // Check exact match or word boundary match for display name, symbol, and code + val namesToCheck = mutableListOf(displayName, symbol, currencyCode) + + // Add common plural forms + if (displayName.isNotEmpty()) { + namesToCheck.add(displayName + "s") // e.g., "euro" -> "euros" + } + + namesToCheck.any { name -> + name == normalizedText || + "\\b${Regex.escape(name)}\\b".toRegex().containsMatchIn(normalizedText) + } + }.getOrDefault(false) + } ?: false + } + } + + fun convert(value: Double, from: Unit, to: Unit): Double? { + if (from.type != to.type) return null + + // Handle temperature conversions specially (non-linear) + if (from.type == UnitType.TEMPERATURE) { + return convertTemperature(value, from, to) + } + + // Get conversion factors to base units + val fromFactor = getConversionFactor(from) ?: return null + val toFactor = getConversionFactor(to) ?: return null + + // Convert: value * (from factor / to factor) + return value * fromFactor / toFactor + } + + private fun convertTemperature(value: Double, from: Unit, to: Unit): Double? { + // Convert to Kelvin first + val kelvin = when (from) { + CELSIUS -> value + 273.15 + FAHRENHEIT -> (value - 32.0) * 5.0 / 9.0 + 273.15 + KELVIN -> value + else -> return null + } + + // Convert from Kelvin to target + return when (to) { + CELSIUS -> kelvin - 273.15 + FAHRENHEIT -> (kelvin - 273.15) * 9.0 / 5.0 + 32.0 + KELVIN -> kelvin + else -> null + } + } + + private fun getConversionFactor(unit: Unit): Double? = when (unit) { + // LENGTH - base: METER + PICOMETER -> 1e-12 + NANOMETER -> 1e-9 + MICROMETER -> 1e-6 + MILLIMETER -> 0.001 + CENTIMETER -> 0.01 + DECIMETER -> 0.1 + METER -> 1.0 + KILOMETER -> 1000.0 + INCH -> 0.0254 + FOOT -> 0.3048 + YARD -> 0.9144 + FATHOM -> 1.8288 + FURLONG -> 201.168 + MILE -> 1609.344 + MILE_SCANDINAVIAN -> 10000.0 + NAUTICAL_MILE -> 1852.0 + LIGHT_YEAR -> 9.4607304725808e15 + ASTRONOMICAL_UNIT -> 1.495978707e11 + PARSEC -> 3.0856776e16 + + // MASS - base: KILOGRAM + MICROGRAM -> 1e-9 + MILLIGRAM -> 1e-6 + GRAM -> 0.001 + KILOGRAM -> 1.0 + METRIC_TON -> 1000.0 + TONNE -> 1000.0 + OUNCE -> 0.028349523125 + POUND -> 0.45359237 + STONE -> 6.35029318 + TON -> 907.18474 + OUNCE_TROY -> 0.0311034768 + CARAT -> 0.0002 + + // TEMPERATURE - special case handled separately + CELSIUS, FAHRENHEIT, KELVIN -> null + + // VOLUME - base: LITER + MILLILITER -> 0.001 + CENTILITER -> 0.01 + DECILITER -> 0.1 + LITER -> 1.0 + HECTOLITER -> 100.0 + MEGALITER -> 1000000.0 + CUBIC_CENTIMETER -> 0.001 + CUBIC_METER -> 1000.0 + CUBIC_KILOMETER -> 1e12 + CUBIC_INCH -> 0.016387064 + CUBIC_FOOT -> 28.316846592 + CUBIC_YARD -> 764.554857984 + CUBIC_MILE -> 4.16818182544e12 + TEASPOON -> 0.00492892 + TABLESPOON -> 0.0147868 + FLUID_OUNCE -> 0.0295735 + CUP -> 0.2365882365 + CUP_METRIC -> 0.25 + PINT -> 0.473176 + PINT_METRIC -> 0.5 + QUART -> 0.946353 + GALLON -> 3.78541 + GALLON_IMPERIAL -> 4.54609 + BUSHEL -> 35.2391 + ACRE_FOOT -> 1233481.84 + + // AREA - base: SQUARE_METER + SQUARE_CENTIMETER -> 0.0001 + SQUARE_METER -> 1.0 + SQUARE_KILOMETER -> 1000000.0 + HECTARE -> 10000.0 + SQUARE_INCH -> 0.00064516 + SQUARE_FOOT -> 0.092903 + SQUARE_YARD -> 0.836127 + ACRE -> 4046.86 + SQUARE_MILE -> 2589988.110336 + + // SPEED - base: METER_PER_SECOND + METER_PER_SECOND -> 1.0 + KILOMETER_PER_HOUR -> 0.277778 + MILE_PER_HOUR -> 0.44704 + KNOT -> 0.514444 + + // TIME - base: SECOND + NANOSECOND -> 1e-9 + MICROSECOND -> 1e-6 + MILLISECOND -> 0.001 + SECOND -> 1.0 + MINUTE -> 60.0 + HOUR -> 3600.0 + DAY -> 86400.0 + WEEK -> 604800.0 + MONTH -> 2629800.0 // 30.4375 days average + YEAR -> 31557600.0 // 365.25 days + DECADE -> 315576000.0 + CENTURY -> 3155760000.0 + + // DIGITAL STORAGE - base: BYTE + BIT -> 0.125 + BYTE -> 1.0 + KILOBIT -> 125.0 + KILOBYTE -> 1000.0 + MEGABIT -> 125000.0 + MEGABYTE -> 1000000.0 + GIGABIT -> 125000000.0 + GIGABYTE -> 1000000000.0 + TERABIT -> 125000000000.0 + TERABYTE -> 1000000000000.0 + PETABYTE -> 1000000000000000.0 + + // ENERGY - base: JOULE + JOULE -> 1.0 + KILOJOULE -> 1000.0 + CALORIE -> 4.184 + KILOCALORIE -> 4184.0 + FOODCALORIE -> 4184.0 + KILOWATT_HOUR -> 3600000.0 + + // POWER - base: WATT + MILLIWATT -> 0.001 + WATT -> 1.0 + KILOWATT -> 1000.0 + MEGAWATT -> 1000000.0 + GIGAWATT -> 1000000000.0 + HORSEPOWER -> 745.7 + + // PRESSURE - base: PASCAL + HECTOPASCAL -> 100.0 + MILLIBAR -> 100.0 + ATMOSPHERE -> 101325.0 + POUND_PER_SQUARE_INCH -> 6894.76 + INCH_HG -> 3386.39 + MILLIMETER_OF_MERCURY -> 133.322 + + // ANGLE - base: RADIAN + ARC_SECOND -> 4.84814e-6 + ARC_MINUTE -> 0.000290888 + DEGREE -> 0.0174533 + RADIAN -> 1.0 + REVOLUTION_ANGLE -> 6.28319 + + // FREQUENCY - base: HERTZ + HERTZ -> 1.0 + KILOHERTZ -> 1000.0 + MEGAHERTZ -> 1000000.0 + GIGAHERTZ -> 1000000000.0 + + // ACCELERATION - base: METER_PER_SECOND_SQUARED + METER_PER_SECOND_SQUARED -> 1.0 + G_FORCE -> 9.80665 + + // CURRENCY - handled separately + else -> null + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/unit_conversion/UnitConversionInfo.kt b/app/src/main/kotlin/org/stypox/dicio/skills/unit_conversion/UnitConversionInfo.kt new file mode 100644 index 00000000..fe4f2b09 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/unit_conversion/UnitConversionInfo.kt @@ -0,0 +1,36 @@ +package org.stypox.dicio.skills.unit_conversion + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SwapHoriz +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 + +object UnitConversionInfo : SkillInfo("unit_conversion") { + override fun name(context: Context) = + context.getString(R.string.skill_name_unit_conversion) + + override fun sentenceExample(context: Context) = + context.getString(R.string.skill_sentence_example_unit_conversion) + + @Composable + override fun icon() = + rememberVectorPainter(Icons.Default.SwapHoriz) + + override fun isAvailable(ctx: SkillContext): Boolean { + return Sentences.UnitConversion[ctx.sentencesLanguage] != null && + ctx.parserFormatter != null + } + + override fun build(ctx: SkillContext): Skill<*> { + return UnitConversionSkill( + UnitConversionInfo, + Sentences.UnitConversion[ctx.sentencesLanguage]!! + ) + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/unit_conversion/UnitConversionOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/unit_conversion/UnitConversionOutput.kt new file mode 100644 index 00000000..6f2f40ab --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/unit_conversion/UnitConversionOutput.kt @@ -0,0 +1,122 @@ +package org.stypox.dicio.skills.unit_conversion + +import android.icu.text.MeasureFormat +import android.icu.util.Measure +import android.icu.util.ULocale +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.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.Headline +import org.stypox.dicio.io.graphical.Subtitle +import org.stypox.dicio.util.getString +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Currency +import java.util.Locale + +sealed interface UnitConversionOutput : SkillOutput { + data class Success( + val inputValue: Double, + val sourceUnit: Unit, + val targetUnit: Unit, + val result: Double + ) : UnitConversionOutput { + + private fun formatNumber(value: Double): String { + // Use appropriate precision based on magnitude + val symbols = DecimalFormatSymbols().apply { + groupingSeparator = ',' + decimalSeparator = '.' + } + + return when { + value == 0.0 -> "0" + Math.abs(value) >= 1000000 -> { + DecimalFormat("#,##0.##E0", symbols).format(value) + } + Math.abs(value) >= 1 -> { + DecimalFormat("#,##0.####", symbols).format(value) + } + Math.abs(value) >= 0.01 -> { + DecimalFormat("0.####", symbols).format(value) + } + else -> { + DecimalFormat("0.######E0", symbols).format(value) + } + } + } + + private fun getUnitDisplayName(unit: Unit, resources: android.content.res.Resources): String { + val locale = ULocale.forLocale(resources.configuration.locales[0]) + + // Use MeasureFormat for non-currency units + unit.measureUnit?.let { measureUnit -> + val formatWidth = when (unit.type) { + UnitType.DIGITAL_STORAGE, UnitType.ENERGY, UnitType.POWER, UnitType.PRESSURE -> { + MeasureFormat.FormatWidth.NARROW + } + else -> { + MeasureFormat.FormatWidth.WIDE + } + } + return MeasureFormat.getInstance(locale, formatWidth).getUnitDisplayName(measureUnit) + } + + // Use Currency API for currency units + unit.currencyCode?.let { code -> + return runCatching { + val javaLocale = Locale.forLanguageTag(locale.toLanguageTag()) + Currency.getInstance(code).getDisplayName(javaLocale) + }.getOrDefault(code) + } + + return "" + } + + override fun getSpeechOutput(ctx: SkillContext): String { + val inputStr = formatNumber(inputValue) + val resultStr = formatNumber(result) + val sourceUnitName = getUnitDisplayName(sourceUnit, ctx.android.resources) + val targetUnitName = getUnitDisplayName(targetUnit, ctx.android.resources) + + return ctx.getString( + R.string.skill_unit_conversion_result, + inputStr, + sourceUnitName, + resultStr, + targetUnitName + ) + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column { + Subtitle( + text = "${formatNumber(inputValue)} ${getUnitDisplayName(sourceUnit, ctx.android.resources)}" + ) + Spacer(modifier = androidx.compose.ui.Modifier.height(8.dp)) +Headline( + text = "${formatNumber(result)} ${getUnitDisplayName(targetUnit, ctx.android.resources)}" + ) + } + } + } + + data class Error( + val message: String + ) : UnitConversionOutput { + override fun getSpeechOutput(ctx: SkillContext): String { + return ctx.getString(R.string.skill_unit_conversion_error, message) + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Headline(text = getSpeechOutput(ctx)) + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/unit_conversion/UnitConversionSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/unit_conversion/UnitConversionSkill.kt new file mode 100644 index 00000000..f48c1f8f --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/unit_conversion/UnitConversionSkill.kt @@ -0,0 +1,134 @@ +package org.stypox.dicio.skills.unit_conversion + +import org.dicio.numbers.unit.Number +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.stypox.dicio.sentences.Sentences.UnitConversion +import org.stypox.dicio.util.ConnectionUtils +import org.json.JSONObject + +class UnitConversionSkill( + correspondingSkillInfo: SkillInfo, + data: StandardRecognizerData +) : StandardRecognizerSkill(correspondingSkillInfo, data) { + + override suspend fun generateOutput(ctx: SkillContext, inputData: UnitConversion): SkillOutput { + when (inputData) { + is UnitConversion.Convert -> { + // Extract target unit + val targetUnitText = inputData.targetUnit?.trim() + if (targetUnitText.isNullOrBlank()) { + return UnitConversionOutput.Error(ctx.android.getString( + org.stypox.dicio.R.string.skill_unit_conversion_missing_target_unit)) + } + + val targetUnit = Unit.findUnit(targetUnitText, ctx.android.resources) + if (targetUnit == null) { + return UnitConversionOutput.Error(ctx.android.getString( + org.stypox.dicio.R.string.skill_unit_conversion_unknown_target_unit, + targetUnitText)) + } + + // Parse value and source unit from the combined string + val valueWithUnitText = inputData.valueWithUnit?.trim() + if (valueWithUnitText.isNullOrBlank()) { + return UnitConversionOutput.Error(ctx.android.getString( + org.stypox.dicio.R.string.skill_unit_conversion_missing_value_and_source_unit)) + } + + // Use number parser to extract the number and remaining text + val parsed = ctx.parserFormatter?.extractNumber(valueWithUnitText) + if (parsed == null) { + return UnitConversionOutput.Error(ctx.android.getString( + org.stypox.dicio.R.string.skill_unit_conversion_could_not_parse_value)) + } + + // Find the number in the mixed list + var value: Double? = null + val mixedList = parsed.mixedWithText + for (item in mixedList) { + if (item is Number) { + value = if (item.isDecimal) { + item.decimalValue() + } else { + item.integerValue().toDouble() + } + break + } + } + + if (value == null) { + // Fallback: check if the text starts with "a " or "an " (e.g., "a gallon") + val normalized = valueWithUnitText.lowercase().trim() + if (normalized.startsWith("a ") || normalized.startsWith("an ")) { + value = 1.0 + } else { + return UnitConversionOutput.Error(ctx.android.getString( + org.stypox.dicio.R.string.skill_unit_conversion_could_not_parse_number_value)) + } + } + + // Extract source unit from the remaining text + val sourceUnit = Unit.findUnit(valueWithUnitText, ctx.android.resources) + if (sourceUnit == null) { + return UnitConversionOutput.Error(ctx.android.getString( + org.stypox.dicio.R.string.skill_unit_conversion_could_not_identify_source_unit, + valueWithUnitText)) + } + + if (sourceUnit.type != targetUnit.type) { + return UnitConversionOutput.Error( + ctx.android.getString( + org.stypox.dicio.R.string.skill_unit_conversion_cannot_convert_between_types, + sourceUnit.type.name.lowercase(), + targetUnit.type.name.lowercase()) + ) + } + + // Perform conversion + val result = if (sourceUnit.type == UnitType.CURRENCY) { + convertCurrency(value, sourceUnit, targetUnit) + } else { + Unit.convert(value, sourceUnit, targetUnit) + } + + if (result == null) { + return UnitConversionOutput.Error(ctx.android.getString( + org.stypox.dicio.R.string.skill_unit_conversion_conversion_failed)) + } + + return UnitConversionOutput.Success( + inputValue = value, + sourceUnit = sourceUnit, + targetUnit = targetUnit, + result = result + ) + } + } + } + + /** + * Convert currency using the Frankfurter API. + * API format: https://api.frankfurter.dev/v1/latest?base=USD&symbols=EUR + * Returns the converted amount with 5 decimal precision, or null if the conversion fails. + */ + private fun convertCurrency(amount: Double, fromCurrency: Unit, toCurrency: Unit): Double? { + val baseCurrency = fromCurrency.currencyCode ?: return null + val targetCurrency = toCurrency.currencyCode ?: return null + + return try { + val apiUrl = "https://api.frankfurter.dev/v1/latest?base=$baseCurrency&symbols=$targetCurrency" + val exchangeRate = ConnectionUtils.getPageJson(apiUrl) + .getJSONObject("rates") + .getDouble(targetCurrency) + + val result = amount * exchangeRate + String.format("%.5f", result).toDouble() + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8230af59..f7008b19 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,4 +246,22 @@ Failed to copy to clipboard Auto DuckDuckGo did not provide results, asking for a Captcha to be solved + Unit conversion + Convert 5 kilometers to miles + %1$s %2$s is %3$s %4$s + %1$s + Missing target unit + Unknown target unit: %1$s + Missing value and source unit + Could not parse value + Could not parse the number value + Could not identify source unit in: %1$s + Cannot convert between %1$s and %2$s + Conversion failed + Crypto price + What is the price of Bitcoin? + %1$s is currently %2$s dollars + Unknown cryptocurrency: %1$s + Could not connect to the crypto price service. Please check your internet connection. + Failed to get valid price data from the service. diff --git a/app/src/main/sentences/en/crypto_price.yml b/app/src/main/sentences/en/crypto_price.yml new file mode 100644 index 00000000..719f6596 --- /dev/null +++ b/app/src/main/sentences/en/crypto_price.yml @@ -0,0 +1,6 @@ +price: + - (what is|whats|what s) the (price|cost|value) of .crypto. + - (what is|whats|what s|how much is) .crypto. (price|cost|value|worth) + - .crypto. price + - price of .crypto. + - (check|get) .crypto. price diff --git a/app/src/main/sentences/en/unit_conversion.yml b/app/src/main/sentences/en/unit_conversion.yml new file mode 100644 index 00000000..fe359bfc --- /dev/null +++ b/app/src/main/sentences/en/unit_conversion.yml @@ -0,0 +1,5 @@ +convert: + - convert .value_with_unit. (to|in(to)?) .target_unit. + - (what is|whats|what s) .value_with_unit. (in|to) .target_unit. + - how much is .value_with_unit. (in|to) .target_unit. + - how many .target_unit. ((are|is) there?)? in .value_with_unit. diff --git a/app/src/main/sentences/skill_definitions.yml b/app/src/main/sentences/skill_definitions.yml index 7aa23f72..144d3421 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -123,3 +123,21 @@ skills: type: string - id: target type: string + + - id: unit_conversion + specificity: high + sentences: + - id: convert + captures: + - id: value_with_unit + type: string + - id: target_unit + type: string + + - id: crypto_price + specificity: high + sentences: + - id: price + captures: + - id: crypto + type: string diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index f572451f..8646ec66 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -14,6 +14,8 @@ Dicio answers questions about:
  • jokes: tells you a joke - Tell me a joke
  • media: play, pause, previous, next song - Next Song
  • translation: translate from/to any language with Lingva - How do I say Football in German?
  • +
  • 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?
  • Dicio can receive input through a text box or through Vosk speech to text, and can talk using toasts or the Android speech synthesis engine. Interactive graphical output is provided by skills when they answer a question.