Skip to content

Commit 9cc8d15

Browse files
committed
feat: add offline access, automatic sync, and conflict resolution
- Implement offline sync with external storage and conflict handling - Implement DownloadProgressDataStore and integrate with DownloadEverythingWorker - Resolve critical bugs in download worker and conflict resolution - Fix storage migration and file downloading - Run DownloadFileWorker as a foreground service to prevent Doze mode interruptions
1 parent f653870 commit 9cc8d15

47 files changed

Lines changed: 1790 additions & 611 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,6 @@ lint-*ml
3333

3434
# Prevent exidental commits of build folders
3535
opencloudApp/release
36+
37+
# Log files
38+
logcat.txt

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ androidxTest = "1.6.1"
2020
androidxTestExt = "1.2.1"
2121
androidxTestMonitor = "1.7.2"
2222
androidxTestUiAutomator ="2.3.0"
23+
androidxDataStore = "1.0.0"
2324
androidxWork = "2.8.1"
2425
coil = "2.2.2"
2526
detekt = "1.23.3"
@@ -56,6 +57,7 @@ androidx-biometric = { group = "androidx.biometric", name = "biometric", version
5657
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" }
5758
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidxContraintLayout" }
5859
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }
60+
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" }
5961
androidx-enterprise-feedback = { group = "androidx.enterprise", name = "enterprise-feedback", version.ref = "androidxEnterpriseFeedback" }
6062
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragment" }
6163
androidx-fragment-testing = { group = "androidx.fragment", name = "fragment-testing", version.ref = "androidxFragment" }

opencloudApp/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dependencies {
2222
implementation libs.androidx.biometric
2323
implementation libs.androidx.constraintlayout
2424
implementation libs.androidx.core.ktx
25+
implementation libs.androidx.datastore.preferences
2526
implementation libs.androidx.fragment.ktx
2627
implementation libs.androidx.legacy.support
2728
implementation libs.androidx.lifecycle.common.java8

opencloudApp/src/main/AndroidManifest.xml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,6 @@
196196
android:name=".presentation.security.passcode.PassCodeActivity"
197197
android:label="@string/passcode_label"
198198
android:screenOrientation="portrait" />
199-
<activity
200-
android:name=".presentation.conflicts.ConflictsResolveActivity" />
201199
<activity
202200
android:name=".presentation.logging.LogsListActivity"
203201
android:label="@string/prefs_log_open_logs_list_view"

opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ import eu.opencloud.android.ui.activity.WhatsNewActivity
7474
import eu.opencloud.android.utils.CONFIGURATION_ALLOW_SCREENSHOTS
7575
import eu.opencloud.android.utils.DOWNLOAD_NOTIFICATION_CHANNEL_ID
7676
import eu.opencloud.android.utils.DebugInjector
77-
import eu.opencloud.android.utils.FILE_SYNC_CONFLICT_NOTIFICATION_CHANNEL_ID
7877
import eu.opencloud.android.utils.FILE_SYNC_NOTIFICATION_CHANNEL_ID
7978
import eu.opencloud.android.utils.MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID
8079
import eu.opencloud.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID
@@ -320,13 +319,6 @@ class MainApp : Application() {
320319
importance = IMPORTANCE_LOW
321320
)
322321

323-
createNotificationChannel(
324-
id = FILE_SYNC_CONFLICT_NOTIFICATION_CHANNEL_ID,
325-
name = getString(R.string.file_sync_conflict_notification_channel_name),
326-
description = getString(R.string.file_sync_conflict_notification_channel_description),
327-
importance = IMPORTANCE_LOW
328-
)
329-
330322
createNotificationChannel(
331323
id = FILE_SYNC_NOTIFICATION_CHANNEL_ID,
332324
name = getString(R.string.file_sync_notification_channel_name),
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* openCloud Android client application
3+
*
4+
* @author OpenCloud Development Team
5+
*
6+
* Copyright (C) 2026 OpenCloud.
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the GNU General Public License version 2,
10+
* as published by the Free Software Foundation.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU General Public License
18+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
19+
*/
20+
21+
package eu.opencloud.android.data.download
22+
23+
import android.content.Context
24+
import androidx.datastore.core.DataStore
25+
import androidx.datastore.preferences.core.Preferences
26+
import androidx.datastore.preferences.core.booleanPreferencesKey
27+
import androidx.datastore.preferences.core.edit
28+
import androidx.datastore.preferences.core.intPreferencesKey
29+
import androidx.datastore.preferences.core.longPreferencesKey
30+
import androidx.datastore.preferences.core.stringPreferencesKey
31+
import androidx.datastore.preferences.preferencesDataStore
32+
import kotlinx.coroutines.flow.firstOrNull
33+
import kotlinx.coroutines.flow.map
34+
import timber.log.Timber
35+
36+
/**
37+
* Data class representing the current progress of the [DownloadEverythingWorker] scan.
38+
*/
39+
data class DownloadProgress(
40+
val accountIndex: Int = 0,
41+
val spaceIndex: Int = 0,
42+
val processedFolderIds: Set<Long> = emptySet(),
43+
val totalFilesFound: Int = 0,
44+
val filesDownloaded: Int = 0,
45+
val filesAlreadyLocal: Int = 0,
46+
val filesSkipped: Int = 0,
47+
val foldersProcessed: Int = 0,
48+
val isRunning: Boolean = false,
49+
val lastUpdateTimestamp: Long = 0
50+
)
51+
52+
/**
53+
* DataStore-backed persistence for [DownloadEverythingWorker] progress.
54+
*
55+
* Stores the scan state so that the worker can resume from where it left off
56+
* instead of restarting from scratch on every interruption (Doze, app killed,
57+
* screen lock, network drop, etc.).
58+
*
59+
* Progress older than [MAX_AGE_MS] is considered stale and will be discarded.
60+
*/
61+
class DownloadProgressDataStore(private val context: Context) {
62+
63+
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = PREFERENCES_NAME)
64+
65+
private val dataStore = context.dataStore
66+
67+
/**
68+
* Persists the current progress to DataStore.
69+
*/
70+
suspend fun saveProgress(progress: DownloadProgress) {
71+
try {
72+
dataStore.edit { prefs ->
73+
prefs[KEY_ACCOUNT_INDEX] = progress.accountIndex
74+
prefs[KEY_SPACE_INDEX] = progress.spaceIndex
75+
prefs[KEY_PROCESSED_FOLDER_IDS] = progress.processedFolderIds.joinToString(SEPARATOR)
76+
prefs[KEY_TOTAL_FILES_FOUND] = progress.totalFilesFound
77+
prefs[KEY_FILES_DOWNLOADED] = progress.filesDownloaded
78+
prefs[KEY_FILES_ALREADY_LOCAL] = progress.filesAlreadyLocal
79+
prefs[KEY_FILES_SKIPPED] = progress.filesSkipped
80+
prefs[KEY_FOLDERS_PROCESSED] = progress.foldersProcessed
81+
prefs[KEY_IS_RUNNING] = progress.isRunning
82+
prefs[KEY_LAST_UPDATE_TIMESTAMP] = System.currentTimeMillis()
83+
}
84+
} catch (e: Exception) {
85+
Timber.e(e, "Failed to save download progress")
86+
}
87+
}
88+
89+
/**
90+
* Loads the last saved progress if it exists and is still valid.
91+
*
92+
* Returns `null` when:
93+
* - No progress was ever saved
94+
* - The saved progress is not marked as running
95+
* - The saved progress is older than [MAX_AGE_MS]
96+
*/
97+
suspend fun loadProgress(): DownloadProgress? {
98+
return try {
99+
val prefs = dataStore.data.map { it }.firstOrNull() ?: return null
100+
101+
val isRunning = prefs[KEY_IS_RUNNING] ?: false
102+
if (!isRunning) return null
103+
104+
val timestamp = prefs[KEY_LAST_UPDATE_TIMESTAMP] ?: 0L
105+
val age = System.currentTimeMillis() - timestamp
106+
if (age > MAX_AGE_MS) {
107+
Timber.w("Download progress is ${age / 1000}s old (> ${MAX_AGE_MS / 1000}s), discarding")
108+
clearProgress()
109+
return null
110+
}
111+
112+
val folderIdsString = prefs[KEY_PROCESSED_FOLDER_IDS] ?: ""
113+
val processedFolderIds = if (folderIdsString.isNotBlank()) {
114+
folderIdsString.split(SEPARATOR).mapNotNull { it.toLongOrNull() }.toSet()
115+
} else {
116+
emptySet()
117+
}
118+
119+
DownloadProgress(
120+
accountIndex = prefs[KEY_ACCOUNT_INDEX] ?: 0,
121+
spaceIndex = prefs[KEY_SPACE_INDEX] ?: 0,
122+
processedFolderIds = processedFolderIds,
123+
totalFilesFound = prefs[KEY_TOTAL_FILES_FOUND] ?: 0,
124+
filesDownloaded = prefs[KEY_FILES_DOWNLOADED] ?: 0,
125+
filesAlreadyLocal = prefs[KEY_FILES_ALREADY_LOCAL] ?: 0,
126+
filesSkipped = prefs[KEY_FILES_SKIPPED] ?: 0,
127+
foldersProcessed = prefs[KEY_FOLDERS_PROCESSED] ?: 0,
128+
isRunning = true,
129+
lastUpdateTimestamp = timestamp
130+
)
131+
} catch (e: Exception) {
132+
Timber.e(e, "Failed to load download progress")
133+
null
134+
}
135+
}
136+
137+
/**
138+
* Clears all saved progress. Call this when the scan completes successfully.
139+
*/
140+
suspend fun clearProgress() {
141+
try {
142+
dataStore.edit { prefs ->
143+
prefs.remove(KEY_ACCOUNT_INDEX)
144+
prefs.remove(KEY_SPACE_INDEX)
145+
prefs.remove(KEY_PROCESSED_FOLDER_IDS)
146+
prefs.remove(KEY_TOTAL_FILES_FOUND)
147+
prefs.remove(KEY_FILES_DOWNLOADED)
148+
prefs.remove(KEY_FILES_ALREADY_LOCAL)
149+
prefs.remove(KEY_FILES_SKIPPED)
150+
prefs.remove(KEY_FOLDERS_PROCESSED)
151+
prefs.remove(KEY_IS_RUNNING)
152+
prefs.remove(KEY_LAST_UPDATE_TIMESTAMP)
153+
}
154+
} catch (e: Exception) {
155+
Timber.e(e, "Failed to clear download progress")
156+
}
157+
}
158+
159+
companion object {
160+
private const val PREFERENCES_NAME = "download_progress"
161+
private const val SEPARATOR = ","
162+
private const val MAX_AGE_MS = 24L * 60L * 60L * 1000L // 24 hours
163+
164+
private val KEY_ACCOUNT_INDEX = intPreferencesKey("account_index")
165+
private val KEY_SPACE_INDEX = intPreferencesKey("space_index")
166+
private val KEY_PROCESSED_FOLDER_IDS = stringPreferencesKey("processed_folder_ids")
167+
private val KEY_TOTAL_FILES_FOUND = intPreferencesKey("total_files_found")
168+
private val KEY_FILES_DOWNLOADED = intPreferencesKey("files_downloaded")
169+
private val KEY_FILES_ALREADY_LOCAL = intPreferencesKey("files_already_local")
170+
private val KEY_FILES_SKIPPED = intPreferencesKey("files_skipped")
171+
private val KEY_FOLDERS_PROCESSED = intPreferencesKey("folders_processed")
172+
private val KEY_IS_RUNNING = booleanPreferencesKey("is_running")
173+
private val KEY_LAST_UPDATE_TIMESTAMP = longPreferencesKey("last_update_timestamp")
174+
}
175+
}

opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/CommonModule.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ package eu.opencloud.android.dependecyinjection
2222

2323
import androidx.work.WorkManager
2424

25+
import eu.opencloud.android.data.download.DownloadProgressDataStore
2526
import eu.opencloud.android.providers.AccountProvider
2627
import eu.opencloud.android.providers.ContextProvider
2728
import eu.opencloud.android.providers.CoroutinesDispatcherProvider
@@ -43,4 +44,5 @@ val commonModule = module {
4344
single { WorkManagerProvider(androidContext()) }
4445
single { AccountProvider(androidContext()) }
4546
single { WorkManager.getInstance(androidApplication()) }
47+
single { DownloadProgressDataStore(androidContext()) }
4648
}

opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/RemoteDataSourceModule.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ val remoteDataSourceModule = module {
7878
singleOf(::OCRemoteShareeDataSource) bind RemoteShareeDataSource::class
7979
singleOf(::OCRemoteSpacesDataSource) bind RemoteSpacesDataSource::class
8080
singleOf(::OCRemoteWebFingerDataSource) bind RemoteWebFingerDataSource::class
81-
single<RemoteUserDataSource> { OCRemoteUserDataSource(get(), androidContext().resources.getDimension(R.dimen.file_avatar_size).toInt()) }
81+
singleOf(::OCRemoteUserDataSource) bind RemoteUserDataSource::class
8282

8383
factoryOf(::RemoteCapabilityMapper)
8484
factoryOf(::RemoteShareMapper)

opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/UseCaseModule.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ import eu.opencloud.android.domain.files.usecases.ManageDeepLinkUseCase
7272
import eu.opencloud.android.domain.files.usecases.MoveFileUseCase
7373
import eu.opencloud.android.domain.files.usecases.RemoveFileUseCase
7474
import eu.opencloud.android.domain.files.usecases.RenameFileUseCase
75-
import eu.opencloud.android.domain.files.usecases.SaveConflictUseCase
7675
import eu.opencloud.android.domain.files.usecases.SaveDownloadWorkerUUIDUseCase
7776
import eu.opencloud.android.domain.files.usecases.SaveFileOrFolderUseCase
7877
import eu.opencloud.android.domain.files.usecases.SetLastUsageFileUseCase
@@ -184,7 +183,6 @@ val useCaseModule = module {
184183
factoryOf(::RemoveLocalFilesForAccountUseCase)
185184
factoryOf(::RemoveLocallyFilesWithLastUsageOlderThanGivenTimeUseCase)
186185
factoryOf(::RenameFileUseCase)
187-
factoryOf(::SaveConflictUseCase)
188186
factoryOf(::SaveDownloadWorkerUUIDUseCase)
189187
factoryOf(::SaveFileOrFolderUseCase)
190188
factoryOf(::SetLastUsageFileUseCase)

opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/ViewModelModule.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import eu.opencloud.android.presentation.authentication.AuthenticationViewModel
3131
import eu.opencloud.android.presentation.authentication.oauth.OAuthViewModel
3232
import eu.opencloud.android.presentation.capabilities.CapabilityViewModel
3333
import eu.opencloud.android.presentation.common.DrawerViewModel
34-
import eu.opencloud.android.presentation.conflicts.ConflictsResolveViewModel
3534
import eu.opencloud.android.presentation.files.details.FileDetailsViewModel
3635
import eu.opencloud.android.presentation.files.filelist.MainFileListViewModel
3736
import eu.opencloud.android.presentation.files.operations.FileOperationsViewModel
@@ -94,7 +93,6 @@ val viewModelModule = module {
9493
MainFileListViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),
9594
initialFolderToDisplay, fileListOption)
9695
}
97-
viewModel { (ocFile: OCFile) -> ConflictsResolveViewModel(get(), get(), get(), get(), get(), ocFile) }
9896
viewModel { AuthenticationViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
9997
viewModel { MigrationViewModel(MainApp.dataFolder, get(), get(), get(), get(), get(), get(), get()) }
10098
viewModel { TransfersViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),

0 commit comments

Comments
 (0)