Plugins: bringup

This commit is contained in:
MM20 2023-11-05 19:01:57 +01:00
parent 7da84a747f
commit 801caf9dd6
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
54 changed files with 1335 additions and 88 deletions

View File

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

View File

@ -27,6 +27,8 @@
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="de.mm20.launcher2.permission.USE_PLUGINS" />
<application
android:name=".LauncherApplication"
android:allowBackup="true"
@ -35,10 +37,10 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:resizeableActivity="true"
android:supportsRtl="true"
android:theme="@style/LauncherTheme"
android:usesCleartextTraffic="true"
android:resizeableActivity="true"
tools:ignore="GoogleAppIndexingWarning">
<meta-data

View File

@ -27,6 +27,8 @@ import de.mm20.launcher2.debug.initDebugMode
import de.mm20.launcher2.globalactions.globalActionsModule
import de.mm20.launcher2.notifications.notificationsModule
import de.mm20.launcher2.permissions.permissionsModule
import de.mm20.launcher2.data.plugins.dataPluginsModule
import de.mm20.launcher2.plugins.servicesPluginsModule
import de.mm20.launcher2.preferences.preferencesModule
import de.mm20.launcher2.searchactions.searchActionsModule
import de.mm20.launcher2.services.favorites.favoritesModule
@ -86,6 +88,8 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory {
wikipediaModule,
servicesTagsModule,
widgetsServiceModule,
dataPluginsModule,
servicesPluginsModule,
backupModule,
)
)

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<permission
android:name="de.mm20.launcher2.permission.USE_PLUGINS"
android:label="Use Kvaesitso plugins"
android:protectionLevel="dangerous" />
</manifest>

View File

@ -51,5 +51,6 @@ dependencies {
implementation(project(":core:ktx"))
implementation(project(":core:i18n"))
implementation(project(":libs:material-color-utilities"))
api(project(":core:shared"))
}

View File

@ -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,
)

View File

@ -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<List<Plugin>>
fun get(authority: String): Flow<Plugin?>
fun insertMany(plugins: List<Plugin>)
fun insert(plugin: Plugin)
fun update(plugin: Plugin)
fun deleteMany()
}

View File

@ -16,8 +16,6 @@ interface File : SavableSearchable {
val isDirectory: Boolean
val metaData: ImmutableMap<FileMetaType, String>
val isStoredInCloud: Boolean
override val preferDetailsOverLaunch: Boolean
get() = false

View File

@ -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<MavenPublication>("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")
}
}
}
}

View File

@ -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()
}

View File

@ -0,0 +1,5 @@
package de.mm20.launcher2.plugin
enum class PluginType {
FileSearch,
}

View File

@ -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,
}

View File

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

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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

View File

@ -0,0 +1,40 @@
package de.mm20.launcher2.database.daos
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import de.mm20.launcher2.database.entities.PluginEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface PluginDao {
@Query("""
SELECT * FROM Plugins WHERE
(type = :type OR :type IS NULL) AND
(enabled = :enabled OR :enabled IS NULL) AND
(packageName = :packageName OR :packageName IS NULL)
""")
fun findMany(
type: String? = null,
enabled: Boolean? = null,
packageName: String? = null,
): Flow<List<PluginEntity>>
@Query("SELECT * FROM Plugins WHERE authority = :authority")
fun get(authority: String): Flow<PluginEntity>
@Insert
fun insertMany(plugins: List<PluginEntity>)
@Insert
fun insert(plugin: PluginEntity)
@Update
fun update(plugin: PluginEntity)
@Query("DELETE FROM Plugins")
fun deleteMany()
}

View File

@ -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,
)

View File

@ -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()
)
}
}

View File

@ -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
@ -300,3 +309,85 @@ 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
}
}
}

View File

@ -6,9 +6,12 @@ import de.mm20.launcher2.files.providers.GDriveFileProvider
import de.mm20.launcher2.files.providers.LocalFileProvider
import de.mm20.launcher2.files.providers.NextcloudFileProvider
import de.mm20.launcher2.files.providers.OwncloudFileProvider
import de.mm20.launcher2.files.providers.PluginFileProvider
import de.mm20.launcher2.nextcloud.NextcloudApiHelper
import de.mm20.launcher2.owncloud.OwncloudClient
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.SearchableRepository
@ -19,16 +22,16 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
internal class FileRepository(
private val context: Context,
private val permissionsManager: PermissionsManager,
private val dataStore: LauncherDataStore,
private val pluginRepository: PluginRepository,
) : SearchableRepository<File> {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val nextcloudClient by lazy {
NextcloudApiHelper(context)
}
@ -44,13 +47,30 @@ internal class FileRepository(
return@channelFlow
}
dataStore.data.map { it.fileSearch }.collectLatest {
val filePlugins = pluginRepository.findMany(
type = PluginType.FileSearch,
enabled = true,
)
dataStore.data.map { it.fileSearch }
.combine(filePlugins) { settings, plugins ->
settings to plugins
}.collectLatest { (settings, plugins) ->
val providers = mutableListOf<FileProvider>()
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))
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())

View File

@ -5,6 +5,7 @@ import de.mm20.launcher2.files.providers.LocalFile
import de.mm20.launcher2.files.providers.NextcloudFile
import de.mm20.launcher2.files.providers.OneDriveFile
import de.mm20.launcher2.files.providers.OwncloudFile
import de.mm20.launcher2.files.providers.PluginFile
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableRepository
@ -13,10 +14,11 @@ import org.koin.core.qualifier.named
import org.koin.dsl.module
val filesModule = module {
factory<SearchableRepository<File>>(named<File>()) { FileRepository(androidContext(), get(), get()) }
factory<SearchableRepository<File>>(named<File>()) { FileRepository(androidContext(), get(), get(), get()) }
factory<SearchableDeserializer>(named(LocalFile.Domain)) { LocalFileDeserializer(androidContext()) }
factory<SearchableDeserializer>(named(OwncloudFile.Domain)) { OwncloudFileDeserializer() }
factory<SearchableDeserializer>(named(NextcloudFile.Domain)) { NextcloudFileDeserializer() }
factory<SearchableDeserializer>(named(OneDriveFile.Domain)) { OneDriveFileDeserializer() }
factory<SearchableDeserializer>(named(GDriveFile.Domain)) { GDriveFileDeserializer() }
factory<SearchableDeserializer>(named(PluginFile.Domain)) { PluginFileDeserializer(androidContext(), get()) }
}

View File

@ -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 {

View File

@ -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,

View File

@ -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 {

View File

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

View File

@ -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 {

View File

@ -0,0 +1,53 @@
package de.mm20.launcher2.files.providers
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import de.mm20.launcher2.files.PluginFileSerializer
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.plugin.config.StorageStrategy
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.FileMetaType
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableSerializer
import kotlinx.collections.immutable.ImmutableMap
data class PluginFile(
val id: String,
override val path: String,
override val mimeType: String,
override val size: Long,
override val metaData: ImmutableMap<FileMetaType, String>,
override val label: String,
override val isDirectory: Boolean,
val uri: Uri,
val thumbnailUri: Uri?,
val authority: String,
internal val storageStrategy: StorageStrategy,
override val labelOverride: String? = null,
) : File {
override val domain: String = Domain
override val key: String
get() = "$domain://$authority:$id"
override fun overrideLabel(label: String): SavableSearchable {
return this.copy(labelOverride = label)
}
override fun launch(context: Context, options: Bundle?): Boolean {
return context.tryStartActivity(Intent(Intent.ACTION_VIEW).apply {
data = uri
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}, options)
}
override fun getSerializer(): SearchableSerializer {
return PluginFileSerializer()
}
companion object {
const val Domain = "plugin.file"
}
}

View File

@ -0,0 +1,127 @@
package de.mm20.launcher2.files.providers
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.CancellationSignal
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.plugin.Plugin
import de.mm20.launcher2.plugin.config.StorageStrategy
import de.mm20.launcher2.plugin.contracts.FilePluginContract
import de.mm20.launcher2.plugin.contracts.SearchPluginContract
import de.mm20.launcher2.search.File
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class PluginFileProvider(
private val context: Context,
private val plugin: Plugin,
) : FileProvider {
override suspend fun search(query: String): List<File> {
val uri = Uri.Builder()
.scheme("content")
.authority(plugin.authority)
.path(SearchPluginContract.Paths.Search)
.appendQueryParameter(SearchPluginContract.Paths.QueryParam, query)
.build()
val cancellationSignal = CancellationSignal()
return suspendCancellableCoroutine {
it.invokeOnCancellation {
cancellationSignal.cancel()
}
val cursor = context.contentResolver.query(
uri,
null,
null,
cancellationSignal
) ?: return@suspendCancellableCoroutine it.resume(emptyList<File>())
val results = fromCursor(cursor) ?: emptyList()
it.resume(results)
}
}
suspend fun getFile(id: String): File? {
val uri = Uri.Builder()
.scheme("content")
.authority(plugin.authority)
.path(SearchPluginContract.Paths.Root)
.appendPath(id)
.build()
val cancellationSignal = CancellationSignal()
return suspendCancellableCoroutine {
it.invokeOnCancellation {
cancellationSignal.cancel()
}
val cursor = context.contentResolver.query(
uri,
null,
null,
cancellationSignal
) ?: return@suspendCancellableCoroutine it.resume(null)
val results = fromCursor(cursor)
it.resume(results?.firstOrNull())
}
}
private fun fromCursor(cursor: Cursor): List<File>? {
val idIndex = cursor
.getColumnIndex(FilePluginContract.FileColumns.Id)
.takeIf { it >= 0 }
?: return null
val pathIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.Path).takeIf { it >= 0 }
val typeIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.MimeType).takeIf { it >= 0 }
val sizeIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.Size).takeIf { it >= 0 }
val nameIndex = cursor.getColumnIndex(FilePluginContract.FileColumns.DisplayName)
.takeIf { it >= 0 }
?: return null
val contentUriIndex = cursor.getColumnIndex(FilePluginContract.FileColumns.ContentUri)
.takeIf { it >= 0 }
?: return null
val thumbnailUriIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.ThumbnailUri)
.takeIf { it >= 0 }
val storageStrategyIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.StorageStrategy)
.takeIf { it >= 0 }
val directoryIndex =
cursor.getColumnIndex(FilePluginContract.FileColumns.IsDirectory).takeIf { it >= 0 }
val results = mutableListOf<File>()
while (cursor.moveToNext()) {
results.add(
PluginFile(
id = cursor.getString(idIndex),
path = pathIndex?.let { cursor.getString(it) } ?: "",
mimeType = typeIndex?.let { cursor.getString(it) }
?: "application/octet-stream",
size = sizeIndex?.let { cursor.getLong(it) } ?: 0,
metaData = persistentMapOf(),
label = cursor.getString(nameIndex),
uri = Uri.parse(cursor.getString(contentUriIndex)),
thumbnailUri = thumbnailUriIndex?.let {
cursor.getStringOrNull(it)
}?.let { Uri.parse(it) },
storageStrategy = try {
storageStrategyIndex?.let {
StorageStrategy.valueOf(cursor.getString(it))
}
} catch (e: IllegalArgumentException) {
null
} ?: StorageStrategy.StoreCopy,
isDirectory = directoryIndex?.let { cursor.getInt(it) } == 1,
authority = plugin.authority,
)
)
}
cursor.close()
return results
}
}

1
data/plugins/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,46 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.data.plugins"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.koin.android)
implementation(project(":core:ktx"))
implementation(project(":core:base"))
implementation(project(":core:crashreporter"))
implementation(project(":data:database"))
}

View File

21
data/plugins/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@ -0,0 +1,9 @@
package de.mm20.launcher2.data.plugins
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.plugin.PluginRepository
import org.koin.dsl.module
val dataPluginsModule = module {
factory<PluginRepository> { PluginRepositoryImpl(get<AppDatabase>().pluginDao()) }
}

View File

@ -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,
)
}

View File

@ -0,0 +1,46 @@
package de.mm20.launcher2.data.plugins
import de.mm20.launcher2.database.daos.PluginDao
import de.mm20.launcher2.plugin.Plugin
import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.PluginType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
internal class PluginRepositoryImpl(
private val dao: PluginDao,
): PluginRepository {
override fun findMany(
type: PluginType?,
enabled: Boolean?,
packageName: String?
): Flow<List<Plugin>> {
return dao.findMany(
type = type?.name,
enabled = enabled,
packageName = packageName,
).map {
it.mapNotNull { Plugin(it) }
}
}
override fun get(authority: String): Flow<Plugin?> {
return dao.get(authority).map { Plugin(it) }
}
override fun insertMany(plugins: List<Plugin>) {
TODO("Not yet implemented")
}
override fun insert(plugin: Plugin) {
dao.insert(PluginEntity(plugin))
}
override fun update(plugin: Plugin) {
dao.update(PluginEntity(plugin))
}
override fun deleteMany() {
dao.deleteMany()
}
}

View File

@ -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" }

View File

@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
`maven-publish`
}
android {
@ -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<MavenPublication>("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")
}
}
}
}

View File

@ -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
}
}

View File

@ -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<T> : BasePluginProvider() {
/**
* Search for items matching the given query
* @param query The query to search for
*/
abstract suspend fun search(query: String): List<T>
abstract suspend fun get(id: String): T?
override fun onCreate(): Boolean {
return true
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? {
return query(uri, projection, null, null)
}
override fun query(
uri: Uri,
projection: Array<out String>?,
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<out String>?): Int {
throw UnsupportedOperationException("This operation is not supported")
}
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int {
throw UnsupportedOperationException("This operation is not supported")
}
private fun search(
query: String,
cancellationSignal: CancellationSignal?
): List<T> {
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")
}
}

View File

@ -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,
)

View File

@ -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<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? {
return null
abstract class FileProvider : SearchPluginProvider<File>() {
abstract override suspend fun search(query: String): List<File>
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,
)
)
}
}

1
services/plugins/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

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

View File

21
services/plugins/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@ -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<PluginService> { PluginServiceImpl(androidContext(), get()) }
}

View File

@ -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<Plugin> {
val contentResolvers = context.packageManager.queryIntentContentProviders(
Intent("de.mm20.launcher2.action.PLUGIN"),
PackageManager.GET_META_DATA,
)
val plugins = mutableListOf<Plugin>()
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
}
}

View File

@ -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<List<PluginWithState>>
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<List<PluginWithState>> {
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()
}
}
}

View File

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