Plugins: bringup
This commit is contained in:
parent
7da84a747f
commit
801caf9dd6
@ -140,6 +140,7 @@ dependencies {
|
|||||||
implementation(project(":data:currencies"))
|
implementation(project(":data:currencies"))
|
||||||
implementation(project(":data:customattrs"))
|
implementation(project(":data:customattrs"))
|
||||||
implementation(project(":data:searchable"))
|
implementation(project(":data:searchable"))
|
||||||
|
implementation(project(":data:plugins"))
|
||||||
implementation(project(":data:themes"))
|
implementation(project(":data:themes"))
|
||||||
implementation(project(":data:files"))
|
implementation(project(":data:files"))
|
||||||
implementation(project(":libs:g-services"))
|
implementation(project(":libs:g-services"))
|
||||||
@ -165,6 +166,7 @@ dependencies {
|
|||||||
implementation(project(":services:global-actions"))
|
implementation(project(":services:global-actions"))
|
||||||
implementation(project(":services:widgets"))
|
implementation(project(":services:widgets"))
|
||||||
implementation(project(":services:favorites"))
|
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
|
// Uncomment this if you want annoying notifications in your debug builds yelling at you how terrible your code is
|
||||||
//debugImplementation(libs.leakcanary)
|
//debugImplementation(libs.leakcanary)
|
||||||
|
|||||||
@ -27,6 +27,8 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
|
<uses-permission android:name="de.mm20.launcher2.permission.USE_PLUGINS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".LauncherApplication"
|
android:name=".LauncherApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@ -35,10 +37,10 @@
|
|||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
|
android:resizeableActivity="true"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/LauncherTheme"
|
android:theme="@style/LauncherTheme"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:resizeableActivity="true"
|
|
||||||
tools:ignore="GoogleAppIndexingWarning">
|
tools:ignore="GoogleAppIndexingWarning">
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@ -27,6 +27,8 @@ import de.mm20.launcher2.debug.initDebugMode
|
|||||||
import de.mm20.launcher2.globalactions.globalActionsModule
|
import de.mm20.launcher2.globalactions.globalActionsModule
|
||||||
import de.mm20.launcher2.notifications.notificationsModule
|
import de.mm20.launcher2.notifications.notificationsModule
|
||||||
import de.mm20.launcher2.permissions.permissionsModule
|
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.preferences.preferencesModule
|
||||||
import de.mm20.launcher2.searchactions.searchActionsModule
|
import de.mm20.launcher2.searchactions.searchActionsModule
|
||||||
import de.mm20.launcher2.services.favorites.favoritesModule
|
import de.mm20.launcher2.services.favorites.favoritesModule
|
||||||
@ -86,6 +88,8 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory {
|
|||||||
wikipediaModule,
|
wikipediaModule,
|
||||||
servicesTagsModule,
|
servicesTagsModule,
|
||||||
widgetsServiceModule,
|
widgetsServiceModule,
|
||||||
|
dataPluginsModule,
|
||||||
|
servicesPluginsModule,
|
||||||
backupModule,
|
backupModule,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
9
app/app/src/release/AndroidManifest.xml
Normal file
9
app/app/src/release/AndroidManifest.xml
Normal 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>
|
||||||
@ -51,5 +51,6 @@ dependencies {
|
|||||||
implementation(project(":core:ktx"))
|
implementation(project(":core:ktx"))
|
||||||
implementation(project(":core:i18n"))
|
implementation(project(":core:i18n"))
|
||||||
implementation(project(":libs:material-color-utilities"))
|
implementation(project(":libs:material-color-utilities"))
|
||||||
|
api(project(":core:shared"))
|
||||||
|
|
||||||
}
|
}
|
||||||
12
core/base/src/main/java/de/mm20/launcher2/plugin/Plugin.kt
Normal file
12
core/base/src/main/java/de/mm20/launcher2/plugin/Plugin.kt
Normal 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,
|
||||||
|
)
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -16,8 +16,6 @@ interface File : SavableSearchable {
|
|||||||
val isDirectory: Boolean
|
val isDirectory: Boolean
|
||||||
val metaData: ImmutableMap<FileMetaType, String>
|
val metaData: ImmutableMap<FileMetaType, String>
|
||||||
|
|
||||||
val isStoredInCloud: Boolean
|
|
||||||
|
|
||||||
override val preferDetailsOverLaunch: Boolean
|
override val preferDetailsOverLaunch: Boolean
|
||||||
get() = false
|
get() = false
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ interface SearchableSerializer {
|
|||||||
val typePrefix: String
|
val typePrefix: String
|
||||||
}
|
}
|
||||||
|
|
||||||
class NullSerializer : SearchableSerializer {
|
class NullSerializer : SearchableSerializer{
|
||||||
override fun serialize(searchable: SavableSearchable): String? {
|
override fun serialize(searchable: SavableSearchable): String? {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.android.library)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
`maven-publish`
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@ -35,4 +36,51 @@ android {
|
|||||||
jvmTarget = "1.8"
|
jvmTarget = "1.8"
|
||||||
}
|
}
|
||||||
namespace = "de.mm20.launcher2.shared"
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package de.mm20.launcher2.plugin
|
||||||
|
|
||||||
|
enum class PluginType {
|
||||||
|
FileSearch,
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
}
|
||||||
@ -1,28 +1,8 @@
|
|||||||
package de.mm20.launcher2.provider.files
|
package de.mm20.launcher2.plugin.contracts
|
||||||
|
|
||||||
import android.content.ContentUris
|
abstract class FilePluginContract {
|
||||||
import android.net.Uri
|
|
||||||
|
|
||||||
object FilePluginContract {
|
object FileColumns {
|
||||||
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 {
|
|
||||||
/**
|
/**
|
||||||
* The unique ID of the file.
|
* The unique ID of the file.
|
||||||
* Type: String
|
* Type: String
|
||||||
@ -36,7 +16,8 @@ object FilePluginContract {
|
|||||||
const val DisplayName = "display_name"
|
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?
|
* Type: String?
|
||||||
*/
|
*/
|
||||||
const val MimeType = "mime_type"
|
const val MimeType = "mime_type"
|
||||||
@ -53,6 +34,22 @@ object FilePluginContract {
|
|||||||
*/
|
*/
|
||||||
const val Path = "path"
|
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 MetaTitle = "meta_title"
|
||||||
const val MetaArtist = "meta_artist"
|
const val MetaArtist = "meta_artist"
|
||||||
const val MetaAlbum = "meta_album"
|
const val MetaAlbum = "meta_album"
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -8,12 +8,14 @@ import androidx.room.Room
|
|||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
import de.mm20.launcher2.database.daos.PluginDao
|
||||||
import de.mm20.launcher2.database.daos.ThemeDao
|
import de.mm20.launcher2.database.daos.ThemeDao
|
||||||
import de.mm20.launcher2.database.entities.CurrencyEntity
|
import de.mm20.launcher2.database.entities.CurrencyEntity
|
||||||
import de.mm20.launcher2.database.entities.CustomAttributeEntity
|
import de.mm20.launcher2.database.entities.CustomAttributeEntity
|
||||||
import de.mm20.launcher2.database.entities.ForecastEntity
|
import de.mm20.launcher2.database.entities.ForecastEntity
|
||||||
import de.mm20.launcher2.database.entities.IconEntity
|
import de.mm20.launcher2.database.entities.IconEntity
|
||||||
import de.mm20.launcher2.database.entities.IconPackEntity
|
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.SavedSearchableEntity
|
||||||
import de.mm20.launcher2.database.entities.SearchActionEntity
|
import de.mm20.launcher2.database.entities.SearchActionEntity
|
||||||
import de.mm20.launcher2.database.entities.ThemeEntity
|
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_22_23
|
||||||
import de.mm20.launcher2.database.migrations.Migration_23_24
|
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_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_6_7
|
||||||
import de.mm20.launcher2.database.migrations.Migration_7_8
|
import de.mm20.launcher2.database.migrations.Migration_7_8
|
||||||
import de.mm20.launcher2.database.migrations.Migration_8_9
|
import de.mm20.launcher2.database.migrations.Migration_8_9
|
||||||
@ -51,7 +54,8 @@ import java.util.UUID
|
|||||||
CustomAttributeEntity::class,
|
CustomAttributeEntity::class,
|
||||||
SearchActionEntity::class,
|
SearchActionEntity::class,
|
||||||
ThemeEntity::class,
|
ThemeEntity::class,
|
||||||
], version = 25, exportSchema = true
|
PluginEntity::class,
|
||||||
|
], version = 26, exportSchema = true
|
||||||
)
|
)
|
||||||
@TypeConverters(ComponentNameConverter::class)
|
@TypeConverters(ComponentNameConverter::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
@ -69,6 +73,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
|
|
||||||
abstract fun themeDao(): ThemeDao
|
abstract fun themeDao(): ThemeDao
|
||||||
|
|
||||||
|
abstract fun pluginDao(): PluginDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private var _instance: AppDatabase? = null
|
private var _instance: AppDatabase? = null
|
||||||
fun getInstance(context: Context): AppDatabase {
|
fun getInstance(context: Context): AppDatabase {
|
||||||
@ -147,6 +153,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
Migration_22_23(),
|
Migration_22_23(),
|
||||||
Migration_23_24(),
|
Migration_23_24(),
|
||||||
Migration_24_25(context),
|
Migration_24_25(context),
|
||||||
|
Migration_25_26(),
|
||||||
).build()
|
).build()
|
||||||
if (_instance == null) _instance = instance
|
if (_instance == null) _instance = instance
|
||||||
return instance
|
return instance
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
package de.mm20.launcher2.database.daos
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Update
|
||||||
|
import de.mm20.launcher2.database.entities.PluginEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface PluginDao {
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM Plugins WHERE
|
||||||
|
(type = :type OR :type IS NULL) AND
|
||||||
|
(enabled = :enabled OR :enabled IS NULL) AND
|
||||||
|
(packageName = :packageName OR :packageName IS NULL)
|
||||||
|
""")
|
||||||
|
fun findMany(
|
||||||
|
type: String? = null,
|
||||||
|
enabled: Boolean? = null,
|
||||||
|
packageName: String? = null,
|
||||||
|
): Flow<List<PluginEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM Plugins WHERE authority = :authority")
|
||||||
|
fun get(authority: String): Flow<PluginEntity>
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
fun insertMany(plugins: List<PluginEntity>)
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
fun insert(plugin: PluginEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
fun update(plugin: PluginEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM Plugins")
|
||||||
|
fun deleteMany()
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package de.mm20.launcher2.database.entities
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "Plugins")
|
||||||
|
data class PluginEntity(
|
||||||
|
@PrimaryKey val authority: String,
|
||||||
|
val label: String,
|
||||||
|
val description: String?,
|
||||||
|
val packageName: String,
|
||||||
|
val className: String,
|
||||||
|
val type: String,
|
||||||
|
val settingsActivity: String?,
|
||||||
|
val enabled: Boolean,
|
||||||
|
)
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package de.mm20.launcher2.database.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
internal class Migration_25_26 : Migration(25, 26) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL("""
|
||||||
|
CREATE TABLE Plugins
|
||||||
|
(
|
||||||
|
authority TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
packageName TEXT NOT NULL,
|
||||||
|
className TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
settingsActivity TEXT,
|
||||||
|
enabled INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(`authority`)
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,22 +1,31 @@
|
|||||||
package de.mm20.launcher2.files
|
package de.mm20.launcher2.files
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
|
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||||
import de.mm20.launcher2.files.providers.GDriveFile
|
import de.mm20.launcher2.files.providers.GDriveFile
|
||||||
import de.mm20.launcher2.files.providers.LocalFile
|
import de.mm20.launcher2.files.providers.LocalFile
|
||||||
import de.mm20.launcher2.files.providers.NextcloudFile
|
import de.mm20.launcher2.files.providers.NextcloudFile
|
||||||
import de.mm20.launcher2.files.providers.OneDriveFile
|
import de.mm20.launcher2.files.providers.OneDriveFile
|
||||||
import de.mm20.launcher2.files.providers.OwncloudFile
|
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.ktx.jsonObjectOf
|
||||||
import de.mm20.launcher2.permissions.PermissionGroup
|
import de.mm20.launcher2.permissions.PermissionGroup
|
||||||
import de.mm20.launcher2.permissions.PermissionsManager
|
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.FileMetaType
|
||||||
import de.mm20.launcher2.search.SavableSearchable
|
import de.mm20.launcher2.search.SavableSearchable
|
||||||
import de.mm20.launcher2.search.SearchableDeserializer
|
import de.mm20.launcher2.search.SearchableDeserializer
|
||||||
import de.mm20.launcher2.search.SearchableSerializer
|
import de.mm20.launcher2.search.SearchableSerializer
|
||||||
import kotlinx.collections.immutable.persistentMapOf
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
import kotlinx.collections.immutable.toImmutableMap
|
import kotlinx.collections.immutable.toImmutableMap
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.get
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -6,9 +6,12 @@ import de.mm20.launcher2.files.providers.GDriveFileProvider
|
|||||||
import de.mm20.launcher2.files.providers.LocalFileProvider
|
import de.mm20.launcher2.files.providers.LocalFileProvider
|
||||||
import de.mm20.launcher2.files.providers.NextcloudFileProvider
|
import de.mm20.launcher2.files.providers.NextcloudFileProvider
|
||||||
import de.mm20.launcher2.files.providers.OwncloudFileProvider
|
import de.mm20.launcher2.files.providers.OwncloudFileProvider
|
||||||
|
import de.mm20.launcher2.files.providers.PluginFileProvider
|
||||||
import de.mm20.launcher2.nextcloud.NextcloudApiHelper
|
import de.mm20.launcher2.nextcloud.NextcloudApiHelper
|
||||||
import de.mm20.launcher2.owncloud.OwncloudClient
|
import de.mm20.launcher2.owncloud.OwncloudClient
|
||||||
import de.mm20.launcher2.permissions.PermissionsManager
|
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.preferences.LauncherDataStore
|
||||||
import de.mm20.launcher2.search.File
|
import de.mm20.launcher2.search.File
|
||||||
import de.mm20.launcher2.search.SearchableRepository
|
import de.mm20.launcher2.search.SearchableRepository
|
||||||
@ -19,16 +22,16 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
internal class FileRepository(
|
internal class FileRepository(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val permissionsManager: PermissionsManager,
|
private val permissionsManager: PermissionsManager,
|
||||||
private val dataStore: LauncherDataStore,
|
private val dataStore: LauncherDataStore,
|
||||||
|
private val pluginRepository: PluginRepository,
|
||||||
) : SearchableRepository<File> {
|
) : SearchableRepository<File> {
|
||||||
|
|
||||||
private val scope = CoroutineScope(Job() + Dispatchers.Default)
|
|
||||||
|
|
||||||
private val nextcloudClient by lazy {
|
private val nextcloudClient by lazy {
|
||||||
NextcloudApiHelper(context)
|
NextcloudApiHelper(context)
|
||||||
}
|
}
|
||||||
@ -44,23 +47,40 @@ internal class FileRepository(
|
|||||||
return@channelFlow
|
return@channelFlow
|
||||||
}
|
}
|
||||||
|
|
||||||
dataStore.data.map { it.fileSearch }.collectLatest {
|
val filePlugins = pluginRepository.findMany(
|
||||||
val providers = mutableListOf<FileProvider>()
|
type = PluginType.FileSearch,
|
||||||
|
enabled = true,
|
||||||
|
)
|
||||||
|
|
||||||
if (it.localFiles) providers.add(LocalFileProvider(context, permissionsManager))
|
dataStore.data.map { it.fileSearch }
|
||||||
if (it.gdrive) providers.add(GDriveFileProvider(context))
|
.combine(filePlugins) { settings, plugins ->
|
||||||
if (it.nextcloud) providers.add(NextcloudFileProvider(nextcloudClient))
|
settings to plugins
|
||||||
if (it.owncloud) providers.add(OwncloudFileProvider(owncloudClient))
|
}.collectLatest { (settings, plugins) ->
|
||||||
|
val providers = mutableListOf<FileProvider>()
|
||||||
|
|
||||||
if (providers.isEmpty()) {
|
if (settings.localFiles) providers.add(
|
||||||
send(persistentListOf())
|
LocalFileProvider(
|
||||||
return@collectLatest
|
context,
|
||||||
|
permissionsManager
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (settings.gdrive) providers.add(GDriveFileProvider(context))
|
||||||
|
if (settings.nextcloud) providers.add(NextcloudFileProvider(nextcloudClient))
|
||||||
|
if (settings.owncloud) providers.add(OwncloudFileProvider(owncloudClient))
|
||||||
|
|
||||||
|
for (plugin in plugins) {
|
||||||
|
providers.add(PluginFileProvider(context, plugin))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providers.isEmpty()) {
|
||||||
|
send(persistentListOf())
|
||||||
|
return@collectLatest
|
||||||
|
}
|
||||||
|
val results = mutableListOf<File>()
|
||||||
|
for (provider in providers) {
|
||||||
|
results.addAll(provider.search(query))
|
||||||
|
send(results.toImmutableList())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val results = mutableListOf<File>()
|
|
||||||
for (provider in providers) {
|
|
||||||
results.addAll(provider.search(query))
|
|
||||||
send(results.toImmutableList())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -5,6 +5,7 @@ import de.mm20.launcher2.files.providers.LocalFile
|
|||||||
import de.mm20.launcher2.files.providers.NextcloudFile
|
import de.mm20.launcher2.files.providers.NextcloudFile
|
||||||
import de.mm20.launcher2.files.providers.OneDriveFile
|
import de.mm20.launcher2.files.providers.OneDriveFile
|
||||||
import de.mm20.launcher2.files.providers.OwncloudFile
|
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.File
|
||||||
import de.mm20.launcher2.search.SearchableDeserializer
|
import de.mm20.launcher2.search.SearchableDeserializer
|
||||||
import de.mm20.launcher2.search.SearchableRepository
|
import de.mm20.launcher2.search.SearchableRepository
|
||||||
@ -13,10 +14,11 @@ import org.koin.core.qualifier.named
|
|||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val filesModule = 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(LocalFile.Domain)) { LocalFileDeserializer(androidContext()) }
|
||||||
factory<SearchableDeserializer>(named(OwncloudFile.Domain)) { OwncloudFileDeserializer() }
|
factory<SearchableDeserializer>(named(OwncloudFile.Domain)) { OwncloudFileDeserializer() }
|
||||||
factory<SearchableDeserializer>(named(NextcloudFile.Domain)) { NextcloudFileDeserializer() }
|
factory<SearchableDeserializer>(named(NextcloudFile.Domain)) { NextcloudFileDeserializer() }
|
||||||
factory<SearchableDeserializer>(named(OneDriveFile.Domain)) { OneDriveFileDeserializer() }
|
factory<SearchableDeserializer>(named(OneDriveFile.Domain)) { OneDriveFileDeserializer() }
|
||||||
factory<SearchableDeserializer>(named(GDriveFile.Domain)) { GDriveFileDeserializer() }
|
factory<SearchableDeserializer>(named(GDriveFile.Domain)) { GDriveFileDeserializer() }
|
||||||
|
factory<SearchableDeserializer>(named(PluginFile.Domain)) { PluginFileDeserializer(androidContext(), get()) }
|
||||||
}
|
}
|
||||||
@ -33,8 +33,6 @@ internal data class GDriveFile(
|
|||||||
|
|
||||||
override val key: String = "$domain://$fileId"
|
override val key: String = "$domain://$fileId"
|
||||||
|
|
||||||
override val isStoredInCloud = true
|
|
||||||
|
|
||||||
override val providerIconRes = R.drawable.ic_badge_gdrive
|
override val providerIconRes = R.drawable.ic_badge_gdrive
|
||||||
|
|
||||||
private fun getLaunchIntent(): Intent {
|
private fun getLaunchIntent(): Intent {
|
||||||
|
|||||||
@ -56,8 +56,6 @@ internal data class LocalFile(
|
|||||||
|
|
||||||
override val key = "$domain://$path"
|
override val key = "$domain://$path"
|
||||||
|
|
||||||
override val isStoredInCloud = false
|
|
||||||
|
|
||||||
override suspend fun loadIcon(
|
override suspend fun loadIcon(
|
||||||
context: Context,
|
context: Context,
|
||||||
size: Int,
|
size: Int,
|
||||||
|
|||||||
@ -33,9 +33,6 @@ internal data class NextcloudFile(
|
|||||||
|
|
||||||
override val key: String = "$domain://$server/$fileId"
|
override val key: String = "$domain://$server/$fileId"
|
||||||
|
|
||||||
override val isStoredInCloud: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
override val providerIconRes = R.drawable.ic_badge_nextcloud
|
override val providerIconRes = R.drawable.ic_badge_nextcloud
|
||||||
|
|
||||||
private fun getLaunchIntent(context: Context): Intent {
|
private fun getLaunchIntent(context: Context): Intent {
|
||||||
|
|||||||
@ -34,8 +34,6 @@ internal data class OneDriveFile(
|
|||||||
|
|
||||||
override val providerIconRes = R.drawable.ic_badge_onedrive
|
override val providerIconRes = R.drawable.ic_badge_onedrive
|
||||||
|
|
||||||
override val isStoredInCloud = true
|
|
||||||
|
|
||||||
private fun getLaunchIntent(): Intent {
|
private fun getLaunchIntent(): Intent {
|
||||||
return Intent(Intent.ACTION_VIEW).apply {
|
return Intent(Intent.ACTION_VIEW).apply {
|
||||||
data = Uri.parse(webUrl)
|
data = Uri.parse(webUrl)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import de.mm20.launcher2.files.R
|
|||||||
import de.mm20.launcher2.ktx.tryStartActivity
|
import de.mm20.launcher2.ktx.tryStartActivity
|
||||||
import de.mm20.launcher2.search.File
|
import de.mm20.launcher2.search.File
|
||||||
import de.mm20.launcher2.search.FileMetaType
|
import de.mm20.launcher2.search.FileMetaType
|
||||||
|
import de.mm20.launcher2.search.SavableSearchable
|
||||||
import de.mm20.launcher2.search.SearchableSerializer
|
import de.mm20.launcher2.search.SearchableSerializer
|
||||||
import kotlinx.collections.immutable.ImmutableMap
|
import kotlinx.collections.immutable.ImmutableMap
|
||||||
|
|
||||||
@ -32,9 +33,6 @@ internal data class OwncloudFile(
|
|||||||
|
|
||||||
override val key: String = "$domain://$server/$fileId"
|
override val key: String = "$domain://$server/$fileId"
|
||||||
|
|
||||||
override val isStoredInCloud: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
override val providerIconRes = R.drawable.ic_badge_owncloud
|
override val providerIconRes = R.drawable.ic_badge_owncloud
|
||||||
|
|
||||||
private fun getLaunchIntent(): Intent {
|
private fun getLaunchIntent(): Intent {
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
package de.mm20.launcher2.files.providers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import de.mm20.launcher2.files.PluginFileSerializer
|
||||||
|
import de.mm20.launcher2.ktx.tryStartActivity
|
||||||
|
import de.mm20.launcher2.plugin.config.StorageStrategy
|
||||||
|
import de.mm20.launcher2.search.File
|
||||||
|
import de.mm20.launcher2.search.FileMetaType
|
||||||
|
import de.mm20.launcher2.search.SavableSearchable
|
||||||
|
import de.mm20.launcher2.search.SearchableSerializer
|
||||||
|
import kotlinx.collections.immutable.ImmutableMap
|
||||||
|
|
||||||
|
data class PluginFile(
|
||||||
|
val id: String,
|
||||||
|
override val path: String,
|
||||||
|
override val mimeType: String,
|
||||||
|
override val size: Long,
|
||||||
|
override val metaData: ImmutableMap<FileMetaType, String>,
|
||||||
|
override val label: String,
|
||||||
|
override val isDirectory: Boolean,
|
||||||
|
val uri: Uri,
|
||||||
|
val thumbnailUri: Uri?,
|
||||||
|
val authority: String,
|
||||||
|
internal val storageStrategy: StorageStrategy,
|
||||||
|
override val labelOverride: String? = null,
|
||||||
|
) : File {
|
||||||
|
override val domain: String = Domain
|
||||||
|
|
||||||
|
override val key: String
|
||||||
|
get() = "$domain://$authority:$id"
|
||||||
|
|
||||||
|
override fun overrideLabel(label: String): SavableSearchable {
|
||||||
|
return this.copy(labelOverride = label)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||||
|
return context.tryStartActivity(Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
data = uri
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
}, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSerializer(): SearchableSerializer {
|
||||||
|
return PluginFileSerializer()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val Domain = "plugin.file"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
package de.mm20.launcher2.files.providers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.CancellationSignal
|
||||||
|
import androidx.core.database.getStringOrNull
|
||||||
|
import de.mm20.launcher2.plugin.Plugin
|
||||||
|
import de.mm20.launcher2.plugin.config.StorageStrategy
|
||||||
|
import de.mm20.launcher2.plugin.contracts.FilePluginContract
|
||||||
|
import de.mm20.launcher2.plugin.contracts.SearchPluginContract
|
||||||
|
import de.mm20.launcher2.search.File
|
||||||
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
class PluginFileProvider(
|
||||||
|
private val context: Context,
|
||||||
|
private val plugin: Plugin,
|
||||||
|
) : FileProvider {
|
||||||
|
override suspend fun search(query: String): List<File> {
|
||||||
|
val uri = Uri.Builder()
|
||||||
|
.scheme("content")
|
||||||
|
.authority(plugin.authority)
|
||||||
|
.path(SearchPluginContract.Paths.Search)
|
||||||
|
.appendQueryParameter(SearchPluginContract.Paths.QueryParam, query)
|
||||||
|
.build()
|
||||||
|
val cancellationSignal = CancellationSignal()
|
||||||
|
|
||||||
|
return suspendCancellableCoroutine {
|
||||||
|
it.invokeOnCancellation {
|
||||||
|
cancellationSignal.cancel()
|
||||||
|
}
|
||||||
|
val cursor = context.contentResolver.query(
|
||||||
|
uri,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
cancellationSignal
|
||||||
|
) ?: return@suspendCancellableCoroutine it.resume(emptyList<File>())
|
||||||
|
|
||||||
|
val results = fromCursor(cursor) ?: emptyList()
|
||||||
|
it.resume(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getFile(id: String): File? {
|
||||||
|
val uri = Uri.Builder()
|
||||||
|
.scheme("content")
|
||||||
|
.authority(plugin.authority)
|
||||||
|
.path(SearchPluginContract.Paths.Root)
|
||||||
|
.appendPath(id)
|
||||||
|
.build()
|
||||||
|
val cancellationSignal = CancellationSignal()
|
||||||
|
|
||||||
|
return suspendCancellableCoroutine {
|
||||||
|
it.invokeOnCancellation {
|
||||||
|
cancellationSignal.cancel()
|
||||||
|
}
|
||||||
|
val cursor = context.contentResolver.query(
|
||||||
|
uri,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
cancellationSignal
|
||||||
|
) ?: return@suspendCancellableCoroutine it.resume(null)
|
||||||
|
|
||||||
|
val results = fromCursor(cursor)
|
||||||
|
it.resume(results?.firstOrNull())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fromCursor(cursor: Cursor): List<File>? {
|
||||||
|
val idIndex = cursor
|
||||||
|
.getColumnIndex(FilePluginContract.FileColumns.Id)
|
||||||
|
.takeIf { it >= 0 }
|
||||||
|
?: return null
|
||||||
|
val pathIndex =
|
||||||
|
cursor.getColumnIndex(FilePluginContract.FileColumns.Path).takeIf { it >= 0 }
|
||||||
|
val typeIndex =
|
||||||
|
cursor.getColumnIndex(FilePluginContract.FileColumns.MimeType).takeIf { it >= 0 }
|
||||||
|
val sizeIndex =
|
||||||
|
cursor.getColumnIndex(FilePluginContract.FileColumns.Size).takeIf { it >= 0 }
|
||||||
|
val nameIndex = cursor.getColumnIndex(FilePluginContract.FileColumns.DisplayName)
|
||||||
|
.takeIf { it >= 0 }
|
||||||
|
?: return null
|
||||||
|
val contentUriIndex = cursor.getColumnIndex(FilePluginContract.FileColumns.ContentUri)
|
||||||
|
.takeIf { it >= 0 }
|
||||||
|
?: return null
|
||||||
|
val thumbnailUriIndex =
|
||||||
|
cursor.getColumnIndex(FilePluginContract.FileColumns.ThumbnailUri)
|
||||||
|
.takeIf { it >= 0 }
|
||||||
|
val storageStrategyIndex =
|
||||||
|
cursor.getColumnIndex(FilePluginContract.FileColumns.StorageStrategy)
|
||||||
|
.takeIf { it >= 0 }
|
||||||
|
val directoryIndex =
|
||||||
|
cursor.getColumnIndex(FilePluginContract.FileColumns.IsDirectory).takeIf { it >= 0 }
|
||||||
|
|
||||||
|
val results = mutableListOf<File>()
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
results.add(
|
||||||
|
PluginFile(
|
||||||
|
id = cursor.getString(idIndex),
|
||||||
|
path = pathIndex?.let { cursor.getString(it) } ?: "",
|
||||||
|
mimeType = typeIndex?.let { cursor.getString(it) }
|
||||||
|
?: "application/octet-stream",
|
||||||
|
size = sizeIndex?.let { cursor.getLong(it) } ?: 0,
|
||||||
|
metaData = persistentMapOf(),
|
||||||
|
label = cursor.getString(nameIndex),
|
||||||
|
uri = Uri.parse(cursor.getString(contentUriIndex)),
|
||||||
|
thumbnailUri = thumbnailUriIndex?.let {
|
||||||
|
cursor.getStringOrNull(it)
|
||||||
|
}?.let { Uri.parse(it) },
|
||||||
|
storageStrategy = try {
|
||||||
|
storageStrategyIndex?.let {
|
||||||
|
StorageStrategy.valueOf(cursor.getString(it))
|
||||||
|
}
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
} ?: StorageStrategy.StoreCopy,
|
||||||
|
isDirectory = directoryIndex?.let { cursor.getInt(it) } == 1,
|
||||||
|
authority = plugin.authority,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
cursor.close()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
1
data/plugins/.gitignore
vendored
Normal file
1
data/plugins/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
46
data/plugins/build.gradle.kts
Normal file
46
data/plugins/build.gradle.kts
Normal 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"))
|
||||||
|
|
||||||
|
}
|
||||||
0
data/plugins/consumer-rules.pro
Normal file
0
data/plugins/consumer-rules.pro
Normal file
21
data/plugins/proguard-rules.pro
vendored
Normal file
21
data/plugins/proguard-rules.pro
vendored
Normal 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
|
||||||
4
data/plugins/src/main/AndroidManifest.xml
Normal file
4
data/plugins/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package de.mm20.launcher2.data.plugins
|
||||||
|
|
||||||
|
import de.mm20.launcher2.database.AppDatabase
|
||||||
|
import de.mm20.launcher2.plugin.PluginRepository
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val dataPluginsModule = module {
|
||||||
|
factory<PluginRepository> { PluginRepositoryImpl(get<AppDatabase>().pluginDao()) }
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package de.mm20.launcher2.data.plugins
|
||||||
|
|
||||||
|
import de.mm20.launcher2.database.entities.PluginEntity
|
||||||
|
import de.mm20.launcher2.plugin.Plugin
|
||||||
|
import de.mm20.launcher2.plugin.PluginType
|
||||||
|
|
||||||
|
internal fun Plugin(entity: PluginEntity): Plugin? {
|
||||||
|
return Plugin(
|
||||||
|
enabled = entity.enabled,
|
||||||
|
label = entity.label,
|
||||||
|
description = entity.description,
|
||||||
|
settingsActivity = entity.settingsActivity,
|
||||||
|
packageName = entity.packageName,
|
||||||
|
className = entity.className,
|
||||||
|
type = try {
|
||||||
|
PluginType.valueOf(entity.type)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
authority = entity.authority,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun PluginEntity(plugin: Plugin): PluginEntity {
|
||||||
|
return PluginEntity(
|
||||||
|
enabled = plugin.enabled,
|
||||||
|
label = plugin.label,
|
||||||
|
description = plugin.description,
|
||||||
|
settingsActivity = plugin.settingsActivity,
|
||||||
|
packageName = plugin.packageName,
|
||||||
|
className = plugin.className,
|
||||||
|
type = plugin.type.name,
|
||||||
|
authority = plugin.authority,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package de.mm20.launcher2.data.plugins
|
||||||
|
|
||||||
|
import de.mm20.launcher2.database.daos.PluginDao
|
||||||
|
import de.mm20.launcher2.plugin.Plugin
|
||||||
|
import de.mm20.launcher2.plugin.PluginRepository
|
||||||
|
import de.mm20.launcher2.plugin.PluginType
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
internal class PluginRepositoryImpl(
|
||||||
|
private val dao: PluginDao,
|
||||||
|
): PluginRepository {
|
||||||
|
override fun findMany(
|
||||||
|
type: PluginType?,
|
||||||
|
enabled: Boolean?,
|
||||||
|
packageName: String?
|
||||||
|
): Flow<List<Plugin>> {
|
||||||
|
return dao.findMany(
|
||||||
|
type = type?.name,
|
||||||
|
enabled = enabled,
|
||||||
|
packageName = packageName,
|
||||||
|
).map {
|
||||||
|
it.mapNotNull { Plugin(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get(authority: String): Flow<Plugin?> {
|
||||||
|
return dao.get(authority).map { Plugin(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun insertMany(plugins: List<Plugin>) {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun insert(plugin: Plugin) {
|
||||||
|
dao.insert(PluginEntity(plugin))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(plugin: Plugin) {
|
||||||
|
dao.update(PluginEntity(plugin))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteMany() {
|
||||||
|
dao.deleteMany()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,6 +34,8 @@ koin = "3.2.0"
|
|||||||
protobuf = "3.14.0"
|
protobuf = "3.14.0"
|
||||||
retrofit = "2.9.0"
|
retrofit = "2.9.0"
|
||||||
junit = "4.13"
|
junit = "4.13"
|
||||||
|
junitVersion = "1.1.5"
|
||||||
|
espressoCore = "3.5.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
gradle = { group = "com.android.tools.build", name = "gradle", version.ref = "android-gradle-plugin" }
|
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" }
|
emoji4j = { group = "com.sigpwned", name = "emoji4j-core", version = "15.0.1" }
|
||||||
|
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
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" }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.android.library)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
`maven-publish`
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@ -16,8 +17,8 @@ android {
|
|||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
create("nightly") {
|
create("nightly") {
|
||||||
@ -34,9 +35,58 @@ android {
|
|||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "1.8"
|
jvmTarget = "1.8"
|
||||||
}
|
}
|
||||||
namespace = "de.mm20.launcher2.shared"
|
namespace = "de.mm20.launcher2.sdk"
|
||||||
|
|
||||||
|
publishing {
|
||||||
|
singleVariant("release") {
|
||||||
|
withSourcesJar()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
@ -1,17 +1,48 @@
|
|||||||
package de.mm20.launcher2.sdk.files
|
package de.mm20.launcher2.sdk.files
|
||||||
|
|
||||||
import android.content.ContentProvider
|
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import android.database.MatrixCursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import de.mm20.launcher2.plugin.contracts.FilePluginContract
|
||||||
|
import de.mm20.launcher2.sdk.base.SearchPluginProvider
|
||||||
|
|
||||||
abstract class FileProvider: ContentProvider() {
|
abstract class FileProvider : SearchPluginProvider<File>() {
|
||||||
override fun query(
|
abstract override suspend fun search(query: String): List<File>
|
||||||
uri: Uri,
|
|
||||||
projection: Array<out String>?,
|
final override fun getPluginType(): de.mm20.launcher2.plugin.PluginType {
|
||||||
selection: String?,
|
return de.mm20.launcher2.plugin.PluginType.FileSearch
|
||||||
selectionArgs: Array<out String>?,
|
}
|
||||||
sortOrder: String?
|
|
||||||
): Cursor? {
|
override fun createCursor(capacity: Int): MatrixCursor {
|
||||||
return null
|
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
1
services/plugins/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
45
services/plugins/build.gradle.kts
Normal file
45
services/plugins/build.gradle.kts
Normal 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"))
|
||||||
|
|
||||||
|
}
|
||||||
0
services/plugins/consumer-rules.pro
Normal file
0
services/plugins/consumer-rules.pro
Normal file
21
services/plugins/proguard-rules.pro
vendored
Normal file
21
services/plugins/proguard-rules.pro
vendored
Normal 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
|
||||||
4
services/plugins/src/main/AndroidManifest.xml
Normal file
4
services/plugins/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,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()) }
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,6 +45,7 @@ include(":data:weather")
|
|||||||
include(":data:notifications")
|
include(":data:notifications")
|
||||||
include(":data:search-actions")
|
include(":data:search-actions")
|
||||||
include(":data:searchable")
|
include(":data:searchable")
|
||||||
|
include(":data:plugins")
|
||||||
|
|
||||||
include(":services:accounts")
|
include(":services:accounts")
|
||||||
include(":services:tags")
|
include(":services:tags")
|
||||||
@ -65,3 +66,4 @@ include(":services:widgets")
|
|||||||
include(":services:favorites")
|
include(":services:favorites")
|
||||||
|
|
||||||
include(":plugins:sdk")
|
include(":plugins:sdk")
|
||||||
|
include(":services:plugins")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user