Reorganize and group modules

This commit is contained in:
MM20
2022-12-13 17:37:26 +01:00
parent bac24baad2
commit 3f8880a90a
995 changed files with 501 additions and 298 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,21 @@
package de.mm20.launcher2.searchactions
import de.mm20.launcher2.searchactions.builders.WebsearchActionBuilder
fun knownWebsearchByHostname(hostname: String): WebsearchActionBuilder? {
// List of popular web search engines that do not implement the OpenSearch standard
return when(hostname) {
"google.com" -> WebsearchActionBuilder(label = "Google", urlTemplate = "https://google.com/search?q=\${1}")
"bing.com" -> WebsearchActionBuilder(label = "Google", urlTemplate = "https://bing.com/search?q=\${1}")
"amazon.com" -> WebsearchActionBuilder(label = "Amazon", urlTemplate = "https://www.amazon.com/s?k=\${1}")
"amazon.de" -> WebsearchActionBuilder(label = "Amazon DE", urlTemplate = "https://www.amazon.de/s?k=\${1}")
"amazon.co.uk" -> WebsearchActionBuilder(label = "Amazon UK", urlTemplate = "https://www.amazon.co.uk/s?k=\${1}")
"amazon.fr" -> WebsearchActionBuilder(label = "Amazon FR", urlTemplate = "https://www.amazon.fr/s?k=\${1}")
"amazon.co.jp" -> WebsearchActionBuilder(label = "Amazon JP", urlTemplate = "https://www.amazon.co.jp/s?k=\${1}")
"amazon.ca" -> WebsearchActionBuilder(label = "Amazon CA", urlTemplate = "https://www.amazon.ca/s?k=\${1}")
"amazon.cn" -> WebsearchActionBuilder(label = "Amazon CN", urlTemplate = "https://www.amazon.cn/s?k=\${1}")
"duckduckgo.com" -> WebsearchActionBuilder(label = "DuckDuckGo", urlTemplate = "https://duckduckgo.com/?q=\${1}")
"yahoo.com" -> WebsearchActionBuilder(label = "DuckDuckGo", urlTemplate = "https://search.yahoo.com/search?p=\${1}")
else -> null
}
}
@@ -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(androidContext(), get()) }
single<SearchActionService> { SearchActionServiceImpl(androidContext(), get(), TextClassifierImpl()) }
}
@@ -0,0 +1,165 @@
package de.mm20.launcher2.searchactions
import android.content.Context
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.database.entities.SearchActionEntity
import de.mm20.launcher2.ktx.jsonObjectOf
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.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONException
import java.io.File
import java.util.UUID
interface SearchActionRepository {
fun getSearchActionBuilders(): Flow<List<SearchActionBuilder>>
fun getBuiltinSearchActionBuilders(): List<SearchActionBuilder>
fun saveSearchActionBuilders(builders: List<SearchActionBuilder>)
suspend fun export(toDir: File)
suspend fun import(fromDir: File)
}
internal class SearchActionRepositoryImpl(
private val context: Context,
private val database: AppDatabase
): SearchActionRepository {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun getSearchActionBuilders(): Flow<List<SearchActionBuilder>> {
val dao = database.searchActionDao()
return dao.getSearchActions().map { it.mapNotNull { SearchActionBuilder.from(context, it) } }
}
override fun getBuiltinSearchActionBuilders(): List<SearchActionBuilder> {
val allActions = listOf(
CallActionBuilder(context),
MessageActionBuilder(context),
CreateContactActionBuilder(context),
EmailActionBuilder(context),
ScheduleEventActionBuilder(context),
SetAlarmActionBuilder(context),
TimerActionBuilder(context),
OpenUrlActionBuilder(context),
)
return allActions
}
override fun saveSearchActionBuilders(builders: List<SearchActionBuilder>) {
scope.launch {
val dao = database.searchActionDao()
dao.replaceAll(
builders.mapIndexed { i, it -> SearchActionBuilder.toDatabaseEntity(it, i) }
)
}
}
override suspend fun export(toDir: File) = withContext(Dispatchers.IO) {
val dao = database.backupDao()
var page = 0
var iconCounter = 0
do {
val websearches = dao.exportSearchActions(limit = 100, offset = page * 100)
val jsonArray = JSONArray()
for (websearch in websearches) {
var customIcon = websearch.customIcon
if (customIcon != null) {
val fileName = "asset.searchaction.${iconCounter.toString().padStart(4, '0')}"
val iconAssetFile = File(toDir, fileName)
File(customIcon).inputStream().use { inStream ->
iconAssetFile.outputStream().use { outStream ->
inStream.copyTo(outStream)
}
}
customIcon = fileName
iconCounter++
}
jsonArray.put(
jsonObjectOf(
"color" to websearch.color,
"label" to websearch.label,
"data" to websearch.data,
"icon" to websearch.icon,
"customIcon" to customIcon,
"options" to websearch.options,
"position" to websearch.position,
"type" to websearch.type,
)
)
}
val file = File(toDir, "searchactions.${page.toString().padStart(4, '0')}")
file.bufferedWriter().use {
it.write(jsonArray.toString())
}
page++
} while (websearches.size == 100)
}
override suspend fun import(fromDir: File) = withContext(Dispatchers.IO) {
val dao = database.backupDao()
dao.wipeSearchActions()
val files = fromDir.listFiles { _, name -> name.startsWith("searchactions.") } ?: return@withContext
for (file in files) {
val searchActions = mutableListOf<SearchActionEntity>()
try {
val jsonArray = JSONArray(file.inputStream().reader().readText())
for (i in 0 until jsonArray.length()) {
val json = jsonArray.getJSONObject(i)
val customIcon = json.optString("customIcon").takeIf { it.isNotEmpty() }
var iconFile: File? = null
if (customIcon != null) {
val asset = File(fromDir, customIcon)
iconFile = File(context.filesDir, UUID.randomUUID().toString())
asset.inputStream().use { inStream ->
iconFile.outputStream().use { outStream ->
inStream.copyTo(outStream)
}
}
}
val entity = SearchActionEntity(
position = json.getInt("position"),
data = json.getString("data"),
color = json.optInt("color", 0),
label = json.getString("label"),
icon = json.optInt("icon", 0),
customIcon = iconFile?.absolutePath,
options = json.optString("options").takeIf { it.isNotEmpty() },
type = json.getString("type"),
)
searchActions.add(entity)
}
dao.importSearchActions(searchActions)
} catch (e: JSONException) {
CrashReporter.logException(e)
}
}
}
}
@@ -0,0 +1,257 @@
package de.mm20.launcher2.searchactions
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.net.Uri
import android.util.Xml
import androidx.core.graphics.drawable.toBitmap
import coil.imageLoader
import coil.request.ImageRequest
import coil.size.Scale
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import de.mm20.launcher2.searchactions.builders.SearchActionBuilder
import de.mm20.launcher2.searchactions.builders.WebsearchActionBuilder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.net.URL
import java.util.UUID
interface SearchActionService {
fun search(query: String): Flow<ImmutableList<SearchAction>>
fun getSearchActionBuilders(): Flow<List<SearchActionBuilder>>
fun getDisabledActionBuilders(): Flow<List<SearchActionBuilder>>
fun saveSearchActionBuilders(builders: List<SearchActionBuilder>)
suspend fun importWebsearch(url: String, iconSize: Int): WebsearchActionBuilder?
suspend fun getSearchActivities(): List<ComponentName>
suspend fun createIcon(uri: Uri, size: Int): String?
}
internal class SearchActionServiceImpl(
private val context: Context,
private val repository: SearchActionRepository,
private val textClassifier: TextClassifier,
) : SearchActionService {
override fun search(
query: String
): Flow<ImmutableList<SearchAction>> = flow {
if (query.isBlank()) {
emit(persistentListOf())
return@flow
}
val classificationResult = textClassifier.classify(context, query)
val builders = repository.getSearchActionBuilders()
emitAll(
builders.map {
it.mapNotNull { it.build(context, classificationResult) }.toImmutableList()
}
)
}
override fun getSearchActionBuilders(): Flow<List<SearchActionBuilder>> {
return repository.getSearchActionBuilders()
}
override fun getDisabledActionBuilders(): Flow<List<SearchActionBuilder>> {
val allActions = repository.getBuiltinSearchActionBuilders()
return getSearchActionBuilders().map { enabled ->
allActions.filter { action -> !enabled.any { it.key == action.key } }
}
}
override fun saveSearchActionBuilders(builders: List<SearchActionBuilder>) {
repository.saveSearchActionBuilders(builders)
}
override suspend fun importWebsearch(url: String, iconSize: Int): WebsearchActionBuilder? =
withContext(Dispatchers.IO) {
try {
val u = if (url.startsWith("http://") || url.startsWith("https://")) {
url
} else {
"https://$url"
}
if (u.contains("${1}")) {
return@withContext WebsearchActionBuilder(
urlTemplate = u,
label = "",
iconColor = 0,
icon = SearchActionIcon.Search,
)
}
val document = Jsoup.parse(URL(u), 5000)
val metaElements =
document.select("link[rel=\"search\"][href][type=\"application/opensearchdescription+xml\"]")
val openSearchHref = metaElements
.getOrNull(0)
?.absUrl("href")
?.takeIf { it.isNotEmpty() }
val action = openSearchHref?.let {
importOpenSearch(it, iconSize)
}
if (action != null) {
return@withContext action
}
val host = URL(u).host ?: return@withContext null
return@withContext knownWebsearchByHostname(host)
} catch (e: IOException) {
CrashReporter.logException(e)
} catch (e: XmlPullParserException) {
CrashReporter.logException(e)
}
return@withContext null
}
private suspend fun importOpenSearch(openSearchHref: String, iconSize: Int): WebsearchActionBuilder? {
try {
val httpClient = OkHttpClient()
val request = Request.Builder()
.url(openSearchHref)
.build()
val response = httpClient.newCall(request).execute()
val inputStream = response.body?.byteStream() ?: return null
var label: String? = null
var urlTemplate: String? = null
var icon: String? = null
var largestIconSize: Int = 0
var largestIcon: String? = null
inputStream.use {
val parser = Xml.newPullParser()
parser.setInput(inputStream.reader())
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG) {
when (parser.name) {
"ShortName" -> {
parser.next()
if (parser.eventType == XmlPullParser.TEXT) {
label = parser.text
}
}
"LongName" -> {
parser.next()
if (parser.eventType == XmlPullParser.TEXT) {
if (label != null) label = parser.text
}
}
"Image" -> {
val width =
parser.getAttributeValue(null, "width")?.toIntOrNull() ?: 0
if (width > largestIconSize || largestIcon == null) {
parser.next()
if (parser.eventType == XmlPullParser.TEXT) {
largestIcon = parser.text
largestIconSize = width
}
}
}
"Url" -> {
if (parser.getAttributeValue(null, "type") == "text/html") {
val rel = parser.getAttributeValue(null, "rel")
if (rel == null || rel == "results") {
val template =
parser.getAttributeValue(null, "template")
?.takeIf { it.isNotEmpty() } ?: continue
urlTemplate = template
.replace("{searchTerms}", "\${1}")
.replace("{startPage?}", "1")
}
}
}
else -> continue
}
}
}
val localIconUrl = largestIcon?.let {
val uri = Uri.parse(it)
createIcon(uri, iconSize)
}
return WebsearchActionBuilder(
label = label ?: "",
icon = if (localIconUrl == null) SearchActionIcon.Search else SearchActionIcon.Custom,
customIcon = localIconUrl,
iconColor = if (localIconUrl == null) 0 else 1,
urlTemplate = urlTemplate ?: ""
)
}
} catch (e: IOException) {
} catch (e: XmlPullParserException) {}
return null
}
override suspend fun createIcon(uri: Uri, size: Int): String? = withContext(
Dispatchers.IO
) {
val file = File(context.filesDir, UUID.randomUUID().toString())
val imageRequest = ImageRequest.Builder(context)
.data(uri)
.size(size)
.scale(Scale.FIT)
.build()
val drawable =
context.imageLoader.execute(imageRequest).drawable ?: return@withContext null
val scaledIcon = drawable.toBitmap()
val out = FileOutputStream(file)
scaledIcon.compress(Bitmap.CompressFormat.PNG, 100, out)
out.close()
return@withContext file.absolutePath
}
override suspend fun getSearchActivities(): List<ComponentName> {
return withContext(Dispatchers.Default) {
val resolveInfos = context.packageManager.queryIntentActivities(
Intent(Intent.ACTION_SEARCH).addCategory(Intent.CATEGORY_DEFAULT), PackageManager.GET_META_DATA,
)
resolveInfos.mapNotNull {
if (!it.activityInfo.exported || !it.activityInfo.enabled) return@mapNotNull null
if (it.activityInfo.permission != null && context.checkSelfPermission(it.activityInfo.permission) != PackageManager.PERMISSION_GRANTED) {
return@mapNotNull null
}
val componentName = ComponentName(it.activityInfo.packageName, it.activityInfo.name)
componentName
}
}
}
}
@@ -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,27 @@
package de.mm20.launcher2.searchactions.actions
import android.app.SearchManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Bundle
import de.mm20.launcher2.ktx.tryStartActivity
data class AppSearchAction(
override val label: String,
val baseIntent: Intent,
val query: String,
override val icon: SearchActionIcon = SearchActionIcon.Custom,
override val iconColor: Int = 1,
override val customIcon: String? = null,
): SearchAction {
override fun start(context: Context) {
val intent = Intent(baseIntent).apply {
action = Intent.ACTION_SEARCH
putExtra(SearchManager.QUERY, query)
putExtra(SearchManager.USER_QUERY, query)
}
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
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 val customIcon: String? = null
override fun start(context: Context) {
val intent = Intent(Intent.ACTION_DIAL).apply {
data = Uri.parse("tel:$number")
}
context.tryStartActivity(intent)
}
}
@@ -0,0 +1,26 @@
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 val customIcon: String? = null
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 de.mm20.launcher2.ktx.tryStartActivity
class CustomIntentAction(
override val label: String,
val query: String,
private val queryKey: String,
private val baseIntent: Intent,
override val icon: SearchActionIcon = SearchActionIcon.Custom,
override val iconColor: Int = 1,
override val customIcon: String? = null,
) : SearchAction {
override fun start(context: Context) {
val intent = Intent(baseIntent).also {
it.putExtra(queryKey, query)
}
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 val customIcon: String? = null
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 val customIcon: String? = null
override fun start(context: Context) {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse("sms:$number")
}
context.tryStartActivity(intent)
}
}
@@ -0,0 +1,26 @@
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,
override val icon: SearchActionIcon = SearchActionIcon.Website,
override val iconColor: Int = 0,
override val customIcon: String? = null,
) : SearchAction {
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 val customIcon: String? = null
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,45 @@
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
val customIcon: String?
fun start(context: Context)
}
enum class SearchActionIcon(private val value: Int) {
Search(0),
Custom(1),
Website(2),
Alarm(3),
Timer(4),
Contact(5),
Phone(6),
Email(7),
Message(8),
Calendar(9),
Translate(10),
WebSearch(11),
PersonSearch(12),
StatsSearch(13),
SearchPage(14),
SearchList(15),
ImageSearch(16),
Location(17),
Movie(18),
Music(19),
Game(20),
Note(21);
fun toInt(): Int {
return value
}
companion object {
fun fromInt(value: Int?): SearchActionIcon {
return values().firstOrNull { it.value == value } ?: Search
}
}
}
@@ -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 val customIcon: String? = null
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 val customIcon: String? = null
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,33 @@
package de.mm20.launcher2.searchactions.builders
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Bundle
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.TextType
import de.mm20.launcher2.searchactions.actions.AppSearchAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
data class AppSearchActionBuilder(
override val label: String,
val baseIntent: Intent,
override val icon: SearchActionIcon = SearchActionIcon.Custom,
override val iconColor: Int = 0,
override val customIcon: String? = null,
) : CustomizableSearchActionBuilder {
override val key: String
get() = "app://${baseIntent.toUri(0)}"
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction {
return AppSearchAction(
label = label,
baseIntent = baseIntent,
query = classifiedQuery.text,
icon = icon,
iconColor = iconColor,
customIcon = customIcon,
)
}
}
@@ -0,0 +1,30 @@
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
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
class CallActionBuilder(
override val label: String
): SearchActionBuilder {
constructor(context: Context): this(context.getString(R.string.search_action_call))
override val key: String
get() = "call"
override val icon: SearchActionIcon = SearchActionIcon.Phone
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,32 @@
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
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
class CreateContactActionBuilder(
override val label: String
) : SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_contact))
override val key: String
get() = "contact"
override val icon: SearchActionIcon = SearchActionIcon.Contact
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,26 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import android.content.Intent
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.CustomIntentAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
data class CustomIntentActionBuilder(
override val label: String,
val queryKey: String,
val baseIntent: Intent,
override val icon: SearchActionIcon = SearchActionIcon.Custom,
override val iconColor: Int = 1,
override val customIcon: String? = null,
) : CustomizableSearchActionBuilder {
override val key: String
get() = "intent://${baseIntent.toUri(0)}"
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction {
return CustomIntentAction(
label, classifiedQuery.text, queryKey, baseIntent, icon, iconColor, customIcon
)
}
}
@@ -0,0 +1,3 @@
package de.mm20.launcher2.searchactions.builders
sealed interface CustomizableSearchActionBuilder: SearchActionBuilder
@@ -0,0 +1,30 @@
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
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
class EmailActionBuilder(
override val label: String
): SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_email))
override val key: String
get() = "email"
override val icon: SearchActionIcon = SearchActionIcon.Email
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,29 @@
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
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
class MessageActionBuilder(
override val label: String
): SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_message))
override val key: String
get() = "message"
override val icon: SearchActionIcon = SearchActionIcon.Message
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,30 @@
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
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
class OpenUrlActionBuilder(
override val label: String
) : SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_open_url))
override val key: String
get() = "website"
override val icon: SearchActionIcon = SearchActionIcon.Website
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,41 @@
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 de.mm20.launcher2.searchactions.actions.SearchActionIcon
import java.time.LocalDateTime
class ScheduleEventActionBuilder(
override val label: String
) : SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_event))
override val key: String
get() = "calendar"
override val icon: SearchActionIcon = SearchActionIcon.Calendar
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,120 @@
package de.mm20.launcher2.searchactions.builders
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.media.metrics.Event
import de.mm20.launcher2.database.entities.SearchActionEntity
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import org.json.JSONException
import org.json.JSONObject
interface SearchActionBuilder {
val label: String
val icon: SearchActionIcon
val iconColor: Int
get() = 0
val customIcon: String?
get() = null
val key: String
fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction?
companion object {
internal fun from(context: Context, entity: SearchActionEntity): SearchActionBuilder? {
val options = entity.options?.let {
try {
JSONObject(it)
} catch (_: JSONException) {
null
}
}
when (entity.type) {
"url" -> {
return WebsearchActionBuilder(
label = entity.label ?: "",
urlTemplate = entity.data ?: return null,
iconColor = entity.color ?: 0,
icon = SearchActionIcon.fromInt(entity.icon),
customIcon = entity.customIcon,
encoding = WebsearchActionBuilder.QueryEncoding.fromInt(options?.optInt("encoding"))
)
}
"app" -> {
return AppSearchActionBuilder(
label = entity.label ?: "",
baseIntent = Intent.parseUri(entity.data, 0),
iconColor = entity.color ?: 0,
icon = SearchActionIcon.fromInt(entity.icon),
customIcon = entity.customIcon,
)
}
"intent" -> {
return CustomIntentActionBuilder(
entity.label ?: "",
baseIntent = Intent.parseUri(entity.data, 0),
iconColor = entity.color ?: 0,
icon = SearchActionIcon.fromInt(entity.icon),
customIcon = entity.customIcon,
queryKey = options?.getString("extra")?.takeIf { it.isNotEmpty() } ?: return null
)
}
"call" -> return CallActionBuilder(context)
"message" -> return MessageActionBuilder(context)
"email" -> return EmailActionBuilder(context)
"contact" -> return CreateContactActionBuilder(context)
"alarm" -> return SetAlarmActionBuilder(context)
"timer" -> return TimerActionBuilder(context)
"calendar" -> return ScheduleEventActionBuilder(context)
"website" -> return OpenUrlActionBuilder(context)
else -> return null
}
}
internal fun toDatabaseEntity(builder: SearchActionBuilder, position: Int): SearchActionEntity {
return when(builder) {
is WebsearchActionBuilder -> SearchActionEntity(
position = position,
type = "url",
label = builder.label,
data = builder.urlTemplate,
color = builder.iconColor,
icon = builder.icon.toInt(),
customIcon = builder.customIcon,
options = jsonObjectOf(
"encoding" to builder.encoding.toInt()
).toString()
)
is AppSearchActionBuilder -> SearchActionEntity(
position = position,
type = "app",
label = builder.label,
data = builder.baseIntent.toUri(0),
color = builder.iconColor,
icon = builder.icon.toInt(),
customIcon = builder.customIcon,
options = null
)
is CustomIntentActionBuilder -> SearchActionEntity(
position = position,
type = "intent",
label = builder.label,
data = builder.baseIntent.toUri(0),
color = builder.iconColor,
icon = builder.icon.toInt(),
customIcon = builder.customIcon,
options = jsonObjectOf(
"extra" to builder.queryKey
).toString()
)
else -> SearchActionEntity(
position = position,
type = builder.key,
)
}
}
}
}
@@ -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.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import de.mm20.launcher2.searchactions.actions.SetAlarmAction
import java.time.LocalDate
class SetAlarmActionBuilder(
override val label: String
) : SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_alarm))
override val key: String
get() = "alarm"
override val icon: SearchActionIcon = SearchActionIcon.Alarm
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,29 @@
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.SearchActionIcon
import de.mm20.launcher2.searchactions.actions.TimerAction
class TimerActionBuilder(
override val label: String,
) : SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_timer))
override val key: String
get() = "timer"
override val icon: SearchActionIcon = SearchActionIcon.Timer
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,66 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import android.net.Uri
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.OpenUrlAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import java.net.URLEncoder
data class WebsearchActionBuilder(
override val label: String,
val urlTemplate: String,
override val icon: SearchActionIcon = SearchActionIcon.Search,
override val iconColor: Int = 0,
override val customIcon: String? = null,
val encoding: QueryEncoding = QueryEncoding.UrlEncode,
) : CustomizableSearchActionBuilder {
override val key: String
get() = "web://$urlTemplate"
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction {
val url = urlTemplate.replace("\${1}", encodeQuery(classifiedQuery.text, encoding))
return OpenUrlAction(
label = label,
url = url,
icon = icon,
customIcon = customIcon,
iconColor = iconColor,
)
}
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
}
}
}
}
}