-
Notifications
You must be signed in to change notification settings - Fork 11
/
Copy pathSyncOfflineUtils.kt
274 lines (247 loc) · 10.6 KB
/
SyncOfflineUtils.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
/*
* Infomaniak kDrive - Android
* Copyright (C) 2022-2024 Infomaniak Network SA
*
* This program 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.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.drive.utils
import android.content.Context
import android.net.Uri
import com.infomaniak.drive.R
import com.infomaniak.drive.data.api.ApiRepository
import com.infomaniak.drive.data.cache.DriveInfosController
import com.infomaniak.drive.data.cache.FileController
import com.infomaniak.drive.data.models.File
import com.infomaniak.drive.data.models.FileActivityType
import com.infomaniak.drive.data.models.FileActivityType.*
import com.infomaniak.drive.data.models.UploadFile
import com.infomaniak.drive.data.models.UserDrive
import com.infomaniak.drive.data.models.file.FileLastActivityBody
import com.infomaniak.drive.data.models.file.LastFileAction
import com.infomaniak.drive.utils.MediaUtils.deleteInMediaScan
import com.infomaniak.drive.utils.MediaUtils.isMedia
import com.infomaniak.drive.utils.SyncUtils.syncImmediately
import com.infomaniak.lib.core.utils.SentryLog
import io.realm.Realm
import io.sentry.Sentry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import java.util.Date
object SyncOfflineUtils {
/** Maximum number of files that can be sent to the api */
private const val API_LIMIT_FILES_ACTION_BODY = 500
private const val API_V3_ROOT_FOLDER_NAME = "Private"
private val renameActions = setOf(FILE_RENAME, FILE_RENAME_ALIAS, FILE_MOVE_OUT)
suspend fun startSyncOffline(context: Context) = coroutineScope {
// Delete all offline storage files prior to APIv3. For more info, see deleteLegacyOfflineFolder kDoc
deleteLegacyOfflineFolder(context)
DriveInfosController.getDrives(AccountUtils.currentUserId).forEach { drive ->
ensureActive()
val userDrive = UserDrive(driveId = drive.id)
FileController.getRealmInstance(userDrive).use { realm ->
val localFiles = FileController.getOfflineFiles(order = null, customRealm = realm)
// The api doesn't support sending a list of files that exceeds a certain limit,
// so we chunk the files in relation to this limit.
localFiles.chunked(API_LIMIT_FILES_ACTION_BODY).forEach {
ensureActive()
processChunk(
context = context,
coroutineScope = this,
userDrive = userDrive,
localFilesMap = it.associateBy { file -> file.id },
realm = realm,
)
}
}
}
}
/**
* After the migration from API V2 to API V3, offline files were saved in a different folder called "Private". Because we
* cannot know if we're in a migration or not, we just delete old files and we marked previously isOffline files as
* isMarkedAsOffline to let the BulkDownloadWorker redownload files.
*/
private fun deleteLegacyOfflineFolder(context: Context) {
val userDrive = UserDrive()
val offlineFolder = IOFile(File.getOfflineFolder(context), "${userDrive.userId}/${userDrive.driveId}")
offlineFolder.listFiles()?.forEach { file ->
if (file.name != API_V3_ROOT_FOLDER_NAME) file.deleteRecursively()
}
}
private fun processChunk(
context: Context,
coroutineScope: CoroutineScope,
userDrive: UserDrive,
localFilesMap: Map<Int, File>,
realm: Realm,
) {
if (localFilesMap.isNotEmpty()) {
val fileActionsBody = localFilesMap.values.map { file ->
FileLastActivityBody.FileActionBody(
id = file.id,
fromDate = file.lastActionAt.takeIf { it > 0 } ?: file.updatedAt,
)
}
val lastFilesActions = ApiRepository.getFilesLastActivities(
driveId = userDrive.driveId,
body = FileLastActivityBody(files = fileActionsBody),
).data
// When a local file changes without a corresponding fileAction, we need to synchronize it differently.
// We store file IDs of the processed fileActions to track what has already been handled,
// so we only need to process files that don't have a fileAction.
val fileActionsIds = mutableSetOf<Int>()
lastFilesActions?.forEach { fileAction ->
coroutineScope.ensureActive()
fileActionsIds.add(fileAction.fileId)
handleFileAction(context, fileAction, localFilesMap, userDrive, realm)
}
// Check if any of the files that don't have fileActions require synchronization.
handleFilesWithoutActions(context, localFilesMap, fileActionsIds, userDrive, realm, coroutineScope)
}
}
private fun handleFilesWithoutActions(
context: Context,
localFilesMap: Map<Int, File>,
fileActionsIds: Set<Int>,
userDrive: UserDrive,
realm: Realm,
coroutineScope: CoroutineScope,
) {
for (file in localFilesMap.values) {
coroutineScope.ensureActive()
if (fileActionsIds.contains(file.id)) continue
val ioFile = file.getOfflineFile(context, userDrive.userId) ?: continue
migrateOfflineIfNeeded(context, file, ioFile, userDrive)
if (ioFile.lastModified() > file.revisedAtInMillis) {
uploadFile(
context = context,
localFile = file,
remoteFile = null,
ioFile = ioFile,
userDrive = userDrive,
realm = realm,
)
}
}
}
private fun handleFileAction(
context: Context,
fileAction: LastFileAction,
localFilesMap: Map<Int, File>,
userDrive: UserDrive,
realm: Realm,
) {
val localFile = localFilesMap[fileAction.fileId]
val ioFile = localFile?.getOfflineFile(context, userDrive.userId) ?: return
migrateOfflineIfNeeded(context, localFile, ioFile, userDrive)
when (fileAction.lastAction) {
FileActivityType.FILE_DELETE, FileActivityType.FILE_TRASH -> ioFile.delete()
else -> updateFile(context, ioFile, localFile, fileAction, userDrive, realm)
}
}
private fun updateFile(
context: Context,
ioFile: IOFile,
localFile: File,
fileAction: LastFileAction,
userDrive: UserDrive,
realm: Realm,
) {
val remoteFile = fileAction.file
val ioFileLastModified = ioFile.lastModified()
when {
remoteFile == null -> {
Sentry.withScope { scope ->
scope.setExtra("fileAction", "${fileAction.lastAction}")
SentryLog.e("SyncOffline", "Expect remote file instead of null file")
}
return
}
ioFileLastModified > remoteFile.revisedAtInMillis -> {
uploadFile(context, localFile, remoteFile, ioFile, userDrive, realm)
}
ioFileLastModified < remoteFile.revisedAtInMillis -> {
downloadOfflineFile(context, localFile, remoteFile, ioFile, userDrive, realm)
}
fileAction.lastAction in renameActions -> {
remoteFile.getOfflineFile(context)?.let { ioFile.renameTo(it) }
}
}
FileController.updateFile(localFile.id, realm) { mutableFile ->
mutableFile.lastActionAt = fileAction.lastActionAt ?: 0
}
}
/**
* Migrate old V1 offline files to the V2 offline structure
*/
private fun migrateOfflineIfNeeded(context: Context, file: File, ioFile: IOFile, userDrive: UserDrive) {
val offlineDir = context.getString(R.string.EXPOSED_OFFLINE_DIR)
val oldPath = IOFile(context.filesDir, "$offlineDir/${userDrive.userId}/${userDrive.driveId}/${file.id}")
if (oldPath.exists()) oldPath.renameTo(ioFile)
}
/**
* Update the remote file with the local file
*/
private fun uploadFile(
context: Context,
localFile: File,
remoteFile: File?,
ioFile: IOFile,
userDrive: UserDrive,
realm: Realm,
) {
val uri = Uri.fromFile(ioFile)
val fileModifiedAt = Date(ioFile.lastModified())
if (UploadFile.canUpload(uri, fileModifiedAt)) {
if (remoteFile != null) {
remoteFile.lastModifiedAt = ioFile.lastModified() / 1000
remoteFile.size = ioFile.length()
FileController.updateExistingFile(newFile = remoteFile, realm = realm)
}
UploadFile(
uri = uri.toString(),
driveId = userDrive.driveId,
fileModifiedAt = fileModifiedAt,
fileName = localFile.name,
fileSize = ioFile.length(),
remoteFolder = localFile.parentId,
type = UploadFile.Type.SYNC_OFFLINE.name,
userId = userDrive.userId,
).store()
context.syncImmediately()
}
}
/**
* Update the local file with the remote file
*/
private fun downloadOfflineFile(
context: Context,
file: File,
remoteFile: File,
offlineFile: IOFile,
userDrive: UserDrive,
realm: Realm,
) {
val remoteOfflineFile = remoteFile.getOfflineFile(context, userDrive.userId) ?: return
val pathChanged = offlineFile.path != remoteOfflineFile.path
if (pathChanged) {
if (file.isMedia()) file.deleteInMediaScan(context, userDrive)
offlineFile.delete()
}
if (!file.isMarkedAsOffline && (!remoteFile.isOfflineAndIntact(remoteOfflineFile) || pathChanged)) {
FileController.updateExistingFile(newFile = remoteFile, realm = realm)
Utils.downloadAsOfflineFile(context, remoteFile, userDrive)
}
}
}