Skip to content

Commit ebe616e

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 ebe616e

File tree

2 files changed

+210
-2
lines changed

2 files changed

+210
-2
lines changed

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

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,9 @@ import androidx.core.content.edit
2727
import com.ichi2.anki.CrashReportService
2828
import com.ichi2.anki.common.time.TimeManager
2929
import com.ichi2.anki.common.time.getTimestamp
30-
import org.acra.ACRA
3130
import org.json.JSONObject
3231
import timber.log.Timber
3332
import java.io.File
34-
import java.util.UUID
3533

3634
/**
3735
* Manages the creation, loading, and switching of user profiles.
@@ -229,6 +227,101 @@ class ProfileManager private constructor(
229227
// TODO: Implement process restart logic (e.g. ProcessPhoenix)
230228
}
231229

230+
/**
231+
* Permanently deletes a profile, removing its registry entry and associated data
232+
* across both internal and external storage.
233+
*
234+
*
235+
* - Internal Storage: Deletes private app data like WebView cookies,
236+
* internal databases, and namespaced SharedPreferences.
237+
* - External Storage (/storage/emulated/0/Android/data/.../files): Deletes the
238+
* AnkiDroid collection directory, media, and backups for this profile.
239+
*/
240+
fun deleteProfile(profileId: ProfileId) {
241+
/*
242+
* The file system data is removed before the registry entry. If deletion fails
243+
* or is interrupted, the registry entry remains so the user can retry.
244+
*/
245+
require(profileRegistry.getLastActiveProfileId() != profileId) {
246+
"Cannot delete the currently active profile ($profileId). Switch first."
247+
}
248+
249+
val appDataRoot = ContextCompat.getDataDir(appContext)
250+
251+
if (profileId.isDefault()) {
252+
deleteDefaultProfileDataOnly(appDataRoot)
253+
} else {
254+
val profileDir = resolveProfileDirectory(profileId).file
255+
if (profileDir.exists()) {
256+
profileDir.deleteRecursively()
257+
}
258+
259+
// Delete namespaced SharedPreferences (e.g., profile_p_alice_preferences.xml)
260+
if (appDataRoot != null) {
261+
val sharedPrefsDir = File(appDataRoot, "shared_prefs")
262+
if (sharedPrefsDir.exists() && sharedPrefsDir.isDirectory) {
263+
val prefix = "profile_${profileId.value}_"
264+
sharedPrefsDir.listFiles()?.forEach { file ->
265+
if (file.name.startsWith(prefix)) {
266+
file.delete()
267+
}
268+
}
269+
}
270+
}
271+
}
272+
273+
val externalFilesDir = appContext.getExternalFilesDir(null)
274+
if (externalFilesDir != null) {
275+
val collectionFolderName = if (profileId.isDefault()) "AnkiDroid" else profileId.value
276+
val collectionDir = File(externalFilesDir, collectionFolderName)
277+
278+
if (collectionDir.exists()) {
279+
collectionDir.deleteRecursively()
280+
}
281+
}
282+
283+
profileRegistry.removeProfile(profileId)
284+
Timber.i("Deleted profile: $profileId")
285+
}
286+
287+
/**
288+
* Wipes the Default profile's legacy data from the root directories
289+
* while protecting the subdirectories belonging to other profiles.
290+
*/
291+
private fun deleteDefaultProfileDataOnly(appDataRoot: File?) {
292+
if (appDataRoot == null) return
293+
294+
val defaultFolders =
295+
listOf(
296+
"app_webview",
297+
"databases",
298+
"files",
299+
"cache",
300+
"code_cache",
301+
"no_backup",
302+
)
303+
304+
defaultFolders.forEach { folderName ->
305+
val folder = File(appDataRoot, folderName)
306+
if (folder.exists()) {
307+
folder.deleteRecursively()
308+
}
309+
}
310+
311+
val sharedPrefsDir = File(appDataRoot, "shared_prefs")
312+
if (sharedPrefsDir.exists() && sharedPrefsDir.isDirectory) {
313+
sharedPrefsDir.listFiles()?.forEach { file ->
314+
val fileName = file.name
315+
val isRegistry = fileName == "$PROFILE_REGISTRY_FILENAME.xml"
316+
val isOtherProfile = fileName.startsWith("profile_")
317+
318+
if (!isRegistry && !isOtherProfile) {
319+
file.delete()
320+
}
321+
}
322+
}
323+
}
324+
232325
/**
233326
* Holds the meta-data for a profile.
234327
* Converted to JSON for storage to allow future extensibility (e.g. avatars, themes).
@@ -327,6 +420,18 @@ class ProfileManager private constructor(
327420
return result
328421
}
329422

423+
/**
424+
* Removes a profile entry from the registry.
425+
*
426+
* Does **not** delete the profile's data directory on disk.
427+
* Callers are responsible for cleaning up file-system resources.
428+
*
429+
* @param id The [ProfileId] of the profile to remove.
430+
*/
431+
fun removeProfile(id: ProfileId) {
432+
globalPrefs.edit { remove(id.value) }
433+
}
434+
330435
fun contains(id: ProfileId): Boolean = globalPrefs.contains(id.value)
331436
}
332437

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,19 @@ 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
4243
import org.junit.runner.RunWith
4344
import org.robolectric.annotation.Config
4445
import java.io.File
46+
import kotlin.test.assertNotEquals
4547

4648
@RunWith(AndroidJUnit4::class)
4749
class ProfileManagerTest {
4850
private lateinit var context: Context
51+
private val appDataRoot: File by lazy { context.filesDir.parentFile!! }
4952

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

0 commit comments

Comments
 (0)