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