Migrate websearches to search actions

This commit is contained in:
MM20 2022-11-03 22:17:37 +01:00
parent f862a578a1
commit 872f55625f
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
38 changed files with 1120 additions and 1275 deletions

View File

@ -42,7 +42,7 @@ dependencies {
implementation(project(":favorites"))
implementation(project(":widgets"))
implementation(project(":search"))
implementation(project(":search-actions"))
implementation(project(":preferences"))
implementation(project(":ktx"))
implementation(project(":customattrs"))

View File

@ -5,7 +5,7 @@ enum class BackupComponent(val value: String) {
Favorites("favorites"),
Widgets("widgets"),
Customizations("customizations"),
Websearches("websearches");
SearchActions("searchactions");
companion object {
fun fromValue(value: String): BackupComponent? {

View File

@ -8,7 +8,7 @@ import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.export
import de.mm20.launcher2.preferences.import
import de.mm20.launcher2.search.WebsearchRepository
import de.mm20.launcher2.searchactions.SearchActionRepository
import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.*
import java.io.File
@ -23,7 +23,7 @@ class BackupManager(
private val dataStore: LauncherDataStore,
private val favoritesRepository: FavoritesRepository,
private val widgetRepository: WidgetRepository,
private val websearchRepository: WebsearchRepository,
private val searchActionRepository: SearchActionRepository,
private val customAttrsRepository: CustomAttributesRepository,
) {
private val scope = CoroutineScope(Dispatchers.Default + Job())
@ -70,8 +70,8 @@ class BackupManager(
widgetRepository.export(backupDir)
}
if (include.contains(BackupComponent.Websearches)) {
websearchRepository.export(backupDir)
if (include.contains(BackupComponent.SearchActions)) {
searchActionRepository.export(backupDir)
}
if (include.contains(BackupComponent.Customizations)) {
@ -111,8 +111,8 @@ class BackupManager(
widgetRepository.import(restoreDir)
}
if (include.contains(BackupComponent.Websearches)) {
websearchRepository.import(restoreDir)
if (include.contains(BackupComponent.SearchActions)) {
searchActionRepository.import(restoreDir)
}
if (include.contains(BackupComponent.Customizations)) {
@ -183,7 +183,7 @@ class BackupManager(
companion object {
private const val BackupFormatMajor = 1
private const val BackupFormatMinor = 3
private const val BackupFormatMinor = 4
internal const val BackupFormat = "$BackupFormatMajor.$BackupFormatMinor"
}
}

View File

@ -0,0 +1,468 @@
{
"formatVersion": 1,
"database": {
"version": 19,
"identityHash": "c6adf1dca8ade5117d4e8952a67786fa",
"entities": [
{
"tableName": "forecasts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `temperature` REAL NOT NULL, `minTemp` REAL NOT NULL, `maxTemp` REAL NOT NULL, `pressure` REAL NOT NULL, `humidity` REAL NOT NULL, `icon` INTEGER NOT NULL, `condition` TEXT NOT NULL, `clouds` INTEGER NOT NULL, `windSpeed` REAL NOT NULL, `windDirection` REAL NOT NULL, `rain` REAL NOT NULL, `snow` REAL NOT NULL, `night` INTEGER NOT NULL, `location` TEXT NOT NULL, `provider` TEXT NOT NULL, `providerUrl` TEXT NOT NULL, `rainProbability` INTEGER NOT NULL, `snowProbability` INTEGER NOT NULL, `updateTime` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))",
"fields": [
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "temperature",
"columnName": "temperature",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "minTemp",
"columnName": "minTemp",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "maxTemp",
"columnName": "maxTemp",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "pressure",
"columnName": "pressure",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "humidity",
"columnName": "humidity",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "condition",
"columnName": "condition",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "clouds",
"columnName": "clouds",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "windSpeed",
"columnName": "windSpeed",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "windDirection",
"columnName": "windDirection",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "precipitation",
"columnName": "rain",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "snow",
"columnName": "snow",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "night",
"columnName": "night",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "provider",
"columnName": "provider",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "providerUrl",
"columnName": "providerUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "precipProbability",
"columnName": "rainProbability",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "snowProbability",
"columnName": "snowProbability",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"timestamp"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Searchable",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `type` TEXT NOT NULL, `searchable` TEXT NOT NULL, `launchCount` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `hidden` INTEGER NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serializedSearchable",
"columnName": "searchable",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinPosition",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hidden",
"columnName": "hidden",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Currency",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`symbol` TEXT NOT NULL, `value` REAL NOT NULL, `lastUpdate` INTEGER NOT NULL, PRIMARY KEY(`symbol`))",
"fields": [
{
"fieldPath": "symbol",
"columnName": "symbol",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lastUpdate",
"columnName": "lastUpdate",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"symbol"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Icons",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `componentName` TEXT, `drawable` TEXT, `iconPack` TEXT NOT NULL, `scale` REAL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "componentName",
"columnName": "componentName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "drawable",
"columnName": "drawable",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "iconPack",
"columnName": "iconPack",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "scale",
"columnName": "scale",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "IconPack",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `packageName` TEXT NOT NULL, `version` TEXT NOT NULL, `scale` REAL NOT NULL, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "scale",
"columnName": "scale",
"affinity": "REAL",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Widget",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `data` TEXT NOT NULL, `height` INTEGER NOT NULL, `position` INTEGER NOT NULL, `label` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "data",
"columnName": "data",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "height",
"columnName": "height",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "CustomAttributes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "SearchAction",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, `label` TEXT, `icon` INTEGER NOT NULL, `color` INTEGER NOT NULL, `customIcon` TEXT, `options` TEXT, PRIMARY KEY(`position`))",
"fields": [
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "data",
"columnName": "data",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "customIcon",
"columnName": "customIcon",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "options",
"columnName": "options",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"position"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c6adf1dca8ade5117d4e8952a67786fa')"
]
}
}

View File

@ -7,18 +7,34 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import de.mm20.launcher2.database.entities.*
import de.mm20.launcher2.database.migrations.Migration_10_11
import de.mm20.launcher2.database.migrations.Migration_11_12
import de.mm20.launcher2.database.migrations.Migration_12_13
import de.mm20.launcher2.database.migrations.Migration_13_14
import de.mm20.launcher2.database.migrations.Migration_14_15
import de.mm20.launcher2.database.migrations.Migration_15_16
import de.mm20.launcher2.database.migrations.Migration_16_17
import de.mm20.launcher2.database.migrations.Migration_17_18
import de.mm20.launcher2.database.migrations.Migration_18_19
import de.mm20.launcher2.database.migrations.Migration_6_7
import de.mm20.launcher2.database.migrations.Migration_7_8
import de.mm20.launcher2.database.migrations.Migration_8_9
import de.mm20.launcher2.database.migrations.Migration_9_10
@Database(entities = [ForecastEntity::class,
SavedSearchableEntity::class,
WebsearchEntity::class,
CurrencyEntity::class,
IconEntity::class,
IconPackEntity::class,
WidgetEntity::class,
CustomAttributeEntity::class], version = 18, exportSchema = true)
@Database(
entities = [
ForecastEntity::class,
SavedSearchableEntity::class,
CurrencyEntity::class,
IconEntity::class,
IconPackEntity::class,
WidgetEntity::class,
CustomAttributeEntity::class,
SearchActionEntity::class,
], version = 19, exportSchema = true
)
@TypeConverters(ComponentNameConverter::class, StringListConverter::class)
abstract class AppDatabase : RoomDatabase() {
@ -34,154 +50,47 @@ abstract class AppDatabase : RoomDatabase() {
private var _instance: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
val instance = _instance
?: Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "room")
//.fallbackToDestructiveMigration()
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
db.execSQL("INSERT INTO Websearch (urlTemplate, label, color, icon) VALUES " +
"('${context.getString(R.string.default_websearch_1_url)}', '${context.getString(R.string.default_websearch_1_name)}', 0, NULL )," +
"('${context.getString(R.string.default_websearch_2_url)}', '${context.getString(R.string.default_websearch_2_name)}', 0, NULL )," +
"('${context.getString(R.string.default_websearch_3_url)}', '${context.getString(R.string.default_websearch_3_name)}', 0, NULL );")
?: Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "room")
//.fallbackToDestructiveMigration()
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
db.execSQL("INSERT INTO Widget (type, data, height, position, label) VALUES " +
"('internal', 'weather', -1, 0, '${context.getString(R.string.widget_name_weather)}')," +
"('internal', 'music', -1, 1, '${context.getString(R.string.widget_name_music)}')," +
"('internal', 'calendar', -1, 2, '${context.getString(R.string.widget_name_calendar)}');")
}
})
.addMigrations(
Migration_6_7(),
Migration_7_8(),
Migration_8_9(),
Migration_9_10(),
Migration_10_11(),
Migration_11_12(),
Migration_12_13(),
Migration_13_14(),
Migration_14_15(),
Migration_15_16(),
Migration_16_17(),
Migration_17_18(),
).build()
db.execSQL("INSERT INTO `SearchAction` (`position`, `type`, `data`, `label`, `color`, `icon`, `customIcon`, `options`) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf(
0, "url", context.getString(R.string.default_websearch_1_url), context.getString(R.string.default_websearch_1_name), 0, 0, null, null,
0, "url", context.getString(R.string.default_websearch_2_url), context.getString(R.string.default_websearch_2_name), 0, 0, null, null,
0, "url", context.getString(R.string.default_websearch_3_url), context.getString(R.string.default_websearch_3_name), 0, 0, null, null,
)
)
db.execSQL(
"INSERT INTO Widget (type, data, height, position, label) VALUES " +
"('internal', 'weather', -1, 0, '${context.getString(R.string.widget_name_weather)}')," +
"('internal', 'music', -1, 1, '${context.getString(R.string.widget_name_music)}')," +
"('internal', 'calendar', -1, 2, '${context.getString(R.string.widget_name_calendar)}');"
)
}
})
.addMigrations(
Migration_6_7(),
Migration_7_8(),
Migration_8_9(),
Migration_9_10(),
Migration_10_11(),
Migration_11_12(),
Migration_12_13(),
Migration_13_14(),
Migration_14_15(),
Migration_15_16(),
Migration_16_17(),
Migration_17_18(),
Migration_18_19(),
).build()
if (_instance == null) _instance = instance
return instance
}
}
}
class Migration_6_7 : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE Searchable2 (`key` TEXT NOT NULL, `searchable` TEXT, `launchCount` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `hidden` INTEGER NOT NULL, `inAllApps` INTEGER NOT NULL, PRIMARY KEY(`key`))")
database.execSQL("INSERT INTO Searchable2 SELECT * FROM Searchable")
database.execSQL("DROP TABLE Searchable")
database.execSQL("ALTER TABLE Searchable2 RENAME TO Searchable")
}
}
class Migration_7_8 : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `${ForecastEntity.TABLE_NAME}2` (`timestamp` INTEGER NOT NULL, `temperature` REAL NOT NULL, `minTemp` REAL NOT NULL, `maxTemp` REAL NOT NULL, `pressure` REAL NOT NULL, `humidity` REAL NOT NULL, `icon` INTEGER NOT NULL, `condition` TEXT NOT NULL, `clouds` INTEGER NOT NULL, `windSpeed` REAL NOT NULL, `windDirection` REAL NOT NULL, `rain` REAL NOT NULL, `snow` REAL NOT NULL, `night` INTEGER NOT NULL, `location` TEXT NOT NULL, `provider` TEXT NOT NULL, `providerUrl` TEXT NOT NULL, `rainPropability` INTEGER NOT NULL, `snowProbability` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))")
database.execSQL("INSERT INTO ${ForecastEntity.TABLE_NAME}2 SELECT *, -1 as rainPropability, -1 as snowPropability FROM ${ForecastEntity.TABLE_NAME}")
database.execSQL("DROP TABLE ${ForecastEntity.TABLE_NAME}")
database.execSQL("ALTER TABLE ${ForecastEntity.TABLE_NAME}2 RENAME TO ${ForecastEntity.TABLE_NAME}")
}
}
class Migration_8_9 : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `${ForecastEntity.TABLE_NAME}2` (" +
"`timestamp` INTEGER NOT NULL, " +
"`temperature` REAL NOT NULL, " +
"`minTemp` REAL NOT NULL, " +
"`maxTemp` REAL NOT NULL, " +
"`pressure` REAL NOT NULL, " +
"`humidity` REAL NOT NULL, " +
"`icon` INTEGER NOT NULL, " +
"`condition` TEXT NOT NULL, " +
"`clouds` INTEGER NOT NULL, " +
"`windSpeed` REAL NOT NULL, " +
"`windDirection` REAL NOT NULL, " +
"`rain` REAL NOT NULL, " +
"`snow` REAL NOT NULL, " +
"`night` INTEGER NOT NULL, " +
"`location` TEXT NOT NULL, " +
"`provider` TEXT NOT NULL, " +
"`providerUrl` TEXT NOT NULL, " +
"`rainProbability` INTEGER NOT NULL, " +
"`snowProbability` INTEGER NOT NULL, " +
"`updateTime` INTEGER NOT NULL, " +
"PRIMARY KEY(`timestamp`))")
database.execSQL("INSERT INTO ${ForecastEntity.TABLE_NAME}2 SELECT timestamp, temperature, minTemp, maxTemp, pressure, humidity, icon, condition, clouds, windSpeed, windDirection, rain, snow, night, location, provider, providerUrl, rainPropability as rainProbability, snowProbability, 0 as updateTime FROM ${ForecastEntity.TABLE_NAME}")
database.execSQL("DROP TABLE ${ForecastEntity.TABLE_NAME}")
database.execSQL("ALTER TABLE ${ForecastEntity.TABLE_NAME}2 RENAME TO ${ForecastEntity.TABLE_NAME}")
}
}
class Migration_9_10 : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `Plugins` (`packageName` TEXT NOT NULL, `label` TEXT NOT NULL, `description` TEXT NOT NULL, `pluginClassName` TEXT NOT NULL, `enabled` INTEGER NOT NULL, PRIMARY KEY(`packageName`) );")
}
}
class Migration_10_11 : Migration(10, 11) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `temp` (`key` TEXT NOT NULL, `searchable` TEXT NOT NULL, `launchCount` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `hidden` INTEGER NOT NULL, PRIMARY KEY(`key`))")
database.execSQL("INSERT INTO `temp` SELECT `key`, `searchable`, `launchCount`, `pinned`, `hidden` FROM `Searchable`")
database.execSQL("DROP TABLE `Searchable`")
database.execSQL("ALTER TABLE `temp` RENAME TO `Searchable`")
}
}
class Migration_11_12 : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `Currency` (`symbol` TEXT NOT NULL, `value` REAL NOT NULL, `lastUpdate` INTEGER NOT NULL, PRIMARY KEY(`symbol`))")
}
}
class Migration_12_13 : Migration(12, 13) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `Plugin` (`packageName` TEXT NOT NULL, `data` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`packageName`, `data`))")
}
}
class Migration_13_14 : Migration(13, 14) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("DROP TABLE IF EXISTS `Plugins`;")
}
}
class Migration_14_15 : Migration(14, 15) {
override fun migrate(database: SupportSQLiteDatabase) {
}
}
class Migration_15_16 : Migration(15, 16) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("""
CREATE TABLE IF NOT EXISTS `CustomAttributes` (
`key` TEXT NOT NULL,
`type` TEXT NOT NULL,
`value` TEXT NOT NULL,
`id` INTEGER PRIMARY KEY AUTOINCREMENT
)
""".trimIndent())
}
}
class Migration_16_17 : Migration(16, 17) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE Websearch ADD COLUMN encoding INTEGER")
}
}
class Migration_17_18: Migration(17, 18) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE Searchable ADD COLUMN type TEXT NOT NULL DEFAULT ''")
database.execSQL("""
UPDATE Searchable
SET type = SUBSTR(`key`, 0, INSTR(`key`, '://')),
searchable = SUBSTR(`searchable`, INSTR(`searchable`, '#') + 1)
""".trimIndent())
}
}

View File

@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import de.mm20.launcher2.database.entities.CustomAttributeEntity
import de.mm20.launcher2.database.entities.SavedSearchableEntity
import de.mm20.launcher2.database.entities.SearchActionEntity
import de.mm20.launcher2.database.entities.WebsearchEntity
import de.mm20.launcher2.database.entities.WidgetEntity
@ -30,14 +31,14 @@ interface BackupRestoreDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun importWidgets(items: List<WidgetEntity>)
@Query("DELETE FROM Websearch")
suspend fun wipeWebsearches()
@Query("DELETE FROM SearchAction")
suspend fun wipeSearchActions()
@Query("SELECT * FROM Websearch LIMIT :limit OFFSET :offset")
suspend fun exportWebsearches(limit: Int, offset: Int): List<WebsearchEntity>
@Query("SELECT * FROM SearchAction LIMIT :limit OFFSET :offset")
suspend fun exportSearchActions(limit: Int, offset: Int): List<SearchActionEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun importWebsearches(items: List<WebsearchEntity>)
suspend fun importSearchActions(items: List<SearchActionEntity>)
@Query("DELETE FROM CustomAttributes")
suspend fun wipeCustomAttributes()

View File

@ -2,7 +2,6 @@ package de.mm20.launcher2.database
import androidx.room.*
import de.mm20.launcher2.database.entities.SavedSearchableEntity
import de.mm20.launcher2.database.entities.WebsearchEntity
import kotlinx.coroutines.flow.Flow
@Dao
@ -114,18 +113,6 @@ interface SearchDao {
@Query("SELECT * FROM SEARCHABLE WHERE hidden = 1")
fun getHiddenItems(): Flow<List<SavedSearchableEntity>>
@Query("SELECT * FROM Websearch ORDER BY label ASC")
fun getWebSearches(): Flow<List<WebsearchEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertWebsearch(websearch: WebsearchEntity)
@Delete
fun deleteWebsearch(websearch: WebsearchEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAllWebsearches(websearches: List<WebsearchEntity>)
@Query("UPDATE Searchable SET launchCount = launchCount + 1 WHERE `key` = :key")
fun incrementExistingLaunchCount(key: String)

View File

@ -1,8 +1,16 @@
package de.mm20.launcher2.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "SearchAction")
data class SearchActionEntity(
@PrimaryKey val position: Int,
val type: String,
val data: String? = null,
val data: String,
val label: String?,
val icon: Int,
val color: Int = 0,
val customIcon: String? = null,
val options: String? = null,
val position: Int,
)

View File

@ -0,0 +1,13 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration_10_11 : Migration(10, 11) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `temp` (`key` TEXT NOT NULL, `searchable` TEXT NOT NULL, `launchCount` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `hidden` INTEGER NOT NULL, PRIMARY KEY(`key`))")
database.execSQL("INSERT INTO `temp` SELECT `key`, `searchable`, `launchCount`, `pinned`, `hidden` FROM `Searchable`")
database.execSQL("DROP TABLE `Searchable`")
database.execSQL("ALTER TABLE `temp` RENAME TO `Searchable`")
}
}

View File

@ -0,0 +1,10 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration_11_12 : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `Currency` (`symbol` TEXT NOT NULL, `value` REAL NOT NULL, `lastUpdate` INTEGER NOT NULL, PRIMARY KEY(`symbol`))")
}
}

View File

@ -0,0 +1,10 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration_12_13 : Migration(12, 13) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `Plugin` (`packageName` TEXT NOT NULL, `data` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`packageName`, `data`))")
}
}

View File

@ -0,0 +1,10 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration_13_14 : Migration(13, 14) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("DROP TABLE IF EXISTS `Plugins`;")
}
}

View File

@ -0,0 +1,9 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration_14_15 : Migration(14, 15) {
override fun migrate(database: SupportSQLiteDatabase) {
}
}

View File

@ -0,0 +1,19 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration_15_16 : Migration(15, 16) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `CustomAttributes` (
`key` TEXT NOT NULL,
`type` TEXT NOT NULL,
`value` TEXT NOT NULL,
`id` INTEGER PRIMARY KEY AUTOINCREMENT
)
""".trimIndent()
)
}
}

View File

@ -0,0 +1,10 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration_16_17 : Migration(16, 17) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE Websearch ADD COLUMN encoding INTEGER")
}
}

View File

@ -0,0 +1,17 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration_17_18 : Migration(17, 18) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE Searchable ADD COLUMN type TEXT NOT NULL DEFAULT ''")
database.execSQL(
"""
UPDATE Searchable
SET type = SUBSTR(`key`, 0, INSTR(`key`, '://')),
searchable = SUBSTR(`searchable`, INSTR(`searchable`, '#') + 1)
""".trimIndent()
)
}
}

View File

@ -0,0 +1,44 @@
package de.mm20.launcher2.database.migrations
import androidx.core.database.getStringOrNull
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import de.mm20.launcher2.ktx.jsonObjectOf
class Migration_18_19 : Migration(18, 19) {
override fun migrate(database: SupportSQLiteDatabase) {
val websearches =
database.query("SELECT label, urlTemplate, color, icon, encoding FROM `Websearch` ORDER BY label ASC")
database.execSQL("CREATE TABLE IF NOT EXISTS `SearchAction` (`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, `label` TEXT, `icon` INTEGER NOT NULL, `color` INTEGER NOT NULL, `customIcon` TEXT, `options` TEXT, PRIMARY KEY(`position`))"
)
var position = 0
while (websearches.moveToNext()) {
val label = websearches.getString(0)
val data = websearches.getString(1)
val color = websearches.getInt(2)
val icon = websearches.getStringOrNull(3)
val encoding = websearches.getStringOrNull(4)
val options = encoding?.let{
jsonObjectOf("encoding" to encoding).toString()
}
database.execSQL(
"INSERT INTO `SearchAction` (`position`, `type`, `data`, `label`, `color`, `icon`, `customIcon`, `options`)" +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf(
position,
"url",
data,
label,
color,
if (icon == null) 0 else 1,
icon,
options
)
)
position++
}
database.execSQL("DROP TABLE `Websearch`")
}
}

View File

@ -0,0 +1,14 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration_6_7 : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE Searchable2 (`key` TEXT NOT NULL, `searchable` TEXT, `launchCount` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `hidden` INTEGER NOT NULL, `inAllApps` INTEGER NOT NULL, PRIMARY KEY(`key`))")
database.execSQL("INSERT INTO Searchable2 SELECT * FROM Searchable")
database.execSQL("DROP TABLE Searchable")
database.execSQL("ALTER TABLE Searchable2 RENAME TO Searchable")
}
}

View File

@ -0,0 +1,14 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import de.mm20.launcher2.database.entities.ForecastEntity
class Migration_7_8 : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `${ForecastEntity.TABLE_NAME}2` (`timestamp` INTEGER NOT NULL, `temperature` REAL NOT NULL, `minTemp` REAL NOT NULL, `maxTemp` REAL NOT NULL, `pressure` REAL NOT NULL, `humidity` REAL NOT NULL, `icon` INTEGER NOT NULL, `condition` TEXT NOT NULL, `clouds` INTEGER NOT NULL, `windSpeed` REAL NOT NULL, `windDirection` REAL NOT NULL, `rain` REAL NOT NULL, `snow` REAL NOT NULL, `night` INTEGER NOT NULL, `location` TEXT NOT NULL, `provider` TEXT NOT NULL, `providerUrl` TEXT NOT NULL, `rainPropability` INTEGER NOT NULL, `snowProbability` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))")
database.execSQL("INSERT INTO ${ForecastEntity.TABLE_NAME}2 SELECT *, -1 as rainPropability, -1 as snowPropability FROM ${ForecastEntity.TABLE_NAME}")
database.execSQL("DROP TABLE ${ForecastEntity.TABLE_NAME}")
database.execSQL("ALTER TABLE ${ForecastEntity.TABLE_NAME}2 RENAME TO ${ForecastEntity.TABLE_NAME}")
}
}

View File

@ -0,0 +1,38 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import de.mm20.launcher2.database.entities.ForecastEntity
class Migration_8_9 : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"CREATE TABLE IF NOT EXISTS `${ForecastEntity.TABLE_NAME}2` (" +
"`timestamp` INTEGER NOT NULL, " +
"`temperature` REAL NOT NULL, " +
"`minTemp` REAL NOT NULL, " +
"`maxTemp` REAL NOT NULL, " +
"`pressure` REAL NOT NULL, " +
"`humidity` REAL NOT NULL, " +
"`icon` INTEGER NOT NULL, " +
"`condition` TEXT NOT NULL, " +
"`clouds` INTEGER NOT NULL, " +
"`windSpeed` REAL NOT NULL, " +
"`windDirection` REAL NOT NULL, " +
"`rain` REAL NOT NULL, " +
"`snow` REAL NOT NULL, " +
"`night` INTEGER NOT NULL, " +
"`location` TEXT NOT NULL, " +
"`provider` TEXT NOT NULL, " +
"`providerUrl` TEXT NOT NULL, " +
"`rainProbability` INTEGER NOT NULL, " +
"`snowProbability` INTEGER NOT NULL, " +
"`updateTime` INTEGER NOT NULL, " +
"PRIMARY KEY(`timestamp`))"
)
database.execSQL("INSERT INTO ${ForecastEntity.TABLE_NAME}2 SELECT timestamp, temperature, minTemp, maxTemp, pressure, humidity, icon, condition, clouds, windSpeed, windDirection, rain, snow, night, location, provider, providerUrl, rainPropability as rainProbability, snowProbability, 0 as updateTime FROM ${ForecastEntity.TABLE_NAME}")
database.execSQL("DROP TABLE ${ForecastEntity.TABLE_NAME}")
database.execSQL("ALTER TABLE ${ForecastEntity.TABLE_NAME}2 RENAME TO ${ForecastEntity.TABLE_NAME}")
}
}

View File

@ -0,0 +1,11 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration_9_10 : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `Plugins` (`packageName` TEXT NOT NULL, `label` TEXT NOT NULL, `description` TEXT NOT NULL, `pluginClassName` TEXT NOT NULL, `enabled` INTEGER NOT NULL, PRIMARY KEY(`packageName`) );")
}
}

View File

@ -651,6 +651,7 @@
<string name="backup_component_favorites">Favorites &amp; hidden apps</string>
<string name="backup_component_settings">Settings</string>
<string name="backup_component_websearches">Web search shortcuts</string>
<string name="backup_component_searchactions">Web &amp; app search shortcuts</string>
<string name="backup_component_widgets">Built-in widgets</string>
<string name="backup_component_customizations">Customizations</string>
<string name="backup_complete">The backup has been completed.</string>

View File

@ -39,10 +39,14 @@ dependencies {
implementation(libs.androidx.core)
implementation(libs.koin.android)
implementation(libs.jsoup)
implementation(libs.okhttp)
implementation(libs.coil.core)
implementation(project(":base"))
implementation(project(":database"))
implementation(project(":ktx"))
implementation(project(":preferences"))
implementation(project(":crashreporter"))
}

View File

@ -4,6 +4,6 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val searchActionsModule = module {
single<SearchActionRepository> { SearchActionRepositoryImpl() }
single<SearchActionRepository> { SearchActionRepositoryImpl(androidContext(), get()) }
single<SearchActionService> { SearchActionServiceImpl(androidContext(), get(), TextClassifierImpl()) }
}

View File

@ -1,14 +1,123 @@
package de.mm20.launcher2.searchactions
import android.content.Context
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.database.entities.SearchActionEntity
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.searchactions.builders.SearchActionBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONException
import java.io.File
import java.util.UUID
interface SearchActionRepository {
fun getSearchActionBuilders(filter: TextType?): Flow<List<SearchActionBuilder>>
suspend fun export(toDir: File)
suspend fun import(fromDir: File)
}
internal class SearchActionRepositoryImpl: SearchActionRepository {
internal class SearchActionRepositoryImpl(
private val context: Context,
private val database: AppDatabase
): SearchActionRepository {
override fun getSearchActionBuilders(filter: TextType?): Flow<List<SearchActionBuilder>> {
TODO("Not yet implemented")
}
override suspend fun export(toDir: File) = withContext(Dispatchers.IO) {
val dao = database.backupDao()
var page = 0
var iconCounter = 0
do {
val websearches = dao.exportSearchActions(limit = 100, offset = page * 100)
val jsonArray = JSONArray()
for (websearch in websearches) {
var customIcon = websearch.customIcon
if (customIcon != null) {
val fileName = "asset.searchaction.${iconCounter.toString().padStart(4, '0')}"
val iconAssetFile = File(toDir, fileName)
File(customIcon).inputStream().use { inStream ->
iconAssetFile.outputStream().use { outStream ->
inStream.copyTo(outStream)
}
}
customIcon = fileName
iconCounter++
}
jsonArray.put(
jsonObjectOf(
"color" to websearch.color,
"label" to websearch.label,
"data" to websearch.data,
"icon" to websearch.icon,
"customIcon" to customIcon,
"options" to websearch.options,
"position" to websearch.position,
"type" to websearch.type,
)
)
}
val file = File(toDir, "searchactions.${page.toString().padStart(4, '0')}")
file.bufferedWriter().use {
it.write(jsonArray.toString())
}
page++
} while (websearches.size == 100)
}
override suspend fun import(fromDir: File) = withContext(Dispatchers.IO) {
val dao = database.backupDao()
dao.wipeSearchActions()
val files = fromDir.listFiles { _, name -> name.startsWith("searchactions.") } ?: return@withContext
for (file in files) {
val searchActions = mutableListOf<SearchActionEntity>()
try {
val jsonArray = JSONArray(file.inputStream().reader().readText())
for (i in 0 until jsonArray.length()) {
val json = jsonArray.getJSONObject(i)
val customIcon = json.optString("customIcon").takeIf { it.isNotEmpty() }
var iconFile: File? = null
if (customIcon != null) {
val asset = File(fromDir, customIcon)
iconFile = File(context.filesDir, UUID.randomUUID().toString())
asset.inputStream().use { inStream ->
iconFile.outputStream().use { outStream ->
inStream.copyTo(outStream)
}
}
}
val entity = SearchActionEntity(
position = json.getInt("position"),
data = json.getString("data"),
color = json.optInt("color", 0),
label = json.getString("label"),
icon = json.optInt("icon", 0),
customIcon = iconFile?.absolutePath,
options = json.optString("options").takeIf { it.isNotEmpty() },
type = json.getString("type"),
)
searchActions.add(entity)
}
dao.importSearchActions(searchActions)
} catch (e: JSONException) {
CrashReporter.logException(e)
}
}
}
}

View File

@ -1,8 +1,20 @@
package de.mm20.launcher2.searchactions
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.entities.WebsearchEntity
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.preferences.Settings.SearchActionSettings
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import de.mm20.launcher2.searchactions.builders.CallActionBuilder
import de.mm20.launcher2.searchactions.builders.CreateContactActionBuilder
import de.mm20.launcher2.searchactions.builders.EmailActionBuilder
@ -12,14 +24,33 @@ import de.mm20.launcher2.searchactions.builders.ScheduleEventActionBuilder
import de.mm20.launcher2.searchactions.builders.SearchActionBuilder
import de.mm20.launcher2.searchactions.builders.SetAlarmActionBuilder
import de.mm20.launcher2.searchactions.builders.TimerActionBuilder
import de.mm20.launcher2.searchactions.builders.WebsearchActionBuilder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONException
import org.jsoup.Jsoup
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
import java.util.UUID
interface SearchActionService {
fun search(settings: SearchActionSettings, query: String): Flow<ImmutableList<SearchAction>>
suspend fun importWebsearch(url: String, iconSize: Int): WebsearchActionBuilder?
suspend fun createIcon(uri: Uri, size: Int): String?
}
internal class SearchActionServiceImpl(
@ -27,7 +58,10 @@ internal class SearchActionServiceImpl(
private val repository: SearchActionRepository,
private val textClassifier: TextClassifier,
) : SearchActionService {
override fun search(settings: SearchActionSettings, query: String): Flow<ImmutableList<SearchAction>> = flow {
override fun search(
settings: SearchActionSettings,
query: String
): Flow<ImmutableList<SearchAction>> = flow {
if (query.isBlank()) {
emit(persistentListOf())
return@flow
@ -50,4 +84,125 @@ internal class SearchActionServiceImpl(
emit(builders.mapNotNull { it.build(context, classificationResult) }.toImmutableList())
}
override suspend fun importWebsearch(url: String, iconSize: Int): WebsearchActionBuilder? =
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) {
parser.next()
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 WebsearchActionBuilder(
label = label ?: "",
icon = if (localIconUrl == null) SearchActionIcon.Search else SearchActionIcon.Custom,
customIcon = localIconUrl,
urlTemplate = urlTemplate ?: ""
)
}
} 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.filesDir, UUID.randomUUID().toString())
val imageRequest = ImageRequest.Builder(context)
.data(uri)
.size(size)
.scale(Scale.FIT)
.build()
val drawable =
context.imageLoader.execute(imageRequest).drawable ?: return@withContext null
val scaledIcon = drawable.toBitmap()
val out = FileOutputStream(file)
scaledIcon.compress(Bitmap.CompressFormat.PNG, 100, out)
out.close()
return@withContext file.absolutePath
}
}

View File

@ -0,0 +1,24 @@
package de.mm20.launcher2.searchactions.actions
import android.app.SearchManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import de.mm20.launcher2.ktx.tryStartActivity
data class AppSearchAction(
override val label: String,
val componentName: ComponentName,
val query: String,
): SearchAction {
override val icon: SearchActionIcon = SearchActionIcon.Search
override val iconColor: Int = 0
override fun start(context: Context) {
val intent = Intent(Intent.ACTION_SEARCH).apply {
component = componentName
putExtra(SearchManager.QUERY, query)
}
context.tryStartActivity(intent)
}
}

View File

@ -10,16 +10,16 @@ interface SearchAction : Searchable {
fun start(context: Context)
}
enum class SearchActionIcon {
Search,
Website,
Alarm,
Timer,
Contact,
Phone,
Email,
Message,
Calendar,
Translate,
Custom,
enum class SearchActionIcon(value: Int) {
Search(0),
Custom(1),
Website(2),
Alarm(3),
Timer(4),
Contact(5),
Phone(6),
Email(7),
Message(8),
Calendar(9),
Translate(10),
}

View File

@ -0,0 +1,22 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import android.content.pm.LauncherActivityInfo
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.TextType
import de.mm20.launcher2.searchactions.actions.AppSearchAction
import de.mm20.launcher2.searchactions.actions.SearchAction
class AppSearchActionBuilder(
val label: String,
val activity: LauncherActivityInfo,
val filter: TextType? = null,
) : SearchActionBuilder {
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
return AppSearchAction(
label = label,
componentName = activity.componentName,
query = classifiedQuery.text,
)
}
}

View File

@ -2,28 +2,26 @@ package de.mm20.launcher2.searchactions.builders
import android.content.Context
import android.net.Uri
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.TextType
import de.mm20.launcher2.searchactions.actions.OpenUrlAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import java.net.URLEncoder
class WebsearchActionBuilder(
val label: String,
val urlTemplate: String,
val filter: TextType? = null,
val encoding: QueryEncoding,
val icon: SearchActionIcon = SearchActionIcon.Search,
val customIcon: String? = null,
val encoding: QueryEncoding = QueryEncoding.UrlEncode,
) : SearchActionBuilder {
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (filter == null || classifiedQuery.type == filter) {
val url = urlTemplate.replace("\${1}", encodeQuery(classifiedQuery.text, encoding))
return OpenUrlAction(
label = label,
url = url,
)
}
return null
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction {
val url = urlTemplate.replace("\${1}", encodeQuery(classifiedQuery.text, encoding))
return OpenUrlAction(
label = label,
url = url,
)
}

View File

@ -19,5 +19,4 @@ val searchModule = module {
get(),
)
}
single<WebsearchRepository> { WebsearchRepositoryImpl(androidContext(), get()) }
}

View File

@ -1,306 +0,0 @@
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.entities.WebsearchEntity
import de.mm20.launcher2.database.entities.WidgetEntity
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.data.Websearch
import kotlinx.coroutines.*
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.json.JSONArray
import org.json.JSONException
import org.jsoup.Jsoup
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.net.URL
interface WebsearchRepository {
fun search(query: String): Flow<List<Websearch>>
fun getWebsearches(): Flow<List<Websearch>>
fun insertWebsearch(websearch: Websearch)
fun deleteWebsearch(websearch: Websearch)
suspend fun importWebsearch(url: String, iconSize: Int): Websearch?
suspend fun createIcon(uri: Uri, size: Int): String?
suspend fun export(toDir: File)
suspend fun import(fromDir: File)
}
internal class WebsearchRepositoryImpl(
private val context: Context,
private val database: AppDatabase
) : WebsearchRepository, KoinComponent {
private val dataStore: LauncherDataStore by inject()
private val scope = CoroutineScope(Job() + Dispatchers.Default)
override fun search(query: String): Flow<List<Websearch>> = channelFlow {
if (query.isEmpty()) {
send(emptyList())
return@channelFlow
}
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())
}
}
}
override fun getWebsearches(): Flow<List<Websearch>> =
database.searchDao().getWebSearches().map {
it.map { Websearch(it) }
}
override fun insertWebsearch(websearch: Websearch) {
scope.launch {
withContext(Dispatchers.IO) {
database.searchDao().insertWebsearch(websearch.toDatabaseEntity())
}
}
}
override fun deleteWebsearch(websearch: Websearch) {
scope.launch {
withContext(Dispatchers.IO) {
database.searchDao().deleteWebsearch(websearch.toDatabaseEntity())
}
}
}
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) {
parser.next()
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
}
override suspend fun export(toDir: File) = withContext(Dispatchers.IO) {
val dao = database.backupDao()
var page = 0
var iconCounter = 0
do {
val websearches = dao.exportWebsearches(limit = 100, offset = page * 100)
val jsonArray = JSONArray()
for (websearch in websearches) {
var icon = websearch.icon
if (icon != null) {
val fileName = "asset.websearch.${iconCounter.toString().padStart(4, '0')}"
val iconAssetFile = File(toDir, fileName)
File(icon).inputStream().use { inStream ->
iconAssetFile.outputStream().use { outStream ->
inStream.copyTo(outStream)
}
}
icon = fileName
iconCounter++
}
jsonArray.put(
jsonObjectOf(
"color" to websearch.color,
"label" to websearch.label,
"template" to websearch.urlTemplate,
"icon" to icon,
"encoding" to websearch.encoding,
)
)
}
val file = File(toDir, "websearches.${page.toString().padStart(4, '0')}")
file.bufferedWriter().use {
it.write(jsonArray.toString())
}
page++
} while (websearches.size == 100)
}
override suspend fun import(fromDir: File) = withContext(Dispatchers.IO) {
val dao = database.backupDao()
dao.wipeWebsearches()
val files = fromDir.listFiles { _, name -> name.startsWith("websearches.") } ?: return@withContext
for (file in files) {
val websearches = mutableListOf<WebsearchEntity>()
try {
val jsonArray = JSONArray(file.inputStream().reader().readText())
for (i in 0 until jsonArray.length()) {
val json = jsonArray.getJSONObject(i)
val icon = json.optString("icon").takeIf { it.isNotEmpty() }
var iconFile: File? = null
if (icon != null) {
val asset = File(fromDir, icon)
iconFile = File(context.filesDir, icon)
asset.inputStream().use { inStream ->
iconFile.outputStream().use { outStream ->
inStream.copyTo(outStream)
}
}
}
val entity = WebsearchEntity(
urlTemplate = json.getString("template"),
color = json.optInt("color", 0),
label = json.getString("label"),
icon = iconFile?.absolutePath,
encoding = json.optInt("encoding"),
id = null
)
websearches.add(entity)
}
dao.importWebsearches(websearches)
} catch (e: JSONException) {
CrashReporter.logException(e)
}
}
}
}

View File

@ -170,7 +170,7 @@ fun RestoreBackupSheet(
imageVector = when (component) {
BackupComponent.Favorites -> Icons.Rounded.Star
BackupComponent.Settings -> Icons.Rounded.Settings
BackupComponent.Websearches -> Icons.Rounded.TravelExplore
BackupComponent.SearchActions -> Icons.Rounded.ArrowOutward
BackupComponent.Widgets -> Icons.Rounded.Widgets
BackupComponent.Customizations -> Icons.Rounded.Edit
},
@ -181,7 +181,7 @@ fun RestoreBackupSheet(
when (component) {
BackupComponent.Favorites -> R.string.backup_component_favorites
BackupComponent.Settings -> R.string.backup_component_settings
BackupComponent.Websearches -> R.string.backup_component_websearches
BackupComponent.SearchActions -> R.string.backup_component_searchactions
BackupComponent.Widgets -> R.string.backup_component_widgets
BackupComponent.Customizations -> R.string.backup_component_customizations
}

View File

@ -10,7 +10,6 @@ import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchService
import de.mm20.launcher2.search.WebsearchRepository
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.Calculator
import de.mm20.launcher2.search.data.CalendarEvent
@ -40,7 +39,6 @@ class SearchVM : ViewModel(), KoinComponent {
private val dataStore: LauncherDataStore by inject()
private val searchService: SearchService by inject()
private val websearchRepository: WebsearchRepository by inject()
val isSearching = MutableLiveData(false)
val searchQuery = MutableLiveData<String>("")

View File

@ -47,7 +47,6 @@ import de.mm20.launcher2.ui.settings.search.SearchSettingsScreen
import de.mm20.launcher2.ui.settings.searchactions.SearchActionsSettingsScreen
import de.mm20.launcher2.ui.settings.unitconverter.UnitConverterSettingsScreen
import de.mm20.launcher2.ui.settings.weatherwidget.WeatherWidgetSettingsScreen
import de.mm20.launcher2.ui.settings.websearch.WebSearchSettingsScreen
import de.mm20.launcher2.ui.settings.widgets.WidgetsSettingsScreen
import de.mm20.launcher2.ui.settings.wikipedia.WikipediaSettingsScreen
import de.mm20.launcher2.ui.theme.LauncherTheme
@ -119,9 +118,6 @@ class SettingsActivity : BaseActivity() {
composable("settings/search/files") {
FileSearchSettingsScreen()
}
composable("settings/search/websearch") {
WebSearchSettingsScreen()
}
composable("settings/search/searchactions") {
SearchActionsSettingsScreen()
}

View File

@ -135,11 +135,11 @@ fun CreateBackupSheet(
}
)
BackupableComponent(
title = stringResource(R.string.backup_component_websearches),
title = stringResource(R.string.backup_component_searchactions),
icon = Icons.Rounded.TravelExplore,
checked = components.contains(BackupComponent.Websearches),
checked = components.contains(BackupComponent.SearchActions),
onCheckedChange = {
viewModel.toggleComponent(BackupComponent.Websearches)
viewModel.toggleComponent(BackupComponent.SearchActions)
}
)
SmallMessage(

View File

@ -1,692 +0,0 @@
package de.mm20.launcher2.ui.settings.websearch
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowForward
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Tag
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.godaddy.android.colorpicker.ClassicColorPicker
import de.mm20.launcher2.search.data.Websearch
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.component.preferences.ListPreference
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.ktx.toHexString
import de.mm20.launcher2.ui.ktx.toPixels
import kotlinx.coroutines.launch
import java.io.File
@Composable
fun WebSearchSettingsScreen() {
val viewModel: WebSearchSettingsScreenVM = viewModel()
val websearches by viewModel.websearches.observeAsState(emptyList())
var showNewDialog by remember { mutableStateOf(false) }
PreferenceScreen(
title = stringResource(R.string.preference_search_websearch),
floatingActionButton = {
FloatingActionButton(onClick = { showNewDialog = true }) {
Icon(imageVector = Icons.Rounded.Add, contentDescription = null)
}
},
helpUrl = "https://kvaesitso.mm20.de/docs/user-guide/search/websearches"
) {
item {
PreferenceCategory {
for (websearch in websearches) {
WebsearchPreference(
value = websearch,
onValueChanged = {
viewModel.updateWebsearch(it)
},
onValueDeleted = { viewModel.deleteWebsearch(it) }
)
}
}
}
}
if (showNewDialog) {
EditWebsearchDialog(
title = stringResource(R.string.websearch_dialog_create_title),
value = Websearch(
label = "",
urlTemplate = "",
color = 0,
icon = null
),
onValueSaved = {
viewModel.createWebsearch(it)
showNewDialog = false
},
onCancel = {
showNewDialog = false
},
enableImport = true
)
}
}
@Composable
fun WebsearchPreference(
value: Websearch,
onValueChanged: (Websearch) -> Unit,
onValueDeleted: (Websearch) -> Unit,
) {
var showDialog by remember { mutableStateOf(false) }
Preference(
title = value.label,
summary = value.urlTemplate,
onClick = {
showDialog = true
},
icon = {
val icon = value.icon
if (icon == null) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = null,
tint = value.color.takeIf { it != 0 }?.let { Color(it) }
?: MaterialTheme.colorScheme.primary,
)
} else {
AsyncImage(
model = File(icon),
contentDescription = null,
modifier = Modifier.sizeIn(maxWidth = 24.dp, maxHeight = 24.dp),
contentScale = ContentScale.Inside
)
}
}
)
if (showDialog) {
EditWebsearchDialog(
title = stringResource(R.string.websearch_dialog_edit_title),
value = value,
onValueSaved = {
onValueChanged(it)
showDialog = false
},
onCancel = {
showDialog = false
},
onValueDeleted = onValueDeleted
)
}
}
@Composable
fun EditWebsearchDialog(
title: String,
value: Websearch,
onValueSaved: (Websearch) -> Unit,
onValueDeleted: ((Websearch) -> Unit)? = null,
onCancel: () -> Unit,
enableImport: Boolean = false
) {
var showDropdown by remember { mutableStateOf(false) }
var label by remember { mutableStateOf(value.label) }
var showError by remember { mutableStateOf(false) }
var urlTemplate by remember { mutableStateOf(value.urlTemplate) }
var encoding by remember { mutableStateOf(value.encoding) }
var color by remember { mutableStateOf(value.color) }
var icon by remember { mutableStateOf(value.icon) }
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()
val chooseIconLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) {
if (it != null) {
scope.launch {
icon = viewModel.createIcon(it, iconSizePx)
}
}
}
BottomSheetDialog(onDismissRequest = onCancel,
title = { Text(title) },
actions = {
if (enableImport) {
Box {
IconButton(onClick = {
showImport = !showImport
importError = false
}) {
Icon(
imageVector = Icons.Rounded.CloudDownload,
contentDescription = null
)
}
}
}
if (onValueDeleted != null) {
Box {
IconButton(onClick = {
showDropdown = true
}) {
Icon(
imageVector = Icons.Rounded.MoreVert,
contentDescription = null
)
}
DropdownMenu(
expanded = showDropdown,
onDismissRequest = { showDropdown = false }) {
DropdownMenuItem(
text = {
Text(
text = stringResource(R.string.menu_delete)
)
},
onClick = {
onValueDeleted(value)
onCancel()
})
}
}
}
},
confirmButton = {
Button(onClick = {
if (urlTemplate.contains("\${1}")) {
value.label = label
value.urlTemplate = urlTemplate
if (value.icon != icon) {
value.icon?.let {
viewModel.deleteIcon(it)
}
}
value.icon = icon
value.color = color
value.encoding = encoding
onValueSaved(value)
} else {
showError = true
}
}) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
OutlinedButton(onClick = {
if (icon != value.icon) {
icon?.let { viewModel.deleteIcon(it) }
}
onCancel()
}) {
Text(stringResource(android.R.string.cancel))
}
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
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
},
textStyle = MaterialTheme.typography.bodyLarge,
)
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
) {
AsyncImage(
model = icon?.let { File(it) },
contentDescription = null,
modifier = Modifier
.padding(end = 16.dp)
.size(48.dp)
)
TextButton(
onClick = {
chooseIconLauncher.launch("image/*")
},
modifier = Modifier.padding(4.dp)
) {
Text(
stringResource(R.string.websearch_dialog_replace_icon),
)
}
TextButton(
onClick = {
icon = null
},
modifier = Modifier.padding(4.dp),
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text(
stringResource(R.string.websearch_dialog_delete_icon),
)
}
}
} else {
ColorPicker(
value = color,
onColorSelected = { color = it }
)
TextButton(
onClick = {
chooseIconLauncher.launch("image/*")
},
modifier = Modifier
.padding(4.dp)
.align(Alignment.End)
) {
Text(
stringResource(R.string.websearch_dialog_custom_icon),
)
}
}
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
value = label,
onValueChange = {
label = it
},
label = {
Text(text = stringResource(R.string.websearch_dialog_name))
},
textStyle = MaterialTheme.typography.bodyLarge,
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
value = urlTemplate,
onValueChange = {
urlTemplate = it
},
label = {
Text(text = stringResource(R.string.websearch_dialog_url))
},
textStyle = MaterialTheme.typography.bodyLarge,
)
AnimatedVisibility(showError) {
Text(
modifier = Modifier.padding(top = 8.dp),
text = stringResource(R.string.websearch_dialog_url_error),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error
)
}
Text(
modifier = Modifier.padding(top = 8.dp),
text = stringResource(R.string.websearch_dialog_url_description),
style = MaterialTheme.typography.labelMedium
)
var showAdvanced by remember { mutableStateOf(false) }
AnimatedVisibility(!showAdvanced) {
TextButton(
modifier = Modifier.padding(vertical = 16.dp).align(Alignment.End),
onClick = { showAdvanced = true }) {
Text(stringResource(R.string.websearch_dialog_advanced))
}
}
AnimatedVisibility(showAdvanced) {
Column(
modifier = Modifier.padding(top = 16.dp)
) {
Divider()
ListPreference(
title = stringResource(R.string.websearch_dialog_query_encoding),
items = listOf(
stringResource(R.string.websearch_dialog_query_encoding_url) to Websearch.QueryEncoding.UrlEncode,
stringResource(R.string.websearch_dialog_query_encoding_form) to Websearch.QueryEncoding.FormData,
stringResource(R.string.websearch_dialog_query_encoding_none) to Websearch.QueryEncoding.None,
),
iconPadding = false,
value = encoding,
onValueChanged = {
encoding = it
}
)
}
}
}
}
}
@Composable
private fun ColorPicker(
value: Int,
onColorSelected: (Int) -> Unit
) {
var selectedColorIndex = -1
val isCustomColor = !ColorPresets.contains(Color(value)) && value != 0
val listState = rememberLazyListState()
var showCustomColorPicker by remember { mutableStateOf(false) }
Column {
AnimatedVisibility(!showCustomColorPicker) {
LazyRow(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(64.dp),
state = listState
) {
item {
if (value == 0) selectedColorIndex = 0
ColorSwatch(
color = MaterialTheme.colorScheme.primary,
checked = value == 0,
onClick = {
onColorSelected(0)
}
)
}
items(ColorPresets) {
ColorSwatch(
color = it,
checked = value == it.toArgb(),
onClick = {
onColorSelected(it.toArgb())
}
)
}
item {
CustomColorSwatch(
checked = isCustomColor,
onClick = {
showCustomColorPicker = true
}
)
}
}
LaunchedEffect(null) {
if (isCustomColor) listState.scrollToItem(ColorPresets.size + 1)
else if (value != 0) listState.scrollToItem(ColorPresets.indexOf(Color(value)) + 1)
}
}
AnimatedVisibility(showCustomColorPicker) {
Column {
ClassicColorPicker(
color = Color(value),
showAlphaBar = false,
modifier = Modifier.height(200.dp),
onColorChanged = {
onColorSelected(it.toColor().toArgb())
})
Row(
modifier = Modifier
.padding(bottom = 24.dp, top = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
var textFieldValue by remember(value) {
mutableStateOf(
Color(value).toHexString().substring(1)
)
}
TextField(
value = textFieldValue,
leadingIcon = {
Icon(imageVector = Icons.Rounded.Tag, contentDescription = null)
},
onValueChange = {
textFieldValue = it
if (it.length == 6) it.toLongOrNull(16)?.let {
onColorSelected((it or 0xFF000000).toInt())
}
},
singleLine = true,
modifier = Modifier.width(150.dp),
textStyle = MaterialTheme.typography.bodyLarge,
)
TextButton(onClick = { showCustomColorPicker = false }) {
Text(
stringResource(android.R.string.ok),
style = MaterialTheme.typography.labelMedium
)
}
}
}
}
}
}
@Composable
private fun ColorSwatch(
color: Color,
checked: Boolean,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.padding(horizontal = 12.dp)
.size(48.dp)
.clip(CircleShape)
.background(color)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
if (checked) {
Icon(
imageVector = Icons.Rounded.Check, contentDescription = null,
tint = if (color.luminance() > 0.5f) Color.Black else Color.White
)
}
}
}
@Composable
private fun CustomColorSwatch(
checked: Boolean,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.padding(horizontal = 12.dp)
.size(48.dp)
.clip(CircleShape)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val brush = Brush.sweepGradient(
listOf(
Color.Red,
Color.Magenta,
Color.Blue,
Color.Cyan,
Color.Green,
Color.Yellow,
Color.Red
)
)
drawRect(brush)
}
if (checked) {
Icon(
imageVector = Icons.Rounded.Check, contentDescription = null,
tint = Color.White
)
}
}
}
private val ColorPresets = listOf(
Color(0xFFEF5350),
Color(0xFFEC407A),
Color(0xFFAB47BC),
Color(0xFF7E57C2),
Color(0xFF5C6BC0),
Color(0xFF42A5F5),
Color(0xFF29B6F6),
Color(0xFF26C6DA),
Color(0xFF26A69A),
Color(0xFF66BB6A),
Color(0xFF9CCC65),
Color(0xFFD4E157),
Color(0xFFFFEE58),
Color(0xFFFFCA28),
Color(0xFFFFA726),
Color(0xFFFF7043),
Color(0xFF8D6E63),
Color(0xFFBDBDBD),
Color(0xFF78909C),
)

View File

@ -1,55 +0,0 @@
package de.mm20.launcher2.ui.settings.websearch
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.search.WebsearchRepository
import de.mm20.launcher2.search.data.Websearch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
class WebSearchSettingsScreenVM: ViewModel(), KoinComponent {
private val repository: WebsearchRepository by inject()
val websearches = repository.getWebsearches().asLiveData()
fun createWebsearch(websearch: Websearch) {
repository.insertWebsearch(websearch)
}
fun updateWebsearch(websearch: Websearch) {
repository.insertWebsearch(websearch)
}
fun deleteWebsearch(websearch: Websearch) {
websearch.icon?.let { deleteIcon(it) }
repository.deleteWebsearch(websearch)
}
/**
* 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(uri: Uri, size: Int): String? {
return repository.createIcon(uri, size)
}
suspend fun importWebsearch(url: String, size: Int): Websearch? {
return repository.importWebsearch(url, size)
}
fun deleteIcon(path: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
File(path).delete()
}
}
}
}