@@ -26,15 +26,14 @@ import android.webkit.WebView
2626import androidx.annotation.VisibleForTesting
2727import androidx.core.content.ContextCompat
2828import androidx.core.content.edit
29+ import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH
2930import com.ichi2.anki.IntentHandler
3031import com.ichi2.anki.common.crashreporting.CrashReportService
3132import com.ichi2.anki.common.time.TimeManager
3233import com.ichi2.anki.common.time.getTimestamp
33- import org.acra.ACRA
3434import org.json.JSONObject
3535import timber.log.Timber
3636import 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