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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/9167.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a banner in the room list inviting users to migrate to Element X, configurable via the homeserver .well-known file.
1 change: 1 addition & 0 deletions changelog.d/9167.sdk
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
RawService: cache definitive "not found" (404/410) answers for the same duration as successful ones, so that a url which is known to be missing is not requested again on every call.
4 changes: 4 additions & 0 deletions library/ui-strings/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3650,4 +3650,8 @@
<string name="view_download_replacement_app_title">Download %1$s</string>
<string name="view_download_replacement_app_subtitle">Faster, more secure, and packed with powerful collaboration tools.</string>

<string name="view_element_x_banner_title">Download Element X</string>
<string name="view_element_x_banner_body">"Faster sync, a cleaner design, and new features you won't find here. The Element Classic app will be retired soon, so now's the time to make the move."</string>
<string name="view_element_x_banner_button">Download app</string>

</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ package org.matrix.android.sdk.api.raw
import org.matrix.android.sdk.api.cache.CacheStrategy

/**
* Useful methods to fetch raw data from the server. The access token will not be used to fetched the data
* Useful methods to fetch raw data from the server. The access token will not be used to fetch the data
*/
interface RawService {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ internal open class RawCacheEntity(
@PrimaryKey
var url: String = "",
var data: String = "",
var lastUpdatedTimestamp: Long = 0L
var lastUpdatedTimestamp: Long = 0L,
/**
* True when the server answered with a definitive "not found" (404 or 410) for this url.
* Used to avoid re-issuing a request which is known to fail, until [lastUpdatedTimestamp]
* is older than the cache validity duration. [data] is left untouched when this is set, so
* that a previously retrieved value can still be returned as a stale fallback.
*/
var isNotFound: Boolean = false,
) : RealmObject() {

companion object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.raw

import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.cache.CacheStrategy
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.internal.database.model.RawCacheEntity
import org.matrix.android.sdk.internal.database.query.get
import org.matrix.android.sdk.internal.database.query.getOrCreate
Expand All @@ -27,6 +28,7 @@ import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import java.util.Date
import javax.inject.Inject
import javax.net.ssl.HttpsURLConnection

internal interface GetUrlTask : Task<GetUrlTask.Params, String> {
data class Params(
Expand Down Expand Up @@ -65,35 +67,64 @@ internal class DefaultGetUrlTask @Inject constructor(

private suspend fun doRequestWithCache(url: String, validityDurationInMillis: Long, strict: Boolean): String {
// Get data from cache
var dataFromCache: String? = null
var isCacheValid = false
var snapshot: RawCacheSnapshot? = null
monarchy.doWithRealm { realm ->
val entity = RawCacheEntity.get(realm, url)
dataFromCache = entity?.data
isCacheValid = entity != null && Date().time < entity.lastUpdatedTimestamp + validityDurationInMillis
snapshot = RawCacheEntity.get(realm, url)?.let {
RawCacheSnapshot(
data = it.data,
isNotFound = it.isNotFound,
lastUpdatedTimestamp = it.lastUpdatedTimestamp
)
}
}

if (dataFromCache != null && isCacheValid) {
return dataFromCache as String
val staleData = when (val resolution = resolveRawCache(snapshot, Date().time, validityDurationInMillis, strict)) {
is RawCacheResolution.Return -> return resolution.data
// The server already told us this url does not exist, and we have nothing to fall back
// on. Fail as a live request would have, but without issuing it.
RawCacheResolution.ThrowNotFound -> throw notFoundFailure()
is RawCacheResolution.Fetch -> resolution.staleData
}

// No cache or outdated cache
val data = try {
doRequest(url)
} catch (throwable: Throwable) {
if (throwable.isDefinitiveNotFound()) {
// Remember it, so that we do not hammer the server with a request we know fails.
// Note that `data` is intentionally left as it is, to keep the stale fallback working.
monarchy.awaitTransaction { realm ->
val rawCacheEntity = RawCacheEntity.getOrCreate(realm, url)
rawCacheEntity.isNotFound = true
rawCacheEntity.lastUpdatedTimestamp = Date().time
}
}
// In case of error, we can return value from cache even if outdated
return dataFromCache
?.takeIf { !strict }
?: throw throwable
return staleData ?: throw throwable
}

// Store cache
monarchy.awaitTransaction { realm ->
val rawCacheEntity = RawCacheEntity.getOrCreate(realm, url)
rawCacheEntity.data = data
rawCacheEntity.isNotFound = false
rawCacheEntity.lastUpdatedTimestamp = Date().time
}

return data
}

private fun notFoundFailure() = Failure.OtherServerError("", HttpsURLConnection.HTTP_NOT_FOUND)
}

/**
* True when the server gave a definitive answer that the resource does not exist, as opposed to a
* transient error such as being offline or a 5xx, which must keep being retried.
*
* Note that [org.matrix.android.sdk.api.failure.is404] cannot be used here: it only matches a
* Matrix API error (a [Failure.ServerError] carrying `M_NOT_FOUND`), whereas requesting a plain
* file returns an HTML error page, which ends up as a [Failure.OtherServerError].
*/
private fun Throwable.isDefinitiveNotFound() = this is Failure.OtherServerError &&
(httpCode == HttpsURLConnection.HTTP_NOT_FOUND || /* 404 */
httpCode == HttpsURLConnection.HTTP_GONE) /* 410 */
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ package org.matrix.android.sdk.internal.raw

import io.realm.DynamicRealm
import org.matrix.android.sdk.internal.raw.migration.MigrateGlobalTo001
import org.matrix.android.sdk.internal.raw.migration.MigrateGlobalTo002
import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration
import javax.inject.Inject

internal class GlobalRealmMigration @Inject constructor() : MatrixRealmMigration(
dbName = "Global",
schemaVersion = 1L,
schemaVersion = 2L,
) {
/**
* Forces all GlobalRealmMigration instances to be equal.
Expand All @@ -34,5 +35,6 @@ internal class GlobalRealmMigration @Inject constructor() : MatrixRealmMigration

override fun doMigrate(realm: DynamicRealm, oldVersion: Long) {
if (oldVersion < 1) MigrateGlobalTo001(realm).perform()
if (oldVersion < 2) MigrateGlobalTo002(realm).perform()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2026 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.matrix.android.sdk.internal.raw

/**
* Realm-free view of a RawCacheEntity, so that the cache decision can be unit tested.
*/
internal data class RawCacheSnapshot(
val data: String,
val isNotFound: Boolean,
val lastUpdatedTimestamp: Long,
)

/**
* What to do with a cache entry, as decided by [resolveRawCache].
*/
internal sealed interface RawCacheResolution {
/**
* Return this value straight away, no request needed.
*/
data class Return(val data: String) : RawCacheResolution

/**
* The url is known to be missing on the server, and there is no usable value to fall back on.
* Fail like a live request would have, without issuing it.
*/
object ThrowNotFound : RawCacheResolution

/**
* Perform the request. [staleData], when not null, is what to return if the request fails.
*/
data class Fetch(val staleData: String?) : RawCacheResolution
}

/**
* Decide whether a request has to be performed, given what is currently in cache.
*
* A cached "not found" behaves exactly like a live 404 would: the stale value is returned when the
* caller tolerates it, otherwise the call fails. The only difference is that no request is issued.
*
* @param snapshot the cached entry, or null if there is none.
* @param now current epoch time in milliseconds.
* @param validityDurationInMillis how long a cached entry, positive or negative, stays valid.
* @param strict when false, an outdated or missing value can be replaced by a stale cached one.
*/
internal fun resolveRawCache(
snapshot: RawCacheSnapshot?,
now: Long,
validityDurationInMillis: Long,
strict: Boolean,
): RawCacheResolution {
snapshot ?: return RawCacheResolution.Fetch(null)

// A stale value is only usable by a caller which does not require fresh data.
val staleData = snapshot.data.takeIf { !strict && it.isNotEmpty() }
val isCacheValid = now < snapshot.lastUpdatedTimestamp + validityDurationInMillis

return when {
!isCacheValid -> RawCacheResolution.Fetch(staleData)
!snapshot.isNotFound -> RawCacheResolution.Return(snapshot.data)
staleData != null -> RawCacheResolution.Return(staleData)
else -> RawCacheResolution.ThrowNotFound
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.matrix.android.sdk.internal.raw.migration

import io.realm.DynamicRealm
import org.matrix.android.sdk.internal.database.model.RawCacheEntityFields
import org.matrix.android.sdk.internal.util.database.RealmMigrator

internal class MigrateGlobalTo002(realm: DynamicRealm) : RealmMigrator(realm, 2) {

override fun doMigrate(realm: DynamicRealm) {
realm.schema.get("RawCacheEntity")
?.addField(RawCacheEntityFields.IS_NOT_FOUND, Boolean::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Copyright (c) 2026 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.matrix.android.sdk.internal.raw

import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test

private const val A_VALIDITY_DURATION = 1_000L
private const val A_TIMESTAMP = 10_000L
private const val SOME_DATA = "{}"

class RawCacheResolverTest {

private fun resolve(
snapshot: RawCacheSnapshot?,
now: Long = A_TIMESTAMP,
strict: Boolean = false,
) = resolveRawCache(
snapshot = snapshot,
now = now,
validityDurationInMillis = A_VALIDITY_DURATION,
strict = strict,
)

private fun snapshot(
data: String = SOME_DATA,
isNotFound: Boolean = false,
) = RawCacheSnapshot(data = data, isNotFound = isNotFound, lastUpdatedTimestamp = A_TIMESTAMP)

@Test
fun `given no cache entry when resolving then the request is performed with no fallback`() {
resolve(null) shouldBeEqualTo RawCacheResolution.Fetch(null)
}

@Test
fun `given a valid entry when resolving then the cached data is returned`() {
resolve(snapshot()) shouldBeEqualTo RawCacheResolution.Return(SOME_DATA)
}

@Test
fun `given a valid entry when resolving in strict mode then the cached data is still returned`() {
resolve(snapshot(), strict = true) shouldBeEqualTo RawCacheResolution.Return(SOME_DATA)
}

@Test
fun `given an entry which just expired when resolving then the request is performed`() {
resolve(snapshot(), now = A_TIMESTAMP + A_VALIDITY_DURATION) shouldBeEqualTo RawCacheResolution.Fetch(SOME_DATA)
}

@Test
fun `given an expired entry when resolving in strict mode then there is no stale fallback`() {
resolve(
snapshot(),
now = A_TIMESTAMP + A_VALIDITY_DURATION,
strict = true,
) shouldBeEqualTo RawCacheResolution.Fetch(null)
}

@Test
fun `given a valid not found entry with no previous data when resolving then it fails without a request`() {
resolve(snapshot(data = "", isNotFound = true)) shouldBeEqualTo RawCacheResolution.ThrowNotFound
}

@Test
fun `given a valid not found entry with previous data when resolving then the stale data is returned`() {
resolve(snapshot(isNotFound = true)) shouldBeEqualTo RawCacheResolution.Return(SOME_DATA)
}

@Test
fun `given a valid not found entry with previous data when resolving in strict mode then it fails`() {
resolve(snapshot(isNotFound = true), strict = true) shouldBeEqualTo RawCacheResolution.ThrowNotFound
}

@Test
fun `given an expired not found entry when resolving then the request is performed again`() {
resolve(
snapshot(isNotFound = true),
now = A_TIMESTAMP + A_VALIDITY_DURATION,
) shouldBeEqualTo RawCacheResolution.Fetch(SOME_DATA)
}

@Test
fun `given an expired not found entry with no data when resolving then the request is performed again`() {
resolve(
snapshot(data = "", isNotFound = true),
now = A_TIMESTAMP + A_VALIDITY_DURATION,
) shouldBeEqualTo RawCacheResolution.Fetch(null)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import im.vector.app.features.crypto.verification.user.UserVerificationViewModel
import im.vector.app.features.devtools.RoomDevToolViewModel
import im.vector.app.features.discovery.DiscoverySettingsViewModel
import im.vector.app.features.discovery.change.SetIdentityServerViewModel
import im.vector.app.features.elementx.MigrationBannerViewModel
import im.vector.app.features.home.HomeActivityViewModel
import im.vector.app.features.home.HomeDetailViewModel
import im.vector.app.features.home.NewHomeDetailViewModel
Expand Down Expand Up @@ -706,4 +707,9 @@ interface MavericksViewModelModule {
@IntoMap
@MavericksViewModelKey(NewHomeDetailViewModel::class)
fun newHomeDetailViewModelFactory(factory: NewHomeDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *>

@Binds
@IntoMap
@MavericksViewModelKey(MigrationBannerViewModel::class)
fun migrationBannerViewModelFactory(factory: MigrationBannerViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright 2026 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

package im.vector.app.features.elementx

import im.vector.app.core.platform.VectorViewModelAction

sealed interface MigrationBannerAction : VectorViewModelAction {
data object Close : MigrationBannerAction
data object OnResume : MigrationBannerAction
}
Loading
Loading