diff --git a/.idea/emulatorDisplays.xml b/.idea/emulatorDisplays.xml deleted file mode 100644 index 1bb36d9e..00000000 --- a/.idea/emulatorDisplays.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/core/base/src/main/java/de/mm20/launcher2/search/Contact.kt b/core/base/src/main/java/de/mm20/launcher2/search/Contact.kt index 2f0b3a4b..6861fc08 100644 --- a/core/base/src/main/java/de/mm20/launcher2/search/Contact.kt +++ b/core/base/src/main/java/de/mm20/launcher2/search/Contact.kt @@ -19,6 +19,9 @@ interface Contact : SavableSearchable { val postalAddresses: List val customActions: List + override val label: String + get() = name + val summary: String get() { return (phoneNumbers.map { it.number } + emailAddresses.map { it.address }) diff --git a/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/ContactPluginContract.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/ContactPluginContract.kt index 8fab2c8f..dfa9d64a 100644 --- a/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/ContactPluginContract.kt +++ b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/ContactPluginContract.kt @@ -12,17 +12,41 @@ abstract class ContactPluginContract { */ val Id = column("id") + /** + * Uri to view the contact. + */ + val Uri = column("uri") + /** * The display name of the contact. * First name + last name, if applicable. */ val Name = column("name") + /** + * List of phone numbers associated with the contact. + */ val PhoneNumbers = column>("phone_numbers") + + + /** + * List of email addresses associated with the contact. + */ val EmailAddresses = column>("email_addresses") + + /** + * List of postal addresses associated with the contact. + */ val PostalAddresses = column>("postal_addresses") + + /** + * List of custom actions associated with the contact. + */ val CustomActions = column>("custom_actions") + /** + * Uri to the contact's photo. + */ val PhotoUri = column("photo_uri") } } \ No newline at end of file diff --git a/data/contacts/build.gradle.kts b/data/contacts/build.gradle.kts index e3e120c1..1e66927c 100644 --- a/data/contacts/build.gradle.kts +++ b/data/contacts/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { implementation(libs.koin.android) + implementation(libs.coil.core) implementation(project(":core:ktx")) implementation(project(":core:base")) diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactSerialization.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactSerialization.kt index 591e04f9..b8dcde86 100644 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactSerialization.kt +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactSerialization.kt @@ -1,18 +1,46 @@ package de.mm20.launcher2.contacts import android.content.Context +import androidx.core.net.toUri import de.mm20.launcher2.contacts.providers.AndroidContact import de.mm20.launcher2.contacts.providers.AndroidContactProvider +import de.mm20.launcher2.contacts.providers.PluginContact +import de.mm20.launcher2.contacts.providers.PluginContactProvider 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.SavableSearchable import de.mm20.launcher2.search.SearchableDeserializer import de.mm20.launcher2.search.SearchableSerializer -import kotlinx.coroutines.flow.first +import de.mm20.launcher2.search.UpdateResult +import de.mm20.launcher2.search.asUpdateResult +import de.mm20.launcher2.search.contact.CustomContactAction +import de.mm20.launcher2.search.contact.EmailAddress +import de.mm20.launcher2.search.contact.PhoneNumber +import de.mm20.launcher2.search.contact.PostalAddress +import de.mm20.launcher2.serialization.Json +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.serialization.Serializable import org.json.JSONObject -internal class ContactSerializer : SearchableSerializer { +@Serializable +internal data class SerializedPluginContact( + val id: String? = null, + val uri: String? = null, + val name: String? = null, + val phoneNumbers: List? = null, + val emailAddresses: List? = null, + val postalAddresses: List? = null, + val customActions: List? = null, + val photoUri: String? = null, + val authority: String? = null, + val strategy: StorageStrategy = StorageStrategy.StoreCopy, + val timestamp: Long = 0L, +) + +internal class AndroidContactSerializer : SearchableSerializer { override fun serialize(searchable: SavableSearchable): String { searchable as AndroidContact return jsonObjectOf( @@ -21,10 +49,45 @@ internal class ContactSerializer : SearchableSerializer { } override val typePrefix: String - get() = "contact" + get() = AndroidContact.Domain } -internal class ContactDeserializer( + +internal class PluginContactSerializer : SearchableSerializer { + override fun serialize(searchable: SavableSearchable): String { + searchable as PluginContact + if (searchable.storageStrategy == StorageStrategy.StoreReference) { + return Json.Lenient.encodeToString( + SerializedPluginContact( + id = searchable.id, + authority = searchable.authority, + strategy = searchable.storageStrategy, + ) + ) + } else { + return Json.Lenient.encodeToString( + SerializedPluginContact( + id = searchable.id, + uri = searchable.uri.toString(), + name = searchable.name, + phoneNumbers = searchable.phoneNumbers, + emailAddresses = searchable.emailAddresses, + postalAddresses = searchable.postalAddresses, + customActions = searchable.customActions, + photoUri = searchable.photoUri?.toString(), + authority = searchable.authority, + strategy = searchable.storageStrategy, + timestamp = searchable.timestamp, + ) + ) + } + } + + override val typePrefix: String + get() = PluginContact.Domain +} + +internal class AndroidContactDeserializer( private val context: Context, private val permissionsManager: PermissionsManager ) : SearchableDeserializer { @@ -37,4 +100,47 @@ internal class ContactDeserializer( return androidContactProvider.get(id) } +} + + +internal class PluginContactDeserializer( + private val context: Context, + private val pluginRepository: PluginRepository, +): SearchableDeserializer { + override suspend fun deserialize(serialized: String): SavableSearchable? { + val json = Json.Lenient.decodeFromString(serialized) + val authority = json.authority ?: return null + val id = json.id ?: return null + val strategy = json.strategy + val plugin = pluginRepository.get(authority).firstOrNull() ?: return null + if (!plugin.enabled) return null + + return when(strategy) { + StorageStrategy.StoreReference -> { + PluginContactProvider(context, authority).get(id).getOrNull() + } + + else -> { + val timestamp = json.timestamp + + PluginContact( + id = id, + name = json.name ?: return null, + uri = json.uri?.toUri() ?: return null, + phoneNumbers = json.phoneNumbers ?: emptyList(), + emailAddresses = json.emailAddresses ?: emptyList(), + postalAddresses = json.postalAddresses ?: emptyList(), + customActions = json.customActions ?: emptyList(), + photoUri = json.photoUri?.toUri(), + authority = authority, + storageStrategy = strategy, + timestamp = timestamp, + updatedSelf = { + if (it !is PluginContact) UpdateResult.TemporarilyUnavailable() + else PluginContactProvider(context, authority).refresh(it, timestamp).asUpdateResult() + } + ) + } + } + } } \ No newline at end of file diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/Module.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/Module.kt index 3776a916..19060ae2 100644 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/Module.kt +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/Module.kt @@ -1,6 +1,7 @@ package de.mm20.launcher2.contacts import de.mm20.launcher2.contacts.providers.AndroidContact +import de.mm20.launcher2.contacts.providers.PluginContact import de.mm20.launcher2.search.Contact import de.mm20.launcher2.search.SearchableDeserializer import de.mm20.launcher2.search.SearchableRepository @@ -11,5 +12,6 @@ import org.koin.dsl.module val contactsModule = module { factory { ContactRepository(androidContext(), get(), get()) } factory>(named()) { get() } - factory(named(AndroidContact.Domain)) { ContactDeserializer(androidContext(), get()) } + factory(named(AndroidContact.Domain)) { AndroidContactDeserializer(androidContext(), get()) } + factory(named(PluginContact.Domain)) { PluginContactDeserializer(androidContext(), get()) } } \ No newline at end of file diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContact.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContact.kt index 3ecb388e..7d45ba0c 100644 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContact.kt +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContact.kt @@ -6,7 +6,7 @@ import android.content.Intent import android.os.Bundle import android.provider.ContactsContract import androidx.core.graphics.drawable.toDrawable -import de.mm20.launcher2.contacts.ContactSerializer +import de.mm20.launcher2.contacts.AndroidContactSerializer import de.mm20.launcher2.icons.ColorLayer import de.mm20.launcher2.icons.LauncherIcon import de.mm20.launcher2.icons.StaticIconLayer @@ -36,7 +36,6 @@ internal data class AndroidContact( override val domain: String = Domain override val key: String get() = "$Domain://$id" - override val label: String = name override val summary: String get() { @@ -76,7 +75,7 @@ internal data class AndroidContact( } override fun getSerializer(): SearchableSerializer { - return ContactSerializer() + return AndroidContactSerializer() } companion object { diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContactProvider.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContactProvider.kt index 7c961343..bb6ee9ad 100644 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContactProvider.kt +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContactProvider.kt @@ -7,7 +7,6 @@ import android.provider.ContactsContract import android.telephony.PhoneNumberUtils import androidx.core.database.getLongOrNull import androidx.core.database.getStringOrNull -import de.mm20.launcher2.contacts.providers.AndroidContact import de.mm20.launcher2.ktx.distinctByEquality import de.mm20.launcher2.search.Contact import de.mm20.launcher2.search.contact.ContactInfoType @@ -21,7 +20,7 @@ import kotlinx.coroutines.withContext /** * A contact provider that uses the Android ContactsContract API to search for contacts. */ -class AndroidContactProvider( +internal class AndroidContactProvider( private val context: Context, ) : ContactProvider { override suspend fun search( diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/ContactProvider.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/ContactProvider.kt index 03ce9006..77fb1e1e 100644 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/ContactProvider.kt +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/ContactProvider.kt @@ -2,6 +2,6 @@ package de.mm20.launcher2.contacts.providers import de.mm20.launcher2.search.Contact -interface ContactProvider { +internal interface ContactProvider { suspend fun search(query: String, allowNetwork: Boolean): List } \ No newline at end of file diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/PluginContact.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/PluginContact.kt new file mode 100644 index 00000000..b15d03c7 --- /dev/null +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/PluginContact.kt @@ -0,0 +1,85 @@ +package de.mm20.launcher2.contacts.providers + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import coil.imageLoader +import coil.request.ImageRequest +import de.mm20.launcher2.contacts.PluginContactSerializer +import de.mm20.launcher2.icons.ColorLayer +import de.mm20.launcher2.icons.LauncherIcon +import de.mm20.launcher2.icons.StaticIconLayer +import de.mm20.launcher2.icons.StaticLauncherIcon +import de.mm20.launcher2.ktx.tryStartActivity +import de.mm20.launcher2.plugin.config.StorageStrategy +import de.mm20.launcher2.search.Contact +import de.mm20.launcher2.search.File +import de.mm20.launcher2.search.SavableSearchable +import de.mm20.launcher2.search.SearchableSerializer +import de.mm20.launcher2.search.UpdatableSearchable +import de.mm20.launcher2.search.UpdateResult +import de.mm20.launcher2.search.contact.CustomContactAction +import de.mm20.launcher2.search.contact.EmailAddress +import de.mm20.launcher2.search.contact.PhoneNumber +import de.mm20.launcher2.search.contact.PostalAddress + +internal data class PluginContact( + val id: String, + val uri: Uri, + override val name: String, + override val phoneNumbers: List, + override val emailAddresses: List, + override val postalAddresses: List, + override val customActions: List, + val photoUri: Uri?, + override val labelOverride: String? = null, + val authority: String, + val storageStrategy: StorageStrategy, + override val timestamp: Long, + override val updatedSelf: (suspend (SavableSearchable) -> UpdateResult)? +) : Contact, UpdatableSearchable { + override val domain: String = Domain + override fun getSerializer(): SearchableSerializer { + return PluginContactSerializer() + } + + override val key: String = "$domain://$authority:$id" + override fun overrideLabel(label: String): SavableSearchable { + return 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 suspend fun loadIcon(context: Context, size: Int, themed: Boolean): LauncherIcon? { + if (photoUri != null) { + val request = ImageRequest.Builder(context) + .data(photoUri) + .size(size) + .build() + val result = context.imageLoader.execute(request) + val drawable = result.drawable ?: return null + return StaticLauncherIcon( + foregroundLayer = StaticIconLayer(icon = drawable), + backgroundLayer = ColorLayer(), + ) + } + return super.loadIcon(context, size, themed) + } + + companion object { + const val Domain = "plugin.contact" + } +} \ No newline at end of file diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/PluginContactProvider.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/PluginContactProvider.kt new file mode 100644 index 00000000..60b7b1c2 --- /dev/null +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/PluginContactProvider.kt @@ -0,0 +1,82 @@ +package de.mm20.launcher2.contacts.providers + +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Bundle +import android.util.Log +import de.mm20.launcher2.plugin.PluginApi +import de.mm20.launcher2.plugin.QueryPluginApi +import de.mm20.launcher2.plugin.config.QueryPluginConfig +import de.mm20.launcher2.plugin.contracts.ContactPluginContract +import de.mm20.launcher2.plugin.contracts.ContactPluginContract.ContactColumns +import de.mm20.launcher2.plugin.contracts.SearchPluginContract +import de.mm20.launcher2.plugin.data.set +import de.mm20.launcher2.plugin.data.withColumns +import de.mm20.launcher2.search.UpdateResult +import de.mm20.launcher2.search.asUpdateResult + +internal class PluginContactProvider( + private val context: Context, + private val authority: String, +): QueryPluginApi(context, authority), ContactProvider { + + private fun getPluginConfig(): QueryPluginConfig? { + return PluginApi(authority, context.contentResolver).getSearchPluginConfig() + } + + override fun Uri.Builder.appendQueryParameters(query: String): Uri.Builder { + return appendQueryParameter(SearchPluginContract.Params.Query, query) + } + + override fun Cursor.getData(): List? { + val config = getPluginConfig() + val cursor = this + + if (config == null) { + Log.e("MM20", "Plugin $authority returned null config") + cursor.close() + return null + } + + val results = mutableListOf() + val timestamp = System.currentTimeMillis() + cursor.withColumns(ContactColumns) { + while (cursor.moveToNext()) { + results.add( + PluginContact( + id = cursor[ContactColumns.Id] ?: continue, + name = cursor[ContactColumns.Name] ?: continue, + uri = Uri.parse(cursor[ContactColumns.Uri] ?: continue), + phoneNumbers = cursor[ContactColumns.PhoneNumbers] ?: emptyList(), + emailAddresses = cursor[ContactColumns.EmailAddresses] ?: emptyList(), + postalAddresses = cursor[ContactColumns.PostalAddresses] ?: emptyList(), + customActions = cursor[ContactColumns.CustomActions] ?: emptyList(), + photoUri = cursor[ContactColumns.PhotoUri]?.let { Uri.parse(it) }, + authority = authority, + storageStrategy = config.storageStrategy, + timestamp = timestamp, + updatedSelf = { + if (it !is PluginContact) UpdateResult.TemporarilyUnavailable() + else refresh(it, timestamp).asUpdateResult() + } + ) + ) + } + } + return results + } + + override fun PluginContact.toBundle(): Bundle { + return Bundle().apply { + set(ContactColumns.Id, id) + set(ContactColumns.Uri, uri.toString()) + set(ContactColumns.Name, name) + set(ContactColumns.PhoneNumbers, phoneNumbers) + set(ContactColumns.EmailAddresses, emailAddresses) + set(ContactColumns.PostalAddresses, postalAddresses) + set(ContactColumns.CustomActions, customActions) + set(ContactColumns.PhotoUri, photoUri?.toString()) + } + } +} \ No newline at end of file diff --git a/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFile.kt b/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFile.kt index ee958ef6..875a022f 100644 --- a/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFile.kt +++ b/data/files/src/main/java/de/mm20/launcher2/files/providers/PluginFile.kt @@ -70,11 +70,12 @@ data class PluginFile( if (thumbnailUri != null) { val request = ImageRequest.Builder(context) .data(thumbnailUri) + .size(size) .build() val result = context.imageLoader.execute(request) val drawable = result.drawable ?: return null return StaticLauncherIcon( - foregroundLayer = StaticIconLayer(icon = drawable, scale = 1.5f), + foregroundLayer = StaticIconLayer(icon = drawable), backgroundLayer = ColorLayer(), ) } diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/Contact.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/Contact.kt index 5cbcf497..ad8f1820 100644 --- a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/Contact.kt +++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/Contact.kt @@ -11,6 +11,11 @@ data class Contact( * A unique and stable identifier for this contact. */ val id: String, + + /** + * The URI to view this contact. + */ + val uri: Uri, /** * The display name for this contact. * First name + last name, if applicable. diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/ContactProvider.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/ContactProvider.kt index 7d04fcbf..4747d2ed 100644 --- a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/ContactProvider.kt +++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/ContactProvider.kt @@ -22,6 +22,7 @@ abstract class ContactProvider( override fun List.toCursor(): Cursor { return buildCursor(ContactColumns, this) { put(ContactColumns.Id, it.id) + put(ContactColumns.Uri, it.uri.toString()) put(ContactColumns.Name, it.name) put(ContactColumns.PhoneNumbers, it.phoneNumbers) put(ContactColumns.EmailAddresses, it.emailAddresses) @@ -35,6 +36,7 @@ abstract class ContactProvider( return Contact( id = get(ContactColumns.Id) ?: return null, name = get(ContactColumns.Name) ?: return null, + uri = Uri.parse(get(ContactColumns.Uri) ?: return null), phoneNumbers = get(ContactColumns.PhoneNumbers) ?: emptyList(), emailAddresses = get(ContactColumns.EmailAddresses) ?: emptyList(), postalAddresses = get(ContactColumns.PostalAddresses) ?: emptyList(),