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() + } + +}