Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,43 @@ class AppSettings internal constructor(
val colorScheme: ColorScheme
get() = prefs.getEnumValue(KEY_COLOR_THEME, ColorScheme.default)

/** Parsed imported themes, cached in memory and invalidated on write. */
val importedThemes: List<ImportedTheme>
get() = cachedImportedThemes ?: ImportedThemeFile
.parseStored(prefs.getString(KEY_CUSTOM_THEMES, "{}") ?: "{}")
.also { cachedImportedThemes = it }

var selectedCustomThemeId: String?
get() = prefs.getString(KEY_CUSTOM_THEME, null)
set(value) = prefs.edit { putString(KEY_CUSTOM_THEME, value) }

val selectedCustomTheme: ImportedTheme?
get() = importedThemes.firstOrNull { it.id == selectedCustomThemeId }

fun importCustomThemes(source: String): List<ImportedTheme> {
val imported = ImportedThemeFile.parseImport(source)
val stored = prefs.getString(KEY_CUSTOM_THEMES, "{}") ?: "{}"
prefs.edit { putString(KEY_CUSTOM_THEMES, ImportedThemeFile.mergeStored(stored, imported)) }
cachedImportedThemes = null
return imported
}

fun deleteCustomTheme(id: String) {
val themes = importedThemes.filterNot { it.id == id }
prefs.edit {
putString(KEY_CUSTOM_THEMES, ImportedThemeFile.mergeStored("{}", themes))
if (selectedCustomThemeId == id) {
val nextThemeId = themes.firstOrNull()?.id
if (nextThemeId != null) {
putString(KEY_CUSTOM_THEME, nextThemeId)
} else {
remove(KEY_CUSTOM_THEME)
}
}
}
cachedImportedThemes = null
}

val isAmoledTheme: Boolean
get() = prefs.getBoolean(KEY_THEME_AMOLED, false)

Expand Down Expand Up @@ -762,6 +799,9 @@ class AppSettings internal constructor(

companion object {

@Volatile
private var cachedImportedThemes: List<ImportedTheme>? = null

const val TRACK_HISTORY = "history"
const val TRACK_FAVOURITES = "favourites"

Expand All @@ -773,6 +813,8 @@ class AppSettings internal constructor(
const val KEY_THEME = "theme"
const val KEY_COLOR_THEME = "color_theme"
const val KEY_THEME_AMOLED = "amoled_theme"
const val KEY_CUSTOM_THEMES = "custom_themes"
const val KEY_CUSTOM_THEME = "custom_theme"
const val KEY_OFFLINE_DISABLED = "no_offline"
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ enum class ColorScheme(
MAMIMI(R.style.ThemeOverlay_Futon_Mamimi, R.string.theme_name_mamimi),
KANADE(R.style.ThemeOverlay_Futon_Kanade, R.string.theme_name_kanade),
ITSUKA(R.style.ThemeOverlay_Futon_Itsuka, R.string.theme_name_itsuka),
CUSTOM(R.style.ThemeOverlay_Futon_Custom_Light, R.string.theme_name_custom),
;

companion object {
Expand All @@ -35,8 +36,11 @@ enum class ColorScheme(
DEFAULT
}

fun getAvailableList(): List<ColorScheme> {
fun getAvailableList(includeCustom: Boolean = false): List<ColorScheme> {
val list = ColorScheme.entries.toMutableList()
if (!includeCustom || !DynamicColors.isDynamicColorAvailable()) {
list.remove(CUSTOM)
}
if (!DynamicColors.isDynamicColorAvailable()) {
list.remove(MONET)
list.remove(EXPRESSIVE)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package io.github.landwarderer.futon.core.prefs

import org.json.JSONObject
import java.util.Locale

/** A validated Idle Fantasy-compatible theme with its original definition retained for storage. */
data class ImportedTheme(
val id: String,
val displayName: String,
val isDark: Boolean,
val primaryColor: Int,
val onPrimaryColor: Int?,
val json: String,
)

object ImportedThemeFile {
const val MAX_FILE_SIZE = 256 * 1024
private val ID_SANITIZATION_REGEX = Regex("[^\\p{L}\\p{N}]+")
private val HEX_COLOR_REGEX = Regex("^(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")

fun parseImport(source: String): List<ImportedTheme> {
require(source.length <= MAX_FILE_SIZE) { "Theme file is too large" }
return parse(JSONObject(source), requireNonEmpty = true)
}

fun parseStored(source: String): List<ImportedTheme> = runCatching {
parse(JSONObject(source), requireNonEmpty = false)
}.getOrDefault(emptyList())

fun mergeStored(current: String, imported: List<ImportedTheme>): String {
val root = runCatching { JSONObject(current) }.getOrElse { JSONObject() }
for (theme in imported) {
val obj = JSONObject(theme.json)
obj.put("display_name", theme.displayName)
root.put(theme.id, obj)
}
return root.toString()
}

private fun parse(root: JSONObject, requireNonEmpty: Boolean): List<ImportedTheme> {
val result = ArrayList<ImportedTheme>(root.length())
val keys = root.keys()
while (keys.hasNext()) {
val sourceName = keys.next()
val theme = root.optJSONObject(sourceName) ?: continue
val id = sourceName.trim().lowercase(Locale.ROOT)
.replace(ID_SANITIZATION_REGEX, "_")
.trim('_')
require(id.isNotBlank() && id.length <= 80) { "Theme name is missing or too long" }

val base = theme.optString("base").lowercase(Locale.ROOT)
require(base == "light" || base == "dark") { "Theme '$sourceName' has an unsupported base" }
val colours = theme.optJSONObject("colours") ?: throw IllegalArgumentException("Theme '$sourceName' has no colours")
val scheme = theme.optJSONObject("colour_scheme") ?: throw IllegalArgumentException("Theme '$sourceName' has no colour_scheme")
require(colours.length() in 1..100) { "Theme '$sourceName' has no usable colours" }
require(scheme.length() in 1..100) { "Theme '$sourceName' has no colour roles" }

val parsedColours = HashMap<String, Int>(colours.length())
val colourNames = colours.keys()
while (colourNames.hasNext()) {
val name = colourNames.next()
parsedColours[name] = parseArgb(colours.getString(name))
}

val roleNames = HashMap<String, String>(scheme.length())
val roles = scheme.keys()
while (roles.hasNext()) {
val role = roles.next()
val colourName = scheme.getString(role)
require(parsedColours.containsKey(colourName)) {
"Theme '$sourceName' refers to missing colour '$colourName'"
}
roleNames[role] = colourName
}
val primaryName = roleNames["primary"] ?: roleNames["colorPrimary"]
?: throw IllegalArgumentException("Theme '$sourceName' has no primary colour")
val onPrimary = (roleNames["on_primary"] ?: roleNames["onPrimary"])?.let(parsedColours::get)
val displayName = theme.optString("display_name").trim().ifEmpty {
sourceName.replace('_', ' ').replaceFirstChar { it.uppercase() }
}
theme.put("display_name", displayName)

result += ImportedTheme(
id = id,
displayName = displayName,
isDark = base == "dark",
primaryColor = parsedColours.getValue(primaryName),
onPrimaryColor = onPrimary,
json = theme.toString(),
)
}
require(!requireNonEmpty || result.isNotEmpty()) { "Theme file contains no themes" }
return result
}

private fun parseArgb(value: String): Int {
val digits = value.trim().removePrefix("0x").removePrefix("0X").removePrefix("#")
require(HEX_COLOR_REGEX.matches(digits)) {
"Invalid hex colour value: $value"
}
val fullHex = when (digits.length) {
3 -> "FF${digits[0]}${digits[0]}${digits[1]}${digits[1]}${digits[2]}${digits[2]}"
6 -> "FF$digits"
else -> digits
}
return fullHex.toLong(16).toInt()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,20 @@ import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.fragment.app.FragmentManager
import androidx.viewbinding.ViewBinding
import androidx.appcompat.R as appcompatR
import com.google.android.material.color.DynamicColors
import com.google.android.material.color.DynamicColorsOptions
import dagger.hilt.android.EntryPointAccessors
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import io.github.landwarderer.futon.BuildConfig
import io.github.landwarderer.futon.R
import io.github.landwarderer.futon.core.exceptions.resolve.ExceptionResolver
import io.github.landwarderer.futon.core.nav.AppRouter
import io.github.landwarderer.futon.core.prefs.ColorScheme
import io.github.landwarderer.futon.core.ui.util.ActionModeDelegate
import io.github.landwarderer.futon.core.util.ext.isWebViewUnavailable
import io.github.landwarderer.futon.main.ui.protect.ScreenshotPolicyHelper
import androidx.appcompat.R as appcompatR

abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(),
Expand Down Expand Up @@ -60,7 +63,27 @@ abstract class BaseActivity<B : ViewBinding> :
override fun onCreate(savedInstanceState: Bundle?) {
val settings = entryPoint.settings
isAmoledTheme = settings.isAmoledTheme
setTheme(settings.colorScheme.styleResId)
val importedTheme = settings.selectedCustomTheme.takeIf {
settings.colorScheme == ColorScheme.CUSTOM && DynamicColors.isDynamicColorAvailable()
}
val colorScheme = when {
importedTheme == null && settings.colorScheme == ColorScheme.CUSTOM -> ColorScheme.default
else -> settings.colorScheme
}
setTheme(colorScheme.styleResId)
if (importedTheme != null) {
val isNightMode = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
setTheme(
if (isNightMode) R.style.ThemeOverlay_Futon_Custom_Dark
else R.style.ThemeOverlay_Futon_Custom_Light,
)
DynamicColors.applyToActivityIfAvailable(
this,
DynamicColorsOptions.Builder()
.setContentBasedSource(importedTheme.primaryColor)
.build(),
)
}
if (isAmoledTheme) {
setTheme(R.style.ThemeOverlay_Futon_Amoled)
}
Expand All @@ -70,6 +93,7 @@ abstract class BaseActivity<B : ViewBinding> :
super.onCreate(savedInstanceState)
}


override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(actionModeDelegate)
Expand Down
Loading
Loading