-
-
Notifications
You must be signed in to change notification settings - Fork 112
Adds in app search #323
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Adds in app search #323
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
package org.stypox.dicio.skills.app_search | ||
|
||
import android.content.Context | ||
import androidx.compose.material.icons.Icons | ||
import androidx.compose.material.icons.filled.Search | ||
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 AppSearchInfo : SkillInfo("app_search") { | ||
override fun name(context: Context) = | ||
context.getString(R.string.skill_name_app_search) | ||
|
||
override fun sentenceExample(context: Context) = | ||
context.getString(R.string.skill_sentence_example_app_search) | ||
|
||
@Composable | ||
override fun icon() = | ||
rememberVectorPainter(Icons.Filled.Search) | ||
|
||
override fun isAvailable(ctx: SkillContext): Boolean { | ||
return Sentences.AppSearch[ctx.sentencesLanguage] != null | ||
} | ||
|
||
override fun build(ctx: SkillContext): Skill<*> { | ||
return AppSearchSkill(AppSearchInfo, Sentences.AppSearch[ctx.sentencesLanguage]!!) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
package org.stypox.dicio.skills.app_search | ||
|
||
import android.content.pm.PackageManager | ||
import android.util.Log | ||
import androidx.compose.foundation.Image | ||
import androidx.compose.foundation.layout.Column | ||
import androidx.compose.foundation.layout.Row | ||
import androidx.compose.foundation.layout.Spacer | ||
import androidx.compose.foundation.layout.aspectRatio | ||
import androidx.compose.foundation.layout.fillMaxWidth | ||
import androidx.compose.foundation.layout.width | ||
import androidx.compose.material3.MaterialTheme | ||
import androidx.compose.material3.Text | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.runtime.remember | ||
import androidx.compose.ui.Alignment | ||
import androidx.compose.ui.Modifier | ||
import androidx.compose.ui.platform.LocalContext | ||
import androidx.compose.ui.text.style.TextAlign | ||
import androidx.compose.ui.unit.dp | ||
import com.google.accompanist.drawablepainter.rememberDrawablePainter | ||
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.util.getString | ||
|
||
private val TAG = AppSearchOutput::class.simpleName | ||
|
||
class AppSearchOutput( | ||
private val appName: String?, | ||
private val packageName: String?, | ||
private val searchQuery: String?, | ||
private val success: Boolean | ||
) : SkillOutput { | ||
override fun getSpeechOutput(ctx: SkillContext): String = when { | ||
appName == null -> ctx.getString(R.string.skill_app_search_could_not_understand) | ||
packageName == null -> ctx.getString(R.string.skill_app_search_unknown_app, appName) | ||
!success -> ctx.getString(R.string.skill_app_search_failed, appName, searchQuery ?: "") | ||
else -> ctx.getString(R.string.skill_app_search_searching, appName, searchQuery ?: "") | ||
} | ||
|
||
@Composable | ||
override fun GraphicalOutput(ctx: SkillContext) { | ||
if (appName == null || packageName == null || !success) { | ||
Headline(text = getSpeechOutput(ctx)) | ||
} else { | ||
Row( | ||
modifier = Modifier.fillMaxWidth(), | ||
verticalAlignment = Alignment.CenterVertically, | ||
) { | ||
val context = LocalContext.current | ||
val icon = remember { | ||
try { | ||
context.packageManager.getApplicationIcon(packageName) | ||
} catch (e: PackageManager.NameNotFoundException) { | ||
Log.e(TAG, "Could not load icon for $packageName", e) | ||
null | ||
} | ||
} | ||
|
||
if (icon != null) { | ||
Image( | ||
painter = rememberDrawablePainter(icon), | ||
contentDescription = appName, | ||
modifier = Modifier | ||
.fillMaxWidth(0.2f) | ||
.aspectRatio(1.0f), | ||
) | ||
|
||
Spacer(modifier = Modifier.width(8.dp)) | ||
} | ||
|
||
Column( | ||
horizontalAlignment = Alignment.CenterHorizontally, | ||
modifier = Modifier.fillMaxWidth(), | ||
) { | ||
Text( | ||
text = getSpeechOutput(ctx), | ||
style = MaterialTheme.typography.headlineMedium, | ||
textAlign = TextAlign.Center, | ||
modifier = Modifier.fillMaxWidth(), | ||
) | ||
|
||
Text( | ||
text = packageName, | ||
textAlign = TextAlign.Center, | ||
modifier = Modifier.fillMaxWidth(), | ||
) | ||
} | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
package org.stypox.dicio.skills.app_search | ||
|
||
import android.annotation.SuppressLint | ||
import android.content.Intent | ||
import android.content.pm.ApplicationInfo | ||
import android.content.pm.PackageManager | ||
import android.content.pm.ResolveInfo | ||
import android.net.Uri | ||
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.AppSearch | ||
import org.stypox.dicio.util.StringUtils | ||
|
||
class AppSearchSkill(correspondingSkillInfo: SkillInfo, data: StandardRecognizerData<AppSearch>) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not sure that we need another skill, maybe it's better to extend the search skill that already exists. The search skill however has a low specificity, while this one has a high specificity at the moment (although I would suggest a medium specificity would be better), so we need to see how it plays out, maybe it is a good idea to have two separate skills. |
||
: StandardRecognizerSkill<AppSearch>(correspondingSkillInfo, data) { | ||
|
||
override suspend fun generateOutput(ctx: SkillContext, inputData: AppSearch): SkillOutput { | ||
val userAppName = when (inputData) { | ||
is AppSearch.Query -> inputData.app?.trim { it <= ' ' } | ||
else -> null | ||
} | ||
val userQuery = when (inputData) { | ||
is AppSearch.Query -> inputData.query?.trim { it <= ' ' } | ||
else -> null | ||
} | ||
|
||
val packageManager: PackageManager = ctx.android.packageManager | ||
val applicationInfo = userAppName?.let { getMostSimilarApp(packageManager, it) } | ||
var success = false | ||
|
||
if (applicationInfo != null && userQuery != null) { | ||
success = launchAppWithSearch(ctx, packageManager, applicationInfo, userQuery) | ||
} | ||
|
||
return AppSearchOutput( | ||
appName = applicationInfo?.loadLabel(packageManager)?.toString() ?: userAppName, | ||
packageName = applicationInfo?.packageName, | ||
searchQuery = userQuery, | ||
success = success | ||
) | ||
} | ||
|
||
private fun launchAppWithSearch(ctx: SkillContext, packageManager: PackageManager, applicationInfo: ApplicationInfo, query: String): Boolean { | ||
val packageName = applicationInfo.packageName | ||
try { | ||
// Handle different apps with their specific search intents | ||
when { | ||
// YouTube | ||
packageName.contains("youtube", ignoreCase = true) -> { | ||
val intent = Intent(Intent.ACTION_SEARCH) | ||
intent.setPackage(packageName) | ||
intent.putExtra("query", query) | ||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK | ||
ctx.android.startActivity(intent) | ||
return true | ||
} | ||
// Spotify | ||
packageName.contains("spotify", ignoreCase = true) -> { | ||
val intent = Intent(Intent.ACTION_VIEW) | ||
intent.setData(Uri.parse("spotify:search:$query")) | ||
intent.putExtra(Intent.EXTRA_REFERRER, Uri.parse("android-app://${ctx.android.packageName}")) | ||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK | ||
ctx.android.startActivity(intent) | ||
return true | ||
} | ||
// Generic search intent as fallback | ||
else -> { | ||
val intent = Intent(Intent.ACTION_SEARCH) | ||
intent.setPackage(packageName) | ||
intent.putExtra("query", query) | ||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK | ||
ctx.android.startActivity(intent) | ||
return true | ||
} | ||
} | ||
} catch (e: Exception) { | ||
// If the specific intent fails, try a web search as fallback | ||
try { | ||
val intent = Intent(Intent.ACTION_VIEW) | ||
when { | ||
packageName.contains("youtube", ignoreCase = true) -> { | ||
intent.data = Uri.parse("https://www.youtube.com/results?search_query=${Uri.encode(query)}") | ||
} | ||
packageName.contains("spotify", ignoreCase = true) -> { | ||
intent.data = Uri.parse("https://open.spotify.com/search/${Uri.encode(query)}") | ||
} | ||
else -> { | ||
return false | ||
} | ||
} | ||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK | ||
ctx.android.startActivity(intent) | ||
return true | ||
} catch (e: Exception) { | ||
return false | ||
} | ||
} | ||
} | ||
|
||
companion object { | ||
private fun getMostSimilarApp( | ||
packageManager: PackageManager, | ||
appName: String | ||
): ApplicationInfo? { | ||
val resolveInfosIntent = Intent(Intent.ACTION_MAIN, null) | ||
resolveInfosIntent.addCategory(Intent.CATEGORY_LAUNCHER) | ||
|
||
@SuppressLint("QueryPermissionsNeeded") // we need to query all apps | ||
val resolveInfos: List<ResolveInfo> = | ||
packageManager.queryIntentActivities(resolveInfosIntent, 0) | ||
var bestDistance = Int.MAX_VALUE | ||
var bestApplicationInfo: ApplicationInfo? = null | ||
|
||
for (resolveInfo in resolveInfos) { | ||
try { | ||
val currentApplicationInfo: ApplicationInfo = packageManager.getApplicationInfo( | ||
resolveInfo.activityInfo.packageName, PackageManager.GET_META_DATA | ||
) | ||
val currentDistance = StringUtils.customStringDistance( | ||
appName, | ||
packageManager.getApplicationLabel(currentApplicationInfo).toString() | ||
) | ||
if (currentDistance < bestDistance) { | ||
bestDistance = currentDistance | ||
bestApplicationInfo = currentApplicationInfo | ||
} | ||
} catch (ignored: PackageManager.NameNotFoundException) { | ||
} | ||
} | ||
return if (bestDistance > 5) null else bestApplicationInfo | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -149,6 +149,8 @@ | |||||||||
<string name="skill_name_timer">Timer</string> | ||||||||||
<string name="skill_sentence_example_timer">Set a timer for five minutes</string> | ||||||||||
<string name="skill_name_current_time">Current time</string> | ||||||||||
<string name="skill_name_app_search">App Search</string> | ||||||||||
<string name="skill_sentence_example_app_search">In YouTube search for cute cats</string> | ||||||||||
Comment on lines
+152
to
+153
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
<string name="skill_sentence_example_current_time">What time is it?</string> | ||||||||||
<string name="skill_fallback_name_text">Text message</string> | ||||||||||
<string name="skill_search_here_is_what_i_found">Here is what I found</string> | ||||||||||
|
@@ -164,6 +166,10 @@ | |||||||||
<string name="skill_open_could_not_understand">Could not understand app</string> | ||||||||||
<string name="skill_open_unknown_app">Unknown app %1$s</string> | ||||||||||
<string name="skill_open_opening">Opening %1$s…</string> | ||||||||||
<string name="skill_app_search_could_not_understand">Could not understand app</string> | ||||||||||
<string name="skill_app_search_unknown_app">Unknown app %1$s</string> | ||||||||||
<string name="skill_app_search_searching">Searching for %2$s on %1$s</string> | ||||||||||
<string name="skill_app_search_failed">Failed to search for %2$s on %1$s</string> | ||||||||||
<string name="skill_media_playing">Playing media…</string> | ||||||||||
<string name="skill_media_pausing">Pausing media…</string> | ||||||||||
<string name="skill_media_previous">Previous media…</string> | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You could condense these into "in|on .app. (search for?)|lookup|(look up|for)|find .query.". You could also add another sentence with "in|on .app." at the end, the sentence recognizer should still be able to distinguish it from searching in general: "(search for?)|lookup|(look up|for)|find .query. in|on .app.". |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
query: | ||
- in .app. look up .query. | ||
- in .app. find .query. | ||
- in .app. search .query. | ||
- in .app. search for .query. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
query: | ||
- en .app. buscar .query. | ||
- en .app. busca .query. | ||
- en .app. busque .query. | ||
- en .app. encontrar .query. | ||
- en .app. encuentra .query. | ||
- en .app. encuentre .query. |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -1,4 +1,14 @@ | ||||||
skills: | ||||||
- id: app_search | ||||||
specificity: high | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
sentences: | ||||||
- id: query | ||||||
captures: | ||||||
- id: app | ||||||
type: string | ||||||
- id: query | ||||||
type: string | ||||||
|
||||||
- id: current_time | ||||||
specificity: high | ||||||
sentences: | ||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of having a class with nullable fields, you can create a
sealed interface
with one class for each possible outcome. It's not so necessary, but it might make things a bit more clear. See how it's done e.g. for the Lyrics, there is just one failure mode there while here you would have three. Also, to avoid having to implementGraphicalOutput
multiple times, you can make the error classes overrideHeadlineSpeechSkillOutput
.