Add search quick actions

This commit is contained in:
MM20
2022-11-03 20:01:24 +01:00
parent 9a514dff31
commit f862a578a1
58 changed files with 1617 additions and 465 deletions
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,9 @@
package de.mm20.launcher2.searchactions
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val searchActionsModule = module {
single<SearchActionRepository> { SearchActionRepositoryImpl() }
single<SearchActionService> { SearchActionServiceImpl(androidContext(), get(), TextClassifierImpl()) }
}
@@ -0,0 +1,14 @@
package de.mm20.launcher2.searchactions
import de.mm20.launcher2.searchactions.builders.SearchActionBuilder
import kotlinx.coroutines.flow.Flow
interface SearchActionRepository {
fun getSearchActionBuilders(filter: TextType?): Flow<List<SearchActionBuilder>>
}
internal class SearchActionRepositoryImpl: SearchActionRepository {
override fun getSearchActionBuilders(filter: TextType?): Flow<List<SearchActionBuilder>> {
TODO("Not yet implemented")
}
}
@@ -0,0 +1,53 @@
package de.mm20.launcher2.searchactions
import android.content.Context
import de.mm20.launcher2.preferences.Settings.SearchActionSettings
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.builders.CallActionBuilder
import de.mm20.launcher2.searchactions.builders.CreateContactActionBuilder
import de.mm20.launcher2.searchactions.builders.EmailActionBuilder
import de.mm20.launcher2.searchactions.builders.MessageActionBuilder
import de.mm20.launcher2.searchactions.builders.OpenUrlActionBuilder
import de.mm20.launcher2.searchactions.builders.ScheduleEventActionBuilder
import de.mm20.launcher2.searchactions.builders.SearchActionBuilder
import de.mm20.launcher2.searchactions.builders.SetAlarmActionBuilder
import de.mm20.launcher2.searchactions.builders.TimerActionBuilder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
interface SearchActionService {
fun search(settings: SearchActionSettings, query: String): Flow<ImmutableList<SearchAction>>
}
internal class SearchActionServiceImpl(
private val context: Context,
private val repository: SearchActionRepository,
private val textClassifier: TextClassifier,
) : SearchActionService {
override fun search(settings: SearchActionSettings, query: String): Flow<ImmutableList<SearchAction>> = flow {
if (query.isBlank()) {
emit(persistentListOf())
return@flow
}
val builders = mutableListOf<SearchActionBuilder>()
if (settings.call) builders.add(CallActionBuilder)
if (settings.message) builders.add(MessageActionBuilder)
if (settings.contact) builders.add(CreateContactActionBuilder)
if (settings.email) builders.add(EmailActionBuilder)
if (settings.openUrl) builders.add(OpenUrlActionBuilder)
if (settings.scheduleEvent) builders.add(ScheduleEventActionBuilder)
if (settings.setAlarm) builders.add(SetAlarmActionBuilder)
if (settings.startTimer) builders.add(TimerActionBuilder)
val classificationResult = textClassifier.classify(context, query)
emit(builders.mapNotNull { it.build(context, classificationResult) }.toImmutableList())
}
}
@@ -0,0 +1,156 @@
package de.mm20.launcher2.searchactions
import android.content.Context
import android.icu.text.SimpleDateFormat
import android.text.format.DateFormat
import java.text.ParseException
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.util.Locale
internal interface TextClassifier {
suspend fun classify(context: Context, query: String): TextClassificationResult
}
internal class TextClassifierImpl : TextClassifier {
override suspend fun classify(context: Context, query: String): TextClassificationResult {
return when {
query.matches(Regex("^\\S+@\\S+$")) -> TextClassificationResult(
type = TextType.Email,
text = query,
email = query
)
query.matches(Regex("^\\+?[0-9- ]{4,18}$")) -> TextClassificationResult(
type = TextType.PhoneNumber,
text = query,
phoneNumber = query
)
query.matches(Regex("^(http(s)?://.)?(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_+.~#?&/=]*)$")) -> TextClassificationResult(
type = TextType.Url,
text = query,
url = query
)
else -> {
parseDate(context, query)?.let { return it }
TextClassificationResult(type = TextType.Text, text = query)
}
}
}
private fun parseDate(context: Context, query: String): TextClassificationResult? {
val dateTimeFormat = SimpleDateFormat(
DateFormat.getBestDateTimePattern(
Locale.getDefault(),
"yyyy-MM-dd, HH:mm"
)
)
try {
dateTimeFormat.parse(query)?.let {
val dateTime = LocalDateTime.ofInstant(it.toInstant(), ZoneId.systemDefault())
return TextClassificationResult(
type = TextType.DateTime,
text = query,
time = dateTime.toLocalTime(),
date = dateTime.toLocalDate(),
)
}
} catch (_: ParseException) {
// Not a datetime
}
val dateFormat = DateFormat.getDateFormat(context)
try {
dateFormat.parse(query)?.let {
return TextClassificationResult(
type = TextType.Date,
text = query,
date = LocalDateTime.ofInstant(it.toInstant(), ZoneId.systemDefault())
.toLocalDate()
)
}
} catch (_: ParseException) {
// Not a date either
}
val timeFormat = DateFormat.getTimeFormat(context)
try {
timeFormat.parse(query)?.let {
return TextClassificationResult(
type = TextType.Time,
text = query,
time = LocalDateTime.ofInstant(it.toInstant(), ZoneId.systemDefault())
.toLocalTime(),
)
}
} catch (_: ParseException) {
// Nope, not a time
}
val seconds = context.getString(R.string.unit_second_symbol)
if (query.matches(Regex("^[0-9]+ ${seconds}$"))) {
val value = query.substringBefore(" ").toLong()
return TextClassificationResult(
type = TextType.Timespan,
text = query,
timespan = Duration.ofSeconds(value)
)
}
val days = context.getString(R.string.unit_day_symbol)
if (query.matches(Regex("^[0-9]+ ${days}$"))) {
val value = query.substringBefore(" ").toLong()
return TextClassificationResult(
type = TextType.Timespan,
text = query,
timespan = Duration.ofDays(value)
)
}
val minutes = context.getString(R.string.unit_minute_symbol)
if (query.matches(Regex("^[0-9]+ ${minutes}$"))) {
val value = query.substringBefore(" ").toLong()
val then = LocalDateTime.now().plusMinutes(value)
return TextClassificationResult(
type = TextType.Timespan,
text = query,
timespan = Duration.ofMinutes(value)
)
}
val hours = context.getString(R.string.unit_hour_symbol)
if (query.matches(Regex("^[0-9]+ ${hours}$"))) {
val value = query.substringBefore(" ").toLong()
return TextClassificationResult(
type = TextType.Timespan,
text = query,
timespan = Duration.ofHours(value)
)
}
return null
}
}
data class TextClassificationResult(
val type: TextType,
val text: String,
val email: String? = null,
val phoneNumber: String? = null,
val time: LocalTime? = null,
val date: LocalDate? = null,
val timespan: Duration? = null,
val url: String? = null,
)
enum class TextType {
Text,
Email,
Url,
PhoneNumber,
DateTime,
Date,
Time,
Timespan,
}
@@ -0,0 +1,23 @@
package de.mm20.launcher2.searchactions.actions
import android.content.Context
import android.content.Intent
import android.net.Uri
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.searchactions.R
data class CallAction(
override val label: String,
val number: String,
): SearchAction {
override val icon: SearchActionIcon = SearchActionIcon.Phone
override val iconColor: Int = 0
override fun start(context: Context) {
val intent = Intent(Intent.ACTION_DIAL).apply {
data = Uri.parse("tel:$number")
}
context.tryStartActivity(intent)
}
}
@@ -0,0 +1,25 @@
package de.mm20.launcher2.searchactions.actions
import android.content.Context
import android.content.Intent
import android.provider.ContactsContract
import android.provider.ContactsContract.Intents.Insert
import de.mm20.launcher2.ktx.tryStartActivity
class CreateContactAction(
override val label: String,
val phone: String? = null,
val email: String? = null,
) : SearchAction {
override val icon: SearchActionIcon = SearchActionIcon.Contact
override val iconColor: Int = 0
override fun start(context: Context) {
val intent = Intent(Intent.ACTION_INSERT).apply {
type = ContactsContract.Contacts.CONTENT_TYPE
if (email != null) putExtra(Insert.EMAIL, email)
if (phone != null) putExtra(Insert.PHONE, phone)
}
context.tryStartActivity(intent)
}
}
@@ -0,0 +1,22 @@
package de.mm20.launcher2.searchactions.actions
import android.content.Context
import android.content.Intent
import android.net.Uri
import de.mm20.launcher2.ktx.tryStartActivity
data class EmailAction(
override val label: String,
val email: String,
) : SearchAction {
override val icon: SearchActionIcon = SearchActionIcon.Email
override val iconColor: Int = 0
override fun start(context: Context) {
val intent = Intent(Intent.ACTION_SENDTO).apply {
type = "*/*"
data = Uri.parse("mailto:$email")
}
context.tryStartActivity(intent)
}
}
@@ -0,0 +1,21 @@
package de.mm20.launcher2.searchactions.actions
import android.content.Context
import android.content.Intent
import android.net.Uri
import de.mm20.launcher2.ktx.tryStartActivity
data class MessageAction(
override val label: String,
val number: String,
): SearchAction {
override val icon: SearchActionIcon = SearchActionIcon.Message
override val iconColor: Int = 0
override fun start(context: Context) {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse("sms:$number")
}
context.tryStartActivity(intent)
}
}
@@ -0,0 +1,24 @@
package de.mm20.launcher2.searchactions.actions
import android.content.Context
import android.content.Intent
import android.net.Uri
import de.mm20.launcher2.ktx.tryStartActivity
data class OpenUrlAction(
override val label: String,
val url: String,
) : SearchAction {
override val icon: SearchActionIcon = SearchActionIcon.Website
override val iconColor: Int = 0
override fun start(context: Context) {
val url = if (url.startsWith("https://") || url.startsWith("http://")) url else "https://$url"
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(url)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.tryStartActivity(intent)
}
}
@@ -0,0 +1,34 @@
package de.mm20.launcher2.searchactions.actions
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
import de.mm20.launcher2.ktx.tryStartActivity
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
data class ScheduleEventAction(
override val label: String,
val date: LocalDate,
val time: LocalTime?,
) : SearchAction {
override val icon: SearchActionIcon = SearchActionIcon.Calendar
override val iconColor: Int = 0
override fun start(context: Context) {
val startTime = date.let {
if (time != null) it.atTime(time)
else it.atTime(0, 0)
}.atZone(ZoneId.systemDefault()).toEpochSecond() * 1000L
val intent = Intent(Intent.ACTION_INSERT).apply {
type = "vnd.android.cursor.dir/event"
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, startTime)
if (time == null) putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, true)
}
context.tryStartActivity(intent)
}
}
@@ -0,0 +1,25 @@
package de.mm20.launcher2.searchactions.actions
import android.content.Context
import de.mm20.launcher2.search.Searchable
interface SearchAction : Searchable {
val label: String
val icon: SearchActionIcon
val iconColor: Int
fun start(context: Context)
}
enum class SearchActionIcon {
Search,
Website,
Alarm,
Timer,
Contact,
Phone,
Email,
Message,
Calendar,
Translate,
Custom,
}
@@ -0,0 +1,23 @@
package de.mm20.launcher2.searchactions.actions
import android.content.Context
import android.content.Intent
import android.provider.AlarmClock
import de.mm20.launcher2.ktx.tryStartActivity
import java.time.LocalTime
data class SetAlarmAction(
override val label: String,
val time: LocalTime
) : SearchAction {
override val icon: SearchActionIcon = SearchActionIcon.Alarm
override val iconColor: Int = 0
override fun start(context: Context) {
val intent = Intent(AlarmClock.ACTION_SET_ALARM).apply {
putExtra(AlarmClock.EXTRA_HOUR, time.hour)
putExtra(AlarmClock.EXTRA_MINUTES, time.minute)
}
context.tryStartActivity(intent)
}
}
@@ -0,0 +1,23 @@
package de.mm20.launcher2.searchactions.actions
import android.content.Context
import android.content.Intent
import android.provider.AlarmClock
import de.mm20.launcher2.ktx.tryStartActivity
import java.time.Duration
data class TimerAction(
override val label: String,
val length: Duration
): SearchAction {
override val icon: SearchActionIcon = SearchActionIcon.Timer
override val iconColor: Int = 0
override fun start(context: Context) {
val intent = Intent(AlarmClock.ACTION_SET_TIMER).apply {
putExtra(AlarmClock.EXTRA_LENGTH, length.seconds.toInt())
}
context.tryStartActivity(intent)
}
}
@@ -0,0 +1,20 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.CallAction
object CallActionBuilder: SearchActionBuilder {
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.phoneNumber != null) {
return CallAction(
context.getString(R.string.search_action_call), classifiedQuery.phoneNumber
)
}
return null
}
}
@@ -0,0 +1,22 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.CreateContactAction
import de.mm20.launcher2.searchactions.actions.SearchAction
object CreateContactActionBuilder : SearchActionBuilder {
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.phoneNumber != null || classifiedQuery.email != null) {
return CreateContactAction(
context.getString(R.string.search_action_contact),
phone = classifiedQuery.phoneNumber,
email = classifiedQuery.email,
)
}
return null
}
}
@@ -0,0 +1,20 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.EmailAction
import de.mm20.launcher2.searchactions.actions.SearchAction
object EmailActionBuilder: SearchActionBuilder {
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.email != null) {
return EmailAction(
context.getString(R.string.search_action_email), classifiedQuery.email
)
}
return null
}
}
@@ -0,0 +1,19 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.MessageAction
import de.mm20.launcher2.searchactions.actions.SearchAction
object MessageActionBuilder: SearchActionBuilder {
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.phoneNumber != null) {
return MessageAction(
context.getString(R.string.search_action_message), classifiedQuery.phoneNumber
)
}
return null
}
}
@@ -0,0 +1,20 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.MessageAction
import de.mm20.launcher2.searchactions.actions.OpenUrlAction
import de.mm20.launcher2.searchactions.actions.SearchAction
object OpenUrlActionBuilder : SearchActionBuilder {
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.url != null) {
return OpenUrlAction(
context.getString(R.string.search_action_open_url), classifiedQuery.url
)
}
return null
}
}
@@ -0,0 +1,31 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.MessageAction
import de.mm20.launcher2.searchactions.actions.ScheduleEventAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import java.time.LocalDateTime
object ScheduleEventActionBuilder : SearchActionBuilder {
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.date != null) {
return ScheduleEventAction(
context.getString(R.string.search_action_event),
date = classifiedQuery.date,
time = classifiedQuery.time
)
}
if (classifiedQuery.timespan != null && classifiedQuery.timespan.seconds > 86400) {
val datetime = LocalDateTime.now().plus(classifiedQuery.timespan)
return ScheduleEventAction(
context.getString(R.string.search_action_event),
date = datetime.toLocalDate(),
time = datetime.toLocalTime(),
)
}
return null
}
}
@@ -0,0 +1,9 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.TextClassificationResult
interface SearchActionBuilder {
fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction?
}
@@ -0,0 +1,21 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SetAlarmAction
import java.time.LocalDate
object SetAlarmActionBuilder : SearchActionBuilder {
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.time != null) {
return SetAlarmAction(
context.getString(R.string.search_action_alarm), classifiedQuery.time
)
}
return null
}
}
@@ -0,0 +1,20 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.TimerAction
import de.mm20.launcher2.searchactions.actions.SearchAction
object TimerActionBuilder : SearchActionBuilder {
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.timespan != null && classifiedQuery.timespan.seconds <= 86400) {
return TimerAction(
context.getString(R.string.search_action_timer), classifiedQuery.timespan
)
}
return null
}
}
@@ -0,0 +1,61 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import android.net.Uri
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.TextType
import de.mm20.launcher2.searchactions.actions.OpenUrlAction
import java.net.URLEncoder
class WebsearchActionBuilder(
val label: String,
val urlTemplate: String,
val filter: TextType? = null,
val encoding: QueryEncoding,
) : SearchActionBuilder {
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (filter == null || classifiedQuery.type == filter) {
val url = urlTemplate.replace("\${1}", encodeQuery(classifiedQuery.text, encoding))
return OpenUrlAction(
label = label,
url = url,
)
}
return null
}
private fun encodeQuery(query: String, encoding: QueryEncoding): String {
return when (encoding) {
QueryEncoding.UrlEncode -> Uri.encode(query)
QueryEncoding.FormData -> URLEncoder.encode(query, "UTF-8")
QueryEncoding.None -> query
}
}
enum class QueryEncoding {
UrlEncode,
FormData,
None;
fun toInt(): Int {
return when (this) {
UrlEncode -> 0
FormData -> 1
None -> 2
}
}
companion object {
fun fromInt(value: Int?): QueryEncoding {
return when (value) {
1 -> FormData
2 -> None
else -> UrlEncode
}
}
}
}
}