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")