Add option to import websearches using the OpenSearch standard

Implements #31
This commit is contained in:
MM20
2022-02-24 18:27:46 +01:00
parent 4c0c75ac59
commit 2f43dab260
6 changed files with 266 additions and 19 deletions
@@ -1,9 +1,10 @@
package de.mm20.launcher2.search
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val searchModule = module {
single<WebsearchRepository> { WebsearchRepositoryImpl(get()) }
single<WebsearchRepository> { WebsearchRepositoryImpl(androidContext(), get()) }
viewModel { WebsearchViewModel(get()) }
}
@@ -1,12 +1,34 @@
package de.mm20.launcher2.search
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.AppDatabase
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.data.Websearch
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
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
interface WebsearchRepository {
fun search(query: String): Flow<List<Websearch>>
@@ -15,9 +37,13 @@ interface WebsearchRepository {
fun insertWebsearch(websearch: Websearch)
fun deleteWebsearch(websearch: Websearch)
suspend fun importWebsearch(url: String, iconSize: Int): Websearch?
suspend fun createIcon(uri: Uri, size: Int): String?
}
internal class WebsearchRepositoryImpl(
private val context: Context,
private val database: AppDatabase
) : WebsearchRepository, KoinComponent {
@@ -65,4 +91,120 @@ internal class WebsearchRepositoryImpl(
}
}
}
override suspend fun importWebsearch(url: String, iconSize: Int): Websearch? =
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) {
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 Websearch(
urlTemplate = urlTemplate ?: "",
label = label ?: "",
icon = localIconUrl,
color = 0,
)
}
} 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.dataDir, System.currentTimeMillis().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
}
}