Add option to import websearches using the OpenSearch standard
Implements #31
This commit is contained in:
parent
4c0c75ac59
commit
2f43dab260
@ -198,6 +198,8 @@
|
|||||||
<string name="websearch_dialog_replace_icon">Replace icon</string>
|
<string name="websearch_dialog_replace_icon">Replace icon</string>
|
||||||
<string name="websearch_dialog_delete_icon">Delete icon</string>
|
<string name="websearch_dialog_delete_icon">Delete icon</string>
|
||||||
<string name="websearch_dialog_custom_icon">Custom 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="menu_edit_widgets">Edit widgets</string>
|
||||||
<string name="widget_name_weather">Weather</string>
|
<string name="widget_name_weather">Weather</string>
|
||||||
<string name="widget_name_calendar">Calendar</string>
|
<string name="widget_name_calendar">Calendar</string>
|
||||||
|
|||||||
@ -42,7 +42,12 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.koin.android)
|
implementation(libs.koin.android)
|
||||||
|
|
||||||
|
implementation(libs.jsoup)
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.coil.core)
|
||||||
|
|
||||||
implementation(project(":base"))
|
implementation(project(":base"))
|
||||||
implementation(project(":database"))
|
implementation(project(":database"))
|
||||||
implementation(project(":preferences"))
|
implementation(project(":preferences"))
|
||||||
|
implementation(project(":crashreporter"))
|
||||||
}
|
}
|
||||||
@ -1,9 +1,10 @@
|
|||||||
package de.mm20.launcher2.search
|
package de.mm20.launcher2.search
|
||||||
|
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val searchModule = module {
|
val searchModule = module {
|
||||||
single<WebsearchRepository> { WebsearchRepositoryImpl(get()) }
|
single<WebsearchRepository> { WebsearchRepositoryImpl(androidContext(), get()) }
|
||||||
viewModel { WebsearchViewModel(get()) }
|
viewModel { WebsearchViewModel(get()) }
|
||||||
}
|
}
|
||||||
@ -1,12 +1,34 @@
|
|||||||
package de.mm20.launcher2.search
|
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.database.AppDatabase
|
||||||
import de.mm20.launcher2.preferences.LauncherDataStore
|
import de.mm20.launcher2.preferences.LauncherDataStore
|
||||||
import de.mm20.launcher2.search.data.Websearch
|
import de.mm20.launcher2.search.data.Websearch
|
||||||
import kotlinx.coroutines.*
|
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.KoinComponent
|
||||||
import org.koin.core.component.inject
|
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 {
|
interface WebsearchRepository {
|
||||||
fun search(query: String): Flow<List<Websearch>>
|
fun search(query: String): Flow<List<Websearch>>
|
||||||
@ -15,9 +37,13 @@ interface WebsearchRepository {
|
|||||||
|
|
||||||
fun insertWebsearch(websearch: Websearch)
|
fun insertWebsearch(websearch: Websearch)
|
||||||
fun deleteWebsearch(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(
|
internal class WebsearchRepositoryImpl(
|
||||||
|
private val context: Context,
|
||||||
private val database: AppDatabase
|
private val database: AppDatabase
|
||||||
) : WebsearchRepository, KoinComponent {
|
) : 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -85,7 +85,8 @@ fun WebSearchSettingsScreen() {
|
|||||||
},
|
},
|
||||||
onCancel = {
|
onCancel = {
|
||||||
showNewDialog = false
|
showNewDialog = false
|
||||||
}
|
},
|
||||||
|
enableImport = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -145,7 +146,8 @@ fun EditWebsearchDialog(
|
|||||||
value: Websearch,
|
value: Websearch,
|
||||||
onValueSaved: (Websearch) -> Unit,
|
onValueSaved: (Websearch) -> Unit,
|
||||||
onValueDeleted: ((Websearch) -> Unit)? = null,
|
onValueDeleted: ((Websearch) -> Unit)? = null,
|
||||||
onCancel: () -> Unit
|
onCancel: () -> Unit,
|
||||||
|
enableImport: Boolean = false
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var showDropdown by remember { mutableStateOf(false) }
|
var showDropdown by remember { mutableStateOf(false) }
|
||||||
@ -158,6 +160,10 @@ fun EditWebsearchDialog(
|
|||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
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 viewModel: WebSearchSettingsScreenVM = viewModel()
|
||||||
|
|
||||||
val iconSizePx = 32.dp.toPixels().toInt()
|
val iconSizePx = 32.dp.toPixels().toInt()
|
||||||
@ -167,11 +173,13 @@ fun EditWebsearchDialog(
|
|||||||
) {
|
) {
|
||||||
if (it != null) {
|
if (it != null) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
icon = viewModel.createIcon(context, it, iconSizePx)
|
icon = viewModel.createIcon(it, iconSizePx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onCancel,
|
onDismissRequest = onCancel,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
@ -217,6 +225,20 @@ fun EditWebsearchDialog(
|
|||||||
}) {
|
}) {
|
||||||
Icon(imageVector = Icons.Rounded.Save, contentDescription = null)
|
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) {
|
if (onValueDeleted != null) {
|
||||||
Box {
|
Box {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
@ -248,6 +270,88 @@ fun EditWebsearchDialog(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
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) {
|
if (icon != null) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@ -347,6 +451,7 @@ fun EditWebsearchDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@ -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
|
* 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
|
* @return the absolute path of the copied file
|
||||||
*/
|
*/
|
||||||
suspend fun createIcon(context: Context, uri: Uri, size: Int): String? = withContext(
|
suspend fun createIcon(uri: Uri, size: Int): String? {
|
||||||
Dispatchers.IO) {
|
return repository.createIcon(uri, size)
|
||||||
val file = File(context.dataDir, System.currentTimeMillis().toString())
|
}
|
||||||
val imageRequest = ImageRequest.Builder(context)
|
|
||||||
.data(uri)
|
suspend fun importWebsearch(url: String, size: Int): Websearch? {
|
||||||
.size(size)
|
return repository.importWebsearch(url, 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user