1 /*
2  * Copyright 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.glance.state
18 
19 import android.content.Context
20 import androidx.annotation.RestrictTo
21 import androidx.datastore.core.DataStore
22 import androidx.datastore.preferences.core.PreferenceDataStoreFactory
23 import androidx.datastore.preferences.core.Preferences
24 import androidx.datastore.preferences.preferencesDataStoreFile
25 import java.io.File
26 import kotlinx.coroutines.CoroutineScope
27 import kotlinx.coroutines.flow.first
28 import kotlinx.coroutines.sync.Mutex
29 import kotlinx.coroutines.sync.withLock
30 
31 /**
32  * Configuration definition for [GlanceState]. This defines where the data is stored and how the
33  * underlying data store is created. Use a unique [GlanceStateDefinition] to get a [GlanceState],
34  * once defined, the data should be updated using the state directly, this definition should not
35  * change.
36  */
37 interface GlanceStateDefinition<T> {
38 
39     /**
40      * This file indicates the location of the persisted data.
41      *
42      * @param context The context used to create the file directory
43      * @param fileKey The unique string key used to name and identify the data file corresponding to
44      *   a remote UI. Each remote UI has a unique UI key, used to key the data for that UI.
45      */
getLocationnull46     fun getLocation(context: Context, fileKey: String): File
47 
48     /**
49      * Creates the underlying data store.
50      *
51      * @param context The context used to create locate the file directory
52      * @param fileKey The unique string key used to name and identify the data file corresponding to
53      *   a remote UI. Each remote UI has a unique UI key, used to key the data for that UI.
54      */
55     suspend fun getDataStore(context: Context, fileKey: String): DataStore<T>
56 }
57 
58 /**
59  * Interface for an object that manages configuration for glanceables using the given
60  * GlanceStateDefinition.
61  */
62 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
63 interface ConfigManager {
64     /**
65      * Returns the stored data associated with the given UI key string.
66      *
67      * @param definition the configuration that defines this state.
68      * @param fileKey identifies the data file associated with the store, must be unique for any
69      *   remote UI in the app.
70      */
71     suspend fun <T> getValue(
72         context: Context,
73         definition: GlanceStateDefinition<T>,
74         fileKey: String
75     ): T
76 
77     /**
78      * Updates the underlying data by applying the provided update block.
79      *
80      * @param definition the configuration that defines this state.
81      * @param fileKey identifies the date file associated with the store, must be unique for any
82      *   remote UI in the app.
83      */
84     suspend fun <T> updateValue(
85         context: Context,
86         definition: GlanceStateDefinition<T>,
87         fileKey: String,
88         updateBlock: suspend (T) -> T
89     ): T
90 
91     /**
92      * Delete the file underlying the [DataStore] and remove local references to the [DataStore].
93      */
94     suspend fun deleteStore(context: Context, definition: GlanceStateDefinition<*>, fileKey: String)
95 }
96 
97 /**
98  * Data store for data specific to the glanceable view. Stored data should include information
99  * relevant to the representation of views, but not surface specific view data. For example, the
100  * month displayed on a calendar rather than actual calendar entries.
101  */
102 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
103 object GlanceState : ConfigManager {
getValuenull104     override suspend fun <T> getValue(
105         context: Context,
106         definition: GlanceStateDefinition<T>,
107         fileKey: String
108     ): T = getDataStore(context, definition, fileKey).data.first()
109 
110     override suspend fun <T> updateValue(
111         context: Context,
112         definition: GlanceStateDefinition<T>,
113         fileKey: String,
114         updateBlock: suspend (T) -> T
115     ): T = getDataStore(context, definition, fileKey).updateData(updateBlock)
116 
117     override suspend fun deleteStore(
118         context: Context,
119         definition: GlanceStateDefinition<*>,
120         fileKey: String
121     ) {
122         mutex.withLock {
123             dataStores.remove(fileKey)
124             val location = definition.getLocation(context, fileKey)
125             location.delete()
126         }
127     }
128 
129     @Suppress("UNCHECKED_CAST")
getDataStorenull130     private suspend fun <T> getDataStore(
131         context: Context,
132         definition: GlanceStateDefinition<T>,
133         fileKey: String
134     ): DataStore<T> =
135         mutex.withLock {
136             dataStores.getOrPut(fileKey) { definition.getDataStore(context, fileKey) }
137                 as DataStore<T>
138         }
139 
140     private val mutex = Mutex()
141 
142     // TODO: Move to a single, global source to manage the data lifecycle
143     private val dataStores: MutableMap<String, DataStore<*>> = mutableMapOf()
144 }
145 
146 /** Base class helping the creation of a state using DataStore's [Preferences]. */
147 object PreferencesGlanceStateDefinition : GlanceStateDefinition<Preferences> {
148     private var coroutineScope: CoroutineScope? = null
149 
getLocationnull150     override fun getLocation(context: Context, fileKey: String): File =
151         context.preferencesDataStoreFile(fileKey)
152 
153     override suspend fun getDataStore(context: Context, fileKey: String): DataStore<Preferences> {
154         return coroutineScope?.let {
155             PreferenceDataStoreFactory.create(scope = it) {
156                 context.preferencesDataStoreFile(fileKey)
157             }
158         } ?: PreferenceDataStoreFactory.create { context.preferencesDataStoreFile(fileKey) }
159     }
160 
161     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
setCoroutineScopenull162     fun setCoroutineScope(scope: CoroutineScope) {
163         coroutineScope = scope
164     }
165 }
166