Plugins: bringup

This commit is contained in:
MM20
2023-11-05 19:02:59 +01:00
parent 7da84a747f
commit 801caf9dd6
54 changed files with 1335 additions and 88 deletions
@@ -8,12 +8,14 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.sqlite.db.SupportSQLiteDatabase
import de.mm20.launcher2.database.daos.PluginDao
import de.mm20.launcher2.database.daos.ThemeDao
import de.mm20.launcher2.database.entities.CurrencyEntity
import de.mm20.launcher2.database.entities.CustomAttributeEntity
import de.mm20.launcher2.database.entities.ForecastEntity
import de.mm20.launcher2.database.entities.IconEntity
import de.mm20.launcher2.database.entities.IconPackEntity
import de.mm20.launcher2.database.entities.PluginEntity
import de.mm20.launcher2.database.entities.SavedSearchableEntity
import de.mm20.launcher2.database.entities.SearchActionEntity
import de.mm20.launcher2.database.entities.ThemeEntity
@@ -33,6 +35,7 @@ import de.mm20.launcher2.database.migrations.Migration_21_22
import de.mm20.launcher2.database.migrations.Migration_22_23
import de.mm20.launcher2.database.migrations.Migration_23_24
import de.mm20.launcher2.database.migrations.Migration_24_25
import de.mm20.launcher2.database.migrations.Migration_25_26
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
@@ -51,7 +54,8 @@ import java.util.UUID
CustomAttributeEntity::class,
SearchActionEntity::class,
ThemeEntity::class,
], version = 25, exportSchema = true
PluginEntity::class,
], version = 26, exportSchema = true
)
@TypeConverters(ComponentNameConverter::class)
abstract class AppDatabase : RoomDatabase() {
@@ -69,6 +73,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun themeDao(): ThemeDao
abstract fun pluginDao(): PluginDao
companion object {
private var _instance: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
@@ -147,6 +153,7 @@ abstract class AppDatabase : RoomDatabase() {
Migration_22_23(),
Migration_23_24(),
Migration_24_25(context),
Migration_25_26(),
).build()
if (_instance == null) _instance = instance
return instance
@@ -0,0 +1,40 @@
package de.mm20.launcher2.database.daos
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import de.mm20.launcher2.database.entities.PluginEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface PluginDao {
@Query("""
SELECT * FROM Plugins WHERE
(type = :type OR :type IS NULL) AND
(enabled = :enabled OR :enabled IS NULL) AND
(packageName = :packageName OR :packageName IS NULL)
""")
fun findMany(
type: String? = null,
enabled: Boolean? = null,
packageName: String? = null,
): Flow<List<PluginEntity>>
@Query("SELECT * FROM Plugins WHERE authority = :authority")
fun get(authority: String): Flow<PluginEntity>
@Insert
fun insertMany(plugins: List<PluginEntity>)
@Insert
fun insert(plugin: PluginEntity)
@Update
fun update(plugin: PluginEntity)
@Query("DELETE FROM Plugins")
fun deleteMany()
}
@@ -0,0 +1,16 @@
package de.mm20.launcher2.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "Plugins")
data class PluginEntity(
@PrimaryKey val authority: String,
val label: String,
val description: String?,
val packageName: String,
val className: String,
val type: String,
val settingsActivity: String?,
val enabled: Boolean,
)
@@ -0,0 +1,24 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
internal class Migration_25_26 : Migration(25, 26) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("""
CREATE TABLE Plugins
(
authority TEXT NOT NULL,
label TEXT NOT NULL,
description TEXT,
packageName TEXT NOT NULL,
className TEXT NOT NULL,
type TEXT NOT NULL,
settingsActivity TEXT,
enabled INTEGER NOT NULL,
PRIMARY KEY(`authority`)
)
""".trimIndent()
)
}
}
@@ -1,22 +1,31 @@
package de.mm20.launcher2.files
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.files.providers.GDriveFile
import de.mm20.launcher2.files.providers.LocalFile
import de.mm20.launcher2.files.providers.NextcloudFile
import de.mm20.launcher2.files.providers.OneDriveFile
import de.mm20.launcher2.files.providers.OwncloudFile
import de.mm20.launcher2.files.providers.PluginFile
import de.mm20.launcher2.files.providers.PluginFileProvider
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.config.StorageStrategy
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.FileMetaType
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.flow.firstOrNull
import org.json.JSONException
import org.json.JSONObject
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
@@ -299,4 +308,86 @@ internal class OwncloudFileDeserializer : SearchableDeserializer {
)
}
}
internal class PluginFileSerializer(
) : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String? {
searchable as PluginFile
if (searchable.storageStrategy == StorageStrategy.StoreReference) {
return jsonObjectOf(
"id" to searchable.id,
"authority" to searchable.authority,
"strategy" to "ref"
).toString()
} else {
return jsonObjectOf(
"id" to searchable.id,
"path" to searchable.path,
"mimeType" to searchable.mimeType,
"size" to searchable.size,
"label" to searchable.label,
"uri" to searchable.uri.toString(),
"thumbnailUri" to searchable.thumbnailUri?.toString(),
"isDirectory" to searchable.isDirectory,
"authority" to searchable.authority,
"strategy" to "copy",
).toString()
}
}
override val typePrefix: String
get() = PluginFile.Domain
}
internal class PluginFileDeserializer(
private val context: Context,
private val pluginRepository: PluginRepository,
): SearchableDeserializer {
override suspend fun deserialize(serialized: String): SavableSearchable? {
val jsonObject = JSONObject(serialized)
return if (jsonObject.optString("strategy", "ref") == "ref") {
getByRef(jsonObject)
} else {
getByCopy(jsonObject)
}
}
private suspend fun getByRef(obj: JSONObject): File? {
try {
val authority = obj.getString("authority")
val id = obj.getString("id")
val plugin = pluginRepository.get(authority).firstOrNull() ?: return null
val provider = PluginFileProvider(context, plugin)
return provider.getFile(id)
} catch (e: Exception) {
CrashReporter.logException(e)
return null
}
}
private fun getByCopy(obj: JSONObject): File? {
try {
val uri = obj.getString("uri")
val thumbnailUri = obj.optString("thumbnailUri")
return PluginFile(
id = obj.getString("id"),
path = obj.getString("path"),
mimeType = obj.getString("mimeType"),
size = obj.optLong("size", 0L),
metaData = persistentMapOf(),
label = obj.getString("label"),
uri = Uri.parse(uri),
thumbnailUri = thumbnailUri.takeIf { it.isNotEmpty() }?.let { Uri.parse(it) },
storageStrategy = StorageStrategy.StoreCopy,
isDirectory = obj.optBoolean("isDirectory", false),
authority = obj.getString("authority"),
)
} catch (e: JSONException) {
CrashReporter.logException(e)
return null
}
}
}
@@ -6,9 +6,12 @@ import de.mm20.launcher2.files.providers.GDriveFileProvider
import de.mm20.launcher2.files.providers.LocalFileProvider
import de.mm20.launcher2.files.providers.NextcloudFileProvider
import de.mm20.launcher2.files.providers.OwncloudFileProvider
import de.mm20.launcher2.files.providers.PluginFileProvider
import de.mm20.launcher2.nextcloud.NextcloudApiHelper
import de.mm20.launcher2.owncloud.OwncloudClient
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.SearchableRepository
@@ -19,16 +22,16 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
internal class FileRepository(
private val context: Context,
private val permissionsManager: PermissionsManager,
private val dataStore: LauncherDataStore,
private val pluginRepository: PluginRepository,
) : SearchableRepository<File> {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val nextcloudClient by lazy {
NextcloudApiHelper(context)
}
@@ -44,23 +47,40 @@ internal class FileRepository(
return@channelFlow
}
dataStore.data.map { it.fileSearch }.collectLatest {
val providers = mutableListOf<FileProvider>()
val filePlugins = pluginRepository.findMany(
type = PluginType.FileSearch,
enabled = true,
)
if (it.localFiles) providers.add(LocalFileProvider(context, permissionsManager))
if (it.gdrive) providers.add(GDriveFileProvider(context))
if (it.nextcloud) providers.add(NextcloudFileProvider(nextcloudClient))
if (it.owncloud) providers.add(OwncloudFileProvider(owncloudClient))
dataStore.data.map { it.fileSearch }
.combine(filePlugins) { settings, plugins ->
settings to plugins
}.collectLatest { (settings, plugins) ->
val providers = mutableListOf<FileProvider>()
if (providers.isEmpty()) {
send(persistentListOf())
return@collectLatest
if (settings.localFiles) providers.add(
LocalFileProvider(
context,
permissionsManager
)
)
if (settings.gdrive) providers.add(GDriveFileProvider(context))
if (settings.nextcloud) providers.add(NextcloudFileProvider(nextcloudClient))
if (settings.owncloud) providers.add(OwncloudFileProvider(owncloudClient))
for (plugin in plugins) {
providers.add(PluginFileProvider(context, plugin))
}
if (providers.isEmpty()) {
send(persistentListOf())
return@collectLatest
}
val results = mutableListOf<File>()
for (provider in providers) {
results.addAll(provider.search(query))
send(results.toImmutableList())
}
}
val results = mutableListOf<File>()
for (provider in providers) {
results.addAll(provider.search(query))
send(results.toImmutableList())
}
}
}
}
@@ -5,6 +5,7 @@ import de.mm20.launcher2.files.providers.LocalFile
import de.mm20.launcher2.files.providers.NextcloudFile
import de.mm20.launcher2.files.providers.OneDriveFile
import de.mm20.launcher2.files.providers.OwncloudFile
import de.mm20.launcher2.files.providers.PluginFile
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableRepository
@@ -13,10 +14,11 @@ import org.koin.core.qualifier.named
import org.koin.dsl.module
val filesModule = module {
factory<SearchableRepository<File>>(named<File>()) { FileRepository(androidContext(), get(), get()) }
factory<SearchableRepository<File>>(named<File>()) { FileRepository(androidContext(), get(), get(), get()) }
factory<SearchableDeserializer>(named(LocalFile.Domain)) { LocalFileDeserializer(androidContext()) }
factory<SearchableDeserializer>(named(OwncloudFile.Domain)) { OwncloudFileDeserializer() }
factory<SearchableDeserializer>(named(NextcloudFile.Domain)) { NextcloudFileDeserializer() }
factory<SearchableDeserializer>(named(OneDriveFile.Domain)) { OneDriveFileDeserializer() }
factory<SearchableDeserializer>(named(GDriveFile.Domain)) { GDriveFileDeserializer() }
factory<SearchableDeserializer>(named(PluginFile.Domain)) { PluginFileDeserializer(androidContext(), get()) }
}
@@ -33,8 +33,6 @@ internal data class GDriveFile(
override val key: String = "$domain://$fileId"
override val isStoredInCloud = true
override val providerIconRes = R.drawable.ic_badge_gdrive
private fun getLaunchIntent(): Intent {
@@ -56,8 +56,6 @@ internal data class LocalFile(
override val key = "$domain://$path"
override val isStoredInCloud = false
override suspend fun loadIcon(
context: Context,
size: Int,
@@ -33,9 +33,6 @@ internal data class NextcloudFile(
override val key: String = "$domain://$server/$fileId"
override val isStoredInCloud: Boolean
get() = true
override val providerIconRes = R.drawable.ic_badge_nextcloud
private fun getLaunchIntent(context: Context): Intent {
@@ -34,8 +34,6 @@ internal data class OneDriveFile(
override val providerIconRes = R.drawable.ic_badge_onedrive
override val isStoredInCloud = true
private fun getLaunchIntent(): Intent {
return Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(webUrl)
@@ -9,6 +9,7 @@ import de.mm20.launcher2.files.R
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.FileMetaType
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableSerializer
import kotlinx.collections.immutable.ImmutableMap
@@ -32,9 +33,6 @@ internal data class OwncloudFile(
override val key: String = "$domain://$server/$fileId"
override val isStoredInCloud: Boolean
get() = true
override val providerIconRes = R.drawable.ic_badge_owncloud
private fun getLaunchIntent(): Intent {
@@ -0,0 +1,53 @@
package de.mm20.launcher2.files.providers
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import de.mm20.launcher2.files.PluginFileSerializer
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.plugin.config.StorageStrategy
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.FileMetaType
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableSerializer
import kotlinx.collections.immutable.ImmutableMap
data class PluginFile(
val id: String,
override val path: String,
override val mimeType: String,
override val size: Long,
override val metaData: ImmutableMap<FileMetaType, String>,
override val label: String,
override val isDirectory: Boolean,
val uri: Uri,
val thumbnailUri: Uri?,
val authority: String,
internal val storageStrategy: StorageStrategy,
override val labelOverride: String? = null,
) : File {
override val domain: String = Domain
override val key: String
get() = "$domain://$authority:$id"
override fun overrideLabel(label: String): SavableSearchable {
return this.copy(labelOverride = label)
}
override fun launch(context: Context, options: Bundle?): Boolean {
return context.tryStartActivity(Intent(Intent.ACTION_VIEW).apply {
data = uri
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}, options)
}
override fun getSerializer(): SearchableSerializer {
return PluginFileSerializer()
}
companion object {
const val Domain = "plugin.file"
}
}
@@ -0,0 +1,127 @@
package de.mm20.launcher2.files.providers
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.CancellationSignal
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.plugin.Plugin
import de.mm20.launcher2.plugin.config.StorageStrategy
import de.mm20.launcher2.plugin.contracts.FilePluginContract
import de.mm20.launcher2.plugin.contracts.SearchPluginContract
import de.mm20.launcher2.search.File
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class PluginFileProvider(
private val context: Context,
private val plugin: Plugin,
) : FileProvider {
override suspend fun search(query: String): List<File> {
val uri = Uri.Builder()
.scheme("content")
.authority(plugin.authority)
.path(SearchPluginContract.Paths.Search)
.appendQueryParameter(SearchPluginContract.Paths.QueryParam, query)
.build()
val cancellationSignal = CancellationSignal()
return suspendCancellableCoroutine {
it.invokeOnCancellation {
cancellationSignal.cancel()
}
val cursor = context.contentResolver.query(
uri,
null,
null,
cancellationSignal
) ?: return@suspendCancellableCoroutine it.resume(emptyList<File>())
val results = fromCursor(cursor) ?: emptyList()
it.resume(results)
}
}
suspend fun getFile(id: String): File? {
val uri = Uri.Builder()
.scheme("content")
.authority(plugin.authority)
.path(SearchPluginContract.Paths.Root)
.appendPath(id)
.build()
val cancellationSignal = CancellationSignal()
return suspendCancellableCoroutine {
it.invokeOnCancellation {
cancellationSignal.cancel()
}
val cursor = context.contentResolver.query(
uri,
null,
null,
cancellationSignal
) ?: return@suspendCancellableCoroutine it.resume(null)
val results = fromCursor(cursor)
it.resume(results?.firstOrNull())
}
}
private fun fromCursor(cursor: Cursor): List<File>? {
val idIndex = cursor
.getColumnIndex(FilePluginContract.FileColumns.Id)
.takeIf { it >= 0 }
?: return null
val pathIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.Path).takeIf { it >= 0 }
val typeIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.MimeType).takeIf { it >= 0 }
val sizeIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.Size).takeIf { it >= 0 }
val nameIndex = cursor.getColumnIndex(FilePluginContract.FileColumns.DisplayName)
.takeIf { it >= 0 }
?: return null
val contentUriIndex = cursor.getColumnIndex(FilePluginContract.FileColumns.ContentUri)
.takeIf { it >= 0 }
?: return null
val thumbnailUriIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.ThumbnailUri)
.takeIf { it >= 0 }
val storageStrategyIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.StorageStrategy)
.takeIf { it >= 0 }
val directoryIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.IsDirectory).takeIf { it >= 0 }
val results = mutableListOf<File>()
while (cursor.moveToNext()) {
results.add(
PluginFile(
id = cursor.getString(idIndex),
path = pathIndex?.let { cursor.getString(it) } ?: "",
mimeType = typeIndex?.let { cursor.getString(it) }
?: "application/octet-stream",
size = sizeIndex?.let { cursor.getLong(it) } ?: 0,
metaData = persistentMapOf(),
label = cursor.getString(nameIndex),
uri = Uri.parse(cursor.getString(contentUriIndex)),
thumbnailUri = thumbnailUriIndex?.let {
cursor.getStringOrNull(it)
}?.let { Uri.parse(it) },
storageStrategy = try {
storageStrategyIndex?.let {
StorageStrategy.valueOf(cursor.getString(it))
}
} catch (e: IllegalArgumentException) {
null
} ?: StorageStrategy.StoreCopy,
isDirectory = directoryIndex?.let { cursor.getInt(it) } == 1,
authority = plugin.authority,
)
)
}
cursor.close()
return results
}
}
+1
View File
@@ -0,0 +1 @@
/build
+46
View File
@@ -0,0 +1,46 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.data.plugins"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.koin.android)
implementation(project(":core:ktx"))
implementation(project(":core:base"))
implementation(project(":core:crashreporter"))
implementation(project(":data:database"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,9 @@
package de.mm20.launcher2.data.plugins
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.plugin.PluginRepository
import org.koin.dsl.module
val dataPluginsModule = module {
factory<PluginRepository> { PluginRepositoryImpl(get<AppDatabase>().pluginDao()) }
}
@@ -0,0 +1,35 @@
package de.mm20.launcher2.data.plugins
import de.mm20.launcher2.database.entities.PluginEntity
import de.mm20.launcher2.plugin.Plugin
import de.mm20.launcher2.plugin.PluginType
internal fun Plugin(entity: PluginEntity): Plugin? {
return Plugin(
enabled = entity.enabled,
label = entity.label,
description = entity.description,
settingsActivity = entity.settingsActivity,
packageName = entity.packageName,
className = entity.className,
type = try {
PluginType.valueOf(entity.type)
} catch (e: IllegalArgumentException) {
return null
},
authority = entity.authority,
)
}
internal fun PluginEntity(plugin: Plugin): PluginEntity {
return PluginEntity(
enabled = plugin.enabled,
label = plugin.label,
description = plugin.description,
settingsActivity = plugin.settingsActivity,
packageName = plugin.packageName,
className = plugin.className,
type = plugin.type.name,
authority = plugin.authority,
)
}
@@ -0,0 +1,46 @@
package de.mm20.launcher2.data.plugins
import de.mm20.launcher2.database.daos.PluginDao
import de.mm20.launcher2.plugin.Plugin
import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.PluginType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
internal class PluginRepositoryImpl(
private val dao: PluginDao,
): PluginRepository {
override fun findMany(
type: PluginType?,
enabled: Boolean?,
packageName: String?
): Flow<List<Plugin>> {
return dao.findMany(
type = type?.name,
enabled = enabled,
packageName = packageName,
).map {
it.mapNotNull { Plugin(it) }
}
}
override fun get(authority: String): Flow<Plugin?> {
return dao.get(authority).map { Plugin(it) }
}
override fun insertMany(plugins: List<Plugin>) {
TODO("Not yet implemented")
}
override fun insert(plugin: Plugin) {
dao.insert(PluginEntity(plugin))
}
override fun update(plugin: Plugin) {
dao.update(PluginEntity(plugin))
}
override fun deleteMany() {
dao.deleteMany()
}
}