main branch updated
This commit is contained in:
parent
3d33d3fe49
commit
9a05dc1657
353 changed files with 16802 additions and 2995 deletions
522
app/build.gradle.kts
Normal file
522
app/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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"/>
|
||||
|
|
|
|||
1210
app/schemas/com.nextcloud.client.database.NextcloudDatabase/94.json
Normal file
1210
app/schemas/com.nextcloud.client.database.NextcloudDatabase/94.json
Normal file
File diff suppressed because it is too large
Load diff
1215
app/schemas/com.nextcloud.client.database.NextcloudDatabase/95.json
Normal file
1215
app/schemas/com.nextcloud.client.database.NextcloudDatabase/95.json
Normal file
File diff suppressed because it is too large
Load diff
1293
app/schemas/com.nextcloud.client.database.NextcloudDatabase/96.json
Normal file
1293
app/schemas/com.nextcloud.client.database.NextcloudDatabase/96.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ class TransferManagerConnectionTest {
|
|||
connection.onServiceConnected(componentName, binder)
|
||||
|
||||
// WHEN
|
||||
// is runnign flag accessed
|
||||
// is running flag accessed
|
||||
val isRunning = connection.isRunning
|
||||
|
||||
// THEN
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
204
app/src/androidTest/java/com/nextcloud/utils/FileHelperTest.kt
Normal file
204
app/src/androidTest/java/com/nextcloud/utils/FileHelperTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -400,11 +400,6 @@ public abstract class AbstractIT {
|
|||
public boolean isPowerSavingEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPowerSavingExclusionAvailable() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
UserAccountManager accountManager = UserAccountManagerImpl.fromContext(targetContext);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -822,7 +822,7 @@ class FileDetailSharingFragmentIT : AbstractIT() {
|
|||
val processFragment =
|
||||
activity.supportFragmentManager.findFragmentByTag(FileDetailsSharingProcessFragment.TAG) as
|
||||
FileDetailsSharingProcessFragment
|
||||
processFragment.onBackPressed()
|
||||
processFragment.activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 } }
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ class DocumentScanActivity :
|
|||
true
|
||||
}
|
||||
android.R.id.home -> {
|
||||
onBackPressed()
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
|
|
|
|||
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ class WhatsNewActivity :
|
|||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
onFinish()
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -335,6 +335,7 @@ class SetStatusMessageBottomSheet(val user: User, val currentStatus: Status?) :
|
|||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
@VisibleForTesting
|
||||
fun setPredefinedStatus(predefinedStatus: ArrayList<PredefinedStatus>) {
|
||||
adapter.list = predefinedStatus
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
app/src/main/java/com/nextcloud/utils/FileHelper.kt
Normal file
67
app/src/main/java/com/nextcloud/utils/FileHelper.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue