Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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})")
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.

Prefer .toString on the class

return newProfileId
}

Expand Down Expand Up @@ -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()
Expand All @@ -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", ""),
)
Expand Down
79 changes: 79 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileName.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
)
Expand All @@ -177,18 +178,18 @@ 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()

assertEquals(3, allProfiles.size)
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
Expand All @@ -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"))
}
}
Loading
Loading