Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/main/java/com/bnyro/contacts/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ class App : Application() {
ShortcutHelper.createShortcuts(this@App)
}

CoroutineScope(Dispatchers.IO).launch {
deviceContactsRepository.migrateLegacyLocalAccountContacts()
}

initSmsRepo()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.annotation.SuppressLint
import android.content.ContentProviderOperation
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
Expand All @@ -31,6 +32,7 @@ import com.bnyro.contacts.domain.model.ContactsGroup
import com.bnyro.contacts.domain.model.ValueWithType
import com.bnyro.contacts.util.ContactsHelper
import com.bnyro.contacts.util.ImageHelper
import com.bnyro.contacts.util.PermissionHelper
import com.bnyro.contacts.util.Preferences
import com.bnyro.contacts.util.extension.boolValue
import com.bnyro.contacts.util.extension.intValue
Expand Down Expand Up @@ -170,8 +172,10 @@ class DeviceContactsRepository(private val context: Context) : ContactsRepositor
ContentProviderOperation.newInsert(ContactsContract.Groups.CONTENT_URI).apply {
withValue(ContactsContract.Groups.TITLE, groupName)
withValue(ContactsContract.Groups.GROUP_VISIBLE, 1)
withValue(ContactsContract.Groups.ACCOUNT_NAME, AccountType.androidDefault.name)
withValue(ContactsContract.Groups.ACCOUNT_TYPE, AccountType.androidDefault.type)
// see the comment in createContact() - a group needs a real null account too,
// not the "Device" UI sentinel, or it's vulnerable to the same cleanup
withValue(ContactsContract.Groups.ACCOUNT_NAME, null as String?)
withValue(ContactsContract.Groups.ACCOUNT_TYPE, null as String?)
operations.add(build())
}

Expand Down Expand Up @@ -309,10 +313,18 @@ class DeviceContactsRepository(private val context: Context) : ContactsRepositor
override suspend fun createContact(contact: ContactData) {
withContext(Dispatchers.IO) {
val lastChosenAccount = Preferences.getLastChosenAccount()
val resolvedAccountType = contact.accountType ?: lastChosenAccount.type
val resolvedAccountName = contact.accountName ?: lastChosenAccount.name
// the local/"Device" account is a UI-only sentinel, not a real registered
// AccountManager account - writing it as a literal account_type/account_name
// makes ContactsProvider2 treat these raw contacts as belonging to an account
// that doesn't exist, so any unrelated account being added/removed anywhere on
// the device wipes them. null/null is Android's actual local-contact convention.
val isLocalAccount = resolvedAccountType == AccountType.androidDefault.type

@Bnyro Bnyro Jul 25, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using AccountType.androidDefault here is not really the cleanest solution, instead we should remove AccountType.androidDefault and just always pass null around as the account name and type. So the idea would be to modify setLastChosenAccount and getLastChosenAccount to also handle null values. Then we don't have to do this case distinction here and can just run getCreateAction(resolvedAccountType, resolvedAccountName).

val ops = listOfNotNull(
getCreateAction(
contact.accountType ?: lastChosenAccount.type,
contact.accountName ?: lastChosenAccount.name
if (isLocalAccount) null else resolvedAccountType,
if (isLocalAccount) null else resolvedAccountName
),
getInsertAction(
StructuredName.CONTENT_ITEM_TYPE,
Expand Down Expand Up @@ -440,9 +452,63 @@ class DeviceContactsRepository(private val context: Context) : ContactsRepositor
return listOf(AccountType.androidDefault) + accounts.map { AccountType(it.name, it.type) }
}

/**
* Before the fix for #477, contacts/groups were created with AccountType.androidDefault
* (a UI-only "Device" sentinel) written as their literal account_type/account_name, as if
* it were a real account. ContactsProvider2 reconciles raw_contacts/data/groups against the
* real current account list on every account-list change anywhere on the device (any
* account, any type, add or remove) and deletes rows whose account no longer exists - since
* this sentinel never was a real account, every contact/group created this way was
* permanently exposed to that cleanup. This moves any such existing rows to the real
* local-contact convention (account_type=null, account_name=null) one time.
*
* Only runs if no real AccountManager account currently matches the sentinel's exact
* (type, name) - if one exists, this is a no-op, since there would be no way to tell which
* rows are ours vs. genuinely belonging to that real account. The flag is only set once a
* migration attempt actually completes (with permission and without a colliding real
* account), so an ambiguous or permission-denied run will naturally retry on a later launch.
*/
suspend fun migrateLegacyLocalAccountContacts() = withContext(Dispatchers.IO) {
if (Preferences.getBoolean(Preferences.legacyLocalAccountMigratedKey, false)) return@withContext
if (!PermissionHelper.hasPermission(
context,
Manifest.permission.READ_CONTACTS,
Manifest.permission.WRITE_CONTACTS
)
) {
return@withContext
}

val sentinelType = AccountType.androidDefault.type

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you call this sentinel, it's not really a sentinel?

val sentinelName = AccountType.androidDefault.name
val realAccountExists = AccountManager.get(context).accounts.any {
it.type == sentinelType && it.name == sentinelName
}
if (realAccountExists) return@withContext

runCatching {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the runCatching? In theory this should never fail/raise an exception, or in which case should it?

val selection = "${RawContacts.ACCOUNT_TYPE} = ? AND ${RawContacts.ACCOUNT_NAME} = ?"
val selectionArgs = arrayOf(sentinelType, sentinelName)
val nullAccountValues = ContentValues().apply {
putNull(RawContacts.ACCOUNT_TYPE)
putNull(RawContacts.ACCOUNT_NAME)
}

context.contentResolver.update(RawContacts.CONTENT_URI, nullAccountValues, selection, selectionArgs)
context.contentResolver.update(
ContactsContract.Groups.CONTENT_URI,
nullAccountValues,
selection,
selectionArgs
)
}

Preferences.edit { putBoolean(Preferences.legacyLocalAccountMigratedKey, true) }
}

private fun getCreateAction(
accountType: String,
accountName: String
accountType: String?,
accountName: String?
): ContentProviderOperation {
return ContentProviderOperation.newInsert(RawContacts.CONTENT_URI)
.withValue(RawContacts.ACCOUNT_TYPE, accountType)
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/bnyro/contacts/util/Preferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ object Preferences {
const val encryptBackupPasswordKey = "encryptBackupsPassword"
const val storeSmsLocallyKey = "storeSmsLocally"
const val lastChosenAccount = "lastChosenAccount"
const val legacyLocalAccountMigratedKey = "legacyLocalAccountMigrated"
const val biometricAuthKey = "biometricAuth"
const val favoritesOnlyKey = "favoritesOnly"
const val selectedSimSubscriptionIdKey = "selectedSimSubscriptionId"
Expand Down
Loading