Compare commits

...

16 Commits

Author SHA1 Message Date
Ricki Hirner
9754770238 [CI] Fix pre-populating configuration cache 2025-12-18 10:36:29 +01:00
Ricki Hirner
6be15fd366 [CI] Actually use configuration cache (#1891)
* Cache configurations per job

* Use separate job for Dependency submission

* Use GRADLE_OPTS to enable build and configuration cache

* Test .android

* Cache .android for configuration cache

* Disable CodeQL for PRs

* Fix AVD path
2025-12-18 10:27:01 +01:00
Ricki Hirner
0cb27f0c2f [CI] Add gradle remote build cache (bitfireAT/davx5#752)
* [CI] Add gradle remote build cache

* Update workflow

* Don't cache local build cache; pre-populate configuration cache

* Allow configuration caching of tasks

* Free some disk space before running instrumented tests; cache whole .android (not only .android/avd)

* Allow branches to update configuration cache

* Use dry run to pre-populate configuration cache

* Test runs: don't cache

* Fix remote build cache configuration for non-CI builds

* Add comment
2025-12-17 18:06:17 +01:00
Sunik Kupfer
776305bd12 Rename dismissInvalidResource to dismissCollectionError (#1887)
Rename method for clarity

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-17 13:14:06 +01:00
dependabot[bot]
01f54df3c0 [CI] Bump actions/cache from 4 to 5 in the ci-actions group (#1886)
Bumps the ci-actions group with 1 update: [actions/cache](https://github.com/actions/cache).


Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: ci-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 13:38:54 +01:00
Ricki Hirner
4944ce59b1 Version bump to 4.5.8-alpha.1 2025-12-12 15:32:30 +01:00
Sunik Kupfer
0e455d8371 LocalTaskList: Stop subclassing DmfsTaskList (#1882)
* LocalTaskList: Stop subclassing DmfsTaskList

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

# Conflicts:
#	gradle/libs.versions.toml

* Dont touch agp

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update synctools

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-11 16:22:02 +01:00
Ricki Hirner
a938b511cd Nextcloud Login Flow: handle non-success status codes (#1878)
* Nextcloud Login Flow: handle non-success status codes

* Update error message to use class name when localized message is null

* Update dav4jvm to get HTTP reason phrases in HttpException
2025-12-11 15:30:32 +01:00
Ricki Hirner
d32b86789b Update AGP 2025-12-11 15:13:15 +01:00
Sunik Kupfer
84d58f73db Assume initial state for test updatesOwnerAccount (#1874)
* Assume initial state

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Enhance comment

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Don't pass provider

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-11 14:16:03 +01:00
dependabot[bot]
cc43998148 Bump the app-dependencies group with 4 updates (#1867)
* Bump the app-dependencies group with 4 updates

Bumps the app-dependencies group with 4 updates: androidx.activity:activity-compose, androidx.compose:compose-bom, [io.mockk:mockk](https://github.com/mockk/mockk) and [io.mockk:mockk-android](https://github.com/mockk/mockk).


Updates `androidx.activity:activity-compose` from 1.12.0 to 1.12.1

Updates `androidx.compose:compose-bom` from 2025.11.01 to 2025.12.00

Updates `io.mockk:mockk` from 1.14.5 to 1.14.7
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.7)

Updates `io.mockk:mockk-android` from 1.14.5 to 1.14.7
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.7)

Updates `io.mockk:mockk-android` from 1.14.5 to 1.14.7
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.7)

---
updated-dependencies:
- dependency-name: androidx.activity:activity-compose
  dependency-version: 1.12.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.12.00
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk
  dependency-version: 1.14.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk-android
  dependency-version: 1.14.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk-android
  dependency-version: 1.14.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* Override SDK level

* Suppress lint warnings for LaunchedEffect / Context.getString

* Suppress lint warnings for other Context.getStrings (or replace by stringResource if possible)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-12-11 11:27:37 +01:00
Sunik Kupfer
b354bfebc2 LocalTask: Don't subclass DmfsTask (#1862)
* LocalTask: Don't subclass DmfsTask

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Adapt usages

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update synctools

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-10 14:37:23 +01:00
Sunik Kupfer
29240ea16f Skip flaky test when not moving into anticipated forever pending sync state (#1872)
* Assume we moved into forever pending sync state

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update comment

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-10 10:25:58 +01:00
Ricki Hirner
e7b88f9aa8 Version bump to 4.5.7.1 2025-12-09 11:49:50 +01:00
Arnau Mora
4d71517cde Delegate fileName into DmfsTask.syncId (#1871)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-12-09 10:40:22 +01:00
Ricki Hirner
0d4f154baf Use Ktor for Nextcloud Login Flow (#1817)
* [WIP] Use Ktor for Nextcloud login flow

- Replace OkHttp with Ktor for HTTP requests
- Update URL handling to use Ktor's `Url` class
- Adjust `postForJson` method to use Ktor's HTTP client
- Refactor URL building logic for login flow initiation

* Use Ktor for Nextcloud login flow

- Migrate to Ktor's ContentNegotiation plugin for JSON handling
- Update dependencies and configuration for Ktor serialization
- Refactor `NextcloudLoginFlow` to use Ktor's JSON serialization

* Add tests

* Allow unit tests that mock/use HttpClient without Conscrypt

* KDoc

* Minor fixes

* Use toUrlOrNull from dav4jvm

* Don't change strings in this PR

* Update dav4jvm and synctools
2025-12-08 15:40:39 +01:00
33 changed files with 445 additions and 267 deletions

View File

@@ -3,11 +3,12 @@ name: "CodeQL"
on:
push:
branches: [ main-ose ]
pull_request:
# pull_request:
# The branches below must be a subset of the branches above
branches: [ main-ose ]
# branches: [ main-ose ]
schedule:
- cron: '22 10 * * 1'
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true
@@ -50,7 +51,7 @@ jobs:
# uses: github/codeql-action/autobuild@v2
- name: Build
run: ./gradlew --build-cache --configuration-cache --no-daemon app:assembleOseDebug
run: ./gradlew --no-daemon app:compileOseDebugSource
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4

View File

@@ -0,0 +1,24 @@
name: Dependency Submission
on:
push:
branches: [ 'main-ose' ]
permissions:
contents: write
jobs:
dependency-submission:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- name: Generate and submit dependency graph
uses: gradle/actions/dependency-submission@v5
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
dependency-graph-exclude-configurations: '.*[Tt]est.* .*[cC]heck.*'

View File

@@ -9,10 +9,17 @@ concurrency:
group: test-dev-${{ github.ref }}
cancel-in-progress: true
# We provide a remote gradle build cache. Take the settings from the secrets and enable
# configuration and build cache for all gradle jobs.
env:
GRADLE_BUILDCACHE_URL: ${{ secrets.gradle_buildcache_url }}
GRADLE_BUILDCACHE_USERNAME: ${{ secrets.gradle_buildcache_username }}
GRADLE_BUILDCACHE_PASSWORD: ${{ secrets.gradle_buildcache_password }}
GRADLE_OPTS: -Dorg.gradle.caching=true -Dorg.gradle.configuration-cache=true
jobs:
compile:
name: Compile for build cache
if: github.ref == 'refs/heads/main-ose'
name: Compile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -22,16 +29,30 @@ jobs:
java-version: 21
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
- uses: gradle/actions/setup-gradle@v5 # creates build cache
- uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
dependency-graph: generate-and-submit # submit Github Dependency Graph info
cache-read-only: false # allow branches to update their configuration cache
gradle-home-cache-excludes: caches/build-cache-1 # don't cache local build cache because we use a remote cache
- run: ./gradlew --build-cache --configuration-cache app:compileOseDebugSource
- name: Cache Android environment
uses: actions/cache@v5
with:
path: ~/.config/.android # needs to be cached so that configuration cache can work
key: android-${{ hashFiles('app/build.gradle.kts') }}
test:
- name: Compile
run: ./gradlew app:compileOseDebugSource
# Cache configurations for the other jobs
- name: Populate configuration cache
run: |
./gradlew --dry-run app:lintOseDebug
./gradlew --dry-run app:testOseDebugUnitTest
./gradlew --dry-run app:virtualOseDebugAndroidTest
unit_tests:
needs: compile
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
name: Lint and unit tests
runs-on: ubuntu-latest
steps:
@@ -45,14 +66,20 @@ jobs:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true
- name: Run lint
run: ./gradlew --build-cache --configuration-cache app:lintOseDebug
- name: Run unit tests
run: ./gradlew --build-cache --configuration-cache app:testOseDebugUnitTest
- name: Restore Android environment
uses: actions/cache/restore@v5
with:
path: ~/.config/.android
key: android-${{ hashFiles('app/build.gradle.kts') }}
test_on_emulator:
- name: Lint checks
run: ./gradlew app:lintOseDebug
- name: Unit tests
run: ./gradlew app:testOseDebugUnitTest
instrumented_tests:
needs: compile
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
name: Instrumented tests
runs-on: ubuntu-latest
steps:
@@ -66,25 +93,41 @@ jobs:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true
- name: Restore Android environment
uses: actions/cache/restore@v5
with:
path: ~/.config/.android
key: android-${{ hashFiles('app/build.gradle.kts') }}
# gradle and Android SDK often take more space than what is available on the default runner.
# We try to free a few GB here to make gradle-managed devices more reliable.
- name: Free some disk space
uses: jlumbroso/free-disk-space@main
with:
android: false # we need the Android SDK
large-packages: false # apt takes too long
swap-storage: false # gradle needs much memory
- name: Restore AVD
id: restore-avd
uses: actions/cache/restore@v5
with:
path: ~/.config/.android/avd # where AVD is stored
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
# Enable virtualization for Android emulator
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Restore cached AVD
id: restore-avd
uses: actions/cache/restore@v4
with:
path: ~/.config/.android/avd
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
- name: Run device tests
run: ./gradlew --build-cache --configuration-cache app:virtualCheck
- name: Instrumented tests
run: ./gradlew app:virtualOseDebugAndroidTest
- name: Cache AVD
uses: actions/cache/save@v4
uses: actions/cache/save@v5
if: steps.restore-avd.outputs.cache-hit != 'true'
with:
path: ~/.config/.android/avd
path: ~/.config/.android/avd # where AVD is stored
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there

View File

@@ -7,8 +7,8 @@ plugins {
alias(libs.plugins.compose.compiler)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.mikepenz.aboutLibraries.android)
}
@@ -19,8 +19,8 @@ android {
defaultConfig {
applicationId = "at.bitfire.davdroid"
versionCode = 405070001
versionName = "4.5.7"
versionCode = 405080000
versionName = "4.5.8-alpha.1"
base.archivesName = "davx5-ose-$versionName"
@@ -190,8 +190,10 @@ dependencies {
implementation(libs.conscrypt)
implementation(libs.dnsjava)
implementation(libs.guava)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.mikepenz.aboutLibraries.m3)
implementation(libs.okhttp.base)
implementation(libs.okhttp.brotli)

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="internalOnly">
<!-- account management permissions not required for own accounts since API level 22 -->
@@ -7,4 +8,9 @@
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
<!--
Since Mockk 1.14.7 it's required to use minSdk 26. We use 24, so override for tests.
-->
<uses-sdk tools:overrideLibrary="io.mockk.android,io.mockk.proxy.android" />
</manifest>

View File

@@ -20,6 +20,7 @@ import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -63,8 +64,8 @@ class LocalCalendarStoreTest {
@Test
fun testUpdateAccount_updatesOwnerAccount() {
// Verify initial state
verifyOwnerAccountIs(provider, "InitialAccountName")
// Verify initial state (assume to skip and prevent flaky test failures)
Assume.assumeTrue("InitialAccountName" == getOwnerAccount())
// Rename account
val oldAccount = account
@@ -74,7 +75,7 @@ class LocalCalendarStoreTest {
localCalendarStore.updateAccount(oldAccount, account, provider)
// Verify [Calendar.OWNER_ACCOUNT] of local calendar was updated
verifyOwnerAccountIs(provider, "ChangedAccountName")
assertEquals("ChangedAccountName", getOwnerAccount())
}
@@ -95,7 +96,7 @@ class LocalCalendarStoreTest {
)
)!!.asSyncAdapter(account)
private fun verifyOwnerAccountIs(provider: ContentProviderClient, expectedOwnerAccount: String) {
private fun getOwnerAccount(): String {
provider.query(
calendarUri,
arrayOf(Calendars.OWNER_ACCOUNT),
@@ -104,8 +105,7 @@ class LocalCalendarStoreTest {
null
)!!.use { cursor ->
cursor.moveToNext()
val ownerAccount = cursor.getString(0)
assertEquals(expectedOwnerAccount, ownerAccount)
return cursor.getString(0)
}
}

View File

@@ -22,7 +22,7 @@ import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
@@ -83,8 +83,8 @@ class AccountSettingsMigration21Test {
inPendingState.first { it }
}
// Assert again that we are now in the forever pending state
assertTrue(ContentResolver.isSyncPending(account, authority))
// Assume that we are now in the forever pending state (Skips test otherwise)
Assume.assumeTrue(ContentResolver.isSyncPending(account, authority))
// Run the migration which should cancel the forever pending sync for all accounts
migration.migrate(account)

View File

@@ -29,8 +29,7 @@ class ConscryptIntegration {
if (initialized)
return
val alreadyInstalled = conscryptInstalled()
if (!alreadyInstalled) {
if (Conscrypt.isAvailable() && !conscryptInstalled()) {
// install Conscrypt as most preferred provider
Security.insertProviderAt(Conscrypt.newProvider(), 1)

View File

@@ -23,6 +23,8 @@ import com.google.errorprone.annotations.MustBeClosed
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
@@ -391,6 +393,11 @@ class HttpClientBuilder @Inject constructor(
val client = HttpClient(OkHttp) {
// Ktor-level configuration here
// automatically convert JSON from/into data classes (if requested in respective code)
install(ContentNegotiation) {
json()
}
engine {
// okhttp engine configuration here

View File

@@ -4,27 +4,28 @@
package at.bitfire.davdroid.network
import at.bitfire.dav4jvm.okhttp.exception.DavException
import at.bitfire.dav4jvm.okhttp.exception.HttpException
import androidx.annotation.VisibleForTesting
import at.bitfire.dav4jvm.ktor.exception.HttpException
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.ui.setup.LoginInfo
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
import at.bitfire.davdroid.util.withTrailingSlash
import at.bitfire.vcard4android.GroupMethod
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.net.HttpURLConnection
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.URLBuilder
import io.ktor.http.Url
import io.ktor.http.appendPathSegments
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.http.path
import kotlinx.serialization.Serializable
import java.net.URI
import javax.inject.Inject
import javax.inject.Provider
/**
* Implements Nextcloud Login Flow v2.
@@ -32,9 +33,134 @@ import javax.inject.Inject
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
*/
class NextcloudLoginFlow @Inject constructor(
httpClientBuilder: HttpClientBuilder
private val httpClientBuilder: Provider<HttpClientBuilder>
) {
// Login flow state
var pollUrl: Url? = null
var token: String? = null
/**
* Starts Nextcloud Login Flow v2.
*
* @param baseUrl Nextcloud login flow or base URL
*
* @return URL that should be opened in the browser (login screen)
*
* @throws HttpException on non-successful HTTP status
*/
suspend fun start(baseUrl: Url): Url {
// reset fields in case something goes wrong
pollUrl = null
token = null
// POST to login flow URL in order to receive endpoint data
createClient().use { client ->
val result = client.post(loginFlowUrl(baseUrl))
if (!result.status.isSuccess())
throw HttpException.fromResponse(result)
// save endpoint data for polling
val endpointData: EndpointData = result.body()
pollUrl = Url(endpointData.poll.endpoint)
token = endpointData.poll.token
return Url(endpointData.login)
}
}
@VisibleForTesting
internal fun loginFlowUrl(baseUrl: Url): Url {
return when {
// already a Login Flow v2 URL
baseUrl.encodedPath.endsWith(FLOW_V2_PATH) ->
baseUrl
// Login Flow v1 URL, rewrite to v2
baseUrl.encodedPath.endsWith(FLOW_V1_PATH) -> {
// drop "[index.php/login]/flow" from the end and append "/v2"
val v2Segments = baseUrl.segments.dropLast(1) + "v2"
val builder = URLBuilder(baseUrl)
builder.path(*v2Segments.toTypedArray())
builder.build()
}
// other URL, make it a Login Flow v2 URL
else ->
URLBuilder(baseUrl)
.appendPathSegments(FLOW_V2_PATH.split('/'))
.build()
}
}
/**
* Retrieves login info from the polling endpoint using [pollUrl]/[token].
*
* @throws HttpException on non-successful HTTP status
*/
suspend fun fetchLoginInfo(): LoginInfo {
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
val token = token ?: throw IllegalArgumentException("Missing token")
// send HTTP request to request server, login name and app password
createClient().use { client ->
val result = client.post(pollUrl) {
contentType(ContentType.Application.FormUrlEncoded)
setBody("token=$token")
}
if (!result.status.isSuccess())
throw HttpException.fromResponse(result)
// make sure server URL ends with a slash so that DAV_PATH can be appended
val loginData: LoginData = result.body()
val serverUrl = loginData.server.withTrailingSlash()
return LoginInfo(
baseUri = URI(serverUrl).resolve(DAV_PATH),
credentials = Credentials(
username = loginData.loginName,
password = loginData.appPassword.toSensitiveString()
),
suggestedGroupMethod = GroupMethod.CATEGORIES
)
}
}
/**
* Creates a Ktor HTTP client that follows redirects.
*/
private fun createClient(): HttpClient =
httpClientBuilder.get()
.followRedirects(true)
.buildKtor()
/**
* Represents the JSON response that is returned on the first call to `/login/v2`.
*/
@Serializable
private data class EndpointData(
val poll: Poll,
val login: String
) {
@Serializable
data class Poll(
val token: String,
val endpoint: String
)
}
/**
* Represents the JSON response that is returned by the polling endpoint.
*/
@Serializable
private data class LoginData(
val server: String,
val loginName: String,
val appPassword: String
)
companion object {
const val FLOW_V1_PATH = "index.php/login/flow"
const val FLOW_V2_PATH = "index.php/login/v2"
@@ -43,92 +169,4 @@ class NextcloudLoginFlow @Inject constructor(
const val DAV_PATH = "remote.php/dav"
}
val httpClient = httpClientBuilder.build()
// Login flow state
var loginUrl: HttpUrl? = null
var pollUrl: HttpUrl? = null
var token: String? = null
suspend fun initiate(baseUrl: HttpUrl): HttpUrl? {
loginUrl = null
pollUrl = null
token = null
val json = postForJson(initiateUrl(baseUrl), "".toRequestBody())
loginUrl = json.getString("login").toHttpUrlOrNull()
json.getJSONObject("poll").let { poll ->
pollUrl = poll.getString("endpoint").toHttpUrl()
token = poll.getString("token")
}
return loginUrl
}
fun initiateUrl(baseUrl: HttpUrl): HttpUrl {
val path = baseUrl.encodedPath
if (path.endsWith(FLOW_V2_PATH))
// already a Login Flow v2 URL
return baseUrl
if (path.endsWith(FLOW_V1_PATH))
// Login Flow v1 URL, rewrite to v2
return baseUrl.newBuilder()
.encodedPath(path.replace(FLOW_V1_PATH, FLOW_V2_PATH))
.build()
// other URL, make it a Login Flow v2 URL
return baseUrl.newBuilder()
.addPathSegments(FLOW_V2_PATH)
.build()
}
suspend fun fetchLoginInfo(): LoginInfo {
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
val token = token ?: throw IllegalArgumentException("Missing token")
// send HTTP request to request server, login name and app password
val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
// make sure server URL ends with a slash so that DAV_PATH can be appended
val serverUrl = json.getString("server").withTrailingSlash()
return LoginInfo(
baseUri = URI(serverUrl).resolve(DAV_PATH),
credentials = Credentials(
username = json.getString("loginName"),
password = json.getString("appPassword").toSensitiveString()
),
suggestedGroupMethod = GroupMethod.CATEGORIES
)
}
private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) {
val postRq = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = runInterruptible {
httpClient.newCall(postRq).execute()
}
if (response.code != HttpURLConnection.HTTP_OK)
throw HttpException(response)
response.body.use { body ->
val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type")
if (mimeType.type != "application" || mimeType.subtype != "json")
throw DavException("Invalid Login Flow response (not JSON)")
// decode JSON
return@withContext JSONObject(body.string())
}
}
}

View File

@@ -10,35 +10,49 @@ import android.content.Context
import android.net.Uri
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.DmfsTaskFactory
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.Task
import at.bitfire.ical4android.TaskProvider
import com.google.common.base.MoreObjects
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.Optional
import java.util.logging.Logger
/**
* Represents a Dmfs Task (OpenTasks and Tasks.org) entry
*/
class LocalTask: DmfsTask, LocalResource {
class LocalTask(
val dmfsTask: DmfsTask
): LocalResource {
override var fileName: String? = null
val logger: Logger = Logger.getLogger(javaClass.name)
// LocalResource implementation
override val id: Long?
get() = dmfsTask.id
override var fileName: String?
get() = dmfsTask.syncId
set(value) { dmfsTask.syncId = value }
override var eTag: String?
get() = dmfsTask.eTag
set(value) { dmfsTask.eTag = value }
/**
* Note: Schedule-Tag for tasks is not supported
*/
override var scheduleTag: String? = null
override val flags: Int
get() = dmfsTask.flags
constructor(taskList: DmfsTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
: super(taskList, task, fileName, eTag, flags)
fun add() = dmfsTask.add()
private constructor(taskList: DmfsTaskList<*>, values: ContentValues)
: super(taskList, values)
fun update(data: Task) = dmfsTask.update(data)
/* custom queries */
fun delete() = dmfsTask.delete()
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
@@ -47,46 +61,33 @@ class LocalTask: DmfsTask, LocalResource {
val values = ContentValues(4)
if (fileName.isPresent)
values.put(Tasks._SYNC_ID, fileName.get())
values.put(COLUMN_ETAG, eTag)
values.put(Tasks.SYNC_VERSION, task!!.sequence)
values.put(DmfsTask.COLUMN_ETAG, eTag)
values.put(Tasks.SYNC_VERSION, dmfsTask.task!!.sequence)
values.put(Tasks._DIRTY, 0)
taskList.provider.update(taskSyncURI(), values, null, null)
dmfsTask.update(values)
if (fileName.isPresent)
this.fileName = fileName.get()
this.eTag = eTag
}
fun update(data: Task, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
if (scheduleTag != null)
logger.fine("Schedule-Tag for tasks not supported, won't save")
this.fileName = fileName
this.eTag = eTag
this.flags = flags
// processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag
update(data)
}
override fun updateFlags(flags: Int) {
if (id != null) {
val values = contentValuesOf(COLUMN_FLAGS to flags)
taskList.provider.update(taskSyncURI(), values, null, null)
val values = contentValuesOf(DmfsTask.COLUMN_FLAGS to flags)
dmfsTask.update(values)
}
this.flags = flags
dmfsTask.flags = flags
}
override fun updateSequence(sequence: Int) = throw NotImplementedError()
override fun updateUid(uid: String) {
val values = contentValuesOf(Tasks._UID to uid)
taskList.provider.update(taskSyncURI(), values, null, null)
dmfsTask.update(values)
}
override fun deleteLocal() {
delete()
dmfsTask.delete()
}
override fun resetDeleted() {
@@ -110,9 +111,9 @@ class LocalTask: DmfsTask, LocalResource {
.toString()
override fun getViewUri(context: Context): Uri? = id?.let { id ->
when (taskList.providerName) {
when (dmfsTask.taskList.providerName) {
TaskProvider.ProviderName.OpenTasks -> {
val contentUri = Tasks.getContentUri(taskList.providerName.authority)
val contentUri = Tasks.getContentUri(dmfsTask.taskList.providerName.authority)
ContentUris.withAppendedId(contentUri, id)
}
// Tasks.org can't handle view content URIs (missing intent-filter)
@@ -121,10 +122,4 @@ class LocalTask: DmfsTask, LocalResource {
}
}
object Factory: DmfsTaskFactory<LocalTask> {
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =
LocalTask(taskList, values)
}
}

View File

@@ -4,13 +4,9 @@
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.DmfsTaskListFactory
import at.bitfire.ical4android.TaskProvider
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.logging.Level
@@ -21,40 +17,38 @@ import java.util.logging.Logger
*
* [TaskLists._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
*/
class LocalTaskList private constructor(
account: Account,
provider: ContentProviderClient,
providerName: TaskProvider.ProviderName,
id: Long
): DmfsTaskList<LocalTask>(account, provider, providerName, LocalTask.Factory, id), LocalCollection<LocalTask> {
class LocalTaskList (
val dmfsTaskList: DmfsTaskList
): LocalCollection<LocalTask> {
private val logger = Logger.getGlobal()
override val readOnly
get() = accessLevel?.let {
get() = dmfsTaskList.accessLevel?.let {
it != TaskListColumns.ACCESS_LEVEL_UNDEFINED && it <= TaskListColumns.ACCESS_LEVEL_READ
} ?: false
override val dbCollectionId: Long?
get() = syncId?.toLongOrNull()
get() = dmfsTaskList.syncId?.toLongOrNull()
override val tag: String
get() = "tasks-${account.name}-$id"
get() = "tasks-${dmfsTaskList.account.name}-${dmfsTaskList.id}"
override val title: String
get() = name ?: id.toString()
get() = dmfsTaskList.name ?: dmfsTaskList.id.toString()
override var lastSyncState: SyncState?
get() = readSyncState()?.let { SyncState.fromString(it) }
get() = dmfsTaskList.readSyncState()?.let { SyncState.fromString(it) }
set(state) {
writeSyncState(state.toString())
dmfsTaskList.writeSyncState(state.toString())
}
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
override fun findDeleted() = dmfsTaskList.queryTasks(Tasks._DELETED, null)
.map { LocalTask(it) }
override fun findDirty(): List<LocalTask> {
val tasks = queryTasks(Tasks._DIRTY, null)
for (localTask in tasks) {
val dmfsTasks = dmfsTaskList.queryTasks(Tasks._DIRTY, null)
for (localTask in dmfsTasks) {
try {
val task = requireNotNull(localTask.task)
val sequence = task.sequence
@@ -66,41 +60,32 @@ class LocalTaskList private constructor(
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
}
}
return tasks
return dmfsTasks.map { LocalTask(it) }
}
override fun findByName(name: String) =
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
dmfsTaskList.queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name))
.firstOrNull()?.let {
LocalTask(it)
}
override fun markNotDirty(flags: Int): Int {
val values = contentValuesOf(DmfsTask.COLUMN_FLAGS to flags)
return provider.update(tasksSyncUri(), values,
return dmfsTaskList.provider.update(dmfsTaskList.tasksSyncUri(), values,
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
arrayOf(id.toString()))
arrayOf(dmfsTaskList.id.toString()))
}
override fun removeNotDirtyMarked(flags: Int) =
provider.delete(tasksSyncUri(),
dmfsTaskList.provider.delete(dmfsTaskList.tasksSyncUri(),
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${DmfsTask.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()))
arrayOf(dmfsTaskList.id.toString(), flags.toString()))
override fun forgetETags() {
val values = contentValuesOf(DmfsTask.COLUMN_ETAG to null)
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(id.toString()))
}
object Factory: DmfsTaskListFactory<LocalTaskList> {
override fun newInstance(
account: Account,
provider: ContentProviderClient,
providerName: TaskProvider.ProviderName,
id: Long
) = LocalTaskList(account, provider, providerName, id)
dmfsTaskList.provider.update(dmfsTaskList.tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(dmfsTaskList.id.toString()))
}
}

View File

@@ -63,7 +63,7 @@ class LocalTaskListStore @AssistedInject constructor(
logger.log(Level.INFO, "Adding local task list", fromCollection)
val uri = create(account, client, providerName, fromCollection)
return DmfsTaskList.findByID(account, client, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
return LocalTaskList(DmfsTaskList.findByID(account, client, providerName, ContentUris.parseId(uri)))
}
private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): Uri {
@@ -81,7 +81,7 @@ class LocalTaskListStore @AssistedInject constructor(
put(TaskLists.SYNC_ENABLED, 1)
put(TaskLists.VISIBLE, 1)
}
return DmfsTaskList.Companion.create(account, provider, providerName, values)
return DmfsTaskList.create(account, provider, providerName, values)
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
@@ -102,12 +102,13 @@ class LocalTaskListStore @AssistedInject constructor(
}
override fun getAll(account: Account, client: ContentProviderClient) =
DmfsTaskList.find(account, LocalTaskList.Factory, client, providerName, null, null)
DmfsTaskList.find(account, client, providerName, null, null)
.map { LocalTaskList(it) }
override fun update(client: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection)
val accountSettings = accountSettingsFactory.create(localCollection.account)
localCollection.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
val accountSettings = accountSettingsFactory.create(localCollection.dmfsTaskList.account)
localCollection.dmfsTaskList.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
}
override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) {
@@ -119,7 +120,7 @@ class LocalTaskListStore @AssistedInject constructor(
}
override fun delete(localCollection: LocalTaskList) {
localCollection.delete()
localCollection.dmfsTaskList.delete()
}
}

View File

@@ -12,7 +12,6 @@ import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Reminders
import androidx.core.content.ContextCompat
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.TaskProvider
import at.techbee.jtx.JtxContract.asSyncAdapter

View File

@@ -98,9 +98,9 @@ class AccountSettingsMigration20 @Inject constructor(
for (taskList in taskListStore.getAll(account, provider)) {
when (taskList) {
is LocalTaskList -> { // tasks.org, OpenTasks
val url = taskList.syncId ?: continue
val url = taskList.dmfsTaskList.syncId ?: continue
collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection ->
taskList.update(contentValuesOf(
taskList.dmfsTaskList.update(contentValuesOf(
TaskLists._SYNC_ID to collection.id.toString()
))
}

View File

@@ -136,7 +136,7 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
suspend fun performSync() = withContext(syncDispatcher) {
// dismiss previous error notifications
syncNotificationManager.dismissInvalidResource(localCollectionTag = localCollection.tag)
syncNotificationManager.dismissCollectionError(localCollectionTag = localCollection.tag)
try {
logger.info("Preparing synchronization")

View File

@@ -159,7 +159,7 @@ class SyncNotificationManager @AssistedInject constructor(
/**
* Sends a notification to inform the user that a push notification has been received, the
* sync has been scheduled, but it still has not run.
* Use [dismissInvalidResource] to dismiss the notification.
* Use [dismissCollectionError] to dismiss the notification.
*
* @param dataType The type of data which was synced.
* @param notificationTag The tag to use for the notification.
@@ -200,7 +200,7 @@ class SyncNotificationManager @AssistedInject constructor(
*
* @param localCollectionTag The tag of the local collection which is used as notification tag also.
*/
fun dismissInvalidResource(localCollectionTag: String) =
fun dismissCollectionError(localCollectionTag: String) =
dismissNotification(localCollectionTag)

View File

@@ -68,7 +68,7 @@ class TaskSyncer @AssistedInject constructor(
collectionRepository.getSyncTaskLists(serviceId)
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalTaskList, remoteCollection: Collection) {
logger.info("Synchronizing task list ${localCollection.id} with database collection ID: ${localCollection.dbCollectionId}")
logger.info("Synchronizing task list ${localCollection.dmfsTaskList.id} with database collection ID: ${localCollection.dbCollectionId}")
val syncManager = tasksSyncManagerFactory.tasksSyncManager(
account,

View File

@@ -25,6 +25,7 @@ import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.resource.SyncState
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.Task
import at.bitfire.synctools.exception.InvalidICalendarException
import dagger.assisted.Assisted
@@ -103,7 +104,7 @@ class TasksSyncManager @AssistedInject constructor(
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
override fun generateUpload(resource: LocalTask): GeneratedResource {
val task = requireNotNull(resource.task)
val task = requireNotNull(resource.dmfsTask.task)
logger.log(Level.FINE, "Preparing upload of task ${resource.id}", task)
// get/create UID
@@ -163,7 +164,7 @@ class TasksSyncManager @AssistedInject constructor(
}
override fun postProcess() {
val touched = localCollection.touchRelations()
val touched = localCollection.dmfsTaskList.touchRelations()
logger.info("Touched $touched relations")
}
@@ -191,7 +192,7 @@ class TasksSyncManager @AssistedInject constructor(
local.update(newData)
} else {
logger.log(Level.INFO, "Adding $fileName to local task list", newData)
val newLocal = LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)
val newLocal = LocalTask(DmfsTask(localCollection.dmfsTaskList, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT))
SyncException.wrapWithLocalResource(newLocal) {
newLocal.add()
}

View File

@@ -4,6 +4,7 @@
package at.bitfire.davdroid.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
@@ -118,6 +119,7 @@ fun TasksCard(
context.startActivity(intent)
else
coroutineScope.launch {
@SuppressLint("LocalContextGetResourceValueCall")
snackbarHostState.showSnackbar(
message = context.getString(R.string.intro_tasks_no_app_store),
duration = SnackbarDuration.Long

View File

@@ -4,6 +4,7 @@
import android.Manifest
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.Intent
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
@@ -420,6 +421,7 @@ fun AccountScreen(
}
idxWebcal -> {
@SuppressLint("LocalContextGetResourceValueCall")
LaunchedEffect(showNoWebcalApp) {
if (showNoWebcalApp) {
if (snackbarHostState.showSnackbar(

View File

@@ -5,6 +5,7 @@
package at.bitfire.davdroid.ui.account
import android.accounts.Account
import android.annotation.SuppressLint
import android.app.Activity
import android.security.KeyChain
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -609,6 +610,7 @@ fun AuthenticationSettings(
onUpdateCredentials(credentials.copy(certificateAlias = newAlias))
else
scope.launch {
@SuppressLint("LocalContextGetResourceValueCall")
if (snackbarHostState.showSnackbar(
context.getString(R.string.settings_certificate_alias_empty),
actionLabel = context.getString(R.string.settings_certificate_install)

View File

@@ -4,6 +4,7 @@
package at.bitfire.davdroid.ui.account
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -189,6 +190,7 @@ fun CollectionsList_Item_Standard(
modifier = Modifier
.padding(start = 4.dp, top = 4.dp, bottom = 4.dp)
.semantics {
@SuppressLint("LocalContextGetResourceValueCall")
contentDescription = context.getString(R.string.account_synchronize_this_collection)
}
)

View File

@@ -5,6 +5,7 @@
package at.bitfire.davdroid.ui.account
import android.accounts.Account
import android.annotation.SuppressLint
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
@@ -210,6 +211,7 @@ fun CreateCalendarScreen(
.fillMaxHeight()
.aspectRatio(1f)
.semantics {
@SuppressLint("LocalContextGetResourceValueCall")
contentDescription = context.getString(R.string.create_collection_color)
}
) { /* no content */ }

View File

@@ -5,6 +5,7 @@
package at.bitfire.davdroid.ui.composable
import android.accounts.Account
import android.annotation.SuppressLint
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Error
import androidx.compose.material3.AlertDialog
@@ -48,6 +49,7 @@ fun ExceptionInfoDialog(
Icon(Icons.Rounded.Error, null)
},
text = {
@SuppressLint("LocalContextGetResourceValueCall")
val message = if (exception is HttpException) {
when (exception.statusCode) {
403 -> context.getString(R.string.debug_info_http_403_description)

View File

@@ -5,6 +5,7 @@
package at.bitfire.davdroid.ui.setup
import android.accounts.Account
import android.annotation.SuppressLint
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -57,6 +58,7 @@ fun AccountDetailsPage(
uiState.createdAccount?.let(onAccountCreated)
val context = LocalContext.current
@SuppressLint("LocalContextGetResourceValueCall")
LaunchedEffect(uiState.couldNotCreateAccount) {
if (uiState.couldNotCreateAccount) {
snackbarHostState.showSnackbar(context.getString(R.string.login_account_not_added))

View File

@@ -213,7 +213,7 @@ fun GoogleLoginScreen(
val privacyPolicyNote = HtmlCompat.fromHtml(
stringResource(
R.string.login_google_client_privacy_policy,
context.getString(R.string.app_name),
stringResource(R.string.app_name),
privacyPolicyUrl.toString()
), 0
).toAnnotatedString()
@@ -223,7 +223,11 @@ fun GoogleLoginScreen(
)
val limitedUseNote = HtmlCompat.fromHtml(
stringResource(R.string.login_google_client_limited_use, context.getString(R.string.app_name), GOOGLE_POLICY_URL), 0
stringResource(
R.string.login_google_client_limited_use,
stringResource(R.string.app_name),
GOOGLE_POLICY_URL
), 0
).toAnnotatedString()
Text(
text = limitedUseNote,

View File

@@ -4,6 +4,7 @@
package at.bitfire.davdroid.ui.setup
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.provider.Browser
@@ -104,6 +105,7 @@ object NextcloudLogin : LoginType {
checkResultCallback.launch(browser)
} else
this@LaunchedEffect.launch {
@SuppressLint("LocalContextGetResourceValueCall")
snackbarHostState.showSnackbar(context.getString(R.string.install_browser))
}
}

View File

@@ -10,15 +10,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.dav4jvm.ktor.toUrlOrNull
import at.bitfire.davdroid.network.NextcloudLoginFlow
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.http.Url
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.util.logging.Level
import java.util.logging.Logger
@@ -46,24 +46,19 @@ class NextcloudLoginModel @AssistedInject constructor(
val error: String? = null,
/** URL to open in the browser (set during Login Flow) */
val loginUrl: HttpUrl? = null,
val loginUrl: Url? = null,
/** login info (set after successful login) */
val result: LoginInfo? = null
) {
val baseUrlWithPrefix =
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
baseUrl
else
"https://$baseUrl"
val baseKtorUrl = baseUrlWithPrefix.toUrlOrNull()
val baseHttpUrl: HttpUrl? = run {
val baseUrlWithPrefix =
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
baseUrl
else
"https://$baseUrl"
baseUrlWithPrefix.toHttpUrlOrNull()
}
val canContinue = !inProgress && baseHttpUrl != null
val canContinue = !inProgress && baseKtorUrl != null
}
var uiState by mutableStateOf(UiState())
@@ -107,7 +102,7 @@ class NextcloudLoginModel @AssistedInject constructor(
* Starts the Login Flow.
*/
fun startLoginFlow() {
val baseUrl = uiState.baseHttpUrl
val baseUrl = uiState.baseKtorUrl
if (uiState.inProgress || baseUrl == null)
return
@@ -118,19 +113,18 @@ class NextcloudLoginModel @AssistedInject constructor(
viewModelScope.launch {
try {
val loginUrl = loginFlow.initiate(baseUrl)
val loginUrl = loginFlow.start(baseUrl)
uiState = uiState.copy(
loginUrl = loginUrl,
inProgress = false
)
} catch (e: Exception) {
logger.log(Level.WARNING, "Initiating Login Flow failed", e)
uiState = uiState.copy(
inProgress = false,
error = e.toString()
error = e.localizedMessage ?: e.javaClass.simpleName
)
}
}
@@ -155,7 +149,7 @@ class NextcloudLoginModel @AssistedInject constructor(
logger.log(Level.WARNING, "Fetching login info failed", e)
uiState = uiState.copy(
inProgress = false,
error = e.toString()
error = e.localizedMessage ?: e.javaClass.simpleName
)
return@launch
}

View File

@@ -4,6 +4,7 @@
package at.bitfire.davdroid.ui.webdav
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Build
@@ -107,6 +108,7 @@ fun WebdavMountsScreen(
val uriHandler = LocalUriHandler.current
var isRefreshing by remember { mutableStateOf(false) }
@SuppressLint("LocalContextGetResourceValueCall")
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
delay(300)
@@ -324,7 +326,11 @@ fun WebdavMountsItem(
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
val uri = DocumentsContract.buildRootUri(context.getString(R.string.webdav_authority), info.mount.id.toString())
@SuppressLint("LocalContextGetResourceValueCall")
val uri = DocumentsContract.buildRootUri(
context.getString(R.string.webdav_authority),
info.mount.id.toString()
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri)
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import io.ktor.http.Url
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Test
class NextcloudLoginFlowTest {
private val flow = NextcloudLoginFlow(mockk(relaxed = true))
@Test
fun `loginFlowUrl accepts v2 URL`() {
assertEquals(
Url("http://example.com/index.php/login/v2"),
flow.loginFlowUrl(Url("http://example.com/index.php/login/v2"))
)
}
@Test
fun `loginFlowUrl rewrites root URL to v2 URL`() {
assertEquals(
Url("http://example.com/index.php/login/v2"),
flow.loginFlowUrl(Url("http://example.com/"))
)
}
@Test
fun `loginFlowUrl rewrites v1 URL to v2 URL`() {
assertEquals(
Url("http://example.com/index.php/login/v2"),
flow.loginFlowUrl(Url("http://example.com/index.php/login/flow"))
)
}
}

View File

@@ -1,9 +1,9 @@
# Comments apply to next line
[versions]
android-agp = "8.13.1"
android-agp = "8.13.2"
android-desugaring = "2.1.5"
androidx-activityCompose = "1.12.0"
androidx-activityCompose = "1.12.1"
androidx-appcompat = "1.7.1"
androidx-arch = "2.2.0"
androidx-browser = "1.9.0"
@@ -19,10 +19,10 @@ androidx-test-rules = "1.7.0"
androidx-test-junit = "1.3.0"
androidx-work = "2.11.0"
bitfire-cert4android = "42d883e958"
bitfire-dav4jvm = "acd9bca096"
bitfire-synctools = "5fb54ec88c"
bitfire-dav4jvm = "57321c95ad"
bitfire-synctools = "42e82f4769"
compose-accompanist = "0.37.3"
compose-bom = "2025.11.01"
compose-bom = "2025.12.00"
conscrypt = "2.5.3"
dnsjava = "3.6.3"
glance = "1.1.1"
@@ -34,7 +34,7 @@ kotlinx-coroutines = "1.10.2"
ksp = "2.3.3"
ktor = "3.3.3"
mikepenz-aboutLibraries = "13.1.0"
mockk = "1.14.5"
mockk = "1.14.7"
okhttp = "5.3.2"
openid-appauth = "0.11.1"
robolectric = "4.16"
@@ -95,8 +95,10 @@ junit = { module = "junit:junit", version = "4.13.2" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
mikepenz-aboutLibraries-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "mikepenz-aboutLibraries" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
@@ -119,5 +121,6 @@ android-application = { id = "com.android.application", version.ref = "android-a
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
mikepenz-aboutLibraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "mikepenz-aboutLibraries" }

View File

@@ -19,4 +19,18 @@ dependencyResolutionManagement {
}
}
// use remote build cache, if configured
if (System.getenv("GRADLE_BUILDCACHE_URL") != null) {
buildCache {
remote<HttpBuildCache> {
url = uri(System.getenv("GRADLE_BUILDCACHE_URL"))
credentials {
username = System.getenv("GRADLE_BUILDCACHE_USERNAME")
password = System.getenv("GRADLE_BUILDCACHE_PASSWORD")
}
isPush = true // read/write
}
}
}
include(":app")