Migrate websearches to search actions
This commit is contained in:
parent
f862a578a1
commit
872f55625f
@ -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"))
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
468
database/schemas/de.mm20.launcher2.database.AppDatabase/19.json
Normal file
468
database/schemas/de.mm20.launcher2.database.AppDatabase/19.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -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`")
|
||||
}
|
||||
}
|
||||
@ -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`))")
|
||||
}
|
||||
}
|
||||
@ -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`))")
|
||||
}
|
||||
}
|
||||
@ -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`;")
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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`")
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
@ -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}")
|
||||
}
|
||||
}
|
||||
@ -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}")
|
||||
}
|
||||
|
||||
}
|
||||
@ -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`) );")
|
||||
}
|
||||
|
||||
}
|
||||
@ -651,6 +651,7 @@
|
||||
<string name="backup_component_favorites">Favorites & 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 & 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>
|
||||
|
||||
@ -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"))
|
||||
|
||||
}
|
||||
@ -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()) }
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -19,5 +19,4 @@ val searchModule = module {
|
||||
get(),
|
||||
)
|
||||
}
|
||||
single<WebsearchRepository> { WebsearchRepositoryImpl(androidContext(), get()) }
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>("")
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
)
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user