Skip to content

Link recipients to phone contacts by lookup key, not phone number - #15044

Open
kees wants to merge 15 commits into
signalapp:mainfrom
kees:feature/contacts-without-phone-number
Open

kees wants to merge 15 commits into
signalapp:mainfrom
kees:feature/contacts-without-phone-number

Conversation

@kees

@kees kees commented Oct 1, 2026 •

Copy link
Copy Markdown

First time contributor checklist

Contributor checklist

  • Google Pixel XL, Android 10
  • My contribution is fully baked and ready to be merged as is
  • I ensure that all the open issues my contribution fixes are mentioned in the commit message of my first commit using the Fixes #1234 syntax

tl;dr: Link Signal contacts to phone contacts so I can actually have (my) photos for my friends

But this is a big change, so here's a long description:

Description

A recipient's link to an address book entry is what gives them the entry's name and photo in Signal. Today a link is created and kept alive only by phone number: every full contact sync re-matches links by number and removes any it cannot confirm. So the name and photo vanish when:

  • the recipient turns off "Who can find me by number," since ae884d79a1 (January 2024) removes links to undiscoverable recipients;
  • the recipient changes numbers, or the user edits the number on the entry;

and a recipient with no number in Signal, or registered under a different number than the one in the address book, can never be linked at all.

This is one of the causes collected in #13827 ("my friend hid their number and their photo vanished"). Forum threads asking to manually associate a contact get "not possible without phone number discovery." This PR addresses that cause; it does not close #13827, which has others.

Phone number privacy is fundamentally unchanged

Since links that used to disappear will now persist, this walks through why there isn't a change to privacy from this PR.

Removing the link when a recipient hides their number protected nothing: anyone with a link already had that number in their own address book. Dropping the link took away the name and photo, not the knowledge of the association (and Signal nicknames persist). The protections that matter are all kept:

  1. No new discovery. A link is never created by phone number for an undiscoverable recipient. That guard in setSystemContactInfo is unchanged.
  2. No exposed number. Until now, any link counted as proof that the user's address book holds the recipient's number, so shouldShowE164 showed it. A link no longer implies that. The new Recipient.isSystemContactByPhoneNumber requires the linked entry to hold the recipient's current number, and the recipient to be discoverable or to share their number. It replaces isSystemContact wherever a decision depends on the address book holding the number: showing the number, digit search, the SMS invite list, and the shared contact card. Everything that means "the user knows this recipient" keeps isSystemContact.
  3. Nothing written to the address book. The link lives only in Signal's database. Signal's own "Message / Call via Signal" rows keep their existing rule: registered, discoverable recipients with a number only.

A link carries the same effects it always has (the profile key is shared, the storage service marks the contact as whitelisted, and linked devices get the contact), however it was made.

What changes

A link means "same person," and is kept by lookup key. The stored link was always the entry's lookup key, never a number. Phone numbers now only discover links; once made, a link survives the recipient changing or hiding their number. Each link has a state in a new column:

State Meaning
NONE Never linked. Matching by number may link it.
LINKED Linked, by number or by the user.
NEEDED Was linked and lost its entry. Keeps the entry's name; offered for relinking.

The sync refreshes links instead of pruning them. Matching by number fills only NONE and NEEDED recipients. Each full sync then reads every linked entry by its lookup key (SystemContactsRepository.getLinkedContact) and refreshes the name, the photo, the entry's current key, and whether the entry still holds the recipient's number. A deleted entry makes its recipient NEEDED rather than wiping what the entry gave it; a provider that cannot be queried leaves links alone.

Restores relink instead of trusting old keys. A lookup key is not portable between phones: for an entry with no sync account, it holds a device-local row id, which can resolve to a different person on the new phone. So after a legacy backup, a device transfer, a backup v2 restore, or a storage service restore, every previously linked recipient becomes NEEDED, and the post-registration sync relinks whatever it can match by number.

UI.

  • About sheet: "is in your phone contacts" now opens the contact. Anyone not linked gets "Link to phone contact," which links them to a contact the user picks, or to a new one prefilled with what Signal shows for them. "Unlink phone contact" appears unless the next sync would just link the recipient again by number.
  • New chat menu: "Unlinked Signal contacts," shown while any recipient is NEEDED, lists them for relinking or dismissal.

Schema and formats

  • Two migrations, both backfilled from existing links: V329 adds system_phone_e164 (the number a link matched on), and V330 adds system_contact_link_state. system_info_pending is no longer used; I left it in place.
  • No change to Backup.proto, the storage service protos, or anything else shared with iOS or Desktop. Restores recognize previously linked recipients by the system name fields they already carry.

Commits

Each commit builds and passes its own tests, and the behavior change is isolated in one commit for review.

  1. Update the About sheet when its recipient changes. (Pre-existing bug, independent of the rest: a StateFlow dropped recipient updates because Recipient.equals compares only ids.)
  2. Keep the system phone type when merging recipients. (Pre-existing bug.)
  3. Record which number a linked system contact holds. (V329.)
  4. Separate knowing a contact from having their number in it.
  5. Add a link state to recipients' system contact links. (V330.)
  6. Test how system contacts are read. (Pins down the existing contact reader before refactoring it.)
  7. Lift contact loading out of the contact iterator. (No behavior change.)
  8. Add a way to read a system contact by its lookup key.
  9. Store the contact id in new system contact lookup URIs. (Pre-existing quirk.)
  10. Share the system contact field mapping. (No behavior change.)
  11. Keep system contact links by lookup key, not by phone number. (The behavior change.)
  12. Mark restored system contact links as NEEDED.
  13. Support linking a recipient to a system contact by hand.
  14. Offer system contact linking from the About sheet. (Now I can have my friend's photos in Signal.)
  15. List the recipients that lost their system contact. (Now I can move to a new phone and not lose my mind figuring out who needs to be linked again.)

Testing

  • 54 new unit tests in 10 classes, including a fake contacts provider for lib/contacts, which had no tests. The tests for each change to existing behavior fail without that change. The full unit suite, ktlint, and fast-lint pass.
  • On a Pixel XL with the staging build: linking by picking and by creating a contact, with both a Google account and the device-only contact store; unlinking; the hidden-number cases; a deleted entry becoming NEEDED; and the Unlinked screen. The new screens look right in light and dark themes, and very long names truncate.
  • Creating a contact relies on the contacts app returning the new contact's URI from ACTION_INSERT. Google Contacts does; I have not tried other vendors' contacts apps. If one does not, nothing is linked (the screen says so when the app reports success without a URI), and picking the newly created contact afterward still works.

No in-app help needed changes. Signal's support articles about contact names and photos may want updating once this ships, since a hidden number no longer removes them.

kees added 15 commits September 30, 2026 21:09
The About sheet keeps its recipient in a StateFlow, which drops any new
value equal to the current one, and Recipient.equals compares only
ids. So a change to the recipient while the sheet is open, such as a new
profile name, never reaches it. Nothing on the sheet changes the
recipient itself yet, which may be why this has gone unnoticed, but
linking and unlinking a phone contact from the sheet will.

Count recipient content changes in the state, as the conversation
settings screens already do, so each one makes a new state.
RecipientTable.merge() copies the linked record's system contact fields
onto the merged recipient, but it left out system_phone_type. The merged
recipient kept the other record's type, usually -1, alongside the
copied contact URI and label, so its phone label type was lost.

Read system_phone_type into RecipientRecord and carry it through the
merge with the rest of the system contact fields.
A recipient's link to a system contact records the contact, and the
type and label of the phone number it matched on, such as "Mobile," but
not the number itself. So whether the contact holds the recipient's
current number cannot be told apart from whether it held a number they
have since changed.

Add system_phone_e164, the recipient's number as it was matched on the
linked contact. The contact sync writes it with the rest of the link,
merges carry it, and clearing a link clears it. Existing links were all
made by matching the recipient's current number, so the migration fills
the column from it. Nothing reads it yet: the next change uses it to
decide when the address book holds a recipient's number.
Recipient.shouldShowE164 treats any system contact link as proof that
the user's address book holds the recipient's phone number. That is
true today only because every link is made by matching a number, and
a full sync drops links that stop matching. Allowing links that are
not tied to a number would break that assumption and could reveal a
number its owner has hidden.

Add Recipient.isSystemContactByPhoneNumber, true only when the linked
contact holds the recipient's current number (system_phone_e164 equals
their E164) and the recipient has not turned off phone number
discovery, unless they share their number. Use it wherever a decision
depends on the address book holding the number: shouldShowE164, digit
search, the non-Signal contact list, and the shared contact card's
Invite button. The "Update contact" button after a number change needs
both: the user knows the person, and the new number may be shown.

Callers that mean "the user knows this recipient" keep isSystemContact.

Today every link's contact holds the number it matched on, so the
changes in behavior are narrow. A partial contact sync never removes
links, so a recipient who turns off discovery could keep a link until
the next full sync, and their number is now hidden during that window.
Likewise, a recipient's new number no longer counts as being in the
address book while the link still holds their old one.
A recipient is linked to a system contact exactly when it has a system
contact URI. That cannot tell a recipient who was never linked from one
whose contact went away, which the coming lookup-key based sync needs.

Add system_contact_link_state: NONE, LINKED, or NEEDED (was linked and
lost the contact; nothing sets it yet). The migration marks every
recipient with a URI as LINKED, and linking by number sets it.

The system contact fields describe a single link, so a recipient merge
now takes all of them, with the state, from whichever record has the
stronger link, preferring the E164 record as before when they are equal.
Until now the merge always took the E164 record's fields, which would
drop a link held only by the ACI record.
The contact sync's reader, CursorContactIterator, has no tests. It
decides which name a contact gets (the provider's name source, never
Signal's own raw contact), which number type wins when a contact lists
a number twice, and what happens when a provider rejects the columns
those choices depend on. It carries each number's label and photo,
which the sync stores, and it relies on the query's sort order, latest
row first, wherever a choice is otherwise tied.

Add a fake contacts provider that serves fixed rows, sorted by the
query's sort order as the real provider sorts them, and tests that pin
those choices down, ahead of sharing the reader with another caller.
The fixture rows are deliberately listed out of order, so a change to
the sort order shows up as a different name or number order.
CursorContactIterator both finds each contact, by walking a cursor
sorted by lookup key, and loads it: formatting its numbers, keeping the
lowest type of a repeated number, and choosing its name. Keeping a link
current needs that same loading for a contact found another way.

Move the loading into ContactDataReader, which takes one contact's data
rows and produces its numbers and name, and share the query's columns
and sort order. The iterator keeps the walking. No change in behavior;
the tests added in the previous commit pass unchanged.
Signal links a recipient to a system contact by the contact's lookup
key, but reads contacts only by walking the whole address book. Keeping
a link current needs the other direction: given a stored key, what does
the contact look like now, if it still exists?

Add SystemContactsRepository.getLinkedContact. It resolves the key,
which the provider does even after the user joins contacts, and loads
the contact's rows with the same ContactDataReader the full read uses,
so a linked contact gets the same numbers and name either way. It adds
what the full read cannot supply for a contact with no phone number: the
display name and photo from the contact itself, and the contact's
current lookup URI.

Rows from Signal's own account are skipped, so a contact kept alive only
by Signal's "Message via Signal" raw contact reads as missing. A
provider that cannot be queried reads as unavailable rather than
missing, so a caller can tell "gone" from "unknown." Nothing calls it
yet.
A system contact link is stored as a lookup URI,
content://com.android.contacts/contacts/lookup/<lookup key>/<id>, whose
last segment Android defines as the contact id:
Contacts.getLookupUri() takes a contact id. The contact reader passes
the matching phone row's Data row id instead, so the URI names the right
contact by its key, but a row of a different table by its id.

Nothing visibly breaks today. When the contacts provider resolves a
lookup URI, it first looks for a contact with both that id and that
key, and falls back to the key alone when none matches. A mismatched id
therefore only costs that first attempt; it cannot select a different
person, since the key must match too. Signal itself only ever reads the
key back out (lookupKeyFromLookupUri), and the contacts app, given the
URI to open, goes through the same provider resolution.

The coming changes keep links current by lookup key: each refreshed
link's URI is rewritten as Contacts.getLookupUri(contactId, currentKey),
and a link the user makes by hand is stored the same way. Without this
change, the same column would hold contact ids for those links, and
Data row ids for links newly found by number until their first
refresh. Build new lookup URIs from the row's contact id, so every link
written from here on has the documented form. Links stored earlier keep
their Data row id until a refresh rewrites them.

That URI was the only reader of the Data row id, so it leaves the query's
columns. It remains the sort key, so a contact's latest row still wins
ties, and sorting needs no projected column.

The reader test recorded the old form: it expected the id of Alice's
winning phone row (11). It now expects her contact id (1), and also
checks that both of her numbers carry the same URI, which under row ids
they did not (11 and 10).
The contact sync turns a matched contact into recipient columns in two
places: discovery derives the system name from a contact's structured
or display name, and the bulk update maps the contact's details onto
the system contact columns. Keeping links current from the contact
itself, coming next, needs both again.

Move each into a helper: ContactDiscovery.systemProfileName and
RecipientTable.systemContactValues. No change in behavior.
A link says a recipient and an address book entry are the same person.
The stored link has always been the entry's lookup key, but every full
contact sync re-matches links by phone number and removes any it cannot
match. So a link is lost whenever the recipient changes numbers, the
user edits the number on the entry, or the recipient turns off phone
number discovery. The recipient then loses the entry's name and photo,
even though the user still has the entry and still knows who the
recipient is.

Phone numbers now only discover links: matching by number fills only
recipients that are not already linked, and keeps refusing to link an
undiscoverable recipient. The number-based prune is gone. Instead, both
full-sync paths refresh every link from its entry by lookup key, with
SystemContactsRepository.getLinkedContact: the name, the photo, the
entry's current lookup key, and whether the entry still holds the
recipient's number. A provider that cannot be queried leaves links
alone.

When the entry itself is gone, the recipient becomes NEEDED rather than
losing everything the entry gave it. It keeps the name, label, and the
rest, and drops only the contact and photo URIs, which pointed into the
deleted entry. Matching by number can link a NEEDED recipient again,
and dismissSystemContactLinkNeeded returns one to NONE, keeping the
name, for when the user does not want to link it.

Phone number privacy is unchanged where it matters. Discovery still
never links an undiscoverable recipient, and a link whose entry lacks
the recipient's number, or whose recipient is undiscoverable without
sharing their number, still does not show that number (see
isSystemContactByPhoneNumber). Nothing about a link is written to the
address book, and Signal's own address book rows keep their existing
rule.

The system_info_pending column is no longer used.
A system contact's lookup key is not portable between phones: for a
contact with no sync account, it holds a device-local row id, which the
new phone's contacts provider can resolve to a different person. The
restore paths treat links inconsistently today. A legacy backup or
device transfer carries the old phone's URIs verbatim until a full sync
re-matches or sweeps them, while backup v2 and the storage service carry
only the contact's name, leaving a name with no link.

Make every restore end the same way: each recipient that was linked
becomes NEEDED, keeping what the old contact gave it, such as its name,
and dropping the contact and photo URIs.

- Legacy backups and device transfers: every recipient with a URI, in
  runPostBackupRestoreTasks, after the imported database is migrated.
- Backup v2 and storage service inserts on a primary device: every
  recipient with a system name, since only a link sets those names.

The post-registration contact sync then links again whatever it can
match by number. The rest stay NEEDED until the user links them by
hand, which the following changes make possible.
Links have only ever been made by matching a phone number, so a
recipient with no number, a hidden number, or a number that differs from
the one in the address book can never get the system contact's name and
photo.

Add the database and sync side of linking by hand:
ContactDiscovery.linkSystemContact reads a system contact the user
picked and links the recipient to it, whatever the recipient's number or
discoverability, replacing any earlier link. As with any link, the
system contact counts as holding the recipient's number only if it
does, and only if the recipient lets the number be known.
unlinkSystemContact removes a link the user no longer wants, and
getSystemContactLinksNeeded lists the recipients waiting for a new
link. Nothing calls them yet.
The About sheet says when a recipient is in the user's system contacts,
but offers no way to open that system contact, and no way to link one
when matching by phone number cannot.

On the About sheet, the "is in your phone contacts" row now opens the
system contact. For a recipient who is not linked, a "Link to phone
contact" row opens a new screen, LinkPhoneContactActivity, which links
the recipient either to a system contact the user picks or to a new one
prefilled with the name, photo, and, when the recipient lets it be seen,
number that Signal shows for them. Linking asks for contacts permission
first, since the contact sync keeps the link current.

An "Unlink phone contact" row removes a link, unless the next contact
sync would just link the recipient again by number, which happens only
when the system contact holds the recipient's number and the recipient
is discoverable. So it is offered for a recipient with no number, one
whose system contact's number was changed to another, and one linked by
hand while undiscoverable.

Linking is offered on primary devices only, which are the ones that
keep links.
A recipient becomes NEEDED when its system contact is deleted, or when
the user restores onto a new phone and the contact sync cannot link it
again by number. Nothing shows the user which recipients those are.

Add an "Unlinked Signal contacts" screen, UnlinkedSignalContactsActivity,
reached from the new-chat menu while any recipient needs a link. It
lists those recipients, links one when tapped, and dismisses one or all,
keeping their names. Without contacts permission it offers to grant it
first, after which a contact sync links again every recipient it can
match by number.
@cody-signal

Copy link
Copy Markdown
Contributor

Thanks for the effort, we'll consider this. Just fyi, given the size and impact we aren't likely to move forward with it. I would also suggest pursing the community forum feature request flow to drive support for this feature change.

@kees

kees commented Oct 1, 2026

Copy link
Copy Markdown
Author

Thanks for the effort, we'll consider this. Just fyi, given the size and impact we aren't likely to move forward with it. I would also suggest pursing the community forum feature request flow to drive support for this feature change.

Okay, I will see what I can do. I was hoping it would be relatively straight-forward to review. The "big" changes are mainly tests and UI layout. The actual logic is small and pretty self-contained, and re-uses the existing Contact reading/syncing code.

@cyberLeelad

Copy link
Copy Markdown

Cody, I’m wondering if you could suggest what a smaller change would need to look like in order for a fix on contract photos to be accepted?

@kees If you set up a community feature forum post, drop a link in 13827 so we can express our support.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Contact photos not showing

3 participants