From 85ab714ec15e42d6d03c37525031d35fc9bfefa9 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 26 Sep 2024 13:51:30 +0200 Subject: [PATCH] feat: Add option to save attachments to per-account folders (#945) The existing code downloaded any attachments to the user's "Downloads" folder. If the user is logged in with several accounts these downloads will be mixed up together. Fix this by adding a new preference that allows the user to specify the downloads should be placed in a sub-folder per account, named after the account. To do this: - Add an interface for enums that can be used as preferences, with properties for the string resource to display and the value to store. - Add `EnumListPreference`, a `ListPreference` that allows the user to choose between different enum values. - Add a `DownloadLocation` enum and preference key so the user can choose the location. - Add a `core.domain` module, with a use case for downloading URLs that respect's the user's download preference. Use this use-case everywhere that files are currently downloaded. Fixes #938 --- app/build.gradle.kts | 1 + app/lint-baseline.xml | 24 +++++- .../main/java/app/pachli/ViewMediaActivity.kt | 15 ++-- .../preference/PreferencesFragment.kt | 11 +++ .../fragments/SearchStatusesFragment.kt | 14 ++- .../java/app/pachli/fragment/SFragment.kt | 16 ++-- .../java/app/pachli/settings/SettingsDSL.kt | 13 +++ core/domain/build.gradle.kts | 34 ++++++++ core/domain/lint-baseline.xml | 4 + .../pachli/core/domain/DownloadUrlUseCase.kt | 68 +++++++++++++++ .../core/preferences/DownloadLocation.kt | 28 ++++++ .../pachli/core/preferences/PreferenceEnum.kt | 52 +++++++++++ .../core/preferences/SettingsConstants.kt | 2 + .../SharedPreferencesExtensions.kt | 11 +++ .../SharedPreferencesRepository.kt | 6 +- .../src/main/res/values/strings.xml | 23 +++++ core/ui/build.gradle.kts | 2 + .../app/pachli/core/ui/EnumListPreference.kt | 86 +++++++++++++++++++ settings.gradle.kts | 1 + 19 files changed, 379 insertions(+), 32 deletions(-) create mode 100644 core/domain/build.gradle.kts create mode 100644 core/domain/lint-baseline.xml create mode 100644 core/domain/src/main/kotlin/app/pachli/core/domain/DownloadUrlUseCase.kt create mode 100644 core/preferences/src/main/kotlin/app/pachli/core/preferences/DownloadLocation.kt create mode 100644 core/preferences/src/main/kotlin/app/pachli/core/preferences/PreferenceEnum.kt create mode 100644 core/preferences/src/main/res/values/strings.xml create mode 100644 core/ui/src/main/kotlin/app/pachli/core/ui/EnumListPreference.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 392c4c404..448f52c11 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,6 +127,7 @@ dependencies { implementation(projects.core.data) implementation(projects.core.database) implementation(projects.core.designsystem) + implementation(projects.core.domain) implementation(projects.core.model) implementation(projects.core.navigation) implementation(projects.core.network) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index d1da6ebd8..ea239b6d2 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -102,7 +102,7 @@ errorLine2=" ^"> + + + + + + + + { + setDefaultValue(DownloadLocation.DOWNLOADS) + setTitle(app.pachli.core.preferences.R.string.pref_title_downloads) + key = PrefKeys.DOWNLOAD_LOCATION + icon = makeIcon(GoogleMaterial.Icon.gmd_file_download) + } + } + preferenceCategory(R.string.pref_title_edit_notification_settings) { val method = notificationMethod(context, accountManager) diff --git a/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt index 72204a264..166c3a8b2 100644 --- a/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt @@ -17,7 +17,6 @@ package app.pachli.components.search.fragments import android.Manifest -import android.app.DownloadManager import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -25,7 +24,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build -import android.os.Environment import android.view.View import android.widget.Toast import androidx.appcompat.app.AlertDialog @@ -46,6 +44,7 @@ import app.pachli.core.activity.extensions.startActivityWithTransition import app.pachli.core.activity.openLink import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.database.model.AccountEntity +import app.pachli.core.domain.DownloadUrlUseCase import app.pachli.core.navigation.AttachmentViewData import app.pachli.core.navigation.ComposeActivityIntent import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions @@ -73,6 +72,9 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis @Inject lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository + @Inject + lateinit var downloadUrlUseCase: DownloadUrlUseCase + override val data: Flow> get() = viewModel.statusesFlow @@ -379,13 +381,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis private fun downloadAllMedia(status: Status) { Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show() for ((_, url) in status.attachments) { - val uri = Uri.parse(url) - val filename = uri.lastPathSegment - - val downloadManager = requireActivity().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - val request = DownloadManager.Request(uri) - request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename) - downloadManager.enqueue(request) + downloadUrlUseCase(url) } } diff --git a/app/src/main/java/app/pachli/fragment/SFragment.kt b/app/src/main/java/app/pachli/fragment/SFragment.kt index 3e60e14f6..20590d126 100644 --- a/app/src/main/java/app/pachli/fragment/SFragment.kt +++ b/app/src/main/java/app/pachli/fragment/SFragment.kt @@ -16,7 +16,6 @@ package app.pachli.fragment import android.Manifest -import android.app.DownloadManager import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -26,7 +25,6 @@ import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.Environment import android.view.MenuItem import android.view.View import android.widget.Toast @@ -50,6 +48,7 @@ import app.pachli.core.activity.openLink import app.pachli.core.data.repository.ServerRepository import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.TranslationState +import app.pachli.core.domain.DownloadUrlUseCase import app.pachli.core.navigation.AttachmentViewData import app.pachli.core.navigation.ComposeActivityIntent import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions @@ -93,6 +92,9 @@ abstract class SFragment : Fragment(), StatusActionListener @Inject lateinit var serverRepository: ServerRepository + @Inject + lateinit var downloadUrlUseCase: DownloadUrlUseCase + private var serverCanTranslate = false override fun startActivity(intent: Intent) { @@ -542,16 +544,8 @@ abstract class SFragment : Fragment(), StatusActionListener private fun downloadAllMedia(status: Status) { Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show() - val downloadManager = requireActivity().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - for ((_, url) in status.attachments) { - val uri = Uri.parse(url) - downloadManager.enqueue( - DownloadManager.Request(uri).apply { - setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, uri.lastPathSegment) - }, - ) - } + status.attachments.forEach { downloadUrlUseCase(it.url) } } private fun requestDownloadAllMedia(status: Status) { diff --git a/app/src/main/java/app/pachli/settings/SettingsDSL.kt b/app/src/main/java/app/pachli/settings/SettingsDSL.kt index 5ea8c073a..3ae4cd836 100644 --- a/app/src/main/java/app/pachli/settings/SettingsDSL.kt +++ b/app/src/main/java/app/pachli/settings/SettingsDSL.kt @@ -13,6 +13,8 @@ import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat +import app.pachli.core.preferences.PreferenceEnum +import app.pachli.core.ui.EnumListPreference import app.pachli.view.SliderPreference import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference @@ -35,6 +37,17 @@ inline fun PreferenceParent.listPreference(builder: ListPreference.() -> Unit): return pref } +inline fun PreferenceParent.enumListPreference( + builder: EnumListPreference.() -> Unit, +): EnumListPreference + where T : Enum, + T : PreferenceEnum { + val pref = EnumListPreference(context) + builder(pref) + addPref(pref) + return pref +} + inline fun PreferenceParent.emojiPreference( activity: A, builder: EmojiPickerPreference.() -> Unit, diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts new file mode 100644 index 000000000..a74cde6ae --- /dev/null +++ b/core/domain/build.gradle.kts @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +plugins { + alias(libs.plugins.pachli.android.library) + alias(libs.plugins.pachli.android.hilt) +} + +android { + namespace = "app.pachli.core.domain" + + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } +} + +dependencies { + implementation(projects.core.accounts) + implementation(projects.core.preferences) +} diff --git a/core/domain/lint-baseline.xml b/core/domain/lint-baseline.xml new file mode 100644 index 000000000..98cd24e06 --- /dev/null +++ b/core/domain/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/core/domain/src/main/kotlin/app/pachli/core/domain/DownloadUrlUseCase.kt b/core/domain/src/main/kotlin/app/pachli/core/domain/DownloadUrlUseCase.kt new file mode 100644 index 000000000..0079c012d --- /dev/null +++ b/core/domain/src/main/kotlin/app/pachli/core/domain/DownloadUrlUseCase.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.domain + +import android.app.DownloadManager +import android.content.Context +import android.net.Uri +import android.os.Environment +import app.pachli.core.accounts.AccountManager +import app.pachli.core.preferences.DownloadLocation +import app.pachli.core.preferences.SharedPreferencesRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject + +/** + * Downloads a URL respecting the user's preferences. + * + * @see [invoke] + */ +class DownloadUrlUseCase @Inject constructor( + @ApplicationContext val context: Context, + private val sharedPreferencesRepository: SharedPreferencesRepository, + private val accountManager: AccountManager, +) { + /** + * Enqueues a [DownloadManager] request to download [url]. + * + * The downloaded file is named after the URL's last path segment, and is + * either saved to the "Downloads" directory, or a subdirectory named after + * the user's account, depending on the app's preferences. + */ + operator fun invoke(url: String) { + val uri = Uri.parse(url) + val filename = uri.lastPathSegment ?: return + val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val request = DownloadManager.Request(uri) + + val locationPref = sharedPreferencesRepository.downloadLocation + + val path = when (locationPref) { + DownloadLocation.DOWNLOADS -> filename + DownloadLocation.DOWNLOADS_PER_ACCOUNT -> { + accountManager.activeAccount?.let { + File(it.fullName, filename).toString() + } ?: filename + } + } + + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, path) + downloadManager.enqueue(request) + } +} diff --git a/core/preferences/src/main/kotlin/app/pachli/core/preferences/DownloadLocation.kt b/core/preferences/src/main/kotlin/app/pachli/core/preferences/DownloadLocation.kt new file mode 100644 index 000000000..403a0381a --- /dev/null +++ b/core/preferences/src/main/kotlin/app/pachli/core/preferences/DownloadLocation.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.preferences + +/** Where to save downloaded files. */ +enum class DownloadLocation(override val displayResource: Int, override val value: String? = null) : + PreferenceEnum { + /** Save to the root of the "Downloads" directory. */ + DOWNLOADS(R.string.download_location_downloads), + + /** Save in per-account folders in the "Downloads" directory. */ + DOWNLOADS_PER_ACCOUNT(R.string.download_location_per_account), +} diff --git a/core/preferences/src/main/kotlin/app/pachli/core/preferences/PreferenceEnum.kt b/core/preferences/src/main/kotlin/app/pachli/core/preferences/PreferenceEnum.kt new file mode 100644 index 000000000..e289edae1 --- /dev/null +++ b/core/preferences/src/main/kotlin/app/pachli/core/preferences/PreferenceEnum.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.preferences + +import androidx.annotation.StringRes + +/** + * Interface for enums that can be saved/restored from [SharedPreferencesRepository]. + */ +interface PreferenceEnum { + /** String resource for the enum's value. */ + @get:StringRes + val displayResource: Int + + /** + * The value to persist in [SharedPreferencesRepository]. + * + * If null the enum's [name][Enum.name] property is used. + */ + val value: String? + + companion object { + /** + * @return The enum identified by [s], or null if the enum does not have [s] as + * a string representation. + */ + inline fun > from(s: String?): T? { + s ?: return null + + return try { + enumValueOf(s) + } catch (_: IllegalArgumentException) { + null + } + } + } +} diff --git a/core/preferences/src/main/kotlin/app/pachli/core/preferences/SettingsConstants.kt b/core/preferences/src/main/kotlin/app/pachli/core/preferences/SettingsConstants.kt index 87d0e11c4..6f498ccdb 100644 --- a/core/preferences/src/main/kotlin/app/pachli/core/preferences/SettingsConstants.kt +++ b/core/preferences/src/main/kotlin/app/pachli/core/preferences/SettingsConstants.kt @@ -137,6 +137,8 @@ object PrefKeys { */ const val USE_PREVIOUS_UNIFIED_PUSH_DISTRIBUTOR = "usePreviousUnifiedPushDistributor" + const val DOWNLOAD_LOCATION = "downloadLocation" + /** Keys that are no longer used (e.g., the preference has been removed */ object Deprecated { // Empty at this time diff --git a/core/preferences/src/main/kotlin/app/pachli/core/preferences/SharedPreferencesExtensions.kt b/core/preferences/src/main/kotlin/app/pachli/core/preferences/SharedPreferencesExtensions.kt index 58cb3cf1c..523ed4128 100644 --- a/core/preferences/src/main/kotlin/app/pachli/core/preferences/SharedPreferencesExtensions.kt +++ b/core/preferences/src/main/kotlin/app/pachli/core/preferences/SharedPreferencesExtensions.kt @@ -5,3 +5,14 @@ import android.content.SharedPreferences fun SharedPreferences.getNonNullString(key: String, defValue: String): String { return this.getString(key, defValue) ?: defValue } + +/** + * @return The enum for the preference at [key]. If there is no value for [key] + * in preferences, or the value can not be converted to [E], then [defValue] is + * returned. + */ +inline fun > SharedPreferences.getEnum(key: String, defValue: E): E { + val enumVal = getString(key, null) ?: return defValue + + return PreferenceEnum.from(enumVal) ?: defValue +} diff --git a/core/preferences/src/main/kotlin/app/pachli/core/preferences/SharedPreferencesRepository.kt b/core/preferences/src/main/kotlin/app/pachli/core/preferences/SharedPreferencesRepository.kt index 4e2405d91..d06e65298 100644 --- a/core/preferences/src/main/kotlin/app/pachli/core/preferences/SharedPreferencesRepository.kt +++ b/core/preferences/src/main/kotlin/app/pachli/core/preferences/SharedPreferencesRepository.kt @@ -25,7 +25,6 @@ import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch -import timber.log.Timber /** * An implementation of [SharedPreferences] that exposes all changes to the @@ -46,6 +45,10 @@ class SharedPreferencesRepository @Inject constructor( */ val changes = MutableSharedFlow() + /** Location of downloaded files. */ + val downloadLocation: DownloadLocation + get() = getEnum(PrefKeys.DOWNLOAD_LOCATION, DownloadLocation.DOWNLOADS) + // Ensure the listener is retained during minification. If you do not do this the // field is removed and eventually garbage collected (because registering it as a // change listener does not create a strong reference to it) and then no more @@ -57,7 +60,6 @@ class SharedPreferencesRepository @Inject constructor( } init { - Timber.d("Being created") sharedPreferences.registerOnSharedPreferenceChangeListener(listener) } } diff --git a/core/preferences/src/main/res/values/strings.xml b/core/preferences/src/main/res/values/strings.xml new file mode 100644 index 000000000..598ea61c6 --- /dev/null +++ b/core/preferences/src/main/res/values/strings.xml @@ -0,0 +1,23 @@ + + + + Downloads + Download location + Downloads folder + Per-account folders, in Downloads folder + diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 6c30d1e6e..1199500a3 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -34,6 +34,8 @@ dependencies { implementation(projects.core.activity) implementation(projects.core.common) implementation(projects.core.designsystem) + implementation(projects.core.preferences) + ?.because("PreferenceEnum types in EnumListPreference") // Uses HttpException from Retrofit implementation(projects.core.network) diff --git a/core/ui/src/main/kotlin/app/pachli/core/ui/EnumListPreference.kt b/core/ui/src/main/kotlin/app/pachli/core/ui/EnumListPreference.kt new file mode 100644 index 000000000..2ab447129 --- /dev/null +++ b/core/ui/src/main/kotlin/app/pachli/core/ui/EnumListPreference.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.ui + +import android.content.Context +import android.util.AttributeSet +import androidx.preference.ListPreference +import app.pachli.core.preferences.PreferenceEnum + +/** + * Displays the different enums in a [PreferenceEnum], allowing the user to choose one + * value. + * + * A [SummaryProvider][androidx.preference.Preference.SummaryProvider] is automatically + * set to show the chosen value. + */ +class EnumListPreference @JvmOverloads constructor( + clazz: Class, + private val context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle, + defStyleRes: Int = android.R.attr.dialogPreferenceStyle, +) : ListPreference(context, attrs, defStyleAttr, defStyleRes) + where T : Enum, + T : PreferenceEnum { + init { + entries = clazz.enumConstants?.map { context.getString(it.displayResource) }?.toTypedArray() + ?: emptyArray() + entryValues = clazz.enumConstants?.map { it.value ?: it.name }?.toTypedArray() ?: emptyArray() + setSummaryProvider { entry } + } + + @Deprecated( + "Do not use, call setDefaultValue with an enum", + replaceWith = ReplaceWith("setDefaultValue(defaultValue)"), + level = DeprecationLevel.ERROR, + ) + override fun setDefaultValue(defaultValue: Any?) { + throw IllegalStateException("Call setDefaultValue with an enum value") + } + + /** + * Sets the default value for this preference, which will be set either if persistence is off + * or persistence is on and the preference is not found in the persistent storage. + * + * @param defaultValue The default value + */ + fun setDefaultValue(defaultValue: T) { + super.setDefaultValue(defaultValue.value ?: defaultValue.name) + } + + companion object { + // Can't use reified types in a class constructor, but you can in inline + // functions, so this helper supplies the correct type for the constructor's + // first class parameter, making it more ergonomic to use this class. + inline operator fun invoke( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle, + defStyleRes: Int = android.R.attr.dialogPreferenceStyle, + ): EnumListPreference + where T : Enum, + T : PreferenceEnum = EnumListPreference( + T::class.java, + context, + attrs, + defStyleAttr, + defStyleRes, + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c0656694f..5b001a039 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -56,6 +56,7 @@ include(":core:common") include(":core:data") include(":core:database") include(":core:designsystem") +include(":core:domain") include(":core:model") include(":core:preferences") include(":core:navigation")