feat: implement home catalog settings management with storage and UI integration

This commit is contained in:
tapframe 2026-03-11 22:35:12 +05:30
parent 3c4cc6b59f
commit 1c2319f2ee
9 changed files with 566 additions and 46 deletions

View file

@ -5,12 +5,14 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
AddonStorage.initialize(applicationContext)
HomeCatalogSettingsStorage.initialize(applicationContext)
setContent {
App()

View file

@ -0,0 +1,25 @@
package com.nuvio.app.features.home
import android.content.Context
import android.content.SharedPreferences
actual object HomeCatalogSettingsStorage {
private const val preferencesName = "nuvio_home_catalog_settings"
private const val payloadKey = "catalog_settings_payload"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(payloadKey, null)
actual fun savePayload(payload: String) {
preferences
?.edit()
?.putString(payloadKey, payload)
?.apply()
}
}

View file

@ -0,0 +1,39 @@
package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.catalog.supportsPagination
data class HomeCatalogDefinition(
val key: String,
val defaultTitle: String,
val addonName: String,
val manifestUrl: String,
val type: String,
val catalogId: String,
val supportsPagination: Boolean,
)
fun buildHomeCatalogDefinitions(addons: List<ManagedAddon>): List<HomeCatalogDefinition> =
addons.mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null
addon to manifest
}.flatMap { (addon, manifest) ->
manifest.catalogs
.filter { catalog -> catalog.extra.none { it.isRequired } }
.map { catalog ->
HomeCatalogDefinition(
key = "${manifest.id}:${catalog.type}:${catalog.id}",
defaultTitle = "${catalog.name} - ${catalog.type.displayLabel()}",
addonName = manifest.name,
manifestUrl = addon.manifestUrl,
type = catalog.type,
catalogId = catalog.id,
supportsPagination = catalog.supportsPagination(),
)
}
}
internal fun String.displayLabel(): String =
replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
}

View file

@ -0,0 +1,209 @@
package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class HomeCatalogSettingsItem(
val key: String,
val defaultTitle: String,
val addonName: String,
val customTitle: String = "",
val enabled: Boolean = true,
val order: Int = 0,
) {
val displayTitle: String
get() = customTitle.ifBlank { defaultTitle }
}
data class HomeCatalogSettingsUiState(
val items: List<HomeCatalogSettingsItem> = emptyList(),
) {
val signature: String
get() = items.joinToString(separator = "|") { item ->
"${item.key}:${item.order}:${item.enabled}:${item.customTitle}"
}
}
internal data class HomeCatalogPreference(
val customTitle: String,
val enabled: Boolean,
val order: Int,
)
@Serializable
private data class StoredHomeCatalogPreference(
val key: String,
val customTitle: String = "",
val enabled: Boolean = true,
val order: Int = 0,
)
object HomeCatalogSettingsRepository {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _uiState = MutableStateFlow(HomeCatalogSettingsUiState())
val uiState: StateFlow<HomeCatalogSettingsUiState> = _uiState.asStateFlow()
private var hasLoaded = false
private var definitions: List<HomeCatalogDefinition> = emptyList()
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
fun syncCatalogs(addons: List<ManagedAddon>) {
ensureLoaded()
definitions = buildHomeCatalogDefinitions(addons)
normalizePreferences()
publish()
persist()
}
internal fun snapshot(): Map<String, HomeCatalogPreference> {
ensureLoaded()
return preferences.mapValues { (_, value) ->
HomeCatalogPreference(
customTitle = value.customTitle,
enabled = value.enabled,
order = value.order,
)
}
}
fun setEnabled(key: String, enabled: Boolean) {
updatePreference(key) { preference ->
preference.copy(enabled = enabled)
}
}
fun setCustomTitle(key: String, title: String) {
updatePreference(key) { preference ->
preference.copy(customTitle = title)
}
}
fun moveUp(key: String) {
move(key = key, direction = -1)
}
fun moveDown(key: String) {
move(key = key, direction = 1)
}
private fun ensureLoaded() {
if (hasLoaded) return
hasLoaded = true
val payload = HomeCatalogSettingsStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) return
val stored = runCatching {
json.decodeFromString<List<StoredHomeCatalogPreference>>(payload)
}.getOrDefault(emptyList())
preferences = stored.associateBy { it.key }.toMutableMap()
}
private fun normalizePreferences() {
val current = preferences
val orderedDefinitions = definitions.mapIndexed { defaultIndex, definition ->
Triple(
definition,
current[definition.key]?.order ?: defaultIndex,
defaultIndex,
)
}.sortedWith(
compareBy<Triple<HomeCatalogDefinition, Int, Int>>(
{ it.second },
{ it.third },
),
).map { it.first }
val normalized = mutableMapOf<String, StoredHomeCatalogPreference>()
orderedDefinitions.forEachIndexed { index, definition ->
val stored = current[definition.key]
normalized[definition.key] = StoredHomeCatalogPreference(
key = definition.key,
customTitle = stored?.customTitle.orEmpty(),
enabled = stored?.enabled ?: true,
order = index,
)
}
preferences = normalized
}
private fun publish() {
val items = definitions
.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE }
.map { definition ->
val preference = preferences[definition.key]
HomeCatalogSettingsItem(
key = definition.key,
defaultTitle = definition.defaultTitle,
addonName = definition.addonName,
customTitle = preference?.customTitle.orEmpty(),
enabled = preference?.enabled ?: true,
order = preference?.order ?: 0,
)
}
_uiState.value = HomeCatalogSettingsUiState(items = items)
}
private fun persist() {
HomeCatalogSettingsStorage.savePayload(
json.encodeToString(
preferences.values.sortedBy { it.order },
),
)
}
private fun updatePreference(
key: String,
transform: (StoredHomeCatalogPreference) -> StoredHomeCatalogPreference,
) {
ensureLoaded()
val current = preferences[key] ?: return
preferences[key] = transform(current)
publish()
persist()
HomeRepository.applyCurrentSettings()
}
private fun move(
key: String,
direction: Int,
) {
ensureLoaded()
if (definitions.isEmpty()) return
val orderedKeys = definitions
.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE }
.map { it.key }
.toMutableList()
val currentIndex = orderedKeys.indexOf(key)
if (currentIndex == -1) return
val targetIndex = currentIndex + direction
if (targetIndex !in orderedKeys.indices) return
val movingKey = orderedKeys.removeAt(currentIndex)
orderedKeys.add(targetIndex, movingKey)
orderedKeys.forEachIndexed { index, itemKey ->
val current = preferences[itemKey] ?: return@forEachIndexed
preferences[itemKey] = current.copy(order = index)
}
publish()
persist()
HomeRepository.applyCurrentSettings()
}
}

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.home
internal expect object HomeCatalogSettingsStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -2,7 +2,6 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.catalog.supportsPagination
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -20,17 +19,26 @@ object HomeRepository {
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
private var lastRequestKey: String? = null
private var currentDefinitions: List<HomeCatalogDefinition> = emptyList()
private var cachedSections: Map<String, HomeCatalogSection> = emptyMap()
private var lastErrorMessage: String? = null
fun refresh(addons: List<ManagedAddon>, force: Boolean = false) {
val requests = buildCatalogRequests(addons)
val requests = buildHomeCatalogDefinitions(addons)
currentDefinitions = requests
val requestKey = requests.joinToString(separator = "|") { request ->
"${request.addon.manifestUrl}:${request.type}:${request.catalogId}"
"${request.manifestUrl}:${request.type}:${request.catalogId}"
}
if (!force && requestKey == lastRequestKey) return
if (!force && requestKey == lastRequestKey && cachedSections.isNotEmpty()) {
applyCurrentSettings()
return
}
lastRequestKey = requestKey
if (requests.isEmpty()) {
cachedSections = emptyMap()
lastErrorMessage = null
_uiState.value = HomeUiState(
isLoading = false,
sections = emptyList(),
@ -47,60 +55,55 @@ object HomeRepository {
}
}.awaitAll()
val sections = results.mapNotNull { it.getOrNull() }
val firstFailure = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message }
_uiState.value = HomeUiState(
isLoading = false,
sections = sections,
errorMessage = if (sections.isEmpty()) firstFailure else null,
)
cachedSections = results
.mapNotNull { it.getOrNull() }
.associateBy { it.key }
lastErrorMessage = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message }
applyCurrentSettings()
}
}
private fun buildCatalogRequests(addons: List<ManagedAddon>): List<CatalogRequest> =
addons.mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null
addon to manifest
}.flatMap { (addon, manifest) ->
manifest.catalogs
.filter { catalog -> catalog.extra.none { it.isRequired } }
.map { catalog ->
CatalogRequest(
addon = addon,
catalogId = catalog.id,
catalogName = catalog.name,
type = catalog.type,
supportsPagination = catalog.supportsPagination(),
)
}
}
fun applyCurrentSettings() {
val preferences = HomeCatalogSettingsRepository.snapshot()
val sections = currentDefinitions
.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE }
.mapNotNull { definition ->
val preference = preferences[definition.key]
if (preference?.enabled == false) return@mapNotNull null
private suspend fun CatalogRequest.toSection(): HomeCatalogSection {
val manifest = requireNotNull(addon.manifest)
val section = cachedSections[definition.key] ?: return@mapNotNull null
val customTitle = preference?.customTitle.orEmpty()
section.copy(
title = customTitle.ifBlank { section.title },
)
}
_uiState.value = HomeUiState(
isLoading = false,
sections = sections,
errorMessage = if (sections.isEmpty()) lastErrorMessage else null,
)
}
private suspend fun HomeCatalogDefinition.toSection(): HomeCatalogSection {
val page = fetchCatalogPage(
manifestUrl = manifest.transportUrl,
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
)
val items = page.items
require(items.isNotEmpty()) { "No feed items returned for $catalogName." }
require(items.isNotEmpty()) { "No feed items returned for $defaultTitle." }
return HomeCatalogSection(
key = "${manifest.id}:$type:$catalogId",
title = "$catalogName - ${type.displayLabel()}",
subtitle = manifest.name,
addonName = manifest.name,
key = key,
title = defaultTitle,
subtitle = addonName,
addonName = addonName,
type = type,
manifestUrl = manifest.transportUrl,
manifestUrl = manifestUrl,
catalogId = catalogId,
items = items,
supportsPagination = supportsPagination,
)
}
}
private fun String.displayLabel(): String =
replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
}

View file

@ -41,6 +41,7 @@ fun HomeScreen(
}
LaunchedEffect(catalogRefreshKey) {
HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons)
HomeRepository.refresh(addonsUiState.addons)
}

View file

@ -21,18 +21,27 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -44,11 +53,17 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioSectionLabel
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.home.HomeCatalogSettingsItem
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.components.HomeEmptyStateCard
import androidx.lifecycle.compose.collectAsStateWithLifecycle
private enum class SettingsCategory(
val label: String,
@ -62,6 +77,7 @@ private enum class SettingsPage(
) {
Root("Settings"),
ContentDiscovery("Content & Discovery"),
Homescreen("Homescreen"),
}
@Composable
@ -74,6 +90,17 @@ fun SettingsScreen(
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
LaunchedEffect(Unit) {
AddonRepository.initialize()
}
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val homescreenSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
LaunchedEffect(addonsUiState.addons) {
HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons)
}
var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) }
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
@ -82,12 +109,14 @@ fun SettingsScreen(
page = page,
onPageChange = { currentPage = it.name },
onAddonsClick = onAddonsClick,
homescreenSettings = homescreenSettingsUiState.items,
)
} else {
MobileSettingsScreen(
page = page,
onPageChange = { currentPage = it.name },
onAddonsClick = onAddonsClick,
homescreenSettings = homescreenSettingsUiState.items,
)
}
}
@ -98,6 +127,7 @@ private fun MobileSettingsScreen(
page: SettingsPage,
onPageChange: (SettingsPage) -> Unit,
onAddonsClick: () -> Unit,
homescreenSettings: List<HomeCatalogSettingsItem>,
) {
NuvioScreen {
stickyHeader {
@ -119,6 +149,11 @@ private fun MobileSettingsScreen(
SettingsPage.ContentDiscovery -> contentDiscoveryContent(
isTablet = false,
onAddonsClick = onAddonsClick,
onHomescreenClick = { onPageChange(SettingsPage.Homescreen) },
)
SettingsPage.Homescreen -> homescreenSettingsContent(
isTablet = false,
items = homescreenSettings,
)
}
}
@ -129,6 +164,7 @@ private fun TabletSettingsScreen(
page: SettingsPage,
onPageChange: (SettingsPage) -> Unit,
onAddonsClick: () -> Unit,
homescreenSettings: List<HomeCatalogSettingsItem>,
) {
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
val activeCategory = SettingsCategory.valueOf(selectedCategory)
@ -195,13 +231,18 @@ private fun TabletSettingsScreen(
SettingsPage.ContentDiscovery -> contentDiscoveryContent(
isTablet = true,
onAddonsClick = onAddonsClick,
onHomescreenClick = { onPageChange(SettingsPage.Homescreen) },
)
SettingsPage.Homescreen -> homescreenSettingsContent(
isTablet = true,
items = homescreenSettings,
)
}
}
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.settingsRootContent(
private fun LazyListScope.settingsRootContent(
isTablet: Boolean,
onContentDiscoveryClick: () -> Unit,
) {
@ -221,13 +262,14 @@ private fun androidx.compose.foundation.lazy.LazyListScope.settingsRootContent(
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.contentDiscoveryContent(
private fun LazyListScope.contentDiscoveryContent(
isTablet: Boolean,
onAddonsClick: () -> Unit,
onHomescreenClick: () -> Unit,
) {
item {
SettingsSection(
title = "GENERAL",
title = "SOURCES",
isTablet = isTablet,
) {
SettingsNavigationRow(
@ -239,6 +281,56 @@ private fun androidx.compose.foundation.lazy.LazyListScope.contentDiscoveryConte
)
}
}
item {
SettingsSection(
title = "HOME",
isTablet = isTablet,
) {
SettingsNavigationRow(
title = "Homescreen",
description = "Control which catalogs appear on Home and in what order.",
icon = Icons.Rounded.Tune,
isTablet = isTablet,
onClick = onHomescreenClick,
)
}
}
}
private fun LazyListScope.homescreenSettingsContent(
isTablet: Boolean,
items: List<HomeCatalogSettingsItem>,
) {
item {
if (items.isEmpty()) {
HomeEmptyStateCard(
modifier = Modifier.fillMaxWidth(),
title = "No home catalogs",
message = "Install an addon with board-compatible catalogs to configure Homescreen rows.",
)
} else {
SettingsSection(
title = "CATALOGS",
isTablet = isTablet,
) {
items.forEachIndexed { index, item ->
HomescreenCatalogRow(
item = item,
isTablet = isTablet,
canMoveUp = index > 0,
canMoveDown = index < items.lastIndex,
onTitleChange = { HomeCatalogSettingsRepository.setCustomTitle(item.key, it) },
onEnabledChange = { HomeCatalogSettingsRepository.setEnabled(item.key, it) },
onMoveUp = { HomeCatalogSettingsRepository.moveUp(item.key) },
onMoveDown = { HomeCatalogSettingsRepository.moveDown(item.key) },
)
if (index < items.lastIndex) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
}
}
}
@Composable
@ -418,3 +510,132 @@ private fun SettingsNavigationRow(
)
}
}
@Composable
private fun HomescreenCatalogRow(
item: HomeCatalogSettingsItem,
isTablet: Boolean,
canMoveUp: Boolean,
canMoveDown: Boolean,
onTitleChange: (String) -> Unit,
onEnabledChange: (Boolean) -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
) {
val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 18.dp else 16.dp
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(
modifier = Modifier
.padding(end = 12.dp)
.widthIn(max = if (isTablet) 560.dp else 260.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = item.displayTitle,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = item.addonName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = item.enabled,
onCheckedChange = onEnabledChange,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
),
)
}
OutlinedTextField(
value = item.customTitle,
onValueChange = onTitleChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = {
Text("Display Name")
},
placeholder = {
Text(item.defaultTitle)
},
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surface,
),
)
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MoveActionChip(
label = "Move Up",
icon = Icons.Rounded.KeyboardArrowUp,
enabled = canMoveUp,
onClick = onMoveUp,
)
MoveActionChip(
label = "Move Down",
icon = Icons.Rounded.KeyboardArrowDown,
enabled = canMoveDown,
onClick = onMoveDown,
)
}
}
}
@Composable
private fun MoveActionChip(
label: String,
icon: ImageVector,
enabled: Boolean,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier
.clickable(enabled = enabled, onClick = onClick)
.alpha(if (enabled) 1f else 0.45f),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f),
shape = RoundedCornerShape(999.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
}

View file

@ -0,0 +1,14 @@
package com.nuvio.app.features.home
import platform.Foundation.NSUserDefaults
actual object HomeCatalogSettingsStorage {
private const val payloadKey = "catalog_settings_payload"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(payloadKey)
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = payloadKey)
}
}