Migrate websearches to search actions

This commit is contained in:
MM20
2022-11-03 22:17:37 +01:00
parent f862a578a1
commit 872f55625f
38 changed files with 1120 additions and 1275 deletions
@@ -4,6 +4,6 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val searchActionsModule = module {
single<SearchActionRepository> { SearchActionRepositoryImpl() }
single<SearchActionRepository> { SearchActionRepositoryImpl(androidContext(), get()) }
single<SearchActionService> { SearchActionServiceImpl(androidContext(), get(), TextClassifierImpl()) }
}
@@ -1,14 +1,123 @@
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.SearchActionBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONException
import java.io.File
import java.util.UUID
interface SearchActionRepository {
fun getSearchActionBuilders(filter: TextType?): Flow<List<SearchActionBuilder>>
suspend fun export(toDir: File)
suspend fun import(fromDir: File)
}
internal class SearchActionRepositoryImpl: SearchActionRepository {
internal class SearchActionRepositoryImpl(
private val context: Context,
private val database: AppDatabase
): SearchActionRepository {
override fun getSearchActionBuilders(filter: TextType?): Flow<List<SearchActionBuilder>> {
TODO("Not yet implemented")
}
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)
}
}
}
}
@@ -1,8 +1,20 @@
package de.mm20.launcher2.searchactions
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
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.database.entities.WebsearchEntity
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.preferences.Settings.SearchActionSettings
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import de.mm20.launcher2.searchactions.builders.CallActionBuilder
import de.mm20.launcher2.searchactions.builders.CreateContactActionBuilder
import de.mm20.launcher2.searchactions.builders.EmailActionBuilder
@@ -12,14 +24,33 @@ 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 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.flow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONException
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(settings: SearchActionSettings, query: String): Flow<ImmutableList<SearchAction>>
suspend fun importWebsearch(url: String, iconSize: Int): WebsearchActionBuilder?
suspend fun createIcon(uri: Uri, size: Int): String?
}
internal class SearchActionServiceImpl(
@@ -27,7 +58,10 @@ internal class SearchActionServiceImpl(
private val repository: SearchActionRepository,
private val textClassifier: TextClassifier,
) : SearchActionService {
override fun search(settings: SearchActionSettings, query: String): Flow<ImmutableList<SearchAction>> = flow {
override fun search(
settings: SearchActionSettings,
query: String
): Flow<ImmutableList<SearchAction>> = flow {
if (query.isBlank()) {
emit(persistentListOf())
return@flow
@@ -50,4 +84,125 @@ internal class SearchActionServiceImpl(
emit(builders.mapNotNull { it.build(context, classificationResult) }.toImmutableList())
}
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"
}
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() }
?: return@withContext run {
Log.d("MM20", "Specified URL does not implement the OpenSearch protocol")
null
}
val httpClient = OkHttpClient()
val request = Request.Builder()
.url(openSearchHref)
.build()
val response = httpClient.newCall(request).execute()
val inputStream = response.body?.byteStream() ?: return@withContext null
var label: String? = null
var urlTemplate: String? = null
var icon: String? = null
var iconSize: Int = 0
var iconUrl: 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 size =
parser.getAttributeValue(null, "width")?.toIntOrNull() ?: 0
if (size > iconSize || iconUrl == null) {
parser.next()
if (parser.eventType == XmlPullParser.TEXT) {
iconUrl = parser.text
iconSize = size
}
}
}
"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 = iconUrl?.let {
val uri = Uri.parse(it)
createIcon(uri, iconSize)
}
return@withContext WebsearchActionBuilder(
label = label ?: "",
icon = if (localIconUrl == null) SearchActionIcon.Search else SearchActionIcon.Custom,
customIcon = localIconUrl,
urlTemplate = urlTemplate ?: ""
)
}
} catch (e: IOException) {
CrashReporter.logException(e)
} catch (e: XmlPullParserException) {
CrashReporter.logException(e)
}
return@withContext 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
}
}
@@ -0,0 +1,24 @@
package de.mm20.launcher2.searchactions.actions
import android.app.SearchManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import de.mm20.launcher2.ktx.tryStartActivity
data class AppSearchAction(
override val label: String,
val componentName: ComponentName,
val query: String,
): SearchAction {
override val icon: SearchActionIcon = SearchActionIcon.Search
override val iconColor: Int = 0
override fun start(context: Context) {
val intent = Intent(Intent.ACTION_SEARCH).apply {
component = componentName
putExtra(SearchManager.QUERY, query)
}
context.tryStartActivity(intent)
}
}
@@ -10,16 +10,16 @@ interface SearchAction : Searchable {
fun start(context: Context)
}
enum class SearchActionIcon {
Search,
Website,
Alarm,
Timer,
Contact,
Phone,
Email,
Message,
Calendar,
Translate,
Custom,
enum class SearchActionIcon(value: Int) {
Search(0),
Custom(1),
Website(2),
Alarm(3),
Timer(4),
Contact(5),
Phone(6),
Email(7),
Message(8),
Calendar(9),
Translate(10),
}
@@ -0,0 +1,22 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import android.content.pm.LauncherActivityInfo
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
class AppSearchActionBuilder(
val label: String,
val activity: LauncherActivityInfo,
val filter: TextType? = null,
) : SearchActionBuilder {
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
return AppSearchAction(
label = label,
componentName = activity.componentName,
query = classifiedQuery.text,
)
}
}
@@ -2,28 +2,26 @@ 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 de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import java.net.URLEncoder
class WebsearchActionBuilder(
val label: String,
val urlTemplate: String,
val filter: TextType? = null,
val encoding: QueryEncoding,
val icon: SearchActionIcon = SearchActionIcon.Search,
val customIcon: String? = null,
val encoding: QueryEncoding = QueryEncoding.UrlEncode,
) : 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
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction {
val url = urlTemplate.replace("\${1}", encodeQuery(classifiedQuery.text, encoding))
return OpenUrlAction(
label = label,
url = url,
)
}