diff --git a/calculator/src/main/java/de/mm20/launcher2/calculator/CalculatorRepository.kt b/calculator/src/main/java/de/mm20/launcher2/calculator/CalculatorRepository.kt index b204296d..c50663dd 100644 --- a/calculator/src/main/java/de/mm20/launcher2/calculator/CalculatorRepository.kt +++ b/calculator/src/main/java/de/mm20/launcher2/calculator/CalculatorRepository.kt @@ -1,57 +1,73 @@ package de.mm20.launcher2.calculator -import de.mm20.launcher2.preferences.LauncherPreferences +import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.search.data.Calculator +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.mariuszgromada.math.mxparser.Expression interface CalculatorRepository { fun search(query: String): Flow } -class CalculatorRepositoryImpl : CalculatorRepository { +class CalculatorRepositoryImpl : CalculatorRepository, KoinComponent { + + private val dataStore: LauncherDataStore by inject() override fun search(query: String): Flow = channelFlow { if (query.isBlank()) { send(null) return@channelFlow } - if (!LauncherPreferences.instance.searchCalculator) return@channelFlow - val calc = when { + val searchCalculator = dataStore.data.map { it.calculatorSearch.enabled } + searchCalculator.collectLatest { + if (it) { + send(queryCalculator(query)) + } else { + send(null) + } + } + } + + private suspend fun queryCalculator(query: String): Calculator? { + return when { query.matches(Regex("0x[0-9a-fA-F]+")) -> { val solution = query.substring(2).toIntOrNull(16) ?: run { - send(null) - return@channelFlow + return null } Calculator(term = query, solution = solution.toDouble()) } query.matches(Regex("0b[01]+")) -> { val solution = query.substring(2).toIntOrNull(2) ?: run { - send(null) - return@channelFlow + return null } Calculator(term = query, solution = solution.toDouble()) } query.matches(Regex("0[0-7]+")) -> { val solution = query.substring(1).toIntOrNull(8) ?: run { - send(null) - return@channelFlow + return null } Calculator(term = query, solution = solution.toDouble()) } else -> { - val exp = Expression(query) - if (exp.checkSyntax()) { - Calculator(term = query, solution = exp.calculate()) - } else { - val exp2 = Expression(query.replace(',', '.').replace(';', ',')) - if (exp2.checkSyntax()) { - Calculator(term = query, solution = exp2.calculate()) - } else null + withContext(Dispatchers.IO) { + val exp = Expression(query) + if (exp.checkSyntax()) { + Calculator(term = query, solution = exp.calculate()) + } else { + val exp2 = Expression(query.replace(',', '.').replace(';', ',')) + if (exp2.checkSyntax()) { + Calculator(term = query, solution = exp2.calculate()) + } else null + } } } } - send(calc) } } \ No newline at end of file diff --git a/calendar/src/main/java/de/mm20/launcher2/calendar/CalendarRepository.kt b/calendar/src/main/java/de/mm20/launcher2/calendar/CalendarRepository.kt index 73dc8237..eb1a9314 100644 --- a/calendar/src/main/java/de/mm20/launcher2/calendar/CalendarRepository.kt +++ b/calendar/src/main/java/de/mm20/launcher2/calendar/CalendarRepository.kt @@ -1,7 +1,13 @@ package de.mm20.launcher2.calendar +import android.content.ContentUris import android.content.Context +import android.provider.CalendarContract +import androidx.core.database.getStringOrNull import de.mm20.launcher2.hiddenitems.HiddenItemsRepository +import de.mm20.launcher2.permissions.PermissionGroup +import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.preferences.LauncherPreferences import de.mm20.launcher2.search.data.CalendarEvent import kotlinx.coroutines.Dispatchers @@ -9,6 +15,10 @@ import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.* import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.component.inject +import java.util.* interface CalendarRepository { fun search(query: String): Flow> @@ -19,34 +29,131 @@ interface CalendarRepository { class CalendarRepositoryImpl( private val context: Context, hiddenItemsRepository: HiddenItemsRepository -) : CalendarRepository { +) : CalendarRepository, KoinComponent { + + private val dataStore: LauncherDataStore by inject() + private val permissionsManager: PermissionsManager by inject() private val hiddenItems = hiddenItemsRepository.hiddenItemsKeys override fun search(query: String): Flow> = channelFlow { - if (query.isBlank()) { + if (query.isBlank() || query.length < 3) { send(emptyList()) return@channelFlow } - val events = withContext(Dispatchers.IO) { - val now = System.currentTimeMillis() - CalendarEvent.search( - context, - query, - intervalStart = now, - intervalEnd = now + 14 * 24 * 60 * 60 * 1000L, - ) - } - hiddenItems.collectLatest { hiddenItems -> - val calendarResults = withContext(Dispatchers.IO) { - events.filter { !hiddenItems.contains(it.key) } + val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar) + val searchCalendar = dataStore.data.map { it.calendarSearch.enabled } + combine(hasPermission, searchCalendar) { permission, search -> + permission && search + }.map { + if (it) { + val now = System.currentTimeMillis() + queryCalendarEvents( + query, + intervalStart = now, + intervalEnd = now + 14 * 24 * 60 * 60 * 1000L, + ) + } else { + emptyList() } - send(calendarResults) + }.flatMapLatest { events -> + hiddenItems.map { hidden -> + events.filter { !hidden.contains(it.key) } + } + }.collectLatest { + send(it) } } + private suspend fun queryCalendarEvents( + query: String, + intervalStart: Long, + intervalEnd: Long, + limit: Int = 10, + excludeAllDayEvents: Boolean = false, + excludeCalendars: List = emptyList(), + ): List { + val results = withContext(Dispatchers.IO) { + val results = mutableListOf() + val builder = CalendarContract.Instances.CONTENT_URI.buildUpon() + ContentUris.appendId(builder, intervalStart) + ContentUris.appendId(builder, intervalEnd) + val uri = builder.build() + val projection = arrayOf( + CalendarContract.Instances.EVENT_ID, + CalendarContract.Instances.TITLE, + CalendarContract.Instances.BEGIN, + CalendarContract.Instances.END, + CalendarContract.Instances.ALL_DAY, + CalendarContract.Instances.DISPLAY_COLOR, + CalendarContract.Instances.EVENT_LOCATION, + CalendarContract.Instances.CALENDAR_ID, + CalendarContract.Instances.DESCRIPTION + ) + val selection = mutableListOf() + if (query.isNotEmpty()) selection.add("${CalendarContract.Instances.TITLE} LIKE ?") + if (excludeCalendars.isNotEmpty()) selection.add("${CalendarContract.Instances.CALENDAR_ID} NOT IN (${excludeCalendars.joinToString()})") + if (excludeAllDayEvents) selection.add("${CalendarContract.Instances.ALL_DAY} = 0") + val selArgs = if (query.isBlank()) null else arrayOf("%$query%") + val sort = + "${CalendarContract.Instances.BEGIN} ASC" + if (limit > -1) " LIMIT $limit" else "" + val cursor = context.contentResolver.query( + uri, + projection, + selection.joinToString(separator = " AND "), + selArgs, + sort + ) ?: return@withContext mutableListOf() + val proj = arrayOf( + CalendarContract.Attendees.EVENT_ID, + CalendarContract.Attendees.ATTENDEE_NAME, + CalendarContract.Attendees.ATTENDEE_EMAIL + ) + val s = "${CalendarContract.Attendees.ATTENDEE_NAME} COLLATE NOCASE ASC" + while (cursor.moveToNext()) { + val sel = "${CalendarContract.Attendees.EVENT_ID} = ${cursor.getLong(0)}" + val cur = context.contentResolver.query( + CalendarContract.Attendees.CONTENT_URI, + proj, sel, null, s + ) ?: return@withContext mutableListOf() + val attendees = mutableListOf() + while (cur.moveToNext()) { + attendees.add(cur.getString(1).takeUnless { it.isNullOrBlank() } + ?: cur.getString(2)) + } + cur.close() + val allday = cursor.getInt(4) > 0 + val begin = cursor.getLong(2) + + val tzOffset = if (allday) { + Calendar.getInstance().timeZone.getOffset(begin) + } else { + 0 + } + val event = CalendarEvent( + label = cursor.getString(1) ?: "", + id = cursor.getLong(0), + color = cursor.getInt(5), + startTime = begin - tzOffset, + endTime = cursor.getLong(3) - tzOffset - if (allday) 1 else 0, + allDay = allday, + location = cursor.getString(6) ?: "", + attendees = attendees, + description = cursor.getStringOrNull(8) + ?: "", + calendar = cursor.getLong(7) + ) + results.add(event) + } + cursor.close() + return@withContext results + } + + return results + } + override fun getUpcomingEvents(): Flow> = channelFlow { val unselectedCalendars = callbackFlow { val unregister = @@ -70,23 +177,26 @@ class CalendarRepositoryImpl( } } - merge(unselectedCalendars, hideAlldayEvents, hiddenItems).collectLatest { - val now = System.currentTimeMillis() - val end = now + 14 * 24 * 60 * 60 * 1000L - val events = withContext(Dispatchers.IO) { - CalendarEvent.search( - context = context, - query = "", - intervalStart = now, - intervalEnd = end, - limit = 700, - hideAllDayEvents = LauncherPreferences.instance.calendarHideAllday, - unselectedCalendars = LauncherPreferences.instance.unselectedCalendars - ).filter { - !hiddenItems.value.contains(it.key) + hideAlldayEvents.collectLatest { hideAllday -> + unselectedCalendars.collectLatest { unselected -> + hiddenItems.collectLatest { hidden -> + val now = System.currentTimeMillis() + val end = now + 14 * 24 * 60 * 60 * 1000L + val events = withContext(Dispatchers.IO) { + queryCalendarEvents( + query = "", + intervalStart = now, + intervalEnd = end, + limit = 700, + excludeAllDayEvents = hideAllday, + excludeCalendars = unselected + ).filter { + !hiddenItems.value.contains(it.key) + } + } + send(events) } } - send(events) } } diff --git a/calendar/src/main/java/de/mm20/launcher2/search/data/CalendarEvent.kt b/calendar/src/main/java/de/mm20/launcher2/search/data/CalendarEvent.kt index 72083ca5..5a2ee72e 100644 --- a/calendar/src/main/java/de/mm20/launcher2/search/data/CalendarEvent.kt +++ b/calendar/src/main/java/de/mm20/launcher2/search/data/CalendarEvent.kt @@ -80,99 +80,6 @@ class CalendarEvent( } companion object: KoinComponent { - fun search( - context: Context, - query: String, - intervalStart: Long, - intervalEnd: Long, - limit: Int = 10, - hideAllDayEvents: Boolean = false, - unselectedCalendars: List = emptyList(), - hiddenEvents: List = emptyList() - ): List { - val permissionsManager: PermissionsManager = get() - val results = mutableListOf() - if (!query.isEmpty() && query.length < 3) return results - if (!LauncherPreferences.instance.searchCalendars) return listOf() - if (!permissionsManager.checkPermissionOnce(PermissionGroup.Calendar)) { - return emptyList() - } - val builder = CalendarContract.Instances.CONTENT_URI.buildUpon() - ContentUris.appendId(builder, intervalStart) - ContentUris.appendId(builder, intervalEnd) - val uri = builder.build() - val projection = arrayOf( - CalendarContract.Instances.EVENT_ID, - CalendarContract.Instances.TITLE, - CalendarContract.Instances.BEGIN, - CalendarContract.Instances.END, - CalendarContract.Instances.ALL_DAY, - CalendarContract.Instances.DISPLAY_COLOR, - CalendarContract.Instances.EVENT_LOCATION, - CalendarContract.Instances.CALENDAR_ID, - CalendarContract.Instances.DESCRIPTION - ) - val selection = mutableListOf() - if (query.isNotEmpty()) selection.add("${CalendarContract.Instances.TITLE} LIKE ?") - if (hiddenEvents.isNotEmpty()) selection.add("${CalendarContract.Instances.EVENT_ID} NOT IN (${hiddenEvents.joinToString()})") - if (unselectedCalendars.isNotEmpty()) selection.add("${CalendarContract.Instances.CALENDAR_ID} NOT IN (${unselectedCalendars.joinToString()})") - if (hideAllDayEvents) selection.add("${CalendarContract.Instances.ALL_DAY} = 0") - val selArgs = if (query.isBlank()) null else arrayOf("%$query%") - val sort = - "${CalendarContract.Instances.BEGIN} ASC" + if (limit > -1) " LIMIT $limit" else "" - val cursor = context.contentResolver.query( - uri, - projection, - selection.joinToString(separator = " AND "), - selArgs, - sort - ) - ?: return mutableListOf() - val proj = arrayOf( - CalendarContract.Attendees.EVENT_ID, - CalendarContract.Attendees.ATTENDEE_NAME, - CalendarContract.Attendees.ATTENDEE_EMAIL - ) - val s = "${CalendarContract.Attendees.ATTENDEE_NAME} COLLATE NOCASE ASC" - while (cursor.moveToNext()) { - val sel = "${CalendarContract.Attendees.EVENT_ID} = ${cursor.getLong(0)}" - val cur = context.contentResolver.query( - CalendarContract.Attendees.CONTENT_URI, - proj, sel, null, s - ) ?: return mutableListOf() - val attendees = mutableListOf() - while (cur.moveToNext()) { - attendees.add(cur.getString(1).takeUnless { it.isNullOrBlank() } - ?: cur.getString(2)) - } - cur.close() - val allday = cursor.getInt(4) > 0 - val begin = cursor.getLong(2) - - val tzOffset = if (allday) { - Calendar.getInstance().timeZone.getOffset(begin) - } else { - 0 - } - val event = CalendarEvent( - label = cursor.getString(1) ?: "", - id = cursor.getLong(0), - color = cursor.getInt(5), - startTime = begin - tzOffset, - endTime = cursor.getLong(3) - tzOffset - if (allday) 1 else 0, - allDay = allday, - location = cursor.getString(6) ?: "", - attendees = attendees, - description = cursor.getStringOrNull(8) - ?: "", - calendar = cursor.getLong(7) - ) - results.add(event) - } - cursor.close() - - return results - } fun getCalendars(context: Context): List { val calendars = mutableListOf() diff --git a/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt b/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt index 0a350c1a..21627d87 100644 --- a/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt +++ b/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt @@ -1,14 +1,17 @@ package de.mm20.launcher2.contacts import android.content.Context +import android.provider.ContactsContract import de.mm20.launcher2.hiddenitems.HiddenItemsRepository +import de.mm20.launcher2.permissions.PermissionGroup +import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.search.data.Contact import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.* import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject interface ContactRepository { fun search(query: String): Flow> @@ -17,19 +20,61 @@ interface ContactRepository { class ContactRepositoryImpl( private val context: Context, hiddenItemsRepository: HiddenItemsRepository -) : ContactRepository { +) : ContactRepository, KoinComponent { + private val permissionsManager: PermissionsManager by inject() + private val dataStore: LauncherDataStore by inject() private val hiddenItems = hiddenItemsRepository.hiddenItemsKeys override fun search(query: String): Flow> = channelFlow { - val contacts = withContext(Dispatchers.IO) { - Contact.search(context, query) + val searchContacts = dataStore.data.map { it.contactsSearch.enabled } + val hasPermission = permissionsManager.hasPermission(PermissionGroup.Contacts) + + if (query.length < 3) { + send(emptyList()) + return@channelFlow } - hiddenItems.collectLatest { hiddenItems -> - val contactResults = withContext(Dispatchers.IO) { - contacts.filter { !hiddenItems.contains(it.key) } + + combine(searchContacts, hasPermission) { search, permission -> + search && permission + }.map { + if (it) { + queryContacts(query) + } else { + emptyList() } - send(contactResults) + }.flatMapLatest { contacts -> + hiddenItems.map { hidden -> + contacts.filter { !hidden.contains(it.key) } + } + }.collectLatest { + send(it) } } + + private suspend fun queryContacts(query: String): List { + val results = withContext(Dispatchers.IO) { + val proj = arrayOf( + ContactsContract.RawContacts.CONTACT_ID, + ContactsContract.RawContacts._ID + ) + val sel = "${ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY} LIKE ?" + val selArgs = arrayOf("%$query%") + val cursor = context.contentResolver.query( + ContactsContract.RawContacts.CONTENT_URI, proj, sel, selArgs, null + ) ?: return@withContext mutableListOf() + //Maps raw contact ids to contact ids + val contactMap = mutableMapOf>() + while (cursor.moveToNext()) { + contactMap.getOrPut(cursor.getLong(0)) { mutableSetOf() }.add(cursor.getLong(1)) + } + cursor.close() + val results = mutableListOf() + for ((id, rawIds) in contactMap) { + Contact.contactById(context, id, rawIds)?.let { results.add(it) } + } + results.sortedBy { it } + } + return results + } } \ No newline at end of file diff --git a/contacts/src/main/java/de/mm20/launcher2/search/data/Contact.kt b/contacts/src/main/java/de/mm20/launcher2/search/data/Contact.kt index 991ddf71..b7512570 100644 --- a/contacts/src/main/java/de/mm20/launcher2/search/data/Contact.kt +++ b/contacts/src/main/java/de/mm20/launcher2/search/data/Contact.kt @@ -74,38 +74,7 @@ class Contact( return null } - companion object: KoinComponent { - fun search(context: Context, query: String): List { - if (query.length < 3) return mutableListOf() - if (!LauncherPreferences.instance.searchContacts) { - return mutableListOf() - } - val permissionsManager: PermissionsManager = get() - if (!permissionsManager.checkPermissionOnce(PermissionGroup.Contacts)) { - return mutableListOf() - } - val proj = arrayOf( - ContactsContract.RawContacts.CONTACT_ID, - ContactsContract.RawContacts._ID - ) - val sel = "${ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY} LIKE ?" - val selArgs = arrayOf("%$query%") - val cursor = context.contentResolver.query( - ContactsContract.RawContacts.CONTENT_URI, proj, sel, selArgs, null - ) ?: return mutableListOf() - //Maps raw contact ids to contact ids - val contactMap = mutableMapOf>() - while (cursor.moveToNext()) { - contactMap.getOrPut(cursor.getLong(0)) { mutableSetOf() }.add(cursor.getLong(1)) - } - cursor.close() - val results = mutableListOf() - for ((id, rawIds) in contactMap) { - contactById(context, id, rawIds)?.let { results.add(it) } - } - return results.sortedBy { it } - } - + companion object { internal fun contactById(context: Context, id: Long, rawIds: Set): Contact? { val s = "(" + rawIds.joinToString(separator = " OR ", transform = { "${ContactsContract.Data.RAW_CONTACT_ID} = $it" }) + ")" + diff --git a/i18n/src/main/res/values-de/strings.xml b/i18n/src/main/res/values-de/strings.xml index 7b6fd8e1..77a016ac 100644 --- a/i18n/src/main/res/values-de/strings.xml +++ b/i18n/src/main/res/values-de/strings.xml @@ -106,34 +106,23 @@ Paketname Min-SDK-Version Dienste - Suche - Suchkategorien Favoriten Favoriten anzeigen Favoritenliste über allen Apps anzeigen Häufig genutze Elemente Häufig genutzte Elemente automatisch in den Favoriten anzeigen Dateien - Dateisuche - Ordner, Dokumente, Fotos und andere Dateitypen auf diesem Gerät durchsuchen Wikipedia - Wikipedia durchsuchen Mobile Daten verwenden Zusätzliche Kosten können anfallen Webseiten - Nach Webseiten suchen - Probieren Sie „wikipedia.org“ Standard Web-Protokoll Standard-Protokoll, um Webseiten zu suchen, wenn nicht angegeben. Stellen Sie https:// oder http:// vor Ihre Suchanfrage, um das Protokoll explizit festzulegen HTTP (unverschlüsselt) HTTPS (verschlüsselt) Taschenrechner - Probieren Sie „4*2+9“ - Taschenrechner aktivieren Probieren Sie „%1$s“ Websuche - Shortcuts zu verschiedenen Websuch-Engines anzeigen - Websuchen-Shortcuts Websuchen bearbeiten Ort Google @@ -192,9 +181,7 @@ Ort %1$d Postadressen Kalender - Kalendersuche aktivieren Kontakte - Kontakte durchsuchen App-Info Verbundene Accounts und Dienste verwalten Google @@ -277,8 +264,6 @@ Unbekannt Von diesem Wetterdienst nicht unterstützt Einheitenrechner - Probieren Sie „23 kg“ oder„5 cm >> in“ - Einheitenrechner aktivieren Symbol-Hintergrund Keiner Dynamisch @@ -418,8 +403,10 @@ Spaltenanzahl Gewähren - Standortzugriff wird benötigt um den Standort automatisch zu ermitteln - Benachrichtigungszugriff wird benötigt um Medienwiedergabe zu steuern + Standortzugriff wird benötigt, um den Standort automatisch zu ermitteln + Benachrichtigungszugriff wird benötigt, um Medienwiedergabe zu steuern + Kontakteberechtigung ist erforderlich, um Kontakte durchsuchen zu können + Kalenderberechtigung ist erforderlich, um Kalender durchsuchen zu können Standort festlegen Debug @@ -445,6 +432,27 @@ Auf Musik-Apps begrenzen Mediensitzungen von Apps ignorieren, die keine Musik-Apps sind + Suche + Konfigurieren was durchsucht werden soll + Favoriten + Angepinnte und häufig genutzte Elemente über dem App-Raster anzeigen + Dateien + Lokale Dateien und Cloud-Dateien durchsuchen + Kontakte + Kontakte auf diesem Gerät durchsuchen + Kalender + Anstehende Termine durchsuchen + Taschenrechner + Mathematische Terme auswerten + Einheitenrechner + Benutzung: „1,5 kg“ oder „4 cm >> in“ + Wikipedia + Die freie Enzyklopädie durchsuchen + Webseiten + Vorschau einer Webseite anzeigen wenn die Suchanfrage eine URL ist + Websuchen + Shortcuts zu verschiedenen Websuch-Engines anzeigen + %1$s spielt Medien Bisher wurden keine Medien abgespielt \ No newline at end of file diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 483d4b02..6d1b92e6 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -147,8 +147,6 @@ Package name Min SDK version Services - Search - Search categories Auto (by time of day) Follow system Favorites @@ -157,29 +155,18 @@ Frequently used items Automatically add frequently used items to favorites Files - File search - Search folders, documents, photos and other kinds of file on this device Wikipedia - Search Wikipedia Allow mobile data usage Additional fees may apply Websites - Search for websites - Try \'wikipedia.org\' Default web protocol Default protocol for websites, if not given. You can explicate protocol by adding https:// or http:// in front of your search term HTTP (unencrypted) HTTPS (encrypted) - Enable calculator - Try \'4*2+9\' Unit converter - Try \'23 kg\' or \'5 cm >> in\' - Enable unit converter Calculator Try \'%1$s\' Web search - Show shortcuts to several web search engines - Web search shortcuts Edit web searches Location Google @@ -237,9 +224,7 @@ Location %1$d postal addresses Calendar - Search calendar events Contacts - Search contacts App info Manage connected accounts and services Google @@ -458,6 +443,8 @@ Grant Location access is required to determine the location automatically Notification access is required to control media playback + Contact permission is required to search contacts + Calendar permission is required to search contacts Set location Debug @@ -480,6 +467,27 @@ Error and crash reports Export log file + Search + Configure what should be searched + Favorites + Show pinned and frequently used items above app grid + Files + Search local files and cloud files + Contacts + Search contacts on this device + Calendar + Search upcoming calendar events + Calculator + Evaluate mathematical terms + Unit converter + Usage: "1.5 kg" or "4 cm >> in" + Wikipedia + Search the free encyclopedia + Websites + Show a preview of a website if the search query is a URL + Web search + Show shortcuts to different search engines + Restrict to music apps Ignore media sessions of apps that are not music apps diff --git a/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt b/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt index 905bfe16..7ba5f452 100644 --- a/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt +++ b/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt @@ -27,5 +27,46 @@ fun createFactorySettings(context: Context): Settings { .setClockStyle(Settings.ClockWidgetSettings.ClockStyle.DigitalClock1) .build() ) + .setFavorites(Settings.FavoritesSettings + .newBuilder() + .setEnabled(true) + ) + .setFileSearch(Settings.FilesSearchSettings + .newBuilder() + .setLocalFiles(true) + .setNextcloud(false) + .setGdrive(false) + .setOnedrive(false) + .setNextcloud(false) + ) + .setContactsSearch(Settings.ContactsSearchSettings + .newBuilder() + .setEnabled(true) + ) + .setCalendarSearch(Settings.CalendarSearchSettings + .newBuilder() + .setEnabled(true) + ) + .setCalculatorSearch(Settings.CalculatorSearchSettings + .newBuilder() + .setEnabled(true) + ) + .setUnitConverterSearch(Settings.UnitConverterSearchSettings + .newBuilder() + .setEnabled(true) + ) + .setWikipediaSearch(Settings.WikipediaSearchSettings + .newBuilder() + .setEnabled(false) + .setCustomUrl(null) + ) + .setWebsiteSearch(Settings.WebsiteSearchSettings + .newBuilder() + .setEnabled(false) + ) + .setWebSearch(Settings.WebSearchSettings + .newBuilder() + .setEnabled(true) + ) .build() } \ No newline at end of file diff --git a/preferences/src/main/proto/settings.proto b/preferences/src/main/proto/settings.proto index b503cf14..f7d15965 100644 --- a/preferences/src/main/proto/settings.proto +++ b/preferences/src/main/proto/settings.proto @@ -52,4 +52,55 @@ message Settings { } ClockWidgetSettings clock_widget = 7; + message FavoritesSettings { + bool enabled = 1; + } + FavoritesSettings favorites = 8; + + message FilesSearchSettings { + bool local_files = 1; + bool gdrive = 2; + bool onedrive = 3; + bool nextcloud = 4; + bool owncloud = 5; + } + FilesSearchSettings file_search = 9; + + message ContactsSearchSettings { + bool enabled = 1; + } + ContactsSearchSettings contacts_search = 10; + + message CalendarSearchSettings { + bool enabled = 1; + } + CalendarSearchSettings calendar_search = 11; + + message CalculatorSearchSettings { + bool enabled = 1; + } + CalculatorSearchSettings calculator_search = 12; + + message UnitConverterSearchSettings { + bool enabled = 1; + } + UnitConverterSearchSettings unit_converter_search = 13; + + message WikipediaSearchSettings { + bool enabled = 1; + bool images = 2; + string custom_url = 3; + } + WikipediaSearchSettings wikipedia_search = 14; + + message WebsiteSearchSettings { + bool enabled = 1; + } + WebsiteSearchSettings website_search = 15; + + message WebSearchSettings { + bool enabled = 1; + } + WebSearchSettings web_search = 16; + } \ No newline at end of file diff --git a/search/build.gradle.kts b/search/build.gradle.kts index 15e74604..cb3fa763 100644 --- a/search/build.gradle.kts +++ b/search/build.gradle.kts @@ -45,4 +45,5 @@ dependencies { implementation(project(":base")) implementation(project(":database")) + implementation(project(":preferences")) } \ 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 f8edc5da..77888ae9 100644 --- a/search/src/main/java/de/mm20/launcher2/search/WebsearchRepository.kt +++ b/search/src/main/java/de/mm20/launcher2/search/WebsearchRepository.kt @@ -1,9 +1,12 @@ package de.mm20.launcher2.search 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 org.koin.core.component.KoinComponent +import org.koin.core.component.inject interface WebsearchRepository { fun search(query: String): Flow> @@ -16,7 +19,9 @@ interface WebsearchRepository { class WebsearchRepositoryImpl( private val database: AppDatabase -) : WebsearchRepository { +) : WebsearchRepository, KoinComponent { + + private val dataStore: LauncherDataStore by inject() private val scope = CoroutineScope(Job() + Dispatchers.Main) @@ -25,12 +30,18 @@ class WebsearchRepositoryImpl( send(emptyList()) return@channelFlow } - withContext(Dispatchers.IO) { - database.searchDao().getWebSearches().map { - it.map { Websearch(it, query) } + dataStore.data.map { it.webSearch.enabled }.collectLatest { + if (it) { + withContext(Dispatchers.IO) { + database.searchDao().getWebSearches().map { + it.map { Websearch(it, query) } + } + }.collectLatest { + send(it) + } + } else { + send(emptyList()) } - }.collectLatest { - send(it) } } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceWithSwitch.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceWithSwitch.kt new file mode 100644 index 00000000..6352176d --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceWithSwitch.kt @@ -0,0 +1,57 @@ +package de.mm20.launcher2.ui.component.preferences + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@Composable +fun PreferenceWithSwitch( + title: String, + summary: String? = null, + icon: ImageVector? = null, + enabled: Boolean = true, + onClick: () -> Unit = {}, + switchValue: Boolean, + onSwitchChanged: (Boolean) -> Unit +) { + Row( + verticalAlignment = (Alignment.CenterVertically) + ) { + Box( + modifier = Modifier.weight(1f) + ) { + Preference( + title = title, + summary = summary, + icon = icon, + enabled = enabled, + onClick = onClick + ) + } + Box( + modifier = Modifier + .height(36.dp) + .width(1.dp) + .alpha(0.38f) + .background(LocalContentColor.current) + ) + Switch( + modifier = Modifier.padding(horizontal = 16.dp), + checked = switchValue, + enabled = enabled, + onCheckedChange = onSwitchChanged, + colors = SwitchDefaults.colors( + uncheckedThumbColor = MaterialTheme.colorScheme.onSurface + ) + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/icons/Icons.kt b/ui/src/main/java/de/mm20/launcher2/ui/icons/Icons.kt index 12b69aba..c4bc5f8c 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/icons/Icons.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/icons/Icons.kt @@ -763,4 +763,47 @@ val Icons.Rounded.Fdroid lineTo(6.3535156f, 10.242188f) close() } + } + +val Icons.Rounded.Wikipedia + get() = materialIcon("Icons.Rounded.Wikipedia") { + materialPath { + moveTo(14.97f, 18.95f) + lineTo(12.41f, 12.92f) + curveTo(11.39f, 14.91f, 10.27f, 17f, 9.31f, 18.95f) + curveTo(9.3f, 18.96f, 8.84f, 18.95f, 8.84f, 18.95f) + curveTo(7.37f, 15.5f, 5.85f, 12.1f, 4.37f, 8.68f) + curveTo(4.03f, 7.84f, 2.83f, 6.5f, 2f, 6.5f) + curveTo(2f, 6.4f, 2f, 6.18f, 2f, 6.05f) + horizontalLineTo(7.06f) + verticalLineTo(6.5f) + curveTo(6.46f, 6.5f, 5.44f, 6.9f, 5.7f, 7.55f) + curveTo(6.42f, 9.09f, 8.94f, 15.06f, 9.63f, 16.58f) + curveTo(10.1f, 15.64f, 11.43f, 13.16f, 12f, 12.11f) + curveTo(11.55f, 11.23f, 10.13f, 7.93f, 9.71f, 7.11f) + curveTo(9.39f, 6.57f, 8.58f, 6.5f, 7.96f, 6.5f) + curveTo(7.96f, 6.35f, 7.97f, 6.25f, 7.96f, 6.06f) + lineTo(12.42f, 6.07f) + verticalLineTo(6.47f) + curveTo(11.81f, 6.5f, 11.24f, 6.71f, 11.5f, 7.29f) + curveTo(12.1f, 8.53f, 12.45f, 9.42f, 13f, 10.57f) + curveTo(13.17f, 10.23f, 14.07f, 8.38f, 14.5f, 7.41f) + curveTo(14.76f, 6.76f, 14.37f, 6.5f, 13.29f, 6.5f) + curveTo(13.3f, 6.38f, 13.3f, 6.17f, 13.3f, 6.07f) + curveTo(14.69f, 6.06f, 16.78f, 6.06f, 17.15f, 6.05f) + verticalLineTo(6.47f) + curveTo(16.44f, 6.5f, 15.71f, 6.88f, 15.33f, 7.46f) + lineTo(13.5f, 11.3f) + curveTo(13.68f, 11.81f, 15.46f, 15.76f, 15.65f, 16.2f) + lineTo(19.5f, 7.37f) + curveTo(19.2f, 6.65f, 18.34f, 6.5f, 18f, 6.5f) + curveTo(18f, 6.37f, 18f, 6.2f, 18f, 6.05f) + lineTo(22f, 6.08f) + verticalLineTo(6.1f) + lineTo(22f, 6.5f) + curveTo(21.12f, 6.5f, 20.57f, 7f, 20.25f, 7.75f) + curveTo(19.45f, 9.54f, 17f, 15.24f, 15.4f, 18.95f) + curveTo(15.4f, 18.95f, 14.97f, 18.95f, 14.97f, 18.95f) + close() + } } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt index 0ac04017..835906f5 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt @@ -28,6 +28,7 @@ import de.mm20.launcher2.ui.settings.debug.DebugSettingsScreen import de.mm20.launcher2.ui.settings.license.LicenseScreen import de.mm20.launcher2.ui.settings.main.MainSettingsScreen import de.mm20.launcher2.ui.settings.musicwidget.MusicWidgetSettingsScreen +import de.mm20.launcher2.ui.settings.search.SearchSettingsScreen import de.mm20.launcher2.ui.settings.weatherwidget.WeatherWidgetSettingsScreen import de.mm20.launcher2.ui.settings.widgets.WidgetsSettingsScreen @@ -85,6 +86,9 @@ class SettingsActivity : BaseActivity() { composable("settings/appearance") { AppearanceSettingsScreen() } + composable("settings/search") { + SearchSettingsScreen() + } composable("settings/widgets") { WidgetsSettingsScreen() } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/main/MainSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/main/MainSettingsScreen.kt index ce323693..255e0cb6 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/main/MainSettingsScreen.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/main/MainSettingsScreen.kt @@ -33,7 +33,10 @@ fun MainSettingsScreen() { Preference( icon = Icons.Rounded.Search, title = stringResource(id = R.string.preference_screen_search), - summary = stringResource(id = R.string.preference_screen_search_summary) + summary = stringResource(id = R.string.preference_screen_search_summary), + onClick = { + navController?.navigate("settings/search") + } ) Preference( icon = Icons.Rounded.Widgets, diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt new file mode 100644 index 00000000..7c625292 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt @@ -0,0 +1,146 @@ +package de.mm20.launcher2.ui.settings.search + +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.MissingPermissionBanner +import de.mm20.launcher2.ui.component.preferences.* +import de.mm20.launcher2.ui.icons.Wikipedia + +@Composable +fun SearchSettingsScreen() { + + val viewModel: SearchSettingsScreenVM = viewModel() + val context = LocalContext.current + + PreferenceScreen(title = stringResource(R.string.preference_screen_search)) { + item { + PreferenceCategory { + val favorites by viewModel.favorites.observeAsState() + SwitchPreference( + title = stringResource(R.string.preference_search_favorites), + summary = stringResource(R.string.preference_search_favorites_summary), + icon = Icons.Rounded.Star, + value = favorites == true, + onValueChanged = { + viewModel.setFavorites(it) + } + ) + + Preference( + title = stringResource(R.string.preference_search_files), + summary = stringResource(R.string.preference_search_files_summary), + icon = Icons.Rounded.Description + ) + + val hasContactsPermission by viewModel.hasContactsPermission.observeAsState() + AnimatedVisibility(hasContactsPermission == false) { + MissingPermissionBanner( + text = stringResource(R.string.missing_permission_contact_search), + onClick = { + viewModel.requestContactsPermission(context as AppCompatActivity) + }, + modifier = Modifier.padding(16.dp) + ) + } + val contacts by viewModel.contacts.observeAsState() + SwitchPreference( + title = stringResource(R.string.preference_search_contacts), + summary = stringResource(R.string.preference_search_contacts_summary), + icon = Icons.Rounded.Person, + value = contacts == true, + onValueChanged = { + viewModel.setContacts(it) + } + ) + + val hasCalendarPermission by viewModel.hasCalendarPermission.observeAsState() + AnimatedVisibility(hasCalendarPermission == false) { + MissingPermissionBanner( + text = stringResource(R.string.missing_permission_calendar_search), + onClick = { + viewModel.requestCalendarPermission(context as AppCompatActivity) + }, + modifier = Modifier.padding(16.dp) + ) + } + val calendar by viewModel.calendar.observeAsState() + SwitchPreference( + title = stringResource(R.string.preference_search_calendar), + summary = stringResource(R.string.preference_search_calendar_summary), + icon = Icons.Rounded.Today, + value = calendar == true, + onValueChanged = { + viewModel.setCalendar(it) + } + ) + + val calculator by viewModel.calculator.observeAsState() + SwitchPreference( + title = stringResource(R.string.preference_search_calculator), + summary = stringResource(R.string.preference_search_calculator_summary), + icon = Icons.Rounded.Calculate, + value = calculator == true, + onValueChanged = { + viewModel.setCalculator(it) + } + ) + + val unitConverter by viewModel.unitConverter.observeAsState() + SwitchPreference( + title = stringResource(R.string.preference_search_unitconverter), + summary = stringResource(R.string.preference_search_unitconverter_summary), + icon = Icons.Rounded.Loop, + value = unitConverter == true, + onValueChanged = { + viewModel.setUnitConverter(it) + } + ) + + val wikipedia by viewModel.wikipedia.observeAsState() + PreferenceWithSwitch( + title = stringResource(R.string.preference_search_wikipedia), + summary = stringResource(R.string.preference_search_wikipedia_summary), + icon = Icons.Rounded.Wikipedia, + switchValue = wikipedia == true, + onSwitchChanged = { + viewModel.setWikipedia(it) + } + ) + + val websites by viewModel.websites.observeAsState() + SwitchPreference( + title = stringResource(R.string.preference_search_websites), + summary = stringResource(R.string.preference_search_websites_summary), + icon = Icons.Rounded.Language, + value = websites == true, + onValueChanged = { + viewModel.setWebsites(it) + } + ) + + val webSearch by viewModel.webSearch.observeAsState() + PreferenceWithSwitch( + title = stringResource(R.string.preference_search_websearch), + summary = stringResource(R.string.preference_search_websearch_summary), + icon = Icons.Rounded.TravelExplore, + switchValue = webSearch == true, + onSwitchChanged = { + viewModel.setWebSearch(it) + } + ) + } + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt new file mode 100644 index 00000000..af8ecd18 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt @@ -0,0 +1,139 @@ +package de.mm20.launcher2.ui.settings.search + +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import de.mm20.launcher2.permissions.PermissionGroup +import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.LauncherDataStore +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class SearchSettingsScreenVM : ViewModel(), KoinComponent { + private val dataStore: LauncherDataStore by inject() + private val permissionsManager: PermissionsManager by inject() + + val favorites = dataStore.data.map { it.favorites.enabled }.asLiveData() + fun setFavorites(favorites: Boolean) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setFavorites( + it.favorites.toBuilder() + .setEnabled(favorites) + ) + .build() + } + } + } + + + val hasContactsPermission = permissionsManager.hasPermission(PermissionGroup.Contacts).asLiveData() + val contacts = dataStore.data.map { it.contactsSearch.enabled }.asLiveData() + fun setContacts(contacts: Boolean) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setContactsSearch( + it.contactsSearch.toBuilder() + .setEnabled(contacts) + ) + .build() + } + } + } + fun requestContactsPermission(activity: AppCompatActivity) { + permissionsManager.requestPermission(activity, PermissionGroup.Contacts) + } + + val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar).asLiveData() + val calendar = dataStore.data.map { it.calendarSearch.enabled }.asLiveData() + fun setCalendar(calendar: Boolean) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setCalendarSearch( + it.calendarSearch.toBuilder() + .setEnabled(calendar) + ) + .build() + } + } + } + fun requestCalendarPermission(activity: AppCompatActivity) { + permissionsManager.requestPermission(activity, PermissionGroup.Calendar) + } + + val calculator = dataStore.data.map { it.calculatorSearch.enabled }.asLiveData() + fun setCalculator(calculator: Boolean) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setCalculatorSearch( + it.calculatorSearch.toBuilder() + .setEnabled(calculator) + ) + .build() + } + } + } + + val unitConverter = dataStore.data.map { it.unitConverterSearch.enabled }.asLiveData() + fun setUnitConverter(unitConverter: Boolean) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setUnitConverterSearch( + it.unitConverterSearch.toBuilder() + .setEnabled(unitConverter) + ) + .build() + } + } + } + + val wikipedia = dataStore.data.map { it.wikipediaSearch.enabled }.asLiveData() + fun setWikipedia(wikipedia: Boolean) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setWikipediaSearch( + it.wikipediaSearch.toBuilder() + .setEnabled(wikipedia) + ) + .build() + } + } + } + + val websites = dataStore.data.map { it.websiteSearch.enabled }.asLiveData() + fun setWebsites(websites: Boolean) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setWebsiteSearch( + it.websiteSearch.toBuilder() + .setEnabled(websites) + ) + .build() + } + } + } + + val webSearch = dataStore.data.map { it.webSearch.enabled }.asLiveData() + fun setWebSearch(webSearch: Boolean) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setWebSearch( + it.webSearch.toBuilder() + .setEnabled(webSearch) + ) + .build() + } + } + } +} \ No newline at end of file diff --git a/unitconverter/src/main/java/de/mm20/launcher2/search/data/UnitConverter.kt b/unitconverter/src/main/java/de/mm20/launcher2/search/data/UnitConverter.kt index 972d6065..a03fc36c 100644 --- a/unitconverter/src/main/java/de/mm20/launcher2/search/data/UnitConverter.kt +++ b/unitconverter/src/main/java/de/mm20/launcher2/search/data/UnitConverter.kt @@ -1,51 +1,11 @@ package de.mm20.launcher2.search.data -import android.content.Context -import de.mm20.launcher2.preferences.LauncherPreferences import de.mm20.launcher2.unitconverter.Dimension import de.mm20.launcher2.unitconverter.UnitValue -import de.mm20.launcher2.unitconverter.converters.* open class UnitConverter( - val dimension: Dimension, - val inputValue: UnitValue, - val values: List -) { - companion object { - - suspend fun search(context: Context, query: String): UnitConverter? { - if (!LauncherPreferences.instance.searchUnitConverter) return null - if (!query.matches(Regex("[0-9,.:]+ [A-Za-z/²³°.]+")) && !query.matches(Regex("[0-9,.:]+ [A-Za-z/²³°.]+ >> [A-Za-z/²³°]+"))) return null - val valueStr: String - val unitStr: String - val targetUnitStr: String? - - query.split(" ").also { - valueStr = it.get(0) - unitStr = it.get(1) - targetUnitStr = it.getOrNull(3) - } - val value = valueStr.toDoubleOrNull() ?: valueStr.replace(',', '.').toDoubleOrNull() - ?: return null - - val converters = listOf( - lazy { MassConverter(context) }, - lazy { LengthConverter(context) }, - lazy { CurrencyConverter() }, - lazy { DataConverter(context) }, - lazy { TimeConverter(context) }, - lazy { VelocityConverter(context) }, - lazy { AreaConverter(context) }, - lazy { TemperatureConverter(context) } - ) - for (conv in converters) { - val converter = conv.value - if (!converter.isValidUnit(unitStr)) continue - if (targetUnitStr != null && !converter.isValidUnit(targetUnitStr)) continue - return converter.convert(context, unitStr, value, targetUnitStr) - } - return null - } - } -} + val dimension: Dimension, + val inputValue: UnitValue, + val values: List +) diff --git a/unitconverter/src/main/java/de/mm20/launcher2/unitconverter/UnitConverterRepository.kt b/unitconverter/src/main/java/de/mm20/launcher2/unitconverter/UnitConverterRepository.kt index ab201bc9..f036f60e 100644 --- a/unitconverter/src/main/java/de/mm20/launcher2/unitconverter/UnitConverterRepository.kt +++ b/unitconverter/src/main/java/de/mm20/launcher2/unitconverter/UnitConverterRepository.kt @@ -2,19 +2,69 @@ package de.mm20.launcher2.unitconverter import android.content.Context import androidx.lifecycle.MutableLiveData +import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.search.data.UnitConverter +import de.mm20.launcher2.unitconverter.converters.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject interface UnitConverterRepository { fun search(query:String): Flow } -class UnitConverterRepositoryImpl(val context: Context) : UnitConverterRepository { +class UnitConverterRepositoryImpl(val context: Context) : UnitConverterRepository, KoinComponent { + private val dataStore: LauncherDataStore by inject() val unitConverter = MutableLiveData() override fun search(query: String): Flow = channelFlow { - send(UnitConverter.search(context, query)) + if (query.isBlank()) { + send(null) + return@channelFlow + } + dataStore.data.map { it.unitConverterSearch.enabled }.collectLatest { + if (it) { + send(queryUnitConverter(query)) + } else { + send(null) + } + } + } + + private suspend fun queryUnitConverter(query: String): UnitConverter? { + if (!query.matches(Regex("[0-9,.:]+ [A-Za-z/²³°.]+")) && !query.matches(Regex("[0-9,.:]+ [A-Za-z/²³°.]+ >> [A-Za-z/²³°]+"))) return null + val valueStr: String + val unitStr: String + val targetUnitStr: String? + + query.split(" ").also { + valueStr = it.get(0) + unitStr = it.get(1) + targetUnitStr = it.getOrNull(3) + } + val value = valueStr.toDoubleOrNull() ?: valueStr.replace(',', '.').toDoubleOrNull() + ?: return null + + val converters = listOf( + lazy { MassConverter(context) }, + lazy { LengthConverter(context) }, + lazy { CurrencyConverter() }, + lazy { DataConverter(context) }, + lazy { TimeConverter(context) }, + lazy { VelocityConverter(context) }, + lazy { AreaConverter(context) }, + lazy { TemperatureConverter(context) } + ) + for (conv in converters) { + val converter = conv.value + if (!converter.isValidUnit(unitStr)) continue + if (targetUnitStr != null && !converter.isValidUnit(targetUnitStr)) continue + return converter.convert(context, unitStr, value, targetUnitStr) + } + return null } } \ No newline at end of file diff --git a/websites/src/main/java/de/mm20/launcher2/search/data/Website.kt b/websites/src/main/java/de/mm20/launcher2/search/data/Website.kt index 83382064..6e92f688 100644 --- a/websites/src/main/java/de/mm20/launcher2/search/data/Website.kt +++ b/websites/src/main/java/de/mm20/launcher2/search/data/Website.kt @@ -89,78 +89,4 @@ class Website( intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK return intent } - - companion object { - fun search(context: Context, query: String, client: OkHttpClient): Website? { - var url = query - val prefs = LauncherPreferences.instance - if (!prefs.searchWebsite || - NetworkUtils.isOffline(context, prefs.searchWebsitesMobileData) - ) return null - val protocol = - if (LauncherPreferences.instance.searchWebsitesProtocol == WebsiteProtocols.HTTPS) "https://" else "http://" - if (!query.startsWith("https://") && !query.startsWith("http://")) url = - "$protocol$query" - if (!URLUtil.isValidUrl(url)) return null - try { - val request = Request.Builder() - .url(URL(url)) - .get() - .tag("onlinesearch") - .build() - val response = client.newCall(request).execute() - url = response.request.url.toString() - val body = response.body?.string() ?: return null - val doc = Jsoup.parse(body) - var title = doc.select("meta[property=og:title]").attr("content") - if (title.isBlank()) title = doc.title() - if (title.isBlank()) title = url - var description = doc.select("meta[property=og:description]").attr("content") - if (description.isBlank()) description = - doc.select("meta[name=description]").attr("content") - val color = try { - val colorString = doc.select("meta[name=theme-color]").attr("content") - if (colorString.isNotEmpty()) colorString.toColorInt() - else 0 - } catch (e: IllegalArgumentException) { - 0 - } - var image = doc.select("meta[property=og:image]").attr("content") - var favicon = doc.select("link[rel=apple-touch-icon]").attr("href") - if (favicon.isBlank()) favicon = - doc.head().select("meta[itemprop=image]").attr("content") - if (favicon.isBlank()) favicon = doc.select("link[rel=icon]").attr("href") - if (favicon.isBlank()) favicon = - doc.head().select("link[href~=.*\\.(ico|png)]").attr("href") - if (!favicon.isBlank()) favicon = resolve(response.request.url, favicon) - if (!image.isBlank()) image = resolve(response.request.url, image) - return Website( - label = title, - url = url, - description = description, - image = image, - favicon = favicon, - color = color - ) - } catch (e: IOException) { - //Ignore. Not a HTML page or no connection. No result for this query - } catch (e: UncheckedIOException) { - } catch (e: URISyntaxException) { - } catch (e: RuntimeException) { - } catch (e: IllegalArgumentException) { - } - return null - } - - private fun resolve(url: HttpUrl, link: String): String { - /*if(link.startsWith("http://") || link.startsWith("https://")) return link - if(link.startsWith("//")) return "${urlTemplate.scheme()}:$link" - if(link.startsWith("/")) return "${urlTemplate.scheme()}:$link"*/ - return try { - URL(url.toUrl(), link).toString() - } catch (e: MalformedURLException) { - "" - } - } - } } \ No newline at end of file diff --git a/websites/src/main/java/de/mm20/launcher2/websites/WebsiteRepository.kt b/websites/src/main/java/de/mm20/launcher2/websites/WebsiteRepository.kt index 14f53a3d..5b86c829 100644 --- a/websites/src/main/java/de/mm20/launcher2/websites/WebsiteRepository.kt +++ b/websites/src/main/java/de/mm20/launcher2/websites/WebsiteRepository.kt @@ -1,19 +1,36 @@ package de.mm20.launcher2.websites import android.content.Context +import android.webkit.URLUtil +import androidx.core.graphics.toColorInt +import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.search.data.Website import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext +import okhttp3.HttpUrl import okhttp3.OkHttpClient +import okhttp3.Request +import org.jsoup.Jsoup +import org.jsoup.UncheckedIOException +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.IOException +import java.net.MalformedURLException +import java.net.URISyntaxException +import java.net.URL import java.util.concurrent.TimeUnit interface WebsiteRepository { fun search(query: String): Flow } -class WebsiteRepositoryImpl(val context: Context) : WebsiteRepository { +class WebsiteRepositoryImpl(val context: Context) : WebsiteRepository, KoinComponent { + + private val dataStore: LauncherDataStore by inject() private val httpClient = OkHttpClient .Builder() @@ -24,18 +41,86 @@ class WebsiteRepositoryImpl(val context: Context) : WebsiteRepository { override fun search(query: String): Flow = channelFlow { send(null) - httpClient.dispatcher.run { - runningCalls().forEach { - it.cancel() - } - queuedCalls().forEach { - it.cancel() - } + withContext(Dispatchers.IO) { + httpClient.dispatcher.cancelAll() } if (query.isBlank()) return@channelFlow - val website = withContext(Dispatchers.IO) { - Website.search(context, query, httpClient) + + dataStore.data.map { it.websiteSearch.enabled }.collectLatest { + if(it) { + val website = queryWebsite(query) + send(website) + } else { + send(null) + } + } + + } + + private suspend fun queryWebsite(query: String): Website? { + val result = withContext(Dispatchers.IO) { + var url = query + val protocol = "https://" + if (!query.startsWith("https://") && !query.startsWith("http://")) url = + "$protocol$query" + if (!URLUtil.isValidUrl(url)) return@withContext null + try { + val request = Request.Builder() + .url(URL(url)) + .get() + .tag("onlinesearch") + .build() + val response = httpClient.newCall(request).execute() + url = response.request.url.toString() + val body = response.body?.string() ?: return@withContext null + val doc = Jsoup.parse(body) + var title = doc.select("meta[property=og:title]").attr("content") + if (title.isBlank()) title = doc.title() + if (title.isBlank()) title = url + var description = doc.select("meta[property=og:description]").attr("content") + if (description.isBlank()) description = + doc.select("meta[name=description]").attr("content") + val color = try { + val colorString = doc.select("meta[name=theme-color]").attr("content") + if (colorString.isNotEmpty()) colorString.toColorInt() + else 0 + } catch (e: IllegalArgumentException) { + 0 + } + var image = doc.select("meta[property=og:image]").attr("content") + var favicon = doc.select("link[rel=apple-touch-icon]").attr("href") + if (favicon.isBlank()) favicon = + doc.head().select("meta[itemprop=image]").attr("content") + if (favicon.isBlank()) favicon = doc.select("link[rel=icon]").attr("href") + if (favicon.isBlank()) favicon = + doc.head().select("link[href~=.*\\.(ico|png)]").attr("href") + if (!favicon.isBlank()) favicon = resolveUrl(response.request.url, favicon) + if (!image.isBlank()) image = resolveUrl(response.request.url, image) + return@withContext Website( + label = title, + url = url, + description = description, + image = image, + favicon = favicon, + color = color + ) + } catch (e: IOException) { + //Ignore. Not a HTML page or no connection. No result for this query + } catch (e: UncheckedIOException) { + } catch (e: URISyntaxException) { + } catch (e: RuntimeException) { + } catch (e: IllegalArgumentException) { + } + return@withContext null + } + return result + } + + private fun resolveUrl(url: HttpUrl, link: String): String { + return try { + URL(url.toUrl(), link).toString() + } catch (e: MalformedURLException) { + "" } - send(website) } } \ No newline at end of file diff --git a/wikipedia/src/main/java/de/mm20/launcher2/wikipedia/WikipediaRepository.kt b/wikipedia/src/main/java/de/mm20/launcher2/wikipedia/WikipediaRepository.kt index 4d3a6970..81a99961 100644 --- a/wikipedia/src/main/java/de/mm20/launcher2/wikipedia/WikipediaRepository.kt +++ b/wikipedia/src/main/java/de/mm20/launcher2/wikipedia/WikipediaRepository.kt @@ -2,11 +2,17 @@ package de.mm20.launcher2.wikipedia import android.content.Context import de.mm20.launcher2.crashreporter.CrashReporter -import de.mm20.launcher2.preferences.LauncherPreferences +import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.search.data.Wikipedia +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit @@ -17,7 +23,9 @@ interface WikipediaRepository { class WikipediaRepositoryImpl( private val context: Context -): WikipediaRepository { +) : WikipediaRepository, KoinComponent { + + private val dataStore: LauncherDataStore by inject() private val httpClient by lazy { OkHttpClient @@ -36,51 +44,56 @@ class WikipediaRepositoryImpl( .build() } - val wikipediaService by lazy { + private val wikipediaService by lazy { retrofit.create(WikipediaApi::class.java) } override fun search(query: String): Flow = channelFlow { send(null) - httpClient.dispatcher.run { - runningCalls().forEach { - it.cancel() - } - queuedCalls().forEach { - it.cancel() + withContext(Dispatchers.IO) { + httpClient.dispatcher.cancelAll() + } + if (query.isBlank()) return@channelFlow + + dataStore.data.map { it.wikipediaSearch }.collectLatest { + if (it.enabled) { + send(queryWikipedia(query, it.images)) + } else { + send(null) } } + } + + private suspend fun queryWikipedia(query: String, loadImages: Boolean): Wikipedia? { - if (query.isBlank()) return@channelFlow val result = try { wikipediaService.search(query) } catch (e: Exception) { CrashReporter.logException(e) - return@channelFlow + return null } - val page = result.query?.pages?.values?.toList()?.getOrNull(0) ?: return@channelFlow + val page = result.query?.pages?.values?.toList()?.getOrNull(0) ?: return null - val image = if (LauncherPreferences.instance.searchWikipediaPictures) { + val image = if (loadImages) { val width = context.resources.displayMetrics.widthPixels / 2 val imageResult = try { wikipediaService.getPageImage(page.pageid, width) } catch (e: Exception) { CrashReporter.logException(e) - return@channelFlow + return null } imageResult.query?.pages?.values?.toList()?.getOrNull(0)?.thumbnail?.source } else null - val wiki = Wikipedia( + return Wikipedia( label = page.title, id = page.pageid, text = page.extract, image = image ) - send(wiki) } } \ No newline at end of file