main branch updated

This commit is contained in:
Fr4nz D13trich 2025-11-20 16:16:40 +01:00
parent 3d33d3fe49
commit 9a05dc1657
353 changed files with 16802 additions and 2995 deletions

522
app/build.gradle.kts Normal file
View file

@ -0,0 +1,522 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Jimly Asshiddiqy <jimly.asshiddiqy@accenture.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@file:Suppress("UnstableApiUsage", "DEPRECATION")
import com.android.build.gradle.internal.api.ApkVariantOutputImpl
import com.github.spotbugs.snom.Confidence
import com.github.spotbugs.snom.Effort
import com.github.spotbugs.snom.SpotBugsTask
import com.karumi.shot.ShotExtension
import org.gradle.internal.jvm.Jvm
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.io.FileInputStream
import java.util.Properties
val shotTest = System.getenv("SHOT_TEST") == "true"
val ciBuild = System.getenv("CI") == "true"
val perfAnalysis = project.hasProperty("perfAnalysis")
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.spotless)
alias(libs.plugins.kapt)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.spotbugs)
alias(libs.plugins.detekt)
// needed to make renovate run without shot, as shot requires Android SDK
// https://github.com/pedrovgs/Shot/issues/300
if (System.getenv("SHOT_TEST") == "true") alias(libs.plugins.shot)
id("checkstyle")
id("pmd")
}
apply(from = "${rootProject.projectDir}/jacoco.gradle.kts")
println("Gradle uses Java ${Jvm.current()}")
configurations.configureEach {
// via prism4j, already using annotations explicitly
exclude(group = "org.jetbrains", module = "annotations-java5")
resolutionStrategy {
force(libs.objenesis)
eachDependency {
if (requested.group == "org.checkerframework" && requested.name != "checker-compat-qual") {
useVersion(libs.versions.checker.get())
because("https://github.com/google/ExoPlayer/issues/10007")
} else if (requested.group == "org.jacoco") {
useVersion(libs.versions.jacoco.get())
} else if (requested.group == "commons-logging" && requested.name == "commons-logging") {
useTarget(libs.slfj)
}
}
}
}
// semantic versioning for version code
val versionMajor = 3
val versionMinor = 35
val versionPatch = 0
val versionBuild = 0 // 0-50=Alpha / 51-98=RC / 90-99=stable
val ndkEnv = buildMap {
file("${project.rootDir}/ndk.env").readLines().forEach {
val (key, value) = it.split("=")
put(key, value)
}
}
val configProps = Properties().apply {
val file = rootProject.file(".gradle/config.properties")
if (file.exists()) load(FileInputStream(file))
}
val ncTestServerUsername = configProps["NC_TEST_SERVER_USERNAME"]
val ncTestServerPassword = configProps["NC_TEST_SERVER_PASSWORD"]
val ncTestServerBaseUrl = configProps["NC_TEST_SERVER_BASEURL"]
android {
// install this NDK version and Cmake to produce smaller APKs. Build will still work if not installed
ndkVersion = "${ndkEnv["NDK_VERSION"]}"
namespace = "com.owncloud.android"
testNamespace = "${namespace}.test"
androidResources.generateLocaleConfig = true
defaultConfig {
applicationId = "com.nextcloud.client"
minSdk = 27
targetSdk = 36
compileSdk = 36
buildConfigField("boolean", "CI", ciBuild.toString())
buildConfigField("boolean", "RUNTIME_PERF_ANALYSIS", perfAnalysis.toString())
javaCompileOptions.annotationProcessorOptions {
arguments += mapOf("room.schemaLocation" to "$projectDir/schemas")
}
// arguments to be passed to functional tests
testInstrumentationRunner = if (shotTest) "com.karumi.shot.ShotTestRunner"
else "com.nextcloud.client.TestRunner"
testInstrumentationRunnerArguments += mapOf(
"TEST_SERVER_URL" to ncTestServerBaseUrl.toString(),
"TEST_SERVER_USERNAME" to ncTestServerUsername.toString(),
"TEST_SERVER_PASSWORD" to ncTestServerPassword.toString()
)
testInstrumentationRunnerArguments["disableAnalytics"] = "true"
versionCode = versionMajor * 10000000 + versionMinor * 10000 + versionPatch * 100 + versionBuild
versionName = when {
versionBuild > 89 -> "${versionMajor}.${versionMinor}.${versionPatch}"
versionBuild > 50 -> "${versionMajor}.${versionMinor}.${versionPatch} RC" + (versionBuild - 50)
else -> "${versionMajor}.${versionMinor}.${versionPatch} Alpha" + (versionBuild + 1)
}
// adapt structure from Eclipse to Gradle/Android Studio expectations;
// see http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Configuring-the-Structure
flavorDimensions += "default"
buildTypes {
release {
buildConfigField("String", "NC_TEST_SERVER_DATA_STRING", "\"\"")
}
debug {
enableUnitTestCoverage = project.hasProperty("coverage")
resConfigs("xxxhdpi")
buildConfigField(
"String",
"NC_TEST_SERVER_DATA_STRING",
"\"nc://login/user:${ncTestServerUsername}&password:${ncTestServerPassword}&server:${ncTestServerBaseUrl}\""
)
}
}
productFlavors {
// used for f-droid
register("generic") {
applicationId = "com.nextcloud.client"
dimension = "default"
}
register("gplay") {
applicationId = "com.nextcloud.client"
dimension = "default"
}
register("huawei") {
applicationId = "com.nextcloud.client"
dimension = "default"
}
register("versionDev") {
applicationId = "com.nextcloud.android.beta"
dimension = "default"
versionCode = 20220322
versionName = "20220322"
}
register("qa") {
applicationId = "com.nextcloud.android.qa"
dimension = "default"
versionCode = 1
versionName = "1"
}
}
}
applicationVariants.configureEach {
outputs.configureEach {
if (this is ApkVariantOutputImpl) this.outputFileName = "${this.baseName}-${this.versionCode}.apk"
}
}
testOptions {
unitTests.isReturnDefaultValues = true
animationsDisabled = true
}
// adapt structure from Eclipse to Gradle/Android Studio expectations;
// see http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Configuring-the-Structure
packaging.resources {
excludes.addAll(listOf("META-INF/LICENSE*", "META-INF/versions/9/OSGI-INF/MANIFEST*"))
pickFirsts.add("MANIFEST.MF") // workaround for duplicated manifest on some dependencies
}
buildFeatures {
buildConfig = true
dataBinding = true
viewBinding = true
aidl = true
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
lint {
abortOnError = true
warningsAsErrors = true
checkGeneratedSources = true
disable.addAll(
listOf(
"MissingTranslation",
"GradleDependency",
"VectorPath",
"IconMissingDensityFolder",
"IconDensities",
"GoogleAppIndexingWarning",
"MissingDefaultResource",
"InvalidPeriodicWorkRequestInterval",
"StringFormatInvalid",
"MissingQuantity",
"IconXmlAndPng",
"SelectedPhotoAccess",
"UnsafeIntentLaunch"
)
)
htmlOutput = layout.buildDirectory.file("reports/lint/lint.html").get().asFile
htmlReport = true
}
sourceSets {
// Adds exported schema location as test app assets.
getByName("androidTest") {
assets.srcDirs(files("$projectDir/schemas"))
}
}
}
kapt.useBuildCache = true
ksp.arg("room.schemaLocation", "$projectDir/schemas")
kotlin.compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
spotless.kotlin {
target("**/*.kt")
ktlint()
}
detekt.config.setFrom("detekt.yml")
if (shotTest) configure<ShotExtension> {
showOnlyFailingTestsInReports = ciBuild
// CI environment renders some shadows slightly different from local VMs
// Add a 0.5% tolerance to account for that
tolerance = if (ciBuild) 0.1 else 0.0
}
spotbugs {
ignoreFailures = true // should continue checking
effort = Effort.MAX
reportLevel = Confidence.valueOf("MEDIUM")
}
tasks.register<Checkstyle>("checkstyle") {
configFile = file("${rootProject.projectDir}/checkstyle.xml")
setConfigProperties(
"checkstyleSuppressionsPath" to file("${rootProject.rootDir}/suppressions.xml").absolutePath
)
source("src")
include("**/*.java")
exclude("**/gen/**")
classpath = files()
}
tasks.register<Pmd>("pmd") {
ruleSetFiles = files("${rootProject.rootDir}/ruleset.xml")
ignoreFailures = true // should continue checking
ruleSets = emptyList()
source("src")
include("**/*.java")
exclude("**/gen/**")
reports {
xml.outputLocation.set(layout.buildDirectory.file("reports/pmd/pmd.xml").get().asFile)
html.outputLocation.set(layout.buildDirectory.file("reports/pmd/pmd.html").get().asFile)
}
}
tasks.withType<SpotBugsTask>().configureEach {
val variantNameCap = name.replace("spotbugs", "")
val variantName = variantNameCap.substring(0, 1).lowercase() + variantNameCap.substring(1)
dependsOn("compile${variantNameCap}Sources")
classes = fileTree(
layout.buildDirectory.get().asFile.toString() +
"/intermediates/javac/${variantName}/compile${variantNameCap}JavaWithJavac/classes/"
)
excludeFilter.set(file("${project.rootDir}/scripts/analysis/spotbugs-filter.xml"))
reports.create("xml") {
required.set(true)
}
reports.create("html") {
required.set(true)
outputLocation.set(layout.buildDirectory.file("reports/spotbugs/spotbugs.html"))
setStylesheet("fancy.xsl")
}
}
// Run the compiler as a separate process
tasks.withType<JavaCompile>().configureEach {
options.isFork = true
// Enable Incremental Compilation
options.isIncremental = true
}
tasks.withType<Test>().configureEach {
// Run tests in parallel
maxParallelForks = Runtime.getRuntime().availableProcessors().div(2)
// increased logging for tests
testLogging.events("passed", "skipped", "failed")
}
tasks.named("check").configure {
dependsOn("checkstyle", "spotbugsGplayDebug", "pmd", "lint", "spotlessKotlinCheck", "detekt")
}
dependencies {
// region Nextcloud library
implementation(libs.android.library) {
exclude(group = "org.ogce", module = "xpp3") // unused in Android and brings wrong Junit version
}
// endregion
// region Splash Screen
implementation(libs.splashscreen)
// endregion
// region Jetpack Compose
implementation(platform(libs.compose.bom))
implementation(libs.material.icons.core)
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
// endregion
// region Media3
implementation(libs.bundles.media3)
// endregion
// region Room
implementation(libs.room.runtime)
ksp(libs.room.compiler)
androidTestImplementation(libs.room.testing)
// endregion
// region Espresso
androidTestImplementation(libs.bundles.espresso)
// endregion
// region Glide
implementation(libs.glide)
ksp(libs.ksp)
// endregion
// region UI
implementation(libs.bundles.ui)
// endregion
// region Worker
implementation(libs.work.runtime)
implementation(libs.work.runtime.ktx)
// endregion
// region Lifecycle
implementation(libs.lifecycle.viewmodel.ktx)
implementation(libs.lifecycle.service)
implementation(libs.lifecycle.runtime.ktx)
// endregion
// region JUnit
androidTestImplementation(libs.junit)
androidTestImplementation(libs.rules)
androidTestImplementation(libs.runner)
androidTestUtil(libs.orchestrator)
androidTestImplementation(libs.core.ktx)
androidTestImplementation(libs.core.testing)
// endregion
// region other libraries
compileOnly(libs.org.jbundle.util.osgi.wrapped.org.apache.http.client)
implementation(libs.commons.httpclient.commons.httpclient) // remove after entire switch to lib v2
implementation(libs.jackrabbit.webdav) // remove after entire switch to lib v2
implementation(libs.constraintlayout)
implementation(libs.legacy.support.v4)
implementation(libs.material)
implementation(libs.disklrucache)
implementation(libs.juniversalchardet) // need this version for Android <7
compileOnly(libs.annotations)
implementation(libs.commons.io)
implementation(libs.eventbus)
implementation(libs.ez.vcard)
implementation(libs.nnio)
implementation(libs.bcpkix.jdk18on)
implementation(libs.gson)
implementation(libs.sectioned.recyclerview)
implementation(libs.photoview)
implementation(libs.android.gif.drawable)
implementation(libs.qrcodescanner) // "com.github.blikoon:QRCodeScanner:0.1.2"
implementation(libs.flexbox)
implementation(libs.androidsvg)
implementation(libs.annotation)
implementation(libs.emoji.google)
// endregion
// region AppScan, document scanner not available on FDroid (generic) due to OpenCV binaries
"gplayImplementation"(project(":appscan"))
"huaweiImplementation"(project(":appscan"))
"qaImplementation"(project(":appscan"))
// endregion
// region SpotBugs
spotbugsPlugins(libs.findsecbugs.plugin)
spotbugsPlugins(libs.fb.contrib)
// endregion
// region Dagger
implementation(libs.dagger)
implementation(libs.dagger.android)
implementation(libs.dagger.android.support)
ksp(libs.dagger.compiler)
ksp(libs.dagger.processor)
// endregion
// region Crypto
implementation(libs.conscrypt.android)
// endregion
// region Library
implementation(libs.library)
// endregion
// region Shimmer
implementation(libs.loaderviewlibrary)
// endregion
// region Markdown rendering
implementation(libs.bundles.markdown.rendering)
kapt(libs.prism4j.bundler)
// endregion
// region Image cropping / rotation
implementation(libs.android.image.cropper)
// endregion
// region Maps
implementation(libs.osmdroid.android)
// endregion
// region iCal4j
implementation(libs.ical4j) {
listOf("org.apache.commons", "commons-logging").forEach { groupName -> exclude(group = groupName) }
}
// endregion
// region LeakCanary
if (perfAnalysis) debugImplementation(libs.leakcanary)
// endregion
// region Local Unit Test
testImplementation(libs.bundles.unit.test)
// endregion
// region Mocking support
androidTestImplementation(libs.bundles.mocking)
// endregion
// region UIAutomator
// UIAutomator - for cross-app UI tests, and to grant screen is turned on in Espresso tests
// androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0"
// fix conflict in dependencies; see http://g.co/androidstudio/app-test-app-conflict for details
// androidTestImplementation("com.android.support:support-annotations:${supportLibraryVersion}"
androidTestImplementation(libs.screengrab)
// endregion
// region Kotlin
implementation(libs.kotlin.stdlib)
// endregion
// region Stateless
implementation(libs.stateless4j)
// endregion
// region Google Play dependencies, upon each update first test: new registration, receive push
"gplayImplementation"(libs.bundles.gplay)
// endregion
// region UI
implementation(libs.ui)
// endregion
// region Image loading
implementation(libs.coil)
// endregion
// kotlinx.serialization
implementation(libs.kotlinx.serialization.json)
}

View file

@ -39,6 +39,10 @@
<ignore path="**/values-**/strings.xml" />
</issue>
<issue id="PluralsCandidate">
<ignore path="**/values-**/strings.xml" />
</issue>
<issue id="ExtraTranslation">
<ignore path="**/strings.xml"/>
<ignore path="**/values-b+en+001/strings.xml"/>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -119,7 +119,8 @@ class SyncedFoldersActivityIT : AbstractIT() {
onIdleSync {
EspressoIdlingResource.increment()
val dialog = sut.buildPowerCheckDialog()
dialog.show()
sut.showPowerCheckDialog()
EspressoIdlingResource.decrement()
val screenShotName = createName(testClassName + "_" + "showPowerCheckDialog", "")

View file

@ -7,7 +7,7 @@
*/
package com.nextcloud.client.assistant
import com.nextcloud.client.assistant.repository.AssistantRepository
import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepositoryImpl
import com.owncloud.android.AbstractOnServerIT
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
import com.owncloud.android.lib.resources.status.NextcloudVersion
@ -18,11 +18,11 @@ import org.junit.Test
@Suppress("MagicNumber")
class AssistantRepositoryTests : AbstractOnServerIT() {
private var sut: AssistantRepository? = null
private var sut: AssistantRemoteRepositoryImpl? = null
@Before
fun setup() {
sut = AssistantRepository(nextcloudClient, capability)
sut = AssistantRemoteRepositoryImpl(nextcloudClient, capability)
}
@Test

View file

@ -202,7 +202,7 @@ class TransferManagerConnectionTest {
connection.onServiceConnected(componentName, binder)
// WHEN
// is runnign flag accessed
// is running flag accessed
val isRunning = connection.isRunning
// THEN

View file

@ -0,0 +1,111 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.extensions
import android.graphics.Bitmap
import com.nextcloud.utils.decodeSampledBitmapFromFile
import com.nextcloud.utils.extensions.toFile
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.OutputStream
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.absolutePathString
import kotlin.io.path.deleteRecursively
import kotlin.io.path.exists
@Suppress("MagicNumber")
class BitmapDecodeTests {
private lateinit var tempDir: Path
@Before
fun setup() {
tempDir = Files.createTempDirectory("auto_upload_test_")
assertTrue("Temp directory should exist", tempDir.exists())
}
@OptIn(ExperimentalPathApi::class)
@After
fun cleanup() {
if (tempDir.exists()) {
tempDir.deleteRecursively()
}
}
private fun createTempImageFile(width: Int = 100, height: Int = 100): Path {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val imagePath = tempDir.resolve("test_${System.currentTimeMillis()}.jpg")
Files.newOutputStream(imagePath).use { out: OutputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
assertTrue(imagePath.exists())
return imagePath
}
@Test
fun testToFileWhenPathIsValidShouldReturnExistingFile() {
val path = createTempImageFile()
val result = path.absolutePathString().toFile()
assertNotNull(result)
assertTrue(result!!.exists())
}
@Test
fun testToFileWhenPathIsEmptyShouldReturnNull() {
val result = "".toFile()
assertNull(result)
}
@Test
fun testToFileWhenFileDoesNotExistShouldReturnNull() {
val nonExistentPath = tempDir.resolve("does_not_exist.jpg")
val result = nonExistentPath.absolutePathString().toFile()
assertNull(result)
}
@Test
fun testDecodeSampledBitmapFromFileWhenValidPathShouldReturnBitmap() {
val path = createTempImageFile(400, 400)
val bitmap = decodeSampledBitmapFromFile(path.absolutePathString(), 100, 100)
assertNotNull(bitmap)
assertTrue(bitmap!!.width <= 400)
assertTrue(bitmap.height <= 400)
}
@Test
fun testDecodeSampledBitmapFromFileWhenInvalidPathShouldReturnNull() {
val invalidPath = tempDir.resolve("invalid_path.jpg").absolutePathString()
val bitmap = decodeSampledBitmapFromFile(invalidPath, 100, 100)
assertNull(bitmap)
}
@Test
fun testDecodeSampledBitmapFromFileWhenImageIsLargeShouldDownsampleBitmap() {
val path = createTempImageFile(2000, 2000)
val bitmap = decodeSampledBitmapFromFile(path.absolutePathString(), 100, 100)
assertNotNull(bitmap)
assertTrue("Bitmap should be smaller than original", bitmap!!.width < 2000 && bitmap.height < 2000)
}
@Test
fun testDecodeSampledBitmapFromFileWhenImageIsSmallerThanRequestedShouldKeepOriginalSize() {
val path = createTempImageFile(100, 100)
val bitmap = decodeSampledBitmapFromFile(path.absolutePathString(), 200, 200)
assertNotNull(bitmap)
assertEquals(100, bitmap!!.width)
assertEquals(100, bitmap.height)
}
}

View file

@ -0,0 +1,90 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.extensions
import android.graphics.Bitmap
import android.graphics.Color
import androidx.exifinterface.media.ExifInterface
import com.nextcloud.utils.rotateBitmapViaExif
import junit.framework.TestCase.assertEquals
import org.junit.Test
class BitmapRotationTests {
private fun createTestBitmap(): Bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888).apply {
setPixel(0, 0, Color.RED)
setPixel(1, 0, Color.GREEN)
setPixel(0, 1, Color.BLUE)
setPixel(1, 1, Color.YELLOW)
}
@Test
fun testRotateBitmapViaExifWhenGivenNullBitmapShouldReturnNull() {
val rotated = null.rotateBitmapViaExif(ExifInterface.ORIENTATION_ROTATE_90)
assertEquals(null, rotated)
}
@Test
fun testRotateBitmapViaExifWhenGivenNormalOrientationShouldReturnSameBitmap() {
val bmp = createTestBitmap()
val rotated = bmp.rotateBitmapViaExif(ExifInterface.ORIENTATION_NORMAL)
assertEquals(bmp, rotated)
}
@Test
fun testRotateBitmapViaExifWhenGivenRotate90ShouldReturnRotatedBitmap() {
val bmp = createTestBitmap()
val rotated = bmp.rotateBitmapViaExif(ExifInterface.ORIENTATION_ROTATE_90)!!
assertEquals(bmp.width, rotated.height)
assertEquals(bmp.height, rotated.width)
assertEquals(Color.BLUE, rotated.getPixel(0, 0))
assertEquals(Color.RED, rotated.getPixel(1, 0))
assertEquals(Color.YELLOW, rotated.getPixel(0, 1))
assertEquals(Color.GREEN, rotated.getPixel(1, 1))
}
@Test
fun testRotateBitmapViaExifWhenGivenRotate180ShouldReturnRotatedBitmap() {
val bmp = createTestBitmap()
val rotated = bmp.rotateBitmapViaExif(ExifInterface.ORIENTATION_ROTATE_180)!!
assertEquals(bmp.width, rotated.width)
assertEquals(bmp.height, rotated.height)
assertEquals(Color.YELLOW, rotated.getPixel(0, 0))
assertEquals(Color.BLUE, rotated.getPixel(1, 0))
assertEquals(Color.GREEN, rotated.getPixel(0, 1))
assertEquals(Color.RED, rotated.getPixel(1, 1))
}
@Test
fun testRotateBitmapViaExifWhenGivenFlipHorizontalShouldReturnFlippedBitmap() {
val bmp = createTestBitmap()
val rotated = bmp.rotateBitmapViaExif(ExifInterface.ORIENTATION_FLIP_HORIZONTAL)!!
assertEquals(bmp.width, rotated.width)
assertEquals(bmp.height, rotated.height)
assertEquals(Color.GREEN, rotated.getPixel(0, 0))
assertEquals(Color.RED, rotated.getPixel(1, 0))
assertEquals(Color.YELLOW, rotated.getPixel(0, 1))
assertEquals(Color.BLUE, rotated.getPixel(1, 1))
}
@Test
fun testRotateBitmapViaExifWhenGivenFlipVerticalShouldReturnFlippedBitmap() {
val bmp = createTestBitmap()
val rotated = bmp.rotateBitmapViaExif(ExifInterface.ORIENTATION_FLIP_VERTICAL)!!
assertEquals(bmp.width, rotated.width)
assertEquals(bmp.height, rotated.height)
assertEquals(Color.BLUE, rotated.getPixel(0, 0))
assertEquals(Color.YELLOW, rotated.getPixel(1, 0))
assertEquals(Color.RED, rotated.getPixel(0, 1))
assertEquals(Color.GREEN, rotated.getPixel(1, 1))
}
}

View file

@ -0,0 +1,78 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.extensions
import android.graphics.Bitmap
import android.graphics.Color
import androidx.exifinterface.media.ExifInterface
import com.nextcloud.utils.extensions.getExifOrientation
import junit.framework.TestCase.assertEquals
import org.junit.After
import org.junit.Test
import java.io.File
class GetExifOrientationTests {
private val tempFiles = mutableListOf<File>()
@Suppress("MagicNumber")
private fun createTempImageFile(): File {
val file = File.createTempFile("test_image", ".jpg")
tempFiles.add(file)
val bmp = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888).apply {
setPixel(0, 0, Color.RED)
setPixel(1, 0, Color.GREEN)
setPixel(0, 1, Color.BLUE)
setPixel(1, 1, Color.YELLOW)
}
file.outputStream().use { out ->
bmp.compress(Bitmap.CompressFormat.JPEG, 100, out)
}
return file
}
@After
fun cleanup() {
tempFiles.forEach { it.delete() }
}
@Test
fun testGetExifOrientationWhenExifIsRotate90ShouldReturnRotate90() {
val file = createTempImageFile()
val exif = ExifInterface(file.absolutePath)
exif.setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_ROTATE_90.toString())
exif.saveAttributes()
val orientation = getExifOrientation(file.absolutePath)
assertEquals(ExifInterface.ORIENTATION_ROTATE_90, orientation)
}
@Test
fun testGetExifOrientationWhenExifIsRotate180ShouldReturnRotate180() {
val file = createTempImageFile()
val exif = ExifInterface(file.absolutePath)
exif.setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_ROTATE_180.toString())
exif.saveAttributes()
val orientation = getExifOrientation(file.absolutePath)
assertEquals(ExifInterface.ORIENTATION_ROTATE_180, orientation)
}
@Test
fun testGetExifOrientationWhenExifIsUndefinedShouldReturnUndefined() {
val file = createTempImageFile()
val orientation = getExifOrientation(file.absolutePath)
assertEquals(ExifInterface.ORIENTATION_UNDEFINED, orientation)
}
}

View file

@ -10,6 +10,7 @@ package com.nextcloud.utils
import com.nextcloud.utils.autoRename.AutoRename
import com.owncloud.android.AbstractOnServerIT
import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile
import com.owncloud.android.lib.resources.status.CapabilityBooleanType
import com.owncloud.android.lib.resources.status.NextcloudVersion
import com.owncloud.android.lib.resources.status.OCCapability
import org.junit.Before
@ -27,6 +28,7 @@ class AutoRenameTests : AbstractOnServerIT() {
testOnlyOnServer(NextcloudVersion.nextcloud_30)
capability = capability.apply {
isWCFEnabled = CapabilityBooleanType.TRUE
forbiddenFilenameExtensionJson = listOf(
"""[" ",".",".part",".part"]""",
"""[".",".part",".part"," "]""",
@ -238,4 +240,14 @@ class AutoRenameTests : AbstractOnServerIT() {
val expectedFilename = "Foo.Bar.Baz"
assert(result == expectedFilename) { "Expected $expectedFilename but got $result" }
}
@Test
fun skipAutoRenameWhenWCFDisabled() {
capability = capability.apply {
isWCFEnabled = CapabilityBooleanType.FALSE
}
val filename = " readme.txt "
val result = AutoRename.rename(filename, capability, isFolderPath = true)
assert(result == filename) { "Expected $filename but got $result" }
}
}

View file

@ -0,0 +1,63 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils
import com.nextcloud.utils.extensions.checkWCFRestrictions
import com.owncloud.android.lib.resources.status.CapabilityBooleanType
import com.owncloud.android.lib.resources.status.NextcloudVersion
import com.owncloud.android.lib.resources.status.OCCapability
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@Suppress("MagicNumber")
class CheckWCFRestrictionsTests {
private fun createCapability(
version: NextcloudVersion,
isWCFEnabled: CapabilityBooleanType = CapabilityBooleanType.UNKNOWN
): OCCapability = OCCapability().apply {
this.versionMayor = version.majorVersionNumber
this.isWCFEnabled = isWCFEnabled
}
@Test
fun testReturnsFalseForVersionsOlderThan30() {
val capability = createCapability(NextcloudVersion.nextcloud_29)
assertFalse(capability.checkWCFRestrictions())
}
@Test
fun testReturnsTrueForVersion30WhenWCFAlwaysEnabled() {
val capability = createCapability(NextcloudVersion.nextcloud_30)
assertTrue(capability.checkWCFRestrictions())
}
@Test
fun testReturnsTrueForVersion31WhenWCFAlwaysEnabled() {
val capability = createCapability(NextcloudVersion.nextcloud_31)
assertTrue(capability.checkWCFRestrictions())
}
@Test
fun testReturnsTrueForVersion32WhenWCFEnabled() {
val capability = createCapability(NextcloudVersion.nextcloud_32, CapabilityBooleanType.TRUE)
assertTrue(capability.checkWCFRestrictions())
}
@Test
fun testReturnsFalseForVersion32WhenWCFDisabled() {
val capability = createCapability(NextcloudVersion.nextcloud_32, CapabilityBooleanType.FALSE)
assertFalse(capability.checkWCFRestrictions())
}
@Test
fun testReturnsFalseForVersion32WhenWCFIsUnknown() {
val capability = createCapability(NextcloudVersion.nextcloud_32)
assertFalse(capability.checkWCFRestrictions())
}
}

View file

@ -0,0 +1,204 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import org.junit.After
import org.junit.Before
import org.junit.Test
import java.io.File
import java.nio.file.Files
@Suppress("TooManyFunctions")
class FileHelperTest {
private lateinit var testDirectory: File
@Before
fun setup() {
testDirectory = Files.createTempDirectory("test").toFile()
}
@After
fun tearDown() {
testDirectory.deleteRecursively()
}
@Test
fun testListDirectoryEntriesWhenGivenNullDirectoryShouldReturnEmptyList() {
val result = FileHelper.listDirectoryEntries(null, 0, 10, false)
assertTrue(result.isEmpty())
}
@Test
fun testListDirectoryEntriesWhenGivenNonExistentDirectoryShouldReturnEmptyList() {
val nonExistent = File(testDirectory, "does_not_exist")
val result = FileHelper.listDirectoryEntries(nonExistent, 0, 10, false)
assertTrue(result.isEmpty())
}
@Test
fun testListDirectoryEntriesWhenGivenFileInsteadOfDirectoryShouldReturnEmptyList() {
val file = File(testDirectory, "test.txt")
file.createNewFile()
val result = FileHelper.listDirectoryEntries(file, 0, 10, false)
assertTrue(result.isEmpty())
}
@Test
fun testListDirectoryEntriesWhenGivenEmptyDirectoryShouldReturnEmptyList() {
val result = FileHelper.listDirectoryEntries(testDirectory, 0, 10, false)
assertTrue(result.isEmpty())
}
@Test
fun testListDirectoryEntriesWhenFetchingFoldersShouldReturnOnlyFolders() {
File(testDirectory, "folder1").mkdir()
File(testDirectory, "folder2").mkdir()
File(testDirectory, "file1.txt").createNewFile()
File(testDirectory, "file2.txt").createNewFile()
val result = FileHelper.listDirectoryEntries(testDirectory, 0, 10, true)
assertEquals(2, result.size)
assertTrue(result.all { it.isDirectory })
}
@Test
fun testListDirectoryEntriesWhenFetchingFilesShouldReturnOnlyFiles() {
File(testDirectory, "folder1").mkdir()
File(testDirectory, "folder2").mkdir()
File(testDirectory, "file1.txt").createNewFile()
File(testDirectory, "file2.txt").createNewFile()
val result = FileHelper.listDirectoryEntries(testDirectory, 0, 10, false)
assertEquals(2, result.size)
assertTrue(result.all { it.isFile })
}
@Test
fun testListDirectoryEntriesWhenStartIndexProvidedShouldSkipCorrectNumberOfItems() {
for (i in 1..5) File(testDirectory, "file$i.txt").createNewFile()
val result = FileHelper.listDirectoryEntries(testDirectory, 2, 10, false)
assertEquals(3, result.size)
}
@Test
fun testListDirectoryEntriesWhenMaxItemsProvidedShouldLimitResults() {
for (i in 1..10) File(testDirectory, "file$i.txt").createNewFile()
val result = FileHelper.listDirectoryEntries(testDirectory, 0, 5, false)
assertEquals(5, result.size)
}
@Test
fun testListDirectoryEntriesWhenGivenStartIndexAndMaxItemsShouldReturnCorrectSubset() {
for (i in 1..10) File(testDirectory, "file$i.txt").createNewFile()
val result = FileHelper.listDirectoryEntries(testDirectory, 3, 4, false)
assertEquals(4, result.size)
}
@Test
fun testListDirectoryEntriesWhenStartIndexBeyondAvailableShouldReturnEmptyList() {
for (i in 1..3) File(testDirectory, "file$i.txt").createNewFile()
val result = FileHelper.listDirectoryEntries(testDirectory, 10, 5, false)
assertTrue(result.isEmpty())
}
@Test
fun testListDirectoryEntriesWhenMaxItemsBeyondAvailableShouldReturnAllItems() {
for (i in 1..3) File(testDirectory, "file$i.txt").createNewFile()
val result = FileHelper.listDirectoryEntries(testDirectory, 0, 100, false)
assertEquals(3, result.size)
}
@Test
fun testListDirectoryEntriesWhenFetchingFoldersWithOffsetShouldSkipCorrectly() {
for (i in 1..5) File(testDirectory, "folder$i").mkdir()
for (i in 1..3) File(testDirectory, "file$i.txt").createNewFile()
val result = FileHelper.listDirectoryEntries(testDirectory, 2, 10, true)
assertEquals(3, result.size)
assertTrue(result.all { it.isDirectory })
}
@Test
fun testListDirectoryEntriesWhenFetchingFilesWithOffsetShouldSkipCorrectly() {
for (i in 1..3) File(testDirectory, "folder$i").mkdir()
for (i in 1..5) File(testDirectory, "file$i.txt").createNewFile()
val result = FileHelper.listDirectoryEntries(testDirectory, 2, 10, false)
assertEquals(3, result.size)
assertTrue(result.all { it.isFile })
}
@Test
fun testListDirectoryEntriesWhenGivenOnlyFoldersAndFetchingFilesShouldReturnEmptyList() {
for (i in 1..5) File(testDirectory, "folder$i").mkdir()
val result = FileHelper.listDirectoryEntries(testDirectory, 0, 10, false)
assertTrue(result.isEmpty())
}
@Test
fun testListDirectoryEntriesWhenGivenOnlyFilesAndFetchingFoldersShouldReturnEmptyList() {
for (i in 1..5) File(testDirectory, "file$i.txt").createNewFile()
val result = FileHelper.listDirectoryEntries(testDirectory, 0, 10, true)
assertTrue(result.isEmpty())
}
@Test
fun testListDirectoryEntriesWhenMaxItemsIsZeroShouldReturnEmptyList() {
for (i in 1..5) File(testDirectory, "file$i.txt").createNewFile()
val result = FileHelper.listDirectoryEntries(testDirectory, 0, 0, false)
assertTrue(result.isEmpty())
}
@Test
fun testListDirectoryEntriesWhenGivenMixedContentShouldFilterCorrectly() {
for (i in 1..3) File(testDirectory, "folder$i").mkdir()
for (i in 1..7) File(testDirectory, "file$i.txt").createNewFile()
val folders = FileHelper.listDirectoryEntries(testDirectory, 0, 10, true)
val files = FileHelper.listDirectoryEntries(testDirectory, 0, 10, false)
assertEquals(3, folders.size)
assertEquals(7, files.size)
assertTrue(folders.all { it.isDirectory })
assertTrue(files.all { it.isFile })
}
@Test
fun testListDirectoryEntriesWhenPaginatingFoldersShouldWorkCorrectly() {
for (i in 1..10) File(testDirectory, "folder$i").mkdir()
val page1 = FileHelper.listDirectoryEntries(testDirectory, 0, 3, true)
val page2 = FileHelper.listDirectoryEntries(testDirectory, 3, 3, true)
val page3 = FileHelper.listDirectoryEntries(testDirectory, 6, 3, true)
val page4 = FileHelper.listDirectoryEntries(testDirectory, 9, 3, true)
assertEquals(3, page1.size)
assertEquals(3, page2.size)
assertEquals(3, page3.size)
assertEquals(1, page4.size)
}
@Test
fun testListDirectoryEntriesWhenPaginatingFilesShouldWorkCorrectly() {
for (i in 1..10) File(testDirectory, "file$i.txt").createNewFile()
val page1 = FileHelper.listDirectoryEntries(testDirectory, 0, 4, false)
val page2 = FileHelper.listDirectoryEntries(testDirectory, 4, 4, false)
val page3 = FileHelper.listDirectoryEntries(testDirectory, 8, 4, false)
assertEquals(4, page1.size)
assertEquals(4, page2.size)
assertEquals(2, page3.size)
}
}

View file

@ -10,6 +10,7 @@ package com.nextcloud.utils
import com.nextcloud.utils.fileNameValidator.FileNameValidator
import com.owncloud.android.AbstractOnServerIT
import com.owncloud.android.R
import com.owncloud.android.lib.resources.status.CapabilityBooleanType
import com.owncloud.android.lib.resources.status.NextcloudVersion
import com.owncloud.android.lib.resources.status.OCCapability
import org.junit.Assert.assertEquals
@ -27,6 +28,7 @@ class FileNameValidatorTests : AbstractOnServerIT() {
@Before
fun setup() {
capability = capability.apply {
isWCFEnabled = CapabilityBooleanType.TRUE
forbiddenFilenamesJson = """[".htaccess",".htaccess"]"""
forbiddenFilenameBaseNamesJson = """
["con", "prn", "aux", "nul", "com0", "com1", "com2", "com3", "com4",
@ -228,4 +230,14 @@ class FileNameValidatorTests : AbstractOnServerIT() {
val result = FileNameValidator.checkFolderAndFilePaths(folderPath, listOf(), capability, targetContext)
assertFalse(result)
}
@Test
fun skipValidationWhenWCFDisabled() {
capability = capability.apply {
isWCFEnabled = CapabilityBooleanType.FALSE
}
val filename = "abc.txt"
val result = FileNameValidator.checkFileName(filename, capability, targetContext)
assertNull(result)
}
}

View file

@ -400,11 +400,6 @@ public abstract class AbstractIT {
public boolean isPowerSavingEnabled() {
return false;
}
@Override
public boolean isPowerSavingExclusionAvailable() {
return false;
}
};
UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext);

View file

@ -216,11 +216,6 @@ public abstract class AbstractOnServerIT extends AbstractIT {
public boolean isPowerSavingEnabled() {
return false;
}
@Override
public boolean isPowerSavingExclusionAvailable() {
return false;
}
};
UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext);

View file

@ -82,12 +82,6 @@ public class UploadIT extends AbstractOnServerIT {
public boolean isPowerSavingEnabled() {
return false;
}
@Override
public boolean isPowerSavingExclusionAvailable() {
return false;
}
@NonNull
@Override
public BatteryStatus getBattery() {
@ -237,11 +231,6 @@ public class UploadIT extends AbstractOnServerIT {
return false;
}
@Override
public boolean isPowerSavingExclusionAvailable() {
return false;
}
@NonNull
@Override
public BatteryStatus getBattery() {

View file

@ -18,6 +18,7 @@ import com.nextcloud.client.account.CurrentAccountProvider;
import com.nextcloud.client.account.User;
import com.nextcloud.client.account.UserAccountManager;
import com.nextcloud.client.account.UserAccountManagerImpl;
import com.nextcloud.client.database.entity.UploadEntityKt;
import com.nextcloud.test.RandomStringGenerator;
import com.owncloud.android.AbstractIT;
import com.owncloud.android.MainApp;
@ -108,7 +109,7 @@ public class UploadStorageManagerTest extends AbstractIT {
OCUpload upload = createUpload(account);
uploads.add(upload);
uploadsStorageManager.storeUpload(upload);
uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(upload));
}
OCUpload[] storedUploads = uploadsStorageManager.getAllStoredUploads();
@ -151,17 +152,14 @@ public class UploadStorageManagerTest extends AbstractIT {
account.name);
corruptUpload.setLocalPath(null);
uploadsStorageManager.storeUpload(corruptUpload);
uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(corruptUpload));
uploadsStorageManager.getAllStoredUploads();
}
@Test
public void getById() {
OCUpload upload = createUpload(account);
long id = uploadsStorageManager.storeUpload(upload);
long id = uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(upload));
OCUpload newUpload = uploadsStorageManager.getUploadById(id);
assertNotNull(newUpload);
@ -178,7 +176,7 @@ public class UploadStorageManagerTest extends AbstractIT {
private void insertUploads(Account account, int rowsToInsert) {
for (int i = 0; i < rowsToInsert; i++) {
uploadsStorageManager.storeUpload(createUpload(account));
uploadsStorageManager.uploadDao.insertOrReplace(UploadEntityKt.toUploadEntity(createUpload(account)));
}
}

View file

@ -46,9 +46,6 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
override val isPowerSavingEnabled: Boolean
get() = false
override val isPowerSavingExclusionAvailable: Boolean
get() = false
override val battery: BatteryStatus
get() = BatteryStatus()
}
@ -327,7 +324,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
user,
null,
ocUpload2,
NameCollisionPolicy.CANCEL,
NameCollisionPolicy.SKIP,
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
targetContext,
false,
@ -376,7 +373,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
user,
arrayOf(ocFile2),
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
NameCollisionPolicy.CANCEL
NameCollisionPolicy.SKIP
)
shortSleep()
@ -403,7 +400,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
user,
null,
ocUpload,
NameCollisionPolicy.CANCEL,
NameCollisionPolicy.SKIP,
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
targetContext,
false,
@ -429,7 +426,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
user,
null,
ocUpload2,
NameCollisionPolicy.CANCEL,
NameCollisionPolicy.SKIP,
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
targetContext,
false,
@ -480,7 +477,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
user,
arrayOf(ocFile2),
FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
NameCollisionPolicy.CANCEL
NameCollisionPolicy.SKIP
)
shortSleep()

View file

@ -27,8 +27,8 @@ public class FileDisplayActivityTest extends AbstractIT {
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
Activity activity =
ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(RESUMED).iterator().next();
if (activity instanceof WhatsNewActivity) {
activity.onBackPressed();
if (activity instanceof WhatsNewActivity whatsNewActivity) {
whatsNewActivity.getOnBackPressedDispatcher().onBackPressed();
}
});
scenario.recreate();

View file

@ -822,7 +822,7 @@ class FileDetailSharingFragmentIT : AbstractIT() {
val processFragment =
activity.supportFragmentManager.findFragmentByTag(FileDetailsSharingProcessFragment.TAG) as
FileDetailsSharingProcessFragment
processFragment.onBackPressed()
processFragment.activity?.onBackPressedDispatcher?.onBackPressed()
}
}
}

View file

@ -46,7 +46,7 @@ class UnifiedSearchFragmentIT : AbstractIT() {
scenario.onActivity { activity ->
onIdleSync {
EspressoIdlingResource.increment()
val sut = UnifiedSearchFragment.newInstance(null, null)
val sut = UnifiedSearchFragment.newInstance(null, null, "/")
activity.addFragment(sut)
sut.onSearchResultChanged(
@ -83,7 +83,7 @@ class UnifiedSearchFragmentIT : AbstractIT() {
onIdleSync {
EspressoIdlingResource.increment()
val sut = UnifiedSearchFragment.newInstance(null, null)
val sut = UnifiedSearchFragment.newInstance(null, null, "/")
val testViewModel = UnifiedSearchViewModel(activity.application)
testViewModel.setConnectivityService(activity.connectivityServiceMock)
val localRepository = UnifiedSearchFakeRepository()

View file

@ -32,6 +32,14 @@ class CapabilityUtilsIT : AbstractIT() {
assertTrue(test(OwnCloudVersion.nextcloud_20))
}
private fun test(version: OwnCloudVersion): Boolean =
CapabilityUtils.checkOutdatedWarning(targetContext.resources, version, false)
@Test
fun checkOutdatedWarningWithSubscription() {
assertFalse(test(NextcloudVersion.nextcloud_31))
assertFalse(test(NextcloudVersion.nextcloud_30))
assertFalse(test(OwnCloudVersion.nextcloud_20, true))
}
private fun test(version: OwnCloudVersion, hasValidSubscription: Boolean = false): Boolean =
CapabilityUtils.checkOutdatedWarning(targetContext.resources, version, false, hasValidSubscription)
}

View file

@ -44,6 +44,7 @@ import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.KeyFactory;
@ -96,7 +97,11 @@ public final class PushUtils {
if (!new File(privateKeyPath).exists() && !new File(publicKeyPath).exists()) {
try {
if (!keyPathFile.exists()) {
keyPathFile.mkdir();
try {
Files.createDirectory(keyPathFile.toPath());
} catch (IOException e) {
Log_OC.e(TAG, "Could not create directory: " + keyPathFile.getAbsolutePath(), e);
}
}
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
@ -304,8 +309,12 @@ public final class PushUtils {
try {
if (!new File(path).exists()) {
File newFile = new File(path);
newFile.getParentFile().mkdirs();
newFile.createNewFile();
try {
Files.createDirectories(newFile.getParentFile().toPath());
} catch (IOException e) {
Log_OC.e(TAG, "Could not create directory: " + newFile.getParentFile(), e);
}
Files.createFile(newFile.toPath());
}
keyFileOutputStream = new FileOutputStream(path);
keyFileOutputStream.write(encoded);

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
<?xml version="1.0" encoding="utf-8"?><!--
~ Nextcloud - Android Client
~
~ SPDX-FileCopyrightText: 2024 TSI-mc <surinder.kumar@t-systems.com>
@ -53,8 +52,10 @@
must request the FOREGROUND_SERVICE permission
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Runtime permissions introduced in Android 13 (API level 33) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <!-- Needed for Android 14 (API level 34) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"
tools:ignore="PhotoAndVideoPolicy,SelectedPhotoAccess" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"
tools:ignore="PhotoAndVideoPolicy,SelectedPhotoAccess" /> <!-- Needed for Android 14 (API level 34) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!--
@ -112,6 +113,7 @@
android:name=".MainApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/backup_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_config"
android:icon="@mipmap/ic_launcher"
android:installLocation="internalOnly"
@ -121,13 +123,13 @@
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:enableOnBackInvokedCallback="false"
android:theme="@style/Theme.ownCloud.Toolbar"
android:usesCleartextTraffic="true"
tools:ignore="UnusedAttribute"
tools:replace="android:allowBackup">
<meta-data android:name="android.content.APP_RESTRICTIONS"
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_config" />
<activity
@ -159,7 +161,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -169,7 +173,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/..*/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -179,7 +185,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/..*/..*/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -189,7 +197,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/..*/..*/..*/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -199,7 +209,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -209,7 +221,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/..*/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -219,7 +233,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/..*/..*/f/..*" />
</intent-filter>
<intent-filter android:autoVerify="true">
@ -229,7 +245,9 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="*" />
<data
android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:pathPattern="/..*/..*/..*/f/..*" />
<!-- path pattern to handle deep link -->
<data android:pathPattern="/app/..*" />
@ -352,6 +370,7 @@
</activity>
<activity
android:name=".ui.activity.SettingsActivity"
android:enableOnBackInvokedCallback="false"
android:exported="false"
android:theme="@style/PreferenceTheme" />
<activity
@ -366,11 +385,11 @@
<service
android:name="com.nextcloud.client.media.BackgroundPlayerService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
android:foregroundServiceType="mediaPlayback"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
@ -584,6 +603,10 @@
</intent-filter>
</receiver>
<receiver
android:name="com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerReceiver"
android:exported="false" />
<activity
android:name=".ui.activity.CopyToClipboardActivity"
android:exported="false"

View file

@ -0,0 +1,334 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.assistant
import android.app.Activity
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.nextcloud.client.assistant.component.AddTaskAlertDialog
import com.nextcloud.client.assistant.extensions.getInputTitle
import com.nextcloud.client.assistant.model.ScreenOverlayState
import com.nextcloud.client.assistant.model.ScreenState
import com.nextcloud.client.assistant.repository.local.MockAssistantLocalRepository
import com.nextcloud.client.assistant.repository.remote.MockAssistantRemoteRepository
import com.nextcloud.client.assistant.task.TaskView
import com.nextcloud.client.assistant.taskTypes.TaskTypesRow
import com.nextcloud.ui.composeActivity.ComposeActivity
import com.nextcloud.ui.composeComponents.alertDialog.SimpleAlertDialog
import com.nextcloud.ui.composeComponents.bottomSheet.MoreActionsBottomSheet
import com.owncloud.android.R
import com.owncloud.android.lib.resources.assistant.v2.model.Task
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
import com.owncloud.android.lib.resources.status.OCCapability
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val PULL_TO_REFRESH_DELAY = 1500L
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AssistantScreen(viewModel: AssistantViewModel, capability: OCCapability, activity: Activity) {
val messageId by viewModel.snackbarMessageId.collectAsState()
val screenOverlayState by viewModel.screenOverlayState.collectAsState()
val selectedTaskType by viewModel.selectedTaskType.collectAsState()
val filteredTaskList by viewModel.filteredTaskList.collectAsState()
val screenState by viewModel.screenState.collectAsState()
val taskTypes by viewModel.taskTypes.collectAsState()
val scope = rememberCoroutineScope()
val pullRefreshState = rememberPullToRefreshState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(messageId) {
messageId?.let {
snackbarHostState.showSnackbar(activity.getString(it))
viewModel.updateSnackbarMessage(null)
}
}
LaunchedEffect(Unit) {
viewModel.startTaskListPolling()
}
DisposableEffect(Unit) {
onDispose {
viewModel.stopTaskListPolling()
}
}
Scaffold(
modifier = Modifier.pullToRefresh(
false,
pullRefreshState,
onRefresh = {
scope.launch {
delay(PULL_TO_REFRESH_DELAY)
viewModel.fetchTaskList()
}
}
),
topBar = {
taskTypes?.let {
TaskTypesRow(selectedTaskType, data = it) { task ->
viewModel.selectTaskType(task)
}
}
},
floatingActionButton = {
if (!taskTypes.isNullOrEmpty()) {
AddTaskButton(
selectedTaskType,
viewModel
)
}
},
floatingActionButtonPosition = FabPosition.EndOverlay,
snackbarHost = {
SnackbarHost(snackbarHostState)
}
) { paddingValues ->
when (screenState) {
is ScreenState.EmptyContent -> {
val state = (screenState as ScreenState.EmptyContent)
EmptyContent(
paddingValues,
state.iconId,
state.descriptionId
)
}
ScreenState.Content -> {
AssistantContent(
paddingValues,
filteredTaskList ?: listOf(),
viewModel,
capability
)
}
else -> EmptyContent(
paddingValues,
R.drawable.spinner_inner,
R.string.assistant_screen_loading
)
}
LinearProgressIndicator(
progress = { pullRefreshState.distanceFraction },
modifier = Modifier.fillMaxWidth()
)
OverlayState(screenOverlayState, activity, viewModel)
}
}
@Composable
private fun AddTaskButton(selectedTaskType: TaskTypeData?, viewModel: AssistantViewModel) {
FloatingActionButton(
onClick = {
selectedTaskType?.let {
val newState = ScreenOverlayState.AddTask(it, "")
viewModel.updateTaskListScreenState(newState)
}
}
) {
Icon(Icons.Filled.Add, "Add Task Icon")
}
}
@Suppress("LongMethod")
@Composable
private fun OverlayState(state: ScreenOverlayState?, activity: Activity, viewModel: AssistantViewModel) {
when (state) {
is ScreenOverlayState.AddTask -> {
AddTaskAlertDialog(
title = state.taskType.name,
description = state.taskType.description,
defaultInput = state.input,
addTask = { input ->
state.taskType.let { taskType ->
viewModel.createTask(input = input, taskType = taskType)
}
},
dismiss = {
viewModel.updateTaskListScreenState(null)
}
)
}
is ScreenOverlayState.DeleteTask -> {
SimpleAlertDialog(
title = stringResource(id = R.string.assistant_screen_delete_task_alert_dialog_title),
description = stringResource(id = R.string.assistant_screen_delete_task_alert_dialog_description),
dismiss = { viewModel.updateTaskListScreenState(null) },
onComplete = { viewModel.deleteTask(state.id) }
)
}
is ScreenOverlayState.TaskActions -> {
val actions = state.getActions(activity, onEditCompleted = { addTask ->
viewModel.updateTaskListScreenState(addTask)
}, onDeleteCompleted = { deleteTask ->
viewModel.updateTaskListScreenState(deleteTask)
})
MoreActionsBottomSheet(
title = state.task.getInputTitle(),
actions = actions,
dismiss = { viewModel.updateTaskListScreenState(null) }
)
}
else -> Unit
}
}
@Composable
private fun AssistantContent(
paddingValues: PaddingValues,
taskList: List<Task>,
viewModel: AssistantViewModel,
capability: OCCapability
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(12.dp),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) {
items(taskList, key = { it.id }) { task ->
TaskView(
task,
capability,
showTaskActions = {
val newState = ScreenOverlayState.TaskActions(task)
viewModel.updateTaskListScreenState(newState)
}
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
private fun EmptyContent(paddingValues: PaddingValues, iconId: Int?, descriptionId: Int) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
iconId?.let {
Image(
painter = painterResource(id = iconId),
modifier = Modifier.size(32.dp),
colorFilter = ColorFilter.tint(color = colorResource(R.color.text_color)),
contentDescription = "empty content icon"
)
Spacer(modifier = Modifier.height(8.dp))
}
Text(
text = stringResource(descriptionId),
fontSize = 18.sp,
textAlign = TextAlign.Center,
color = colorResource(R.color.text_color)
)
}
}
@Suppress("MagicNumber")
@Composable
@Preview
private fun AssistantScreenPreview() {
MaterialTheme(
content = {
AssistantScreen(
viewModel = getMockViewModel(false),
activity = ComposeActivity(),
capability = OCCapability().apply {
versionMayor = 30
}
)
}
)
}
@Suppress("MagicNumber")
@Composable
@Preview
private fun AssistantEmptyScreenPreview() {
MaterialTheme(
content = {
AssistantScreen(
viewModel = getMockViewModel(true),
activity = ComposeActivity(),
capability = OCCapability().apply {
versionMayor = 30
}
)
}
)
}
private fun getMockViewModel(giveEmptyTasks: Boolean): AssistantViewModel {
val mockLocalRepository = MockAssistantLocalRepository()
val mockRemoteRepository = MockAssistantRemoteRepository(giveEmptyTasks)
return AssistantViewModel(
accountName = "test:localhost",
remoteRepository = mockRemoteRepository,
localRepository = mockLocalRepository
)
}

View file

@ -11,18 +11,31 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.nextcloud.client.assistant.model.ScreenOverlayState
import com.nextcloud.client.assistant.model.ScreenState
import com.nextcloud.client.assistant.repository.AssistantRepositoryType
import com.nextcloud.client.assistant.repository.local.AssistantLocalRepository
import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepository
import com.owncloud.android.R
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.lib.resources.assistant.v2.model.Task
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class AssistantViewModel(private val repository: AssistantRepositoryType) : ViewModel() {
class AssistantViewModel(
private val accountName: String,
private val remoteRepository: AssistantRemoteRepository,
private val localRepository: AssistantLocalRepository
) : ViewModel() {
companion object {
private const val TAG = "AssistantViewModel"
private const val TASK_LIST_POLLING_INTERVAL_MS = 15_000L
}
private val _screenState = MutableStateFlow<ScreenState?>(null)
val screenState: StateFlow<ScreenState?> = _screenState
@ -44,14 +57,54 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
private val _filteredTaskList = MutableStateFlow<List<Task>?>(null)
val filteredTaskList: StateFlow<List<Task>?> = _filteredTaskList
private var taskPollingJob: Job? = null
init {
fetchTaskTypes()
}
// region task polling
fun startTaskListPolling() {
stopTaskListPolling()
taskPollingJob = viewModelScope.launch(Dispatchers.IO) {
try {
while (isActive) {
Log_OC.d(TAG, "Polling task list...")
fetchTaskListSuspending()
delay(TASK_LIST_POLLING_INTERVAL_MS)
}
} finally {
Log_OC.d(TAG, "Polling coroutine cancelled")
}
}
}
fun stopTaskListPolling() {
taskPollingJob?.cancel()
taskPollingJob = null
}
// endregion
private suspend fun fetchTaskListSuspending() {
val cachedTasks = localRepository.getCachedTasks(accountName)
if (cachedTasks.isNotEmpty()) {
_filteredTaskList.value = cachedTasks.sortedByDescending { it.id }
}
val taskType = _selectedTaskType.value?.id ?: return
val result = remoteRepository.getTaskList(taskType)
if (result != null) {
taskList = result
_filteredTaskList.value = taskList?.sortedByDescending { it.id }
localRepository.cacheTasks(result, accountName)
}
}
@Suppress("MagicNumber")
fun createTask(input: String, taskType: TaskTypeData) {
viewModelScope.launch(Dispatchers.IO) {
val result = repository.createTask(input, taskType)
val result = remoteRepository.createTask(input, taskType)
val messageId = if (result.isSuccess) {
R.string.assistant_screen_task_create_success_message
@ -76,15 +129,11 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
private fun fetchTaskTypes() {
viewModelScope.launch(Dispatchers.IO) {
val taskTypesResult = repository.getTaskTypes()
if (taskTypesResult == null) {
updateSnackbarMessage(R.string.assistant_screen_task_types_error_state_message)
return@launch
}
if (taskTypesResult.isEmpty()) {
updateSnackbarMessage(R.string.assistant_screen_task_list_empty_message)
val taskTypesResult = remoteRepository.getTaskTypes()
if (taskTypesResult == null || taskTypesResult.isEmpty()) {
_screenState.update {
ScreenState.emptyTaskTypes()
}
return@launch
}
@ -98,12 +147,17 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
fun fetchTaskList() {
viewModelScope.launch(Dispatchers.IO) {
_screenState.update {
ScreenState.Refreshing
// Try cached data first
val cachedTasks = localRepository.getCachedTasks(accountName)
if (cachedTasks.isNotEmpty()) {
_filteredTaskList.update {
cachedTasks.sortedByDescending { it.id }
}
updateTaskListScreenState()
}
val taskType = _selectedTaskType.value?.id ?: return@launch
val result = repository.getTaskList(taskType)
val result = remoteRepository.getTaskList(taskType)
if (result != null) {
taskList = result
_filteredTaskList.update {
@ -111,19 +165,21 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
task.id
}
}
localRepository.cacheTasks(result, accountName)
updateSnackbarMessage(null)
} else {
updateSnackbarMessage(R.string.assistant_screen_task_list_error_state_message)
}
updateScreenState()
updateTaskListScreenState()
}
}
private fun updateScreenState() {
private fun updateTaskListScreenState() {
_screenState.update {
if (_filteredTaskList.value?.isEmpty() == true) {
ScreenState.EmptyContent
ScreenState.emptyTaskList()
} else {
ScreenState.Content
}
@ -132,7 +188,7 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
fun deleteTask(id: Long) {
viewModelScope.launch(Dispatchers.IO) {
val result = repository.deleteTask(id)
val result = remoteRepository.deleteTask(id)
val messageId = if (result.isSuccess) {
R.string.assistant_screen_task_delete_success_message
@ -144,6 +200,7 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
if (result.isSuccess) {
removeTaskFromList(id)
localRepository.deleteTask(id, accountName)
}
}
}
@ -154,7 +211,7 @@ class AssistantViewModel(private val repository: AssistantRepositoryType) : View
}
}
fun updateScreenState(value: ScreenOverlayState?) {
fun updateTaskListScreenState(value: ScreenOverlayState?) {
_screenOverlayState.update {
value
}

View file

@ -7,8 +7,24 @@
package com.nextcloud.client.assistant.model
enum class ScreenState {
Refreshing,
EmptyContent,
Content
import com.owncloud.android.R
sealed class ScreenState {
data object Loading : ScreenState()
data object Content : ScreenState()
data class EmptyContent(val iconId: Int?, val descriptionId: Int) : ScreenState()
companion object {
fun emptyTaskTypes(): ScreenState = EmptyContent(
descriptionId = R.string.assistant_screen_task_list_empty_warning,
iconId = null
)
fun emptyTaskList(): ScreenState = EmptyContent(
descriptionId = R.string.assistant_screen_create_a_new_task_from_bottom_right_text,
iconId = null
)
}
}

View file

@ -0,0 +1,17 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.assistant.repository.local
import com.owncloud.android.lib.resources.assistant.v2.model.Task
interface AssistantLocalRepository {
suspend fun cacheTasks(tasks: List<Task>, accountName: String)
suspend fun getCachedTasks(accountName: String): List<Task>
suspend fun insertTask(task: Task, accountName: String)
suspend fun deleteTask(id: Long, accountName: String)
}

View file

@ -0,0 +1,69 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.assistant.repository.local
import com.nextcloud.client.database.dao.AssistantDao
import com.nextcloud.client.database.entity.AssistantEntity
import com.owncloud.android.lib.resources.assistant.v2.model.Task
import com.owncloud.android.lib.resources.assistant.v2.model.TaskInput
import com.owncloud.android.lib.resources.assistant.v2.model.TaskOutput
class AssistantLocalRepositoryImpl(private val assistantDao: AssistantDao) : AssistantLocalRepository {
override suspend fun cacheTasks(tasks: List<Task>, accountName: String) {
val entities = tasks.map { it.toEntity(accountName) }
assistantDao.insertAssistantTasks(entities)
}
override suspend fun getCachedTasks(accountName: String): List<Task> {
val entities = assistantDao.getAssistantTasksByAccount(accountName)
return entities.map { it.toTask() }
}
override suspend fun insertTask(task: Task, accountName: String) {
assistantDao.insertAssistantTask(task.toEntity(accountName))
}
override suspend fun deleteTask(id: Long, accountName: String) {
val cached = assistantDao.getAssistantTasksByAccount(accountName).firstOrNull { it.id == id } ?: return
assistantDao.deleteAssistantTask(cached)
}
// region Mapping helpers
private fun Task.toEntity(accountName: String): AssistantEntity = AssistantEntity(
id = this.id,
accountName = accountName,
type = this.type,
status = this.status,
userId = this.userId,
appId = this.appId,
input = this.input?.input,
output = this.output?.output,
completionExpectedAt = this.completionExpectedAt,
progress = this.progress,
lastUpdated = this.lastUpdated,
scheduledAt = this.scheduledAt,
endedAt = this.endedAt
)
private fun AssistantEntity.toTask(): Task = Task(
id = this.id,
type = this.type,
status = this.status,
userId = this.userId,
appId = this.appId,
input = TaskInput(input = this.input),
output = TaskOutput(output = this.output),
completionExpectedAt = this.completionExpectedAt,
progress = this.progress,
lastUpdated = this.lastUpdated,
scheduledAt = this.scheduledAt,
endedAt = this.endedAt
)
// endregion
}

View file

@ -0,0 +1,35 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.assistant.repository.local
import com.owncloud.android.lib.resources.assistant.v2.model.Task
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class MockAssistantLocalRepository : AssistantLocalRepository {
private val tasks = mutableListOf<Task>()
private val mutex = Mutex()
override suspend fun cacheTasks(tasks: List<Task>, accountName: String) {
mutex.withLock {
this.tasks.clear()
this.tasks.addAll(tasks)
}
}
override suspend fun getCachedTasks(accountName: String): List<Task> = mutex.withLock { tasks.toList() }
override suspend fun insertTask(task: Task, accountName: String) {
mutex.withLock { tasks.add(task) }
}
override suspend fun deleteTask(id: Long, accountName: String) {
mutex.withLock { tasks.removeAll { it.id == id } }
}
}

View file

@ -0,0 +1,22 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.assistant.repository.remote
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.resources.assistant.v2.model.Task
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
interface AssistantRemoteRepository {
fun getTaskTypes(): List<TaskTypeData>?
fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult<Void>
fun getTaskList(taskType: String): List<Task>?
fun deleteTask(id: Long): RemoteOperationResult<Void>
}

View file

@ -0,0 +1,80 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.assistant.repository.remote
import com.nextcloud.common.NextcloudClient
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
import com.owncloud.android.lib.resources.assistant.v1.CreateTaskRemoteOperationV1
import com.owncloud.android.lib.resources.assistant.v1.DeleteTaskRemoteOperationV1
import com.owncloud.android.lib.resources.assistant.v1.GetTaskListRemoteOperationV1
import com.owncloud.android.lib.resources.assistant.v1.GetTaskTypesRemoteOperationV1
import com.owncloud.android.lib.resources.assistant.v1.model.toV2
import com.owncloud.android.lib.resources.assistant.v2.CreateTaskRemoteOperationV2
import com.owncloud.android.lib.resources.assistant.v2.DeleteTaskRemoteOperationV2
import com.owncloud.android.lib.resources.assistant.v2.GetTaskListRemoteOperationV2
import com.owncloud.android.lib.resources.assistant.v2.GetTaskTypesRemoteOperationV2
import com.owncloud.android.lib.resources.assistant.v2.model.Task
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
import com.owncloud.android.lib.resources.status.NextcloudVersion
import com.owncloud.android.lib.resources.status.OCCapability
class AssistantRemoteRepositoryImpl(private val client: NextcloudClient, capability: OCCapability) :
AssistantRemoteRepository {
private val supportsV2 = capability.version.isNewerOrEqual(NextcloudVersion.nextcloud_30)
@Suppress("ReturnCount")
override fun getTaskTypes(): List<TaskTypeData>? {
if (supportsV2) {
val result = GetTaskTypesRemoteOperationV2().execute(client)
if (result.isSuccess) {
return result.resultData
}
} else {
val result = GetTaskTypesRemoteOperationV1().execute(client)
if (result.isSuccess) {
return result.resultData.toV2()
}
}
return null
}
override fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult<Void> = if (supportsV2) {
CreateTaskRemoteOperationV2(input, taskType).execute(client)
} else {
if (taskType.id.isNullOrEmpty()) {
RemoteOperationResult<Void>(ResultCode.CANCELLED)
} else {
CreateTaskRemoteOperationV1(input, taskType.id!!).execute(client)
}
}
@Suppress("ReturnCount")
override fun getTaskList(taskType: String): List<Task>? {
if (supportsV2) {
val result = GetTaskListRemoteOperationV2(taskType).execute(client)
if (result.isSuccess) {
return result.resultData.tasks.filter { it.appId == "assistant" }
}
} else {
val result = GetTaskListRemoteOperationV1("assistant").execute(client)
if (result.isSuccess) {
return result.resultData.toV2().tasks.filter { it.type == taskType }
}
}
return null
}
override fun deleteTask(id: Long): RemoteOperationResult<Void> = if (supportsV2) {
DeleteTaskRemoteOperationV2(id).execute(client)
} else {
DeleteTaskRemoteOperationV1(id).execute(client)
}
}

View file

@ -0,0 +1,67 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.assistant.repository.remote
import com.nextcloud.utils.extensions.getRandomString
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.resources.assistant.v2.model.Shape
import com.owncloud.android.lib.resources.assistant.v2.model.Task
import com.owncloud.android.lib.resources.assistant.v2.model.TaskInput
import com.owncloud.android.lib.resources.assistant.v2.model.TaskOutput
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
@Suppress("MagicNumber")
class MockAssistantRemoteRepository(private val giveEmptyTasks: Boolean = false) : AssistantRemoteRepository {
override fun getTaskTypes(): List<TaskTypeData> = listOf(
TaskTypeData(
id = "core:text2text",
name = "Free text to text prompt",
description = "Runs an arbitrary prompt through a language model that returns a reply",
inputShape = mapOf(
"input" to Shape(
name = "Prompt",
description = "Describe a task that you want the assistant to do or ask a question",
type = "Text"
)
),
outputShape = mapOf(
"output" to Shape(
name = "Generated reply",
description = "The generated text from the assistant",
type = "Text"
)
)
)
)
override fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult<Void> =
RemoteOperationResult<Void>(RemoteOperationResult.ResultCode.OK)
override fun getTaskList(taskType: String): List<Task> = if (giveEmptyTasks) {
listOf()
} else {
listOf(
Task(
1,
"FreePrompt",
null,
"12",
"",
TaskInput("Give me some long text 1"),
TaskOutput("Lorem ipsum".getRandomString(100)),
1707692337,
1707692337,
1707692337,
1707692337,
1707692337
)
)
}
override fun deleteTask(id: Long): RemoteOperationResult<Void> =
RemoteOperationResult<Void>(RemoteOperationResult.ResultCode.OK)
}

View file

@ -8,7 +8,9 @@
package com.nextcloud.client.assistant.taskDetail
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -17,6 +19,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@ -28,9 +32,11 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@ -54,29 +60,54 @@ fun TaskDetailBottomSheet(task: Task, showTaskActions: () -> Unit, dismiss: () -
onDismissRequest = { dismiss() },
sheetState = sheetState
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
stickyHeader {
Row(
modifier = Modifier.fillMaxWidth()
) {
Spacer(modifier = Modifier.weight(1f))
Box {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
stickyHeader {
Row(
modifier = Modifier.fillMaxWidth()
) {
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = showTaskActions) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = "More button",
tint = colorResource(R.color.text_color)
)
IconButton(onClick = showTaskActions) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = "More button",
tint = colorResource(R.color.text_color)
)
}
}
}
item {
InputOutputCard(task)
}
}
item {
InputOutputCard(task)
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.ic_assistant),
contentDescription = "assistant icon",
modifier = Modifier.size(12.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.assistant_generation_warning),
color = colorResource(R.color.text_color),
fontSize = 12.sp
)
}
}
}

View file

@ -8,10 +8,9 @@
package com.nextcloud.client.assistant.taskTypes
import android.annotation.SuppressLint
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -26,13 +25,13 @@ import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
fun TaskTypesRow(selectedTaskType: TaskTypeData?, data: List<TaskTypeData>, selectTaskType: (TaskTypeData) -> Unit) {
val selectedTabIndex = data.indexOfFirst { it.id == selectedTaskType?.id }.takeIf { it >= 0 } ?: 0
ScrollableTabRow(
PrimaryScrollableTabRow(
selectedTabIndex = selectedTabIndex,
edgePadding = 0.dp,
containerColor = colorResource(R.color.actionbar_color),
indicator = {
TabRowDefaults.SecondaryIndicator(
Modifier.tabIndicatorOffset(it[selectedTabIndex]),
Modifier.tabIndicatorOffset(selectedTabIndex),
color = colorResource(R.color.primary)
)
}

View file

@ -16,11 +16,15 @@ import androidx.room.TypeConverters
import com.nextcloud.client.core.Clock
import com.nextcloud.client.core.ClockImpl
import com.nextcloud.client.database.dao.ArbitraryDataDao
import com.nextcloud.client.database.dao.AssistantDao
import com.nextcloud.client.database.dao.FileDao
import com.nextcloud.client.database.dao.FileSystemDao
import com.nextcloud.client.database.dao.OfflineOperationDao
import com.nextcloud.client.database.dao.RecommendedFileDao
import com.nextcloud.client.database.dao.SyncedFolderDao
import com.nextcloud.client.database.dao.UploadDao
import com.nextcloud.client.database.entity.ArbitraryDataEntity
import com.nextcloud.client.database.entity.AssistantEntity
import com.nextcloud.client.database.entity.CapabilityEntity
import com.nextcloud.client.database.entity.ExternalLinkEntity
import com.nextcloud.client.database.entity.FileEntity
@ -37,6 +41,7 @@ import com.nextcloud.client.database.migrations.Migration67to68
import com.nextcloud.client.database.migrations.RoomMigration
import com.nextcloud.client.database.migrations.addLegacyMigrations
import com.nextcloud.client.database.typeConverter.OfflineOperationTypeConverter
import com.owncloud.android.MainApp
import com.owncloud.android.db.ProviderMeta
@Database(
@ -51,7 +56,8 @@ import com.owncloud.android.db.ProviderMeta
UploadEntity::class,
VirtualEntity::class,
OfflineOperationEntity::class,
RecommendedFileEntity::class
RecommendedFileEntity::class,
AssistantEntity::class
],
version = ProviderMeta.DB_VERSION,
autoMigrations = [
@ -81,7 +87,10 @@ import com.owncloud.android.db.ProviderMeta
AutoMigration(from = 89, to = 90),
AutoMigration(from = 90, to = 91),
AutoMigration(from = 91, to = 92),
AutoMigration(from = 92, to = 93, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class)
AutoMigration(from = 92, to = 93, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class),
AutoMigration(from = 93, to = 94, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class),
AutoMigration(from = 94, to = 95, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class),
AutoMigration(from = 95, to = 96)
],
exportSchema = true
)
@ -94,6 +103,9 @@ abstract class NextcloudDatabase : RoomDatabase() {
abstract fun offlineOperationDao(): OfflineOperationDao
abstract fun uploadDao(): UploadDao
abstract fun recommendedFileDao(): RecommendedFileDao
abstract fun fileSystemDao(): FileSystemDao
abstract fun syncedFolderDao(): SyncedFolderDao
abstract fun assistantDao(): AssistantDao
companion object {
const val FIRST_ROOM_DB_VERSION = 65
@ -119,5 +131,9 @@ abstract class NextcloudDatabase : RoomDatabase() {
}
return instance!!
}
@Suppress("DEPRECATION")
@JvmStatic
fun instance(): NextcloudDatabase = getInstance(MainApp.getAppContext())
}
}

View file

@ -0,0 +1,41 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.database.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.nextcloud.client.database.entity.AssistantEntity
import com.owncloud.android.db.ProviderMeta
@Dao
interface AssistantDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAssistantTask(task: AssistantEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAssistantTasks(tasks: List<AssistantEntity>)
@Update
suspend fun updateAssistantTask(task: AssistantEntity)
@Delete
suspend fun deleteAssistantTask(task: AssistantEntity)
@Query(
"""
SELECT * FROM ${ProviderMeta.ProviderTableMeta.ASSISTANT_TABLE_NAME}
WHERE accountName = :accountName
ORDER BY lastUpdated DESC
"""
)
suspend fun getAssistantTasksByAccount(accountName: String): List<AssistantEntity>
}

View file

@ -17,15 +17,6 @@ import com.owncloud.android.utils.MimeType
@Suppress("TooManyFunctions")
@Dao
interface FileDao {
@Query(
"""
SELECT DISTINCT parent
FROM filelist
WHERE path IN (:subfilePaths)
"""
)
fun getParentIdsOfSubfiles(subfilePaths: List<String>): List<Long>
@Update
fun update(entity: FileEntity)
@ -108,4 +99,16 @@ interface FileDao {
dirType: String = MimeType.DIRECTORY,
webdavType: String = MimeType.WEBDAV_FOLDER
): List<FileEntity>
@Query(
"""
SELECT *
FROM filelist
WHERE file_owner = :fileOwner
AND parent = :parentId
AND ${ProviderTableMeta.FILE_NAME} LIKE '%' || :query || '%'
ORDER BY ${ProviderTableMeta.FILE_DEFAULT_SORT_ORDER}
"""
)
fun searchFilesInFolder(parentId: Long, fileOwner: String, query: String): List<FileEntity>
}

View file

@ -0,0 +1,40 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.nextcloud.client.database.entity.FilesystemEntity
import com.owncloud.android.db.ProviderMeta
@Dao
interface FileSystemDao {
@Query(
"""
SELECT *
FROM ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME}
WHERE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID} = :syncedFolderId
AND ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD} = 0
AND ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_IS_FOLDER} = 0
AND ${ProviderMeta.ProviderTableMeta._ID} > :lastId
ORDER BY ${ProviderMeta.ProviderTableMeta._ID}
LIMIT :limit
"""
)
suspend fun getAutoUploadFilesEntities(syncedFolderId: String, limit: Int, lastId: Int): List<FilesystemEntity>
@Query(
"""
UPDATE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME}
SET ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD} = 1
WHERE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH} = :localPath
AND ${ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID} = :syncedFolderId
"""
)
suspend fun markFileAsUploaded(localPath: String, syncedFolderId: String)
}

View file

@ -0,0 +1,26 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.nextcloud.client.database.entity.SyncedFolderEntity
import com.owncloud.android.db.ProviderMeta
@Dao
interface SyncedFolderDao {
@Query(
"""
SELECT * FROM ${ProviderMeta.ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME}
WHERE ${ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_LOCAL_PATH} = :localPath
AND ${ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ACCOUNT} = :account
LIMIT 1
"""
)
fun findByLocalPathAndAccount(localPath: String, account: String): SyncedFolderEntity?
}

View file

@ -8,6 +8,8 @@
package com.nextcloud.client.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.nextcloud.client.database.entity.UploadEntity
import com.owncloud.android.db.ProviderMeta.ProviderTableMeta
@ -27,4 +29,68 @@ interface UploadDao {
ProviderTableMeta.UPLOADS_ACCOUNT_NAME + " = :accountName"
)
fun getUploadsByIds(ids: LongArray, accountName: String): List<UploadEntity>
@Query(
"SELECT * FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} " +
"WHERE ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath LIMIT 1"
)
fun getByRemotePath(remotePath: String): UploadEntity?
@Query(
"DELETE FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} " +
"WHERE ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName " +
"AND ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath"
)
fun deleteByAccountAndRemotePath(accountName: String, remotePath: String)
@Query(
"SELECT * FROM " + ProviderTableMeta.UPLOADS_TABLE_NAME +
" WHERE " + ProviderTableMeta._ID + " = :id AND " +
ProviderTableMeta.UPLOADS_ACCOUNT_NAME + " = :accountName " +
"LIMIT 1"
)
fun getUploadById(id: Long, accountName: String): UploadEntity?
@Insert(onConflict = OnConflictStrategy.Companion.REPLACE)
fun insertOrReplace(entity: UploadEntity): Long
@Query(
"SELECT * FROM " + ProviderTableMeta.UPLOADS_TABLE_NAME +
" WHERE " + ProviderTableMeta.UPLOADS_ACCOUNT_NAME + " = :accountName AND " +
ProviderTableMeta.UPLOADS_LOCAL_PATH + " = :localPath AND " +
ProviderTableMeta.UPLOADS_REMOTE_PATH + " = :remotePath " +
"LIMIT 1"
)
fun getUploadByAccountAndPaths(accountName: String, localPath: String, remotePath: String): UploadEntity?
@Query(
"UPDATE ${ProviderTableMeta.UPLOADS_TABLE_NAME} " +
"SET ${ProviderTableMeta.UPLOADS_STATUS} = :status " +
"WHERE ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath " +
"AND ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName"
)
suspend fun updateStatus(remotePath: String, accountName: String, status: Int): Int
@Query(
"""
SELECT * FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME}
WHERE ${ProviderTableMeta.UPLOADS_STATUS} = :status
AND (:nameCollisionPolicy IS NULL OR ${ProviderTableMeta.UPLOADS_NAME_COLLISION_POLICY} = :nameCollisionPolicy)
"""
)
suspend fun getUploadsByStatus(status: Int, nameCollisionPolicy: Int? = null): List<UploadEntity>
@Query(
"""
SELECT * FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME}
WHERE ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName
AND ${ProviderTableMeta.UPLOADS_STATUS} = :status
AND (:nameCollisionPolicy IS NULL OR ${ProviderTableMeta.UPLOADS_NAME_COLLISION_POLICY} = :nameCollisionPolicy)
"""
)
suspend fun getUploadsByAccountNameAndStatus(
accountName: String,
status: Int,
nameCollisionPolicy: Int? = null
): List<UploadEntity>
}

View file

@ -0,0 +1,30 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.database.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.owncloud.android.db.ProviderMeta
@Entity(tableName = ProviderMeta.ProviderTableMeta.ASSISTANT_TABLE_NAME)
data class AssistantEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0L,
val accountName: String?,
val type: String?,
val status: String?,
val userId: String?,
val appId: String?,
val input: String? = null,
val output: String? = null,
val completionExpectedAt: Int? = null,
var progress: Int? = null,
val lastUpdated: Int? = null,
val scheduledAt: Int? = null,
val endedAt: Int? = null
)

View file

@ -142,5 +142,9 @@ data class CapabilityEntity(
@ColumnInfo(name = ProviderTableMeta.CAPABILITIES_DEFAULT_PERMISSIONS)
val defaultPermissions: Int?,
@ColumnInfo(name = ProviderTableMeta.CAPABILITIES_USER_STATUS_SUPPORTS_BUSY)
val userStatusSupportsBusy: Int?
val userStatusSupportsBusy: Int?,
@ColumnInfo(name = ProviderTableMeta.CAPABILITIES_WINDOWS_COMPATIBLE_FILENAMES)
val isWCFEnabled: Int?,
@ColumnInfo(name = ProviderTableMeta.CAPABILITIES_HAS_VALID_SUBSCRIPTION)
val hasValidSubscription: Int?
)

View file

@ -10,6 +10,9 @@ package com.nextcloud.client.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.nextcloud.client.preferences.SubFolderRule
import com.owncloud.android.datamodel.MediaFolderType
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.db.ProviderMeta.ProviderTableMeta
@Entity(tableName = ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME)
@ -50,3 +53,40 @@ data class SyncedFolderEntity(
@ColumnInfo(name = ProviderTableMeta.SYNCED_FOLDER_LAST_SCAN_TIMESTAMP_MS)
val lastScanTimestampMs: Long?
)
fun SyncedFolderEntity.toSyncedFolder(): SyncedFolder = SyncedFolder(
// id
(this.id ?: SyncedFolder.UNPERSISTED_ID).toLong(),
// localPath
this.localPath ?: "",
// remotePath
this.remotePath ?: "",
// wifiOnly
this.wifiOnly == 1,
// chargingOnly
this.chargingOnly == 1,
// existing
this.existing == 1,
// subfolderByDate
this.subfolderByDate == 1,
// account
this.account ?: "",
// uploadAction
this.uploadAction ?: 0,
// nameCollisionPolicy
this.nameCollisionPolicy ?: 0,
// enabled
this.enabled == 1,
// timestampMs
(this.enabledTimestampMs ?: SyncedFolder.EMPTY_ENABLED_TIMESTAMP_MS).toLong(),
// type
MediaFolderType.getById(this.type ?: MediaFolderType.CUSTOM.id),
// hidden
this.hidden == 1,
// subFolderRule
this.subFolderRule?.let { SubFolderRule.entries[it] },
// excludeHidden
this.excludeHidden == 1,
// lastScanTimestampMs
this.lastScanTimestampMs ?: SyncedFolder.NOT_SCANNED_YET
)

View file

@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
@ -78,3 +79,33 @@ fun UploadEntity.toOCUpload(capability: OCCapability? = null): OCUpload {
return upload
}
fun OCUpload.toUploadEntity(): UploadEntity {
val id = if (uploadId == -1L) {
// needed for the insert new records to the db so that insert DAO function returns new generated id
null
} else {
uploadId
}
return UploadEntity(
id = id?.toInt(),
localPath = localPath,
remotePath = remotePath,
accountName = accountName,
fileSize = fileSize,
status = uploadStatus?.value,
localBehaviour = localAction,
nameCollisionPolicy = nameCollisionPolicy?.serialize(),
isCreateRemoteFolder = if (isCreateRemoteFolder) 1 else 0,
// uploadEndTimestamp may overflow max int capacity since it is conversion from long to int. coerceAtMost needed
uploadEndTimestamp = uploadEndTimestamp.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(),
lastResult = lastResult?.value,
createdBy = createdBy,
isWifiOnly = if (isUseWifiOnly) 1 else 0,
isWhileChargingOnly = if (isWhileChargingOnly) 1 else 0,
folderUnlockToken = folderUnlockToken,
uploadTime = null
)
}

View file

@ -10,7 +10,6 @@ package com.nextcloud.client.device
import android.content.Context
import android.os.PowerManager
import com.nextcloud.client.preferences.AppPreferences
import dagger.Module
import dagger.Provides
@ -18,13 +17,11 @@ import dagger.Provides
class DeviceModule {
@Provides
fun powerManagementService(context: Context, preferences: AppPreferences): PowerManagementService {
fun powerManagementService(context: Context): PowerManagementService {
val platformPowerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
return PowerManagementServiceImpl(
context = context,
platformPowerManager = platformPowerManager,
deviceInfo = DeviceInfo(),
preferences = preferences
platformPowerManager = platformPowerManager
)
}
}

View file

@ -21,14 +21,6 @@ interface PowerManagementService {
*/
val isPowerSavingEnabled: Boolean
/**
* Checks if the device vendor requires power saving
* exclusion workaround.
*
* @return true if workaround is required, false otherwise
*/
val isPowerSavingExclusionAvailable: Boolean
/**
* Checks current battery status using platform [android.os.BatteryManager]
*/

View file

@ -11,46 +11,27 @@ import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.PowerManager
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.client.preferences.AppPreferencesImpl
import com.nextcloud.utils.extensions.registerBroadcastReceiver
import com.owncloud.android.datamodel.ReceiverFlag
internal class PowerManagementServiceImpl(
private val context: Context,
private val platformPowerManager: PowerManager,
private val preferences: AppPreferences,
private val deviceInfo: DeviceInfo = DeviceInfo()
private val platformPowerManager: PowerManager
) : PowerManagementService {
companion object {
/**
* Vendors on this list use aggressive power saving methods that might
* break application experience.
*/
val OVERLY_AGGRESSIVE_POWER_SAVING_VENDORS = setOf("samsung", "huawei", "xiaomi")
@JvmStatic
fun fromContext(context: Context): PowerManagementServiceImpl {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
val preferences = AppPreferencesImpl.fromContext(context)
return PowerManagementServiceImpl(context, powerManager, preferences, DeviceInfo())
return PowerManagementServiceImpl(context, powerManager)
}
}
override val isPowerSavingEnabled: Boolean
get() {
if (preferences.isPowerCheckDisabled) {
return false
}
return platformPowerManager.isPowerSaveMode
}
override val isPowerSavingExclusionAvailable: Boolean
get() = deviceInfo.vendor in OVERLY_AGGRESSIVE_POWER_SAVING_VENDORS
@Suppress("MagicNumber") // 100% is 100, we're not doing Cobol
override val battery: BatteryStatus
get() {

View file

@ -17,6 +17,7 @@ import com.nextcloud.client.integrations.IntegrationsModule;
import com.nextcloud.client.jobs.JobsModule;
import com.nextcloud.client.jobs.download.FileDownloadHelper;
import com.nextcloud.client.jobs.offlineOperations.receiver.OfflineOperationReceiver;
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorkerReceiver;
import com.nextcloud.client.jobs.upload.FileUploadBroadcastReceiver;
import com.nextcloud.client.jobs.upload.FileUploadHelper;
import com.nextcloud.client.media.BackgroundPlayerService;
@ -75,6 +76,8 @@ public interface AppComponent {
void inject(OfflineOperationReceiver offlineOperationReceiver);
void inject(FolderDownloadWorkerReceiver folderDownloadWorkerReceiver);
@Component.Builder
interface Builder {
@BindsInstance

View file

@ -104,7 +104,7 @@ class DocumentScanActivity :
true
}
android.R.id.home -> {
onBackPressed()
onBackPressedDispatcher.onBackPressed()
true
}
else -> false

View file

@ -10,6 +10,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import com.nextcloud.client.di.Injectable
@ -46,6 +47,7 @@ class EtmActivity :
onPageChanged(it)
}
)
handleOnBackPressed()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
@ -58,11 +60,17 @@ class EtmActivity :
else -> super.onOptionsItemSelected(item)
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (!vm.onBackPressed()) {
super.onBackPressed()
}
private fun handleOnBackPressed() {
onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val handledByVm = vm.onBackPressed()
if (!handledByVm) {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
})
}
private fun onPageChanged(page: EtmMenuEntry?) {

View file

@ -6,6 +6,7 @@
*/
package com.nextcloud.client.etm
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.View
@ -20,6 +21,7 @@ class EtmMenuAdapter(context: Context, val onItemClicked: (Int) -> Unit) :
private val layoutInflater = LayoutInflater.from(context)
var pages: List<EtmMenuEntry> = listOf()
@SuppressLint("NotifyDataSetChanged")
set(value) {
field = value
notifyDataSetChanged()

View file

@ -6,6 +6,7 @@
*/
package com.nextcloud.client.etm.pages
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
@ -63,6 +64,7 @@ class EtmFileTransferFragment : EtmBaseFragment() {
private var transfers = listOf<Transfer>()
@SuppressLint("NotifyDataSetChanged")
fun setStatus(status: TransferManager.Status) {
transfers = listOf(status.pending, status.running, status.completed).flatten().reversed()
notifyDataSetChanged()

View file

@ -17,14 +17,17 @@ import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.core.Clock
import com.nextcloud.client.device.DeviceInfo
import com.nextcloud.client.database.NextcloudDatabase
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.documentscan.GeneratePDFUseCase
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
import com.nextcloud.client.integrations.deck.DeckApi
import com.nextcloud.client.jobs.autoUpload.AutoUploadWorker
import com.nextcloud.client.jobs.autoUpload.FileSystemRepository
import com.nextcloud.client.jobs.download.FileDownloadWorker
import com.nextcloud.client.jobs.metadata.MetadataWorker
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.logger.Logger
import com.nextcloud.client.network.ConnectivityService
@ -50,7 +53,6 @@ class BackgroundJobFactory @Inject constructor(
private val clock: Clock,
private val powerManagementService: PowerManagementService,
private val backgroundJobManager: Provider<BackgroundJobManager>,
private val deviceInfo: DeviceInfo,
private val accountManager: UserAccountManager,
private val resources: Resources,
private val arbitraryDataProvider: ArbitraryDataProvider,
@ -62,7 +64,8 @@ class BackgroundJobFactory @Inject constructor(
private val viewThemeUtils: Provider<ViewThemeUtils>,
private val localBroadcastManager: Provider<LocalBroadcastManager>,
private val generatePdfUseCase: GeneratePDFUseCase,
private val syncedFolderProvider: SyncedFolderProvider
private val syncedFolderProvider: SyncedFolderProvider,
private val database: NextcloudDatabase
) : WorkerFactory() {
@SuppressLint("NewApi")
@ -84,7 +87,7 @@ class BackgroundJobFactory @Inject constructor(
when (workerClass) {
ContactsBackupWork::class -> createContactsBackupWork(context, workerParameters)
ContactsImportWork::class -> createContactsImportWork(context, workerParameters)
FilesSyncWork::class -> createFilesSyncWork(context, workerParameters)
AutoUploadWorker::class -> createFilesSyncWork(context, workerParameters)
OfflineSyncWork::class -> createOfflineSyncWork(context, workerParameters)
MediaFoldersDetectionWork::class -> createMediaFoldersDetectionWork(context, workerParameters)
NotificationWork::class -> createNotificationWork(context, workerParameters)
@ -100,6 +103,7 @@ class BackgroundJobFactory @Inject constructor(
OfflineOperationsWorker::class -> createOfflineOperationsWorker(context, workerParameters)
InternalTwoWaySyncWork::class -> createInternalTwoWaySyncWork(context, workerParameters)
MetadataWorker::class -> createMetadataWorker(context, workerParameters)
FolderDownloadWorker::class -> createFolderDownloadWorker(context, workerParameters)
else -> null // caller falls back to default factory
}
}
@ -166,16 +170,16 @@ class BackgroundJobFactory @Inject constructor(
contentResolver
)
private fun createFilesSyncWork(context: Context, params: WorkerParameters): FilesSyncWork = FilesSyncWork(
private fun createFilesSyncWork(context: Context, params: WorkerParameters): AutoUploadWorker = AutoUploadWorker(
context = context,
params = params,
contentResolver = contentResolver,
userAccountManager = accountManager,
uploadsStorageManager = uploadsStorageManager,
connectivityService = connectivityService,
powerManagementService = powerManagementService,
syncedFolderProvider = syncedFolderProvider,
backgroundJobManager = backgroundJobManager.get()
backgroundJobManager = backgroundJobManager.get(),
repository = FileSystemRepository(dao = database.fileSystemDao())
)
private fun createOfflineSyncWork(context: Context, params: WorkerParameters): OfflineSyncWork = OfflineSyncWork(
@ -285,4 +289,12 @@ class BackgroundJobFactory @Inject constructor(
params,
accountManager.user
)
private fun createFolderDownloadWorker(context: Context, params: WorkerParameters): FolderDownloadWorker =
FolderDownloadWorker(
accountManager,
context,
viewThemeUtils.get(),
params
)
}

View file

@ -10,6 +10,7 @@ import androidx.lifecycle.LiveData
import androidx.work.ListenableWorker
import com.nextcloud.client.account.User
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.operations.DownloadType
/**
@ -119,15 +120,12 @@ interface BackgroundJobManager {
fun startImmediateFilesExportJob(files: Collection<OCFile>): LiveData<JobInfo?>
fun schedulePeriodicFilesSyncJob(syncedFolderID: Long)
fun schedulePeriodicFilesSyncJob(syncedFolder: SyncedFolder)
/**
* Immediately start File Sync job for given syncFolderID.
*/
fun startImmediateFilesSyncJob(
syncedFolderID: Long,
fun startAutoUploadImmediately(
syncedFolder: SyncedFolder,
overridePowerSaving: Boolean = false,
changedFiles: Array<String?> = arrayOf<String?>()
contentUris: Array<String?> = arrayOf()
)
fun cancelTwoWaySyncJob()
@ -142,12 +140,10 @@ interface BackgroundJobManager {
fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean)
fun getFileUploads(user: User): LiveData<List<JobInfo>>
fun cancelFilesUploadJob(user: User)
fun isStartFileUploadJobScheduled(user: User): Boolean
fun isStartFileUploadJobScheduled(accountName: String): Boolean
fun cancelFilesDownloadJob(user: User, fileId: Long)
fun isStartFileDownloadJobScheduled(user: User, fileId: Long): Boolean
@Suppress("LongParameterList")
fun startFileDownloadJob(
user: User,
@ -175,4 +171,6 @@ interface BackgroundJobManager {
fun scheduleInternal2WaySync(intervalMinutes: Long)
fun cancelAllFilesDownloadJobs()
fun startMetadataSyncJob(currentDirPath: String)
fun downloadFolder(folder: OCFile, accountName: String)
fun cancelFolderDownload()
}

View file

@ -26,7 +26,9 @@ import com.nextcloud.client.account.User
import com.nextcloud.client.core.Clock
import com.nextcloud.client.di.Injectable
import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
import com.nextcloud.client.jobs.autoUpload.AutoUploadWorker
import com.nextcloud.client.jobs.download.FileDownloadWorker
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker
import com.nextcloud.client.jobs.metadata.MetadataWorker
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker
import com.nextcloud.client.jobs.upload.FileUploadHelper
@ -35,6 +37,7 @@ import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.utils.extensions.isWorkRunning
import com.nextcloud.utils.extensions.isWorkScheduled
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.operations.DownloadType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -91,6 +94,7 @@ internal class BackgroundJobManagerImpl(
const val JOB_PERIODIC_OFFLINE_OPERATIONS = "periodic_offline_operations"
const val JOB_PERIODIC_HEALTH_STATUS = "periodic_health_status"
const val JOB_IMMEDIATE_HEALTH_STATUS = "immediate_health_status"
const val JOB_DOWNLOAD_FOLDER = "download_folder"
const val JOB_METADATA_SYNC = "metadata_sync"
const val JOB_INTERNAL_TWO_WAY_SYNC = "internal_two_way_sync"
@ -472,41 +476,68 @@ internal class BackgroundJobManagerImpl(
)
}
override fun schedulePeriodicFilesSyncJob(syncedFolderID: Long) {
override fun schedulePeriodicFilesSyncJob(syncedFolder: SyncedFolder) {
val syncedFolderID = syncedFolder.id
val arguments = Data.Builder()
.putLong(FilesSyncWork.SYNCED_FOLDER_ID, syncedFolderID)
.putLong(AutoUploadWorker.SYNCED_FOLDER_ID, syncedFolderID)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresCharging(syncedFolder.isChargingOnly)
.build()
val request = periodicRequestBuilder(
jobClass = FilesSyncWork::class,
jobClass = AutoUploadWorker::class,
jobName = JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID,
intervalMins = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES
intervalMins = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES,
constraints = constraints
)
.setBackoffCriteria(
BackoffPolicy.LINEAR,
DEFAULT_BACKOFF_CRITERIA_DELAY_SEC,
TimeUnit.SECONDS
)
.setInputData(arguments)
.build()
workManager.enqueueUniquePeriodicWork(
JOB_PERIODIC_FILES_SYNC + "_" + syncedFolderID,
ExistingPeriodicWorkPolicy.REPLACE,
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
override fun startImmediateFilesSyncJob(
syncedFolderID: Long,
override fun startAutoUploadImmediately(
syncedFolder: SyncedFolder,
overridePowerSaving: Boolean,
changedFiles: Array<String?>
contentUris: Array<String?>
) {
val syncedFolderID = syncedFolder.id
val arguments = Data.Builder()
.putBoolean(FilesSyncWork.OVERRIDE_POWER_SAVING, overridePowerSaving)
.putStringArray(FilesSyncWork.CHANGED_FILES, changedFiles)
.putLong(FilesSyncWork.SYNCED_FOLDER_ID, syncedFolderID)
.putBoolean(AutoUploadWorker.OVERRIDE_POWER_SAVING, overridePowerSaving)
.putStringArray(AutoUploadWorker.CONTENT_URIS, contentUris)
.putLong(AutoUploadWorker.SYNCED_FOLDER_ID, syncedFolderID)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresCharging(syncedFolder.isChargingOnly)
.build()
val request = oneTimeRequestBuilder(
jobClass = FilesSyncWork::class,
jobClass = AutoUploadWorker::class,
jobName = JOB_IMMEDIATE_FILES_SYNC + "_" + syncedFolderID
)
.setInputData(arguments)
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.LINEAR,
DEFAULT_BACKOFF_CRITERIA_DELAY_SEC,
TimeUnit.SECONDS
)
.build()
workManager.enqueueUniqueWork(
@ -606,10 +637,10 @@ internal class BackgroundJobManagerImpl(
workManager.enqueue(request)
}
private fun startFileUploadJobTag(user: User): String = JOB_FILES_UPLOAD + user.accountName
private fun startFileUploadJobTag(accountName: String): String = JOB_FILES_UPLOAD + accountName
override fun isStartFileUploadJobScheduled(user: User): Boolean =
workManager.isWorkScheduled(startFileUploadJobTag(user))
override fun isStartFileUploadJobScheduled(accountName: String): Boolean =
workManager.isWorkScheduled(startFileUploadJobTag(accountName))
/**
* This method supports initiating uploads for various scenarios, including:
@ -627,7 +658,7 @@ internal class BackgroundJobManagerImpl(
defaultDispatcherScope.launch {
val batchSize = FileUploadHelper.MAX_FILE_COUNT
val batches = uploadIds.toList().chunked(batchSize)
val tag = startFileUploadJobTag(user)
val tag = startFileUploadJobTag(user.accountName)
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
@ -673,9 +704,6 @@ internal class BackgroundJobManagerImpl(
private fun startFileDownloadJobTag(user: User, fileId: Long): String =
JOB_FOLDER_DOWNLOAD + user.accountName + fileId
override fun isStartFileDownloadJobScheduled(user: User, fileId: Long): Boolean =
workManager.isWorkScheduled(startFileDownloadJobTag(user, fileId))
override fun startFileDownloadJob(
user: User,
file: OCFile,
@ -795,4 +823,28 @@ internal class BackgroundJobManagerImpl(
workManager.enqueueUniquePeriodicWork(JOB_INTERNAL_TWO_WAY_SYNC, ExistingPeriodicWorkPolicy.UPDATE, request)
}
override fun downloadFolder(folder: OCFile, accountName: String) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresStorageNotLow(true)
.build()
val data = Data.Builder()
.putLong(FolderDownloadWorker.FOLDER_ID, folder.fileId)
.putString(FolderDownloadWorker.ACCOUNT_NAME, accountName)
.build()
val request = oneTimeRequestBuilder(FolderDownloadWorker::class, JOB_DOWNLOAD_FOLDER)
.addTag(JOB_DOWNLOAD_FOLDER)
.setInputData(data)
.setConstraints(constraints)
.build()
workManager.enqueueUniqueWork(JOB_DOWNLOAD_FOLDER, ExistingWorkPolicy.APPEND_OR_REPLACE, request)
}
override fun cancelFolderDownload() {
workManager.cancelAllWorkByTag(JOB_DOWNLOAD_FOLDER)
}
}

View file

@ -1,18 +1,27 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz <hello@ezaquarii.com>
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
import android.app.Notification
import android.content.Context
import androidx.work.Worker
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.utils.ForegroundServiceHelper
import com.owncloud.android.R
import com.owncloud.android.datamodel.ForegroundServiceType
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.FilesSyncHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* This work is triggered when OS detects change in media folders.
@ -21,53 +30,113 @@ import com.owncloud.android.utils.FilesSyncHelper
*
* This job must not be started on API < 24.
*/
@Suppress("TooGenericExceptionCaught")
class ContentObserverWork(
appContext: Context,
private val context: Context,
private val params: WorkerParameters,
private val syncedFolderProvider: SyncedFolderProvider,
private val powerManagementService: PowerManagementService,
private val backgroundJobManager: BackgroundJobManager
) : Worker(appContext, params) {
) : CoroutineWorker(context, params) {
override fun doWork(): Result {
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
if (params.triggeredContentUris.isNotEmpty()) {
Log_OC.d(TAG, "File-sync Content Observer detected files change")
checkAndStartFileSyncJob()
backgroundJobManager.startMediaFoldersDetectionJob()
} else {
Log_OC.d(TAG, "triggeredContentUris empty")
}
recheduleSelf()
val result = Result.success()
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
return result
companion object {
private const val TAG = "🔍" + "ContentObserverWork"
private const val CHANNEL_ID = NotificationUtils.NOTIFICATION_CHANNEL_CONTENT_OBSERVER
private const val NOTIFICATION_ID = 774
}
private fun recheduleSelf() {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val workerName = BackgroundJobManagerImpl.formatClassTag(this@ContentObserverWork::class)
backgroundJobManager.logStartOfWorker(workerName)
Log_OC.d(TAG, "started")
try {
if (params.triggeredContentUris.isNotEmpty()) {
Log_OC.d(TAG, "📸 content observer detected file changes.")
val notificationTitle = context.getString(R.string.content_observer_work_notification_title)
val notification = createNotification(notificationTitle)
updateForegroundInfo(notification)
checkAndTriggerAutoUpload()
// prevent worker fail because of another worker
try {
backgroundJobManager.startMediaFoldersDetectionJob()
} catch (e: Exception) {
Log_OC.d(TAG, "⚠️ media folder detection job failed :$e")
}
} else {
Log_OC.d(TAG, "⚠️ triggeredContentUris is empty — nothing to sync.")
}
rescheduleSelf()
val result = Result.success()
backgroundJobManager.logEndOfWorker(workerName, result)
Log_OC.d(TAG, "finished")
result
} catch (e: Exception) {
Log_OC.e(TAG, "❌ Exception in ContentObserverWork: ${e.message}", e)
Result.retry()
}
}
private suspend fun updateForegroundInfo(notification: Notification) {
val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
NOTIFICATION_ID,
notification,
ForegroundServiceType.DataSync
)
setForeground(foregroundInfo)
}
private fun createNotification(title: String): Notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setSmallIcon(R.drawable.ic_find_in_page)
.setOngoing(true)
.setSound(null)
.setVibrate(null)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSilent(true)
.build()
/**
* Re-schedules this observer to ensure continuous monitoring of media changes.
*/
private fun rescheduleSelf() {
Log_OC.d(TAG, "🔁 Rescheduling ContentObserverWork for continued observation.")
backgroundJobManager.scheduleContentObserverJob()
}
private fun checkAndStartFileSyncJob() {
if (!powerManagementService.isPowerSavingEnabled && syncedFolderProvider.countEnabledSyncedFolders() > 0) {
val changedFiles = mutableListOf<String>()
for (uri in params.triggeredContentUris) {
changedFiles.add(uri.toString())
}
FilesSyncHelper.startFilesSyncForAllFolders(
private suspend fun checkAndTriggerAutoUpload() = withContext(Dispatchers.IO) {
if (powerManagementService.isPowerSavingEnabled) {
Log_OC.w(TAG, "⚡ Power saving mode active — skipping file sync.")
return@withContext
}
val enabledFoldersCount = syncedFolderProvider.countEnabledSyncedFolders()
if (enabledFoldersCount <= 0) {
Log_OC.w(TAG, "🚫 No enabled synced folders found — skipping file sync.")
return@withContext
}
val contentUris = params.triggeredContentUris.map { uri ->
// adds uri strings e.g. content://media/external/images/media/2281
uri.toString()
}.toTypedArray()
Log_OC.d(TAG, "📄 Content uris detected")
try {
FilesSyncHelper.startAutoUploadImmediatelyWithContentUris(
syncedFolderProvider,
backgroundJobManager,
false,
changedFiles.toTypedArray()
contentUris
)
} else {
Log_OC.w(TAG, "cant startFilesSyncForAllFolders")
Log_OC.d(TAG, "✅ auto upload triggered successfully for ${contentUris.size} file(s).")
} catch (e: Exception) {
Log_OC.e(TAG, "❌ Failed to start auto upload for changed files: ${e.message}", e)
}
}
companion object {
val TAG: String = ContentObserverWork::class.java.simpleName
}
}

View file

@ -0,0 +1,135 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.autoUpload
import com.nextcloud.utils.extensions.shouldSkipFile
import com.nextcloud.utils.extensions.toLocalPath
import com.owncloud.android.datamodel.FilesystemDataProvider
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.lib.common.utils.Log_OC
import java.io.IOException
import java.nio.file.AccessDeniedException
import java.nio.file.FileVisitOption
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
@Suppress("TooGenericExceptionCaught", "MagicNumber", "ReturnCount")
class AutoUploadHelper {
companion object {
private const val TAG = "AutoUploadHelper"
private const val MAX_DEPTH = 100
}
fun insertCustomFolderIntoDB(folder: SyncedFolder, filesystemDataProvider: FilesystemDataProvider?): Int {
val path = Paths.get(folder.localPath)
if (!Files.exists(path)) {
Log_OC.w(TAG, "Folder does not exist: ${folder.localPath}")
return 0
}
if (!Files.isReadable(path)) {
Log_OC.w(TAG, "Folder is not readable: ${folder.localPath}")
return 0
}
val excludeHidden = folder.isExcludeHidden
var fileCount = 0
var skipCount = 0
var errorCount = 0
try {
Files.walkFileTree(
path,
setOf(FileVisitOption.FOLLOW_LINKS),
MAX_DEPTH,
object : SimpleFileVisitor<Path>() {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes?): FileVisitResult {
if (excludeHidden && dir != path && dir.toFile().isHidden) {
Log_OC.d(TAG, "Skipping hidden directory: ${dir.fileName}")
skipCount++
return FileVisitResult.SKIP_SUBTREE
}
return FileVisitResult.CONTINUE
}
override fun visitFile(file: Path, attrs: BasicFileAttributes?): FileVisitResult {
try {
val javaFile = file.toFile()
val lastModified = attrs?.lastModifiedTime()?.toMillis() ?: javaFile.lastModified()
val creationTime = attrs?.creationTime()?.toMillis()
if (folder.shouldSkipFile(javaFile, lastModified, creationTime)) {
skipCount++
return FileVisitResult.CONTINUE
}
val localPath = file.toLocalPath()
filesystemDataProvider?.storeOrUpdateFileValue(
localPath,
lastModified,
javaFile.isDirectory,
folder
)
fileCount++
if (fileCount % 100 == 0) {
Log_OC.d(TAG, "Processed $fileCount files so far...")
}
} catch (e: Exception) {
Log_OC.e(TAG, "Error processing file: $file", e)
errorCount++
}
return FileVisitResult.CONTINUE
}
override fun visitFileFailed(file: Path, exc: IOException?): FileVisitResult {
when (exc) {
is AccessDeniedException -> {
Log_OC.w(TAG, "Access denied: $file")
}
else -> {
Log_OC.e(TAG, "Failed to visit file: $file", exc)
}
}
errorCount++
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (exc != null) {
Log_OC.e(TAG, "Error after visiting directory: $dir", exc)
errorCount++
}
return FileVisitResult.CONTINUE
}
}
)
Log_OC.d(
TAG,
"Scan complete for ${folder.localPath}: " +
"$fileCount files processed, $skipCount skipped, $errorCount errors"
)
} catch (e: Exception) {
Log_OC.e(TAG, "Error walking file tree: ${folder.localPath}", e)
}
return fileCount
}
}

View file

@ -0,0 +1,480 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.autoUpload
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import android.content.res.Resources
import androidx.core.app.NotificationCompat
import androidx.exifinterface.media.ExifInterface
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.database.entity.UploadEntity
import com.nextcloud.client.database.entity.toOCUpload
import com.nextcloud.client.database.entity.toUploadEntity
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.jobs.BackgroundJobManager
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.client.preferences.SubFolderRule
import com.nextcloud.utils.ForegroundServiceHelper
import com.nextcloud.utils.extensions.updateStatus
import com.owncloud.android.R
import com.owncloud.android.datamodel.ArbitraryDataProviderImpl
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.ForegroundServiceType
import com.owncloud.android.datamodel.MediaFolderType
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.db.OCUpload
import com.owncloud.android.lib.common.OwnCloudAccount
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.UploadFileOperation
import com.owncloud.android.ui.activity.SettingsActivity
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.FileStorageUtils
import com.owncloud.android.utils.FilesSyncHelper
import com.owncloud.android.utils.MimeType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.text.ParsePosition
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
@Suppress("LongParameterList", "TooManyFunctions")
class AutoUploadWorker(
private val context: Context,
params: WorkerParameters,
private val userAccountManager: UserAccountManager,
private val uploadsStorageManager: UploadsStorageManager,
private val connectivityService: ConnectivityService,
private val powerManagementService: PowerManagementService,
private val syncedFolderProvider: SyncedFolderProvider,
private val backgroundJobManager: BackgroundJobManager,
private val repository: FileSystemRepository
) : CoroutineWorker(context, params) {
companion object {
const val TAG = "🔄📤" + "AutoUpload"
const val OVERRIDE_POWER_SAVING = "overridePowerSaving"
const val CONTENT_URIS = "content_uris"
const val SYNCED_FOLDER_ID = "syncedFolderId"
private const val CHANNEL_ID = NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD
private const val NOTIFICATION_ID = 266
}
private val helper = AutoUploadHelper()
private lateinit var syncedFolder: SyncedFolder
private val notificationManager by lazy {
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
@Suppress("TooGenericExceptionCaught", "ReturnCount")
override suspend fun doWork(): Result {
return try {
val syncFolderId = inputData.getLong(SYNCED_FOLDER_ID, -1)
syncedFolder = syncedFolderProvider.getSyncedFolderByID(syncFolderId)
?.takeIf { it.isEnabled } ?: return Result.failure()
// initial notification
val notification = createNotification(context.getString(R.string.upload_files))
updateForegroundInfo(notification)
/**
* Receives from [com.nextcloud.client.jobs.ContentObserverWork.checkAndTriggerAutoUpload]
*/
val contentUris = inputData.getStringArray(CONTENT_URIS)
if (canExitEarly(contentUris, syncFolderId)) {
return Result.retry()
}
collectFileChangesFromContentObserverWork(contentUris)
updateNotification()
uploadFiles(syncedFolder)
Log_OC.d(TAG, "${syncedFolder.remotePath} finished checking files.")
Result.success()
} catch (e: Exception) {
Log_OC.e(TAG, "❌ failed: ${e.message}")
Result.failure()
}
}
private fun updateNotification() {
getStartNotificationTitle()?.let { (localFolderName, remoteFolderName) ->
val startNotification = createNotification(
context.getString(
R.string.auto_upload_worker_start_text,
localFolderName,
remoteFolderName
)
)
notificationManager.notify(NOTIFICATION_ID, startNotification)
}
}
private suspend fun updateForegroundInfo(notification: Notification) {
val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
NOTIFICATION_ID,
notification,
ForegroundServiceType.DataSync
)
setForeground(foregroundInfo)
}
private fun createNotification(title: String): Notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setSmallIcon(R.drawable.uploads)
.setOngoing(true)
.setSound(null)
.setVibrate(null)
.setOnlyAlertOnce(true)
.setSilent(true)
.build()
@Suppress("TooGenericExceptionCaught")
private fun getStartNotificationTitle(): Pair<String, String>? = try {
val localPath = syncedFolder.localPath
val remotePath = syncedFolder.remotePath
if (localPath.isBlank() || remotePath.isBlank()) {
null
} else {
try {
File(localPath).name to File(remotePath).name
} catch (_: Exception) {
null
}
}
} catch (_: Exception) {
null
}
@Suppress("ReturnCount")
private fun canExitEarly(contentUris: Array<String>?, syncedFolderID: Long): Boolean {
val overridePowerSaving = inputData.getBoolean(OVERRIDE_POWER_SAVING, false)
if ((powerManagementService.isPowerSavingEnabled && !overridePowerSaving)) {
Log_OC.w(TAG, "⚡ Skipping: device is in power saving mode")
return true
}
if (syncedFolderID < 0) {
Log_OC.e(TAG, "invalid sync folder id")
return true
}
if (backgroundJobManager.bothFilesSyncJobsRunning(syncedFolderID)) {
Log_OC.w(TAG, "🚧 another worker is already running for $syncedFolderID")
return true
}
val totalScanInterval = syncedFolder.getTotalScanInterval(connectivityService, powerManagementService)
val currentTime = System.currentTimeMillis()
val passedScanInterval = totalScanInterval <= currentTime
Log_OC.d(TAG, "lastScanTimestampMs: " + syncedFolder.lastScanTimestampMs)
Log_OC.d(TAG, "totalScanInterval: $totalScanInterval")
Log_OC.d(TAG, "currentTime: $currentTime")
Log_OC.d(TAG, "passedScanInterval: $passedScanInterval")
if (!passedScanInterval && contentUris.isNullOrEmpty() && !overridePowerSaving) {
Log_OC.w(
TAG,
"skipped since started before scan interval and nothing todo: " + syncedFolder.localPath
)
return true
}
return false
}
/**
* Instead of scanning the entire local folder, optional content URIs can be passed to the worker
* to detect only the relevant changes.
*/
@Suppress("MagicNumber", "TooGenericExceptionCaught")
private suspend fun collectFileChangesFromContentObserverWork(contentUris: Array<String>?) = try {
withContext(Dispatchers.IO) {
if (contentUris.isNullOrEmpty()) {
FilesSyncHelper.insertAllDBEntriesForSyncedFolder(syncedFolder, helper)
} else {
val isContentUrisStored = FilesSyncHelper.insertChangedEntries(syncedFolder, contentUris)
if (!isContentUrisStored) {
Log_OC.w(
TAG,
"changed content uris not stored, fallback to insert all db entries to not lose files"
)
FilesSyncHelper.insertAllDBEntriesForSyncedFolder(syncedFolder, helper)
}
}
syncedFolder.lastScanTimestampMs = System.currentTimeMillis()
syncedFolderProvider.updateSyncFolder(syncedFolder)
}
} catch (e: Exception) {
Log_OC.d(TAG, "Exception collectFileChangesFromContentObserverWork: $e")
}
private fun prepareDateFormat(): SimpleDateFormat {
val currentLocale = context.resources.configuration.locales[0]
return SimpleDateFormat("yyyy:MM:dd HH:mm:ss", currentLocale).apply {
timeZone = TimeZone.getTimeZone(TimeZone.getDefault().id)
}
}
private fun getUserOrReturn(syncedFolder: SyncedFolder): User? {
val optionalUser = userAccountManager.getUser(syncedFolder.account)
if (!optionalUser.isPresent) {
Log_OC.w(TAG, "user not present")
return null
}
return optionalUser.get()
}
@Suppress("DEPRECATION")
private fun getUploadSettings(syncedFolder: SyncedFolder): Triple<Boolean, Boolean, Int> {
val lightVersion = context.resources.getBoolean(R.bool.syncedFolder_light)
val accountName = syncedFolder.account
return if (lightVersion) {
Log_OC.d(TAG, "light version is used")
val arbitraryDataProvider = ArbitraryDataProviderImpl(context)
val needsCharging = context.resources.getBoolean(R.bool.syncedFolder_light_on_charging)
val needsWifi = arbitraryDataProvider.getBooleanValue(
accountName,
SettingsActivity.SYNCED_FOLDER_LIGHT_UPLOAD_ON_WIFI
)
val uploadActionString = context.resources.getString(R.string.syncedFolder_light_upload_behaviour)
val uploadAction = getUploadAction(uploadActionString)
Log_OC.d(TAG, "upload action is: $uploadAction")
Triple(needsCharging, needsWifi, uploadAction)
} else {
Log_OC.d(TAG, "not light version is used")
Triple(syncedFolder.isChargingOnly, syncedFolder.isWifiOnly, syncedFolder.uploadAction)
}
}
@Suppress("LongMethod", "DEPRECATION", "TooGenericExceptionCaught")
private suspend fun uploadFiles(syncedFolder: SyncedFolder) = withContext(Dispatchers.IO) {
val dateFormat = prepareDateFormat()
val user = getUserOrReturn(syncedFolder) ?: return@withContext
val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context)
val client = OwnCloudClientManagerFactory.getDefaultSingleton()
.getClientFor(ocAccount, context)
val lightVersion = context.resources.getBoolean(R.bool.syncedFolder_light)
val currentLocale = context.resources.configuration.locales[0]
var lastId = 0
while (true) {
val filePathsWithIds = repository.getFilePathsWithIds(syncedFolder, lastId)
if (filePathsWithIds.isEmpty()) {
Log_OC.w(TAG, "no more files to upload at lastId: $lastId")
break
}
Log_OC.d(TAG, "Processing batch: lastId=$lastId, count=${filePathsWithIds.size}")
filePathsWithIds.forEach { (path, id) ->
val file = File(path)
val localPath = file.absolutePath
val remotePath = getRemotePath(
file,
syncedFolder,
dateFormat,
lightVersion,
context.resources,
currentLocale
)
try {
var (uploadEntity, upload) = createEntityAndUpload(user, localPath, remotePath)
try {
// Insert/update to IN_PROGRESS state before starting upload
val generatedId = uploadsStorageManager.uploadDao.insertOrReplace(uploadEntity)
uploadEntity = uploadEntity.copy(id = generatedId.toInt())
upload.uploadId = generatedId
val operation = createUploadFileOperation(upload, user)
Log_OC.d(TAG, "🕒 uploading: $localPath, id: $generatedId")
val result = operation.execute(client)
uploadsStorageManager.updateStatus(uploadEntity, result.isSuccess)
if (result.isSuccess) {
repository.markFileAsUploaded(localPath, syncedFolder)
Log_OC.d(TAG, "✅ upload completed: $localPath")
} else {
Log_OC.e(
TAG,
"❌ upload failed $localPath (${upload.accountName}): ${result.logMessage}"
)
}
} catch (e: Exception) {
uploadsStorageManager.updateStatus(
uploadEntity,
UploadsStorageManager.UploadStatus.UPLOAD_FAILED
)
Log_OC.e(
TAG,
"Exception during upload file, localPath: $localPath, remotePath: $remotePath," +
" exception: $e"
)
}
} catch (e: Exception) {
Log_OC.e(
TAG,
"Exception uploadFiles during creating entity and upload, localPath: $localPath, " +
"remotePath: $remotePath, exception: $e"
)
}
// update last id so upload can continue where it left
lastId = id
}
}
}
private fun createEntityAndUpload(user: User, localPath: String, remotePath: String): Pair<UploadEntity, OCUpload> {
val (needsCharging, needsWifi, uploadAction) = getUploadSettings(syncedFolder)
Log_OC.d(TAG, "creating oc upload for ${user.accountName}")
// Get existing upload or create new one
val uploadEntity = uploadsStorageManager.uploadDao.getUploadByAccountAndPaths(
localPath = localPath,
remotePath = remotePath,
accountName = user.accountName
)
val upload = (
uploadEntity?.toOCUpload(null) ?: OCUpload(
localPath,
remotePath,
user.accountName
)
).apply {
uploadStatus = UploadsStorageManager.UploadStatus.UPLOAD_IN_PROGRESS
nameCollisionPolicy = syncedFolder.nameCollisionPolicy
isUseWifiOnly = needsWifi
isWhileChargingOnly = needsCharging
localAction = uploadAction
// Only set these for new uploads
if (uploadEntity == null) {
createdBy = UploadFileOperation.CREATED_AS_INSTANT_PICTURE
isCreateRemoteFolder = true
}
}
return upload.toUploadEntity() to upload
}
private fun createUploadFileOperation(upload: OCUpload, user: User): UploadFileOperation = UploadFileOperation(
uploadsStorageManager,
connectivityService,
powerManagementService,
user,
null,
upload,
upload.nameCollisionPolicy,
upload.localAction,
context,
upload.isUseWifiOnly,
upload.isWhileChargingOnly,
true,
FileDataStorageManager(user, context.contentResolver)
)
private fun getRemotePath(
file: File,
syncedFolder: SyncedFolder,
sFormatter: SimpleDateFormat,
lightVersion: Boolean,
resources: Resources,
currentLocale: Locale
): String {
val lastModificationTime = calculateLastModificationTime(file, syncedFolder, sFormatter)
val (remoteFolder, useSubfolders, subFolderRule) = if (lightVersion) {
Triple(
resources.getString(R.string.syncedFolder_remote_folder),
resources.getBoolean(R.bool.syncedFolder_light_use_subfolders),
SubFolderRule.YEAR_MONTH
)
} else {
Triple(
syncedFolder.remotePath,
syncedFolder.isSubfolderByDate,
syncedFolder.subfolderRule
)
}
return FileStorageUtils.getInstantUploadFilePath(
file,
currentLocale,
remoteFolder,
syncedFolder.localPath,
lastModificationTime,
useSubfolders,
subFolderRule
)
}
private fun hasExif(file: File): Boolean {
val mimeType = FileStorageUtils.getMimeTypeFromName(file.absolutePath)
return MimeType.JPEG.equals(mimeType, ignoreCase = true) || MimeType.TIFF.equals(mimeType, ignoreCase = true)
}
@Suppress("NestedBlockDepth")
private fun calculateLastModificationTime(
file: File,
syncedFolder: SyncedFolder,
formatter: SimpleDateFormat
): Long {
var lastModificationTime = file.lastModified()
if (MediaFolderType.IMAGE == syncedFolder.type && hasExif(file)) {
Log_OC.d(TAG, "calculateLastModificationTime exif found")
@Suppress("TooGenericExceptionCaught")
try {
val exifInterface = ExifInterface(file.absolutePath)
val exifDate = exifInterface.getAttribute(ExifInterface.TAG_DATETIME)
if (!exifDate.isNullOrBlank()) {
val pos = ParsePosition(0)
val dateTime = formatter.parse(exifDate, pos)
if (dateTime != null) {
lastModificationTime = dateTime.time
Log_OC.w(TAG, "calculateLastModificationTime calculatedTime is: $lastModificationTime")
} else {
Log_OC.w(TAG, "calculateLastModificationTime dateTime is empty")
}
} else {
Log_OC.w(TAG, "calculateLastModificationTime exifDate is empty")
}
} catch (e: Exception) {
Log_OC.d(TAG, "Failed to get the proper time " + e.localizedMessage)
}
}
return lastModificationTime
}
private fun getUploadAction(action: String): Int = when (action) {
"LOCAL_BEHAVIOUR_FORGET" -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_FORGET
"LOCAL_BEHAVIOUR_MOVE" -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_MOVE
"LOCAL_BEHAVIOUR_DELETE" -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_DELETE
else -> FileUploadWorker.Companion.LOCAL_BEHAVIOUR_FORGET
}
}

View file

@ -0,0 +1,66 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.autoUpload
import com.nextcloud.client.database.dao.FileSystemDao
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.utils.SyncedFolderUtils
import java.io.File
class FileSystemRepository(private val dao: FileSystemDao) {
companion object {
private const val TAG = "FilesystemRepository"
const val BATCH_SIZE = 50
}
@Suppress("NestedBlockDepth")
suspend fun getFilePathsWithIds(syncedFolder: SyncedFolder, lastId: Int): List<Pair<String, Int>> {
val syncedFolderId = syncedFolder.id.toString()
Log_OC.d(TAG, "Fetching candidate files for syncedFolderId = $syncedFolderId")
val entities = dao.getAutoUploadFilesEntities(syncedFolderId, BATCH_SIZE, lastId)
val filtered = mutableListOf<Pair<String, Int>>()
entities.forEach {
it.localPath?.let { path ->
val file = File(path)
if (!file.exists()) {
Log_OC.w(TAG, "Ignoring file for upload (doesn't exist): $path")
} else if (!SyncedFolderUtils.isQualifiedFolder(file.parent)) {
Log_OC.w(TAG, "Ignoring file for upload (unqualified folder): $path")
} else if (!SyncedFolderUtils.isFileNameQualifiedForAutoUpload(file.name)) {
Log_OC.w(TAG, "Ignoring file for upload (unqualified file): $path")
} else {
Log_OC.d(TAG, "Adding path to upload: $path")
if (it.id != null) {
filtered.add(path to it.id)
} else {
Log_OC.w(TAG, "cant adding path to upload, id is null")
}
}
}
}
return filtered
}
@Suppress("TooGenericExceptionCaught")
suspend fun markFileAsUploaded(localPath: String, syncedFolder: SyncedFolder) {
val syncedFolderIdStr = syncedFolder.id.toString()
try {
dao.markFileAsUploaded(localPath, syncedFolderIdStr)
Log_OC.d(TAG, "Marked file as uploaded: $localPath for syncedFolderId=$syncedFolderIdStr")
} catch (e: Exception) {
Log_OC.e(TAG, "Error marking file as uploaded: ${e.message}", e)
}
}
}

View file

@ -9,10 +9,12 @@ package com.nextcloud.client.jobs.download
import com.nextcloud.client.account.User
import com.nextcloud.client.jobs.BackgroundJobManager
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker
import com.owncloud.android.MainApp
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.DownloadFileOperation
import com.owncloud.android.operations.DownloadType
import com.owncloud.android.utils.MimeTypeUtil
@ -29,6 +31,7 @@ class FileDownloadHelper {
companion object {
private var instance: FileDownloadHelper? = null
private const val TAG = "FileDownloadHelper"
fun instance(): FileDownloadHelper = instance ?: synchronized(this) {
instance ?: FileDownloadHelper().also { instance = it }
@ -44,17 +47,11 @@ class FileDownloadHelper {
return false
}
val fileStorageManager = FileDataStorageManager(user, MainApp.getAppContext().contentResolver)
val topParentId = fileStorageManager.getTopParentId(file)
val isJobScheduled = backgroundJobManager.isStartFileDownloadJobScheduled(user, file.fileId)
return isJobScheduled ||
if (file.isFolder) {
FileDownloadWorker.isDownloadingFolder(file.fileId) ||
backgroundJobManager.isStartFileDownloadJobScheduled(user, topParentId)
} else {
FileDownloadWorker.isDownloading(user.accountName, file.fileId)
}
return if (file.isFolder) {
FolderDownloadWorker.isDownloading(file.fileId)
} else {
FileDownloadWorker.isDownloading(user.accountName, file.fileId)
}
}
fun cancelPendingOrCurrentDownloads(user: User?, files: List<OCFile>?) {
@ -141,4 +138,14 @@ class FileDownloadHelper {
conflictUploadId
)
}
fun downloadFolder(folder: OCFile?, accountName: String) {
if (folder == null) {
Log_OC.e(TAG, "folder cannot be null, cant sync")
return
}
backgroundJobManager.downloadFolder(folder, accountName)
}
fun cancelFolderDownload() = backgroundJobManager.cancelFolderDownload()
}

View file

@ -24,7 +24,6 @@ import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.model.WorkerState
import com.nextcloud.model.WorkerStateLiveData
import com.nextcloud.utils.ForegroundServiceHelper
import com.nextcloud.utils.extensions.getParentIdsOfSubfiles
import com.nextcloud.utils.extensions.getPercent
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
@ -45,7 +44,6 @@ import com.owncloud.android.utils.theme.ViewThemeUtils
import java.util.AbstractList
import java.util.Optional
import java.util.Vector
import java.util.concurrent.ConcurrentHashMap
import kotlin.random.Random
@Suppress("LongParameterList", "TooManyFunctions")
@ -63,7 +61,6 @@ class FileDownloadWorker(
private val TAG = FileDownloadWorker::class.java.simpleName
private val pendingDownloads = IndexedForest<DownloadFileOperation>()
private val pendingFolderDownloads: MutableSet<Long> = ConcurrentHashMap.newKeySet<Long>()
fun cancelOperation(accountName: String, fileId: Long) {
pendingDownloads.all.forEach {
@ -75,8 +72,6 @@ class FileDownloadWorker(
it.value?.payload?.isMatching(accountName, fileId) == true
}
fun isDownloadingFolder(id: Long): Boolean = pendingFolderDownloads.contains(id)
const val FILE_REMOTE_PATH = "FILE_REMOTE_PATH"
const val ACCOUNT_NAME = "ACCOUNT_NAME"
const val BEHAVIOUR = "BEHAVIOUR"
@ -170,10 +165,6 @@ class FileDownloadWorker(
private fun getRequestDownloads(ocFile: OCFile): AbstractList<String> {
val files = getFiles(ocFile)
val filesPaths = files.map { it.remotePath }
val parentIdsOfSubFiles = fileDataStorageManager?.getParentIdsOfSubfiles(filesPaths) ?: listOf()
pendingFolderDownloads.addAll(parentIdsOfSubFiles)
val downloadType = getDownloadType()
conflictUploadId = inputData.keyValueMap[CONFLICT_UPLOAD_ID] as Long?

View file

@ -0,0 +1,167 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.folderDownload
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.jobs.download.FileDownloadHelper
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.DownloadFileOperation
import com.owncloud.android.operations.DownloadType
import com.owncloud.android.ui.helpers.FileOperationsHelper
import com.owncloud.android.utils.theme.ViewThemeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
@Suppress("LongMethod")
class FolderDownloadWorker(
private val accountManager: UserAccountManager,
private val context: Context,
private val viewThemeUtils: ViewThemeUtils,
params: WorkerParameters
) : CoroutineWorker(context, params) {
companion object {
private const val TAG = "📂" + "FolderDownloadWorker"
const val FOLDER_ID = "FOLDER_ID"
const val ACCOUNT_NAME = "ACCOUNT_NAME"
private val pendingDownloads: MutableSet<Long> = ConcurrentHashMap.newKeySet<Long>()
fun isDownloading(id: Long): Boolean = pendingDownloads.contains(id)
}
private var notificationManager: FolderDownloadWorkerNotificationManager? = null
private lateinit var storageManager: FileDataStorageManager
@Suppress("TooGenericExceptionCaught", "ReturnCount", "DEPRECATION")
override suspend fun doWork(): Result {
val folderID = inputData.getLong(FOLDER_ID, -1)
if (folderID == -1L) {
return Result.failure()
}
val accountName = inputData.getString(ACCOUNT_NAME)
if (accountName == null) {
Log_OC.e(TAG, "failed accountName cannot be null")
return Result.failure()
}
val optionalUser = accountManager.getUser(accountName)
if (optionalUser.isEmpty) {
Log_OC.e(TAG, "failed user is not present")
return Result.failure()
}
val user = optionalUser.get()
storageManager = FileDataStorageManager(user, context.contentResolver)
val folder = storageManager.getFileById(folderID)
if (folder == null) {
Log_OC.e(TAG, "failed folder cannot be nul")
return Result.failure()
}
notificationManager = FolderDownloadWorkerNotificationManager(context, viewThemeUtils)
Log_OC.d(TAG, "🕒 started for ${user.accountName} downloading ${folder.fileName}")
val foregroundInfo = notificationManager?.getForegroundInfo(folder) ?: return Result.failure()
setForeground(foregroundInfo)
pendingDownloads.add(folder.fileId)
val downloadHelper = FileDownloadHelper.instance()
return withContext(Dispatchers.IO) {
try {
val files = getFiles(folder, storageManager)
val account = user.toOwnCloudAccount()
val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(account, context)
var result = true
files.forEachIndexed { index, file ->
if (!checkDiskSize(file)) {
return@withContext Result.failure()
}
withContext(Dispatchers.Main) {
notificationManager?.showProgressNotification(
folder.fileName,
file.fileName,
index,
files.size
)
}
val operation = DownloadFileOperation(user, file, context)
val operationResult = operation.execute(client)
if (operationResult?.isSuccess == true && operation.downloadType === DownloadType.DOWNLOAD) {
getOCFile(operation)?.let { ocFile ->
downloadHelper.saveFile(ocFile, operation, storageManager)
}
}
if (!operationResult.isSuccess) {
result = false
}
}
withContext(Dispatchers.Main) {
notificationManager?.showCompletionMessage(folder.fileName, result)
}
if (result) {
Log_OC.d(TAG, "✅ completed")
Result.success()
} else {
Log_OC.d(TAG, "❌ failed")
Result.failure()
}
} catch (e: Exception) {
Log_OC.d(TAG, "❌ failed reason: $e")
Result.failure()
} finally {
pendingDownloads.remove(folder.fileId)
notificationManager?.dismiss()
}
}
}
private fun getOCFile(operation: DownloadFileOperation): OCFile? {
val file = operation.file?.fileId?.let { storageManager.getFileById(it) }
?: storageManager.getFileByDecryptedRemotePath(operation.file?.remotePath)
?: run {
Log_OC.e(TAG, "could not save ${operation.file?.remotePath}")
return null
}
return file
}
private fun getFiles(folder: OCFile, storageManager: FileDataStorageManager): List<OCFile> =
storageManager.getFolderContent(folder, false)
.filter { !it.isFolder && !it.isDown }
private suspend fun checkDiskSize(file: OCFile): Boolean {
val fileSizeInByte = file.fileLength
val availableDiskSpace = FileOperationsHelper.getAvailableSpaceOnDevice()
return if (availableDiskSpace < fileSizeInByte) {
notificationManager?.showNotAvailableDiskSpace()
false
} else {
true
}
}
}

View file

@ -0,0 +1,113 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.folderDownload
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.work.ForegroundInfo
import com.nextcloud.client.jobs.notification.WorkerNotificationManager
import com.nextcloud.utils.ForegroundServiceHelper
import com.owncloud.android.R
import com.owncloud.android.datamodel.ForegroundServiceType
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.theme.ViewThemeUtils
import kotlinx.coroutines.delay
import kotlin.random.Random
class FolderDownloadWorkerNotificationManager(private val context: Context, viewThemeUtils: ViewThemeUtils) :
WorkerNotificationManager(
id = NOTIFICATION_ID,
context,
viewThemeUtils,
tickerId = R.string.folder_download_worker_ticker_id,
channelId = NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD
) {
companion object {
private const val NOTIFICATION_ID = 391
private const val MAX_PROGRESS = 100
private const val DELAY = 1000L
}
private fun getNotification(title: String, description: String? = null, progress: Int? = null): Notification =
notificationBuilder.apply {
setSmallIcon(R.drawable.ic_sync)
setContentTitle(title)
clearActions()
description?.let {
setContentText(description)
}
progress?.let {
setProgress(MAX_PROGRESS, progress, false)
addAction(
android.R.drawable.ic_menu_close_clear_cancel,
context.getString(R.string.common_cancel),
getCancelPendingIntent()
)
}
setAutoCancel(true)
}.build()
private fun getCancelPendingIntent(): PendingIntent {
val intent = Intent(context, FolderDownloadWorkerReceiver::class.java)
return PendingIntent.getBroadcast(
context,
Random.nextInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
fun showProgressNotification(folderName: String, filename: String, currentIndex: Int, totalFileSize: Int) {
val currentFileIndex = (currentIndex + 1)
val description = context.getString(R.string.folder_download_counter, currentFileIndex, totalFileSize, filename)
val progress = (currentFileIndex * MAX_PROGRESS) / totalFileSize
val notification = getNotification(title = folderName, description = description, progress = progress)
notificationManager.notify(NOTIFICATION_ID, notification)
}
suspend fun showCompletionMessage(folderName: String, success: Boolean) {
val title = if (success) {
context.getString(R.string.folder_download_success_notification_title, folderName)
} else {
context.getString(R.string.folder_download_error_notification_title, folderName)
}
val notification = getNotification(title = title)
notificationManager.notify(NOTIFICATION_ID, notification)
delay(DELAY)
dismiss()
}
fun getForegroundInfo(folder: OCFile): ForegroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
NOTIFICATION_ID,
getNotification(folder.fileName, progress = 0),
ForegroundServiceType.DataSync
)
suspend fun showNotAvailableDiskSpace() {
val title = context.getString(R.string.folder_download_insufficient_disk_space_notification_title)
val notification = getNotification(title)
notificationManager.notify(NOTIFICATION_ID, notification)
delay(DELAY)
dismiss()
}
fun dismiss() {
notificationManager.cancel(NOTIFICATION_ID)
}
}

View file

@ -0,0 +1,25 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.client.jobs.folderDownload
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.nextcloud.client.jobs.BackgroundJobManager
import com.owncloud.android.MainApp
import javax.inject.Inject
class FolderDownloadWorkerReceiver : BroadcastReceiver() {
@Inject
lateinit var backgroundJobManager: BackgroundJobManager
override fun onReceive(context: Context, intent: Intent) {
MainApp.getAppComponent().inject(this)
backgroundJobManager.cancelFolderDownload()
}
}

View file

@ -7,11 +7,14 @@
*/
package com.nextcloud.client.jobs.upload
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.database.entity.toOCUpload
import com.nextcloud.client.database.entity.toUploadEntity
import com.nextcloud.client.device.BatteryStatus
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.jobs.BackgroundJobManager
@ -20,6 +23,7 @@ import com.nextcloud.client.network.Connectivity
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.utils.extensions.getUploadIds
import com.owncloud.android.MainApp
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.UploadsStorageManager
@ -35,13 +39,12 @@ import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation
import com.owncloud.android.lib.resources.files.model.RemoteFile
import com.owncloud.android.operations.RemoveFileOperation
import com.owncloud.android.operations.UploadFileOperation
import com.owncloud.android.utils.DisplayUtils
import com.owncloud.android.utils.FileUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutionException
import java.util.concurrent.Semaphore
import javax.inject.Inject
@ -85,18 +88,42 @@ class FileUploadHelper {
fun buildRemoteName(accountName: String, remotePath: String): String = accountName + remotePath
}
/**
* Retries all failed uploads across all user accounts.
*
* This function retrieves all uploads with the status [UploadStatus.UPLOAD_FAILED], including both
* manual uploads and auto uploads. It runs in a background thread (Dispatcher.IO) and ensures
* that only one retry operation runs at a time by using a semaphore to prevent concurrent execution.
*
* Once the failed uploads are retrieved, it calls [retryUploads], which triggers the corresponding
* upload workers for each failed upload.
*
* The function returns `true` if there were any failed uploads to retry and the retry process was
* started, or `false` if no uploads were retried.
*
* @param uploadsStorageManager Provides access to upload data and persistence.
* @param connectivityService Checks the current network connectivity state.
* @param accountManager Handles user account authentication and selection.
* @param powerManagementService Ensures uploads respect power constraints.
* @return `true` if any failed uploads were found and retried; `false` otherwise.
*/
fun retryFailedUploads(
uploadsStorageManager: UploadsStorageManager,
connectivityService: ConnectivityService,
accountManager: UserAccountManager,
powerManagementService: PowerManagementService
) {
if (retryFailedUploadsSemaphore.tryAcquire()) {
try {
val failedUploads = uploadsStorageManager.failedUploads
if (failedUploads == null || failedUploads.isEmpty()) {
Log_OC.d(TAG, "Failed uploads are empty or null")
return
): Boolean {
if (!retryFailedUploadsSemaphore.tryAcquire()) {
Log_OC.d(TAG, "skipping retryFailedUploads, already running")
return true
}
var isUploadStarted = false
try {
getUploadsByStatus(null, UploadStatus.UPLOAD_FAILED) {
if (it.isNotEmpty()) {
isUploadStarted = true
}
retryUploads(
@ -104,14 +131,14 @@ class FileUploadHelper {
connectivityService,
accountManager,
powerManagementService,
failedUploads
uploads = it
)
} finally {
retryFailedUploadsSemaphore.release()
}
} else {
Log_OC.d(TAG, "Skip retryFailedUploads since it is already running")
} finally {
retryFailedUploadsSemaphore.release()
}
return isUploadStarted
}
fun retryCancelledUploads(
@ -120,18 +147,18 @@ class FileUploadHelper {
accountManager: UserAccountManager,
powerManagementService: PowerManagementService
): Boolean {
val cancelledUploads = uploadsStorageManager.cancelledUploadsForCurrentAccount
if (cancelledUploads == null || cancelledUploads.isEmpty()) {
return false
var result = false
getUploadsByStatus(accountManager.user.accountName, UploadStatus.UPLOAD_CANCELLED) {
result = retryUploads(
uploadsStorageManager,
connectivityService,
accountManager,
powerManagementService,
it
)
}
return retryUploads(
uploadsStorageManager,
connectivityService,
accountManager,
powerManagementService,
cancelledUploads
)
return result
}
@Suppress("ComplexCondition")
@ -140,35 +167,32 @@ class FileUploadHelper {
connectivityService: ConnectivityService,
accountManager: UserAccountManager,
powerManagementService: PowerManagementService,
failedUploads: Array<OCUpload>
uploads: Array<OCUpload>
): Boolean {
var showNotExistMessage = false
val isOnline = checkConnectivity(connectivityService)
val connectivity = connectivityService.connectivity
val batteryStatus = powerManagementService.battery
val accountNames = accountManager.accounts.filter { account ->
accountManager.getUser(account.name).isPresent
}.map { account ->
account.name
}.toHashSet()
for (failedUpload in failedUploads) {
if (!accountNames.contains(failedUpload.accountName)) {
uploadsStorageManager.removeUpload(failedUpload)
continue
}
val uploadsToRetry = mutableListOf<Long>()
val uploadResult =
checkUploadConditions(failedUpload, connectivity, batteryStatus, powerManagementService, isOnline)
for (upload in uploads) {
val uploadResult = checkUploadConditions(
upload,
connectivity,
batteryStatus,
powerManagementService,
isOnline
)
if (uploadResult != UploadResult.UPLOADED) {
if (failedUpload.lastResult != uploadResult) {
if (upload.lastResult != uploadResult) {
// Setting Upload status else cancelled uploads will behave wrong, when retrying
// Needs to happen first since lastResult wil be overwritten by setter
failedUpload.uploadStatus = UploadStatus.UPLOAD_FAILED
upload.uploadStatus = UploadStatus.UPLOAD_FAILED
failedUpload.lastResult = uploadResult
uploadsStorageManager.updateUpload(failedUpload)
upload.lastResult = uploadResult
uploadsStorageManager.updateUpload(upload)
}
if (uploadResult == UploadResult.FILE_NOT_FOUND) {
showNotExistMessage = true
@ -176,15 +200,18 @@ class FileUploadHelper {
continue
}
failedUpload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
uploadsStorageManager.updateUpload(failedUpload)
// Only uploads that passed checks get marked in progress and are collected for scheduling
upload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
uploadsStorageManager.updateUpload(upload)
uploadsToRetry.add(upload.uploadId)
}
accountNames.forEach { accountName ->
val user = accountManager.getUser(accountName)
if (user.isPresent) {
backgroundJobManager.startFilesUploadJob(user.get(), failedUploads.getUploadIds(), false)
}
if (uploadsToRetry.isNotEmpty()) {
backgroundJobManager.startFilesUploadJob(
accountManager.user,
uploadsToRetry.toLongArray(),
false
)
}
return showNotExistMessage
@ -205,7 +232,7 @@ class FileUploadHelper {
showSameFileAlreadyExistsNotification: Boolean = true
) {
val uploads = localPaths.mapIndexed { index, localPath ->
OCUpload(localPath, remotePaths[index], user.accountName).apply {
val result = OCUpload(localPath, remotePaths[index], user.accountName).apply {
this.nameCollisionPolicy = nameCollisionPolicy
isUseWifiOnly = requiresWifi
isWhileChargingOnly = requiresCharging
@ -214,47 +241,54 @@ class FileUploadHelper {
isCreateRemoteFolder = createRemoteFolder
localAction = localBehavior
}
val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity())
result.uploadId = id
result
}
uploadsStorageManager.storeUploads(uploads)
backgroundJobManager.startFilesUploadJob(user, uploads.getUploadIds(), showSameFileAlreadyExistsNotification)
}
fun removeFileUpload(remotePath: String, accountName: String) {
try {
val user = accountManager.getUser(accountName).get()
// need to update now table in mUploadsStorageManager,
// since the operation will not get to be run by FileUploader#uploadFile
uploadsStorageManager.removeUpload(accountName, remotePath)
val uploadIds = uploadsStorageManager.getCurrentUploadIds(user.accountName)
cancelAndRestartUploadJob(user, uploadIds)
} catch (e: NoSuchElementException) {
Log_OC.e(TAG, "Error cancelling current upload because user does not exist!: " + e.message)
}
uploadsStorageManager.uploadDao.deleteByAccountAndRemotePath(accountName, remotePath)
}
fun cancelFileUpload(remotePath: String, accountName: String) {
fun updateUploadStatus(remotePath: String, accountName: String, status: UploadStatus) {
ioScope.launch {
val upload = uploadsStorageManager.getUploadByRemotePath(remotePath)
if (upload != null) {
cancelFileUploads(listOf(upload), accountName)
} else {
Log_OC.e(TAG, "Error cancelling current upload because upload does not exist!")
}
uploadsStorageManager.uploadDao.updateStatus(remotePath, accountName, status.value)
}
}
fun cancelFileUploads(uploads: List<OCUpload>, accountName: String) {
for (upload in uploads) {
upload.uploadStatus = UploadStatus.UPLOAD_CANCELLED
uploadsStorageManager.updateUpload(upload)
}
try {
val user = accountManager.getUser(accountName).get()
cancelAndRestartUploadJob(user, uploads.getUploadIds())
} catch (e: NoSuchElementException) {
Log_OC.e(TAG, "Error restarting upload job because user does not exist!: " + e.message)
/**
* Retrieves uploads filtered by their status, optionally for a specific account.
*
* This function queries the uploads database asynchronously to obtain a list of uploads
* that match the specified [status]. If an [accountName] is provided, only uploads
* belonging to that account are retrieved. If [accountName] is `null`, uploads with the
* given [status] from **all user accounts** are returned.
*
* Once the uploads are fetched, the [onCompleted] callback is invoked with the resulting array.
*
* @param accountName The name of the account to filter uploads by.
* If `null`, uploads matching the given [status] from all accounts are returned.
* @param status The [UploadStatus] to filter uploads by (e.g., `UPLOAD_FAILED`).
* @param nameCollisionPolicy The [NameCollisionPolicy] to filter uploads by (e.g., `SKIP`).
* @param onCompleted A callback invoked with the resulting array of [OCUpload] objects.
*/
fun getUploadsByStatus(
accountName: String?,
status: UploadStatus,
nameCollisionPolicy: NameCollisionPolicy? = null,
onCompleted: (Array<OCUpload>) -> Unit
) {
ioScope.launch {
val dao = uploadsStorageManager.uploadDao
val result = if (accountName != null) {
dao.getUploadsByAccountNameAndStatus(accountName, status.value, nameCollisionPolicy?.serialize())
} else {
dao.getUploadsByStatus(status.value, nameCollisionPolicy?.serialize())
}.map { it.toOCUpload(null) }.toTypedArray()
onCompleted(result)
}
}
@ -266,26 +300,16 @@ class FileUploadHelper {
}
@Suppress("ReturnCount")
fun isUploading(user: User?, file: OCFile?): Boolean {
if (user == null || file == null || !backgroundJobManager.isStartFileUploadJobScheduled(user)) {
fun isUploading(remotePath: String?, accountName: String?): Boolean {
accountName ?: return false
if (!backgroundJobManager.isStartFileUploadJobScheduled(accountName)) {
return false
}
val uploadCompletableFuture = CompletableFuture.supplyAsync {
uploadsStorageManager.getUploadByRemotePath(file.remotePath)
}
return try {
val upload = uploadCompletableFuture.get()
if (upload != null) {
upload.uploadStatus == UploadStatus.UPLOAD_IN_PROGRESS
} else {
false
}
} catch (e: ExecutionException) {
false
} catch (e: InterruptedException) {
false
}
remotePath ?: return false
val upload = uploadsStorageManager.uploadDao.getByRemotePath(remotePath)
return upload?.status == UploadStatus.UPLOAD_IN_PROGRESS.value ||
FileUploadWorker.isUploading(remotePath, accountName)
}
private fun checkConnectivity(connectivityService: ConnectivityService): Boolean {
@ -364,7 +388,7 @@ class FileUploadHelper {
val uploads = existingFiles.map { file ->
file?.let {
OCUpload(file, user).apply {
val result = OCUpload(file, user).apply {
fileSize = file.fileLength
this.nameCollisionPolicy = nameCollisionPolicy
isCreateRemoteFolder = true
@ -373,9 +397,12 @@ class FileUploadHelper {
isWhileChargingOnly = false
uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
}
val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity())
result.uploadId = id
result
}
}
uploadsStorageManager.storeUploads(uploads)
val uploadIds: LongArray = uploads.filterNotNull().map { it.uploadId }.toLongArray()
backgroundJobManager.startFilesUploadJob(user, uploadIds, true)
}
@ -459,6 +486,14 @@ class FileUploadHelper {
return false
}
fun showFileUploadLimitMessage(activity: Activity) {
val message = activity.resources.getQuantityString(
R.plurals.file_upload_limit_message,
MAX_FILE_COUNT
)
DisplayUtils.showSnackMessage(activity, message)
}
class UploadNotificationActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val accountName = intent.getStringExtra(FileUploadWorker.EXTRA_ACCOUNT_NAME)
@ -474,7 +509,9 @@ class FileUploadHelper {
return
}
instance().cancelFileUpload(remotePath, accountName)
FileUploadWorker.cancelCurrentUpload(remotePath, accountName, onCompleted = {
instance().updateUploadStatus(remotePath, accountName, UploadStatus.UPLOAD_CANCELLED)
})
}
}
}

View file

@ -7,10 +7,12 @@
*/
package com.nextcloud.client.jobs.upload
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.Worker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
@ -21,8 +23,12 @@ import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.model.WorkerState
import com.nextcloud.model.WorkerStateLiveData
import com.nextcloud.utils.ForegroundServiceHelper
import com.nextcloud.utils.extensions.getPercent
import com.nextcloud.utils.extensions.updateStatus
import com.owncloud.android.R
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.ForegroundServiceType
import com.owncloud.android.datamodel.ThumbnailsCacheManager
import com.owncloud.android.datamodel.UploadsStorageManager
import com.owncloud.android.db.OCUpload
@ -34,8 +40,12 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.UploadFileOperation
import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.ErrorMessageAdapter
import com.owncloud.android.utils.theme.ViewThemeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.random.Random
@ -51,7 +61,7 @@ class FileUploadWorker(
val preferences: AppPreferences,
val context: Context,
params: WorkerParameters
) : Worker(context, params),
) : CoroutineWorker(context, params),
OnDatatransferProgressListener {
companion object {
@ -91,19 +101,44 @@ class FileUploadWorker(
fun getUploadStartMessage(): String = FileUploadWorker::class.java.name + UPLOAD_START_MESSAGE
fun getUploadFinishMessage(): String = FileUploadWorker::class.java.name + UPLOAD_FINISH_MESSAGE
fun cancelCurrentUpload(remotePath: String, accountName: String, onCompleted: () -> Unit) {
currentUploadFileOperation?.let {
if (it.remotePath == remotePath && it.user.accountName == accountName) {
it.cancel(ResultCode.USER_CANCELLED)
onCompleted()
}
}
}
fun isUploading(remotePath: String?, accountName: String?): Boolean {
currentUploadFileOperation?.let {
return it.remotePath == remotePath && it.user.accountName == accountName
}
return false
}
}
private var lastPercent = 0
private val notificationManager = UploadNotificationManager(context, viewThemeUtils, Random.nextInt())
private val notificationId = Random.nextInt()
private val notificationManager = UploadNotificationManager(context, viewThemeUtils, notificationId)
private val intents = FileUploaderIntents(context)
private val fileUploaderDelegate = FileUploaderDelegate()
@Suppress("TooGenericExceptionCaught")
override fun doWork(): Result = try {
override suspend fun doWork(): Result = try {
Log_OC.d(TAG, "FileUploadWorker started")
backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
val workerName = BackgroundJobManagerImpl.formatClassTag(this::class)
backgroundJobManager.logStartOfWorker(workerName)
val notificationTitle = notificationManager.currentOperationTitle
?: context.getString(R.string.foreground_service_upload)
val notification = createNotification(notificationTitle)
updateForegroundInfo(notification)
val result = uploadFiles()
backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
backgroundJobManager.logEndOfWorker(workerName, result)
notificationManager.dismissNotification()
if (result == Result.success()) {
setIdleWorkerState()
@ -111,17 +146,37 @@ class FileUploadWorker(
result
} catch (t: Throwable) {
Log_OC.e(TAG, "Error caught at FileUploadWorker $t")
cleanup()
Result.failure()
}
override fun onStopped() {
private suspend fun updateForegroundInfo(notification: Notification) {
val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
notificationId,
notification,
ForegroundServiceType.DataSync
)
setForeground(foregroundInfo)
}
private fun createNotification(title: String): Notification =
NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD)
.setContentTitle(title)
.setSmallIcon(R.drawable.uploads)
.setOngoing(true)
.setSound(null)
.setVibrate(null)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSilent(true)
.build()
private fun cleanup() {
Log_OC.e(TAG, "FileUploadWorker stopped")
setIdleWorkerState()
currentUploadFileOperation?.cancel(null)
notificationManager.dismissNotification()
super.onStopped()
}
private fun setWorkerState(user: User?) {
@ -133,36 +188,36 @@ class FileUploadWorker(
}
@Suppress("ReturnCount", "LongMethod")
private fun uploadFiles(): Result {
private suspend fun uploadFiles(): Result = withContext(Dispatchers.IO) {
val accountName = inputData.getString(ACCOUNT)
if (accountName == null) {
Log_OC.e(TAG, "accountName is null")
return Result.failure()
return@withContext Result.failure()
}
val uploadIds = inputData.getLongArray(UPLOAD_IDS)
if (uploadIds == null) {
Log_OC.e(TAG, "uploadIds is null")
return Result.failure()
return@withContext Result.failure()
}
val currentBatchIndex = inputData.getInt(CURRENT_BATCH_INDEX, -1)
if (currentBatchIndex == -1) {
Log_OC.e(TAG, "currentBatchIndex is -1, cancelling")
return Result.failure()
return@withContext Result.failure()
}
val totalUploadSize = inputData.getInt(TOTAL_UPLOAD_SIZE, -1)
if (totalUploadSize == -1) {
Log_OC.e(TAG, "totalUploadSize is -1, cancelling")
return Result.failure()
return@withContext Result.failure()
}
// since worker's policy is append or replace and account name comes from there no need check in the loop
val optionalUser = userAccountManager.getUser(accountName)
if (!optionalUser.isPresent) {
Log_OC.e(TAG, "User not found for account: $accountName")
return Result.failure()
return@withContext Result.failure()
}
val user = optionalUser.get()
@ -172,21 +227,19 @@ class FileUploadWorker(
val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context)
for ((index, upload) in uploads.withIndex()) {
ensureActive()
if (preferences.isGlobalUploadPaused) {
Log_OC.d(TAG, "Upload is paused, skip uploading files!")
notificationManager.notifyPaused(
intents.notificationStartIntent(null)
)
return Result.success()
return@withContext Result.success()
}
if (canExitEarly()) {
notificationManager.showConnectionErrorNotification()
return Result.failure()
}
if (isStopped) {
continue
return@withContext Result.failure()
}
setWorkerState(user)
@ -203,12 +256,16 @@ class FileUploadWorker(
totalUploadSize = totalUploadSize
)
val result = upload(operation, user, client)
val result = withContext(Dispatchers.IO) {
upload(operation, user, client)
}
val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName)
uploadsStorageManager.updateStatus(entity, result.isSuccess)
currentUploadFileOperation = null
sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result)
}
return Result.success()
return@withContext Result.success()
}
private fun sendUploadFinishEvent(
@ -346,6 +403,10 @@ class FileUploadWorker(
return
}
if (uploadResult.code == ResultCode.USER_CANCELLED) {
return
}
notificationManager.run {
val errorMessage = ErrorMessageAdapter.getErrorCauseMessage(
uploadResult,

View file

@ -6,6 +6,7 @@
*/
package com.nextcloud.client.logger.ui
import android.annotation.SuppressLint
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
@ -17,6 +18,7 @@ import com.nextcloud.client.logger.LogsRepository
import com.owncloud.android.R
import javax.inject.Inject
@SuppressLint("StaticFieldLeak")
class LogsViewModel @Inject constructor(
private val context: Context,
clock: Clock,

View file

@ -132,6 +132,7 @@ class WhatsNewActivity :
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
onFinish()
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}

View file

@ -63,6 +63,12 @@ public interface AppPreferences {
boolean isShowHiddenFilesEnabled();
void setShowHiddenFilesEnabled(boolean enabled);
boolean isSortFoldersBeforeFiles();
void setSortFoldersBeforeFiles(boolean enabled);
boolean isSortFavoritesFirst();
void setSortFavoritesFirst(boolean enabled);
boolean isShowEcosystemApps();
void setShowEcosystemApps(boolean enabled);
@ -344,10 +350,6 @@ public interface AppPreferences {
long getPhotoSearchTimestamp();
boolean isPowerCheckDisabled();
void setPowerCheckDisabled(boolean value);
void increasePinWrongAttempts();
void resetPinWrongAttempts();

View file

@ -70,6 +70,8 @@ public final class AppPreferencesImpl implements AppPreferences {
private static final String PREF__INSTANT_UPLOADING = "instant_uploading";
private static final String PREF__INSTANT_VIDEO_UPLOADING = "instant_video_uploading";
private static final String PREF__SHOW_HIDDEN_FILES = "show_hidden_files_pref";
private static final String PREF__SORT_FOLDERS_BEFORE_FILES = "sort_folders_before_files";
private static final String PREF__SORT_FAVORITES_FIRST = "sort_favorites_first";
private static final String PREF__SHOW_ECOSYSTEM_APPS = "show_ecosystem_apps";
private static final String PREF__LEGACY_CLEAN = "legacyClean";
private static final String PREF__KEYS_MIGRATION = "keysMigration";
@ -88,7 +90,6 @@ public final class AppPreferencesImpl implements AppPreferences {
private static final String PREF__SELECTED_ACCOUNT_NAME = "select_oc_account";
private static final String PREF__MIGRATED_USER_ID = "migrated_user_id";
private static final String PREF__PHOTO_SEARCH_TIMESTAMP = "photo_search_timestamp";
private static final String PREF__POWER_CHECK_DISABLED = "power_check_disabled";
private static final String PREF__PIN_BRUTE_FORCE_COUNT = "pin_brute_force_count";
private static final String PREF__UID_PID = "uid_pid";
@ -229,6 +230,26 @@ public final class AppPreferencesImpl implements AppPreferences {
preferences.edit().putBoolean(PREF__SHOW_HIDDEN_FILES, enabled).apply();
}
@Override
public boolean isSortFoldersBeforeFiles() {
return preferences.getBoolean(PREF__SORT_FOLDERS_BEFORE_FILES, true);
}
@Override
public void setSortFoldersBeforeFiles(boolean enabled) {
preferences.edit().putBoolean(PREF__SORT_FOLDERS_BEFORE_FILES, enabled).apply();
}
@Override
public boolean isSortFavoritesFirst() {
return preferences.getBoolean(PREF__SORT_FAVORITES_FIRST, true);
}
@Override
public void setSortFavoritesFirst(boolean enabled) {
preferences.edit().putBoolean(PREF__SORT_FAVORITES_FIRST, enabled).apply();
}
@Override
public boolean isShowEcosystemApps() {
return preferences.getBoolean(PREF__SHOW_ECOSYSTEM_APPS, true);
@ -689,16 +710,6 @@ public final class AppPreferencesImpl implements AppPreferences {
return preferenceName + "_" + folderIdString;
}
@Override
public boolean isPowerCheckDisabled() {
return preferences.getBoolean(PREF__POWER_CHECK_DISABLED, false);
}
@Override
public void setPowerCheckDisabled(boolean value) {
preferences.edit().putBoolean(PREF__POWER_CHECK_DISABLED, value).apply();
}
public void increasePinWrongAttempts() {
int count = preferences.getInt(PREF__PIN_BRUTE_FORCE_COUNT, 0);
preferences.edit().putInt(PREF__PIN_BRUTE_FORCE_COUNT, count + 1).apply();

View file

@ -18,11 +18,11 @@ enum class SearchResultEntryType {
Unknown;
fun iconId(): Int = when (this) {
CalendarEvent -> R.drawable.file_calendar
Folder -> R.drawable.folder
Note -> R.drawable.ic_edit
Contact -> R.drawable.file_vcard
CalendarEvent -> R.drawable.file_calendar
Deck -> R.drawable.ic_deck
else -> R.drawable.ic_find_in_page
Unknown -> R.drawable.ic_find_in_page
}
}

View file

@ -84,6 +84,7 @@ class ChooseAccountDialogFragment :
return builder.create()
}
@Suppress("LongMethod")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
accountManager = (activity as BaseActivity).userAccountManager

View file

@ -335,6 +335,7 @@ class SetStatusMessageBottomSheet(val user: User, val currentStatus: Status?) :
}
}
@SuppressLint("NotifyDataSetChanged")
@VisibleForTesting
fun setPredefinedStatus(predefinedStatus: ArrayList<PredefinedStatus>) {
adapter.list = predefinedStatus

View file

@ -0,0 +1,36 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.ui.behavior
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.behavior.HideViewOnScrollBehavior
class OnScrollBehavior<V : View> @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
HideViewOnScrollBehavior<V>(context, attrs) {
override fun onNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: V,
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
if (dyConsumed > 0) {
slideOut(child)
} else if (dyConsumed < 0 || dyUnconsumed < 0) {
slideIn(child)
}
}
}

View file

@ -18,7 +18,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.nextcloud.client.assistant.AssistantScreen
import com.nextcloud.client.assistant.AssistantViewModel
import com.nextcloud.client.assistant.repository.AssistantRepository
import com.nextcloud.client.assistant.repository.local.AssistantLocalRepositoryImpl
import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepositoryImpl
import com.nextcloud.client.database.NextcloudDatabase
import com.nextcloud.common.NextcloudClient
import com.nextcloud.utils.extensions.getSerializableArgument
import com.owncloud.android.R
@ -79,10 +81,14 @@ class ComposeActivity : DrawerActivity() {
isChecked = true
}
val dao = NextcloudDatabase.instance().assistantDao()
nextcloudClient?.let { client ->
AssistantScreen(
viewModel = AssistantViewModel(
repository = AssistantRepository(client, capabilities)
accountName = userAccountManager.user.accountName,
remoteRepository = AssistantRemoteRepositoryImpl(client, capabilities),
localRepository = AssistantLocalRepositoryImpl(dao)
),
activity = this,
capability = capabilities

View file

@ -39,8 +39,8 @@ enum class FileAction(
// Uploads and downloads
DOWNLOAD_FILE(R.id.action_download_file, R.string.filedetails_download, R.drawable.ic_cloud_download),
SYNC_FILE(R.id.action_sync_file, R.string.filedetails_sync_file, R.drawable.ic_cloud_sync_on),
CANCEL_SYNC(R.id.action_cancel_sync, R.string.common_cancel_sync, R.drawable.ic_cloud_sync_off),
DOWNLOAD_FOLDER(R.id.action_sync_file, R.string.filedetails_sync_file, R.drawable.ic_sync),
CANCEL_SYNC(R.id.action_cancel_sync, R.string.common_cancel_sync, R.drawable.ic_sync_off),
// File sharing
EXPORT_FILE(R.id.action_export_file, R.string.filedetails_export, R.drawable.ic_export),
@ -84,7 +84,7 @@ enum class FileAction(
SEND_SHARE_FILE,
SEND_FILE,
OPEN_FILE_WITH,
SYNC_FILE,
DOWNLOAD_FOLDER,
CANCEL_SYNC,
SELECT_ALL,
SELECT_NONE,

View file

@ -8,7 +8,15 @@
package com.nextcloud.utils
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import com.nextcloud.utils.extensions.toFile
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.utils.BitmapUtils.calculateSampleFactor
private const val TAG = "BitmapExtension"
@Suppress("MagicNumber")
fun Bitmap.allocationKilobyte(): Int = allocationByteCount.div(1024)
@ -38,3 +46,115 @@ fun Bitmap.scaleUntil(targetKB: Int): Bitmap {
val scaledBitmap = scale(width, height)
return scaledBitmap.scaleUntil(targetKB)
}
/**
* Rotates and/or flips a [Bitmap] according to an EXIF orientation constant.
*
* Needed because loading bitmaps directly may ignore EXIF metadata with some devices,
* resulting in incorrectly displayed images.
*
* This function uses a [Matrix] transformation to adjust the image so that it
* appears upright when displayed. It supports all standard EXIF orientations,
* including mirrored and rotated cases.
*
* The original bitmap will be recycled if a new one is successfully created.
* If the device runs out of memory during the transformation, the original bitmap
* is returned unchanged.
*
* @receiver The [Bitmap] to rotate or flip. Can be `null`.
* @param orientation One of the [ExifInterface] orientation constants, such as
* [ExifInterface.ORIENTATION_ROTATE_90] or [ExifInterface.ORIENTATION_FLIP_HORIZONTAL].
* @return The correctly oriented [Bitmap], or `null` if the receiver was `null`.
*
* @see ExifInterface
* @see Matrix
*/
@Suppress("MagicNumber", "ReturnCount")
fun Bitmap?.rotateBitmapViaExif(orientation: Int): Bitmap? {
if (this == null) {
return null
}
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_NORMAL -> return this
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.setRotate(180f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.setRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.setRotate(-90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f)
else -> return this
}
return try {
val rotated = Bitmap.createBitmap(
this,
0,
0,
this.width,
this.height,
matrix,
true
)
// release original if a new one was created
if (rotated != this) {
this.recycle()
}
rotated
} catch (_: OutOfMemoryError) {
Log_OC.e("BitmapExtension", "rotating bitmap, out of memory exception")
this
}
}
/**
* Decodes a bitmap from a file path while minimizing memory usage.
*
* This function first checks if the file exists (via [toFile]), then performs following steps:
*
* 1. Reads image dimensions using [BitmapFactory.Options.inJustDecodeBounds] without allocating memory.
* 2. Calculates a sampling factor with [calculateSampleFactor] to scale down large images efficiently.
* 3. Decodes the actual bitmap using the computed sample size.
*
* @param srcPath Absolute path to the image file.
* @param reqWidth Desired width in pixels of the output bitmap.
* @param reqHeight Desired height in pixels of the output bitmap.
* @return The decoded [Bitmap], or `null` if the file does not exist or decoding fails.
*/
@Suppress("TooGenericExceptionCaught")
fun decodeSampledBitmapFromFile(srcPath: String?, reqWidth: Int, reqHeight: Int): Bitmap? {
// check existence of file
srcPath?.toFile() ?: return null
// Read image dimensions without allocating memory just to get pixels
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(srcPath, options)
// Calculate sampling factor
options.inSampleSize = calculateSampleFactor(options, reqWidth, reqHeight)
options.inJustDecodeBounds = false
// Decode actual bitmap
return try {
BitmapFactory.decodeFile(srcPath, options)
} catch (e: Exception) {
Log_OC.e(TAG, "exception during decoding path: $e")
null
}
}

View file

@ -0,0 +1,67 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils
import com.owncloud.android.lib.common.utils.Log_OC
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.util.stream.Collectors
import kotlin.io.path.pathString
@Suppress("NestedBlockDepth")
object FileHelper {
private const val TAG = "FileHelper"
fun listDirectoryEntries(directory: File?, startIndex: Int, maxItems: Int, fetchFolders: Boolean): List<File> {
if (directory == null || !directory.exists() || !directory.isDirectory) return emptyList()
return try {
Files.list(directory.toPath())
.map { it.toFile() }
.filter { file -> if (fetchFolders) file.isDirectory else !file.isDirectory }
.skip(startIndex.toLong())
.limit(maxItems.toLong())
.collect(Collectors.toList())
} catch (e: IOException) {
Log_OC.d(TAG, "listDirectoryEntries: $e")
emptyList()
}
}
fun listFilesRecursive(files: Collection<File>): List<String> {
val result = mutableListOf<String>()
for (file in files) {
try {
collectFilesRecursively(file.toPath(), result)
} catch (e: IOException) {
Log_OC.e(TAG, "Error collecting files recursively from: ${file.absolutePath}", e)
}
}
return result
}
private fun collectFilesRecursively(path: Path, result: MutableList<String>) {
if (Files.isDirectory(path)) {
try {
Files.newDirectoryStream(path).use { stream ->
for (entry in stream) {
collectFilesRecursively(entry, result)
}
}
} catch (e: IOException) {
Log_OC.e(TAG, "Error reading directory: ${path.pathString}", e)
}
} else {
result.add(path.pathString)
}
}
}

View file

@ -9,6 +9,7 @@ package com.nextcloud.utils
import android.app.Notification
import android.app.Service
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import androidx.core.app.ServiceCompat
@ -32,7 +33,7 @@ object ForegroundServiceHelper {
service,
id,
notification,
foregroundServiceType.getId()
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} catch (e: Exception) {
Log.d(TAG, "Exception caught at ForegroundServiceHelper.startService: $e")

View file

@ -45,52 +45,62 @@ class ShortcutUtil @Inject constructor(private val mContext: Context) {
user: User,
syncedFolderProvider: SyncedFolderProvider
) {
if (ShortcutManagerCompat.isRequestPinShortcutSupported(mContext)) {
val intent = Intent(mContext, FileDisplayActivity::class.java)
intent.action = FileDisplayActivity.OPEN_FILE
intent.putExtra(FileActivity.EXTRA_FILE, file.remotePath)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
val shortcutId = "nextcloud_shortcut_" + file.remoteId
val icon: IconCompat
var thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(
ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId
)
if (thumbnail != null) {
thumbnail = bitmapToAdaptiveBitmap(thumbnail)
icon = IconCompat.createWithAdaptiveBitmap(thumbnail)
} else if (file.isFolder) {
if (!ShortcutManagerCompat.isRequestPinShortcutSupported(mContext)) {
return
}
val intent = Intent(mContext, FileDisplayActivity::class.java).apply {
action = FileDisplayActivity.OPEN_FILE
putExtra(FileActivity.EXTRA_FILE_REMOTE_PATH, file.decryptedRemotePath)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val icon = createShortcutIcon(file, viewThemeUtils, user, syncedFolderProvider)
val shortcutInfo = ShortcutInfoCompat.Builder(mContext, "nextcloud_shortcut_${file.remoteId}")
.setShortLabel(file.fileName)
.setLongLabel(mContext.getString(R.string.pin_shortcut_label, file.fileName))
.setIcon(icon)
.setIntent(intent)
.build()
val resultIntent =
ShortcutManagerCompat.createShortcutResultIntent(mContext, shortcutInfo)
val pendingIntent = PendingIntent.getBroadcast(
mContext,
file.hashCode(),
resultIntent,
FLAG_IMMUTABLE
)
ShortcutManagerCompat.requestPinShortcut(mContext, shortcutInfo, pendingIntent.intentSender)
}
private fun createShortcutIcon(
file: OCFile,
viewThemeUtils: ViewThemeUtils,
user: User,
syncedFolderProvider: SyncedFolderProvider
): IconCompat {
val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(
ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId
)
return when {
thumbnail != null -> IconCompat.createWithAdaptiveBitmap(bitmapToAdaptiveBitmap(thumbnail))
file.isFolder -> {
val isAutoUploadFolder = SyncedFolderProvider.isAutoUploadFolder(syncedFolderProvider, file, user)
val isDarkModeActive = syncedFolderProvider.preferences.isDarkModeEnabled
val overlayIconId = file.getFileOverlayIconId(isAutoUploadFolder)
val drawable = MimeTypeUtil.getFolderIcon(isDarkModeActive, overlayIconId, mContext, viewThemeUtils)
val bitmapIcon = drawable.toBitmap()
icon = IconCompat.createWithBitmap(bitmapIcon)
} else {
icon = IconCompat.createWithResource(
mContext,
MimeTypeUtil.getFileTypeIconId(file.mimeType, file.fileName)
)
IconCompat.createWithBitmap(drawable.toBitmap())
}
val longLabel = mContext.getString(R.string.pin_shortcut_label, file.fileName)
val pinShortcutInfo = ShortcutInfoCompat.Builder(mContext, shortcutId)
.setShortLabel(file.fileName)
.setLongLabel(longLabel)
.setIcon(icon)
.setIntent(intent)
.build()
val pinnedShortcutCallbackIntent =
ShortcutManagerCompat.createShortcutResultIntent(mContext, pinShortcutInfo)
val successCallback = PendingIntent.getBroadcast(
else -> IconCompat.createWithResource(
mContext,
0,
pinnedShortcutCallbackIntent,
FLAG_IMMUTABLE
)
ShortcutManagerCompat.requestPinShortcut(
mContext,
pinShortcutInfo,
successCallback.intentSender
MimeTypeUtil.getFileTypeIconId(file.mimeType, file.fileName)
)
}
}

View file

@ -8,12 +8,12 @@
package com.nextcloud.utils.autoRename
import com.nextcloud.utils.extensions.StringConstants
import com.nextcloud.utils.extensions.checkWCFRestrictions
import com.nextcloud.utils.extensions.forbiddenFilenameCharacters
import com.nextcloud.utils.extensions.forbiddenFilenameExtensions
import com.nextcloud.utils.extensions.shouldRemoveNonPrintableUnicodeCharactersAndConvertToUTF8
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.lib.resources.status.NextcloudVersion
import com.owncloud.android.lib.resources.status.OCCapability
import org.apache.commons.io.FilenameUtils
import java.util.regex.Pattern
@ -25,12 +25,12 @@ object AutoRename {
@Suppress("NestedBlockDepth")
@JvmOverloads
fun rename(filename: String, capability: OCCapability, isFolderPath: Boolean? = null): String {
Log_OC.d(TAG, "Before - $filename")
if (!capability.version.isNewerOrEqual(NextcloudVersion.nextcloud_30)) {
if (!capability.checkWCFRestrictions()) {
return filename
}
Log_OC.d(TAG, "Before - $filename")
val isFolder = isFolderPath ?: filename.endsWith(OCFile.PATH_SEPARATOR)
val pathSegments = filename.split(OCFile.PATH_SEPARATOR).toMutableList()

View file

@ -1,7 +1,7 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2024 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@ -10,22 +10,10 @@ package com.nextcloud.utils.extensions
import android.content.Intent
import com.owncloud.android.MainApp
import com.owncloud.android.R
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.ui.activity.DrawerActivity
import com.owncloud.android.ui.activity.FileDisplayActivity
@Suppress("ReturnCount")
fun DrawerActivity.handleBackButtonEvent(currentDir: OCFile): Boolean {
if (DrawerActivity.menuItemId == R.id.nav_all_files && currentDir.isRootDirectory) {
moveTaskToBack(true)
return true
}
val isParentDirExists = (storageManager.getFileById(currentDir.parentId) != null)
if (isParentDirExists) {
return false
}
fun DrawerActivity.navigateToAllFiles() {
DrawerActivity.menuItemId = R.id.nav_all_files
setNavigationViewItemChecked()
@ -38,6 +26,4 @@ fun DrawerActivity.handleBackButtonEvent(currentDir: OCFile): Boolean {
}.run {
startActivity(this)
}
return true
}

View file

@ -8,6 +8,8 @@
*/
package com.nextcloud.utils.extensions
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.text.Selection
import android.text.Spannable
@ -24,6 +26,12 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
fun mainThread(delay: Long = 1000, action: () -> Unit) {
Handler(Looper.getMainLooper()).postDelayed({
action()
}, delay)
}
fun clickWithDebounce(view: View, debounceTime: Long = 600L, action: () -> Unit) {
view.setOnClickListener(object : View.OnClickListener {
private var lastClickTime: Long = 0

View file

@ -10,8 +10,10 @@ package com.nextcloud.utils.extensions
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
fun FileDataStorageManager.getParentIdsOfSubfiles(paths: List<String>): List<Long> =
fileDao.getParentIdsOfSubfiles(paths)
fun FileDataStorageManager.searchFilesByName(file: OCFile, accountName: String, query: String): List<OCFile> =
fileDao.searchFilesInFolder(file.fileId, accountName, query).map {
createFileInstance(it)
}
fun FileDataStorageManager.getDecryptedPath(file: OCFile): String {
val paths = mutableListOf<String>()

View file

@ -11,6 +11,9 @@ import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.utils.DisplayUtils
import java.io.File
import java.nio.file.Path
private const val TAG = "FileExtensions"
fun OCFile?.logFileSize(tag: String) {
val size = DisplayUtils.bytesToHumanReadable(this?.fileLength ?: -1)
@ -23,3 +26,27 @@ fun File?.logFileSize(tag: String) {
val rawByte = this?.length() ?: -1
Log_OC.d(tag, "onSaveInstanceState: $size, raw byte $rawByte")
}
fun Path.toLocalPath(): String = toAbsolutePath().toString()
/**
* Converts a non-null and non-empty [String] path into a [File] object, if it exists.
*
* @receiver String path to a file.
* @return [File] instance if the file exists, or `null` if the path is null, empty, or non-existent.
*/
@Suppress("ReturnCount")
fun String.toFile(): File? {
if (isNullOrEmpty()) {
Log_OC.e(TAG, "given path is null or empty")
return null
}
val file = File(this)
if (!file.exists()) {
Log_OC.e(TAG, "File does not exist: $this")
return null
}
return file
}

View file

@ -8,11 +8,31 @@
package com.nextcloud.utils.extensions
import com.google.gson.Gson
import com.owncloud.android.lib.resources.status.NextcloudVersion
import com.owncloud.android.lib.resources.status.OCCapability
import org.json.JSONException
private val gson = Gson()
/**
* Determines whether **Windows-compatible file (WCF)** restrictions should be applied
* for the current server version and configuration.
*
* Behavior:
* - For **Nextcloud 32 and newer**, WCF enforcement depends on the [`isWCFEnabled`] flag
* provided by the server capabilities.
* - For **Nextcloud 30 and 31**, WCF restrictions are always applied (feature considered enabled).
* - For **versions older than 30**, WCF is not supported, and no restrictions are applied.
*
* @return `true` if WCF restrictions should be enforced based on the server version and configuration;
* `false` otherwise.
*/
fun OCCapability.checkWCFRestrictions(): Boolean = if (version.isNewerOrEqual(NextcloudVersion.nextcloud_32)) {
isWCFEnabled.isTrue
} else {
version.isNewerOrEqual(NextcloudVersion.nextcloud_30)
}
fun OCCapability.forbiddenFilenames(): List<String> = jsonToList(forbiddenFilenamesJson)
fun OCCapability.forbiddenFilenameCharacters(): List<String> = jsonToList(forbiddenFilenameCharactersJson)
@ -33,7 +53,7 @@ private fun jsonToList(json: String?): List<String> {
return try {
return gson.fromJson(json, Array<String>::class.java).toList()
} catch (e: JSONException) {
} catch (_: JSONException) {
emptyList()
}
}

View file

@ -7,10 +7,50 @@
package com.nextcloud.utils.extensions
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.jobs.BackgroundJobManagerImpl
import com.nextcloud.client.network.ConnectivityService
import com.owncloud.android.R
import com.owncloud.android.datamodel.SyncedFolder
import com.owncloud.android.datamodel.SyncedFolderDisplayItem
import com.owncloud.android.lib.common.utils.Log_OC
import java.io.File
private const val TAG = "SyncedFolderExtensions"
/**
* Determines whether a file should be skipped during auto-upload based on folder settings.
*/
@Suppress("ReturnCount")
fun SyncedFolder.shouldSkipFile(file: File, lastModified: Long, creationTime: Long?): Boolean {
if (isExcludeHidden && file.isHidden) {
Log_OC.d(TAG, "Skipping hidden: ${file.absolutePath}")
return true
}
// If "upload existing files" is DISABLED, only upload files created after enabled time
if (!isExisting) {
if (creationTime != null) {
if (creationTime < enabledTimestampMs) {
Log_OC.d(TAG, "Skipping pre-existing file (creation < enabled): ${file.absolutePath}")
return true
}
} else {
Log_OC.w(TAG, "file sent for upload - cannot determine creation time: ${file.absolutePath}")
return false
}
}
// Skip files that haven't changed since last scan (already processed)
// BUT only if this is not the first scan
if (lastScanTimestampMs != -1L && lastModified < lastScanTimestampMs) {
Log_OC.d(TAG, "Skipping unchanged file (last modified < last scan): ${file.absolutePath}")
return true
}
return false
}
fun List<SyncedFolderDisplayItem>.filterEnabledOrWithoutEnabledParent(): List<SyncedFolderDisplayItem> = filter {
it.isEnabled || !hasEnabledParent(it.localPath)
}
@ -25,3 +65,27 @@ fun List<SyncedFolder>.hasEnabledParent(localPath: String?): Boolean {
return any { it.isEnabled && File(it.localPath).exists() && File(it.localPath) == parent } ||
hasEnabledParent(parent.absolutePath)
}
@Suppress("MagicNumber", "ReturnCount")
fun SyncedFolder.calculateScanInterval(
connectivityService: ConnectivityService,
powerManagementService: PowerManagementService
): Pair<Long, Int?> {
val defaultIntervalMillis = BackgroundJobManagerImpl.DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES * 60_000L
if (!connectivityService.isConnected() || connectivityService.isInternetWalled()) {
return defaultIntervalMillis * 2 to null
}
if (isWifiOnly && !connectivityService.getConnectivity().isWifi) {
return defaultIntervalMillis * 4 to R.string.auto_upload_wifi_only_warning_info
}
val batteryLevel = powerManagementService.battery.level
return when {
batteryLevel < 20 -> defaultIntervalMillis * 8 to R.string.auto_upload_low_battery_warning_info
batteryLevel < 50 -> defaultIntervalMillis * 4 to R.string.auto_upload_low_battery_warning_info
batteryLevel < 80 -> defaultIntervalMillis * 2 to null
else -> defaultIntervalMillis to null
}
}

View file

@ -0,0 +1,77 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils.extensions
import android.provider.MediaStore
import androidx.exifinterface.media.ExifInterface
import androidx.core.net.toUri
import com.owncloud.android.MainApp
import com.owncloud.android.lib.common.utils.Log_OC
/**
* Retrieves the orientation of an image file from its EXIF metadata or, as a fallback,
* from the Android MediaStore.
*
* This function first attempts to read the orientation using [ExifInterface.TAG_ORIENTATION]
* directly from the file at the given [path]. If that fails or returns
* [ExifInterface.ORIENTATION_UNDEFINED], it then queries the MediaStore for the image's
* stored orientation in degrees (0, 90, 180, or 270), converting that to an EXIF-compatible
* orientation constant.
*
* @param path Absolute file path or content URI (as string) of the image.
* @return One of the [ExifInterface] orientation constants, e.g.
* [ExifInterface.ORIENTATION_ROTATE_90], or [ExifInterface.ORIENTATION_UNDEFINED]
* if the orientation could not be determined.
*
* @see ExifInterface
* @see MediaStore.Images.Media.ORIENTATION
*/
@Suppress("TooGenericExceptionCaught", "NestedBlockDepth", "MagicNumber")
fun getExifOrientation(path: String): Int {
val context = MainApp.getAppContext()
if (context == null || path.isBlank()) {
return ExifInterface.ORIENTATION_UNDEFINED
}
var orientation = ExifInterface.ORIENTATION_UNDEFINED
try {
val exif = ExifInterface(path)
orientation = exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_UNDEFINED
)
} catch (e: Exception) {
Log_OC.e("ThumbnailsCacheManager", "getExifOrientation exception: $e")
}
// Fallback: query MediaStore if EXIF is undefined
if (orientation == ExifInterface.ORIENTATION_UNDEFINED) {
try {
val uri = path.toUri()
val projection = arrayOf(MediaStore.Images.Media.ORIENTATION)
context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val orientationIndex = cursor.getColumnIndexOrThrow(projection[0])
val degrees = cursor.getInt(orientationIndex)
orientation = when (degrees) {
90 -> ExifInterface.ORIENTATION_ROTATE_90
180 -> ExifInterface.ORIENTATION_ROTATE_180
270 -> ExifInterface.ORIENTATION_ROTATE_270
else -> ExifInterface.ORIENTATION_NORMAL
}
}
}
} catch (e: Exception) {
Log_OC.e("ThumbnailsCacheManager", "getExifOrientation exception: $e")
}
}
return orientation
}

View file

@ -0,0 +1,28 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils.extensions
import com.nextcloud.client.database.entity.UploadEntity
import com.owncloud.android.datamodel.UploadsStorageManager
fun UploadsStorageManager.updateStatus(entity: UploadEntity?, status: UploadsStorageManager.UploadStatus) {
entity ?: return
uploadDao.insertOrReplace(entity.withStatus(status))
}
fun UploadsStorageManager.updateStatus(entity: UploadEntity?, success: Boolean) {
entity ?: return
val newStatus = if (success) {
UploadsStorageManager.UploadStatus.UPLOAD_SUCCEEDED
} else {
UploadsStorageManager.UploadStatus.UPLOAD_FAILED
}
uploadDao.insertOrReplace(entity.withStatus(newStatus))
}
private fun UploadEntity.withStatus(newStatus: UploadsStorageManager.UploadStatus) = this.copy(status = newStatus.value)

View file

@ -0,0 +1,43 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package com.nextcloud.utils.extensions
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import com.owncloud.android.lib.common.utils.Log_OC
/**
* Returns absolute filesystem path to the media item on disk. I/O errors that could occur. From Android 11 onwards,
* this column is read-only for apps that target R and higher.
*
* [More Info](https://developer.android.com/reference/android/provider/MediaStore.MediaColumns#DATA)
*/
@Suppress("ReturnCount", "TooGenericExceptionCaught")
fun Uri.toFilePath(context: Context): String? {
try {
val projection = arrayOf(MediaStore.MediaColumns.DATA)
val resolver = context.contentResolver
resolver.query(this, projection, null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) {
return null
}
val dataIdx = cursor.getColumnIndex(MediaStore.MediaColumns.DATA)
val data = if (dataIdx != -1) cursor.getString(dataIdx) else null
return data
}
return null
} catch (e: Exception) {
Log_OC.e("UriExtensions", "exception, toFilePath: $e")
return null
}
}

View file

@ -16,7 +16,8 @@ import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.behavior.HideBottomViewOnScrollBehavior
import com.nextcloud.ui.behavior.OnScrollBehavior
import com.owncloud.android.lib.common.utils.Log_OC
fun View?.setVisibleIf(condition: Boolean) {
if (this == null) return
@ -85,15 +86,20 @@ fun createRoundedOutline(context: Context, cornerRadiusValue: Float): ViewOutlin
}
}
@Suppress("UNCHECKED_CAST", "ReturnCount")
@Suppress("UNCHECKED_CAST", "ReturnCount", "TooGenericExceptionCaught")
fun <T : View?> T.slideHideBottomBehavior(visible: Boolean) {
this ?: return
val params = layoutParams as? CoordinatorLayout.LayoutParams ?: return
val behavior = params.behavior as? HideBottomViewOnScrollBehavior<T> ?: return
if (visible) {
behavior.slideUp(this)
} else {
behavior.slideDown(this)
val behavior = params.behavior as? OnScrollBehavior<T> ?: return
post {
try {
if (visible) {
behavior.slideIn(this)
} else {
behavior.slideOut(this)
}
} catch (e: Exception) {
Log_OC.e("slideHideBottomBehavior", e.message)
}
}
}

View file

@ -10,6 +10,7 @@ package com.nextcloud.utils.fileNameValidator
import android.content.Context
import android.text.TextUtils
import com.nextcloud.utils.extensions.StringConstants
import com.nextcloud.utils.extensions.checkWCFRestrictions
import com.nextcloud.utils.extensions.forbiddenFilenameBaseNames
import com.nextcloud.utils.extensions.forbiddenFilenameCharacters
import com.nextcloud.utils.extensions.forbiddenFilenameExtensions
@ -17,7 +18,6 @@ import com.nextcloud.utils.extensions.forbiddenFilenames
import com.nextcloud.utils.extensions.removeFileExtension
import com.owncloud.android.R
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.resources.status.NextcloudVersion
import com.owncloud.android.lib.resources.status.OCCapability
object FileNameValidator {
@ -49,10 +49,11 @@ object FileNameValidator {
}
}
if (!capability.version.isNewerOrEqual(NextcloudVersion.nextcloud_30)) {
if (!capability.checkWCFRestrictions()) {
return null
}
// region WCF related checks
checkInvalidCharacters(filename, capability, context)?.let { return it }
val filenameVariants = setOf(filename.lowercase(), filename.removeFileExtension().lowercase())
@ -91,6 +92,7 @@ object FileNameValidator {
}
}
}
// endregion
return null
}

View file

@ -636,7 +636,7 @@ public class MainApp extends Application implements HasAndroidInjector, NetworkC
}
if (!preferences.isAutoUploadInitialized()) {
FilesSyncHelper.startFilesSyncForAllFolders(syncedFolderProvider, backgroundJobManager,false, new String[]{});
FilesSyncHelper.startAutoUploadImmediately(syncedFolderProvider, backgroundJobManager, false);
preferences.setAutoUploadInit(true);
}
@ -706,6 +706,13 @@ public class MainApp extends Application implements HasAndroidInjector, NetworkC
createChannel(notificationManager, NotificationUtils.NOTIFICATION_CHANNEL_OFFLINE_OPERATIONS,
R.string.notification_channel_offline_operations_name_short,
R.string.notification_channel_offline_operations_description, context);
createChannel(notificationManager,
NotificationUtils.NOTIFICATION_CHANNEL_CONTENT_OBSERVER,
R.string.notification_channel_content_observer_name_short,
R.string.notification_channel_content_observer_description,
context,
NotificationManager.IMPORTANCE_LOW);
} else {
Log_OC.e(TAG, "Notification manager is null");
}

View file

@ -48,7 +48,6 @@ import android.widget.Toast;
import com.blikoon.qrcodescanner.QrCodeActivity;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.snackbar.Snackbar;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
@ -105,7 +104,6 @@ import com.owncloud.android.utils.DisplayUtils;
import com.owncloud.android.utils.ErrorMessageAdapter;
import com.owncloud.android.utils.PermissionUtil;
import com.owncloud.android.utils.WebViewUtil;
import com.owncloud.android.utils.theme.CapabilityUtils;
import com.owncloud.android.utils.theme.ViewThemeUtils;
import java.io.InputStream;
@ -1104,13 +1102,6 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
// 4. we got the authentication method required by the server
mServerInfo = (GetServerInfoOperation.ServerInfo) (result.getData().get(0));
// show outdated warning
if (CapabilityUtils.checkOutdatedWarning(getResources(),
mServerInfo.mVersion,
mServerInfo.hasExtendedSupport)) {
DisplayUtils.showServerOutdatedSnackbar(this, Snackbar.LENGTH_INDEFINITE);
}
if (webViewUser != null && !webViewUser.isEmpty() &&
webViewPassword != null && !webViewPassword.isEmpty()) {
checkBasicAuthorization(webViewUser, webViewPassword);

View file

@ -2411,6 +2411,7 @@ public class FileDataStorageManager {
contentValues.put(ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAMES, capability.getForbiddenFilenamesJson());
contentValues.put(ProviderTableMeta.CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_EXTENSIONS, capability.getForbiddenFilenameExtensionJson());
contentValues.put(ProviderTableMeta.CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_BASE_NAMES, capability.getForbiddenFilenameBaseNamesJson());
contentValues.put(ProviderTableMeta.CAPABILITIES_WINDOWS_COMPATIBLE_FILENAMES, capability.isWCFEnabled().getValue());
contentValues.put(ProviderTableMeta.CAPABILITIES_FILES_DOWNLOAD_LIMIT, capability.getFilesDownloadLimit().getValue());
contentValues.put(ProviderTableMeta.CAPABILITIES_FILES_DOWNLOAD_LIMIT_DEFAULT, capability.getFilesDownloadLimitDefault());
@ -2419,6 +2420,8 @@ public class FileDataStorageManager {
contentValues.put(ProviderTableMeta.CAPABILITIES_NOTES_FOLDER_PATH, capability.getNotesFolderPath());
contentValues.put(ProviderTableMeta.CAPABILITIES_DEFAULT_PERMISSIONS, capability.getDefaultPermissions());
contentValues.put(ProviderTableMeta.CAPABILITIES_HAS_VALID_SUBSCRIPTION, capability.getHasValidSubscription().getValue());
return contentValues;
}
@ -2595,6 +2598,7 @@ public class FileDataStorageManager {
capability.setForbiddenFilenamesJson(getString(cursor, ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAMES));
capability.setForbiddenFilenameExtensionJson(getString(cursor, ProviderTableMeta.CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_EXTENSIONS));
capability.setForbiddenFilenameBaseNamesJson(getString(cursor, ProviderTableMeta.CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_BASE_NAMES));
capability.setWCFEnabled(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_WINDOWS_COMPATIBLE_FILENAMES));
capability.setFilesDownloadLimit(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_FILES_DOWNLOAD_LIMIT));
capability.setFilesDownloadLimitDefault(getInt(cursor, ProviderTableMeta.CAPABILITIES_FILES_DOWNLOAD_LIMIT_DEFAULT));
@ -2603,6 +2607,7 @@ public class FileDataStorageManager {
capability.setNotesFolderPath(getString(cursor, ProviderTableMeta.CAPABILITIES_NOTES_FOLDER_PATH));
capability.setDefaultPermissions(getInt(cursor, ProviderTableMeta.CAPABILITIES_DEFAULT_PERMISSIONS));
capability.setHasValidSubscription(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_HAS_VALID_SUBSCRIPTION));
}
return capability;

View file

@ -14,15 +14,11 @@ import android.net.Uri;
import com.owncloud.android.db.ProviderMeta;
import com.owncloud.android.lib.common.utils.Log_OC;
import com.owncloud.android.utils.SyncedFolderUtils;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashSet;
import java.util.Set;
import java.util.zip.CRC32;
/**
@ -51,21 +47,6 @@ public class FilesystemDataProvider {
new String[]{syncedFolderId});
}
public void updateFilesystemFileAsSentForUpload(String path, String syncedFolderId) {
Log_OC.d(TAG, "updateFilesystemFileAsSentForUpload called, path: " + path + " ID: " + syncedFolderId);
ContentValues cv = new ContentValues();
cv.put(ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD, 1);
contentResolver.update(
ProviderMeta.ProviderTableMeta.CONTENT_URI_FILESYSTEM,
cv,
ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH + " = ? and " +
ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID + " = ?",
new String[]{path, syncedFolderId}
);
}
public void storeOrUpdateFileValue(String localPath, long modifiedAt, boolean isFolder, SyncedFolder syncedFolder) {
Log_OC.d(TAG, "storeOrUpdateFileValue called, localPath: " + localPath + " ID: " + syncedFolder.getId());
@ -124,56 +105,6 @@ public class FilesystemDataProvider {
}
}
public Set<String> getFilesForUpload(String localPath, String syncedFolderId) {
Log_OC.d(TAG, "getFilesForUpload called, localPath: " + localPath + " ID: " + syncedFolderId);
Set<String> localPathsToUpload = new HashSet<>();
String likeParam = localPath + "%";
Cursor cursor = contentResolver.query(
ProviderMeta.ProviderTableMeta.CONTENT_URI_FILESYSTEM,
null,
ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH + " LIKE ? and " +
ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID + " = ? and " +
ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD + " = ? and " +
ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_IS_FOLDER + " = ?",
new String[]{likeParam, syncedFolderId, "0", "0"},
null);
if (cursor != null) {
if (cursor.moveToFirst()) {
do {
String value = cursor.getString(cursor.getColumnIndexOrThrow(
ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH));
if (value == null) {
Log_OC.e(TAG, "Cannot get local path");
} else {
File file = new File(value);
if (!file.exists()) {
Log_OC.w(TAG, "Ignoring file for upload (doesn't exist): " + value);
} else if (!SyncedFolderUtils.isQualifiedFolder(file.getParent())) {
Log_OC.w(TAG, "Ignoring file for upload (unqualified folder): " + value);
} else if (!SyncedFolderUtils.isFileNameQualifiedForAutoUpload(file.getName())) {
Log_OC.w(TAG, "Ignoring file for upload (unqualified file): " + value);
} else {
Log_OC.d(TAG, "adding path to the localPathsToUpload: " + value);
localPathsToUpload.add(value);
}
}
} while (cursor.moveToNext());
} else {
Log_OC.w(TAG, "cursor cannot move");
}
cursor.close();
} else {
Log_OC.e(TAG, "getFilesForUpload called, cursor is null");
}
return localPathsToUpload;
}
private FileSystemDataSet getFilesystemDataSet(String localPathParam, SyncedFolder syncedFolder) {
Log_OC.d(TAG, "getFilesForUpload called, localPath: " + localPathParam + " ID: " + syncedFolder.getId());

View file

@ -10,7 +10,10 @@
package com.owncloud.android.datamodel;
import com.nextcloud.client.device.PowerManagementService;
import com.nextcloud.client.network.ConnectivityService;
import com.nextcloud.client.preferences.SubFolderRule;
import com.nextcloud.utils.extensions.SyncedFolderExtensionsKt;
import com.owncloud.android.files.services.NameCollisionPolicy;
import com.owncloud.android.utils.MimeTypeUtil;
@ -105,7 +108,7 @@ public class SyncedFolder implements Serializable, Cloneable {
*
* @param id id
*/
protected SyncedFolder(long id,
public SyncedFolder(long id,
String localPath,
String remotePath,
boolean wifiOnly,
@ -176,6 +179,28 @@ public class SyncedFolder implements Serializable, Cloneable {
return this.chargingOnly;
}
/**
* Indicates whether the "Also upload existing files" option is enabled for this folder.
*
* <p>
* This flag controls how files in the folder are treated when auto-upload is enabled:
* <ul>
* <li>If {@code true} (existing files are included):
* <ul>
* <li>All files in the folder, regardless of creation date, will be uploaded.</li>
* </ul>
* </li>
* <li>If {@code false} (existing files are skipped):
* <ul>
* <li>Only files created or added after the folder was enabled will be uploaded.</li>
* <li>Files that existed before enabling will be skipped, based on their creation time.</li>
* </ul>
* </li>
* </ul>
* </p>
*
* @return {@code true} if existing files should also be uploaded, {@code false} otherwise
*/
public boolean isExisting() {
return this.existing;
}
@ -276,15 +301,20 @@ public class SyncedFolder implements Serializable, Cloneable {
this.excludeHidden = excludeHidden;
}
public boolean containsTypedFile(String filePath){
public boolean containsTypedFile(File file,String filePath){
boolean isCorrectMediaType =
(getType() == MediaFolderType.IMAGE && MimeTypeUtil.isImage(new File(filePath))) ||
(getType() == MediaFolderType.VIDEO && MimeTypeUtil.isVideo(new File(filePath))) ||
getType() == MediaFolderType.CUSTOM;
(getType() == MediaFolderType.IMAGE && MimeTypeUtil.isImage(file)) ||
(getType() == MediaFolderType.VIDEO && MimeTypeUtil.isVideo(file)) ||
getType() == MediaFolderType.CUSTOM;
return filePath.contains(localPath) && isCorrectMediaType;
}
public long getLastScanTimestampMs() { return lastScanTimestampMs; }
public void setLastScanTimestampMs(long lastScanTimestampMs) { this.lastScanTimestampMs = lastScanTimestampMs; }
public long getTotalScanInterval(ConnectivityService connectivityService, PowerManagementService powerManagementService) {
final var calculatedScanInterval = SyncedFolderExtensionsKt.calculateScanInterval(this, connectivityService, powerManagementService);
return lastScanTimestampMs + calculatedScanInterval.getFirst();
}
}

Some files were not shown because too many files have changed in this diff Show more