[network/kdeconnect-android] src/main/java/org/kde/kdeconnect: refactor: migrate `ContactsHelper` to Kotlin

Simon Redman <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit e4a5f9a36b78f7059aade4296ac5f70a7b5ac27d by Simon Redman, on behalf of ShellWen Chen.
Committed on 17/08/2026 at 21:56.
Pushed by sredman into branch 'master'.

refactor: migrate `ContactsHelper` to Kotlin

D  +0    -380  src/main/java/org/kde/kdeconnect/helpers/ContactsHelper.java
A  +344  -0    src/main/java/org/kde/kdeconnect/helpers/ContactsHelper.kt
M  +4    -4    src/main/java/org/kde/kdeconnect/plugins/sms/SMSPlugin.kt
M  +11   -13   src/main/java/org/kde/kdeconnect/plugins/telephony/TelephonyPlugin.kt

https://invent.kde.org/network/kdeconnect-android/-/commit/e4a5f9a36b78f7059aade4296ac5f70a7b5ac27d

diff --git a/src/main/java/org/kde/kdeconnect/helpers/ContactsHelper.java b/src/main/java/org/kde/kdeconnect/helpers/ContactsHelper.java
deleted file mode 100644
index 754c876d4..000000000
--- a/src/main/java/org/kde/kdeconnect/helpers/ContactsHelper.java
+++ /dev/null
@@ -1,380 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2014 Albert Vaca Cintora <[email protected]>
- * SPDX-FileCopyrightText: 2018 Simon Redman <[email protected]>
- *
- * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
-*/
-
-package org.kde.kdeconnect.helpers;
-
-import android.content.Context;
-import android.database.Cursor;
-import android.net.Uri;
-import android.provider.ContactsContract;
-import android.provider.ContactsContract.PhoneLookup;
-import android.util.Base64;
-import android.util.Base64OutputStream;
-import android.util.Log;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import org.apache.commons.io.IOUtils;
-
-import java.io.ByteArrayOutputStream;
-import java.io.InputStream;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import kotlin.text.Charsets;
-
-public class ContactsHelper {
-
-    static final String LOG_TAG = "ContactsHelper";
-
-    /**
-     * Lookup the name and photoID of a contact given a phone number
-     */
-    public static Map<String, String> phoneNumberLookup(Context context, String number) {
-
-        Map<String, String> contactInfo = new HashMap<>();
-
-        Uri uri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number));
-        String[] columns = new String[]{
-                PhoneLookup.DISPLAY_NAME,
-                PhoneLookup.PHOTO_URI
-                /*, PhoneLookup.TYPE
-                  , PhoneLookup.LABEL
-                  , PhoneLookup.ID */
-        };
-        try (Cursor cursor = context.getContentResolver().query(uri, columns,null, null, null)) {
-            // Take the first match only
-            if (cursor != null && cursor.moveToFirst()) {
-                int nameIndex = cursor.getColumnIndex(PhoneLookup.DISPLAY_NAME);
-                if (nameIndex != -1) {
-                    contactInfo.put("name", cursor.getString(nameIndex));
-                }
-
-                nameIndex = cursor.getColumnIndex(PhoneLookup.PHOTO_URI);
-                if (nameIndex != -1) {
-                    contactInfo.put("photoID", cursor.getString(nameIndex));
-                }
-            }
-        } catch (Exception ignored) {
-        }
-        return contactInfo;
-    }
-
-    public static String photoId64Encoded(Context context, String photoId) {
-        if (photoId == null) {
-            return "";
-        }
-        Uri photoUri = Uri.parse(photoId);
-
-        ByteArrayOutputStream encodedPhoto = new ByteArrayOutputStream();
-        try (InputStream input = context.getContentResolver().openInputStream(photoUri);
-             Base64OutputStream output = new Base64OutputStream(encodedPhoto, Base64.DEFAULT)) {
-            IOUtils.copy(input, output, 1024);
-            return encodedPhoto.toString();
-        } catch (Exception ex) {
-            Log.e(LOG_TAG, ex.toString());
-            return "";
-        }
-    }
-
-    /**
-     * Return all the NAME_RAW_CONTACT_IDS which contribute an entry to a Contact in the database
-     * <p>
-     * If the user has, for example, joined several contacts, on the phone, the IDs returned will
-     * be representative of the joined contact
-     * <p>
-     * See here: https://developer.android.com/reference/android/provider/ContactsContract.Contacts.html
-     * for more information about the connection between contacts and raw contacts
-     *
-     * @param context android.content.Context running the request
-     * @return List of each NAME_RAW_CONTACT_ID in the Contacts database
-     */
-    public static List<uID> getAllContactContactIDs(Context context) {
-        ArrayList<uID> toReturn = new ArrayList<>();
-
-        // Define the columns we want to read from the Contacts database
-        final String[] columns = new String[]{
-                ContactsContract.Contacts.LOOKUP_KEY
-        };
-
-        Uri contactsUri = ContactsContract.Contacts.CONTENT_URI;
-        try (Cursor contactsCursor = context.getContentResolver().query(contactsUri, columns, null, null, null)) {
-            if (contactsCursor != null && contactsCursor.moveToFirst()) {
-                do {
-                    uID contactID;
-
-                    int idIndex = contactsCursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY);
-                    if (idIndex != -1) {
-                        contactID = new uID(contactsCursor.getString(idIndex));
-                    } else {
-                        // Something went wrong with this contact
-                        // If you are experiencing this, please open a bug report indicating how you got here
-                        Log.e(LOG_TAG, "Got a contact which does not have a LOOKUP_KEY");
-                        continue;
-                    }
-
-                    if (!toReturn.contains(contactID)) {
-                        toReturn.add(contactID);
-                    }
-                } while (contactsCursor.moveToNext());
-            }
-        }
-
-        return toReturn;
-    }
-
-    /**
-     * Get VCards using serial database lookups. This is tragically slow, so call only when needed.
-     *
-     * There is a faster API specified using ContactsContract.Contacts.CONTENT_MULTI_VCARD_URI,
-     * but there does not seem to be a way to figure out which ID resulted in which VCard using that API
-     *
-     * @param context    android.content.Context running the request
-     * @param IDs        collection of uIDs to look up
-     * @return Mapping of uIDs to the corresponding VCard
-     */
-    private static Map<uID, VCardBuilder> getVCardsSlow(Context context, Collection<uID> IDs) {
-        Map<uID, VCardBuilder> toReturn = new HashMap<>();
-
-        for (uID ID : IDs) {
-            String lookupKey = ID.toString();
-            Uri vcardURI = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey);
-
-            try (InputStream input = context.getContentResolver().openInputStream(vcardURI)) {
-                if (input == null) {
-                    Log.w("Contacts", "ContentResolver did not give us a stream for the VCard for uID " + ID);
-                    continue;
-                }
-                toReturn.put(ID, new VCardBuilder(IOUtils.toString(input, Charsets.UTF_8)));
-            } catch (Exception e) {
-                // If you are experiencing this, please open a bug report indicating how you got here
-                Log.e("Contacts", "Exception while fetching vcards", e);
-            }
-        }
-
-        return toReturn;
-    }
-
-    /**
-     * Get the VCard for every specified raw contact ID
-     *
-     * @param context android.content.Context running the request
-     * @param IDs     collection of raw contact IDs to look up
-     * @return Mapping of raw contact IDs to the corresponding VCard
-     */
-    public static Map<uID, VCardBuilder> getVCardsForContactIDs(Context context, Collection<uID> IDs) {
-        return getVCardsSlow(context, IDs);
-    }
-
-    /**
-     * Get the last-modified timestamp for every contact in the database
-     *
-     * @param context android.content.Context running the request
-     * @return Mapping of contact uID to last-modified timestamp
-     */
-    public static Map<uID, Long> getAllContactTimestamps(Context context) {
-        String[] projection = { uID.COLUMN, ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP };
-
-        Map<uID, Map<String, String>> databaseValues = accessContactsDatabase(context, projection, null, null, null);
-
-        Map<uID, Long> timestamps = new HashMap<>();
-        for (uID contactID : databaseValues.keySet()) {
-            Map<String, String> data = databaseValues.get(contactID);
-            timestamps.put(
-              contactID,
-              Long.parseLong(data.get(ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP))
-            );
-        }
-
-        return timestamps;
-    }
-
-    /**
-     * Get the last-modified timestamp for the specified contact
-     *
-     * @param context   android.content.Context running the request
-     * @param contactID Contact uID to read
-     * @throws ContactNotFoundException If the given ID for some reason does not match a contact
-     * @return          Last-modified timestamp of the contact
-     */
-    public static Long getContactTimestamp(Context context, uID contactID) throws ContactNotFoundException {
-        String[] projection = { uID.COLUMN, ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP };
-        String selection = uID.COLUMN + " = ?";
-        String[] selectionArgs = { contactID.toString() };
-
-        Map<uID, Map<String, String>> databaseValue = accessContactsDatabase(context, projection, selection, selectionArgs, null);
-
-        if (databaseValue.isEmpty()) {
-            throw new ContactNotFoundException("Querying for contact with id " + contactID + " returned no results.");
-        }
-
-        if (databaseValue.size() != 1) {
-            Log.w(LOG_TAG, "Received an improper number of return values from the database in getContactTimestamp: " + databaseValue.size());
-        }
-
-        return Long.parseLong(databaseValue.get(contactID).get(ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP));
-    }
-
-    /**
-     * Return a mapping of contact IDs to a map of the requested data from the Contacts database.
-     *
-     * @param context    android.content.Context running the request
-     * @param projection List of column names to extract, defined in ContactsContract.Contacts. Must contain uID.COLUMN
-     * @param selection  Parameterizable filter to use with the ContentResolver query. May be null.
-     * @param selectionArgs Parameters for selection. May be null.
-     * @param sortOrder  Sort order to request from the ContentResolver query. May be null.
-     * @return mapping of contact uIDs to desired values, which are a mapping of column names to the data contained there
-     */
-    private static Map<uID, Map<String, String>> accessContactsDatabase(
-            @NonNull Context context,
-            @NonNull String[] projection,
-            @Nullable String   selection,
-            @Nullable String[] selectionArgs,
-            @Nullable String   sortOrder
-            ) {
-        Uri contactsUri = ContactsContract.Contacts.CONTENT_URI;
-
-        HashMap<uID, Map<String, String>> toReturn = new HashMap<>();
-
-        try (Cursor contactsCursor = context.getContentResolver().query(
-                contactsUri,
-                projection,
-                selection,
-                selectionArgs,
-                sortOrder
-        )) {
-            if (contactsCursor != null && contactsCursor.moveToFirst()) {
-                do {
-                    Map<String, String> requestedData = new HashMap<>();
-
-                    int uIDIndex = contactsCursor.getColumnIndexOrThrow(uID.COLUMN);
-                    uID uID = new uID(contactsCursor.getString(uIDIndex));
-
-                    // For each column, collect the data from that column
-                    for (String column : projection) {
-                        int index = contactsCursor.getColumnIndex(column);
-                        // Since we might be getting various kinds of data, Object is the best we can do
-                        String data;
-                        if (index == -1) {
-                            // This contact didn't have the requested column? Something is very wrong.
-                            // If you are experiencing this, please open a bug report indicating how you got here
-                            Log.e(LOG_TAG, "Got a contact which does not have a requested column");
-                            continue;
-                        }
-                        data = contactsCursor.getString(index);
-
-                        requestedData.put(column, data);
-                    }
-
-                    toReturn.put(uID, requestedData);
-                } while (contactsCursor.moveToNext());
-            }
-        }
-        return toReturn;
-    }
-
-    /**
-     * This is a cheap ripoff of com.android.vcard.VCardBuilder
-     * <p>
-     * Maybe in the future that library will be made public and we can switch to using that!
-     * <p>
-     * The main similarity is the usage of .toString() to produce the finalized VCard and the
-     * usage of .appendLine(String, String) to add stuff to the vcard
-     */
-    public static class VCardBuilder {
-        static final String VCARD_END = "END:VCARD"; // Written to terminate the vcard
-        static final String VCARD_DATA_SEPARATOR = ":";
-
-        final StringBuilder vcardBody;
-
-        /**
-         * Take a partial vcard as a string and make a VCardBuilder
-         *
-         * @param vcard vcard to build upon
-         */
-        VCardBuilder(String vcard) {
-            // Remove the end tag. We will add it back on in .toString()
-            vcard = vcard.substring(0, vcard.indexOf(VCARD_END));
-
-            vcardBody = new StringBuilder(vcard);
-        }
-
-        /**
-         * Appends one line with a given property name and value.
-         */
-        public void appendLine(final String propertyName, final String rawValue) {
-            vcardBody.append(propertyName)
-                    .append(VCARD_DATA_SEPARATOR)
-                    .append(rawValue)
-                    .append("\n");
-        }
-
-        @NonNull
-        public String toString() {
-            return vcardBody.toString() + VCARD_END;
-        }
-    }
-
-    /**
-     * Essentially a typedef of the type used for a unique identifier
-     */
-    public static class uID {
-        /**
-         * We use the LOOKUP_KEY column of the Contacts table as a unique ID, since that's what it's
-         * for
-         */
-        final String contactLookupKey;
-
-        /**
-         * Which Contacts column this uID is pulled from
-         */
-        static final String COLUMN = ContactsContract.Contacts.LOOKUP_KEY;
-
-        public uID(String lookupKey) {
-
-            if (lookupKey == null)
-                throw new IllegalArgumentException("lookUpKey should not be null");
-
-            contactLookupKey = lookupKey;
-        }
-
-        @NonNull
-        public String toString() {
-            return this.contactLookupKey;
-        }
-
-        @Override
-        public int hashCode() {
-            return contactLookupKey.hashCode();
-        }
-
-        @Override
-        public boolean equals(Object other) {
-            if (other instanceof uID) {
-                return contactLookupKey.equals(((uID) other).contactLookupKey);
-            }
-            return contactLookupKey.equals(other);
-        }
-    }
-
-    /**
-     * Exception to indicate that a specified contact was not found
-     */
-    public static class ContactNotFoundException extends Exception {
-        public ContactNotFoundException(uID contactID) {
-            super("Unable to find contact with ID " + contactID);
-        }
-
-        public ContactNotFoundException(String message) {
-            super(message);
-        }
-    }
-}
diff --git a/src/main/java/org/kde/kdeconnect/helpers/ContactsHelper.kt b/src/main/java/org/kde/kdeconnect/helpers/ContactsHelper.kt
new file mode 100644
index 000000000..5312e60de
--- /dev/null
+++ b/src/main/java/org/kde/kdeconnect/helpers/ContactsHelper.kt
@@ -0,0 +1,344 @@
+/*
+ * SPDX-FileCopyrightText: 2014 Albert Vaca Cintora <[email protected]>
+ * SPDX-FileCopyrightText: 2018 Simon Redman <[email protected]>
+ *
+ * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+*/
+package org.kde.kdeconnect.helpers
+
+import android.content.ContentResolver
+import android.content.Context
+import android.net.Uri
+import android.provider.ContactsContract
+import android.provider.ContactsContract.PhoneLookup
+import android.util.Base64
+import android.util.Base64OutputStream
+import android.util.Log
+import java.io.ByteArrayOutputStream
+
+/**
+ * Helper object for contacts-related operations.
+ */
+object ContactsHelper {
+    /**
+     * The tag used for logging.
+     */
+    private const val TAG: String = "ContactsHelper"
+
+    data class PhoneNumberLookupResult(
+        val name: String? = null,
+        val photoId: String? = null
+    )
+
+    /**
+     * Lookup the name and photoID of a contact given a phone number.
+     *
+     * Only the first match is returned.
+     *
+     * @param context the context running the request.
+     * @param number the phone number to lookup.
+     *
+     * @return a map containing the `name` and `photoID` of the contact, or an empty map if the contact could not be found.
+     */
+    @JvmStatic
+    fun phoneNumberLookup(
+        context: Context,
+        number: String
+    ): PhoneNumberLookupResult {
+        val uri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
+        val columns = arrayOf(
+            PhoneLookup.DISPLAY_NAME,
+            PhoneLookup.PHOTO_URI,
+        )
+        try {
+            context.contentResolver.query(uri, columns, null, null, null).use { cursor ->
+                // Take the first match only
+                if (cursor != null && cursor.moveToFirst()) {
+                    val name = cursor.getColumnIndex(PhoneLookup.DISPLAY_NAME).takeIf { it != -1 }?.let {
+                        cursor.getString(it)
+                    }
+                    val photoId = cursor.getColumnIndex(PhoneLookup.PHOTO_URI).takeIf { it != -1 }?.let {
+                        cursor.getString(it)
+                    }
+                    return PhoneNumberLookupResult(name, photoId)
+                }
+            }
+        } catch (ignore: Exception) {
+        }
+        return PhoneNumberLookupResult()
+    }
+
+    /**
+     * Get the base64 encoded photo for a contact.
+     *
+     * @param context the context running the request.
+     * @param photoId the photoId of the contact.
+     *
+     * @return the base64 encoded photo, or an empty string if the photo could not be encoded or [photoId] is null.
+     */
+    @JvmStatic
+    fun photoId64Encoded(
+        context: Context,
+        photoId: String?
+    ): String { // TODO: Make photoId notnull, make return type nullable to tag error
+        if (photoId == null) {
+            return ""
+        }
+        val photoUri = Uri.parse(photoId)
+
+        val encodedPhoto = ByteArrayOutputStream()
+        try {
+            context.contentResolver.openInputStream(photoUri).use { input ->
+                Base64OutputStream(encodedPhoto, Base64.DEFAULT).use { output ->
+                    input!!.copyTo(output, 1024)
+                }
+            }
+            return encodedPhoto.toString()
+        } catch (ex: Exception) {
+            Log.e(TAG, "Error encoding photo", ex)
+            return ""
+        }
+    }
+
+    /**
+     * Get VCards using serial database lookups. This is tragically slow, so call only when needed.
+     *
+     * There is a faster API specified using ContactsContract.Contacts.CONTENT_MULTI_VCARD_URI,
+     * but there does not seem to be a way to figure out which ID resulted in which VCard using that API.
+     *
+     * @param context [android.content.Context] running the request.
+     * @param ids collection of uIDs to look up.
+     * @return map of uIDs to the corresponding VCard.
+     */
+    private fun getVCardsSlow(context: Context, ids: Collection<uID>): Map<uID, VCardBuilder> {
+        return ids.mapNotNull { id ->
+            val lookupKey = id.toString()
+            val vcardURI =
+                Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey)
+
+            try {
+                context.contentResolver.openInputStream(vcardURI).use { input ->
+                    if (input == null) {
+                        Log.w("Contacts", "ContentResolver did not give us a stream for the VCard for uID $id")
+                        return@mapNotNull null
+                    }
+                    id to VCardBuilder(input.bufferedReader().readText())
+                }
+            } catch (e: Exception) {
+                Log.e("Contacts", "Exception while fetching vcards", e)
+                null
+            }
+        }.toMap()
+    }
+
+    /**
+     * Get the VCard for every specified raw contact ID.
+     *
+     * @param context [android.content.Context] running the request.
+     * @param ids collection of raw contact IDs to look up.
+     * @return map of raw contact IDs to the corresponding VCard.
+     */
+    @JvmStatic
+    fun getVCardsForContactIDs(context: Context, ids: Collection<uID>): Map<uID, VCardBuilder> {
+        return getVCardsSlow(context, ids)
+    }
+
+    /**
+     * Get the last-modified timestamp for every contact in the database.
+     *
+     * @param context [android.content.Context] running the request.
+     * @return map of contact uID to last-modified timestamp.
+     */
+    @JvmStatic
+    fun getAllContactTimestamps(context: Context): Map<uID, Long> {
+        val projection =
+            arrayOf(uID.COLUMN, ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP)
+
+        val databaseValues = accessContactsDatabase(context, projection, null, null, null)
+
+        val timestamps: Map<uID, Long> = databaseValues.keys.associateWith { contactId ->
+            val data = databaseValues[contactId]!!
+            data[ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP]!!.toLong()
+        }
+
+        return timestamps
+    }
+
+    /**
+     * Get the last-modified timestamp for the specified contact.
+     *
+     * @param context [android.content.Context] running the request.
+     * @param contactID contact uID to read.
+     * @throws ContactNotFoundException if the given ID for some reason does not match a contact.
+     * @return last-modified timestamp of the contact.
+     */
+    @JvmStatic
+    @Throws(ContactNotFoundException::class)
+    fun getContactTimestamp(context: Context, contactID: uID): Long {
+        val projection =
+            arrayOf(uID.COLUMN, ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP)
+        val selection = uID.COLUMN + " = ?"
+        val selectionArgs = arrayOf(contactID.toString())
+
+        val databaseValue =
+            accessContactsDatabase(context, projection, selection, selectionArgs, null)
+
+        if (databaseValue.isEmpty()) {
+            throw ContactNotFoundException("Querying for contact with id $contactID returned no results.")
+        }
+
+        if (databaseValue.size != 1) {
+            Log.w(
+                TAG,
+                "Received an improper number of return values from the database in getContactTimestamp: ${databaseValue.size}"
+            )
+        }
+
+        val timestamp =
+            databaseValue[contactID]!![ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP]!!.toLong()
+
+        return timestamp
+    }
+
+    /**
+     * Return a mapping of contact IDs to a map of the requested data from the Contacts database.
+     *
+     * @param context [android.content.Context] running the request.
+     * @param projection list of column names to extract, defined in [ContactsContract.Contacts], must contain [uID.COLUMN].
+     * @param selection parameterizable filter to use with the [ContentResolver] query.
+     * @param selectionArgs parameters for selection.
+     * @param sortOrder sort order to request from the [ContentResolver] query.
+     * @return map of contact uIDs to desired values, which are a mapping of column names to the data contained there.
+     */
+    private fun accessContactsDatabase(
+        context: Context,
+        projection: Array<String>,
+        selection: String?,
+        selectionArgs: Array<String>?,
+        sortOrder: String?
+    ): Map<uID, Map<String, String>> {
+        val contactsUri = ContactsContract.Contacts.CONTENT_URI
+
+        val toReturn = HashMap<uID, Map<String, String>>()
+
+        context.contentResolver.query(
+            contactsUri,
+            projection,
+            selection,
+            selectionArgs,
+            sortOrder
+        ).use { contactsCursor ->
+            if (contactsCursor != null && contactsCursor.moveToFirst()) {
+                do {
+                    val requestedData: MutableMap<String, String> = HashMap()
+
+                    val uIDIndex = contactsCursor.getColumnIndexOrThrow(uID.COLUMN)
+                    val uID = uID(contactsCursor.getString(uIDIndex)!!)
+
+                    // For each column, collect the data from that column
+                    for (column in projection) {
+                        val index = contactsCursor.getColumnIndex(column)
+                        if (index == -1) {
+                            // This contact didn't have the requested column? Something is very wrong.
+                            // If you are experiencing this, please open a bug report indicating how you got here
+                            Log.e(TAG, "Got a contact which does not have a requested column")
+                            continue
+                        }
+                        // Since we might be getting various kinds of data, Object is the best we can do
+                        val data = contactsCursor.getString(index)
+
+                        requestedData[column] = data
+                    }
+
+                    toReturn[uID] = requestedData
+                } while (contactsCursor.moveToNext())
+            }
+        }
+        return toReturn
+    }
+
+    /**
+     * This is a cheap ripoff of com.android.vcard.VCardBuilder.
+     *
+     * Maybe in the future that library will be made public and we can switch to using that!
+     *
+     * The main similarity is the usage of .toString() to produce the finalized VCard and the
+     * usage of .appendLine(String, String) to add stuff to the vcard.
+     *
+     * @param vcard the vcard to build upon.
+     */
+    class VCardBuilder internal constructor(vcard: String) {
+        private val vcardBody: StringBuilder = StringBuilder(
+            // Remove the end tag. We will add it back on in .toString()
+            // Throws if VCARD_END is missing, so a malformed vcard fails closed rather than being silently accepted.
+            vcard.substring(0, vcard.indexOf(VCARD_END))
+        )
+
+        /**
+         * Appends one line with a given property name and value.
+         *
+         * Please note that this method does not check the validity of the property name and value.
+         * So, you need to make sure that the property name and value are valid.
+         *
+         * @param propertyName the name of the property to append.
+         * @param rawValue the value of the property to append.
+         */
+        fun appendLine(propertyName: String, rawValue: String) {
+            vcardBody.append(propertyName)
+                .append(VCARD_DATA_SEPARATOR)
+                .append(rawValue)
+                .append("\n")
+        }
+
+        /**
+         * Converts the VCard to standard VCard format.
+         *
+         * Please note that this method does not check the validity of the VCard.
+         * So, you need to make sure that the VCard is valid.
+         *
+         * @return the VCard in standard VCard format.
+         */
+        override fun toString(): String = vcardBody.toString() + VCARD_END
+
+        companion object {
+            const val VCARD_END: String = "END:VCARD" // Written to terminate the vcard
+            const val VCARD_DATA_SEPARATOR: String = ":"
+        }
+    }
+
+    /**
+     * Essentially a typedef of the type used for a unique identifier.
+     *
+     * @param contactLookupKey the lookup key of the contact.
+     * We use the LOOKUP_KEY column of the Contacts table as a unique ID, since that's what it's.
+     */
+    @Suppress("ClassName")
+    class uID(val contactLookupKey: String) {
+        override fun toString(): String = this.contactLookupKey
+
+        override fun hashCode(): Int = contactLookupKey.hashCode()
+
+        override fun equals(other: Any?): Boolean {
+            if (other is uID) {
+                return contactLookupKey == other.contactLookupKey
+            }
+            return contactLookupKey == other
+        }
+
+        companion object {
+            /**
+             * Which Contacts column this uID is pulled from
+             */
+            const val COLUMN: String = ContactsContract.Contacts.LOOKUP_KEY
+        }
+    }
+
+    /**
+     * Exception to indicate that a specified contact was not found.
+     */
+    class ContactNotFoundException : Exception {
+        constructor(contactId: uID) : super("Unable to find contact with ID $contactId")
+
+        constructor(message: String?) : super(message)
+    }
+}
diff --git a/src/main/java/org/kde/kdeconnect/plugins/sms/SMSPlugin.kt b/src/main/java/org/kde/kdeconnect/plugins/sms/SMSPlugin.kt
index 2fdb10991..58ed8a731 100644
--- a/src/main/java/org/kde/kdeconnect/plugins/sms/SMSPlugin.kt
+++ b/src/main/java/org/kde/kdeconnect/plugins/sms/SMSPlugin.kt
@@ -201,15 +201,15 @@ class SMSPlugin : Plugin() {
 
         val permissionCheck: Int = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS)
 
-        if (permissionCheck == PackageManager.PERMISSION_GRANTED) {
-            val contactInfo: Map<String, String> = ContactsHelper.phoneNumberLookup(context, phoneNumber)
+        if (permissionCheck == PackageManager.PERMISSION_GRANTED && phoneNumber != null) {
+            val result = ContactsHelper.phoneNumberLookup(context, phoneNumber)
 
-            val name = contactInfo["name"]
+            val name = result.name
             if (name != null) {
                 np["contactName"] = name
             }
 
-            val photoID = contactInfo["photoID"]
+            val photoID = result.photoId
             if (photoID != null) {
                 np["phoneThumbnail"] = ContactsHelper.photoId64Encoded(context, photoID)
             }
diff --git a/src/main/java/org/kde/kdeconnect/plugins/telephony/TelephonyPlugin.kt b/src/main/java/org/kde/kdeconnect/plugins/telephony/TelephonyPlugin.kt
index 0cb1d8c7f..098757c77 100644
--- a/src/main/java/org/kde/kdeconnect/plugins/telephony/TelephonyPlugin.kt
+++ b/src/main/java/org/kde/kdeconnect/plugins/telephony/TelephonyPlugin.kt
@@ -70,25 +70,23 @@ class TelephonyPlugin : Plugin() {
 
         val permissionCheck = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS)
 
-        if (permissionCheck == PackageManager.PERMISSION_GRANTED) {
-            val contactInfo = ContactsHelper.phoneNumberLookup(context, phoneNumber)
+        if (permissionCheck == PackageManager.PERMISSION_GRANTED && phoneNumber != null) {
+            val result = ContactsHelper.phoneNumberLookup(context, phoneNumber)
 
-            val name = contactInfo["name"]
+            val name = result.name
             if (name != null) {
                 np["contactName"] = name
             }
 
-            if (contactInfo.containsKey("photoID")) {
-                val photoUri = contactInfo["photoID"]
-                if (photoUri != null) {
-                    try {
-                        val base64photo = ContactsHelper.photoId64Encoded(context, photoUri)
-                        if (!base64photo.isNullOrEmpty()) {
-                            np["phoneThumbnail"] = base64photo
-                        }
-                    } catch (e: Exception) {
-                        Log.e("TelephonyPlugin", "Failed to get contact photo")
+            val photoUri = result.photoId
+            if (photoUri != null) {
+                try {
+                    val base64photo = ContactsHelper.photoId64Encoded(context, photoUri)
+                    if (!base64photo.isNullOrEmpty()) {
+                        np["phoneThumbnail"] = base64photo
                     }
+                } catch (e: Exception) {
+                    Log.e("TelephonyPlugin", "Failed to get contact photo")
                 }
             }
         } else if (phoneNumber != null) {
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.