1 /*
<lambda>null2  * Copyright (C) 2022 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 com.android.permissioncontroller.safetylabel
18 
19 import android.content.Context
20 import android.os.Build
21 import android.provider.DeviceConfig
22 import android.util.AtomicFile
23 import android.util.Log
24 import android.util.Xml
25 import androidx.annotation.RequiresApi
26 import com.android.permissioncontroller.safetylabel.AppsSafetyLabelHistory.AppInfo
27 import com.android.permissioncontroller.safetylabel.AppsSafetyLabelHistory.AppSafetyLabelDiff
28 import com.android.permissioncontroller.safetylabel.AppsSafetyLabelHistory.AppSafetyLabelHistory
29 import com.android.permissioncontroller.safetylabel.AppsSafetyLabelHistory.DataCategory
30 import com.android.permissioncontroller.safetylabel.AppsSafetyLabelHistory.DataLabel
31 import com.android.permissioncontroller.safetylabel.AppsSafetyLabelHistory.SafetyLabel
32 import java.io.File
33 import java.io.FileNotFoundException
34 import java.io.FileOutputStream
35 import java.io.IOException
36 import java.nio.charset.StandardCharsets
37 import java.time.Instant
38 import org.xmlpull.v1.XmlPullParser
39 import org.xmlpull.v1.XmlPullParserException
40 import org.xmlpull.v1.XmlSerializer
41 
42 /** Persists safety label history to disk and allows reading from and writing to this storage */
43 @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
44 object AppsSafetyLabelHistoryPersistence {
45     private const val TAG_DATA_SHARED_MAP = "shared"
46     private const val TAG_DATA_SHARED_ENTRY = "entry"
47     private const val TAG_APP_INFO = "app-info"
48     private const val TAG_DATA_LABEL = "data-lbl"
49     private const val TAG_SAFETY_LABEL = "sfty-lbl"
50     private const val TAG_APP_SAFETY_LABEL_HISTORY = "app-hstry"
51     private const val TAG_APPS_SAFETY_LABEL_HISTORY = "apps-hstry"
52     private const val ATTRIBUTE_VERSION = "vrs"
53     private const val ATTRIBUTE_PACKAGE_NAME = "pkg-name"
54     private const val ATTRIBUTE_RECEIVED_AT = "rcvd"
55     private const val ATTRIBUTE_CATEGORY = "cat"
56     private const val ATTRIBUTE_CONTAINS_ADS = "ads"
57     private const val CURRENT_VERSION = 0
58     private const val INITIAL_VERSION = 0
59 
60     /** The name of the file used to persist Safety Label history. */
61     private const val APPS_SAFETY_LABEL_HISTORY_PERSISTENCE_FILE_NAME =
62         "apps_safety_label_history_persistence.xml"
63     private val LOG_TAG = "AppsSafetyLabelHistoryPersistence".take(23)
64     private val readWriteLock = Any()
65 
66     private var listeners = mutableSetOf<ChangeListener>()
67 
68     /** Adds a listener to listen for changes to persisted safety labels. */
69     fun addListener(listener: ChangeListener) {
70         synchronized(readWriteLock) { listeners.add(listener) }
71     }
72 
73     /** Removes a listener from listening for changes to persisted safety labels. */
74     fun removeListener(listener: ChangeListener) {
75         synchronized(readWriteLock) { listeners.remove(listener) }
76     }
77 
78     /**
79      * Reads the provided file storing safety label history and returns the parsed
80      * [AppsSafetyLabelHistoryFileContent].
81      */
82     fun read(file: File): AppsSafetyLabelHistoryFileContent {
83         val parser = Xml.newPullParser()
84         try {
85             AtomicFile(file).openRead().let { inputStream ->
86                 parser.setInput(inputStream, StandardCharsets.UTF_8.name())
87                 return parser.parseHistoryFile()
88             }
89         } catch (e: FileNotFoundException) {
90             Log.e(LOG_TAG, "File not found: $file")
91         } catch (e: IOException) {
92             Log.e(
93                 LOG_TAG,
94                 "Failed to read file: $file, encountered exception ${e.localizedMessage}"
95             )
96         } catch (e: XmlPullParserException) {
97             Log.e(
98                 LOG_TAG,
99                 "Failed to parse file: $file, encountered exception ${e.localizedMessage}"
100             )
101         }
102 
103         return AppsSafetyLabelHistoryFileContent(appsSafetyLabelHistory = null, INITIAL_VERSION)
104     }
105 
106     /** Returns the last updated time for each stored [AppSafetyLabelHistory]. */
107     fun getSafetyLabelsLastUpdatedTimes(file: File): Map<AppInfo, Instant> {
108         synchronized(readWriteLock) {
109             val appHistories =
110                 read(file).appsSafetyLabelHistory?.appSafetyLabelHistories ?: return emptyMap()
111 
112             val lastUpdatedTimes = mutableMapOf<AppInfo, Instant>()
113             for (appHistory in appHistories) {
114                 val lastSafetyLabelReceiptTime: Instant? = appHistory.getLastReceiptTime()
115                 if (lastSafetyLabelReceiptTime != null) {
116                     lastUpdatedTimes[appHistory.appInfo] = lastSafetyLabelReceiptTime
117                 }
118             }
119 
120             return lastUpdatedTimes
121         }
122     }
123 
124     /**
125      * Writes a new safety label to the provided file, if the provided safety label has changed from
126      * the last recorded.
127      */
128     fun recordSafetyLabel(safetyLabel: SafetyLabel, file: File) {
129         synchronized(readWriteLock) {
130             val currentAppsSafetyLabelHistory =
131                 read(file).appsSafetyLabelHistory ?: AppsSafetyLabelHistory(listOf())
132             val appInfo = safetyLabel.appInfo
133             val currentHistories = currentAppsSafetyLabelHistory.appSafetyLabelHistories
134 
135             val updatedAppsSafetyLabelHistory: AppsSafetyLabelHistory =
136                 if (currentHistories.all { it.appInfo != appInfo }) {
137                     AppsSafetyLabelHistory(
138                         currentHistories.toMutableList().apply {
139                             add(AppSafetyLabelHistory(appInfo, listOf(safetyLabel)))
140                         }
141                     )
142                 } else {
143                     AppsSafetyLabelHistory(
144                         currentHistories.map {
145                             if (it.appInfo != appInfo) it
146                             else it.addSafetyLabelIfChanged(safetyLabel)
147                         }
148                     )
149                 }
150 
151             write(file, updatedAppsSafetyLabelHistory)
152         }
153     }
154 
155     /**
156      * Writes new safety labels to the provided file, if the provided safety labels have changed
157      * from the last recorded (when considered in order of [SafetyLabel.receivedAt]).
158      */
159     fun recordSafetyLabels(safetyLabelsToAdd: Set<SafetyLabel>, file: File) {
160         if (safetyLabelsToAdd.isEmpty()) return
161 
162         synchronized(readWriteLock) {
163             val currentAppsSafetyLabelHistory =
164                 read(file).appsSafetyLabelHistory ?: AppsSafetyLabelHistory(listOf())
165             val appInfoToOrderedSafetyLabels =
166                 safetyLabelsToAdd
167                     .groupBy { it.appInfo }
168                     .mapValues { (_, safetyLabels) -> safetyLabels.sortedBy { it.receivedAt } }
169             val currentAppHistories = currentAppsSafetyLabelHistory.appSafetyLabelHistories
170             val newApps =
171                 appInfoToOrderedSafetyLabels.keys - currentAppHistories.map { it.appInfo }.toSet()
172 
173             // For apps that already have some safety labels persisted, add the provided safety
174             // labels to the history.
175             var updatedAppHistories: List<AppSafetyLabelHistory> =
176                 currentAppHistories.map { currentAppHistory: AppSafetyLabelHistory ->
177                     val safetyLabels = appInfoToOrderedSafetyLabels[currentAppHistory.appInfo]
178                     if (safetyLabels != null) {
179                         currentAppHistory.addSafetyLabelsIfChanged(safetyLabels)
180                     } else {
181                         currentAppHistory
182                     }
183                 }
184 
185             // For apps that don't already have some safety labels persisted, add new
186             // AppSafetyLabelHistory instances for them with the provided safety labels.
187             updatedAppHistories =
188                 updatedAppHistories.toMutableList().apply {
189                     newApps.forEach { newAppInfo ->
190                         val safetyLabelsForNewApp = appInfoToOrderedSafetyLabels[newAppInfo]
191                         if (safetyLabelsForNewApp != null) {
192                             add(AppSafetyLabelHistory(newAppInfo, safetyLabelsForNewApp))
193                         }
194                     }
195                 }
196 
197             write(file, AppsSafetyLabelHistory(updatedAppHistories))
198         }
199     }
200 
201     /** Deletes stored safety labels for all apps in [appInfosToRemove]. */
202     fun deleteSafetyLabelsForApps(appInfosToRemove: Set<AppInfo>, file: File) {
203         if (appInfosToRemove.isEmpty()) return
204 
205         synchronized(readWriteLock) {
206             val currentAppsSafetyLabelHistory =
207                 read(file).appsSafetyLabelHistory ?: AppsSafetyLabelHistory(listOf())
208             val historiesWithAppsRemoved =
209                 currentAppsSafetyLabelHistory.appSafetyLabelHistories.filter {
210                     it.appInfo !in appInfosToRemove
211                 }
212 
213             write(file, AppsSafetyLabelHistory(historiesWithAppsRemoved))
214         }
215     }
216 
217     /**
218      * Deletes stored safety labels so that there is at most one safety label for each app with
219      * [SafetyLabel.receivedAt] earlier that [startTime].
220      */
221     fun deleteSafetyLabelsOlderThan(startTime: Instant, file: File) {
222         synchronized(readWriteLock) {
223             val currentAppsSafetyLabelHistory =
224                 read(file).appsSafetyLabelHistory ?: AppsSafetyLabelHistory(listOf())
225             val updatedAppHistories =
226                 currentAppsSafetyLabelHistory.appSafetyLabelHistories.map { appHistory ->
227                     val history = appHistory.safetyLabelHistory
228                     // Retrieve the last safety label that was received prior to startTime.
229                     val last =
230                         history.indexOfLast { safetyLabels -> safetyLabels.receivedAt <= startTime }
231                     if (last <= 0) {
232                         // If there is only one or no safety labels received prior to startTime,
233                         // then return the history as is.
234                         appHistory
235                     } else {
236                         // Else, discard all safety labels other than the last safety label prior
237                         // to startTime. The aim is retain one safety label prior to start time to
238                         // be used as the "before" safety label when determining updates.
239                         AppSafetyLabelHistory(
240                             appHistory.appInfo,
241                             history.subList(last, history.size)
242                         )
243                     }
244                 }
245 
246             write(file, AppsSafetyLabelHistory(updatedAppHistories))
247         }
248     }
249 
250     /**
251      * Serializes and writes the provided [AppsSafetyLabelHistory] with [CURRENT_VERSION] schema to
252      * the provided file.
253      */
254     fun write(file: File, appsSafetyLabelHistory: AppsSafetyLabelHistory) {
255         write(file, AppsSafetyLabelHistoryFileContent(appsSafetyLabelHistory, CURRENT_VERSION))
256     }
257 
258     /**
259      * Serializes and writes the provided [AppsSafetyLabelHistoryFileContent] to the provided file.
260      */
261     fun write(file: File, fileContent: AppsSafetyLabelHistoryFileContent) {
262         val atomicFile = AtomicFile(file)
263         var outputStream: FileOutputStream? = null
264 
265         try {
266             outputStream = atomicFile.startWrite()
267             val serializer = Xml.newSerializer()
268             serializer.setOutput(outputStream, StandardCharsets.UTF_8.name())
269             serializer.startDocument(null, true)
270             serializer.serializeAllAppSafetyLabelHistory(fileContent)
271             serializer.endDocument()
272             atomicFile.finishWrite(outputStream)
273             listeners.forEach { it.onSafetyLabelHistoryChanged() }
274         } catch (e: Exception) {
275             Log.i(
276                 LOG_TAG,
277                 "Failed to write to $file. Previous version of file will be restored.",
278                 e
279             )
280             atomicFile.failWrite(outputStream)
281         } finally {
282             try {
283                 outputStream?.close()
284             } catch (e: Exception) {
285                 Log.e(LOG_TAG, "Failed to close $file.", e)
286             }
287         }
288     }
289 
290     /** Reads the provided history file and returns all safety label changes since [startTime]. */
291     fun getAppSafetyLabelDiffs(startTime: Instant, file: File): List<AppSafetyLabelDiff> {
292         val currentAppsSafetyLabelHistory =
293             read(file).appsSafetyLabelHistory ?: AppsSafetyLabelHistory(listOf())
294 
295         return currentAppsSafetyLabelHistory.appSafetyLabelHistories.mapNotNull {
296             val before = it.getSafetyLabelAt(startTime)
297             val after = it.getLatestSafetyLabel()
298             if (
299                 before == null ||
300                     after == null ||
301                     before == after ||
302                     before.receivedAt.isAfter(after.receivedAt)
303             )
304                 null
305             else AppSafetyLabelDiff(before, after)
306         }
307     }
308 
309     /** Clears the file. */
310     fun clear(file: File) {
311         AtomicFile(file).delete()
312     }
313 
314     /** Returns the file persisting safety label history for installed apps. */
315     fun getSafetyLabelHistoryFile(context: Context): File =
316         File(context.filesDir, APPS_SAFETY_LABEL_HISTORY_PERSISTENCE_FILE_NAME)
317 
318     private fun AppSafetyLabelHistory.getLastReceiptTime(): Instant? =
319         this.safetyLabelHistory.lastOrNull()?.receivedAt
320 
321     private fun XmlPullParser.parseHistoryFile(): AppsSafetyLabelHistoryFileContent {
322         if (eventType != XmlPullParser.START_DOCUMENT) {
323             throw IllegalArgumentException()
324         }
325         nextTag()
326 
327         val appsSafetyLabelHistory = parseAppsSafetyLabelHistory()
328 
329         while (eventType == XmlPullParser.TEXT && isWhitespace) {
330             next()
331         }
332         if (eventType != XmlPullParser.END_DOCUMENT) {
333             throw IllegalArgumentException("Unexpected extra element")
334         }
335 
336         return appsSafetyLabelHistory
337     }
338 
339     private fun XmlPullParser.parseAppsSafetyLabelHistory(): AppsSafetyLabelHistoryFileContent {
340         checkTagStart(TAG_APPS_SAFETY_LABEL_HISTORY)
341         var version: Int? = null
342         for (i in 0 until attributeCount) {
343             when (getAttributeName(i)) {
344                 ATTRIBUTE_VERSION -> version = getAttributeValue(i).toInt()
345                 else ->
346                     throw IllegalArgumentException(
347                         "Unexpected attribute ${getAttributeName(i)} in tag" +
348                             " $TAG_APPS_SAFETY_LABEL_HISTORY"
349                     )
350             }
351         }
352         if (version == null) {
353             version = INITIAL_VERSION
354             Log.w(LOG_TAG, "Missing $ATTRIBUTE_VERSION in $TAG_APPS_SAFETY_LABEL_HISTORY")
355         }
356         nextTag()
357 
358         val appSafetyLabelHistories = mutableListOf<AppSafetyLabelHistory>()
359         while (eventType == XmlPullParser.START_TAG && name == TAG_APP_SAFETY_LABEL_HISTORY) {
360             appSafetyLabelHistories.add(parseAppSafetyLabelHistory())
361         }
362 
363         checkTagEnd(TAG_APPS_SAFETY_LABEL_HISTORY)
364         next()
365 
366         return AppsSafetyLabelHistoryFileContent(
367             AppsSafetyLabelHistory(appSafetyLabelHistories),
368             version
369         )
370     }
371 
372     private fun XmlPullParser.parseAppSafetyLabelHistory(): AppSafetyLabelHistory {
373         checkTagStart(TAG_APP_SAFETY_LABEL_HISTORY)
374         nextTag()
375 
376         val appInfo = parseAppInfo()
377 
378         val safetyLabels = mutableListOf<SafetyLabel>()
379         while (eventType == XmlPullParser.START_TAG && name == TAG_SAFETY_LABEL) {
380             safetyLabels.add(parseSafetyLabel(appInfo))
381         }
382 
383         checkTagEnd(TAG_APP_SAFETY_LABEL_HISTORY)
384         nextTag()
385 
386         return AppSafetyLabelHistory(appInfo, safetyLabels)
387     }
388 
389     private fun XmlPullParser.parseSafetyLabel(appInfo: AppInfo): SafetyLabel {
390         checkTagStart(TAG_SAFETY_LABEL)
391 
392         var receivedAt: Instant? = null
393         for (i in 0 until attributeCount) {
394             when (getAttributeName(i)) {
395                 ATTRIBUTE_RECEIVED_AT -> receivedAt = parseInstant(getAttributeValue(i))
396                 else ->
397                     throw IllegalArgumentException(
398                         "Unexpected attribute ${getAttributeName(i)} in tag $TAG_SAFETY_LABEL"
399                     )
400             }
401         }
402         if (receivedAt == null) {
403             throw IllegalArgumentException("Missing $ATTRIBUTE_RECEIVED_AT in $TAG_SAFETY_LABEL")
404         }
405         nextTag()
406 
407         val dataLabel = parseDataLabel()
408 
409         checkTagEnd(TAG_SAFETY_LABEL)
410         nextTag()
411 
412         return SafetyLabel(appInfo, receivedAt, dataLabel)
413     }
414 
415     private fun XmlPullParser.parseDataLabel(): DataLabel {
416         checkTagStart(TAG_DATA_LABEL)
417         nextTag()
418 
419         val dataSharing = parseDataShared()
420 
421         checkTagEnd(TAG_DATA_LABEL)
422         nextTag()
423 
424         return DataLabel(dataSharing)
425     }
426 
427     private fun XmlPullParser.parseDataShared(): Map<String, DataCategory> {
428         checkTagStart(TAG_DATA_SHARED_MAP)
429         nextTag()
430 
431         val sharedCategories = mutableListOf<Pair<String, DataCategory>>()
432         while (eventType == XmlPullParser.START_TAG && name == TAG_DATA_SHARED_ENTRY) {
433             sharedCategories.add(parseDataSharedEntry())
434         }
435 
436         checkTagEnd(TAG_DATA_SHARED_MAP)
437         nextTag()
438 
439         return sharedCategories.associate { it.first to it.second }
440     }
441 
442     private fun XmlPullParser.parseDataSharedEntry(): Pair<String, DataCategory> {
443         checkTagStart(TAG_DATA_SHARED_ENTRY)
444         var category: String? = null
445         var hasAds: Boolean? = null
446         for (i in 0 until attributeCount) {
447             when (getAttributeName(i)) {
448                 ATTRIBUTE_CATEGORY -> category = getAttributeValue(i)
449                 ATTRIBUTE_CONTAINS_ADS -> hasAds = getAttributeValue(i).toBoolean()
450                 else ->
451                     throw IllegalArgumentException(
452                         "Unexpected attribute ${getAttributeName(i)} in tag $TAG_DATA_SHARED_ENTRY"
453                     )
454             }
455         }
456         if (category == null) {
457             throw IllegalArgumentException("Missing $ATTRIBUTE_CATEGORY in $TAG_DATA_SHARED_ENTRY")
458         }
459         if (hasAds == null) {
460             throw IllegalArgumentException(
461                 "Missing $ATTRIBUTE_CONTAINS_ADS in $TAG_DATA_SHARED_ENTRY"
462             )
463         }
464         nextTag()
465 
466         checkTagEnd(TAG_DATA_SHARED_ENTRY)
467         nextTag()
468 
469         return category to DataCategory(hasAds)
470     }
471 
472     private fun XmlPullParser.parseAppInfo(): AppInfo {
473         checkTagStart(TAG_APP_INFO)
474         var packageName: String? = null
475         for (i in 0 until attributeCount) {
476             when (getAttributeName(i)) {
477                 ATTRIBUTE_PACKAGE_NAME -> packageName = getAttributeValue(i)
478                 else ->
479                     throw IllegalArgumentException(
480                         "Unexpected attribute ${getAttributeName(i)} in tag $TAG_APP_INFO"
481                     )
482             }
483         }
484         if (packageName == null) {
485             throw IllegalArgumentException("Missing $ATTRIBUTE_PACKAGE_NAME in $TAG_APP_INFO")
486         }
487 
488         nextTag()
489         checkTagEnd(TAG_APP_INFO)
490         nextTag()
491         return AppInfo(packageName)
492     }
493 
494     private fun XmlPullParser.checkTagStart(tag: String) {
495         check(eventType == XmlPullParser.START_TAG && tag == name)
496     }
497 
498     private fun XmlPullParser.checkTagEnd(tag: String) {
499         check(eventType == XmlPullParser.END_TAG && tag == name)
500     }
501 
502     private fun parseInstant(value: String): Instant {
503         return try {
504             Instant.ofEpochMilli(value.toLong())
505         } catch (e: Exception) {
506             throw IllegalArgumentException("Could not parse $value as Instant")
507         }
508     }
509 
510     private fun XmlSerializer.serializeAllAppSafetyLabelHistory(
511         fileContent: AppsSafetyLabelHistoryFileContent
512     ) {
513         startTag(null, TAG_APPS_SAFETY_LABEL_HISTORY)
514         attribute(null, ATTRIBUTE_VERSION, fileContent.version.toString())
515         fileContent.appsSafetyLabelHistory?.appSafetyLabelHistories?.forEach {
516             serializeAppSafetyLabelHistory(it)
517         }
518         endTag(null, TAG_APPS_SAFETY_LABEL_HISTORY)
519     }
520 
521     private fun XmlSerializer.serializeAppSafetyLabelHistory(
522         appSafetyLabelHistory: AppSafetyLabelHistory
523     ) {
524         startTag(null, TAG_APP_SAFETY_LABEL_HISTORY)
525         serializeAppInfo(appSafetyLabelHistory.appInfo)
526         appSafetyLabelHistory.safetyLabelHistory.forEach { serializeSafetyLabel(it) }
527         endTag(null, TAG_APP_SAFETY_LABEL_HISTORY)
528     }
529 
530     private fun XmlSerializer.serializeAppInfo(appInfo: AppInfo) {
531         startTag(null, TAG_APP_INFO)
532         attribute(null, ATTRIBUTE_PACKAGE_NAME, appInfo.packageName)
533         endTag(null, TAG_APP_INFO)
534     }
535 
536     private fun XmlSerializer.serializeSafetyLabel(safetyLabel: SafetyLabel) {
537         startTag(null, TAG_SAFETY_LABEL)
538         attribute(null, ATTRIBUTE_RECEIVED_AT, safetyLabel.receivedAt.toEpochMilli().toString())
539         serializeDataLabel(safetyLabel.dataLabel)
540         endTag(null, TAG_SAFETY_LABEL)
541     }
542 
543     private fun XmlSerializer.serializeDataLabel(dataLabel: DataLabel) {
544         startTag(null, TAG_DATA_LABEL)
545         serializeDataSharedMap(dataLabel.dataShared)
546         endTag(null, TAG_DATA_LABEL)
547     }
548 
549     private fun XmlSerializer.serializeDataSharedMap(dataShared: Map<String, DataCategory>) {
550         startTag(null, TAG_DATA_SHARED_MAP)
551         dataShared.entries.forEach { serializeDataSharedEntry(it) }
552         endTag(null, TAG_DATA_SHARED_MAP)
553     }
554 
555     private fun XmlSerializer.serializeDataSharedEntry(
556         dataSharedEntry: Map.Entry<String, DataCategory>
557     ) {
558         startTag(null, TAG_DATA_SHARED_ENTRY)
559         attribute(null, ATTRIBUTE_CATEGORY, dataSharedEntry.key)
560         attribute(
561             null,
562             ATTRIBUTE_CONTAINS_ADS,
563             dataSharedEntry.value.containsAdvertisingPurpose.toString()
564         )
565         endTag(null, TAG_DATA_SHARED_ENTRY)
566     }
567 
568     private fun AppSafetyLabelHistory.addSafetyLabelIfChanged(
569         safetyLabel: SafetyLabel
570     ): AppSafetyLabelHistory {
571         val latestSafetyLabel = safetyLabelHistory.lastOrNull()
572         return if (latestSafetyLabel?.dataLabel == safetyLabel.dataLabel) this
573         else this.withSafetyLabel(safetyLabel, getMaxSafetyLabelsToPersist())
574     }
575 
576     private fun AppSafetyLabelHistory.addSafetyLabelsIfChanged(
577         safetyLabels: List<SafetyLabel>
578     ): AppSafetyLabelHistory {
579         var updatedAppHistory = this
580         val maxSafetyLabels = getMaxSafetyLabelsToPersist()
581         for (safetyLabel in safetyLabels) {
582             val latestSafetyLabel = updatedAppHistory.safetyLabelHistory.lastOrNull()
583             if (latestSafetyLabel?.dataLabel != safetyLabel.dataLabel) {
584                 updatedAppHistory = updatedAppHistory.withSafetyLabel(safetyLabel, maxSafetyLabels)
585             }
586         }
587 
588         return updatedAppHistory
589     }
590 
591     private fun AppSafetyLabelHistory.getLatestSafetyLabel() = safetyLabelHistory.lastOrNull()
592 
593     /**
594      * Return the safety label known to be the current safety label for the app at the provided
595      * time, if available in the history.
596      */
597     private fun AppSafetyLabelHistory.getSafetyLabelAt(startTime: Instant) =
598         safetyLabelHistory.lastOrNull {
599             // the last received safety label before or at startTime
600             it.receivedAt.isBefore(startTime) || it.receivedAt == startTime
601         }
602             ?: // the first safety label received after startTime, as a fallback
603         safetyLabelHistory.firstOrNull { it.receivedAt.isAfter(startTime) }
604 
605     private const val PROPERTY_MAX_SAFETY_LABELS_PERSISTED_PER_APP =
606         "max_safety_labels_persisted_per_app"
607 
608     /**
609      * Returns the maximum number of safety labels to persist per app.
610      *
611      * Note that this will be checked at the time of adding a new safety label to storage for an
612      * app; simply changing this Device Config property will not result in any storage being purged.
613      */
614     private fun getMaxSafetyLabelsToPersist() =
615         DeviceConfig.getInt(
616             DeviceConfig.NAMESPACE_PRIVACY,
617             PROPERTY_MAX_SAFETY_LABELS_PERSISTED_PER_APP,
618             20
619         )
620 
621     /** An interface to listen to changes to persisted safety labels. */
622     interface ChangeListener {
623         /** Callback when the persisted safety labels are changed. */
624         fun onSafetyLabelHistoryChanged()
625     }
626 
627     /** Data class to hold an [AppsSafetyLabelHistory] along with the file schema version. */
628     data class AppsSafetyLabelHistoryFileContent(
629         val appsSafetyLabelHistory: AppsSafetyLabelHistory?,
630         val version: Int,
631     )
632 }
633