Skip to content

Commit 675b179

Browse files
Merge pull request #1468 from jbsession/refactor/avatar-cleanup
Recipient Settings and Avatar cache cleanup
2 parents eee71fd + c197125 commit 675b179

File tree

6 files changed

+255
-2
lines changed

6 files changed

+255
-2
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package org.session.libsession.avatars
2+
3+
import android.app.Application
4+
import kotlinx.coroutines.CoroutineScope
5+
import kotlinx.coroutines.Dispatchers
6+
import kotlinx.coroutines.launch
7+
import kotlinx.coroutines.withContext
8+
import org.session.libsession.utilities.recipients.RemoteFile
9+
import org.session.libsignal.utilities.Log
10+
import org.thoughtcrime.securesms.attachments.RemoteFileDownloadWorker
11+
import org.thoughtcrime.securesms.database.RecipientSettingsDatabase
12+
import org.thoughtcrime.securesms.dependencies.ManagerScope
13+
import org.thoughtcrime.securesms.glide.RecipientAvatarDownloadManager
14+
import java.io.File
15+
import javax.inject.Inject
16+
import javax.inject.Singleton
17+
18+
@Singleton
19+
class AvatarCacheCleaner @Inject constructor(
20+
private val application: Application,
21+
private val recipientAvatarDownloadManager: RecipientAvatarDownloadManager,
22+
private val recipientSettingsDatabase: RecipientSettingsDatabase,
23+
@param:ManagerScope private val coroutineScope: CoroutineScope
24+
) {
25+
26+
companion object {
27+
const val TAG = "AvatarCacheCleaner"
28+
}
29+
30+
/**
31+
* Deletes avatar files under cache/remote_files that are no longer referenced
32+
* in the current config. Returns number of files deleted.
33+
*/
34+
private suspend fun cleanUpAvatars(): Int = withContext(Dispatchers.IO) {
35+
// 1) Build the set of still-wanted Avatars from:
36+
// -> Config
37+
// -> Recipient Settings DB
38+
39+
// config
40+
val avatarsFromConfig: Set<RemoteFile> = recipientAvatarDownloadManager.getAllAvatars()
41+
// recipient_settings
42+
val recipientAvatars : Set<RemoteFile> = recipientSettingsDatabase.getAllReferencedAvatarFiles()
43+
44+
// 3) Union of everything we want to keep
45+
val filesToKeep: Set<RemoteFile> =
46+
(avatarsFromConfig + recipientAvatars).toSet()
47+
48+
// 4) Map to actual files (same hashing/location as downloader)
49+
val wantedFiles: Set<File> = filesToKeep
50+
.map { RemoteFileDownloadWorker.computeFileName(application, it) }
51+
.toSet()
52+
53+
// 5) Delete everything not wanted in cache/remote_files
54+
val files = RemoteFileDownloadWorker.listDownloadedFiles(application)
55+
var deleted = 0
56+
for (file in files) {
57+
if (file !in wantedFiles && file.delete()) deleted++
58+
}
59+
60+
deleted
61+
}
62+
63+
fun launchAvatarCleanup() {
64+
coroutineScope.launch(Dispatchers.IO) {
65+
val deleted = cleanUpAvatars()
66+
Log.d(TAG, "Avatar cache removed: $deleted files")
67+
}
68+
}
69+
}

app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,52 @@ fun ConfigFactoryProtocol.userConfigsChanged(
151151
}
152152
}
153153

154+
/** All addresses that exist in config and therefore must be kept. */
155+
fun ConfigFactoryProtocol.allConfigAddresses(): Set<Address> {
156+
val (contacts, blinded, groups) = withUserConfigs { config ->
157+
Triple(config.contacts.all(), config.contacts.allBlinded(), config.userGroups.all())
158+
}
159+
160+
val contactsAddress : Set<Address> =
161+
contacts.asSequence().map { Address.fromSerialized(it.id) }.toSet()
162+
val blindedAddress : Set<Address> =
163+
blinded.asSequence().map { Address.fromSerialized(it.id) }.toSet()
164+
165+
val closedIds = mutableListOf<AccountId>()
166+
val groupAddresses: Set<Address> = buildSet {
167+
groups.forEach { groupInfo ->
168+
when (groupInfo) {
169+
is GroupInfo.LegacyGroupInfo -> {
170+
add(Address.LegacyGroup(groupInfo.accountId))
171+
groupInfo.members.keys.forEach { add(Address.fromSerialized(it)) }
172+
}
173+
is GroupInfo.ClosedGroupInfo -> {
174+
val groupId = AccountId(groupInfo.groupAccountId)
175+
closedIds += groupId
176+
add(Address.Group(groupId))
177+
}
178+
is GroupInfo.CommunityGroupInfo -> {
179+
add(Address.Community(groupInfo.community.baseUrl, groupInfo.community.room))
180+
}
181+
}
182+
}
183+
}
184+
185+
val closedMemberAddresses: Set<Address> = buildSet {
186+
closedIds.forEach { groupId ->
187+
withGroupConfigs(groupId) { config ->
188+
config.groupMembers.all().forEach { add(Address.fromSerialized(it.accountId())) }
189+
}
190+
}
191+
}
192+
193+
return buildSet {
194+
addAll(contactsAddress)
195+
addAll(blindedAddress)
196+
addAll(groupAddresses)
197+
addAll(closedMemberAddresses)
198+
}
199+
}
154200

155201
/**
156202
* Wait until all configs of given group are pushed to the server.

app/src/main/java/org/thoughtcrime/securesms/attachments/RemoteFileDownloadWorker.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ class RemoteFileDownloadWorker @AssistedInject constructor(
261261
private const val ARG_COMMUNITY_ROOM_ID = "community_room_id"
262262
private const val ARG_COMMUNITY_FILE_ID = "community_file_id"
263263

264+
private const val SUBDIRECTORY = "remote_files"
265+
264266
private fun RemoteFile.sha256Hash(): String {
265267
val hash = MessageDigest.getInstance("SHA-256")
266268
when (this) {
@@ -278,9 +280,12 @@ class RemoteFileDownloadWorker @AssistedInject constructor(
278280
return hash.digest().toHexString()
279281
}
280282

283+
private fun downloadsDirectory(context: Context): File =
284+
File(context.cacheDir, SUBDIRECTORY)
285+
281286
// Deterministically get the file path for the given remote file.
282287
fun computeFileName(context: Context, remote: RemoteFile): File {
283-
return File(context.cacheDir, "remote_files/${remote.sha256Hash()}")
288+
return File(downloadsDirectory(context), remote.sha256Hash())
284289
}
285290

286291
fun cancelAll(context: Context) {
@@ -297,6 +302,12 @@ class RemoteFileDownloadWorker @AssistedInject constructor(
297302
)
298303
}
299304

305+
/** Returns all currently downloaded files (may be empty). */
306+
fun listDownloadedFiles(context: Context): List<File> {
307+
val directory = downloadsDirectory(context)
308+
return directory.listFiles()?.toList().orEmpty()
309+
}
310+
300311
/**
301312
* @param isOldAvatarOf used to indicate that this file is an avatar of a specific address. This
302313
* information is optional and only used for migration purposes (to move the avatar from

app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.collectLatest
99
import kotlinx.coroutines.flow.combine
1010
import kotlinx.coroutines.flow.distinctUntilChanged
1111
import kotlinx.coroutines.flow.filterNotNull
12-
import kotlinx.coroutines.flow.first
1312
import kotlinx.coroutines.flow.flatMapLatest
1413
import kotlinx.coroutines.flow.map
1514
import kotlinx.coroutines.flow.onStart
@@ -19,6 +18,7 @@ import network.loki.messenger.R
1918
import network.loki.messenger.libsession_util.ReadableGroupInfoConfig
2019
import network.loki.messenger.libsession_util.util.Conversation
2120
import network.loki.messenger.libsession_util.util.UserPic
21+
import org.session.libsession.avatars.AvatarCacheCleaner
2222
import org.session.libsession.database.StorageProtocol
2323
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier
2424
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
@@ -30,6 +30,7 @@ import org.session.libsession.utilities.Address.Companion.fromSerialized
3030
import org.session.libsession.utilities.ConfigFactoryProtocol
3131
import org.session.libsession.utilities.TextSecurePreferences
3232
import org.session.libsession.utilities.UserConfigType
33+
import org.session.libsession.utilities.allConfigAddresses
3334
import org.session.libsession.utilities.getGroup
3435
import org.session.libsession.utilities.userConfigsChanged
3536
import org.session.libsignal.crypto.ecc.DjbECPrivateKey
@@ -45,6 +46,7 @@ import org.thoughtcrime.securesms.database.LokiMessageDatabase
4546
import org.thoughtcrime.securesms.database.LokiThreadDatabase
4647
import org.thoughtcrime.securesms.database.MmsDatabase
4748
import org.thoughtcrime.securesms.database.MmsSmsDatabase
49+
import org.thoughtcrime.securesms.database.RecipientSettingsDatabase
4850
import org.thoughtcrime.securesms.database.SmsDatabase
4951
import org.thoughtcrime.securesms.database.ThreadDatabase
5052
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
@@ -83,6 +85,8 @@ class ConfigToDatabaseSync @Inject constructor(
8385
private val mmsSmsDatabase: MmsSmsDatabase,
8486
private val lokiMessageDatabase: LokiMessageDatabase,
8587
private val messageNotifier: MessageNotifier,
88+
private val recipientSettingsDatabase: RecipientSettingsDatabase,
89+
private val avatarCacheCleaner: AvatarCacheCleaner,
8690
@param:ManagerScope private val scope: CoroutineScope,
8791
) : OnAppStartupComponent {
8892
init {
@@ -148,6 +152,9 @@ class ConfigToDatabaseSync @Inject constructor(
148152
}
149153
}
150154
}
155+
156+
// Initiate cleanup in recipient_settings
157+
pruneRecipientSettingsAndAvatars()
151158
}
152159

153160
// If we created threads, we need to update the thread database with the creation date.
@@ -167,6 +174,20 @@ class ConfigToDatabaseSync @Inject constructor(
167174
}
168175
}
169176

177+
private fun pruneRecipientSettingsAndAvatars() {
178+
val addressesToKeep: Set<Address> = buildSet {
179+
addAll(configFactory.allConfigAddresses())
180+
addAll(mmsSmsDatabase.getAllReferencedAddresses())
181+
}
182+
183+
val removed = recipientSettingsDatabase.cleanupRecipientSettings(addressesToKeep)
184+
Log.d(TAG, "Recipient settings pruned: $removed orphan rows")
185+
186+
if (removed > 0) {
187+
avatarCacheCleaner.launchAvatarCleanup()
188+
}
189+
}
190+
170191
private fun deleteGroupData(address: Address.Group) {
171192
lokiAPIDatabase.clearLastMessageHashes(address.accountId.hexString)
172193
lokiAPIDatabase.clearReceivedMessageHashValues(address.accountId.hexString)

app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,26 @@ public Cursor getUnreadOrUnseenReactions() {
449449
return queryTables(PROJECTION, selection, order, null);
450450
}
451451

452+
public Set<Address> getAllReferencedAddresses() {
453+
final String[] projection = new String[] { "DISTINCT " + MmsSmsColumns.ADDRESS };
454+
final String selection = MmsSmsColumns.ADDRESS + " IS NOT NULL" +
455+
" AND " + MmsSmsColumns.ADDRESS + " != ''";
456+
457+
Set<Address> out = new HashSet<>();
458+
try (Cursor cursor = queryTables(projection, selection, null, null)) {
459+
while (cursor != null && cursor.moveToNext()) {
460+
String serialized = cursor.getString(0);
461+
try {
462+
out.add(Address.fromSerialized(serialized));
463+
} catch (Exception e) {
464+
// If parsing fails, skip this row
465+
Log.w(TAG, "Skipping unparsable address: " + serialized, e);
466+
}
467+
}
468+
}
469+
return out;
470+
}
471+
452472
/** Builds the comma-separated list of base types that represent
453473
* *outgoing* messages (same helper as before). */
454474
private String buildOutgoingTypesList() {

app/src/main/java/org/thoughtcrime/securesms/database/RecipientSettingsDatabase.kt

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import dagger.hilt.android.qualifiers.ApplicationContext
99
import kotlinx.coroutines.flow.MutableSharedFlow
1010
import kotlinx.coroutines.flow.SharedFlow
1111
import kotlinx.serialization.json.Json
12+
import network.loki.messenger.libsession_util.util.Bytes
1213
import network.loki.messenger.libsession_util.util.UserPic
1314
import org.session.libsession.utilities.Address
1415
import org.session.libsession.utilities.recipients.ProStatus
16+
import org.session.libsession.utilities.recipients.RemoteFile
1517
import org.session.libsignal.utilities.Base64
1618
import org.session.libsignal.utilities.Log
1719
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
@@ -143,6 +145,90 @@ class RecipientSettingsDatabase @Inject constructor(
143145
}
144146
}
145147

148+
/**
149+
* This method returns all profile pic url and key.
150+
* This will be used to identify which avatars are still being used to exclude
151+
* them from the cleanup.
152+
*/
153+
fun getAllReferencedAvatarFiles(): Set<RemoteFile.Encrypted> {
154+
val recipientAvatars = HashSet<RemoteFile.Encrypted>()
155+
readableDatabase.rawQuery(
156+
"""
157+
SELECT DISTINCT $COL_PROFILE_PIC_URL, $COL_PROFILE_PIC_KEY
158+
FROM $TABLE_NAME
159+
WHERE $COL_PROFILE_PIC_URL IS NOT NULL AND $COL_PROFILE_PIC_URL != ''
160+
AND $COL_PROFILE_PIC_KEY IS NOT NULL AND $COL_PROFILE_PIC_KEY != ''
161+
""".trimIndent(),
162+
null
163+
).use { cursor ->
164+
while (cursor.moveToNext()) {
165+
val url = cursor.getString(0)
166+
val keyB64 = cursor.getString(1)
167+
runCatching {
168+
val keyBytes = Base64.decode(keyB64)
169+
recipientAvatars += RemoteFile.Encrypted(url = url, key = Bytes(keyBytes))
170+
}.onFailure {
171+
// ignore bad rows
172+
}
173+
}
174+
}
175+
return recipientAvatars
176+
}
177+
178+
fun getAllRecipientAddresses(): Set<Address> {
179+
return readableDatabase.rawQuery(
180+
"SELECT $COL_ADDRESS FROM $TABLE_NAME"
181+
).use { cursor ->
182+
buildSet {
183+
while (cursor.moveToNext()) {
184+
val raw = cursor.getString(0)
185+
if (!raw.isNullOrBlank()) add(Address.fromSerialized(raw))
186+
}
187+
}
188+
}
189+
}
190+
191+
/**
192+
* Delete all rows whose address is NOT in [addressesToKeep].
193+
* Returns the number of rows deleted.
194+
*/
195+
fun cleanupRecipientSettings(addressesToKeep: Set<Address>): Int {
196+
if (addressesToKeep.isEmpty()) return 0
197+
198+
// Collect all rows, figure out orphans in memory
199+
val allRecipientAddresses = getAllRecipientAddresses()
200+
val orphans = allRecipientAddresses.filter { it !in addressesToKeep }
201+
202+
if (orphans.isEmpty()) return 0
203+
204+
var deleted = 0
205+
val database = writableDatabase
206+
database.beginTransaction()
207+
try {
208+
for (address in orphans) {
209+
val rows = database.delete(
210+
TABLE_NAME,
211+
"$COL_ADDRESS = ?",
212+
arrayOf(address.toString())
213+
)
214+
if (rows > 0) {
215+
deleted += rows
216+
}
217+
}
218+
database.setTransactionSuccessful()
219+
} finally {
220+
database.endTransaction()
221+
}
222+
223+
// Notify after db transaction
224+
for (address in orphans) {
225+
cache.remove(address)
226+
mutableChangeNotification.tryEmit(address)
227+
}
228+
229+
return deleted
230+
}
231+
146232
companion object {
147233
private const val TAG = "RecipientSettingsDatabase"
148234

0 commit comments

Comments
 (0)