Skip to content

Commit 4df4d66

Browse files
committed
feat: allow profile deletion
Introduce deleteProfile() and batch deleteProfiles() methods for removing user profiles. Deletion removes both the registry entry (SharedPreferences) and the on-disk data directory. The only guard is: the currently active profile cannot be deleted. The default profile can be deleted if the user has switched to another profile. Batch deletion validates all IDs upfront before touching anything, ensuring no partial deletes if one ID is invalid
1 parent 423346d commit 4df4d66

2 files changed

Lines changed: 157 additions & 0 deletions

File tree

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,48 @@ class ProfileManager private constructor(
229229
// TODO: Implement process restart logic (e.g. ProcessPhoenix)
230230
}
231231

232+
/**
233+
* Permanently deletes multiple profiles in a single batch.
234+
*
235+
* @param profileIds The profiles to delete.
236+
* @throws IllegalArgumentException if any ID is the
237+
* currently active profile.
238+
*/
239+
fun deleteProfiles(profileIds: List<ProfileId>) {
240+
val activeId = profileRegistry.getLastActiveProfileId()
241+
242+
for (id in profileIds) {
243+
require(id != activeId) {
244+
"Cannot delete the currently active profile (${id.value}). Switch first."
245+
}
246+
}
247+
248+
profileRegistry.removeProfiles(profileIds)
249+
250+
for (id in profileIds) {
251+
val profileDir = resolveProfileDirectory(id)
252+
if (profileDir.file.exists()) {
253+
val deleted = profileDir.file.deleteRecursively()
254+
if (!deleted) {
255+
Timber.w("Failed to fully delete profile directory: ${profileDir.file.absolutePath}")
256+
}
257+
}
258+
}
259+
260+
Timber.i("Deleted ${profileIds.size} profiles: ${profileIds.map { it.value }}")
261+
}
262+
263+
/**
264+
* Permanently deletes a single profile.
265+
*
266+
* @param profileId The [ProfileId] to delete.
267+
* @throws IllegalArgumentException if [profileId] is the
268+
* currently active profile.
269+
*/
270+
fun deleteProfile(profileId: ProfileId) {
271+
deleteProfiles(listOf(profileId))
272+
}
273+
232274
/**
233275
* Holds the meta-data for a profile.
234276
* Converted to JSON for storage to allow future extensibility (e.g. avatars, themes).
@@ -327,6 +369,20 @@ class ProfileManager private constructor(
327369
return result
328370
}
329371

372+
/**
373+
* Removes multiple profile entries from the registry in a
374+
* single SharedPreferences transaction.
375+
*
376+
* @param ids The [ProfileId]s to remove.
377+
*/
378+
fun removeProfiles(ids: List<ProfileId>) {
379+
globalPrefs.edit {
380+
for (id in ids) {
381+
remove(id.value)
382+
}
383+
}
384+
}
385+
330386
fun contains(id: ProfileId): Boolean = globalPrefs.contains(id.value)
331387
}
332388

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import io.mockk.unmockkAll
3636
import io.mockk.verify
3737
import org.junit.After
3838
import org.junit.Assert.assertEquals
39+
import org.junit.Assert.assertFalse
3940
import org.junit.Assert.assertTrue
4041
import org.junit.Before
4142
import org.junit.Test
@@ -200,4 +201,104 @@ class ProfileManagerTest {
200201
assertEquals(1, allProfiles.size)
201202
assertTrue(allProfiles.containsKey(ProfileId.DEFAULT))
202203
}
204+
205+
@Test
206+
fun `deleteProfile removes profile from registry`() {
207+
val manager = ProfileManager.create(context)
208+
val profileId = manager.createNewProfile("Temporary")
209+
210+
manager.deleteProfile(profileId)
211+
212+
val allProfiles = manager.getAllProfiles()
213+
assertFalse(allProfiles.containsKey(profileId))
214+
}
215+
216+
@Test
217+
fun `deleteProfile allows deleting default when not active`() {
218+
val manager = ProfileManager.create(context)
219+
val other = manager.createNewProfile("Other")
220+
221+
prefs.edit(commit = true) {
222+
putString(KEY_LAST_ACTIVE_PROFILE_ID, other.value)
223+
}
224+
225+
val freshManager = ProfileManager.create(context)
226+
freshManager.deleteProfile(ProfileId.DEFAULT)
227+
228+
assertFalse(freshManager.getAllProfiles().containsKey(ProfileId.DEFAULT))
229+
}
230+
231+
@Test(expected = IllegalArgumentException::class)
232+
fun `deleteProfile throws when deleting active profile`() {
233+
val manager = ProfileManager.create(context)
234+
val profileId = manager.createNewProfile("Active")
235+
236+
prefs.edit(commit = true) {
237+
putString(KEY_LAST_ACTIVE_PROFILE_ID, profileId.value)
238+
}
239+
240+
val freshManager = ProfileManager.create(context)
241+
freshManager.deleteProfile(profileId)
242+
}
243+
244+
@Test
245+
fun `deleteProfiles removes all specified profiles`() {
246+
val manager = ProfileManager.create(context)
247+
val profile1 = manager.createNewProfile("One")
248+
val profile2 = manager.createNewProfile("Two")
249+
val profile3 = manager.createNewProfile("Three")
250+
251+
manager.deleteProfiles(listOf(profile1, profile2, profile3))
252+
253+
val allProfiles = manager.getAllProfiles()
254+
assertEquals(1, allProfiles.size)
255+
assertTrue(allProfiles.containsKey(ProfileId.DEFAULT))
256+
}
257+
258+
@Test
259+
fun `deleteProfiles does not remove profiles outside the list`() {
260+
val manager = ProfileManager.create(context)
261+
val keep = manager.createNewProfile("Keep")
262+
val remove1 = manager.createNewProfile("Remove1")
263+
val remove2 = manager.createNewProfile("Remove2")
264+
265+
manager.deleteProfiles(listOf(remove1, remove2))
266+
267+
val allProfiles = manager.getAllProfiles()
268+
assertEquals(2, allProfiles.size)
269+
assertTrue(allProfiles.containsKey(ProfileId.DEFAULT))
270+
assertTrue(allProfiles.containsKey(keep))
271+
}
272+
273+
@Test(expected = IllegalArgumentException::class)
274+
fun `deleteProfiles throws if active profile is in the list`() {
275+
val manager = ProfileManager.create(context)
276+
val profile1 = manager.createNewProfile("One")
277+
val profile2 = manager.createNewProfile("Two")
278+
279+
prefs.edit(commit = true) {
280+
putString(KEY_LAST_ACTIVE_PROFILE_ID, profile1.value)
281+
}
282+
283+
val freshManager = ProfileManager.create(context)
284+
freshManager.deleteProfiles(listOf(profile1, profile2))
285+
}
286+
287+
@Test
288+
fun `deleteProfiles is atomic - nothing deleted if one ID is active`() {
289+
val manager = ProfileManager.create(context)
290+
val valid = manager.createNewProfile("Valid")
291+
292+
val before = manager.getAllProfiles().size
293+
294+
try {
295+
// default is active, so this should fail
296+
manager.deleteProfiles(listOf(valid, ProfileId.DEFAULT))
297+
} catch (_: IllegalArgumentException) {
298+
// expected
299+
}
300+
301+
assertEquals(before, manager.getAllProfiles().size)
302+
assertTrue(manager.getAllProfiles().containsKey(valid))
303+
}
203304
}

0 commit comments

Comments
 (0)