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