Skip to content

Commit ef4d854

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 ef4d854

File tree

2 files changed

+191
-0
lines changed

2 files changed

+191
-0
lines changed

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

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

232+
/**
233+
* Permanently deletes a profile, removing its on-disk data and registry entry.
234+
*
235+
* @param profileId The [ProfileId] of the profile to delete.
236+
* @throws IllegalArgumentException if [profileId] is the currently active profile.
237+
*/
238+
fun deleteProfile(profileId: ProfileId) {
239+
require(profileRegistry.getLastActiveProfileId() != profileId) {
240+
"Cannot delete the currently active profile ($profileId). Switch first."
241+
}
242+
/*
243+
* SCOPE OF DELETION:
244+
* - Secondary Profiles: Deletes the profile-specific directory (e.g., .../files/[profileId]).
245+
* This includes the collection database, media, and profile-specific SharedPreferences.
246+
* - Default Profile: Deletes the standard app directories (files, databases, shared_prefs)
247+
* while explicitly preserving directories belonging to other profiles.
248+
*
249+
* BACKUPS & PRIVACY:
250+
* On Google Play builds, backups are stored within the app's internal scoped storage
251+
* (.../files/AnkiDroid/backups). Deletion WILL remove any backups nested within
252+
* the profile's file tree. This is irreversible.
253+
*
254+
* SAFETY & ORDERING:
255+
* The on-disk directory is removed BEFORE the registry entry. If the process is
256+
* interrupted, the registry entry remains so the user can retry. The active profile
257+
* cannot be deleted.
258+
*/
259+
260+
if (profileId.isDefault()) {
261+
deleteDefaultProfileDataOnly()
262+
} else {
263+
val profileDir = resolveProfileDirectory(profileId).file
264+
if (profileDir.exists()) {
265+
profileDir.deleteRecursively()
266+
}
267+
}
268+
269+
profileRegistry.removeProfile(profileId)
270+
}
271+
272+
private fun deleteDefaultProfileDataOnly() {
273+
val appDataRoot = ContextCompat.getDataDir(appContext) ?: return
274+
275+
val defaultFolders =
276+
listOf(
277+
"app_webview",
278+
"databases",
279+
"files",
280+
"cache",
281+
"code_cache",
282+
"no_backup",
283+
)
284+
285+
defaultFolders.forEach { folderName ->
286+
val folder = File(appDataRoot, folderName)
287+
if (folder.exists()) {
288+
folder.deleteRecursively()
289+
}
290+
}
291+
292+
// We can't delete the 'shared_prefs' folder because it contains the Registry.
293+
// We must only delete the XML files belonging to the Default profile.
294+
val sharedPrefsDir = File(appDataRoot, "shared_prefs")
295+
if (sharedPrefsDir.exists() && sharedPrefsDir.isDirectory) {
296+
sharedPrefsDir.listFiles()?.forEach { file ->
297+
val fileName = file.name
298+
// Skip the Registry and skip any file prefixed with "profile_"
299+
val isRegistry = fileName == "$PROFILE_REGISTRY_FILENAME.xml"
300+
val isOtherProfile = fileName.startsWith("profile_")
301+
302+
if (!isRegistry && !isOtherProfile) {
303+
file.delete()
304+
}
305+
}
306+
}
307+
}
308+
232309
/**
233310
* Holds the meta-data for a profile.
234311
* Converted to JSON for storage to allow future extensibility (e.g. avatars, themes).
@@ -327,6 +404,18 @@ class ProfileManager private constructor(
327404
return result
328405
}
329406

407+
/**
408+
* Removes a profile entry from the registry.
409+
*
410+
* Does **not** delete the profile's data directory on disk.
411+
* Callers are responsible for cleaning up file-system resources.
412+
*
413+
* @param id The [ProfileId] of the profile to remove.
414+
*/
415+
fun removeProfile(id: ProfileId) {
416+
globalPrefs.edit { remove(id.value) }
417+
}
418+
330419
fun contains(id: ProfileId): Boolean = globalPrefs.contains(id.value)
331420
}
332421

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

Lines changed: 102 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
@@ -46,6 +47,7 @@ import java.io.File
4647
@RunWith(AndroidJUnit4::class)
4748
class ProfileManagerTest {
4849
private lateinit var context: Context
50+
private val appDataRoot: File by lazy { context.filesDir.parentFile!! }
4951

5052
private val prefs: SharedPreferences
5153
get() = context.getSharedPreferences(PROFILE_REGISTRY_FILENAME, Context.MODE_PRIVATE)
@@ -200,4 +202,104 @@ class ProfileManagerTest {
200202
assertEquals(1, allProfiles.size)
201203
assertTrue(allProfiles.containsKey(ProfileId.DEFAULT))
202204
}
205+
206+
@Test
207+
fun `deleteProfile removes profile from registry`() {
208+
val manager = ProfileManager.create(context)
209+
val profileId = manager.createNewProfile("Temporary")
210+
211+
manager.deleteProfile(profileId)
212+
213+
val allProfiles = manager.getAllProfiles()
214+
assertFalse(allProfiles.containsKey(profileId))
215+
}
216+
217+
@Test
218+
fun `deleteProfile does not remove other profiles`() {
219+
val manager = ProfileManager.create(context)
220+
val keep = manager.createNewProfile("Keep")
221+
val remove = manager.createNewProfile("Remove")
222+
223+
manager.deleteProfile(remove)
224+
225+
val allProfiles = manager.getAllProfiles()
226+
assertEquals(2, allProfiles.size)
227+
assertTrue(allProfiles.containsKey(ProfileId.DEFAULT))
228+
assertTrue(allProfiles.containsKey(keep))
229+
}
230+
231+
@Test
232+
fun `deleteProfile allows deleting default when not active`() {
233+
val manager = ProfileManager.create(context)
234+
val other = manager.createNewProfile("Other")
235+
236+
prefs.edit(commit = true) {
237+
putString(KEY_LAST_ACTIVE_PROFILE_ID, other.value)
238+
}
239+
240+
val freshManager = ProfileManager.create(context)
241+
freshManager.deleteProfile(ProfileId.DEFAULT)
242+
243+
assertFalse(freshManager.getAllProfiles().containsKey(ProfileId.DEFAULT))
244+
}
245+
246+
@Test(expected = IllegalArgumentException::class)
247+
fun `deleteProfile throws when deleting active profile`() {
248+
val manager = ProfileManager.create(context)
249+
val profileId = manager.createNewProfile("Active")
250+
251+
prefs.edit(commit = true) {
252+
putString(KEY_LAST_ACTIVE_PROFILE_ID, profileId.value)
253+
}
254+
255+
val freshManager = ProfileManager.create(context)
256+
freshManager.deleteProfile(profileId)
257+
}
258+
259+
@Test
260+
fun `deleteProfile for Default profile wipes legacy root folders but preserves Alice and Bob`() {
261+
val manager = ProfileManager.create(context)
262+
263+
val aliceId = manager.createNewProfile("Alice")
264+
val aliceDir = File(appDataRoot, aliceId.value).apply { mkdirs() }
265+
val alicePref =
266+
File(appDataRoot, "shared_prefs/profile_${aliceId.value}.xml").apply {
267+
parentFile?.mkdirs()
268+
createNewFile()
269+
}
270+
271+
val defaultFile =
272+
File(context.filesDir, "legacy_default.txt").apply {
273+
parentFile?.mkdirs()
274+
writeText("Old data")
275+
}
276+
val defaultWebview = File(appDataRoot, "app_webview").apply { mkdirs() }
277+
val defaultPref =
278+
File(appDataRoot, "shared_prefs/com.ichi2.anki_preferences.xml").apply {
279+
createNewFile()
280+
}
281+
282+
manager.switchActiveProfile(aliceId)
283+
val managerWithAlice = ProfileManager.create(context)
284+
285+
managerWithAlice.deleteProfile(ProfileId.DEFAULT)
286+
287+
// Default data should be gone
288+
assertFalse("Default's filesDir content should be gone", defaultFile.exists())
289+
assertFalse("Default's webview folder should be gone", defaultWebview.exists())
290+
assertFalse("Default's specific SharedPreferences should be gone", defaultPref.exists())
291+
292+
// Alice and Global Registry MUST remain
293+
assertTrue("Alice's directory must not be touched", aliceDir.exists())
294+
assertTrue("Alice's SharedPreferences must not be touched", alicePref.exists())
295+
assertTrue(
296+
"The Profile Registry file itself must not be deleted",
297+
File(appDataRoot, "shared_prefs/$PROFILE_REGISTRY_FILENAME.xml").exists(),
298+
)
299+
300+
assertFalse(
301+
"Registry should no longer contain Default",
302+
managerWithAlice.getAllProfiles().containsKey(ProfileId.DEFAULT),
303+
)
304+
}
203305
}

0 commit comments

Comments
 (0)