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