From 5c375ec2eaab3aa1b13e5aaf8320d4b9e4507b54 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sun, 5 Apr 2026 05:50:50 +0530 Subject: [PATCH 1/2] feat: allow renaming profiles - test: unit test for renaming method --- .../ichi2/anki/multiprofile/ProfileManager.kt | 38 ++++++++++++ .../anki/multiprofile/ProfileManagerTest.kt | 62 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt index 8157fb1e23cd..47b01374fdc7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -232,6 +232,44 @@ class ProfileManager private constructor( return ProfileRestrictedDirectory(directoryFile) } + /** + * Renames an existing profile by updating its display name in + * the registry. + * + * All other metadata fields (version, creation timestamp) are + * preserved. The change is persisted immediately. + * + * @param profileId The [ProfileId] of the profile to rename. + * @param newDisplayName The new user-facing name. + * + * @throws IllegalArgumentException if [profileId] does not + * exist in the registry. + */ + fun renameProfile( + profileId: ProfileId, + newDisplayName: String, + ) { + Timber.d("ProfileManager::renameProfile called for $profileId") + + require(newDisplayName.isNotBlank()) { + "Profile display name must not be blank" + } + + val existing = + profileRegistry.getProfileMetadata(profileId) + ?: throw IllegalArgumentException("Profile $profileId not found") + + if (existing.displayName == newDisplayName) { + Timber.d("Rename skipped: New name matches existing name for $profileId") + return + } + + val updated = existing.copy(displayName = newDisplayName) + profileRegistry.saveProfile(profileId, updated) + + Timber.d("Renamed profile $profileId to '$newDisplayName'") + } + /** * Holds the meta-data for a profile. * Converted to JSON for storage to allow future extensibility (e.g. avatars, themes). diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt index 6adba877654e..521d5a2cad43 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt @@ -39,6 +39,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.runner.RunWith import org.robolectric.annotation.Config import java.io.File @@ -200,4 +201,65 @@ class ProfileManagerTest { assertEquals(1, allProfiles.size) assertTrue(allProfiles.containsKey(ProfileId.DEFAULT)) } + + fun `renameProfile updates displayName in registry`() { + val manager = ProfileManager.create(context) + val profileId = manager.createNewProfile("Original Name") + val newName = "Updated Name" + + manager.renameProfile(profileId, newName) + + val json = prefs.getString(profileId.value, null) + val metadata = ProfileManager.ProfileMetadata.fromJson(json!!) + + assertEquals(newName, metadata.displayName) + } + + @Test + fun `renameProfile preserves version and createdTimestamp`() { + val manager = ProfileManager.create(context) + val profileId = manager.createNewProfile("Original Name") + + val originalJson = prefs.getString(profileId.value, null) + val originalMetadata = ProfileManager.ProfileMetadata.fromJson(originalJson!!) + + manager.renameProfile(profileId, "New Name") + + val updatedJson = prefs.getString(profileId.value, null) + val updatedMetadata = ProfileManager.ProfileMetadata.fromJson(updatedJson!!) + + assertEquals("Version must be preserved", originalMetadata.version, updatedMetadata.version) + assertEquals( + "Timestamp must be preserved", + originalMetadata.createdTimestamp, + updatedMetadata.createdTimestamp, + ) + } + + @Test + fun `renameProfile does not write to disk if name is identical`() { + val manager = ProfileManager.create(context) + val name = "No Change" + val profileId = manager.createNewProfile(name) + + val originalJson = prefs.getString(profileId.value, null) + + manager.renameProfile(profileId, name) + + val currentJson = prefs.getString(profileId.value, null) + assertEquals("No disk write should occur for identical names", originalJson, currentJson) + } + + @Test + fun `renameProfile throws IllegalArgumentException for missing profile`() { + val manager = ProfileManager.create(context) + val fakeId = ProfileId("p_ghost") + + val exception = + assertThrows(IllegalArgumentException::class.java) { + manager.renameProfile(fakeId, "New Name") + } + + assertTrue(exception.message!!.contains("not found")) + } } From 6fb5daae5eb653ce26f09ba1097a176e13a77b98 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:31:28 +0530 Subject: [PATCH 2/2] feat: profile name validation --- .../ichi2/anki/multiprofile/ProfileManager.kt | 18 +-- .../ichi2/anki/multiprofile/ProfileName.kt | 79 ++++++++++ .../anki/multiprofile/ProfileManagerTest.kt | 25 +-- .../anki/multiprofile/ProfileNameTest.kt | 145 ++++++++++++++++++ 4 files changed, 244 insertions(+), 23 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileName.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileNameTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt index 47b01374fdc7..61db266ba782 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -76,7 +76,7 @@ class ProfileManager private constructor( val defaultId = ProfileId.DEFAULT val metadata = - ProfileMetadata(displayName = DEFAULT_PROFILE_DISPLAY_NAME) + ProfileMetadata(displayName = ProfileName.fromTrustedSource(DEFAULT_PROFILE_DISPLAY_NAME)) profileRegistry.saveProfile(id = defaultId, metadata = metadata, isActive = true) profileRegistry.setLastActiveProfileId(defaultId) @@ -96,14 +96,14 @@ class ProfileManager private constructor( * * @throws Exception if profile creation or persistence fails. */ - fun createNewProfile(displayName: String): ProfileId { + fun createNewProfile(displayName: ProfileName): ProfileId { val newProfileId = generateUniqueProfileId() val metadata = ProfileMetadata(displayName = displayName) profileRegistry.saveProfile(newProfileId, metadata) - Timber.i("Created new profile: $displayName (${newProfileId.value})") + Timber.i("Created new profile: ${displayName.value} (${newProfileId.value})") return newProfileId } @@ -247,14 +247,10 @@ class ProfileManager private constructor( */ fun renameProfile( profileId: ProfileId, - newDisplayName: String, + newDisplayName: ProfileName, ) { Timber.d("ProfileManager::renameProfile called for $profileId") - require(newDisplayName.isNotBlank()) { - "Profile display name must not be blank" - } - val existing = profileRegistry.getProfileMetadata(profileId) ?: throw IllegalArgumentException("Profile $profileId not found") @@ -275,14 +271,14 @@ class ProfileManager private constructor( * Converted to JSON for storage to allow future extensibility (e.g. avatars, themes). */ data class ProfileMetadata( - val displayName: String, + val displayName: ProfileName, val version: Int = 1, val createdTimestamp: String = getTimestamp(TimeManager.time), ) { fun toJson(): String = JSONObject() .apply { - put("displayName", displayName) + put("displayName", displayName.value) put("version", version) put("created", createdTimestamp) }.toString() @@ -291,7 +287,7 @@ class ProfileManager private constructor( fun fromJson(jsonString: String): ProfileMetadata { val json = JSONObject(jsonString) return ProfileMetadata( - displayName = json.optString("displayName", "Unknown"), + displayName = ProfileName.fromTrustedSource(json.optString("displayName", "Unknown")), version = json.optInt("version", 1), createdTimestamp = json.optString("created", ""), ) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileName.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileName.kt new file mode 100644 index 000000000000..649030bb55fc --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileName.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.multiprofile + +/** + * A validated, user-facing profile display name. + * + * Values can only be obtained through [validate] (for user input) or + * [fromTrustedSource] (for persisted / hard-coded values). This makes invalid + * profile names unrepresentable in the domain layer. + */ +@JvmInline +value class ProfileName private constructor( + val value: String, +) { + override fun toString(): String = value + + companion object { + const val MAX_LENGTH = 50 + + /** + * Validates raw user input. Trims leading and trailing whitespace + * before checking the rules; interior whitespace (including multiple + * spaces between words) is preserved as-is. + * + * Any non-whitespace character is permitted — including punctuation, + * emoji, and symbols — matching the desktop Anki behavior. Only + * length and emptiness are enforced here; uniqueness is checked at + * the registry layer. + * + * Returns a [ValidationResult] — never throws. + */ + fun validate(raw: String): ValidationResult { + val cleaned = raw.trim() + return when { + cleaned.isEmpty() -> ValidationResult.Empty + cleaned.length > MAX_LENGTH -> + ValidationResult.TooLong(cleaned.length) + else -> ValidationResult.Valid(ProfileName(cleaned)) + } + } + + /** + * Constructs a [ProfileName] from a value that has already been + * validated elsewhere (persisted metadata, hard-coded defaults). + * + * DO NOT use this for raw user input — use [validate] instead. + */ + internal fun fromTrustedSource(value: String): ProfileName = ProfileName(value) + } + + /** Outcome of validating a candidate profile name. */ + sealed interface ValidationResult { + data class Valid( + val name: ProfileName, + ) : ValidationResult + + data object Empty : ValidationResult + + data class TooLong( + val actualLength: Int, + ) : ValidationResult + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt index 521d5a2cad43..b2b7f5a1e63b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt @@ -163,7 +163,7 @@ class ProfileManagerTest { fun `ProfileMetadata JSON round-trip preserves all fields`() { val original = ProfileManager.ProfileMetadata( - displayName = "Test User", + displayName = ProfileName.fromTrustedSource("Test User"), version = 5, createdTimestamp = "2025-12-31T23:59:59Z", ) @@ -178,8 +178,8 @@ class ProfileManagerTest { fun `getAllProfiles returns all registered profiles`() { val manager = ProfileManager.create(context) - val profile1 = manager.createNewProfile("Work") - val profile2 = manager.createNewProfile("Personal") + val profile1 = manager.createNewProfile(ProfileName.fromTrustedSource("Work")) + val profile2 = manager.createNewProfile(ProfileName.fromTrustedSource("Personal")) val allProfiles = manager.getAllProfiles() @@ -187,9 +187,9 @@ class ProfileManagerTest { assertTrue(allProfiles.containsKey(ProfileId.DEFAULT)) assertTrue(allProfiles.containsKey(profile1)) assertTrue(allProfiles.containsKey(profile2)) - assertEquals("Default", allProfiles[ProfileId.DEFAULT]?.displayName) - assertEquals("Work", allProfiles[profile1]?.displayName) - assertEquals("Personal", allProfiles[profile2]?.displayName) + assertEquals("Default", allProfiles[ProfileId.DEFAULT]?.displayName?.value) + assertEquals("Work", allProfiles[profile1]?.displayName?.value) + assertEquals("Personal", allProfiles[profile2]?.displayName?.value) } @Test @@ -202,10 +202,11 @@ class ProfileManagerTest { assertTrue(allProfiles.containsKey(ProfileId.DEFAULT)) } + @Test fun `renameProfile updates displayName in registry`() { val manager = ProfileManager.create(context) - val profileId = manager.createNewProfile("Original Name") - val newName = "Updated Name" + val profileId = manager.createNewProfile(ProfileName.fromTrustedSource("Original Name")) + val newName = ProfileName.fromTrustedSource("Updated Name") manager.renameProfile(profileId, newName) @@ -218,12 +219,12 @@ class ProfileManagerTest { @Test fun `renameProfile preserves version and createdTimestamp`() { val manager = ProfileManager.create(context) - val profileId = manager.createNewProfile("Original Name") + val profileId = manager.createNewProfile(ProfileName.fromTrustedSource("Original Name")) val originalJson = prefs.getString(profileId.value, null) val originalMetadata = ProfileManager.ProfileMetadata.fromJson(originalJson!!) - manager.renameProfile(profileId, "New Name") + manager.renameProfile(profileId, ProfileName.fromTrustedSource("New Name")) val updatedJson = prefs.getString(profileId.value, null) val updatedMetadata = ProfileManager.ProfileMetadata.fromJson(updatedJson!!) @@ -239,7 +240,7 @@ class ProfileManagerTest { @Test fun `renameProfile does not write to disk if name is identical`() { val manager = ProfileManager.create(context) - val name = "No Change" + val name = ProfileName.fromTrustedSource("No Change") val profileId = manager.createNewProfile(name) val originalJson = prefs.getString(profileId.value, null) @@ -257,7 +258,7 @@ class ProfileManagerTest { val exception = assertThrows(IllegalArgumentException::class.java) { - manager.renameProfile(fakeId, "New Name") + manager.renameProfile(fakeId, ProfileName.fromTrustedSource("New Name")) } assertTrue(exception.message!!.contains("not found")) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileNameTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileNameTest.kt new file mode 100644 index 000000000000..43aa7528d84c --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileNameTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.multiprofile + +import com.ichi2.anki.multiprofile.ProfileName.ValidationResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProfileNameTest { + @Test + fun `blank input returns Empty`() { + assertEquals(ValidationResult.Empty, ProfileName.validate("")) + } + + @Test + fun `whitespace-only input returns Empty`() { + assertEquals(ValidationResult.Empty, ProfileName.validate(" ")) + } + + @Test + fun `tabs and newlines only return Empty`() { + assertEquals(ValidationResult.Empty, ProfileName.validate("\t\n ")) + } + + @Test + fun `simple valid name returns Ok`() { + val result = ProfileName.validate("Mike") + assertTrue(result is ValidationResult.Valid) + assertEquals("Mike", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `name exactly at MAX_LENGTH is accepted`() { + val input = "a".repeat(ProfileName.MAX_LENGTH) + val result = ProfileName.validate(input) + assertTrue(result is ValidationResult.Valid) + assertEquals(input, (result as ValidationResult.Valid).name.value) + } + + @Test + fun `name one character over MAX_LENGTH returns TooLong`() { + val input = "a".repeat(ProfileName.MAX_LENGTH + 1) + val result = ProfileName.validate(input) + assertEquals(ValidationResult.TooLong(ProfileName.MAX_LENGTH + 1), result) + } + + @Test + fun `leading and trailing whitespace is trimmed`() { + val result = ProfileName.validate(" David ") + assertTrue(result is ValidationResult.Valid) + assertEquals("David", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `two-word name is trimmed at start and end`() { + val result = ProfileName.validate(" Ashish Yadav ") + assertTrue(result is ValidationResult.Valid) + assertEquals("Ashish Yadav", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `interior whitespace is preserved`() { + val result = ProfileName.validate("Ashish Yadav") + assertTrue(result is ValidationResult.Valid) + assertEquals("Ashish Yadav", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `only leading and trailing whitespace is removed for multi-word names`() { + val result = ProfileName.validate(" a b c ") + assertTrue(result is ValidationResult.Valid) + assertEquals("a b c", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `unicode letters are accepted`() { + val result = ProfileName.validate("日本語") + assertTrue(result is ValidationResult.Valid) + assertEquals("日本語", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `accented letters are accepted`() { + val result = ProfileName.validate("José") + assertTrue(result is ValidationResult.Valid) + assertEquals("José", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `digits hyphens and underscores are accepted`() { + val result = ProfileName.validate("user_1-profile") + assertTrue(result is ValidationResult.Valid) + assertEquals("user_1-profile", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `punctuation is accepted`() { + val result = ProfileName.validate("India!") + assertTrue(result is ValidationResult.Valid) + assertEquals("India!", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `mixed punctuation is accepted`() { + val result = ProfileName.validate("Bob!@#") + assertTrue(result is ValidationResult.Valid) + assertEquals("Bob!@#", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `repeated special characters are preserved`() { + val result = ProfileName.validate("a!!!b") + assertTrue(result is ValidationResult.Valid) + assertEquals("a!!!b", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `emoji is accepted`() { + val result = ProfileName.validate("Ashish 🎉") + assertTrue(result is ValidationResult.Valid) + assertEquals("Ashish 🎉", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `fromTrustedSource preserves value untouched`() { + val untouched = " weird!! value " + assertEquals(untouched, ProfileName.fromTrustedSource(untouched).value) + } +}