Skip to content

Commit a2b05be

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 691b693 commit a2b05be

2 files changed

Lines changed: 396 additions & 2 deletions

File tree

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

Lines changed: 189 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,160 @@ 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+
private fun resolveStoredCollectionDir(profileId: ProfileId): File? {
333+
val storedPath = readStoredCollectionPath(profileId)
334+
if (storedPath != null) return File(storedPath)
335+
336+
val externalFilesDir = appContext.getExternalFilesDir(null) ?: return null
337+
return if (profileId.isDefault()) {
338+
File(externalFilesDir, "AnkiDroid")
339+
} else {
340+
File(File(externalFilesDir, profileId.value), "AnkiDroid")
341+
}
342+
}
343+
344+
/**
345+
* Reads [PREF_COLLECTION_PATH] from the profile's namespaced default SharedPreferences
346+
* by filename, so we don't need to instantiate a [ProfileContextWrapper] (which has
347+
* mkdir side effects we don't want in the delete flow).
348+
*
349+
* The filename format must stay in sync with [ProfileContextWrapper.getSharedPreferences].
350+
*/
351+
private fun readStoredCollectionPath(profileId: ProfileId): String? {
352+
val defaultPrefsName = "${appContext.packageName}_preferences"
353+
val prefsName =
354+
if (profileId.isDefault()) defaultPrefsName else "profile_${profileId.value}_$defaultPrefsName"
355+
return appContext
356+
.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
357+
.getString(PREF_COLLECTION_PATH, null)
358+
}
359+
360+
/**
361+
* Deletes only known AnkiDroid collection artifacts inside [collectionDir].
362+
*
363+
* AnkiDroid lets the user point the collection path at any directory — including ones
364+
* they also use for unrelated data, e.g. `/Pictures/`. We therefore never
365+
* `deleteRecursively()` this directory. Files and subdirectories we don't recognize are
366+
* left untouched. The containing directory is removed only if it becomes empty.
367+
*/
368+
private fun deleteCollectionArtifactsSafely(collectionDir: File) {
369+
if (!collectionDir.exists() || !collectionDir.isDirectory) return
370+
371+
collectionDir.listFiles()?.forEach { entry ->
372+
when {
373+
entry.isFile && entry.name in COLLECTION_ARTIFACT_FILES -> entry.delete()
374+
entry.isDirectory && entry.name in COLLECTION_ARTIFACT_DIRS -> entry.deleteRecursively()
375+
else -> Timber.w("deleteProfile: leaving unknown entry untouched: %s", entry.absolutePath)
376+
}
377+
}
378+
379+
if (collectionDir.listFiles()?.isEmpty() == true) {
380+
collectionDir.delete()
381+
}
382+
}
383+
384+
/**
385+
* Wipes the Default profile's legacy data from the root directories
386+
* while protecting the subdirectories belonging to other profiles.
387+
*/
388+
private fun deleteDefaultProfileDataOnly(appDataRoot: File?) {
389+
if (appDataRoot == null) return
390+
391+
val defaultFolders =
392+
listOf(
393+
"app_webview",
394+
"databases",
395+
"files",
396+
"cache",
397+
"code_cache",
398+
"no_backup",
399+
)
400+
401+
defaultFolders.forEach { folderName ->
402+
val folder = File(appDataRoot, folderName)
403+
if (folder.exists()) {
404+
folder.deleteRecursively()
405+
}
406+
}
407+
408+
val sharedPrefsDir = File(appDataRoot, "shared_prefs")
409+
if (sharedPrefsDir.exists() && sharedPrefsDir.isDirectory) {
410+
sharedPrefsDir.listFiles()?.forEach { file ->
411+
val fileName = file.name
412+
val isRegistry = fileName == "$PROFILE_REGISTRY_FILENAME.xml"
413+
val isOtherProfile = fileName.startsWith("profile_")
414+
415+
if (!isRegistry && !isOtherProfile) {
416+
file.delete()
417+
}
418+
}
419+
}
420+
}
421+
269422
/**
270423
* Holds the meta-data for a profile.
271424
* Converted to JSON for storage to allow future extensibility (e.g. avatars, themes).
@@ -364,6 +517,18 @@ class ProfileManager private constructor(
364517
return result
365518
}
366519

520+
/**
521+
* Removes a profile entry from the registry.
522+
*
523+
* Does **not** delete the profile's data directory on disk.
524+
* Callers are responsible for cleaning up file-system resources.
525+
*
526+
* @param id The [ProfileId] of the profile to remove.
527+
*/
528+
fun removeProfile(id: ProfileId) {
529+
globalPrefs.edit { remove(id.value) }
530+
}
531+
367532
fun contains(id: ProfileId): Boolean = globalPrefs.contains(id.value)
368533
}
369534

@@ -385,6 +550,28 @@ class ProfileManager private constructor(
385550

386551
const val DEFAULT_PROFILE_DISPLAY_NAME = "Default"
387552

553+
/** Files AnkiDroid creates directly inside the collection directory. */
554+
private val COLLECTION_ARTIFACT_FILES =
555+
setOf(
556+
"collection.anki2",
557+
"collection.anki2-journal",
558+
"collection.anki2-wal",
559+
"collection.anki2-shm",
560+
"collection.media.db",
561+
"collection.media.db-journal",
562+
"collection.media.db-wal",
563+
"collection.media.db-shm",
564+
".nomedia",
565+
)
566+
567+
/** Subdirectories AnkiDroid creates inside the collection directory. */
568+
private val COLLECTION_ARTIFACT_DIRS =
569+
setOf(
570+
"collection.media",
571+
"backup",
572+
"broken",
573+
)
574+
388575
/**
389576
* Factory method to safely create and initialize the ProfileManager.
390577
* Guaranteed to return a ProfileManager with a valid [activeProfileContext].

0 commit comments

Comments
 (0)