@@ -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,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