feat: import and export theme override (rato) files

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-11-01 00:28:23 +01:00
parent 35c8976e30
commit 3ef9ae2fe9
6 changed files with 169 additions and 5 deletions

View File

@ -171,7 +171,8 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-reflect:1.9.10"
// Kotlinx - various first-party extensions for Kotlin
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.6.0"
implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0"
// Compose BOM

View File

@ -9,7 +9,6 @@ import chat.revolt.api.internals.Members
import chat.revolt.api.realtime.DisconnectionState
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.routes.user.fetchSelf
import chat.revolt.api.schemas.Channel as ChannelSchema
import chat.revolt.api.schemas.Emoji
import chat.revolt.api.schemas.Message
import chat.revolt.api.schemas.Server
@ -38,7 +37,9 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.json.Json
import chat.revolt.api.schemas.Channel as ChannelSchema
const val REVOLT_BASE = "https://api.revolt.chat"
const val REVOLT_SUPPORT = "https://support.revolt.chat"
@ -61,6 +62,11 @@ val RevoltJson = Json {
explicitNulls = false
}
@OptIn(ExperimentalSerializationApi::class)
val RevoltCbor = Cbor {
ignoreUnknownKeys = true
}
val RevoltHttp = HttpClient(OkHttp) {
install(DefaultRequest)
install(ContentNegotiation) {

View File

@ -1,6 +1,10 @@
package chat.revolt.screens.settings
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
@ -51,14 +55,16 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.RevoltCbor
import chat.revolt.api.settings.GlobalState
import chat.revolt.api.settings.SyncedSettings
import chat.revolt.components.generic.PageHeader
@ -71,11 +77,22 @@ import com.github.skydoves.colorpicker.compose.BrightnessSlider
import com.github.skydoves.colorpicker.compose.ColorEnvelope
import com.github.skydoves.colorpicker.compose.HsvColorPicker
import com.github.skydoves.colorpicker.compose.rememberColorPickerController
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import java.io.File
import javax.inject.Inject
import kotlin.reflect.KVisibility
import kotlin.reflect.full.memberProperties
class AppearanceSettingsScreenViewModel : ViewModel() {
@HiltViewModel
@Suppress("StaticFieldLeak")
class AppearanceSettingsScreenViewModel @Inject constructor(
@ApplicationContext val context: Context
) : ViewModel() {
var showColourOverrides by mutableStateOf(false)
var selectedOverrideName by mutableStateOf<String?>(null)
var selectedOverrideInitialValue by mutableStateOf<Int?>(null)
@ -116,13 +133,72 @@ class AppearanceSettingsScreenViewModel : ViewModel() {
}
}
}
private fun validOverrideKey(key: String): Boolean {
return ColorScheme::class.memberProperties.any { it.name == key }
}
private fun applyBulkOverrides(overrides: Map<String, Int>) {
val existingOverrides = SyncedSettings.android.colourOverrides ?: mapOf()
val newOverrides = existingOverrides.toMutableMap()
newOverrides.putAll(overrides.filterKeys { validOverrideKey(it) })
viewModelScope.launch {
SyncedSettings.updateAndroid(
SyncedSettings.android.copy(
colourOverrides = newOverrides
)
)
}
}
@OptIn(ExperimentalSerializationApi::class)
fun processImportedOverrides(uri: Uri) {
val mFile = File(context.cacheDir, uri.lastPathSegment ?: "temp")
mFile.outputStream().use { outputStream ->
context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.copyTo(outputStream)
}
}
try {
RevoltCbor.decodeFromByteArray(
MapSerializer(String.serializer(), Int.serializer()),
mFile.readBytes()
).let {
applyBulkOverrides(it)
}
} catch (e: Exception) {
Toast.makeText(
context,
context.getString(R.string.settings_appearance_colour_overrides_import_error),
Toast.LENGTH_SHORT
).show()
}
mFile.delete()
}
@OptIn(ExperimentalSerializationApi::class)
fun saveOverridesToFile(uri: Uri) {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(
RevoltCbor.encodeToByteArray(
MapSerializer(String.serializer(), Int.serializer()),
SyncedSettings.android.colourOverrides ?: mapOf()
)
)
}
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun AppearanceSettingsScreen(
navController: NavController,
viewModel: AppearanceSettingsScreenViewModel = viewModel()
viewModel: AppearanceSettingsScreenViewModel = hiltViewModel()
) {
val colourOverridesOpenerArrowRotation by animateFloatAsState(
if (viewModel.showColourOverrides) {
@ -131,6 +207,21 @@ fun AppearanceSettingsScreen(
label = "colourOverridesOpenerArrowRotation"
)
val filePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
if (uri != null) {
viewModel.processImportedOverrides(uri)
}
}
val fileSaver = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/x-revolt-android-theme-overrides"),
) { uri ->
if (uri != null) {
viewModel.saveOverridesToFile(uri)
}
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
@ -314,6 +405,53 @@ fun AppearanceSettingsScreen(
AnimatedVisibility(viewModel.showColourOverrides) {
Column {
Spacer(modifier = Modifier.height(10.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
) {
TextButton(
onClick = {
filePicker.launch(arrayOf("*/*"))
},
modifier = Modifier.weight(1f)
) {
Icon(
painter = painterResource(R.drawable.ic_folder_24dp),
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(id = R.string.settings_appearance_colour_overrides_import)
)
}
TextButton(
onClick = {
fileSaver.launch("${SyncedSettings.android.theme}-colours.rato")
},
modifier = Modifier.weight(1f)
) {
Icon(
painter = painterResource(R.drawable.ic_content_save_24dp),
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(id = R.string.settings_appearance_colour_overrides_export)
)
}
}
Spacer(modifier = Modifier.height(10.dp))
ColorScheme::class.memberProperties.forEach { member ->
if (member.visibility != KVisibility.PUBLIC) return@forEach

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z" />
</vector>

View File

@ -455,6 +455,7 @@
<string name="settings_appearance_colour_overrides_reset">Reset</string>
<string name="settings_appearance_colour_overrides_export">Export</string>
<string name="settings_appearance_colour_overrides_import">Import</string>
<string name="settings_appearance_colour_overrides_import_error">This file is not a valid colour override file.</string>
<string name="settings_feedback">Feedback</string>
<string name="settings_feedback_introduction">Any feedback you have for Revolt is greatly appreciated and all feedback is read by the development team of our Android app.</string>