Skip to content

Commit 741dfd0

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 ff8e408 commit 741dfd0

2 files changed

Lines changed: 465 additions & 2 deletions

File tree

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

Lines changed: 236 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,14 @@ import android.webkit.WebView
2626
import androidx.annotation.VisibleForTesting
2727
import androidx.core.content.ContextCompat
2828
import androidx.core.content.edit
29+
import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH
2930
import com.ichi2.anki.IntentHandler
3031
import com.ichi2.anki.common.crashreporting.CrashReportService
3132
import com.ichi2.anki.common.time.TimeManager
3233
import com.ichi2.anki.common.time.getTimestamp
33-
import org.acra.ACRA
3434
import org.json.JSONObject
3535
import timber.log.Timber
3636
import java.io.File
37-
import java.util.UUID
3837

3938
/**
4039
* Manages the creation, loading, and switching of user profiles.
@@ -266,6 +265,206 @@ class ProfileManager private constructor(
266265
Timber.d("Renamed profile $profileId to '$newDisplayName'")
267266
}
268267

268+
/**
269+
* Permanently deletes a profile, removing its registry entry and associated data
270+
* across both internal and external storage.
271+
*
272+
* - Internal storage: app-private. We own these directories, so bulk deletion is safe.
273+
* For non-default profiles: the profile's data directory and every
274+
* `profile_<id>_*.xml` SharedPreferences file. For the default profile:
275+
* the legacy root folders (`files`, `cache`, `databases`, ...).
276+
* - Collection directory: user-visible under `Android/data/.../files/` and
277+
* relocatable by the user to any path via [PREF_COLLECTION_PATH] (e.g. `/Pictures/`).
278+
* We therefore resolve the stored path, and only delete known AnkiDroid artifacts
279+
* inside it - never `deleteRecursively()`.
280+
*/
281+
fun deleteProfile(profileId: ProfileId) {
282+
/*
283+
* File-system data is removed before the registry entry. If deletion fails
284+
* or is interrupted, the registry entry remains so the user can retry.
285+
*/
286+
require(profileRegistry.getLastActiveProfileId() != profileId) {
287+
"Cannot delete the currently active profile ($profileId). Switch first."
288+
}
289+
290+
val appDataRoot = ContextCompat.getDataDir(appContext)
291+
292+
if (profileId.isDefault()) {
293+
deleteDefaultProfileDataOnly(appDataRoot)
294+
} else {
295+
val profileDir = resolveProfileDirectory(profileId).file
296+
if (profileDir.exists()) {
297+
profileDir.deleteRecursively()
298+
}
299+
deleteNamespacedSharedPreferences(appDataRoot, profileId)
300+
}
301+
302+
resolveStoredCollectionDir(profileId)?.let { deleteCollectionArtifactsSafely(it) }
303+
304+
profileRegistry.removeProfile(profileId)
305+
Timber.i("Deleted profile: $profileId")
306+
}
307+
308+
/**
309+
* Deletes every `profile_<id>_*.xml` file inside the app's `shared_prefs` directory.
310+
* No-op if [appDataRoot] is null or the `shared_prefs` dir doesn't exist.
311+
*/
312+
private fun deleteNamespacedSharedPreferences(
313+
appDataRoot: File?,
314+
profileId: ProfileId,
315+
) {
316+
if (appDataRoot == null) return
317+
val sharedPrefsDir = File(appDataRoot, "shared_prefs")
318+
if (!sharedPrefsDir.exists() || !sharedPrefsDir.isDirectory) return
319+
320+
val prefix = "profile_${profileId.value}_"
321+
val deletedCount =
322+
sharedPrefsDir
323+
.listFiles()
324+
?.filter { it.name.startsWith(prefix) }
325+
?.count { it.delete() }
326+
?: 0
327+
Timber.d("deleteNamespacedSharedPreferences: removed %d prefs file(s) for %s", deletedCount, profileId)
328+
}
329+
330+
/**
331+
* Returns the collection directory for [profileId], honouring any user relocation via
332+
* [PREF_COLLECTION_PATH], and only if it currently exists on disk a non-null result
333+
* is guaranteed to refer to an existing directory. Falls back to the default location if
334+
* the preference is unset; returns null if neither path resolves to an existing directory
335+
*
336+
* Default profile: `<external>/AnkiDroid/` (the historical layout).
337+
* Non-default profile: `<external>/<profileId>/`.
338+
*/
339+
private fun resolveStoredCollectionDir(profileId: ProfileId): File? {
340+
val candidate: File =
341+
readStoredCollectionPath(profileId)?.let(::File)
342+
?: defaultCollectionDirFor(profileId)
343+
?: return null
344+
345+
return candidate.takeIf { it.exists() && it.isDirectory }
346+
}
347+
348+
/**
349+
* The default-location fallback used when the profile has never written `PREF_COLLECTION_PATH`.
350+
*
351+
* TODO: consolidate with the profile-creation path this should delegate to
352+
* `CollectionHelper.getDefaultAnkiDroidDirectory(appContext, directoryName = ...)`
353+
* that gives us legacy-storage handling and `SystemStorageException`-on-null for free, and keeps
354+
* the "where does a profile collection live" decision in a single place shared with
355+
* `ensureProfileCollectionPath`.
356+
*/
357+
private fun defaultCollectionDirFor(profileId: ProfileId): File? {
358+
val externalFilesDir = appContext.getExternalFilesDir(null) ?: return null
359+
return if (profileId.isDefault()) {
360+
File(externalFilesDir, "AnkiDroid")
361+
} else {
362+
File(externalFilesDir, profileId.value)
363+
}
364+
}
365+
366+
/**
367+
* Reads [PREF_COLLECTION_PATH] from the profile's namespaced default SharedPreferences
368+
* by filename, so we don't need to instantiate a [ProfileContextWrapper] (which has
369+
* mkdir side effects we don't want in the delete flow).
370+
*
371+
* The filename format must stay in sync with [ProfileContextWrapper.getSharedPreferences]
372+
* and with `deleteNamespacedSharedPreferences`'s `profile_<id>_` prefix.
373+
*
374+
* TODO: extract a `ProfilePreferences` accessor (e.g. `prefsForProfile(profileId).collectionPath`)
375+
*/
376+
private fun readStoredCollectionPath(profileId: ProfileId): String? {
377+
val defaultPrefsName = "${appContext.packageName}_preferences"
378+
val prefsName =
379+
if (profileId.isDefault()) defaultPrefsName else "profile_${profileId.value}_$defaultPrefsName"
380+
return appContext
381+
.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
382+
.getString(PREF_COLLECTION_PATH, null)
383+
}
384+
385+
/**
386+
* Deletes only known AnkiDroid collection artifacts inside [collectionDir].
387+
*
388+
* AnkiDroid lets the user point the collection path at any directory — including ones
389+
* they also use for unrelated data, e.g. `/Pictures/`. We therefore never
390+
* `deleteRecursively()` this directory. Files and subdirectories we don't recognize are
391+
* left untouched.
392+
*
393+
* If [collectionDir] is empty after the sweep, it is also removed. If anything remains,
394+
* we send a silent crash report
395+
*/
396+
private fun deleteCollectionArtifactsSafely(collectionDir: File) {
397+
if (!collectionDir.exists() || !collectionDir.isDirectory) return
398+
399+
collectionDir.listFiles()?.forEach { entry ->
400+
when {
401+
entry.isFile && entry.name in COLLECTION_ARTIFACT_FILES -> entry.delete()
402+
entry.isDirectory && entry.name in COLLECTION_ARTIFACT_DIRS -> entry.deleteRecursively()
403+
else -> {
404+
val entryType = if (entry.isDirectory) "Directory" else "File"
405+
Timber.w("deleteProfile: leaving unknown entry untouched: <$entryType>")
406+
}
407+
}
408+
}
409+
410+
val remaining = collectionDir.listFiles()
411+
if (remaining.isNullOrEmpty()) {
412+
collectionDir.delete()
413+
return
414+
}
415+
416+
val fileCount = remaining.count { it.isFile }
417+
val dirCount = remaining.count { it.isDirectory }
418+
419+
val message =
420+
"deleteCollectionArtifactsSafely left ${remaining.size} unknown entries in collection dir (Files: $fileCount, Dirs: $dirCount)"
421+
422+
val silent = IllegalStateException(message)
423+
CrashReportService.sendExceptionReport(
424+
silent,
425+
"ProfileManager::deleteCollectionArtifactsSafely",
426+
)
427+
Timber.w(silent, message)
428+
}
429+
430+
/**
431+
* Wipes the Default profile's legacy data from the root directories
432+
* while protecting the subdirectories belonging to other profiles.
433+
*/
434+
private fun deleteDefaultProfileDataOnly(appDataRoot: File?) {
435+
if (appDataRoot == null) return
436+
437+
val defaultFolders =
438+
listOf(
439+
"app_webview",
440+
"databases",
441+
"files",
442+
"cache",
443+
"code_cache",
444+
"no_backup",
445+
)
446+
447+
defaultFolders.forEach { folderName ->
448+
val folder = File(appDataRoot, folderName)
449+
if (folder.exists()) {
450+
folder.deleteRecursively()
451+
}
452+
}
453+
454+
val sharedPrefsDir = File(appDataRoot, "shared_prefs")
455+
if (sharedPrefsDir.exists() && sharedPrefsDir.isDirectory) {
456+
sharedPrefsDir.listFiles()?.forEach { file ->
457+
val fileName = file.name
458+
val isRegistry = fileName == "$PROFILE_REGISTRY_FILENAME.xml"
459+
val isOtherProfile = fileName.startsWith("profile_")
460+
461+
if (!isRegistry && !isOtherProfile) {
462+
file.delete()
463+
}
464+
}
465+
}
466+
}
467+
269468
/**
270469
* Holds the meta-data for a profile.
271470
* Converted to JSON for storage to allow future extensibility (e.g. avatars, themes).
@@ -364,6 +563,18 @@ class ProfileManager private constructor(
364563
return result
365564
}
366565

566+
/**
567+
* Removes a profile entry from the registry.
568+
*
569+
* Does **not** delete the profile's data directory on disk.
570+
* Callers are responsible for cleaning up file-system resources.
571+
*
572+
* @param id The [ProfileId] of the profile to remove.
573+
*/
574+
fun removeProfile(id: ProfileId) {
575+
globalPrefs.edit { remove(id.value) }
576+
}
577+
367578
fun contains(id: ProfileId): Boolean = globalPrefs.contains(id.value)
368579
}
369580

@@ -385,6 +596,29 @@ class ProfileManager private constructor(
385596

386597
const val DEFAULT_PROFILE_DISPLAY_NAME = "Default"
387598

599+
/** Files AnkiDroid creates directly inside the collection directory. */
600+
private val COLLECTION_ARTIFACT_FILES =
601+
setOf(
602+
"collection.anki2",
603+
"collection.anki2-journal",
604+
"collection.anki2-wal",
605+
"collection.anki2-shm",
606+
"collection.media.db",
607+
"collection.media.db-journal",
608+
"collection.media.db-wal",
609+
"collection.media.db-shm",
610+
".nomedia",
611+
)
612+
613+
/** Subdirectories AnkiDroid creates inside the collection directory. */
614+
private val COLLECTION_ARTIFACT_DIRS =
615+
setOf(
616+
"collection.media",
617+
"media.trash",
618+
"backup",
619+
"broken",
620+
)
621+
388622
/**
389623
* Factory method to safely create and initialize the ProfileManager.
390624
* Guaranteed to return a ProfileManager with a valid [activeProfileContext].

0 commit comments

Comments
 (0)