Implement contact plugins
This commit is contained in:
parent
cab93f87aa
commit
02cec92d72
47
.idea/emulatorDisplays.xml
generated
47
.idea/emulatorDisplays.xml
generated
@ -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>
|
||||
@ -19,6 +19,9 @@ interface Contact : SavableSearchable {
|
||||
val postalAddresses: List<PostalAddress>
|
||||
val customActions: List<CustomContactAction>
|
||||
|
||||
override val label: String
|
||||
get() = name
|
||||
|
||||
val summary: String
|
||||
get() {
|
||||
return (phoneNumbers.map { it.number } + emailAddresses.map { it.address })
|
||||
|
||||
@ -12,17 +12,41 @@ abstract class ContactPluginContract {
|
||||
*/
|
||||
val Id = column<String>("id")
|
||||
|
||||
/**
|
||||
* Uri to view the contact.
|
||||
*/
|
||||
val Uri = column<String>("uri")
|
||||
|
||||
/**
|
||||
* The display name of the contact.
|
||||
* First name + last name, if applicable.
|
||||
*/
|
||||
val Name = column<String>("name")
|
||||
|
||||
/**
|
||||
* List of phone numbers associated with the contact.
|
||||
*/
|
||||
val PhoneNumbers = column<List<PhoneNumber>>("phone_numbers")
|
||||
|
||||
|
||||
/**
|
||||
* List of email addresses associated with the contact.
|
||||
*/
|
||||
val EmailAddresses = column<List<EmailAddress>>("email_addresses")
|
||||
|
||||
/**
|
||||
* List of postal addresses associated with the contact.
|
||||
*/
|
||||
val PostalAddresses = column<List<PostalAddress>>("postal_addresses")
|
||||
|
||||
/**
|
||||
* List of custom actions associated with the contact.
|
||||
*/
|
||||
val CustomActions = column<List<CustomContactAction>>("custom_actions")
|
||||
|
||||
/**
|
||||
* Uri to the contact's photo.
|
||||
*/
|
||||
val PhotoUri = column<String>("photo_uri")
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,7 @@ dependencies {
|
||||
|
||||
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.coil.core)
|
||||
|
||||
implementation(project(":core:ktx"))
|
||||
implementation(project(":core:base"))
|
||||
|
||||
@ -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<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 {
|
||||
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<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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<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()) }
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<Contact>
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -22,6 +22,7 @@ abstract class ContactProvider(
|
||||
override fun List<Contact>.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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user