diff --git a/.github/workflows/test-debug-release.yml b/.github/workflows/test-debug-release.yml new file mode 100644 index 00000000..ac21c6ba --- /dev/null +++ b/.github/workflows/test-debug-release.yml @@ -0,0 +1,241 @@ +name: "Test Debug Release" + +on: + workflow_dispatch: + inputs: + architecture: + description: 'APK architecture to upload for manual test builds' + required: true + type: choice + options: + - arm64-v8a + - armeabi-v7a + - x86 + - x86_64 + - universal + default: universal + push: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-debug: + name: Build test debug APK + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: 'temurin' + java-version: '21' + cache: 'gradle' + verify-signature: true + + - name: Read latest dated version from CHANGELOG.md + id: changelog + shell: bash + run: | + set -euo pipefail + + if [[ ! -f CHANGELOG.md ]]; then + echo "::error::CHANGELOG.md not found" + exit 1 + fi + + # Expected format (this repo's changelog uses a blockquoted heading): + # > # 1.8.9.1 (2026-06) + CHANGELOG_LINE="$( + grep -m1 -E '^>[[:space:]]+#[[:space:]]+[^[:space:]]+[[:space:]]+\([0-9]{4}-[0-9]{2}\)[[:space:]]*$' CHANGELOG.md + )" || { + echo "::error::No dated version heading was found in CHANGELOG.md" + echo "::error::Expected: > # 1.8.9.1 (YYYY-MM)" + exit 1 + } + + VERSION="$(sed -E 's/^>[[:space:]]+#[[:space:]]+([^[:space:]]+).*$/\1/' <<< "$CHANGELOG_LINE")" + RELEASE_DATE="$(sed -E 's/^>[[:space:]]+#[[:space:]]+[^[:space:]]+[[:space:]]+\(([0-9]{4}-[0-9]{2})\).*$/\1/' <<< "$CHANGELOG_LINE")" + + # Extract the complete latest dated release section, excluding + # any older releases. Stops at the next top-level version heading. + RELEASE_SECTION="$( + awk -v heading="$CHANGELOG_LINE" ' + $0 == heading { found=1; print; next } + found && /^>[[:space:]]+#[[:space:]]+/ { exit } + found { print } + ' CHANGELOG.md + )" + + # Keep the values safe for filenames/tags. + SAFE_VERSION="$(tr -cd '[:alnum:]._-v' <<< "$VERSION")" + + if [[ -z "$SAFE_VERSION" || -z "$RELEASE_DATE" || -z "$RELEASE_SECTION" ]]; then + echo "::error::Could not extract the latest dated release section from CHANGELOG.md" + exit 1 + fi + + { + echo "version=$SAFE_VERSION" + echo "date=$RELEASE_DATE" + echo "heading=$CHANGELOG_LINE" + echo "section<> "$GITHUB_OUTPUT" + + echo "Latest dated changelog version: $SAFE_VERSION ($RELEASE_DATE)" + + - name: Generate debug keystore + run: | + if [[ -f app/debug.keystore ]]; then + echo "Using the committed app/debug.keystore — signature stays the same across runs." + else + echo "::warning::app/debug.keystore not found in the repo — generating a temporary one for this run only. Commit the keystore this workflow uploads as a build artifact to app/debug.keystore so future test builds share one signature and can be installed as updates instead of reinstalls." + keytool -genkeypair -v \ + -keystore app/debug.keystore \ + -storepass android \ + -keypass android \ + -alias androiddebugkey \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" + fi + + # app/build.gradle only defines signingConfigs.debug when this + # file exists and is non-empty — without it, the keystore above + # is never actually referenced and the build silently falls + # back to Gradle's own implicit debug signing config instead. + cat > keystore.properties <> "$GITHUB_OUTPUT" + echo "dir=dist" >> "$GITHUB_OUTPUT" + + SELECTED_APK="" + SELECTED_ABI="" + SELECTED_FLAVOR="" + + for APK_PATH in "${APK_PATHS[@]}"; do + # Flavor is the directory two levels above the APK file + # (.../apk//debug/.apk). + FLAVOR="$(basename "$(dirname "$(dirname "$APK_PATH")")")" + + # Pull the ABI out of the filename by matching a known ABI + # token instead of assuming the default "app--debug" + # naming — this project's APKs are named "YTDLnis-...". + ABI="$(basename "$APK_PATH" .apk | grep -oE 'arm64-v8a|armeabi-v7a|x86_64|x86|universal' || true)" + if [[ -z "$ABI" ]]; then + ABI="$(basename "$APK_PATH" .apk)" + fi + + if [[ "$ABI" == "$TARGET_ARCH" ]]; then + SELECTED_APK="$APK_PATH" + SELECTED_ABI="$ABI" + SELECTED_FLAVOR="$FLAVOR" + break + fi + done + + if [[ -z "$SELECTED_APK" ]]; then + echo "::error::No APK found for requested architecture: $TARGET_ARCH" + echo "::error::Produced APKs:" + printf ' %s\n' "${APK_PATHS[@]}" + exit 1 + fi + + OUTPUT_APK="dist/YTDLnis-debug-v${VERSION}-${RELEASE_DATE}-${SHORT_SHA}-${SELECTED_FLAVOR}-${SELECTED_ABI}.apk" + cp "$SELECTED_APK" "$OUTPUT_APK" + + echo "architecture=$SELECTED_ABI" >> "$GITHUB_OUTPUT" + echo "apk=$OUTPUT_APK" >> "$GITHUB_OUTPUT" + echo "Selected architecture: $SELECTED_ABI" + echo "Selected APK: $SELECTED_APK" + + - name: Create internal debug release notes + id: notes + shell: bash + env: + VERSION: ${{ steps.changelog.outputs.version }} + RELEASE_DATE: ${{ steps.changelog.outputs.date }} + CHANGELOG_HEADING: ${{ steps.changelog.outputs.heading }} + CHANGELOG_SECTION: ${{ steps.changelog.outputs.section }} + SHORT_SHA: ${{ steps.apk.outputs.short_sha }} + ARCHITECTURE: ${{ steps.apk.outputs.architecture }} + run: | + cat > debug-release-notes.md < + scenario.onActivity { activity -> + ThemeUtil.updateTheme(activity) + + assertThat(AppCompatDelegate.getDefaultNightMode()) + .isEqualTo(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + assertThat(activity.themeColor(MaterialR.attr.colorPrimary)) + .isEqualTo(Color.parseColor("#639eff")) + } + } + } + + @Test + fun updateTheme_automaticPresetUsesDarkPresetInNightMode() { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean("use_theme_presets", true) + .putBoolean("theme_preset_auto_mode", true) + .putString("theme_preset_light_id", ThemeUtil.ThemePreset.Classic.value) + .putString("theme_preset_dark_id", ThemeUtil.ThemePreset.Dark.value) + .commit() + + ActivityScenario.launch(ThemeUtilDarkTestActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + ThemeUtil.updateTheme(activity) + + assertThat(AppCompatDelegate.getDefaultNightMode()) + .isEqualTo(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + assertThat(activity.themeColor(MaterialR.attr.colorSurface)) + .isEqualTo(Color.parseColor("#131313")) + } + } + } + + @Test + fun updateTheme_darkConcretePresetEnablesNightModeAndAppliesPreset() { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean("use_theme_presets", true) + .putBoolean("theme_preset_auto_mode", false) + .putString("theme_preset_concrete_id", ThemeUtil.ThemePreset.Dark.value) + .commit() + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + + ActivityScenario.launch(ThemeUtilTestActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + ThemeUtil.updateTheme(activity) + + assertThat(AppCompatDelegate.getDefaultNightMode()) + .isEqualTo(AppCompatDelegate.MODE_NIGHT_YES) + assertThat(activity.themeColor(MaterialR.attr.colorSurface)) + .isEqualTo(Color.parseColor("#131313")) + } + } + } +} + +private fun Activity.themeColor(attribute: Int): Int { + val value = TypedValue() + theme.resolveAttribute(attribute, value, true) + return value.data +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..ab91d723 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/debug/java/com/deniscerri/ytdl/util/ThemeUtilDarkTestActivity.kt b/app/src/debug/java/com/deniscerri/ytdl/util/ThemeUtilDarkTestActivity.kt new file mode 100644 index 00000000..e3027a45 --- /dev/null +++ b/app/src/debug/java/com/deniscerri/ytdl/util/ThemeUtilDarkTestActivity.kt @@ -0,0 +1,15 @@ +package com.deniscerri.ytdl.util + +import android.app.Activity +import android.content.Context +import android.content.res.Configuration + +class ThemeUtilDarkTestActivity : Activity() { + override fun attachBaseContext(newBase: Context) { + val darkConfiguration = Configuration(newBase.resources.configuration).apply { + uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or + Configuration.UI_MODE_NIGHT_YES + } + super.attachBaseContext(newBase.createConfigurationContext(darkConfiguration)) + } +} diff --git a/app/src/debug/java/com/deniscerri/ytdl/util/ThemeUtilTestActivity.kt b/app/src/debug/java/com/deniscerri/ytdl/util/ThemeUtilTestActivity.kt new file mode 100644 index 00000000..daf73569 --- /dev/null +++ b/app/src/debug/java/com/deniscerri/ytdl/util/ThemeUtilTestActivity.kt @@ -0,0 +1,15 @@ +package com.deniscerri.ytdl.util + +import android.app.Activity +import android.content.Context +import android.content.res.Configuration + +class ThemeUtilTestActivity : Activity() { + override fun attachBaseContext(newBase: Context) { + val lightConfiguration = Configuration(newBase.resources.configuration).apply { + uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or + Configuration.UI_MODE_NIGHT_NO + } + super.attachBaseContext(newBase.createConfigurationContext(lightConfiguration)) + } +} diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/adapter/AccentAdapter.kt b/app/src/main/java/com/deniscerri/ytdl/ui/adapter/AccentAdapter.kt new file mode 100644 index 00000000..ff1468c1 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/ui/adapter/AccentAdapter.kt @@ -0,0 +1,71 @@ +package com.deniscerri.ytdl.ui.adapter + +import android.content.Context +import android.content.res.ColorStateList +import android.util.TypedValue +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView +import com.deniscerri.ytdl.R +import com.deniscerri.ytdl.databinding.ItemAccentBinding +import com.deniscerri.ytdl.ui.more.settings.SettingHost +import com.deniscerri.ytdl.util.ThemeUtil +import com.google.android.material.color.DynamicColors +import com.google.android.material.R as MaterialR + +class AccentAdapter(val host: SettingHost) : RecyclerView.Adapter() { + + class AccentViewHolder(val binding: ItemAccentBinding) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccentViewHolder { + val binding = ItemAccentBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return AccentViewHolder(binding) + } + + override fun getItemCount() = ThemeUtil.availableAccents.size + + override fun onBindViewHolder(holder: AccentViewHolder, position: Int) { + val accent = ThemeUtil.availableAccents[position] + val context = holder.binding.root.context + + // "Default" is Material You. Only wrap it with real dynamic color when + // the device actually supports it — wrapContextIfAvailable() returns the + // context unchanged (not BaseTheme) when it doesn't, which would preview + // whatever accent happens to currently be active instead of a neutral one. + val themedContext = when { + accent.value == "Default" && DynamicColors.isDynamicColorAvailable() -> + DynamicColors.wrapContextIfAvailable(context) + accent.value == "Default" -> + ContextThemeWrapper(context, R.style.BaseTheme) + else -> + ContextThemeWrapper(context, accent.styleResource) + } + + val primary = themedContext.colorFromAttr(MaterialR.attr.colorPrimary) + val onPrimary = themedContext.colorFromAttr(MaterialR.attr.colorOnPrimary) + + holder.binding.apply { + accentName.text = root.context.getString(accent.nameResource) + accentCard.setCardBackgroundColor(primary) + accentCard.strokeColor = onPrimary + accentName.setTextColor(onPrimary) + accentCard.rippleColor = ColorStateList.valueOf(onPrimary).withAlpha(40) + + root.setOnClickListener { + val preferences = PreferenceManager.getDefaultSharedPreferences(host.getHostContext()) + preferences.edit { putString("theme_accent", accent.value) } + ThemeUtil.updateThemes() + host.refreshUI() + } + } + } +} + +private fun Context.colorFromAttr(attr: Int): Int { + val value = TypedValue() + theme.resolveAttribute(attr, value, true) + return value.data +} diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/adapter/ThemePresetAdapter.kt b/app/src/main/java/com/deniscerri/ytdl/ui/adapter/ThemePresetAdapter.kt new file mode 100644 index 00000000..46adc812 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/ui/adapter/ThemePresetAdapter.kt @@ -0,0 +1,84 @@ +package com.deniscerri.ytdl.ui.adapter + +import android.content.Context +import android.content.res.ColorStateList +import android.util.TypedValue +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.deniscerri.ytdl.databinding.ItemThemePresetBinding +import com.deniscerri.ytdl.ui.more.settings.SettingHost +import com.deniscerri.ytdl.util.ThemeUtil +import com.google.android.material.R as MaterialR + +/** + * Grid adapter for the 12 ported theme presets. Two modes, mirroring the split + * BAI has between ThemeAdapter/ThemeSelectionDialogFragment's MODE_APPLY and + * MODE_CHOOSE: + * - [Mode.APPLY]: tapping a card saves it as the concrete preset immediately + * and recreates. Used by the top-level "Theme preset" picker. + * - [Mode.CHOOSE]: tapping a card just reports it back through [onChosen] + * instead of persisting anything. Used when this adapter is hosted inside + * the light/dark pairing dialog, which decides whether the pick belongs in + * the light slot or the dark slot. + * + * Each card previews its preset the same way [AccentAdapter] does: wrap the + * preset's style in a [ContextThemeWrapper] and read colorPrimary/colorOnPrimary + * back off it. + */ +class ThemePresetAdapter( + private val host: SettingHost?, + private val mode: Mode, + private val presets: List = ThemeUtil.availableThemePresets, + private val onChosen: ((ThemeUtil.ThemePreset) -> Unit)? = null +) : RecyclerView.Adapter() { + + enum class Mode { APPLY, CHOOSE } + + class ThemePresetViewHolder( + val binding: ItemThemePresetBinding + ) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThemePresetViewHolder { + val binding = ItemThemePresetBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ThemePresetViewHolder(binding) + } + + override fun getItemCount() = presets.size + + override fun onBindViewHolder(holder: ThemePresetViewHolder, position: Int) { + val preset = presets[position] + val context = holder.binding.root.context + val themedContext = ContextThemeWrapper(context, preset.styleResource) + + val primary = themedContext.colorFromAttr(MaterialR.attr.colorPrimary) + val onPrimary = themedContext.colorFromAttr(MaterialR.attr.colorOnPrimary) + + holder.binding.apply { + presetName.text = context.getString(preset.nameResource) + presetCard.setCardBackgroundColor(primary) + presetCard.strokeColor = onPrimary + presetName.setTextColor(onPrimary) + presetCard.rippleColor = ColorStateList.valueOf(onPrimary).withAlpha(40) + + root.setOnClickListener { + when (mode) { + Mode.APPLY -> { + val activity = host?.getHostContext() ?: return@setOnClickListener + ThemeUtil.setConcreteThemePreset(activity, preset) + ThemeUtil.updateThemes() + host.refreshUI() + } + Mode.CHOOSE -> onChosen?.invoke(preset) + } + } + } + } +} + +private fun Context.colorFromAttr(attr: Int): Int { + val value = TypedValue() + theme.resolveAttribute(attr, value, true) + return value.data +} diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/MoreFragment.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/MoreFragment.kt index 3d264db8..9936b5a9 100644 --- a/app/src/main/java/com/deniscerri/ytdl/ui/more/MoreFragment.kt +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/MoreFragment.kt @@ -2,7 +2,6 @@ package com.deniscerri.ytdl.ui.more import android.content.Intent import android.content.SharedPreferences -import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -24,6 +23,8 @@ import com.deniscerri.ytdl.database.viewmodel.DownloadViewModel import com.deniscerri.ytdl.ui.more.settings.SettingsActivity import com.deniscerri.ytdl.ui.more.terminal.TerminalActivity import com.deniscerri.ytdl.util.NavbarUtil +import com.deniscerri.ytdl.util.ThemeUtil +import com.google.android.material.color.DynamicColors import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.launch @@ -68,7 +69,10 @@ class MoreFragment : Fragment() { settings = view.findViewById(R.id.settings) val appIcon = view.findViewById(R.id.app_icon) - if (mainSharedPreferences.getString("theme_accent", "blue") == "Default" && Build.VERSION.SDK_INT >= 32) { + val usingMaterialYou = mainSharedPreferences.getString("theme_accent", "blue") == "Default" && + DynamicColors.isDynamicColorAvailable() && + !ThemeUtil.isThemePresetsEnabled(requireContext()) + if (usingMaterialYou) { appIcon.backgroundTintList = MaterialColors.getColorStateList(requireContext(), R.attr.colorPrimary, ContextCompat.getColorStateList(requireContext(), R.color.icon_fg)!!) } else { appIcon.backgroundTintList = null diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/general/GeneralSettingsModule.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/general/GeneralSettingsModule.kt index a34fa76f..472bba9c 100644 --- a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/general/GeneralSettingsModule.kt +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/general/GeneralSettingsModule.kt @@ -8,9 +8,12 @@ import android.content.pm.PackageManager import android.net.Uri import android.provider.Settings import android.util.DisplayMetrics +import android.util.TypedValue +import android.view.ContextThemeWrapper import android.view.ViewGroup import android.view.Window import android.view.WindowManager +import android.widget.TextView import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.edit import androidx.core.os.LocaleListCompat @@ -31,13 +34,16 @@ import androidx.work.WorkManager import com.deniscerri.ytdl.R import com.deniscerri.ytdl.database.viewmodel.ResultViewModel import com.deniscerri.ytdl.databinding.NavOptionsItemBinding +import com.deniscerri.ytdl.ui.adapter.AccentAdapter import com.deniscerri.ytdl.ui.adapter.IconsSheetAdapter import com.deniscerri.ytdl.ui.adapter.NavBarOptionsAdapter +import com.deniscerri.ytdl.ui.adapter.ThemePresetAdapter import com.deniscerri.ytdl.ui.more.settings.SettingHost import com.deniscerri.ytdl.ui.more.settings.SettingModule import com.deniscerri.ytdl.util.NavbarUtil import com.deniscerri.ytdl.util.ThemeUtil import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.card.MaterialCardView import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -176,6 +182,7 @@ object GeneralSettingsModule : SettingModule { } "ytdlnis_theme" -> { (pref as ListPreference).apply { + isVisible = !preferences.getBoolean("use_theme_presets", false) summary = entry setOnPreferenceChangeListener { _, newValue -> val dialog = MaterialAlertDialogBuilder(host.getHostContext()) @@ -236,17 +243,190 @@ object GeneralSettingsModule : SettingModule { } } "theme_accent" -> { - (pref as ListPreference).apply { - summary = entry - setOnPreferenceChangeListener { _, _ -> + pref.apply { + isVisible = !preferences.getBoolean("use_theme_presets", false) + + val currentValue = preferences.getString("theme_accent", "blue") + ThemeUtil.availableAccents.firstOrNull { it.value == currentValue }?.let { + summary = context.getString(it.nameResource) + } + + setOnPreferenceClickListener { + val bottomSheet = BottomSheetDialog(host.getHostContext()) + bottomSheet.requestWindowFeature(Window.FEATURE_NO_TITLE) + bottomSheet.setContentView(R.layout.generic_list) + + val recycler = bottomSheet.findViewById(R.id.download_recyclerview)!! + recycler.layoutManager = GridLayoutManager(context, 2) + recycler.adapter = AccentAdapter(host) + + bottomSheet.show() + val displayMetrics = DisplayMetrics() + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + wm.defaultDisplay.getMetrics(displayMetrics) + bottomSheet.behavior.peekHeight = displayMetrics.heightPixels + bottomSheet.window!!.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + false + } + } + } + "use_theme_presets" -> { + (pref as SwitchPreferenceCompat).apply { + setOnPreferenceChangeListener { _, newValue -> + val enabled = newValue as Boolean + val autoMode = preferences.getBoolean("theme_preset_auto_mode", false) + + host.findPref("theme_accent")?.isVisible = !enabled + host.findPref("ytdlnis_theme")?.isVisible = !enabled + host.findPref("high_contrast")?.isVisible = !enabled + host.findPref("theme_preset_auto_mode")?.isVisible = enabled + host.findPref("theme_preset_concrete")?.isVisible = enabled && !autoMode + host.findPref("theme_preset_light_dark")?.isVisible = enabled && autoMode + + preferences.edit(commit = true) { + putBoolean(pref.key, enabled) + } + ThemeUtil.updateThemes() + host.refreshUI() + true + } + } + } + "theme_preset_auto_mode" -> { + (pref as SwitchPreferenceCompat).apply { + isVisible = preferences.getBoolean("use_theme_presets", false) + + setOnPreferenceChangeListener { _, newValue -> + val autoMode = newValue as Boolean + host.findPref("theme_preset_concrete")?.isVisible = !autoMode + host.findPref("theme_preset_light_dark")?.isVisible = autoMode + + preferences.edit(commit = true) { + putBoolean(pref.key, autoMode) + } ThemeUtil.updateThemes() host.refreshUI() true } } } + "theme_preset_concrete" -> { + pref.apply { + isVisible = preferences.getBoolean("use_theme_presets", false) && + !preferences.getBoolean("theme_preset_auto_mode", false) + summary = context.getString(ThemeUtil.getConcreteThemePreset(context).nameResource) + + setOnPreferenceClickListener { + val bottomSheet = BottomSheetDialog(host.getHostContext()) + bottomSheet.requestWindowFeature(Window.FEATURE_NO_TITLE) + bottomSheet.setContentView(R.layout.generic_list) + + val recycler = bottomSheet.findViewById(R.id.download_recyclerview)!! + recycler.layoutManager = GridLayoutManager(context, 2) + recycler.adapter = ThemePresetAdapter(host, ThemePresetAdapter.Mode.APPLY) + + bottomSheet.show() + val displayMetrics = DisplayMetrics() + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + wm.defaultDisplay.getMetrics(displayMetrics) + bottomSheet.behavior.peekHeight = displayMetrics.heightPixels + bottomSheet.window!!.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + false + } + } + } + "theme_preset_light_dark" -> { + pref.apply { + isVisible = preferences.getBoolean("use_theme_presets", false) && + preferences.getBoolean("theme_preset_auto_mode", false) + + fun updateSummary() { + summary = "${context.getString(ThemeUtil.getLightThemePreset(context).nameResource)} / " + + context.getString(ThemeUtil.getDarkThemePreset(context).nameResource) + } + updateSummary() + + setOnPreferenceClickListener { + var stagedLight = ThemeUtil.getLightThemePreset(context) + var stagedDark = ThemeUtil.getDarkThemePreset(context) + + val binding = host.getHostContext().layoutInflater.inflate(R.layout.dialog_light_dark_preset, null) + val lightCard = binding.findViewById(R.id.lightSlotCard) + val lightName = binding.findViewById(R.id.lightSlotName) + val darkCard = binding.findViewById(R.id.darkSlotCard) + val darkName = binding.findViewById(R.id.darkSlotName) + + fun renderSlot(card: MaterialCardView, name: TextView, preset: ThemeUtil.ThemePreset) { + val themedContext = ContextThemeWrapper(context, preset.styleResource) + val value = TypedValue() + themedContext.theme.resolveAttribute(com.google.android.material.R.attr.colorPrimary, value, true) + val primary = value.data + themedContext.theme.resolveAttribute(com.google.android.material.R.attr.colorOnPrimary, value, true) + val onPrimary = value.data + + name.text = context.getString(preset.nameResource) + card.setCardBackgroundColor(primary) + card.strokeColor = onPrimary + name.setTextColor(onPrimary) + } + + renderSlot(lightCard, lightName, stagedLight) + renderSlot(darkCard, darkName, stagedDark) + + fun openPicker( + presets: List, + onPicked: (ThemeUtil.ThemePreset) -> Unit + ) { + val bottomSheet = BottomSheetDialog(host.getHostContext()) + bottomSheet.requestWindowFeature(Window.FEATURE_NO_TITLE) + bottomSheet.setContentView(R.layout.generic_list) + val recycler = bottomSheet.findViewById(R.id.download_recyclerview)!! + recycler.layoutManager = GridLayoutManager(context, 2) + recycler.adapter = ThemePresetAdapter(null, ThemePresetAdapter.Mode.CHOOSE, presets) { chosen -> + onPicked(chosen) + bottomSheet.dismiss() + } + bottomSheet.show() + } + + lightCard.setOnClickListener { + openPicker(ThemeUtil.availableThemePresets.filter { !it.isDark }) { chosen -> + stagedLight = chosen + renderSlot(lightCard, lightName, stagedLight) + } + } + darkCard.setOnClickListener { + openPicker(ThemeUtil.availableThemePresets.filter { it.isDark }) { chosen -> + stagedDark = chosen + renderSlot(darkCard, darkName, stagedDark) + } + } + + MaterialAlertDialogBuilder(host.getHostContext()) + .setTitle(R.string.theme_preset_choose_title) + .setView(binding) + .setPositiveButton(R.string.ok) { _, _ -> + ThemeUtil.setLightAndDarkThemePresets(context, stagedLight, stagedDark) + updateSummary() + ThemeUtil.updateThemes() + host.refreshUI() + } + .setNegativeButton(R.string.cancel, null) + .show() + true + } + } + } "high_contrast" -> { (pref as SwitchPreferenceCompat).apply { + isVisible = !preferences.getBoolean("use_theme_presets", false) + setOnPreferenceChangeListener { _, _ -> ThemeUtil.updateThemes() host.refreshUI() @@ -467,4 +647,4 @@ object GeneralSettingsModule : SettingModule { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/deniscerri/ytdl/util/ThemeUtil.kt b/app/src/main/java/com/deniscerri/ytdl/util/ThemeUtil.kt index 1aab8559..ba5639c0 100644 --- a/app/src/main/java/com/deniscerri/ytdl/util/ThemeUtil.kt +++ b/app/src/main/java/com/deniscerri/ytdl/util/ThemeUtil.kt @@ -5,12 +5,15 @@ import android.app.Application import android.content.ComponentName import android.content.Context import android.content.pm.PackageManager +import android.content.res.Configuration import android.os.Bundle import android.text.Spanned import android.util.TypedValue import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.annotation.StyleRes import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.edit import androidx.core.text.HtmlCompat import androidx.core.text.parseAsHtml import androidx.preference.PreferenceManager @@ -76,6 +79,147 @@ object ThemeUtil { AppIcon.Green, ) + /** + * One entry per "theme_accent" option. [value] is the exact string stored in + * SharedPreferences (must match res/values/arrays.xml's accents_values and the + * `when` branches in [updateTheme]). [styleResource] is only meaningful for the + * non-Default entries — Default is previewed/applied via DynamicColors instead, + * since it has no fixed style of its own. + */ + sealed class AccentOption( + @StringRes val nameResource: Int, + val value: String, + @StyleRes val styleResource: Int + ) { + object Default : AccentOption(R.string.material_you, "Default", R.style.BaseTheme) + object Blue : AccentOption(R.string.blue, "blue", R.style.Theme_Blue) + object Red : AccentOption(R.string.red, "red", R.style.Theme_Red) + object Green : AccentOption(R.string.green, "green", R.style.Theme_Green) + object Purple : AccentOption(R.string.purple, "purple", R.style.Theme_Purple) + object Yellow : AccentOption(R.string.yellow, "yellow", R.style.Theme_Yellow) + object Orange : AccentOption(R.string.orange, "orange", R.style.Theme_Orange) + object Monochrome : AccentOption(R.string.monochrome, "monochrome", R.style.Theme_Monochrome) + } + + val availableAccents = listOf( + AccentOption.Default, + AccentOption.Blue, + AccentOption.Red, + AccentOption.Green, + AccentOption.Purple, + AccentOption.Yellow, + AccentOption.Orange, + AccentOption.Monochrome, + ) + + /** + * Ported from BAI's Theme.Mode. CONCRETE = always use one fixed preset. + * AUTO_LIGHT_DARK = separately pick a light-mode preset and a dark-mode + * preset, and switch between them based on the system's current light/dark + * state — checked directly via Configuration rather than porting BAI's own + * pre-Android-10 detection/warning path, since ytdlnis's AppCompatDelegate + * usage elsewhere already handles that compatibility range. + */ + enum class ThemePresetMode { CONCRETE, AUTO_LIGHT_DARK } + + /** + * Ported from BAI's Theme.java ThemeDescriptor list. [isDark] reflects each + * preset's actual rendered luminance, not always BAI's own stored flag — + * see theme_presets.xml's header comment for the one entry ("Ukrrooter") + * where the two disagree. + */ + sealed class ThemePreset( + @StringRes val nameResource: Int, + val value: String, + @StyleRes val styleResource: Int, + val isDark: Boolean + ) { + object Classic : ThemePreset(R.string.theme_preset_classic, "classic", R.style.ThemePreset_Classic, false) + object Ruby : ThemePreset(R.string.theme_preset_ruby, "ruby", R.style.ThemePreset_Ruby, true) + object Rena : ThemePreset(R.string.theme_preset_rena, "rena", R.style.ThemePreset_Rena, true) + object Ukrrooter : ThemePreset(R.string.theme_preset_ukrrooter, "ukrrooter", R.style.ThemePreset_Ukrrooter, true) + object Amoled : ThemePreset(R.string.theme_preset_amoled, "amoled", R.style.ThemePreset_Amoled, true) + object Pixel : ThemePreset(R.string.theme_preset_pixel, "pixel", R.style.ThemePreset_Pixel, false) + object FDroidLight : ThemePreset(R.string.theme_preset_fdroid_light, "fdroid_light", R.style.ThemePreset_FDroidLight, false) + object Dark : ThemePreset(R.string.theme_preset_dark, "dark", R.style.ThemePreset_Dark, true) + object Gold : ThemePreset(R.string.theme_preset_gold, "gold", R.style.ThemePreset_Gold, true) + object RenaLight : ThemePreset(R.string.theme_preset_rena_light, "rena_light", R.style.ThemePreset_RenaLight, false) + object Mint : ThemePreset(R.string.theme_preset_mint, "mint", R.style.ThemePreset_Mint, true) + object FDroidDark : ThemePreset(R.string.theme_preset_fdroid_dark, "fdroid_dark", R.style.ThemePreset_FDroidDark, true) + } + + val availableThemePresets = listOf( + ThemePreset.Classic, + ThemePreset.Ruby, + ThemePreset.Rena, + ThemePreset.Ukrrooter, + ThemePreset.Amoled, + ThemePreset.Pixel, + ThemePreset.FDroidLight, + ThemePreset.Dark, + ThemePreset.Gold, + ThemePreset.RenaLight, + ThemePreset.Mint, + ThemePreset.FDroidDark, + ) + + fun isThemePresetsEnabled(context: Context): Boolean = + PreferenceManager.getDefaultSharedPreferences(context).getBoolean("use_theme_presets", false) + + fun getThemePresetMode(context: Context): ThemePresetMode { + val auto = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean("theme_preset_auto_mode", false) + return if (auto) ThemePresetMode.AUTO_LIGHT_DARK else ThemePresetMode.CONCRETE + } + + fun setThemePresetAutoMode(context: Context, auto: Boolean) { + PreferenceManager.getDefaultSharedPreferences(context).edit { putBoolean("theme_preset_auto_mode", auto) } + } + + private fun findPreset(value: String?, fallback: ThemePreset): ThemePreset = + availableThemePresets.firstOrNull { it.value == value } ?: fallback + + fun getConcreteThemePreset(context: Context): ThemePreset = findPreset( + PreferenceManager.getDefaultSharedPreferences(context).getString("theme_preset_concrete_id", null), + ThemePreset.Classic + ) + + fun setConcreteThemePreset(context: Context, preset: ThemePreset) { + PreferenceManager.getDefaultSharedPreferences(context).edit { putString("theme_preset_concrete_id", preset.value) } + } + + fun getLightThemePreset(context: Context): ThemePreset = findPreset( + PreferenceManager.getDefaultSharedPreferences(context).getString("theme_preset_light_id", null), + ThemePreset.Classic + ) + + fun getDarkThemePreset(context: Context): ThemePreset = findPreset( + PreferenceManager.getDefaultSharedPreferences(context).getString("theme_preset_dark_id", null), + ThemePreset.Dark + ) + + /** + * Writes both slots in a single Editor transaction so a process death + * mid-save can't persist one side of the pair without the other. + */ + fun setLightAndDarkThemePresets(context: Context, light: ThemePreset, dark: ThemePreset) { + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString("theme_preset_light_id", light.value) + putString("theme_preset_dark_id", dark.value) + } + } + + private fun applyThemePreset(activity: Activity) { + val preset = when (getThemePresetMode(activity)) { + ThemePresetMode.CONCRETE -> getConcreteThemePreset(activity) + ThemePresetMode.AUTO_LIGHT_DARK -> { + val nightMode = activity.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + if (nightMode == Configuration.UI_MODE_NIGHT_YES) getDarkThemePreset(activity) else getLightThemePreset(activity) + } + } + activity.setTheme(preset.styleResource) + } + fun recreateMain() { activities.firstOrNull { it.javaClass == MainActivity::class.java }?.recreate() } @@ -96,41 +240,53 @@ object ThemeUtil { fun updateTheme(activity: Activity) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity) - //update accent - when (sharedPreferences.getString("theme_accent","blue")) { - "Default" -> { - DynamicColors.applyToActivityIfAvailable(activity) - activity.setTheme(R.style.BaseTheme) - } - "blue" -> activity.setTheme(R.style.Theme_Blue) - "red" -> activity.setTheme(R.style.Theme_Red) - "green" -> activity.setTheme(R.style.Theme_Green) - "purple" -> activity.setTheme(R.style.Theme_Purple) - "yellow" -> activity.setTheme(R.style.Theme_Yellow) - "orange" -> activity.setTheme(R.style.Theme_Orange) - "monochrome" -> activity.setTheme(R.style.Theme_Monochrome) - } - - //high contrast theme - if (sharedPreferences.getBoolean("high_contrast",false)) { - activity.theme.applyStyle(R.style.Pure, true) - } - - val theme = sharedPreferences.getString("ytdlnis_theme", "System")!! - when (theme) { - "Light" -> { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + if (isThemePresetsEnabled(activity)) { + val nightMode = when (getThemePresetMode(activity)) { + ThemePresetMode.AUTO_LIGHT_DARK -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + ThemePresetMode.CONCRETE -> if (getConcreteThemePreset(activity).isDark) { + AppCompatDelegate.MODE_NIGHT_YES + } else { + AppCompatDelegate.MODE_NIGHT_NO + } } - "Dark" -> { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + AppCompatDelegate.setDefaultNightMode(nightMode) + applyThemePreset(activity) + } else { + //update accent + when (sharedPreferences.getString("theme_accent","blue")) { + "Default" -> { + activity.setTheme(R.style.BaseTheme) + DynamicColors.applyToActivityIfAvailable(activity) + } + "blue" -> activity.setTheme(R.style.Theme_Blue) + "red" -> activity.setTheme(R.style.Theme_Red) + "green" -> activity.setTheme(R.style.Theme_Green) + "purple" -> activity.setTheme(R.style.Theme_Purple) + "yellow" -> activity.setTheme(R.style.Theme_Yellow) + "orange" -> activity.setTheme(R.style.Theme_Orange) + "monochrome" -> activity.setTheme(R.style.Theme_Monochrome) } - // or "System" - else -> { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + + //high contrast theme + if (sharedPreferences.getBoolean("high_contrast",false)) { + activity.theme.applyStyle(R.style.Pure, true) } - } + when (sharedPreferences.getString("ytdlnis_theme", "System")) { + "Light" -> { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + } + "Dark" -> { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + } + // or "System" + else -> { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + } + } + } + val theme = sharedPreferences.getString("ytdlnis_theme", "System")!! val iconMode = sharedPreferences.getString("ytdlnis_icon", "Default")!! updateAppIcon(activity,theme, iconMode) } @@ -138,7 +294,7 @@ object ThemeUtil { fun getThemeColor(context: Context, colorCode: Int): Int { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) val accent = sharedPreferences.getString("theme_accent", "blue") - return if (accent == "blue"){ + return if (accent == "blue" && !isThemePresetsEnabled(context)){ "d43c3b".toInt(16) }else{ val value = TypedValue() @@ -217,4 +373,4 @@ object ThemeUtil { } } } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/dialog_light_dark_preset.xml b/app/src/main/res/layout/dialog_light_dark_preset.xml new file mode 100644 index 00000000..3c8741f9 --- /dev/null +++ b/app/src/main/res/layout/dialog_light_dark_preset.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/download_delay_preference_dialog.xml b/app/src/main/res/layout/download_delay_preference_dialog.xml index a099a758..7c5af7b6 100644 --- a/app/src/main/res/layout/download_delay_preference_dialog.xml +++ b/app/src/main/res/layout/download_delay_preference_dialog.xml @@ -38,7 +38,7 @@ android:layout_marginBottom="12dp" android:text="–" android:textAppearance="?attr/textAppearanceBodyLarge" - android:textColor="?attr/colorOnSurfaceVariant" /> + android:textColor="?attr/colorOnSurface" /> + + + + + diff --git a/app/src/main/res/layout/item_theme_preset.xml b/app/src/main/res/layout/item_theme_preset.xml new file mode 100644 index 00000000..9ae462f2 --- /dev/null +++ b/app/src/main/res/layout/item_theme_preset.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 74db48df..dcb845d4 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -18,6 +18,7 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 73a4c235..e11a10f2 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -16,6 +16,7 @@