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