Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ lint-*ml

# Prevent exidental commits of build folders
opencloudApp/release

# Log files
logcat.txt
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ androidxTest = "1.6.1"
androidxTestExt = "1.2.1"
androidxTestMonitor = "1.7.2"
androidxTestUiAutomator ="2.3.0"
androidxDataStore = "1.0.0"
androidxWork = "2.8.1"
coil = "2.2.2"
detekt = "1.23.3"
Expand Down Expand Up @@ -56,6 +57,7 @@ androidx-biometric = { group = "androidx.biometric", name = "biometric", version
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidxContraintLayout" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" }
androidx-enterprise-feedback = { group = "androidx.enterprise", name = "enterprise-feedback", version.ref = "androidxEnterpriseFeedback" }
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragment" }
androidx-fragment-testing = { group = "androidx.fragment", name = "fragment-testing", version.ref = "androidxFragment" }
Expand Down
1 change: 1 addition & 0 deletions opencloudApp/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies {
implementation libs.androidx.biometric
implementation libs.androidx.constraintlayout
implementation libs.androidx.core.ktx
implementation libs.androidx.datastore.preferences
implementation libs.androidx.fragment.ktx
implementation libs.androidx.legacy.support
implementation libs.androidx.lifecycle.common.java8
Expand Down
18 changes: 16 additions & 2 deletions opencloudApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REORDER_TASKS" />

<!-- Android 11+ package visibility: allow discovering apps that handle file open/send -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="*/*" />
</intent>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" />
</intent>
<intent>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<data android:mimeType="*/*" />
</intent>
</queries>

<application
android:name=".MainApp"
android:allowBackup="false"
Expand Down Expand Up @@ -196,8 +212,6 @@
android:name=".presentation.security.passcode.PassCodeActivity"
android:label="@string/passcode_label"
android:screenOrientation="portrait" />
<activity
android:name=".presentation.conflicts.ConflictsResolveActivity" />
<activity
android:name=".presentation.logging.LogsListActivity"
android:label="@string/prefs_log_open_logs_list_view"
Expand Down
8 changes: 0 additions & 8 deletions opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ import eu.opencloud.android.ui.activity.WhatsNewActivity
import eu.opencloud.android.utils.CONFIGURATION_ALLOW_SCREENSHOTS
import eu.opencloud.android.utils.DOWNLOAD_NOTIFICATION_CHANNEL_ID
import eu.opencloud.android.utils.DebugInjector
import eu.opencloud.android.utils.FILE_SYNC_CONFLICT_NOTIFICATION_CHANNEL_ID
import eu.opencloud.android.utils.FILE_SYNC_NOTIFICATION_CHANNEL_ID
import eu.opencloud.android.utils.MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID
import eu.opencloud.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID
Expand Down Expand Up @@ -320,13 +319,6 @@ class MainApp : Application() {
importance = IMPORTANCE_LOW
)

createNotificationChannel(
id = FILE_SYNC_CONFLICT_NOTIFICATION_CHANNEL_ID,
name = getString(R.string.file_sync_conflict_notification_channel_name),
description = getString(R.string.file_sync_conflict_notification_channel_description),
importance = IMPORTANCE_LOW
)

createNotificationChannel(
id = FILE_SYNC_NOTIFICATION_CHANNEL_ID,
name = getString(R.string.file_sync_notification_channel_name),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* openCloud Android client application
*
* @author OpenCloud Development Team
*
* Copyright (C) 2026 OpenCloud.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* 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 eu.opencloud.android.data.download

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import timber.log.Timber

/**
* Data class representing the current progress of the [DownloadEverythingWorker] scan.
*/
data class DownloadProgress(
val accountIndex: Int = 0,
val spaceIndex: Int = 0,
val processedFolderIds: Set<Long> = emptySet(),
val totalFilesFound: Int = 0,
val filesDownloaded: Int = 0,
val filesAlreadyLocal: Int = 0,
val filesSkipped: Int = 0,
val foldersProcessed: Int = 0,
val isRunning: Boolean = false,
val lastUpdateTimestamp: Long = 0
)

/**
* DataStore-backed persistence for [DownloadEverythingWorker] progress.
*
* Stores the scan state so that the worker can resume from where it left off
* instead of restarting from scratch on every interruption (Doze, app killed,
* screen lock, network drop, etc.).
*
* Progress older than [MAX_AGE_MS] is considered stale and will be discarded.
*/
class DownloadProgressDataStore(private val context: Context) {

private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = PREFERENCES_NAME)

private val dataStore = context.dataStore

/**
* Persists the current progress to DataStore.
*/
suspend fun saveProgress(progress: DownloadProgress) {
try {
dataStore.edit { prefs ->
prefs[KEY_ACCOUNT_INDEX] = progress.accountIndex
prefs[KEY_SPACE_INDEX] = progress.spaceIndex
prefs[KEY_PROCESSED_FOLDER_IDS] = progress.processedFolderIds.joinToString(SEPARATOR)
prefs[KEY_TOTAL_FILES_FOUND] = progress.totalFilesFound
prefs[KEY_FILES_DOWNLOADED] = progress.filesDownloaded
prefs[KEY_FILES_ALREADY_LOCAL] = progress.filesAlreadyLocal
prefs[KEY_FILES_SKIPPED] = progress.filesSkipped
prefs[KEY_FOLDERS_PROCESSED] = progress.foldersProcessed
prefs[KEY_IS_RUNNING] = progress.isRunning
prefs[KEY_LAST_UPDATE_TIMESTAMP] = System.currentTimeMillis()
}
} catch (e: Exception) {
Timber.e(e, "Failed to save download progress")
}
}

/**
* Loads the last saved progress if it exists and is still valid.
*
* Returns `null` when:
* - No progress was ever saved
* - The saved progress is not marked as running
* - The saved progress is older than [MAX_AGE_MS]
*/
suspend fun loadProgress(): DownloadProgress? {
return try {
val prefs = dataStore.data.map { it }.firstOrNull() ?: return null

val isRunning = prefs[KEY_IS_RUNNING] ?: false
if (!isRunning) return null

val timestamp = prefs[KEY_LAST_UPDATE_TIMESTAMP] ?: 0L
val age = System.currentTimeMillis() - timestamp
if (age > MAX_AGE_MS) {
Timber.w("Download progress is ${age / 1000}s old (> ${MAX_AGE_MS / 1000}s), discarding")
clearProgress()
return null
}

val folderIdsString = prefs[KEY_PROCESSED_FOLDER_IDS] ?: ""
val processedFolderIds = if (folderIdsString.isNotBlank()) {
folderIdsString.split(SEPARATOR).mapNotNull { it.toLongOrNull() }.toSet()
} else {
emptySet()
}

DownloadProgress(
accountIndex = prefs[KEY_ACCOUNT_INDEX] ?: 0,
spaceIndex = prefs[KEY_SPACE_INDEX] ?: 0,
processedFolderIds = processedFolderIds,
totalFilesFound = prefs[KEY_TOTAL_FILES_FOUND] ?: 0,
filesDownloaded = prefs[KEY_FILES_DOWNLOADED] ?: 0,
filesAlreadyLocal = prefs[KEY_FILES_ALREADY_LOCAL] ?: 0,
filesSkipped = prefs[KEY_FILES_SKIPPED] ?: 0,
foldersProcessed = prefs[KEY_FOLDERS_PROCESSED] ?: 0,
isRunning = true,
lastUpdateTimestamp = timestamp
)
} catch (e: Exception) {
Timber.e(e, "Failed to load download progress")
null
}
}

/**
* Clears all saved progress. Call this when the scan completes successfully.
*/
suspend fun clearProgress() {
try {
dataStore.edit { prefs ->
prefs.remove(KEY_ACCOUNT_INDEX)
prefs.remove(KEY_SPACE_INDEX)
prefs.remove(KEY_PROCESSED_FOLDER_IDS)
prefs.remove(KEY_TOTAL_FILES_FOUND)
prefs.remove(KEY_FILES_DOWNLOADED)
prefs.remove(KEY_FILES_ALREADY_LOCAL)
prefs.remove(KEY_FILES_SKIPPED)
prefs.remove(KEY_FOLDERS_PROCESSED)
prefs.remove(KEY_IS_RUNNING)
prefs.remove(KEY_LAST_UPDATE_TIMESTAMP)
}
} catch (e: Exception) {
Timber.e(e, "Failed to clear download progress")
}
}

companion object {
private const val PREFERENCES_NAME = "download_progress"
private const val SEPARATOR = ","
private const val MAX_AGE_MS = 24L * 60L * 60L * 1000L // 24 hours

private val KEY_ACCOUNT_INDEX = intPreferencesKey("account_index")
private val KEY_SPACE_INDEX = intPreferencesKey("space_index")
private val KEY_PROCESSED_FOLDER_IDS = stringPreferencesKey("processed_folder_ids")
private val KEY_TOTAL_FILES_FOUND = intPreferencesKey("total_files_found")
private val KEY_FILES_DOWNLOADED = intPreferencesKey("files_downloaded")
private val KEY_FILES_ALREADY_LOCAL = intPreferencesKey("files_already_local")
private val KEY_FILES_SKIPPED = intPreferencesKey("files_skipped")
private val KEY_FOLDERS_PROCESSED = intPreferencesKey("folders_processed")
private val KEY_IS_RUNNING = booleanPreferencesKey("is_running")
private val KEY_LAST_UPDATE_TIMESTAMP = longPreferencesKey("last_update_timestamp")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ package eu.opencloud.android.dependecyinjection

import androidx.work.WorkManager

import eu.opencloud.android.data.download.DownloadProgressDataStore
import eu.opencloud.android.providers.AccountProvider
import eu.opencloud.android.providers.ContextProvider
import eu.opencloud.android.providers.CoroutinesDispatcherProvider
Expand All @@ -43,4 +44,5 @@ val commonModule = module {
single { WorkManagerProvider(androidContext()) }
single { AccountProvider(androidContext()) }
single { WorkManager.getInstance(androidApplication()) }
single { DownloadProgressDataStore(androidContext()) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ val remoteDataSourceModule = module {
singleOf(::OCRemoteShareeDataSource) bind RemoteShareeDataSource::class
singleOf(::OCRemoteSpacesDataSource) bind RemoteSpacesDataSource::class
singleOf(::OCRemoteWebFingerDataSource) bind RemoteWebFingerDataSource::class
single<RemoteUserDataSource> { OCRemoteUserDataSource(get(), androidContext().resources.getDimension(R.dimen.file_avatar_size).toInt()) }
singleOf(::OCRemoteUserDataSource) bind RemoteUserDataSource::class

factoryOf(::RemoteCapabilityMapper)
factoryOf(::RemoteShareMapper)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ import eu.opencloud.android.domain.files.usecases.ManageDeepLinkUseCase
import eu.opencloud.android.domain.files.usecases.MoveFileUseCase
import eu.opencloud.android.domain.files.usecases.RemoveFileUseCase
import eu.opencloud.android.domain.files.usecases.RenameFileUseCase
import eu.opencloud.android.domain.files.usecases.SaveConflictUseCase
import eu.opencloud.android.domain.files.usecases.SaveDownloadWorkerUUIDUseCase
import eu.opencloud.android.domain.files.usecases.SaveFileOrFolderUseCase
import eu.opencloud.android.domain.files.usecases.SetLastUsageFileUseCase
Expand Down Expand Up @@ -184,7 +183,6 @@ val useCaseModule = module {
factoryOf(::RemoveLocalFilesForAccountUseCase)
factoryOf(::RemoveLocallyFilesWithLastUsageOlderThanGivenTimeUseCase)
factoryOf(::RenameFileUseCase)
factoryOf(::SaveConflictUseCase)
factoryOf(::SaveDownloadWorkerUUIDUseCase)
factoryOf(::SaveFileOrFolderUseCase)
factoryOf(::SetLastUsageFileUseCase)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import eu.opencloud.android.presentation.authentication.AuthenticationViewModel
import eu.opencloud.android.presentation.authentication.oauth.OAuthViewModel
import eu.opencloud.android.presentation.capabilities.CapabilityViewModel
import eu.opencloud.android.presentation.common.DrawerViewModel
import eu.opencloud.android.presentation.conflicts.ConflictsResolveViewModel
import eu.opencloud.android.presentation.files.details.FileDetailsViewModel
import eu.opencloud.android.presentation.files.filelist.MainFileListViewModel
import eu.opencloud.android.presentation.files.operations.FileOperationsViewModel
Expand Down Expand Up @@ -94,7 +93,6 @@ val viewModelModule = module {
MainFileListViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),
initialFolderToDisplay, fileListOption)
}
viewModel { (ocFile: OCFile) -> ConflictsResolveViewModel(get(), get(), get(), get(), get(), ocFile) }
viewModel { AuthenticationViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
viewModel { MigrationViewModel(MainApp.dataFolder, get(), get(), get(), get(), get(), get(), get()) }
viewModel { TransfersViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import android.net.Uri
import android.text.method.LinkMovementMethod
import android.util.TypedValue
import android.view.inputmethod.InputMethodManager
import android.webkit.MimeTypeMap
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
Expand Down Expand Up @@ -163,8 +162,8 @@ private fun getIntentForSavedMimeType(data: Uri, type: String): Intent {
private fun getIntentForGuessedMimeType(storagePath: String, type: String, data: Uri): Intent? {
var intentForGuessedMimeType: Intent? = null
if (storagePath.lastIndexOf('.') >= 0) {
val guessedMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(storagePath.substring(storagePath.lastIndexOf('.') + 1))
if (guessedMimeType != null && guessedMimeType != type) {
val guessedMimeType = MimetypeIconUtil.getBestMimeTypeByFilename(storagePath)
if (guessedMimeType != null && guessedMimeType != type && guessedMimeType != "application/octet-stream") {
intentForGuessedMimeType = Intent(Intent.ACTION_VIEW)
intentForGuessedMimeType.setDataAndType(data, guessedMimeType)
intentForGuessedMimeType.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
Expand Down Expand Up @@ -456,8 +455,13 @@ fun FragmentActivity.sendDownloadedFilesByShareSheet(ocFiles: List<OCFile>) {
}

fun Activity.openOCFile(ocFile: OCFile) {
var finalMimeType = MimetypeIconUtil.getBestMimeTypeByFilename(ocFile.fileName)
if (finalMimeType.isNullOrEmpty() || finalMimeType == "application/octet-stream") {
finalMimeType = ocFile.mimeType
}

val intentForSavedMimeType = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(getExposedFileUriForOCFile(this@openOCFile, ocFile), ocFile.mimeType)
setDataAndType(getExposedFileUriForOCFile(this@openOCFile, ocFile), finalMimeType)
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
if (ocFile.hasWritePermission) {
flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
Expand Down
Loading
Loading