diff --git a/util/android/shared-preferences/build.gradle.kts b/util/android/shared-preferences/build.gradle.kts
index 91bfd859e..f5124bf78 100644
--- a/util/android/shared-preferences/build.gradle.kts
+++ b/util/android/shared-preferences/build.gradle.kts
@@ -25,7 +25,7 @@ plugins {
kotlin("plugin.serialization")
}
-libVersion = Version(0, 2, 1)
+libVersion = Version(0, 2, 2)
android()
diff --git a/util/android/shared-preferences/src/main/kotlin/me/proton/core/util/android/sharedpreferences/observe.kt b/util/android/shared-preferences/src/main/kotlin/me/proton/core/util/android/sharedpreferences/observe.kt
new file mode 100644
index 000000000..94604621c
--- /dev/null
+++ b/util/android/shared-preferences/src/main/kotlin/me/proton/core/util/android/sharedpreferences/observe.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2020 Proton Technologies AG
+ * This file is part of Proton Technologies AG and ProtonCore.
+ *
+ * ProtonCore is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * ProtonCore is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with ProtonCore. If not, see .
+ */
+
+package me.proton.core.util.android.sharedpreferences
+
+import android.content.SharedPreferences
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+/**
+ * @return [Flow] that emits at every change in the receiver [SharedPreferences]
+ */
+fun SharedPreferences.observe(): Flow = callbackFlow {
+
+ // Emit initial value
+ send(SharedPreferencesChangeValue(this@observe, null))
+
+ val listener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
+ offer(SharedPreferencesChangeValue(sharedPreferences, key))
+ }
+ registerOnSharedPreferenceChangeListener(listener)
+ awaitClose { unregisterOnSharedPreferenceChangeListener(listener) }
+}
+
+/**
+ * @return [Flow] that emits at every change related to the given [key] in the receiver [SharedPreferences]
+ */
+inline fun SharedPreferences.observe(key: String): Flow =
+ observe()
+ .map { it.sharedPreferences.get(key) }
+ .distinctUntilChanged()
+
+/**
+ * The model emitted then from [SharedPreferences.observe] on each change
+ * @param key the key of the changed value
+ * it can be null only at initial emission, before any change happened
+ */
+data class SharedPreferencesChangeValue(
+ val sharedPreferences: SharedPreferences,
+ val key: String?
+)
diff --git a/util/android/shared-preferences/src/test/kotlin/me/proton/core/util/android/sharedpreferences/ObserveTest.kt b/util/android/shared-preferences/src/test/kotlin/me/proton/core/util/android/sharedpreferences/ObserveTest.kt
new file mode 100644
index 000000000..8cf083fde
--- /dev/null
+++ b/util/android/shared-preferences/src/test/kotlin/me/proton/core/util/android/sharedpreferences/ObserveTest.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright (c) 2020 Proton Technologies AG
+ * This file is part of Proton Technologies AG and ProtonCore.
+ *
+ * ProtonCore is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * ProtonCore is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with ProtonCore. If not, see .
+ */
+
+package me.proton.core.util.android.sharedpreferences
+
+import android.content.Context
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import me.proton.core.test.kotlin.CoroutinesTest
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+
+/*
+ * Runs with Robolectric as we can't reliably mock listeners in a easy way
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.P])
+class ObserveTest : CoroutinesTest {
+
+ private val prefs = ApplicationProvider.getApplicationContext()
+ .getSharedPreferences("test", Context.MODE_PRIVATE)
+
+ @After
+ fun tearDown() {
+ prefs.clearAll()
+ }
+
+ @Test
+ fun `observe all emits initial value`() = coroutinesTest {
+
+ // given
+ prefs["string"] = "hello"
+ prefs["int"] = 10
+
+ val expected = mapOf(
+ "string" to "hello",
+ "int" to 10
+ )
+
+ // when
+ val flow = prefs.observe()
+
+ // then
+ assertEquals(expected, flow.first().sharedPreferences.all)
+ }
+
+ @Test
+ fun `observe all emits every change`() = coroutinesTest {
+
+ // given
+ val flow = prefs.observe()
+ val result = mutableListOf >>()
+
+ val expected = listOf(
+ null to emptyMap(),
+ "string" to mapOf(
+ "string" to "hello"
+ ),
+ "int" to mapOf(
+ "string" to "hello",
+ "int" to 10,
+ ),
+ "boolean" to mapOf(
+ "boolean" to true,
+ "string" to "hello",
+ "int" to 10,
+ )
+ )
+
+ // when
+ val job = launch {
+ flow.map { it.key to it.sharedPreferences.all.toMap() }.take(4).toList(result)
+ }
+ prefs["string"] = "hello"
+ prefs["int"] = 10
+ prefs["boolean"] = true
+
+ // then
+ @Suppress("TYPE_INFERENCE_ONLY_INPUT_TYPES_WARNING")
+ assertEquals(expected, result)
+ job.cancel()
+ }
+
+ @Test
+ fun `observe single emits initial value`() = coroutinesTest {
+
+ // given
+ prefs["string"] = "hello"
+
+ // when
+ val flow = prefs.observe(key = "string")
+
+ // then
+ assertEquals("hello", flow.first())
+ }
+
+ @Test
+ fun `observe single emits every change`() = coroutinesTest {
+
+ // given
+ val flow = prefs.observe(key = "string")
+ val result = mutableListOf()
+
+ val expected = listOf(
+ null,
+ "hello",
+ "hi"
+ )
+
+ // when
+ val job = launch {
+ flow.take(3).toList(result)
+ }
+ prefs["string"] = "hello"
+ prefs["string"] = "hi"
+
+ // then
+ assertEquals(expected, result)
+ job.cancel()
+ }
+
+ @Test
+ fun `observe single does not emit for unrelated changes`() = coroutinesTest {
+
+ // given
+ val flow = prefs.observe(key = "string")
+ val result = mutableListOf()
+
+ val expected = listOf(
+ null,
+ "hello",
+ )
+
+ // when
+ val job = launch {
+ flow.take(2).toList(result)
+ }
+ prefs["other"] = "hi"
+ prefs["string"] = "hello"
+
+ // then
+ assertEquals(expected, result)
+ job.cancel()
+ }
+
+}