diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml
index 946a5a55..b1ef31ce 100644
--- a/i18n/src/main/res/values/strings.xml
+++ b/i18n/src/main/res/values/strings.xml
@@ -198,6 +198,8 @@
Replace icon
Delete icon
Custom icon
+ Import from URL
+ The given URL cannot be imported automatically. You can try a different URL or enter the required data manually.
Edit widgets
Weather
Calendar
diff --git a/search/build.gradle.kts b/search/build.gradle.kts
index d6ea204b..c71fe049 100644
--- a/search/build.gradle.kts
+++ b/search/build.gradle.kts
@@ -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"))
}
\ No newline at end of file
diff --git a/search/src/main/java/de/mm20/launcher2/search/Module.kt b/search/src/main/java/de/mm20/launcher2/search/Module.kt
index 67173f60..e7d60010 100644
--- a/search/src/main/java/de/mm20/launcher2/search/Module.kt
+++ b/search/src/main/java/de/mm20/launcher2/search/Module.kt
@@ -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 { WebsearchRepositoryImpl(get()) }
+ single { WebsearchRepositoryImpl(androidContext(), get()) }
viewModel { WebsearchViewModel(get()) }
}
\ No newline at end of file
diff --git a/search/src/main/java/de/mm20/launcher2/search/WebsearchRepository.kt b/search/src/main/java/de/mm20/launcher2/search/WebsearchRepository.kt
index 97eccd4d..66f5039b 100644
--- a/search/src/main/java/de/mm20/launcher2/search/WebsearchRepository.kt
+++ b/search/src/main/java/de/mm20/launcher2/search/WebsearchRepository.kt
@@ -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>
@@ -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
+ }
}
\ No newline at end of file
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreen.kt
index b230f630..5c67ef84 100644
--- a/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreen.kt
+++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreen.kt
@@ -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
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreenVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreenVM.kt
index 335c8c20..0e8fb66e 100644
--- a/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreenVM.kt
+++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreenVM.kt
@@ -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)
}