From 3ef9ae2fe9d82550b9871719fd9daa58ecf21017 Mon Sep 17 00:00:00 2001 From: Infi Date: Wed, 1 Nov 2023 00:28:23 +0100 Subject: [PATCH] feat: import and export theme override (rato) files Signed-off-by: Infi --- app/build.gradle | 3 +- .../main/java/chat/revolt/api/RevoltAPI.kt | 8 +- .../settings/AppearanceSettingsScreen.kt | 144 +++++++++++++++++- .../res/drawable/ic_content_save_24dp.xml | 9 ++ app/src/main/res/drawable/ic_folder_24dp.xml | 9 ++ app/src/main/res/values/strings.xml | 1 + 6 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 app/src/main/res/drawable/ic_content_save_24dp.xml create mode 100644 app/src/main/res/drawable/ic_folder_24dp.xml diff --git a/app/build.gradle b/app/build.gradle index 4f7a482a..5014eeea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 diff --git a/app/src/main/java/chat/revolt/api/RevoltAPI.kt b/app/src/main/java/chat/revolt/api/RevoltAPI.kt index b37bce63..6a70279b 100644 --- a/app/src/main/java/chat/revolt/api/RevoltAPI.kt +++ b/app/src/main/java/chat/revolt/api/RevoltAPI.kt @@ -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) { diff --git a/app/src/main/java/chat/revolt/screens/settings/AppearanceSettingsScreen.kt b/app/src/main/java/chat/revolt/screens/settings/AppearanceSettingsScreen.kt index e8b8656a..a289e091 100644 --- a/app/src/main/java/chat/revolt/screens/settings/AppearanceSettingsScreen.kt +++ b/app/src/main/java/chat/revolt/screens/settings/AppearanceSettingsScreen.kt @@ -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(null) var selectedOverrideInitialValue by mutableStateOf(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) { + 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 diff --git a/app/src/main/res/drawable/ic_content_save_24dp.xml b/app/src/main/res/drawable/ic_content_save_24dp.xml new file mode 100644 index 00000000..3467bdf0 --- /dev/null +++ b/app/src/main/res/drawable/ic_content_save_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_folder_24dp.xml b/app/src/main/res/drawable/ic_folder_24dp.xml new file mode 100644 index 00000000..ad9bf271 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2ee75e90..6fa15529 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -455,6 +455,7 @@ Reset Export Import + This file is not a valid colour override file. Feedback Any feedback you have for Revolt is greatly appreciated and all feedback is read by the development team of our Android app.