Migrate websearches to search actions
This commit is contained in:
@@ -39,10 +39,14 @@ dependencies {
|
||||
implementation(libs.androidx.core)
|
||||
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.jsoup)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.coil.core)
|
||||
|
||||
implementation(project(":base"))
|
||||
implementation(project(":database"))
|
||||
implementation(project(":ktx"))
|
||||
implementation(project(":preferences"))
|
||||
implementation(project(":crashreporter"))
|
||||
|
||||
}
|
||||
@@ -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()) }
|
||||
}
|
||||
+110
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
-1
@@ -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
|
||||
}
|
||||
}
|
||||
+24
@@ -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)
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -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),
|
||||
}
|
||||
+22
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+11
-13
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user