From b057e145c582305af439c57d521c668a2032d0ad Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 25 Feb 2026 00:34:22 -0500 Subject: [PATCH] Ensure usernames are unique regardless of casing. --- .../securesms/database/RecipientTable.kt | 14 +++++-- .../helpers/SignalDatabaseMigrations.kt | 6 ++- .../V303_CaseInsensitiveUsernames.kt | 41 +++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V303_CaseInsensitiveUsernames.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index ae323a8f45..5bc14020be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -275,7 +275,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da val CREATE_INDEXS = arrayOf( "CREATE INDEX IF NOT EXISTS recipient_type_index ON $TABLE_NAME ($TYPE);", - "CREATE INDEX IF NOT EXISTS recipient_aci_profile_key_index ON $TABLE_NAME ($ACI_COLUMN, $PROFILE_KEY) WHERE $ACI_COLUMN NOT NULL AND $PROFILE_KEY NOT NULL" + "CREATE INDEX IF NOT EXISTS recipient_aci_profile_key_index ON $TABLE_NAME ($ACI_COLUMN, $PROFILE_KEY) WHERE $ACI_COLUMN NOT NULL AND $PROFILE_KEY NOT NULL", + "CREATE UNIQUE INDEX recipient_username_unique_nocase ON recipient(username COLLATE NOCASE)" ) private val RECIPIENT_PROJECTION: Array = arrayOf( @@ -446,7 +447,14 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } fun getByUsername(username: String): Optional { - return getByColumn(USERNAME, username) + return readableDatabase + .select(ID) + .from(TABLE_NAME) + .where("$USERNAME = ? COLLATE NOCASE", username) + .run() + .readToSingleObject { cursor -> + Optional.of(RecipientId.from(cursor.requireLong(ID))) + } ?: Optional.empty() } fun getByCallLinkRoomId(callLinkRoomId: CallLinkRoomId): Optional { @@ -974,7 +982,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da writableDatabase .update(TABLE_NAME) .values(USERNAME to null) - .where("$USERNAME = ? AND $ID != ?", username, recipientId.serialize()) + .where("$USERNAME = ? COLLATE NOCASE AND $ID != ?", username, recipientId.serialize()) .run() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index b9305b0d4b..38398e7523 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -156,6 +156,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V299_AddAttachmentM import org.thoughtcrime.securesms.database.helpers.migration.V300_AddKeyTransparencyColumn import org.thoughtcrime.securesms.database.helpers.migration.V301_RemoveCallLinkEpoch import org.thoughtcrime.securesms.database.helpers.migration.V302_AddDeletedByColumn +import org.thoughtcrime.securesms.database.helpers.migration.V303_CaseInsensitiveUsernames import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -318,10 +319,11 @@ object SignalDatabaseMigrations { 299 to V299_AddAttachmentMetadataTable, 300 to V300_AddKeyTransparencyColumn, 301 to V301_RemoveCallLinkEpoch, - 302 to V302_AddDeletedByColumn + 302 to V302_AddDeletedByColumn, + 303 to V303_CaseInsensitiveUsernames ) - const val DATABASE_VERSION = 302 + const val DATABASE_VERSION = 303 @JvmStatic fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V303_CaseInsensitiveUsernames.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V303_CaseInsensitiveUsernames.kt new file mode 100644 index 0000000000..b215dafdd0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V303_CaseInsensitiveUsernames.kt @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import org.signal.core.util.Stopwatch +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SQLiteDatabase + +/** + * Enforces case-insensitive uniqueness on the username column in the recipient table. + * Cleans up any existing case-insensitive duplicates before creating the index. + */ +@Suppress("ClassName") +object V303_CaseInsensitiveUsernames : SignalDatabaseMigration { + + private val TAG = Log.tag(V303_CaseInsensitiveUsernames::class.java) + + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + val stopwatch = Stopwatch("migration", decimalPlaces = 2) + + // Clear the username if it doesn't have the highest _id of recipient rows with the same case-insensitive username + db.execSQL( + """ + UPDATE recipient + SET username = NULL + WHERE username IS NOT NULL + AND _id NOT IN ( + SELECT MAX(_id) + FROM recipient + WHERE username IS NOT NULL + GROUP BY LOWER(username) + ) + """.trimIndent() + ) + stopwatch.split("dedupe") + + db.execSQL("CREATE UNIQUE INDEX recipient_username_unique_nocase ON recipient(username COLLATE NOCASE)") + stopwatch.split("create-index") + + stopwatch.stop(TAG) + } +}