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
This commit is contained in:
Nik Clayton 2024-09-26 13:51:30 +02:00 committed by GitHub
parent 91284ffad1
commit 85ab714ec1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 379 additions and 32 deletions

View File

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

View File

@ -102,7 +102,7 @@
errorLine2=" ^">
<location
file="src/main/java/app/pachli/components/compose/MediaUploader.kt"
line="388"
line="392"
column="28"/>
<location
file="${:core:activity*buildDir}/generated/res/resValues/blueFdroid/debug/values/gradleResValues.xml"
@ -716,6 +716,28 @@
column="43"/>
</issue>
<issue
id="Typos"
message="&quot;Media&quot; is a common misspelling; did you mean &quot;Medier&quot;?"
errorLine1=" &lt;string name=&quot;search_operator_attachment_all&quot;>Media ▾&lt;/string>"
errorLine2=" ^">
<location
file="src/main/res/values-nb-rNO/strings.xml"
line="736"
column="51"/>
</issue>
<issue
id="Typos"
message="&quot;media&quot; is a common misspelling; did you mean &quot;medier&quot;?"
errorLine1=" &lt;string name=&quot;search_operator_attachment_no_media_label&quot;>Ingen media&lt;/string>"
errorLine2=" ^">
<location
file="src/main/res/values-nb-rNO/strings.xml"
line="760"
column="68"/>
</issue>
<issue
id="ImpliedQuantity"
message="The quantity `&apos;one&apos;` matches more than one specific number in this locale (0, 1), but the message did not include a formatting argument (such as `%d`). This is usually an internationalization error. See full issue explanation for more."

View File

@ -26,10 +26,8 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.transition.Transition
import android.view.Menu
import android.view.MenuInflater
@ -50,6 +48,7 @@ import app.pachli.core.activity.extensions.startActivityWithDefaultTransition
import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.domain.DownloadUrlUseCase
import app.pachli.core.navigation.AttachmentViewData
import app.pachli.core.navigation.ViewMediaActivityIntent
import app.pachli.core.navigation.ViewThreadActivityIntent
@ -80,6 +79,9 @@ class ViewMediaActivity : BaseActivity(), MediaActionsListener {
@Inject
lateinit var okHttpClient: OkHttpClient
@Inject
lateinit var downloadUrlUseCase: DownloadUrlUseCase
private val viewModel: ViewMediaViewModel by viewModels()
private val binding by viewBinding(ActivityViewMediaBinding::inflate)
@ -224,13 +226,8 @@ class ViewMediaActivity : BaseActivity(), MediaActionsListener {
private fun downloadMedia() {
val url = imageUrl ?: attachmentViewData!![binding.viewPager.currentItem].attachment.url
val filename = Uri.parse(url).lastPathSegment
Toast.makeText(applicationContext, resources.getString(R.string.download_image, filename), Toast.LENGTH_SHORT).show()
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(Uri.parse(url))
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename)
downloadManager.enqueue(request)
Toast.makeText(applicationContext, resources.getString(R.string.download_image, url), Toast.LENGTH_SHORT).show()
downloadUrlUseCase(url)
}
private fun requestDownloadMedia() {

View File

@ -52,6 +52,7 @@ import app.pachli.core.designsystem.R as DR
import app.pachli.core.network.model.Notification
import app.pachli.core.preferences.AppTheme
import app.pachli.core.preferences.AppTheme.Companion.APP_THEME_DEFAULT
import app.pachli.core.preferences.DownloadLocation
import app.pachli.core.preferences.PrefKeys
import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.core.ui.extensions.await
@ -60,6 +61,7 @@ import app.pachli.databinding.AccountNotificationDetailsListItemBinding
import app.pachli.feature.about.asDdHhMmSs
import app.pachli.feature.about.instantFormatter
import app.pachli.settings.emojiPreference
import app.pachli.settings.enumListPreference
import app.pachli.settings.listPreference
import app.pachli.settings.makePreferenceScreen
import app.pachli.settings.preference
@ -302,6 +304,15 @@ class PreferencesFragment : PreferenceFragmentCompat() {
}
}
preferenceCategory(app.pachli.core.preferences.R.string.pref_category_downloads) {
enumListPreference<DownloadLocation> {
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)

View File

@ -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<StatusViewData>(), StatusActionLis
@Inject
lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository
@Inject
lateinit var downloadUrlUseCase: DownloadUrlUseCase
override val data: Flow<PagingData<StatusViewData>>
get() = viewModel.statusesFlow
@ -379,13 +381,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), 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)
}
}

View File

@ -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<T : IStatusViewData> : 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<T : IStatusViewData> : 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) {

View File

@ -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 <reified T> PreferenceParent.enumListPreference(
builder: EnumListPreference<T>.() -> Unit,
): EnumListPreference<T>
where T : Enum<T>,
T : PreferenceEnum {
val pref = EnumListPreference<T>(context)
builder(pref)
addPref(pref)
return pref
}
inline fun <A> PreferenceParent.emojiPreference(
activity: A,
builder: EmojiPickerPreference.() -> Unit,

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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)
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.5.2" type="baseline" client="gradle" dependencies="false" name="AGP (8.5.2)" variant="all" version="8.5.2">
</issues>

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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)
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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),
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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 <reified T : Enum<T>> from(s: String?): T? {
s ?: return null
return try {
enumValueOf<T>(s)
} catch (_: IllegalArgumentException) {
null
}
}
}
}

View File

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

View File

@ -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 <reified E : Enum<E>> SharedPreferences.getEnum(key: String, defValue: E): E {
val enumVal = getString(key, null) ?: return defValue
return PreferenceEnum.from<E>(enumVal) ?: defValue
}

View File

@ -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<String?>()
/** 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)
}
}

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <http://www.gnu.org/licenses>.
-->
<resources>
<string name="pref_category_downloads">Downloads</string>
<string name="pref_title_downloads">Download location</string>
<string name="download_location_downloads">Downloads folder</string>
<string name="download_location_per_account">Per-account folders, in Downloads folder</string>
</resources>

View File

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

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<T> @JvmOverloads constructor(
clazz: Class<T>,
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>,
T : PreferenceEnum {
init {
entries = clazz.enumConstants?.map { context.getString(it.displayResource) }?.toTypedArray<CharSequence>()
?: emptyArray()
entryValues = clazz.enumConstants?.map { it.value ?: it.name }?.toTypedArray<CharSequence>() ?: 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 <reified T> invoke(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle,
defStyleRes: Int = android.R.attr.dialogPreferenceStyle,
): EnumListPreference<T>
where T : Enum<T>,
T : PreferenceEnum = EnumListPreference(
T::class.java,
context,
attrs,
defStyleAttr,
defStyleRes,
)
}
}

View File

@ -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")