Implement contact plugins

This commit is contained in:
MM20 2025-04-06 12:53:38 +02:00
parent cab93f87aa
commit 02cec92d72
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
14 changed files with 321 additions and 59 deletions

View File

@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EmulatorDisplays">
<option name="displayStateByAvdFolder">
<map>
<entry key="$USER_HOME$/.android/avd/Pixel_7_API_34.avd">
<value>
<MultiDisplayState>
<option name="displayDescriptors">
<list>
<DisplayDescriptor>
<option name="height" value="2541" />
<option name="width" value="1200" />
</DisplayDescriptor>
<DisplayDescriptor>
<option name="displayId" value="1" />
<option name="height" value="1920" />
<option name="width" value="1080" />
</DisplayDescriptor>
</list>
</option>
<option name="panelState">
<PanelState>
<option name="splitPanel">
<SplitPanelState>
<option name="proportion" value="0.5263158082962036" />
<option name="firstComponent">
<PanelState>
<option name="displayId" value="0" />
</PanelState>
</option>
<option name="secondComponent">
<PanelState>
<option name="displayId" value="1" />
</PanelState>
</option>
</SplitPanelState>
</option>
</PanelState>
</option>
</MultiDisplayState>
</value>
</entry>
</map>
</option>
</component>
</project>

View File

@ -19,6 +19,9 @@ interface Contact : SavableSearchable {
val postalAddresses: List<PostalAddress> val postalAddresses: List<PostalAddress>
val customActions: List<CustomContactAction> val customActions: List<CustomContactAction>
override val label: String
get() = name
val summary: String val summary: String
get() { get() {
return (phoneNumbers.map { it.number } + emailAddresses.map { it.address }) return (phoneNumbers.map { it.number } + emailAddresses.map { it.address })

View File

@ -12,17 +12,41 @@ abstract class ContactPluginContract {
*/ */
val Id = column<String>("id") val Id = column<String>("id")
/**
* Uri to view the contact.
*/
val Uri = column<String>("uri")
/** /**
* The display name of the contact. * The display name of the contact.
* First name + last name, if applicable. * First name + last name, if applicable.
*/ */
val Name = column<String>("name") val Name = column<String>("name")
/**
* List of phone numbers associated with the contact.
*/
val PhoneNumbers = column<List<PhoneNumber>>("phone_numbers") val PhoneNumbers = column<List<PhoneNumber>>("phone_numbers")
/**
* List of email addresses associated with the contact.
*/
val EmailAddresses = column<List<EmailAddress>>("email_addresses") val EmailAddresses = column<List<EmailAddress>>("email_addresses")
/**
* List of postal addresses associated with the contact.
*/
val PostalAddresses = column<List<PostalAddress>>("postal_addresses") val PostalAddresses = column<List<PostalAddress>>("postal_addresses")
/**
* List of custom actions associated with the contact.
*/
val CustomActions = column<List<CustomContactAction>>("custom_actions") val CustomActions = column<List<CustomContactAction>>("custom_actions")
/**
* Uri to the contact's photo.
*/
val PhotoUri = column<String>("photo_uri") val PhotoUri = column<String>("photo_uri")
} }
} }

View File

@ -40,6 +40,7 @@ dependencies {
implementation(libs.koin.android) implementation(libs.koin.android)
implementation(libs.coil.core)
implementation(project(":core:ktx")) implementation(project(":core:ktx"))
implementation(project(":core:base")) implementation(project(":core:base"))

View File

@ -1,18 +1,46 @@
package de.mm20.launcher2.contacts package de.mm20.launcher2.contacts
import android.content.Context import android.content.Context
import androidx.core.net.toUri
import de.mm20.launcher2.contacts.providers.AndroidContact import de.mm20.launcher2.contacts.providers.AndroidContact
import de.mm20.launcher2.contacts.providers.AndroidContactProvider 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.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.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.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 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<PhoneNumber>? = null,
val emailAddresses: List<EmailAddress>? = null,
val postalAddresses: List<PostalAddress>? = null,
val customActions: List<CustomContactAction>? = 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 { override fun serialize(searchable: SavableSearchable): String {
searchable as AndroidContact searchable as AndroidContact
return jsonObjectOf( return jsonObjectOf(
@ -21,10 +49,45 @@ internal class ContactSerializer : SearchableSerializer {
} }
override val typePrefix: String 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 context: Context,
private val permissionsManager: PermissionsManager private val permissionsManager: PermissionsManager
) : SearchableDeserializer { ) : SearchableDeserializer {
@ -37,4 +100,47 @@ internal class ContactDeserializer(
return androidContactProvider.get(id) 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<SerializedPluginContact>(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()
}
)
}
}
}
} }

View File

@ -1,6 +1,7 @@
package de.mm20.launcher2.contacts package de.mm20.launcher2.contacts
import de.mm20.launcher2.contacts.providers.AndroidContact 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.Contact
import de.mm20.launcher2.search.SearchableDeserializer import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableRepository import de.mm20.launcher2.search.SearchableRepository
@ -11,5 +12,6 @@ import org.koin.dsl.module
val contactsModule = module { val contactsModule = module {
factory { ContactRepository(androidContext(), get(), get()) } factory { ContactRepository(androidContext(), get(), get()) }
factory<SearchableRepository<Contact>>(named<Contact>()) { get<ContactRepository>() } factory<SearchableRepository<Contact>>(named<Contact>()) { get<ContactRepository>() }
factory<SearchableDeserializer>(named(AndroidContact.Domain)) { ContactDeserializer(androidContext(), get()) } factory<SearchableDeserializer>(named(AndroidContact.Domain)) { AndroidContactDeserializer(androidContext(), get()) }
factory<SearchableDeserializer>(named(PluginContact.Domain)) { PluginContactDeserializer(androidContext(), get()) }
} }

View File

@ -6,7 +6,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.provider.ContactsContract import android.provider.ContactsContract
import androidx.core.graphics.drawable.toDrawable 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.ColorLayer
import de.mm20.launcher2.icons.LauncherIcon import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.icons.StaticIconLayer import de.mm20.launcher2.icons.StaticIconLayer
@ -36,7 +36,6 @@ internal data class AndroidContact(
override val domain: String = Domain override val domain: String = Domain
override val key: String override val key: String
get() = "$Domain://$id" get() = "$Domain://$id"
override val label: String = name
override val summary: String override val summary: String
get() { get() {
@ -76,7 +75,7 @@ internal data class AndroidContact(
} }
override fun getSerializer(): SearchableSerializer { override fun getSerializer(): SearchableSerializer {
return ContactSerializer() return AndroidContactSerializer()
} }
companion object { companion object {

View File

@ -7,7 +7,6 @@ import android.provider.ContactsContract
import android.telephony.PhoneNumberUtils import android.telephony.PhoneNumberUtils
import androidx.core.database.getLongOrNull import androidx.core.database.getLongOrNull
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import de.mm20.launcher2.contacts.providers.AndroidContact
import de.mm20.launcher2.ktx.distinctByEquality import de.mm20.launcher2.ktx.distinctByEquality
import de.mm20.launcher2.search.Contact import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.contact.ContactInfoType 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. * A contact provider that uses the Android ContactsContract API to search for contacts.
*/ */
class AndroidContactProvider( internal class AndroidContactProvider(
private val context: Context, private val context: Context,
) : ContactProvider { ) : ContactProvider {
override suspend fun search( override suspend fun search(

View File

@ -2,6 +2,6 @@ package de.mm20.launcher2.contacts.providers
import de.mm20.launcher2.search.Contact import de.mm20.launcher2.search.Contact
interface ContactProvider { internal interface ContactProvider {
suspend fun search(query: String, allowNetwork: Boolean): List<Contact> suspend fun search(query: String, allowNetwork: Boolean): List<Contact>
} }

View File

@ -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<PhoneNumber>,
override val emailAddresses: List<EmailAddress>,
override val postalAddresses: List<PostalAddress>,
override val customActions: List<CustomContactAction>,
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>)?
) : Contact, UpdatableSearchable<Contact> {
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"
}
}

View File

@ -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<String, PluginContact>(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<PluginContact>? {
val config = getPluginConfig()
val cursor = this
if (config == null) {
Log.e("MM20", "Plugin $authority returned null config")
cursor.close()
return null
}
val results = mutableListOf<PluginContact>()
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())
}
}
}

View File

@ -70,11 +70,12 @@ data class PluginFile(
if (thumbnailUri != null) { if (thumbnailUri != null) {
val request = ImageRequest.Builder(context) val request = ImageRequest.Builder(context)
.data(thumbnailUri) .data(thumbnailUri)
.size(size)
.build() .build()
val result = context.imageLoader.execute(request) val result = context.imageLoader.execute(request)
val drawable = result.drawable ?: return null val drawable = result.drawable ?: return null
return StaticLauncherIcon( return StaticLauncherIcon(
foregroundLayer = StaticIconLayer(icon = drawable, scale = 1.5f), foregroundLayer = StaticIconLayer(icon = drawable),
backgroundLayer = ColorLayer(), backgroundLayer = ColorLayer(),
) )
} }

View File

@ -11,6 +11,11 @@ data class Contact(
* A unique and stable identifier for this contact. * A unique and stable identifier for this contact.
*/ */
val id: String, val id: String,
/**
* The URI to view this contact.
*/
val uri: Uri,
/** /**
* The display name for this contact. * The display name for this contact.
* First name + last name, if applicable. * First name + last name, if applicable.

View File

@ -22,6 +22,7 @@ abstract class ContactProvider(
override fun List<Contact>.toCursor(): Cursor { override fun List<Contact>.toCursor(): Cursor {
return buildCursor(ContactColumns, this) { return buildCursor(ContactColumns, this) {
put(ContactColumns.Id, it.id) put(ContactColumns.Id, it.id)
put(ContactColumns.Uri, it.uri.toString())
put(ContactColumns.Name, it.name) put(ContactColumns.Name, it.name)
put(ContactColumns.PhoneNumbers, it.phoneNumbers) put(ContactColumns.PhoneNumbers, it.phoneNumbers)
put(ContactColumns.EmailAddresses, it.emailAddresses) put(ContactColumns.EmailAddresses, it.emailAddresses)
@ -35,6 +36,7 @@ abstract class ContactProvider(
return Contact( return Contact(
id = get(ContactColumns.Id) ?: return null, id = get(ContactColumns.Id) ?: return null,
name = get(ContactColumns.Name) ?: return null, name = get(ContactColumns.Name) ?: return null,
uri = Uri.parse(get(ContactColumns.Uri) ?: return null),
phoneNumbers = get(ContactColumns.PhoneNumbers) ?: emptyList(), phoneNumbers = get(ContactColumns.PhoneNumbers) ?: emptyList(),
emailAddresses = get(ContactColumns.EmailAddresses) ?: emptyList(), emailAddresses = get(ContactColumns.EmailAddresses) ?: emptyList(),
postalAddresses = get(ContactColumns.PostalAddresses) ?: emptyList(), postalAddresses = get(ContactColumns.PostalAddresses) ?: emptyList(),