From 801caf9dd6776c291b4de7c1896a2c92a0417323 Mon Sep 17 00:00:00 2001
From: MM20 <15646950+MM2-0@users.noreply.github.com>
Date: Sun, 5 Nov 2023 19:01:57 +0100
Subject: [PATCH] Plugins: bringup
---
app/app/build.gradle.kts | 2 +
app/app/src/main/AndroidManifest.xml | 4 +-
.../de/mm20/launcher2/LauncherApplication.kt | 4 +
app/app/src/release/AndroidManifest.xml | 9 ++
core/base/build.gradle.kts | 1 +
.../java/de/mm20/launcher2/plugin/Plugin.kt | 12 ++
.../mm20/launcher2/plugin/PluginRepository.kt | 17 +++
.../java/de/mm20/launcher2/search/File.kt | 2 -
.../launcher2/search/SearchableSerializer.kt | 2 +-
core/shared/build.gradle.kts | 48 +++++++
.../de/mm20/launcher2/plugin/PluginState.kt | 10 ++
.../de/mm20/launcher2/plugin/PluginType.kt | 5 +
.../plugin/config/StorageStrategy.kt | 25 ++++
.../contracts}/FilePluginContract.kt | 45 +++---
.../plugin/contracts/PluginContract.kt | 10 ++
.../plugin/contracts/SearchPluginContract.kt | 9 ++
.../accounts/AccountPluginContract.kt | 14 --
.../de/mm20/launcher2/database/AppDatabase.kt | 9 +-
.../mm20/launcher2/database/daos/PluginDao.kt | 40 ++++++
.../database/entities/PluginEntity.kt | 16 +++
.../database/migrations/Migration_25_26.kt | 24 ++++
.../mm20/launcher2/files/FileSerialization.kt | 91 ++++++++++++
.../mm20/launcher2/files/FilesRepository.kt | 54 ++++---
.../java/de/mm20/launcher2/files/Module.kt | 4 +-
.../launcher2/files/providers/GDriveFile.kt | 2 -
.../launcher2/files/providers/LocalFile.kt | 2 -
.../files/providers/NextcloudFile.kt | 3 -
.../launcher2/files/providers/OneDriveFile.kt | 2 -
.../launcher2/files/providers/OwncloudFile.kt | 4 +-
.../launcher2/files/providers/PluginFile.kt | 53 +++++++
.../files/providers/PluginFileProvider.kt | 127 ++++++++++++++++
data/plugins/.gitignore | 1 +
data/plugins/build.gradle.kts | 46 ++++++
data/plugins/consumer-rules.pro | 0
data/plugins/proguard-rules.pro | 21 +++
data/plugins/src/main/AndroidManifest.xml | 4 +
.../de/mm20/launcher2/data/plugins/Module.kt | 9 ++
.../de/mm20/launcher2/data/plugins/Plugin.kt | 35 +++++
.../data/plugins/PluginRepositoryImpl.kt | 46 ++++++
gradle/libs.versions.toml | 4 +
plugins/sdk/build.gradle.kts | 60 +++++++-
.../launcher2/sdk/base/BasePluginProvider.kt | 46 ++++++
.../sdk/base/SearchPluginProvider.kt | 121 ++++++++++++++++
.../java/de/mm20/launcher2/sdk/files/File.kt | 58 ++++++++
.../mm20/launcher2/sdk/files/FileProvider.kt | 51 +++++--
services/plugins/.gitignore | 1 +
services/plugins/build.gradle.kts | 45 ++++++
services/plugins/consumer-rules.pro | 0
services/plugins/proguard-rules.pro | 21 +++
services/plugins/src/main/AndroidManifest.xml | 4 +
.../java/de/mm20/launcher2/plugins/Module.kt | 8 ++
.../mm20/launcher2/plugins/PluginScanner.kt | 54 +++++++
.../mm20/launcher2/plugins/PluginService.kt | 136 ++++++++++++++++++
settings.gradle.kts | 2 +
54 files changed, 1335 insertions(+), 88 deletions(-)
create mode 100644 app/app/src/release/AndroidManifest.xml
create mode 100644 core/base/src/main/java/de/mm20/launcher2/plugin/Plugin.kt
create mode 100644 core/base/src/main/java/de/mm20/launcher2/plugin/PluginRepository.kt
create mode 100644 core/shared/src/main/java/de/mm20/launcher2/plugin/PluginState.kt
create mode 100644 core/shared/src/main/java/de/mm20/launcher2/plugin/PluginType.kt
create mode 100644 core/shared/src/main/java/de/mm20/launcher2/plugin/config/StorageStrategy.kt
rename core/shared/src/main/java/de/mm20/launcher2/{provider/files => plugin/contracts}/FilePluginContract.kt (63%)
create mode 100644 core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/PluginContract.kt
create mode 100644 core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/SearchPluginContract.kt
delete mode 100644 core/shared/src/main/java/de/mm20/launcher2/provider/accounts/AccountPluginContract.kt
create mode 100644 data/database/src/main/java/de/mm20/launcher2/database/daos/PluginDao.kt
create mode 100644 data/database/src/main/java/de/mm20/launcher2/database/entities/PluginEntity.kt
create mode 100644 data/database/src/main/java/de/mm20/launcher2/database/migrations/Migration_25_26.kt
create mode 100644 data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFile.kt
create mode 100644 data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFileProvider.kt
create mode 100644 data/plugins/.gitignore
create mode 100644 data/plugins/build.gradle.kts
create mode 100644 data/plugins/consumer-rules.pro
create mode 100644 data/plugins/proguard-rules.pro
create mode 100644 data/plugins/src/main/AndroidManifest.xml
create mode 100644 data/plugins/src/main/java/de/mm20/launcher2/data/plugins/Module.kt
create mode 100644 data/plugins/src/main/java/de/mm20/launcher2/data/plugins/Plugin.kt
create mode 100644 data/plugins/src/main/java/de/mm20/launcher2/data/plugins/PluginRepositoryImpl.kt
create mode 100644 plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/BasePluginProvider.kt
create mode 100644 plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/SearchPluginProvider.kt
create mode 100644 plugins/sdk/src/main/java/de/mm20/launcher2/sdk/files/File.kt
create mode 100644 services/plugins/.gitignore
create mode 100644 services/plugins/build.gradle.kts
create mode 100644 services/plugins/consumer-rules.pro
create mode 100644 services/plugins/proguard-rules.pro
create mode 100644 services/plugins/src/main/AndroidManifest.xml
create mode 100644 services/plugins/src/main/java/de/mm20/launcher2/plugins/Module.kt
create mode 100644 services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginScanner.kt
create mode 100644 services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginService.kt
diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts
index b4e6daa5..4c18ca6d 100644
--- a/app/app/build.gradle.kts
+++ b/app/app/build.gradle.kts
@@ -140,6 +140,7 @@ dependencies {
implementation(project(":data:currencies"))
implementation(project(":data:customattrs"))
implementation(project(":data:searchable"))
+ implementation(project(":data:plugins"))
implementation(project(":data:themes"))
implementation(project(":data:files"))
implementation(project(":libs:g-services"))
@@ -165,6 +166,7 @@ dependencies {
implementation(project(":services:global-actions"))
implementation(project(":services:widgets"))
implementation(project(":services:favorites"))
+ implementation(project(":services:plugins"))
// Uncomment this if you want annoying notifications in your debug builds yelling at you how terrible your code is
//debugImplementation(libs.leakcanary)
diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml
index 551e0f92..612007b6 100644
--- a/app/app/src/main/AndroidManifest.xml
+++ b/app/app/src/main/AndroidManifest.xml
@@ -27,6 +27,8 @@
+
+
+
+
+
+
+
diff --git a/core/base/build.gradle.kts b/core/base/build.gradle.kts
index fbf75394..5af99272 100644
--- a/core/base/build.gradle.kts
+++ b/core/base/build.gradle.kts
@@ -51,5 +51,6 @@ dependencies {
implementation(project(":core:ktx"))
implementation(project(":core:i18n"))
implementation(project(":libs:material-color-utilities"))
+ api(project(":core:shared"))
}
\ No newline at end of file
diff --git a/core/base/src/main/java/de/mm20/launcher2/plugin/Plugin.kt b/core/base/src/main/java/de/mm20/launcher2/plugin/Plugin.kt
new file mode 100644
index 00000000..b7cfe06b
--- /dev/null
+++ b/core/base/src/main/java/de/mm20/launcher2/plugin/Plugin.kt
@@ -0,0 +1,12 @@
+package de.mm20.launcher2.plugin
+
+data class Plugin(
+ val enabled: Boolean,
+ val label: String,
+ val description: String? = null,
+ val settingsActivity: String? = null,
+ val packageName: String,
+ val className: String,
+ val type: PluginType,
+ val authority: String,
+)
\ No newline at end of file
diff --git a/core/base/src/main/java/de/mm20/launcher2/plugin/PluginRepository.kt b/core/base/src/main/java/de/mm20/launcher2/plugin/PluginRepository.kt
new file mode 100644
index 00000000..d687bb64
--- /dev/null
+++ b/core/base/src/main/java/de/mm20/launcher2/plugin/PluginRepository.kt
@@ -0,0 +1,17 @@
+package de.mm20.launcher2.plugin
+
+import kotlinx.coroutines.flow.Flow
+
+interface PluginRepository {
+ fun findMany(
+ type: PluginType? = null,
+ enabled: Boolean? = null,
+ packageName: String? = null,
+ ): Flow>
+ fun get(authority: String): Flow
+
+ fun insertMany(plugins: List)
+ fun insert(plugin: Plugin)
+ fun update(plugin: Plugin)
+ fun deleteMany()
+}
\ No newline at end of file
diff --git a/core/base/src/main/java/de/mm20/launcher2/search/File.kt b/core/base/src/main/java/de/mm20/launcher2/search/File.kt
index 85c810fc..ddd9fa9c 100644
--- a/core/base/src/main/java/de/mm20/launcher2/search/File.kt
+++ b/core/base/src/main/java/de/mm20/launcher2/search/File.kt
@@ -16,8 +16,6 @@ interface File : SavableSearchable {
val isDirectory: Boolean
val metaData: ImmutableMap
- val isStoredInCloud: Boolean
-
override val preferDetailsOverLaunch: Boolean
get() = false
diff --git a/core/base/src/main/java/de/mm20/launcher2/search/SearchableSerializer.kt b/core/base/src/main/java/de/mm20/launcher2/search/SearchableSerializer.kt
index 165f1919..4ca3bf3b 100644
--- a/core/base/src/main/java/de/mm20/launcher2/search/SearchableSerializer.kt
+++ b/core/base/src/main/java/de/mm20/launcher2/search/SearchableSerializer.kt
@@ -5,7 +5,7 @@ interface SearchableSerializer {
val typePrefix: String
}
-class NullSerializer : SearchableSerializer {
+class NullSerializer : SearchableSerializer{
override fun serialize(searchable: SavableSearchable): String? {
return null
}
diff --git a/core/shared/build.gradle.kts b/core/shared/build.gradle.kts
index 168a12f2..eb564cd8 100644
--- a/core/shared/build.gradle.kts
+++ b/core/shared/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
+ `maven-publish`
}
android {
@@ -35,4 +36,51 @@ android {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.shared"
+
+ publishing {
+ singleVariant("release") {
+ withSourcesJar()
+ }
+ }
+}
+
+publishing {
+ publications {
+ register("release") {
+ groupId = "de.mm20.launcher2"
+ artifactId = "shared"
+ version = "1.0.0-SNAPSHOT"
+
+ pom {
+ name = "Kvaesitso SDK"
+ licenses {
+ license {
+ name = "The Apache License, Version 2.0"
+ url = "http://www.apache.org/licenses/LICENSE-2.0.txt"
+ }
+ }
+ developers {
+ developer {
+ id = "MM2-0"
+ name = "U.N.Owen"
+ }
+ }
+ }
+
+ afterEvaluate {
+ from(components["release"])
+ }
+ }
+ }
+ repositories {
+ mavenLocal()
+ maven {
+ name = "GitHubPackages"
+ url = uri("https://maven.pkg.github.com/MM2-0/Kvaesitso")
+ credentials {
+ username = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME")
+ password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN")
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginState.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginState.kt
new file mode 100644
index 00000000..bc0433b4
--- /dev/null
+++ b/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginState.kt
@@ -0,0 +1,10 @@
+package de.mm20.launcher2.plugin
+
+
+sealed class PluginState {
+ data object Ready : PluginState()
+ data class SetupRequired(
+ val setupActivity: String,
+ val message: String? = null,
+ ) : PluginState()
+}
\ No newline at end of file
diff --git a/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginType.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginType.kt
new file mode 100644
index 00000000..33d78ad1
--- /dev/null
+++ b/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginType.kt
@@ -0,0 +1,5 @@
+package de.mm20.launcher2.plugin
+
+enum class PluginType {
+ FileSearch,
+}
\ No newline at end of file
diff --git a/core/shared/src/main/java/de/mm20/launcher2/plugin/config/StorageStrategy.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/config/StorageStrategy.kt
new file mode 100644
index 00000000..ea7ddbc1
--- /dev/null
+++ b/core/shared/src/main/java/de/mm20/launcher2/plugin/config/StorageStrategy.kt
@@ -0,0 +1,25 @@
+package de.mm20.launcher2.plugin.config
+
+/**
+ * Defines how the launcher should store search results from a plugin (i.e. when the search result is
+ * added to favorites).
+ */
+enum class StorageStrategy {
+ /**
+ * The launcher only stores the ID and the plugin provider for the search result. To restore the
+ * search result, the launcher will query the plugin provider again. This strategy allows the
+ * plugin provider to update a search result at a later point in time. However, plugins that use
+ * this strategy must guarantee that a search result can be restored in a timely manner. In
+ * particular, the plugin provider must be able to restore a search result without any network
+ * requests.
+ */
+ StoreReference,
+
+ /**
+ * The launcher stores all relevant information in its own internal database. This strategy
+ * is easier to implement, but search results cannot be updated at a later point in time.
+ * Use this strategy if your plugin needs to perform network requests to retrieve search
+ * results and if you don't want to implement a cache for search results.
+ */
+ StoreCopy,
+}
\ No newline at end of file
diff --git a/core/shared/src/main/java/de/mm20/launcher2/provider/files/FilePluginContract.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/FilePluginContract.kt
similarity index 63%
rename from core/shared/src/main/java/de/mm20/launcher2/provider/files/FilePluginContract.kt
rename to core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/FilePluginContract.kt
index 5b009d1e..415a1bc8 100644
--- a/core/shared/src/main/java/de/mm20/launcher2/provider/files/FilePluginContract.kt
+++ b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/FilePluginContract.kt
@@ -1,28 +1,8 @@
-package de.mm20.launcher2.provider.files
+package de.mm20.launcher2.plugin.contracts
-import android.content.ContentUris
-import android.net.Uri
+abstract class FilePluginContract {
-object FilePluginContract {
- object Roots {
- const val Id = "id"
- const val DisplayName = "display_name"
- const val Description = "description"
- const val Status = "status"
- const val AccountAuthority = "account_authority"
-
- const val PathSegment = "roots"
-
- fun getContentUri(authority: String): Uri {
- return Uri.Builder()
- .scheme("content")
- .authority(authority)
- .path(PathSegment)
- .build()
- }
- }
-
- object Files {
+ object FileColumns {
/**
* The unique ID of the file.
* Type: String
@@ -36,7 +16,8 @@ object FilePluginContract {
const val DisplayName = "display_name"
/**
- * The MIME type of the file.
+ * The MIME type of the file. This is used to determine how to open the file. Make sure that
+ * this is either a common MIME type or that your app can handle this MIME type.
* Type: String?
*/
const val MimeType = "mime_type"
@@ -53,6 +34,22 @@ object FilePluginContract {
*/
const val Path = "path"
+ /**
+ * The URI to view the file.
+ * Type: String?
+ */
+ const val ContentUri = "uri"
+
+ const val ThumbnailUri = "thumbnail_uri"
+
+ const val StorageStrategy = "storage_strategy"
+
+ /**
+ * Whether the file is a directory.
+ * Type: Int
+ */
+ const val IsDirectory = "is_directory"
+
const val MetaTitle = "meta_title"
const val MetaArtist = "meta_artist"
const val MetaAlbum = "meta_album"
diff --git a/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/PluginContract.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/PluginContract.kt
new file mode 100644
index 00000000..3fcf380a
--- /dev/null
+++ b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/PluginContract.kt
@@ -0,0 +1,10 @@
+package de.mm20.launcher2.plugin.contracts
+
+object PluginContract {
+
+ const val Permission = "de.mm20.launcher2.permission.USE_PLUGINS"
+ object Methods {
+ const val GetType = "getType"
+ const val GetState = "getState"
+ }
+}
\ No newline at end of file
diff --git a/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/SearchPluginContract.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/SearchPluginContract.kt
new file mode 100644
index 00000000..04ea5f31
--- /dev/null
+++ b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/SearchPluginContract.kt
@@ -0,0 +1,9 @@
+package de.mm20.launcher2.plugin.contracts
+
+abstract class SearchPluginContract {
+ object Paths {
+ const val Search = "search"
+ const val Root = "root"
+ const val QueryParam = "query"
+ }
+}
\ No newline at end of file
diff --git a/core/shared/src/main/java/de/mm20/launcher2/provider/accounts/AccountPluginContract.kt b/core/shared/src/main/java/de/mm20/launcher2/provider/accounts/AccountPluginContract.kt
deleted file mode 100644
index 1508cd42..00000000
--- a/core/shared/src/main/java/de/mm20/launcher2/provider/accounts/AccountPluginContract.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package de.mm20.launcher2.provider.accounts
-
-object AccountPluginContract {
- object Accounts {
- const val Id = "id"
- const val DisplayName = "display_name"
- const val Type = "type"
- }
- object AccountTypes {
- const val Id = "id"
- const val DisplayName = "display_name"
- const val SupportsMultipleAccounts = "supports_multiple_accounts"
- }
-}
\ No newline at end of file
diff --git a/data/database/src/main/java/de/mm20/launcher2/database/AppDatabase.kt b/data/database/src/main/java/de/mm20/launcher2/database/AppDatabase.kt
index dd9805a6..8b0f0316 100644
--- a/data/database/src/main/java/de/mm20/launcher2/database/AppDatabase.kt
+++ b/data/database/src/main/java/de/mm20/launcher2/database/AppDatabase.kt
@@ -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
diff --git a/data/database/src/main/java/de/mm20/launcher2/database/daos/PluginDao.kt b/data/database/src/main/java/de/mm20/launcher2/database/daos/PluginDao.kt
new file mode 100644
index 00000000..ead0386f
--- /dev/null
+++ b/data/database/src/main/java/de/mm20/launcher2/database/daos/PluginDao.kt
@@ -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>
+
+ @Query("SELECT * FROM Plugins WHERE authority = :authority")
+ fun get(authority: String): Flow
+
+ @Insert
+ fun insertMany(plugins: List)
+
+ @Insert
+ fun insert(plugin: PluginEntity)
+
+ @Update
+ fun update(plugin: PluginEntity)
+
+ @Query("DELETE FROM Plugins")
+ fun deleteMany()
+}
\ No newline at end of file
diff --git a/data/database/src/main/java/de/mm20/launcher2/database/entities/PluginEntity.kt b/data/database/src/main/java/de/mm20/launcher2/database/entities/PluginEntity.kt
new file mode 100644
index 00000000..a1fb396c
--- /dev/null
+++ b/data/database/src/main/java/de/mm20/launcher2/database/entities/PluginEntity.kt
@@ -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,
+)
\ No newline at end of file
diff --git a/data/database/src/main/java/de/mm20/launcher2/database/migrations/Migration_25_26.kt b/data/database/src/main/java/de/mm20/launcher2/database/migrations/Migration_25_26.kt
new file mode 100644
index 00000000..9b31160f
--- /dev/null
+++ b/data/database/src/main/java/de/mm20/launcher2/database/migrations/Migration_25_26.kt
@@ -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()
+ )
+ }
+}
\ No newline at end of file
diff --git a/data/files/src/main/java/de/mm20/launcher2/files/FileSerialization.kt b/data/files/src/main/java/de/mm20/launcher2/files/FileSerialization.kt
index d1812799..f394b731 100644
--- a/data/files/src/main/java/de/mm20/launcher2/files/FileSerialization.kt
+++ b/data/files/src/main/java/de/mm20/launcher2/files/FileSerialization.kt
@@ -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
+ }
+ }
+
}
\ No newline at end of file
diff --git a/data/files/src/main/java/de/mm20/launcher2/files/FilesRepository.kt b/data/files/src/main/java/de/mm20/launcher2/files/FilesRepository.kt
index f02bdd9f..a7265c98 100644
--- a/data/files/src/main/java/de/mm20/launcher2/files/FilesRepository.kt
+++ b/data/files/src/main/java/de/mm20/launcher2/files/FilesRepository.kt
@@ -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 {
- 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()
+ 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()
- 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()
+ for (provider in providers) {
+ results.addAll(provider.search(query))
+ send(results.toImmutableList())
+ }
}
- val results = mutableListOf()
- for (provider in providers) {
- results.addAll(provider.search(query))
- send(results.toImmutableList())
- }
- }
}
}
\ No newline at end of file
diff --git a/data/files/src/main/java/de/mm20/launcher2/files/Module.kt b/data/files/src/main/java/de/mm20/launcher2/files/Module.kt
index 05df5c1c..1c0526eb 100644
--- a/data/files/src/main/java/de/mm20/launcher2/files/Module.kt
+++ b/data/files/src/main/java/de/mm20/launcher2/files/Module.kt
@@ -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>(named()) { FileRepository(androidContext(), get(), get()) }
+ factory>(named()) { FileRepository(androidContext(), get(), get(), get()) }
factory(named(LocalFile.Domain)) { LocalFileDeserializer(androidContext()) }
factory(named(OwncloudFile.Domain)) { OwncloudFileDeserializer() }
factory(named(NextcloudFile.Domain)) { NextcloudFileDeserializer() }
factory(named(OneDriveFile.Domain)) { OneDriveFileDeserializer() }
factory(named(GDriveFile.Domain)) { GDriveFileDeserializer() }
+ factory(named(PluginFile.Domain)) { PluginFileDeserializer(androidContext(), get()) }
}
\ No newline at end of file
diff --git a/data/files/src/main/java/de/mm20/launcher2/files/providers/GDriveFile.kt b/data/files/src/main/java/de/mm20/launcher2/files/providers/GDriveFile.kt
index 737208ad..454fd123 100644
--- a/data/files/src/main/java/de/mm20/launcher2/files/providers/GDriveFile.kt
+++ b/data/files/src/main/java/de/mm20/launcher2/files/providers/GDriveFile.kt
@@ -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 {
diff --git a/data/files/src/main/java/de/mm20/launcher2/files/providers/LocalFile.kt b/data/files/src/main/java/de/mm20/launcher2/files/providers/LocalFile.kt
index acd25f88..739fbd34 100644
--- a/data/files/src/main/java/de/mm20/launcher2/files/providers/LocalFile.kt
+++ b/data/files/src/main/java/de/mm20/launcher2/files/providers/LocalFile.kt
@@ -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,
diff --git a/data/files/src/main/java/de/mm20/launcher2/files/providers/NextcloudFile.kt b/data/files/src/main/java/de/mm20/launcher2/files/providers/NextcloudFile.kt
index 593aa80d..ad7d3826 100644
--- a/data/files/src/main/java/de/mm20/launcher2/files/providers/NextcloudFile.kt
+++ b/data/files/src/main/java/de/mm20/launcher2/files/providers/NextcloudFile.kt
@@ -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 {
diff --git a/data/files/src/main/java/de/mm20/launcher2/files/providers/OneDriveFile.kt b/data/files/src/main/java/de/mm20/launcher2/files/providers/OneDriveFile.kt
index c3325b09..b2a2346a 100644
--- a/data/files/src/main/java/de/mm20/launcher2/files/providers/OneDriveFile.kt
+++ b/data/files/src/main/java/de/mm20/launcher2/files/providers/OneDriveFile.kt
@@ -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)
diff --git a/data/files/src/main/java/de/mm20/launcher2/files/providers/OwncloudFile.kt b/data/files/src/main/java/de/mm20/launcher2/files/providers/OwncloudFile.kt
index d1418da4..2157d12b 100644
--- a/data/files/src/main/java/de/mm20/launcher2/files/providers/OwncloudFile.kt
+++ b/data/files/src/main/java/de/mm20/launcher2/files/providers/OwncloudFile.kt
@@ -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 {
diff --git a/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFile.kt b/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFile.kt
new file mode 100644
index 00000000..4d52fbb5
--- /dev/null
+++ b/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFile.kt
@@ -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,
+ 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"
+ }
+}
\ No newline at end of file
diff --git a/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFileProvider.kt b/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFileProvider.kt
new file mode 100644
index 00000000..2951921b
--- /dev/null
+++ b/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFileProvider.kt
@@ -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 {
+ 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())
+
+ 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? {
+ 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()
+ 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
+ }
+}
\ No newline at end of file
diff --git a/data/plugins/.gitignore b/data/plugins/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/data/plugins/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/data/plugins/build.gradle.kts b/data/plugins/build.gradle.kts
new file mode 100644
index 00000000..75b4db09
--- /dev/null
+++ b/data/plugins/build.gradle.kts
@@ -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"))
+
+}
\ No newline at end of file
diff --git a/data/plugins/consumer-rules.pro b/data/plugins/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/data/plugins/proguard-rules.pro b/data/plugins/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/data/plugins/proguard-rules.pro
@@ -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
\ No newline at end of file
diff --git a/data/plugins/src/main/AndroidManifest.xml b/data/plugins/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a5918e68
--- /dev/null
+++ b/data/plugins/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/data/plugins/src/main/java/de/mm20/launcher2/data/plugins/Module.kt b/data/plugins/src/main/java/de/mm20/launcher2/data/plugins/Module.kt
new file mode 100644
index 00000000..3efbb575
--- /dev/null
+++ b/data/plugins/src/main/java/de/mm20/launcher2/data/plugins/Module.kt
@@ -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 { PluginRepositoryImpl(get().pluginDao()) }
+}
\ No newline at end of file
diff --git a/data/plugins/src/main/java/de/mm20/launcher2/data/plugins/Plugin.kt b/data/plugins/src/main/java/de/mm20/launcher2/data/plugins/Plugin.kt
new file mode 100644
index 00000000..1087429f
--- /dev/null
+++ b/data/plugins/src/main/java/de/mm20/launcher2/data/plugins/Plugin.kt
@@ -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,
+ )
+}
\ No newline at end of file
diff --git a/data/plugins/src/main/java/de/mm20/launcher2/data/plugins/PluginRepositoryImpl.kt b/data/plugins/src/main/java/de/mm20/launcher2/data/plugins/PluginRepositoryImpl.kt
new file mode 100644
index 00000000..443ec0f8
--- /dev/null
+++ b/data/plugins/src/main/java/de/mm20/launcher2/data/plugins/PluginRepositoryImpl.kt
@@ -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> {
+ return dao.findMany(
+ type = type?.name,
+ enabled = enabled,
+ packageName = packageName,
+ ).map {
+ it.mapNotNull { Plugin(it) }
+ }
+ }
+
+ override fun get(authority: String): Flow {
+ return dao.get(authority).map { Plugin(it) }
+ }
+
+ override fun insertMany(plugins: List) {
+ 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()
+ }
+}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f0176a9a..bab24583 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -34,6 +34,8 @@ koin = "3.2.0"
protobuf = "3.14.0"
retrofit = "2.9.0"
junit = "4.13"
+junitVersion = "1.1.5"
+espressoCore = "3.5.1"
[libraries]
gradle = { group = "com.android.tools.build", name = "gradle", version.ref = "android-gradle-plugin" }
@@ -131,6 +133,8 @@ tinypinyin = { group = "com.github.promeg", name = "tinypinyin", version = "2.0.
emoji4j = { group = "com.sigpwned", name = "emoji4j-core", version = "15.0.1" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
diff --git a/plugins/sdk/build.gradle.kts b/plugins/sdk/build.gradle.kts
index 01819053..03fd16b3 100644
--- a/plugins/sdk/build.gradle.kts
+++ b/plugins/sdk/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
+ `maven-publish`
}
android {
@@ -8,7 +9,7 @@ android {
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
-
+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
@@ -16,8 +17,8 @@ android {
buildTypes {
release {
proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
)
}
create("nightly") {
@@ -34,9 +35,58 @@ android {
kotlinOptions {
jvmTarget = "1.8"
}
- namespace = "de.mm20.launcher2.shared"
+ namespace = "de.mm20.launcher2.sdk"
+
+ publishing {
+ singleVariant("release") {
+ withSourcesJar()
+ }
+ }
}
dependencies {
- implementation(project(":core:shared"))
+ api(project(":core:shared"))
+ implementation(libs.bundles.kotlin)
+}
+
+publishing {
+ publications {
+ register("release") {
+ groupId = "de.mm20.launcher2"
+ artifactId = "plugin-sdk"
+ version = "1.0.0-SNAPSHOT"
+
+ pom {
+ name = "Kvaesitso shared library"
+ description = "Contains shared code between the launcher and its plugin SDK"
+ licenses {
+ license {
+ name = "The Apache License, Version 2.0"
+ url = "http://www.apache.org/licenses/LICENSE-2.0.txt"
+ }
+ }
+ developers {
+ developer {
+ id = "MM2-0"
+ name = "U.N.Owen"
+ }
+ }
+ }
+
+ afterEvaluate {
+ from(components["release"])
+ }
+ }
+ }
+ repositories {
+ mavenLocal()
+ maven {
+ name = "GitHubPackages"
+ url = uri("https://maven.pkg.github.com/MM2-0/Kvaesitso")
+ credentials {
+ username = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME")
+ password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN")
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/BasePluginProvider.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/BasePluginProvider.kt
new file mode 100644
index 00000000..edd17205
--- /dev/null
+++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/BasePluginProvider.kt
@@ -0,0 +1,46 @@
+package de.mm20.launcher2.sdk.base
+
+import android.content.ContentProvider
+import android.os.Bundle
+import de.mm20.launcher2.plugin.PluginState
+import de.mm20.launcher2.plugin.PluginType
+import de.mm20.launcher2.plugin.contracts.PluginContract
+import kotlinx.coroutines.runBlocking
+
+abstract class BasePluginProvider : ContentProvider() {
+
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
+ return when (method) {
+ PluginContract.Methods.GetType -> Bundle().apply {
+ putString("type", getPluginType().name)
+ }
+
+ PluginContract.Methods.GetState -> Bundle().apply {
+ val state = runBlocking {
+ getPluginState()
+ }
+
+ when (state) {
+ is PluginState.SetupRequired -> {
+ putString("type", "SetupRequired")
+ putString("setupActivity", state.setupActivity)
+ putString("message", state.message)
+ }
+
+ is PluginState.Ready -> {
+ putString("type", "Ready")
+ }
+ }
+ }
+
+ else -> super.call(method, arg, extras)
+ }
+ }
+
+ internal abstract fun getPluginType(): PluginType
+
+ open suspend fun getPluginState(): PluginState {
+ return PluginState.Ready
+ }
+
+}
\ No newline at end of file
diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/SearchPluginProvider.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/SearchPluginProvider.kt
new file mode 100644
index 00000000..81f5c1ad
--- /dev/null
+++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/SearchPluginProvider.kt
@@ -0,0 +1,121 @@
+package de.mm20.launcher2.sdk.base
+
+import android.content.ContentValues
+import android.content.Context
+import android.content.pm.PackageManager
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.os.Bundle
+import android.os.CancellationSignal
+import de.mm20.launcher2.plugin.contracts.PluginContract
+import de.mm20.launcher2.plugin.contracts.SearchPluginContract
+import kotlinx.coroutines.async
+import kotlinx.coroutines.runBlocking
+
+abstract class SearchPluginProvider : BasePluginProvider() {
+
+ /**
+ * Search for items matching the given query
+ * @param query The query to search for
+ */
+ abstract suspend fun search(query: String): List
+ abstract suspend fun get(id: String): T?
+
+ override fun onCreate(): Boolean {
+ return true
+ }
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?
+ ): Cursor? {
+ return query(uri, projection, null, null)
+ }
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ queryArgs: Bundle?,
+ cancellationSignal: CancellationSignal?
+ ): Cursor? {
+ val context = context ?: return null
+ checkPermissionOrThrow(context)
+ when {
+ uri.path == SearchPluginContract.Paths.Search -> {
+ val query =
+ uri.getQueryParameter(SearchPluginContract.Paths.QueryParam) ?: return null
+ val results = search(query, cancellationSignal)
+ val cursor = createCursor(results.size)
+ for (result in results) {
+ writeToCursor(cursor, result)
+ }
+ return null
+ }
+ uri.pathSegments.size == 2 && uri.pathSegments.first() == SearchPluginContract.Paths.Root -> {
+ val id = uri.pathSegments[1]
+ val result = runBlocking {
+ get(id)
+ }
+ return if (result != null) {
+ val cursor = createCursor(1)
+ writeToCursor(cursor, result)
+ cursor
+ } else {
+ createCursor(0)
+ }
+ }
+ }
+ return null
+ }
+
+ override fun getType(uri: Uri): String? {
+ throw UnsupportedOperationException("This operation is not supported")
+ }
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? {
+ throw UnsupportedOperationException("This operation is not supported")
+ }
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int {
+ throw UnsupportedOperationException("This operation is not supported")
+ }
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?
+ ): Int {
+ throw UnsupportedOperationException("This operation is not supported")
+ }
+
+ private fun search(
+ query: String,
+ cancellationSignal: CancellationSignal?
+ ): List {
+ return runBlocking {
+ val deferred = async {
+ search(query)
+ }
+ cancellationSignal?.setOnCancelListener {
+ deferred.cancel()
+ }
+ deferred.await()
+ }
+ }
+
+ internal abstract fun createCursor(capacity: Int): MatrixCursor
+ internal abstract fun writeToCursor(cursor: MatrixCursor, item: T)
+
+
+ private fun checkPermissionOrThrow(context: Context) {
+ if (context.checkCallingPermission(PluginContract.Permission) == PackageManager.PERMISSION_GRANTED) {
+ return
+ }
+ throw SecurityException("Caller does not have permission to use plugins")
+ }
+}
\ No newline at end of file
diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/files/File.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/files/File.kt
new file mode 100644
index 00000000..20198f09
--- /dev/null
+++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/files/File.kt
@@ -0,0 +1,58 @@
+package de.mm20.launcher2.sdk.files
+
+import android.net.Uri
+import de.mm20.launcher2.plugin.config.StorageStrategy
+
+data class File(
+ /**
+ * A unique and stable identifier for this file.
+ */
+ val id: String,
+
+ /**
+ * The URI to this file. To open this file, an intent with this URI as data and ACTION_VIEW as action
+ * is used.
+ */
+ val uri: Uri,
+
+ /**
+ * The display name of this file.
+ */
+ val displayName: String,
+
+ /**
+ * The MIME type that is shown to the user and that is used to determine the icon.
+ */
+ val mimeType: String,
+
+ /**
+ * The size of this file in bytes.
+ */
+ val size: Long,
+ /**
+ * A path to this file. This is shown to the user purely for informational purposes.
+ * It is not used to open the file.
+ */
+ val path: String,
+ /**
+ * Whether this file is a directory. If set, a folder icon will be shown instead of a file icon.
+ */
+ val isDirectory: Boolean,
+ /**
+ * An URI to a thumbnail of this file. This is used to show a preview of the file.
+ * Supported schemes:
+ * - content
+ * - file
+ * - android.resource
+ * - http
+ * - https
+ *
+ * If null, a default icon will be shown, depending on the file type.
+ */
+ val thumbnailUri: Uri? = null,
+
+ /**
+ * How the launcher should store this file in its database (i.e. when the user adds it to favorites).
+ */
+ val storageStrategy: StorageStrategy = StorageStrategy.StoreCopy,
+)
\ No newline at end of file
diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/files/FileProvider.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/files/FileProvider.kt
index da1d4a21..3fded0c2 100644
--- a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/files/FileProvider.kt
+++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/files/FileProvider.kt
@@ -1,17 +1,48 @@
package de.mm20.launcher2.sdk.files
-import android.content.ContentProvider
import android.database.Cursor
+import android.database.MatrixCursor
import android.net.Uri
+import de.mm20.launcher2.plugin.contracts.FilePluginContract
+import de.mm20.launcher2.sdk.base.SearchPluginProvider
-abstract class FileProvider: ContentProvider() {
- override fun query(
- uri: Uri,
- projection: Array?,
- selection: String?,
- selectionArgs: Array?,
- sortOrder: String?
- ): Cursor? {
- return null
+abstract class FileProvider : SearchPluginProvider() {
+ abstract override suspend fun search(query: String): List
+
+ final override fun getPluginType(): de.mm20.launcher2.plugin.PluginType {
+ return de.mm20.launcher2.plugin.PluginType.FileSearch
+ }
+
+ override fun createCursor(capacity: Int): MatrixCursor {
+ return MatrixCursor(
+ arrayOf(
+ FilePluginContract.FileColumns.Id,
+ FilePluginContract.FileColumns.DisplayName,
+ FilePluginContract.FileColumns.MimeType,
+ FilePluginContract.FileColumns.Size,
+ FilePluginContract.FileColumns.Path,
+ FilePluginContract.FileColumns.ContentUri,
+ FilePluginContract.FileColumns.ThumbnailUri,
+ FilePluginContract.FileColumns.IsDirectory,
+ FilePluginContract.FileColumns.StorageStrategy
+ ),
+ capacity,
+ )
+ }
+
+ override fun writeToCursor(cursor: MatrixCursor, item: File) {
+ cursor.addRow(
+ arrayOf(
+ item.id,
+ item.displayName,
+ item.mimeType,
+ item.size,
+ item.path,
+ item.uri.toString(),
+ item.thumbnailUri?.toString(),
+ if (item.isDirectory) 1 else 0,
+ item.storageStrategy.name,
+ )
+ )
}
}
\ No newline at end of file
diff --git a/services/plugins/.gitignore b/services/plugins/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/services/plugins/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/services/plugins/build.gradle.kts b/services/plugins/build.gradle.kts
new file mode 100644
index 00000000..7b51a340
--- /dev/null
+++ b/services/plugins/build.gradle.kts
@@ -0,0 +1,45 @@
+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.services.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"))
+
+}
\ No newline at end of file
diff --git a/services/plugins/consumer-rules.pro b/services/plugins/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/services/plugins/proguard-rules.pro b/services/plugins/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/services/plugins/proguard-rules.pro
@@ -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
\ No newline at end of file
diff --git a/services/plugins/src/main/AndroidManifest.xml b/services/plugins/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a5918e68
--- /dev/null
+++ b/services/plugins/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/services/plugins/src/main/java/de/mm20/launcher2/plugins/Module.kt b/services/plugins/src/main/java/de/mm20/launcher2/plugins/Module.kt
new file mode 100644
index 00000000..db401dcf
--- /dev/null
+++ b/services/plugins/src/main/java/de/mm20/launcher2/plugins/Module.kt
@@ -0,0 +1,8 @@
+package de.mm20.launcher2.plugins
+
+import org.koin.android.ext.koin.androidContext
+import org.koin.dsl.module
+
+val servicesPluginsModule = module {
+ single { PluginServiceImpl(androidContext(), get()) }
+}
\ No newline at end of file
diff --git a/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginScanner.kt b/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginScanner.kt
new file mode 100644
index 00000000..aff24763
--- /dev/null
+++ b/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginScanner.kt
@@ -0,0 +1,54 @@
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import de.mm20.launcher2.plugin.Plugin
+import de.mm20.launcher2.plugin.PluginType
+
+class PluginScanner(
+ private val context: Context,
+) {
+ suspend fun findPlugins(): List {
+ val contentResolvers = context.packageManager.queryIntentContentProviders(
+ Intent("de.mm20.launcher2.action.PLUGIN"),
+ PackageManager.GET_META_DATA,
+ )
+ val plugins = mutableListOf()
+
+ for (cr in contentResolvers) {
+ val providerInfo = cr.providerInfo ?: continue
+ val authority = providerInfo.authority ?: continue
+ val bundle = context.contentResolver.call(
+ Uri.Builder()
+ .scheme("content")
+ .authority(authority)
+ .build(),
+ "getType",
+ null,
+ null,
+ ) ?: continue
+ val type = bundle.getString("type")
+ ?.let {
+ try {
+ PluginType.valueOf(it)
+ } catch (e: IllegalArgumentException) {
+ null
+ }
+ } ?: continue
+ plugins.add(
+ Plugin(
+ label = cr.loadLabel(context.packageManager).toString(),
+ description = providerInfo.metaData?.getString("de.mm20.launcher2.plugin.description"),
+ packageName = providerInfo.packageName,
+ className = providerInfo.name,
+ type = type,
+ authority = authority,
+ settingsActivity = providerInfo.metaData?.getString("de.mm20.launcher2.plugin.settings"),
+ enabled = false,
+ )
+ )
+ }
+
+ return plugins
+ }
+}
\ No newline at end of file
diff --git a/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginService.kt b/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginService.kt
new file mode 100644
index 00000000..62166f2d
--- /dev/null
+++ b/services/plugins/src/main/java/de/mm20/launcher2/plugins/PluginService.kt
@@ -0,0 +1,136 @@
+package de.mm20.launcher2.plugins
+
+import PluginScanner
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.Uri
+import androidx.core.content.ContextCompat
+import de.mm20.launcher2.plugin.Plugin
+import de.mm20.launcher2.plugin.PluginRepository
+import de.mm20.launcher2.plugin.PluginState
+import de.mm20.launcher2.plugin.PluginType
+import de.mm20.launcher2.plugin.contracts.PluginContract
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+data class PluginWithState(
+ val plugin: Plugin,
+ val state: PluginState?,
+)
+
+interface PluginService {
+ fun enablePlugin(plugin: Plugin)
+ fun disablePlugin(plugin: Plugin)
+ fun getPluginsWithState(type: PluginType? = null): Flow>
+ suspend fun getPluginState(plugin: Plugin): PluginState?
+}
+
+internal class PluginServiceImpl(
+ private val context: Context,
+ private val repository: PluginRepository,
+) : PluginService {
+
+ private val scope: CoroutineScope = CoroutineScope(Job() + Dispatchers.Default)
+
+ init {
+ refreshPlugins()
+ ContextCompat.registerReceiver(
+ context,
+ AppUpdateReceiver(),
+ IntentFilter().apply {
+ addAction(Intent.ACTION_PACKAGE_ADDED)
+ addAction(Intent.ACTION_PACKAGE_REMOVED)
+ addAction(Intent.ACTION_PACKAGE_REPLACED)
+ addAction(Intent.ACTION_PACKAGE_CHANGED)
+ },
+ ContextCompat.RECEIVER_NOT_EXPORTED
+ )
+ }
+
+ override fun enablePlugin(plugin: Plugin) {
+ repository.update(plugin.copy(enabled = true))
+ }
+
+ override fun disablePlugin(plugin: Plugin) {
+ repository.update(plugin.copy(enabled = false))
+ }
+
+ private val mutex = Mutex()
+ private fun refreshPlugins() {
+ scope.launch {
+ mutex.withLock {
+ val enabledPlugins =
+ repository.findMany(enabled = true).first().map { it.authority }
+ val scanner = PluginScanner(context)
+ val plugins = scanner.findPlugins().map {
+ if (it.authority in enabledPlugins) {
+ it.copy(enabled = true)
+ } else {
+ it
+ }
+ }
+ repository.deleteMany()
+ repository.insertMany(plugins)
+ }
+ }
+ }
+
+ override fun getPluginsWithState(
+ type: PluginType?,
+ ): Flow> {
+ return repository.findMany(
+ type = type,
+ ).map {
+ it.map {
+ PluginWithState(
+ plugin = it,
+ state = getPluginState(it),
+ )
+ }
+ }
+ }
+
+ override suspend fun getPluginState(plugin: Plugin): PluginState? {
+ val bundle = withContext(Dispatchers.IO) {
+ context.contentResolver.call(
+ Uri.Builder()
+ .scheme("content")
+ .authority(plugin.authority)
+ .build(),
+ PluginContract.Methods.GetState,
+ null,
+ null
+ )
+ } ?: return null
+ val type = bundle.getString("type") ?: return null
+ return when (type) {
+ "Ready" -> PluginState.Ready
+ "SetupRequired" -> {
+ val setupActivity = bundle.getString("setupActivity") ?: return null
+ val message = bundle.getString("message")
+ PluginState.SetupRequired(
+ setupActivity = setupActivity,
+ message = message,
+ )
+ }
+
+ else -> null
+ }
+ }
+
+ private inner class AppUpdateReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ refreshPlugins()
+ }
+ }
+}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 6f7694d9..5e2c2e5a 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -45,6 +45,7 @@ include(":data:weather")
include(":data:notifications")
include(":data:search-actions")
include(":data:searchable")
+include(":data:plugins")
include(":services:accounts")
include(":services:tags")
@@ -65,3 +66,4 @@ include(":services:widgets")
include(":services:favorites")
include(":plugins:sdk")
+include(":services:plugins")