Skip to content

Commit 6fb5daa

Browse files
criticalAYmikehardy
authored andcommitted
feat: profile name validation
1 parent 5c375ec commit 6fb5daa

4 files changed

Lines changed: 244 additions & 23 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ class ProfileManager private constructor(
7676
val defaultId = ProfileId.DEFAULT
7777

7878
val metadata =
79-
ProfileMetadata(displayName = DEFAULT_PROFILE_DISPLAY_NAME)
79+
ProfileMetadata(displayName = ProfileName.fromTrustedSource(DEFAULT_PROFILE_DISPLAY_NAME))
8080

8181
profileRegistry.saveProfile(id = defaultId, metadata = metadata, isActive = true)
8282
profileRegistry.setLastActiveProfileId(defaultId)
@@ -96,14 +96,14 @@ class ProfileManager private constructor(
9696
*
9797
* @throws Exception if profile creation or persistence fails.
9898
*/
99-
fun createNewProfile(displayName: String): ProfileId {
99+
fun createNewProfile(displayName: ProfileName): ProfileId {
100100
val newProfileId = generateUniqueProfileId()
101101

102102
val metadata = ProfileMetadata(displayName = displayName)
103103

104104
profileRegistry.saveProfile(newProfileId, metadata)
105105

106-
Timber.i("Created new profile: $displayName (${newProfileId.value})")
106+
Timber.i("Created new profile: ${displayName.value} (${newProfileId.value})")
107107
return newProfileId
108108
}
109109

@@ -247,14 +247,10 @@ class ProfileManager private constructor(
247247
*/
248248
fun renameProfile(
249249
profileId: ProfileId,
250-
newDisplayName: String,
250+
newDisplayName: ProfileName,
251251
) {
252252
Timber.d("ProfileManager::renameProfile called for $profileId")
253253

254-
require(newDisplayName.isNotBlank()) {
255-
"Profile display name must not be blank"
256-
}
257-
258254
val existing =
259255
profileRegistry.getProfileMetadata(profileId)
260256
?: throw IllegalArgumentException("Profile $profileId not found")
@@ -275,14 +271,14 @@ class ProfileManager private constructor(
275271
* Converted to JSON for storage to allow future extensibility (e.g. avatars, themes).
276272
*/
277273
data class ProfileMetadata(
278-
val displayName: String,
274+
val displayName: ProfileName,
279275
val version: Int = 1,
280276
val createdTimestamp: String = getTimestamp(TimeManager.time),
281277
) {
282278
fun toJson(): String =
283279
JSONObject()
284280
.apply {
285-
put("displayName", displayName)
281+
put("displayName", displayName.value)
286282
put("version", version)
287283
put("created", createdTimestamp)
288284
}.toString()
@@ -291,7 +287,7 @@ class ProfileManager private constructor(
291287
fun fromJson(jsonString: String): ProfileMetadata {
292288
val json = JSONObject(jsonString)
293289
return ProfileMetadata(
294-
displayName = json.optString("displayName", "Unknown"),
290+
displayName = ProfileName.fromTrustedSource(json.optString("displayName", "Unknown")),
295291
version = json.optInt("version", 1),
296292
createdTimestamp = json.optString("created", ""),
297293
)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
* details.
13+
*
14+
* You should have received a copy of the GNU General Public License along with
15+
* this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.ichi2.anki.multiprofile
19+
20+
/**
21+
* A validated, user-facing profile display name.
22+
*
23+
* Values can only be obtained through [validate] (for user input) or
24+
* [fromTrustedSource] (for persisted / hard-coded values). This makes invalid
25+
* profile names unrepresentable in the domain layer.
26+
*/
27+
@JvmInline
28+
value class ProfileName private constructor(
29+
val value: String,
30+
) {
31+
override fun toString(): String = value
32+
33+
companion object {
34+
const val MAX_LENGTH = 50
35+
36+
/**
37+
* Validates raw user input. Trims leading and trailing whitespace
38+
* before checking the rules; interior whitespace (including multiple
39+
* spaces between words) is preserved as-is.
40+
*
41+
* Any non-whitespace character is permitted — including punctuation,
42+
* emoji, and symbols — matching the desktop Anki behavior. Only
43+
* length and emptiness are enforced here; uniqueness is checked at
44+
* the registry layer.
45+
*
46+
* Returns a [ValidationResult] — never throws.
47+
*/
48+
fun validate(raw: String): ValidationResult {
49+
val cleaned = raw.trim()
50+
return when {
51+
cleaned.isEmpty() -> ValidationResult.Empty
52+
cleaned.length > MAX_LENGTH ->
53+
ValidationResult.TooLong(cleaned.length)
54+
else -> ValidationResult.Valid(ProfileName(cleaned))
55+
}
56+
}
57+
58+
/**
59+
* Constructs a [ProfileName] from a value that has already been
60+
* validated elsewhere (persisted metadata, hard-coded defaults).
61+
*
62+
* DO NOT use this for raw user input — use [validate] instead.
63+
*/
64+
internal fun fromTrustedSource(value: String): ProfileName = ProfileName(value)
65+
}
66+
67+
/** Outcome of validating a candidate profile name. */
68+
sealed interface ValidationResult {
69+
data class Valid(
70+
val name: ProfileName,
71+
) : ValidationResult
72+
73+
data object Empty : ValidationResult
74+
75+
data class TooLong(
76+
val actualLength: Int,
77+
) : ValidationResult
78+
}
79+
}

AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ class ProfileManagerTest {
163163
fun `ProfileMetadata JSON round-trip preserves all fields`() {
164164
val original =
165165
ProfileManager.ProfileMetadata(
166-
displayName = "Test User",
166+
displayName = ProfileName.fromTrustedSource("Test User"),
167167
version = 5,
168168
createdTimestamp = "2025-12-31T23:59:59Z",
169169
)
@@ -178,18 +178,18 @@ class ProfileManagerTest {
178178
fun `getAllProfiles returns all registered profiles`() {
179179
val manager = ProfileManager.create(context)
180180

181-
val profile1 = manager.createNewProfile("Work")
182-
val profile2 = manager.createNewProfile("Personal")
181+
val profile1 = manager.createNewProfile(ProfileName.fromTrustedSource("Work"))
182+
val profile2 = manager.createNewProfile(ProfileName.fromTrustedSource("Personal"))
183183

184184
val allProfiles = manager.getAllProfiles()
185185

186186
assertEquals(3, allProfiles.size)
187187
assertTrue(allProfiles.containsKey(ProfileId.DEFAULT))
188188
assertTrue(allProfiles.containsKey(profile1))
189189
assertTrue(allProfiles.containsKey(profile2))
190-
assertEquals("Default", allProfiles[ProfileId.DEFAULT]?.displayName)
191-
assertEquals("Work", allProfiles[profile1]?.displayName)
192-
assertEquals("Personal", allProfiles[profile2]?.displayName)
190+
assertEquals("Default", allProfiles[ProfileId.DEFAULT]?.displayName?.value)
191+
assertEquals("Work", allProfiles[profile1]?.displayName?.value)
192+
assertEquals("Personal", allProfiles[profile2]?.displayName?.value)
193193
}
194194

195195
@Test
@@ -202,10 +202,11 @@ class ProfileManagerTest {
202202
assertTrue(allProfiles.containsKey(ProfileId.DEFAULT))
203203
}
204204

205+
@Test
205206
fun `renameProfile updates displayName in registry`() {
206207
val manager = ProfileManager.create(context)
207-
val profileId = manager.createNewProfile("Original Name")
208-
val newName = "Updated Name"
208+
val profileId = manager.createNewProfile(ProfileName.fromTrustedSource("Original Name"))
209+
val newName = ProfileName.fromTrustedSource("Updated Name")
209210

210211
manager.renameProfile(profileId, newName)
211212

@@ -218,12 +219,12 @@ class ProfileManagerTest {
218219
@Test
219220
fun `renameProfile preserves version and createdTimestamp`() {
220221
val manager = ProfileManager.create(context)
221-
val profileId = manager.createNewProfile("Original Name")
222+
val profileId = manager.createNewProfile(ProfileName.fromTrustedSource("Original Name"))
222223

223224
val originalJson = prefs.getString(profileId.value, null)
224225
val originalMetadata = ProfileManager.ProfileMetadata.fromJson(originalJson!!)
225226

226-
manager.renameProfile(profileId, "New Name")
227+
manager.renameProfile(profileId, ProfileName.fromTrustedSource("New Name"))
227228

228229
val updatedJson = prefs.getString(profileId.value, null)
229230
val updatedMetadata = ProfileManager.ProfileMetadata.fromJson(updatedJson!!)
@@ -239,7 +240,7 @@ class ProfileManagerTest {
239240
@Test
240241
fun `renameProfile does not write to disk if name is identical`() {
241242
val manager = ProfileManager.create(context)
242-
val name = "No Change"
243+
val name = ProfileName.fromTrustedSource("No Change")
243244
val profileId = manager.createNewProfile(name)
244245

245246
val originalJson = prefs.getString(profileId.value, null)
@@ -257,7 +258,7 @@ class ProfileManagerTest {
257258

258259
val exception =
259260
assertThrows(IllegalArgumentException::class.java) {
260-
manager.renameProfile(fakeId, "New Name")
261+
manager.renameProfile(fakeId, ProfileName.fromTrustedSource("New Name"))
261262
}
262263

263264
assertTrue(exception.message!!.contains("not found"))
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
* details.
13+
*
14+
* You should have received a copy of the GNU General Public License along with
15+
* this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.ichi2.anki.multiprofile
19+
20+
import com.ichi2.anki.multiprofile.ProfileName.ValidationResult
21+
import org.junit.Assert.assertEquals
22+
import org.junit.Assert.assertTrue
23+
import org.junit.Test
24+
25+
class ProfileNameTest {
26+
@Test
27+
fun `blank input returns Empty`() {
28+
assertEquals(ValidationResult.Empty, ProfileName.validate(""))
29+
}
30+
31+
@Test
32+
fun `whitespace-only input returns Empty`() {
33+
assertEquals(ValidationResult.Empty, ProfileName.validate(" "))
34+
}
35+
36+
@Test
37+
fun `tabs and newlines only return Empty`() {
38+
assertEquals(ValidationResult.Empty, ProfileName.validate("\t\n "))
39+
}
40+
41+
@Test
42+
fun `simple valid name returns Ok`() {
43+
val result = ProfileName.validate("Mike")
44+
assertTrue(result is ValidationResult.Valid)
45+
assertEquals("Mike", (result as ValidationResult.Valid).name.value)
46+
}
47+
48+
@Test
49+
fun `name exactly at MAX_LENGTH is accepted`() {
50+
val input = "a".repeat(ProfileName.MAX_LENGTH)
51+
val result = ProfileName.validate(input)
52+
assertTrue(result is ValidationResult.Valid)
53+
assertEquals(input, (result as ValidationResult.Valid).name.value)
54+
}
55+
56+
@Test
57+
fun `name one character over MAX_LENGTH returns TooLong`() {
58+
val input = "a".repeat(ProfileName.MAX_LENGTH + 1)
59+
val result = ProfileName.validate(input)
60+
assertEquals(ValidationResult.TooLong(ProfileName.MAX_LENGTH + 1), result)
61+
}
62+
63+
@Test
64+
fun `leading and trailing whitespace is trimmed`() {
65+
val result = ProfileName.validate(" David ")
66+
assertTrue(result is ValidationResult.Valid)
67+
assertEquals("David", (result as ValidationResult.Valid).name.value)
68+
}
69+
70+
@Test
71+
fun `two-word name is trimmed at start and end`() {
72+
val result = ProfileName.validate(" Ashish Yadav ")
73+
assertTrue(result is ValidationResult.Valid)
74+
assertEquals("Ashish Yadav", (result as ValidationResult.Valid).name.value)
75+
}
76+
77+
@Test
78+
fun `interior whitespace is preserved`() {
79+
val result = ProfileName.validate("Ashish Yadav")
80+
assertTrue(result is ValidationResult.Valid)
81+
assertEquals("Ashish Yadav", (result as ValidationResult.Valid).name.value)
82+
}
83+
84+
@Test
85+
fun `only leading and trailing whitespace is removed for multi-word names`() {
86+
val result = ProfileName.validate(" a b c ")
87+
assertTrue(result is ValidationResult.Valid)
88+
assertEquals("a b c", (result as ValidationResult.Valid).name.value)
89+
}
90+
91+
@Test
92+
fun `unicode letters are accepted`() {
93+
val result = ProfileName.validate("日本語")
94+
assertTrue(result is ValidationResult.Valid)
95+
assertEquals("日本語", (result as ValidationResult.Valid).name.value)
96+
}
97+
98+
@Test
99+
fun `accented letters are accepted`() {
100+
val result = ProfileName.validate("José")
101+
assertTrue(result is ValidationResult.Valid)
102+
assertEquals("José", (result as ValidationResult.Valid).name.value)
103+
}
104+
105+
@Test
106+
fun `digits hyphens and underscores are accepted`() {
107+
val result = ProfileName.validate("user_1-profile")
108+
assertTrue(result is ValidationResult.Valid)
109+
assertEquals("user_1-profile", (result as ValidationResult.Valid).name.value)
110+
}
111+
112+
@Test
113+
fun `punctuation is accepted`() {
114+
val result = ProfileName.validate("India!")
115+
assertTrue(result is ValidationResult.Valid)
116+
assertEquals("India!", (result as ValidationResult.Valid).name.value)
117+
}
118+
119+
@Test
120+
fun `mixed punctuation is accepted`() {
121+
val result = ProfileName.validate("Bob!@#")
122+
assertTrue(result is ValidationResult.Valid)
123+
assertEquals("Bob!@#", (result as ValidationResult.Valid).name.value)
124+
}
125+
126+
@Test
127+
fun `repeated special characters are preserved`() {
128+
val result = ProfileName.validate("a!!!b")
129+
assertTrue(result is ValidationResult.Valid)
130+
assertEquals("a!!!b", (result as ValidationResult.Valid).name.value)
131+
}
132+
133+
@Test
134+
fun `emoji is accepted`() {
135+
val result = ProfileName.validate("Ashish 🎉")
136+
assertTrue(result is ValidationResult.Valid)
137+
assertEquals("Ashish 🎉", (result as ValidationResult.Valid).name.value)
138+
}
139+
140+
@Test
141+
fun `fromTrustedSource preserves value untouched`() {
142+
val untouched = " weird!! value "
143+
assertEquals(untouched, ProfileName.fromTrustedSource(untouched).value)
144+
}
145+
}

0 commit comments

Comments
 (0)