Skip to content

Commit 943018f

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 91ad9ef commit 943018f

2 files changed

Lines changed: 450 additions & 2 deletions

File tree

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

Lines changed: 221 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,191 @@ 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+
sharedPrefsDir
322+
.listFiles()
323+
?.filter { it.name.startsWith(prefix) }
324+
?.forEach { it.delete() }
325+
}
326+
327+
/**
328+
* Returns the collection directory for [profileId], see any user relocation via
329+
* [PREF_COLLECTION_PATH]. Falls back to the default location if the preference is unset.
330+
* Returns null if no candidate directory can be resolved.
331+
*
332+
* Default profile: `<external>/AnkiDroid/` (the historical layout).
333+
* Non-default profile: `<external>/<profileId>/`
334+
*/
335+
private fun resolveStoredCollectionDir(profileId: ProfileId): File? {
336+
val candidate: File =
337+
readStoredCollectionPath(profileId)?.let(::File)
338+
?: defaultCollectionDirFor(profileId)
339+
?: return null
340+
341+
return candidate.takeIf { it.exists() && it.isDirectory }
342+
}
343+
344+
/** The default-location fallback used when the profile has never written `PREF_COLLECTION_PATH`. */
345+
private fun defaultCollectionDirFor(profileId: ProfileId): File? {
346+
val externalFilesDir = appContext.getExternalFilesDir(null) ?: return null
347+
return if (profileId.isDefault()) {
348+
File(externalFilesDir, "AnkiDroid")
349+
} else {
350+
File(externalFilesDir, profileId.value)
351+
}
352+
}
353+
354+
/**
355+
* Reads [PREF_COLLECTION_PATH] from the profile's namespaced default SharedPreferences
356+
* by filename, so we don't need to instantiate a [ProfileContextWrapper] (which has
357+
* mkdir side effects we don't want in the delete flow).
358+
*
359+
* The filename format must stay in sync with [ProfileContextWrapper.getSharedPreferences].
360+
*/
361+
private fun readStoredCollectionPath(profileId: ProfileId): String? {
362+
val defaultPrefsName = "${appContext.packageName}_preferences"
363+
val prefsName =
364+
if (profileId.isDefault()) defaultPrefsName else "profile_${profileId.value}_$defaultPrefsName"
365+
return appContext
366+
.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
367+
.getString(PREF_COLLECTION_PATH, null)
368+
}
369+
370+
/**
371+
* Deletes only known AnkiDroid collection artifacts inside [collectionDir].
372+
*
373+
* AnkiDroid lets the user point the collection path at any directory — including ones
374+
* they also use for unrelated data, e.g. `/Pictures/`. We therefore never
375+
* `deleteRecursively()` this directory. Files and subdirectories we don't recognize are
376+
* left untouched.
377+
*
378+
* If [collectionDir] is empty after the sweep, it is also removed. If anything remains,
379+
* we send a silent crash report
380+
*/
381+
private fun deleteCollectionArtifactsSafely(collectionDir: File) {
382+
if (!collectionDir.exists() || !collectionDir.isDirectory) return
383+
384+
collectionDir.listFiles()?.forEach { entry ->
385+
when {
386+
entry.isFile && entry.name in COLLECTION_ARTIFACT_FILES -> entry.delete()
387+
entry.isDirectory && entry.name in COLLECTION_ARTIFACT_DIRS -> entry.deleteRecursively()
388+
else -> {
389+
val entryType = if (entry.isDirectory) "Directory" else "File"
390+
Timber.w("deleteProfile: leaving unknown entry untouched: <$entryType>")
391+
}
392+
}
393+
}
394+
395+
val remaining = collectionDir.listFiles()
396+
if (remaining.isNullOrEmpty()) {
397+
collectionDir.delete()
398+
return
399+
}
400+
401+
val fileCount = remaining.count { it.isFile }
402+
val dirCount = remaining.count { it.isDirectory }
403+
404+
val message =
405+
"deleteCollectionArtifactsSafely left ${remaining.size} unknown entries in collection dir (Files: $fileCount, Dirs: $dirCount)"
406+
407+
val silent = IllegalStateException(message)
408+
CrashReportService.sendExceptionReport(
409+
silent,
410+
"ProfileManager::deleteCollectionArtifactsSafely",
411+
)
412+
Timber.w(silent, message)
413+
}
414+
415+
/**
416+
* Wipes the Default profile's legacy data from the root directories
417+
* while protecting the subdirectories belonging to other profiles.
418+
*/
419+
private fun deleteDefaultProfileDataOnly(appDataRoot: File?) {
420+
if (appDataRoot == null) return
421+
422+
val defaultFolders =
423+
listOf(
424+
"app_webview",
425+
"databases",
426+
"files",
427+
"cache",
428+
"code_cache",
429+
"no_backup",
430+
)
431+
432+
defaultFolders.forEach { folderName ->
433+
val folder = File(appDataRoot, folderName)
434+
if (folder.exists()) {
435+
folder.deleteRecursively()
436+
}
437+
}
438+
439+
val sharedPrefsDir = File(appDataRoot, "shared_prefs")
440+
if (sharedPrefsDir.exists() && sharedPrefsDir.isDirectory) {
441+
sharedPrefsDir.listFiles()?.forEach { file ->
442+
val fileName = file.name
443+
val isRegistry = fileName == "$PROFILE_REGISTRY_FILENAME.xml"
444+
val isOtherProfile = fileName.startsWith("profile_")
445+
446+
if (!isRegistry && !isOtherProfile) {
447+
file.delete()
448+
}
449+
}
450+
}
451+
}
452+
269453
/**
270454
* Holds the meta-data for a profile.
271455
* Converted to JSON for storage to allow future extensibility (e.g. avatars, themes).
@@ -364,6 +548,18 @@ class ProfileManager private constructor(
364548
return result
365549
}
366550

551+
/**
552+
* Removes a profile entry from the registry.
553+
*
554+
* Does **not** delete the profile's data directory on disk.
555+
* Callers are responsible for cleaning up file-system resources.
556+
*
557+
* @param id The [ProfileId] of the profile to remove.
558+
*/
559+
fun removeProfile(id: ProfileId) {
560+
globalPrefs.edit { remove(id.value) }
561+
}
562+
367563
fun contains(id: ProfileId): Boolean = globalPrefs.contains(id.value)
368564
}
369565

@@ -385,6 +581,29 @@ class ProfileManager private constructor(
385581

386582
const val DEFAULT_PROFILE_DISPLAY_NAME = "Default"
387583

584+
/** Files AnkiDroid creates directly inside the collection directory. */
585+
private val COLLECTION_ARTIFACT_FILES =
586+
setOf(
587+
"collection.anki2",
588+
"collection.anki2-journal",
589+
"collection.anki2-wal",
590+
"collection.anki2-shm",
591+
"collection.media.db",
592+
"collection.media.db-journal",
593+
"collection.media.db-wal",
594+
"collection.media.db-shm",
595+
".nomedia",
596+
)
597+
598+
/** Subdirectories AnkiDroid creates inside the collection directory. */
599+
private val COLLECTION_ARTIFACT_DIRS =
600+
setOf(
601+
"collection.media",
602+
"media.trash",
603+
"backup",
604+
"broken",
605+
)
606+
388607
/**
389608
* Factory method to safely create and initialize the ProfileManager.
390609
* Guaranteed to return a ProfileManager with a valid [activeProfileContext].

0 commit comments

Comments
 (0)