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..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 } @@ -232,19 +232,53 @@ 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: ProfileName, + ) { + Timber.d("ProfileManager::renameProfile called for $profileId") + + 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). */ 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() @@ -253,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 6adba877654e..b2b7f5a1e63b 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 @@ -162,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", ) @@ -177,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() @@ -186,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 @@ -200,4 +201,66 @@ class ProfileManagerTest { assertEquals(1, allProfiles.size) assertTrue(allProfiles.containsKey(ProfileId.DEFAULT)) } + + @Test + fun `renameProfile updates displayName in registry`() { + val manager = ProfileManager.create(context) + val profileId = manager.createNewProfile(ProfileName.fromTrustedSource("Original Name")) + val newName = ProfileName.fromTrustedSource("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(ProfileName.fromTrustedSource("Original Name")) + + val originalJson = prefs.getString(profileId.value, null) + val originalMetadata = ProfileManager.ProfileMetadata.fromJson(originalJson!!) + + manager.renameProfile(profileId, ProfileName.fromTrustedSource("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 = ProfileName.fromTrustedSource("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, 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) + } +}