improve json serialization

This commit is contained in:
Josia Pietsch 2024-12-29 01:21:38 +01:00
parent 970c160f4a
commit 4ddb893d41
Signed by: jrpie
GPG key ID: E70B571D66986A2D
10 changed files with 169 additions and 138 deletions

View file

@ -13,6 +13,8 @@ import de.jrpie.android.launcher.actions.TorchManager
import de.jrpie.android.launcher.apps.AppInfo import de.jrpie.android.launcher.apps.AppInfo
import de.jrpie.android.launcher.apps.DetailedAppInfo import de.jrpie.android.launcher.apps.DetailedAppInfo
import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.preferences.LauncherPreferences
import de.jrpie.android.launcher.preferences.migratePreferencesToNewVersion
import de.jrpie.android.launcher.preferences.resetPreferences
class Application : android.app.Application() { class Application : android.app.Application() {
val apps = MutableLiveData<List<DetailedAppInfo>>() val apps = MutableLiveData<List<DetailedAppInfo>>()
@ -85,6 +87,19 @@ class Application : android.app.Application() {
val preferences = PreferenceManager.getDefaultSharedPreferences(this) val preferences = PreferenceManager.getDefaultSharedPreferences(this)
LauncherPreferences.init(preferences, this.resources) LauncherPreferences.init(preferences, this.resources)
// Try to restore old preferences
migratePreferencesToNewVersion(this)
// First time opening the app: set defaults and start tutorial
if (!LauncherPreferences.internal().started()) {
resetPreferences(this)
LauncherPreferences.internal().started(true)
openTutorial(this)
}
LauncherPreferences.getSharedPreferences() LauncherPreferences.getSharedPreferences()
.registerOnSharedPreferenceChangeListener(listener) .registerOnSharedPreferenceChangeListener(listener)

View file

@ -75,7 +75,9 @@ fun openInBrowser(url: String, context: Context) {
} }
fun openTutorial(context: Context) { fun openTutorial(context: Context) {
context.startActivity(Intent(context, TutorialActivity::class.java)) context.startActivity(Intent(context, TutorialActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
})
} }

View file

@ -19,10 +19,13 @@ import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.decodeStructure
import kotlinx.serialization.encoding.encodeStructure
@Serializable(with = LauncherActionSerializer::class) @Serializable(with = LauncherActionSerializer::class)
@SerialName("action:launcher") @SerialName("action:launcher")
@ -248,30 +251,27 @@ fun openAppsList(context: Context, favorite: Boolean = false, hidden: Boolean =
context.startActivity(intent) context.startActivity(intent)
} }
/* A custom serializer is required to store type information,
/** see https://github.com/Kotlin/kotlinx.serialization/issues/1486
* LauncherAction can't be serialized directly, since it needs a type annotation.
* Thus this hack is needed.
*/ */
@Serializable private class LauncherActionSerializer : KSerializer<LauncherAction> {
private class LauncherActionWrapper(val id: String)
private class LauncherActionSerializer() : KSerializer<LauncherAction> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor( override val descriptor: SerialDescriptor = buildClassSerialDescriptor(
"action:launcher", "action:launcher",
) { ) {
element("id", LauncherActionWrapper.serializer().descriptor) element("value", String.serializer().descriptor)
} }
override fun deserialize(decoder: Decoder): LauncherAction { override fun deserialize(decoder: Decoder): LauncherAction {
val wrapper = decoder.decodeSerializableValue(LauncherActionWrapper.serializer()) val s = decoder.decodeStructure(descriptor) {
return LauncherAction.byId(wrapper.id) ?: throw SerializationException() decodeElementIndex(descriptor)
decodeSerializableElement(descriptor, 0, String.serializer())
}
return LauncherAction.byId(s) ?: throw SerializationException()
} }
override fun serialize(encoder: Encoder, value: LauncherAction) { override fun serialize(encoder: Encoder, value: LauncherAction) {
encoder.encodeSerializableValue( encoder.encodeStructure(descriptor) {
LauncherActionWrapper.serializer(), encodeSerializableElement(descriptor, 0, String.serializer(), value.id)
LauncherActionWrapper(value.id) }
)
} }
} }

View file

@ -1,23 +1,18 @@
package de.jrpie.android.launcher.preferences; package de.jrpie.android.launcher.preferences;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.Set; import java.util.Set;
import de.jrpie.android.launcher.R; import de.jrpie.android.launcher.R;
import de.jrpie.android.launcher.actions.lock.LockMethod; import de.jrpie.android.launcher.actions.lock.LockMethod;
import de.jrpie.android.launcher.apps.AppInfo; import de.jrpie.android.launcher.preferences.serialization.SetAppInfoPreferenceSerializer;
import de.jrpie.android.launcher.preferences.serialization.MapAppInfoStringPreferenceSerializer;
import de.jrpie.android.launcher.preferences.theme.Background; import de.jrpie.android.launcher.preferences.theme.Background;
import de.jrpie.android.launcher.preferences.theme.ColorTheme; import de.jrpie.android.launcher.preferences.theme.ColorTheme;
import de.jrpie.android.launcher.preferences.theme.Font; import de.jrpie.android.launcher.preferences.theme.Font;
import eu.jonahbauer.android.preference.annotations.Preference; import eu.jonahbauer.android.preference.annotations.Preference;
import eu.jonahbauer.android.preference.annotations.PreferenceGroup; import eu.jonahbauer.android.preference.annotations.PreferenceGroup;
import eu.jonahbauer.android.preference.annotations.Preferences; import eu.jonahbauer.android.preference.annotations.Preferences;
import eu.jonahbauer.android.preference.annotations.serializer.PreferenceSerializationException;
import eu.jonahbauer.android.preference.annotations.serializer.PreferenceSerializer;
@Preferences( @Preferences(
name = "de.jrpie.android.launcher.preferences.LauncherPreferences", name = "de.jrpie.android.launcher.preferences.LauncherPreferences",
@ -30,9 +25,9 @@ import eu.jonahbauer.android.preference.annotations.serializer.PreferenceSeriali
@Preference(name = "version_code", type = int.class, defaultValue = "-1"), @Preference(name = "version_code", type = int.class, defaultValue = "-1"),
}), }),
@PreferenceGroup(name = "apps", prefix = "settings_apps_", suffix = "_key", value = { @PreferenceGroup(name = "apps", prefix = "settings_apps_", suffix = "_key", value = {
@Preference(name = "favorites", type = Set.class, serializer = LauncherPreferences$Config.AppInfoSetSerializer.class), @Preference(name = "favorites", type = Set.class, serializer = SetAppInfoPreferenceSerializer.class),
@Preference(name = "hidden", type = Set.class, serializer = LauncherPreferences$Config.AppInfoSetSerializer.class), @Preference(name = "hidden", type = Set.class, serializer = SetAppInfoPreferenceSerializer.class),
@Preference(name = "custom_names", type = HashMap.class, serializer = LauncherPreferences$Config.MapAppInfoStringSerializer.class), @Preference(name = "custom_names", type = HashMap.class, serializer = MapAppInfoStringPreferenceSerializer.class),
@Preference(name = "hide_bound_apps", type = boolean.class, defaultValue = "false"), @Preference(name = "hide_bound_apps", type = boolean.class, defaultValue = "false"),
}), }),
@PreferenceGroup(name = "list", prefix = "settings_list_", suffix = "_key", value = { @PreferenceGroup(name = "list", prefix = "settings_list_", suffix = "_key", value = {
@ -78,74 +73,4 @@ import eu.jonahbauer.android.preference.annotations.serializer.PreferenceSeriali
@Preference(name = "lock_method", type = LockMethod.class, defaultValue = "DEVICE_ADMIN"), @Preference(name = "lock_method", type = LockMethod.class, defaultValue = "DEVICE_ADMIN"),
}), }),
}) })
public final class LauncherPreferences$Config { public final class LauncherPreferences$Config {}
public static class AppInfoSetSerializer implements PreferenceSerializer<Set<AppInfo>, Set<String>> {
@Override
public Set<String> serialize(Set<AppInfo> value) throws PreferenceSerializationException {
if (value == null) return null;
var serialized = new HashSet<String>(value.size());
for (var app : value) {
serialized.add(app.serialize());
}
return serialized;
}
@Override
public Set<AppInfo> deserialize(Set<String> value) throws PreferenceSerializationException {
if (value == null) return null;
var deserialized = new HashSet<AppInfo>(value.size());
for (var s : value) {
deserialized.add(AppInfo.Companion.deserialize(s));
}
return deserialized;
}
}
// TODO migrate to version 2
public static class MapAppInfoStringSerializer implements PreferenceSerializer<HashMap<AppInfo, String>, Set<String>> {
@Override
public Set<String> serialize(HashMap<AppInfo, String> value) throws PreferenceSerializationException {
if (value == null) return null;
var serialized = new HashSet<String>(value.size());
for (var entry : value.entrySet()) {
JSONObject obj = new JSONObject();
try {
obj.put("key", entry.getKey().serialize());
obj.put("value", entry.getValue());
serialized.add(obj.toString());
} catch (JSONException ignored) {
}
}
return serialized;
}
@Override
public HashMap<AppInfo, String> deserialize(Set<String> value) throws PreferenceSerializationException {
if (value == null) return null;
var deserialized = new HashMap<AppInfo, String>();
for (var entry : value) {
try {
JSONObject obj = new JSONObject(entry);
AppInfo info = AppInfo.Companion.deserialize(obj.getString("key"));
String s = obj.getString("value");
deserialized.put(info, s);
} catch (JSONException ignored) {
}
}
return deserialized;
}
}
}

View file

@ -6,8 +6,8 @@ import de.jrpie.android.launcher.BuildConfig
import de.jrpie.android.launcher.actions.Action import de.jrpie.android.launcher.actions.Action
import de.jrpie.android.launcher.apps.AppInfo import de.jrpie.android.launcher.apps.AppInfo
import de.jrpie.android.launcher.apps.DetailedAppInfo import de.jrpie.android.launcher.apps.DetailedAppInfo
import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersionUnknown
import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersion1 import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersion1
import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersionUnknown
import de.jrpie.android.launcher.ui.HomeActivity import de.jrpie.android.launcher.ui.HomeActivity
/* Current version of the structure of preferences. /* Current version of the structure of preferences.
@ -19,8 +19,12 @@ const val UNKNOWN_PREFERENCE_VERSION = -1
private const val TAG = "Launcher - Preferences" private const val TAG = "Launcher - Preferences"
/*
* Tries to detect preferences written by older versions of the app
* and migrate them to the current format.
*/
fun migratePreferencesToNewVersion(context: Context) { fun migratePreferencesToNewVersion(context: Context) {
try {
when (LauncherPreferences.internal().versionCode()) { when (LauncherPreferences.internal().versionCode()) {
// Check versions, make sure transitions between versions go well // Check versions, make sure transitions between versions go well
PREFERENCE_VERSION -> { /* the version installed and used previously are the same */ PREFERENCE_VERSION -> { /* the version installed and used previously are the same */
@ -31,6 +35,7 @@ fun migratePreferencesToNewVersion(context: Context) {
Log.i(TAG, "migration of preferences complete.") Log.i(TAG, "migration of preferences complete.")
} }
1 -> { 1 -> {
migratePreferencesFromVersion1() migratePreferencesFromVersion1()
Log.i(TAG, "migration of preferences complete.") Log.i(TAG, "migration of preferences complete.")
@ -45,6 +50,10 @@ fun migratePreferencesToNewVersion(context: Context) {
) )
} }
} }
} catch (e: Exception) {
Log.e(TAG, "Unable to restore preferences:\n${e.stackTrace}")
resetPreferences(context)
}
} }
fun resetPreferences(context: Context) { fun resetPreferences(context: Context) {

View file

@ -7,8 +7,12 @@ import de.jrpie.android.launcher.actions.LauncherAction
import de.jrpie.android.launcher.apps.AppInfo import de.jrpie.android.launcher.apps.AppInfo
import de.jrpie.android.launcher.apps.AppInfo.Companion.INVALID_USER import de.jrpie.android.launcher.apps.AppInfo.Companion.INVALID_USER
import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.preferences.LauncherPreferences
import de.jrpie.android.launcher.preferences.PREFERENCE_VERSION
import de.jrpie.android.launcher.preferences.serialization.MapAppInfoStringPreferenceSerializer
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.json.JSONException
import org.json.JSONObject
val oldLauncherActionIds: Map<String, LauncherAction> = val oldLauncherActionIds: Map<String, LauncherAction> =
mapOf( mapOf(
@ -44,7 +48,7 @@ private fun AppInfo.Companion.legacyDeserialize(serialized: String): AppInfo {
* @param id * @param id
* @param user a user id, ignored if the action is a [LauncherAction]. * @param user a user id, ignored if the action is a [LauncherAction].
*/ */
private fun Action.Companion.fromId(id: String, user: Int?): Action? { private fun Action.Companion.legacyFromId(id: String, user: Int?): Action? {
if (id.isEmpty()) { if (id.isEmpty()) {
return null return null
} }
@ -68,7 +72,25 @@ private fun Action.Companion.legacyFromPreference(id: String): Action? {
) )
u = if (u == AppInfo.INVALID_USER) null else u u = if (u == AppInfo.INVALID_USER) null else u
return Action.fromId(actionId, u) return Action.legacyFromId(actionId, u)
}
private fun migrateAppInfoStringMap(key: String) {
val preferences = LauncherPreferences.getSharedPreferences()
MapAppInfoStringPreferenceSerializer().serialize(
preferences.getStringSet(key, setOf())?.mapNotNull { entry ->
try {
val obj = JSONObject(entry);
val info = AppInfo.legacyDeserialize(obj.getString("key"))
val value = obj.getString("value");
Pair(info, value)
} catch (_: JSONException) {
null
}
}?.toMap(HashMap())
)?.let {
preferences.edit().putStringSet(key, it as Set<String>).apply()
}
} }
private fun migrateAppInfoSet(key: String) { private fun migrateAppInfoSet(key: String) {
@ -82,13 +104,24 @@ private fun migrateAppInfoSet(key: String) {
private fun migrateAction(key: String) { private fun migrateAction(key: String) {
Action.legacyFromPreference(key)?.let { action -> Action.legacyFromPreference(key)?.let { action ->
LauncherPreferences.getSharedPreferences().edit() LauncherPreferences.getSharedPreferences().edit()
.putString(key, Json.encodeToString(action)).apply() .putString(key, Json.encodeToString(action))
.remove("$key.app")
.remove("$key.user")
.apply()
} }
} }
/**
* Migrate preferences from version 1 (used until version j-0.0.18) to the current format
* (see [PREFERENCE_VERSION])
*/
fun migratePreferencesFromVersion1() { fun migratePreferencesFromVersion1() {
assert(PREFERENCE_VERSION == 2)
assert(LauncherPreferences.internal().versionCode() == 1)
Gesture.entries.forEach { g -> migrateAction(g.id) } Gesture.entries.forEach { g -> migrateAction(g.id) }
migrateAppInfoSet(LauncherPreferences.apps().keys().hidden()) migrateAppInfoSet(LauncherPreferences.apps().keys().hidden())
migrateAppInfoSet(LauncherPreferences.apps().keys().favorites()) migrateAppInfoSet(LauncherPreferences.apps().keys().favorites())
migrateAppInfoStringMap(LauncherPreferences.apps().keys().customNames())
LauncherPreferences.internal().versionCode(2)
} }

View file

@ -4,9 +4,12 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import android.util.Log
import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.preferences.LauncherPreferences
import de.jrpie.android.launcher.preferences.PREFERENCE_VERSION
import de.jrpie.android.launcher.preferences.theme.Background import de.jrpie.android.launcher.preferences.theme.Background
import de.jrpie.android.launcher.preferences.theme.ColorTheme import de.jrpie.android.launcher.preferences.theme.ColorTheme
private fun migrateStringPreference( private fun migrateStringPreference(
oldPrefs: SharedPreferences, oldPrefs: SharedPreferences,
newPreferences: SharedPreferences.Editor, newPreferences: SharedPreferences.Editor,
@ -42,7 +45,13 @@ private fun migrateBooleanPreference(
private const val TAG = "Preferences ? -> 1" private const val TAG = "Preferences ? -> 1"
/**
* Try to migrate from a very old preference version, where no version number was stored
* and a different file was used.
*/
fun migratePreferencesFromVersionUnknown(context: Context) { fun migratePreferencesFromVersionUnknown(context: Context) {
assert(PREFERENCE_VERSION == 2)
Log.i( Log.i(
TAG, TAG,
"Unknown preference version, trying to restore preferences from old version." "Unknown preference version, trying to restore preferences from old version."

View file

@ -0,0 +1,49 @@
@file:Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
package de.jrpie.android.launcher.preferences.serialization
import de.jrpie.android.launcher.apps.AppInfo
import eu.jonahbauer.android.preference.annotations.serializer.PreferenceSerializationException
import eu.jonahbauer.android.preference.annotations.serializer.PreferenceSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
// Serializers for [LauncherPreference$Config]
@Suppress("UNCHECKED_CAST")
class SetAppInfoPreferenceSerializer :
PreferenceSerializer<java.util.Set<AppInfo>?, java.util.Set<java.lang.String>?> {
@Throws(PreferenceSerializationException::class)
override fun serialize(value: java.util.Set<AppInfo>?): java.util.Set<java.lang.String>? {
return value?.map(AppInfo::serialize)?.toHashSet() as java.util.Set<java.lang.String>
}
@Throws(PreferenceSerializationException::class)
override fun deserialize(value: java.util.Set<java.lang.String>?): java.util.Set<AppInfo>? {
return value?.map (java.lang.String::toString)?.map(AppInfo::deserialize)?.toHashSet() as? java.util.Set<AppInfo>
}
}
@Suppress("UNCHECKED_CAST")
class MapAppInfoStringPreferenceSerializer :
PreferenceSerializer<java.util.HashMap<AppInfo, String>?, java.util.Set<java.lang.String>?> {
@Serializable()
private class MapEntry(val key: AppInfo, val value: String)
@Throws(PreferenceSerializationException::class)
override fun serialize(value: java.util.HashMap<AppInfo, String>?): java.util.Set<java.lang.String>? {
return value?.map { (key, value) ->
Json.encodeToString(MapEntry(key, value))
}?.toHashSet() as? java.util.Set<java.lang.String>
}
@Throws(PreferenceSerializationException::class)
override fun deserialize(value: java.util.Set<java.lang.String>?): java.util.HashMap<AppInfo, String>? {
return value?.associateTo(HashMap()) {
val entry = Json.decodeFromString<MapEntry>(it.toString())
Pair(entry.key, entry.value)
}
}
}

View file

@ -76,17 +76,6 @@ class HomeActivity : UIObject, AppCompatActivity(),
super<AppCompatActivity>.onCreate(savedInstanceState) super<AppCompatActivity>.onCreate(savedInstanceState)
super<UIObject>.onCreate() super<UIObject>.onCreate()
// Try to restore old preferences
migratePreferencesToNewVersion(this)
// First time opening the app: set defaults and start tutorial
if (!LauncherPreferences.internal().started()) {
resetPreferences(this)
LauncherPreferences.internal().started(true)
openTutorial(this)
}
// Initialise layout // Initialise layout
binding = HomeBinding.inflate(layoutInflater) binding = HomeBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)

View file

@ -5,12 +5,12 @@
<!-- Swipe up - Apps list --> <!-- Swipe up - Apps list -->
<string-array name="default_up"> <string-array name="default_up">
<item>{\"type\": \"action:launcher\", \"id\": \"choose\"}</item> <!-- All Apps --> <item>{\"type\": \"action:launcher\", \"value\": \"choose\"}</item> <!-- All Apps -->
</string-array> </string-array>
<!-- Swipe up (left edge) - Favorite Apps --> <!-- Swipe up (left edge) - Favorite Apps -->
<string-array name="default_up_left"> <string-array name="default_up_left">
<item>{\"type\": \"action:launcher\", \"id\": \"choose_from_favorites\"}</item> <item>{\"type\": \"action:launcher\", \"value\": \"choose_from_favorites\"}</item>
</string-array> </string-array>
<!-- Swipe up (right edge) - Maps --> <!-- Swipe up (right edge) - Maps -->
@ -106,12 +106,12 @@
<!-- Volume up --> <!-- Volume up -->
<string-array name="default_volume_up"> <string-array name="default_volume_up">
<item>{\"type\": \"action:launcher\", \"id\": \"volume_up\"}</item> <item>{\"type\": \"action:launcher\", \"value\": \"volume_up\"}</item>
</string-array> </string-array>
<!-- Volume down --> <!-- Volume down -->
<string-array name="default_volume_down"> <string-array name="default_volume_down">
<item>{\"type\": \"action:launcher\", \"id\": \"volume_down\"}</item> <item>{\"type\": \"action:launcher\", \"value\": \"volume_down\"}</item>
</string-array> </string-array>
<!-- Double click - Notes --> <!-- Double click - Notes -->
@ -119,7 +119,7 @@
<item>{\"type\": \"action:app\", \"app\": {\"packageName\": \"it.niedermann.owncloud.notes\", \"activityName\": null}}</item> <item>{\"type\": \"action:app\", \"app\": {\"packageName\": \"it.niedermann.owncloud.notes\", \"activityName\": null}}</item>
<item>{\"type\": \"action:app\", \"app\": {\"packageName\": \"com.samsung.android.app.notes\", \"activityName\": null}}</item> <!-- Samsung Notes --> <item>{\"type\": \"action:app\", \"app\": {\"packageName\": \"com.samsung.android.app.notes\", \"activityName\": null}}</item> <!-- Samsung Notes -->
<item>{\"type\": \"action:app\", \"app\": {\"packageName\": \"com.sec.android.widgetapp.diotek.smemo\", \"activityName\": null}}</item> <!-- S Memo (older devices) --> <item>{\"type\": \"action:app\", \"app\": {\"packageName\": \"com.sec.android.widgetapp.diotek.smemo\", \"activityName\": null}}</item> <!-- S Memo (older devices) -->
<item>{\"type\": \"action:launcher\", \"id\": \"lock_screen\"}</item> <item>{\"type\": \"action:launcher\", \"value\": \"lock_screen\"}</item>
</string-array> </string-array>
<!-- Long click - Security --> <!-- Long click - Security -->
@ -128,7 +128,7 @@
<item>{\"type\": \"action:app\", \"app\": {\"packageName\": \"org.fedorahosted.freeotp\", \"activityName\": null}}</item> <item>{\"type\": \"action:app\", \"app\": {\"packageName\": \"org.fedorahosted.freeotp\", \"activityName\": null}}</item>
<item>{\"type\": \"action:app\", \"app\": {\"packageName\": \"proton.android.pass.fdroid\", \"activityName\": null}}</item> <!-- Proton Pass --> <item>{\"type\": \"action:app\", \"app\": {\"packageName\": \"proton.android.pass.fdroid\", \"activityName\": null}}</item> <!-- Proton Pass -->
<item>{\"type\": \"action:app\", \"app\": {\"packageName\": \"com.kunzisoft.keepass.libre\", \"activityName\": null}}</item> <!-- KeePassDX --> <item>{\"type\": \"action:app\", \"app\": {\"packageName\": \"com.kunzisoft.keepass.libre\", \"activityName\": null}}</item> <!-- KeePassDX -->
<item>{\"type\": \"action:launcher\", \"id\": \"settings\"}</item> <!-- Launcher Settings --> <item>{\"type\": \"action:launcher\", \"value\": \"settings\"}</item> <!-- Launcher Settings -->
</string-array> </string-array>
<!-- Time / Clock --> <!-- Time / Clock -->