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
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
6 changed files with 266 additions and 19 deletions

View File

@ -198,6 +198,8 @@
<string name="websearch_dialog_replace_icon">Replace icon</string>
<string name="websearch_dialog_delete_icon">Delete icon</string>
<string name="websearch_dialog_custom_icon">Custom icon</string>
<string name="websearch_dialog_import_url">Import from URL</string>
<string name="websearch_dialog_import_error">The given URL cannot be imported automatically. You can try a different URL or enter the required data manually.</string>
<string name="menu_edit_widgets">Edit widgets</string>
<string name="widget_name_weather">Weather</string>
<string name="widget_name_calendar">Calendar</string>

View File

@ -42,7 +42,12 @@ dependencies {
implementation(libs.koin.android)
implementation(libs.jsoup)
implementation(libs.okhttp)
implementation(libs.coil.core)
implementation(project(":base"))
implementation(project(":database"))
implementation(project(":preferences"))
implementation(project(":crashreporter"))
}

View File

@ -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()) }
}

View File

@ -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
}
}

View File

@ -85,7 +85,8 @@ fun WebSearchSettingsScreen() {
},
onCancel = {
showNewDialog = false
}
},
enableImport = true
)
}
}
@ -145,7 +146,8 @@ fun EditWebsearchDialog(
value: Websearch,
onValueSaved: (Websearch) -> Unit,
onValueDeleted: ((Websearch) -> Unit)? = null,
onCancel: () -> Unit
onCancel: () -> Unit,
enableImport: Boolean = false
) {
val context = LocalContext.current
var showDropdown by remember { mutableStateOf(false) }
@ -158,6 +160,10 @@ fun EditWebsearchDialog(
val scope = rememberCoroutineScope()
var showImport by remember { mutableStateOf(false) }
var loadingImport by remember { mutableStateOf(false) }
var importError by remember { mutableStateOf(false) }
val viewModel: WebSearchSettingsScreenVM = viewModel()
val iconSizePx = 32.dp.toPixels().toInt()
@ -167,11 +173,13 @@ fun EditWebsearchDialog(
) {
if (it != null) {
scope.launch {
icon = viewModel.createIcon(context, it, iconSizePx)
icon = viewModel.createIcon(it, iconSizePx)
}
}
}
Dialog(
onDismissRequest = onCancel,
properties = DialogProperties(usePlatformDefaultWidth = false)
@ -217,6 +225,20 @@ fun EditWebsearchDialog(
}) {
Icon(imageVector = Icons.Rounded.Save, contentDescription = null)
}
if (enableImport) {
Box {
IconButton(onClick = {
showImport = !showImport
importError = false
}) {
Icon(
imageVector = Icons.Rounded.Download,
contentDescription = null
)
}
}
}
if (onValueDeleted != null) {
Box {
IconButton(onClick = {
@ -248,6 +270,88 @@ fun EditWebsearchDialog(
}
) {
Column(modifier = Modifier.padding(16.dp)) {
AnimatedVisibility(showImport) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
var importUrl by remember { mutableStateOf("") }
OutlinedTextField(
modifier = Modifier
.weight(1f)
.padding(bottom = 16.dp, top = 8.dp, end = 8.dp),
label = { Text(stringResource(R.string.websearch_dialog_import_url)) },
value = importUrl,
onValueChange = {
importUrl = it
importError = false
}
)
if (loadingImport) {
CircularProgressIndicator(
modifier = Modifier
.padding(12.dp)
.size(24.dp)
)
} else {
IconButton(onClick = {
scope.launch {
loadingImport = true
val websearch =
viewModel.importWebsearch(
importUrl,
iconSizePx
)
if (websearch != null) {
label = websearch.label
icon = websearch.icon
urlTemplate = websearch.urlTemplate
color = websearch.color
showImport = false
} else {
importError = true
}
loadingImport = false
}
}) {
Icon(
imageVector = Icons.Rounded.ArrowForward,
contentDescription = null
)
}
}
}
AnimatedVisibility(importError) {
Column(
modifier = Modifier.padding(bottom = 8.dp)
) {
Text(
text = stringResource(R.string.websearch_dialog_import_error),
style = MaterialTheme.typography.labelSmall
)
TextButton(
modifier = Modifier.align(Alignment.End),
onClick = { showImport = false }) {
Text(
text = stringResource(android.R.string.ok),
style = MaterialTheme.typography.labelMedium
)
}
}
}
}
}
}
if (icon != null) {
Row(
verticalAlignment = Alignment.CenterVertically
@ -347,6 +451,7 @@ fun EditWebsearchDialog(
}
}
}
}
@Composable

View File

@ -45,20 +45,12 @@ class WebSearchSettingsScreenVM: ViewModel(), KoinComponent {
* Read a user-selected icon, scale it down and copy it to the app's data dir
* @return the absolute path of the copied file
*/
suspend fun createIcon(context: Context, 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
suspend fun createIcon(uri: Uri, size: Int): String? {
return repository.createIcon(uri, size)
}
suspend fun importWebsearch(url: String, size: Int): Websearch? {
return repository.importWebsearch(url, size)
}