Skip to content

Commit 30a1d3f

Browse files
committed
feat: allow profile deletion
Introduce deleteProfile() for removing a single user profile. Deletion removes both the registry entry (SharedPreferences) and the on-disk data directory. The only guard is: the currently active profile cannot be deleted, all other profiles including default are deletable.
1 parent 423346d commit 30a1d3f

File tree

2 files changed

+109
-0
lines changed

2 files changed

+109
-0
lines changed

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

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

232+
/**
233+
* Permanently deletes a profile, removing both its on-disk data
234+
* directory and its registry entry.
235+
*
236+
* Scope of deletion is limited to the profile's private-storage
237+
* directory (under `/storage/emulated/0/Android/data/com.ichi2.anki/files/...`):
238+
* the collection database and any files the app writes inside that
239+
* directory. Backups live in a separate, user-accessible location
240+
* (`/storage/emulated/0/AnkiDroid/backups`) and are **not** touched
241+
* here — their lifecycle is managed by the collection-folder guard
242+
* elsewhere in the app. Deletion is irreversible.
243+
*
244+
* Ordering is important: the on-disk directory is removed **before**
245+
* the registry entry. If the process is killed mid-operation, the
246+
* registry entry survives, so the user can see the profile still
247+
* exists (possibly in a half-deleted state) and retry the deletion.
248+
* The reverse ordering would leak orphaned files on disk with no
249+
* registry reference to clean them up later.
250+
*
251+
* @param profileId The [ProfileId] of the profile to delete.
252+
* @throws IllegalArgumentException if [profileId] is the
253+
* currently active profile.
254+
*/
255+
fun deleteProfile(profileId: ProfileId) {
256+
require(profileRegistry.getLastActiveProfileId() != profileId) {
257+
"Cannot delete the currently active profile ($profileId). Switch first."
258+
}
259+
260+
val profileDir = resolveProfileDirectory(profileId)
261+
if (profileDir.file.exists()) {
262+
val deleted = profileDir.file.deleteRecursively()
263+
if (!deleted) {
264+
// Leave the registry entry intact so the user can retry.
265+
Timber.w("Failed to fully delete profile directory for $profileId; registry entry preserved for retry")
266+
return
267+
}
268+
}
269+
270+
profileRegistry.removeProfile(profileId)
271+
272+
Timber.i("Deleted profile: $profileId")
273+
}
274+
232275
/**
233276
* Holds the meta-data for a profile.
234277
* Converted to JSON for storage to allow future extensibility (e.g. avatars, themes).
@@ -327,6 +370,18 @@ class ProfileManager private constructor(
327370
return result
328371
}
329372

373+
/**
374+
* Removes a profile entry from the registry.
375+
*
376+
* Does **not** delete the profile's data directory on disk.
377+
* Callers are responsible for cleaning up file-system resources.
378+
*
379+
* @param id The [ProfileId] of the profile to remove.
380+
*/
381+
fun removeProfile(id: ProfileId) {
382+
globalPrefs.edit { remove(id.value) }
383+
}
384+
330385
fun contains(id: ProfileId): Boolean = globalPrefs.contains(id.value)
331386
}
332387

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

Lines changed: 54 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,57 @@ 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 does not remove other profiles`() {
218+
val manager = ProfileManager.create(context)
219+
val keep = manager.createNewProfile("Keep")
220+
val remove = manager.createNewProfile("Remove")
221+
222+
manager.deleteProfile(remove)
223+
224+
val allProfiles = manager.getAllProfiles()
225+
assertEquals(2, allProfiles.size)
226+
assertTrue(allProfiles.containsKey(ProfileId.DEFAULT))
227+
assertTrue(allProfiles.containsKey(keep))
228+
}
229+
230+
@Test
231+
fun `deleteProfile allows deleting default when not active`() {
232+
val manager = ProfileManager.create(context)
233+
val other = manager.createNewProfile("Other")
234+
235+
prefs.edit(commit = true) {
236+
putString(KEY_LAST_ACTIVE_PROFILE_ID, other.value)
237+
}
238+
239+
val freshManager = ProfileManager.create(context)
240+
freshManager.deleteProfile(ProfileId.DEFAULT)
241+
242+
assertFalse(freshManager.getAllProfiles().containsKey(ProfileId.DEFAULT))
243+
}
244+
245+
@Test(expected = IllegalArgumentException::class)
246+
fun `deleteProfile throws when deleting active profile`() {
247+
val manager = ProfileManager.create(context)
248+
val profileId = manager.createNewProfile("Active")
249+
250+
prefs.edit(commit = true) {
251+
putString(KEY_LAST_ACTIVE_PROFILE_ID, profileId.value)
252+
}
253+
254+
val freshManager = ProfileManager.create(context)
255+
freshManager.deleteProfile(profileId)
256+
}
203257
}

0 commit comments

Comments
 (0)