Merge pull request #6238 from element-hq/feature/bma/importFromClassic

Ensure that Element X can use the service from Element Classic.
This commit is contained in:
Benoit Marty 2026-02-24 15:58:16 +01:00 committed by GitHub
commit c91c78171e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 69 additions and 31 deletions

View file

@ -1,3 +1,4 @@
import extension.buildConfigFieldStr
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies import extension.testCommonDependencies
@ -23,6 +24,30 @@ android {
isIncludeAndroidResources = true isIncludeAndroidResources = true
} }
} }
buildFeatures {
buildConfig = true
}
buildTypes {
val elementClassicPackageKey = "elementClassicPackage"
val elementClassicPackage = "im.vector.app"
val elementClassicPackageDebug = "$elementClassicPackage.debug"
val elementClassicPackageNightly = "$elementClassicPackage.nightly"
getByName("release") {
manifestPlaceholders[elementClassicPackageKey] = elementClassicPackage
buildConfigFieldStr(elementClassicPackageKey, elementClassicPackage)
}
getByName("debug") {
manifestPlaceholders[elementClassicPackageKey] = elementClassicPackageDebug
buildConfigFieldStr(elementClassicPackageKey, elementClassicPackageDebug)
}
register("nightly") {
matchingFallbacks += listOf("release")
manifestPlaceholders[elementClassicPackageKey] = elementClassicPackageNightly
buildConfigFieldStr(elementClassicPackageKey, elementClassicPackageNightly)
}
}
} }
setupDependencyInjection() setupDependencyInjection()

View file

@ -13,8 +13,9 @@
<intent> <intent>
<action android:name="android.support.customtabs.action.CustomTabsService" /> <action android:name="android.support.customtabs.action.CustomTabsService" />
</intent> </intent>
<!-- To be able to start the service exported by Element Classic -->
<package android:name="${elementClassicPackage}" />
</queries> </queries>
<!-- Permission to read data from Element classic -->
<uses-permission android:name="im.vector.app.READ_DATA" />
</manifest> </manifest>

View file

@ -20,9 +20,8 @@ import android.os.Messenger
import android.os.RemoteException import android.os.RemoteException
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.login.impl.BuildConfig
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -44,7 +43,11 @@ sealed interface ElementClassicConnectionState {
object Idle : ElementClassicConnectionState object Idle : ElementClassicConnectionState
object ElementClassicNotFound : ElementClassicConnectionState object ElementClassicNotFound : ElementClassicConnectionState
object ElementClassicReadyNoSession : ElementClassicConnectionState object ElementClassicReadyNoSession : ElementClassicConnectionState
data class ElementClassicReady(val userId: UserId) : ElementClassicConnectionState data class ElementClassicReady(
val userId: UserId,
val secrets: String,
) : ElementClassicConnectionState
data class Error(val error: String) : ElementClassicConnectionState data class Error(val error: String) : ElementClassicConnectionState
} }
@ -56,7 +59,6 @@ class DefaultElementClassicConnection(
private val context: Context, private val context: Context,
@AppCoroutineScope @AppCoroutineScope
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val buildMeta: BuildMeta,
) : ElementClassicConnection { ) : ElementClassicConnection {
// Messenger for communicating with the service. // Messenger for communicating with the service.
private var messenger: Messenger? = null private var messenger: Messenger? = null
@ -101,7 +103,7 @@ class DefaultElementClassicConnection(
// applications replace our component. // applications replace our component.
try { try {
val intentService = Intent() val intentService = Intent()
intentService.setComponent(getElementClassicComponent(buildMeta)) intentService.setComponent(getElementClassicComponent())
if (context.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) { if (context.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) {
Timber.tag(loggerTag.value).d("Binding returned true") Timber.tag(loggerTag.value).d("Binding returned true")
} else { } else {
@ -198,17 +200,8 @@ class DefaultElementClassicConnection(
} }
} }
private fun getElementClassicComponent(buildMeta: BuildMeta) = ComponentName( private fun getElementClassicComponent() = ComponentName(
buildString { BuildConfig.elementClassicPackage,
append(ELEMENT_CLASSIC_APP_ID)
append(
when (buildMeta.buildType) {
BuildType.DEBUG -> ELEMENT_CLASSIC_APP_ID_DEBUG_SUFFIX
BuildType.NIGHTLY -> ELEMENT_CLASSIC_APP_ID_NIGHTLY_SUFFIX
BuildType.RELEASE -> ELEMENT_CLASSIC_APP_ID_RELEASE_SUFFIX
}
)
},
ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME, ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME,
) )
@ -220,9 +213,14 @@ class DefaultElementClassicConnection(
if (error != null) { if (error != null) {
ElementClassicConnectionState.Error(error) ElementClassicConnectionState.Error(error)
} else { } else {
val userId = getString(KEY_USER_ID_STR)?.let(::UserId) val userId = getString(KEY_USER_ID_STR)?.takeIf { it.isNotEmpty() }?.let(::UserId)
if (userId != null) { if (userId != null) {
ElementClassicConnectionState.ElementClassicReady(userId) val secrets = getString(KEY_SECRETS_STR)?.takeIf { it.isNotEmpty() }
if (secrets == null) {
ElementClassicConnectionState.Error("No secrets received from Element Classic")
} else {
ElementClassicConnectionState.ElementClassicReady(userId, secrets)
}
} else { } else {
ElementClassicConnectionState.ElementClassicReadyNoSession ElementClassicConnectionState.ElementClassicReadyNoSession
} }
@ -232,18 +230,31 @@ class DefaultElementClassicConnection(
// Everything in this companion object must match what is defined in Element Classic // Everything in this companion object must match what is defined in Element Classic
private companion object { private companion object {
const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService"
// Command to the service to get the data. // Command to the service to get the data.
const val MSG_GET_DATA = 1 const val MSG_GET_DATA = 1
const val ELEMENT_CLASSIC_APP_ID = "im.vector.app"
const val ELEMENT_CLASSIC_APP_ID_DEBUG_SUFFIX = ".debug"
const val ELEMENT_CLASSIC_APP_ID_NIGHTLY_SUFFIX = ".nightly"
const val ELEMENT_CLASSIC_APP_ID_RELEASE_SUFFIX = ""
const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService"
// Keys for the bundle returned from the service // Keys for the bundle returned from the service
const val KEY_ERROR_STR = "error" const val KEY_ERROR_STR = "error"
const val KEY_USER_ID_STR = "userId" const val KEY_USER_ID_STR = "userId"
/**
* Key to extract the secrets from the bundle, as a Json string.
* Json will have this format:
* {
* "cross_signing" : {
* "master_key" : "z8RUxnaAGu___REDACTED___k+BQL9o",
* "user_signing_key" : "baJHzA___REDACTED___xMLbSUAXw9QUzqms",
* "self_signing_key" : "DU0CvLtR2G/___REDACTED___dV/MONNq4nsQhM"
* },
* "backup" : {
* "algorithm" : "m.megolm_backup.v1.curve25519-aes-sha2",
* "key" : "VzncmQ+UOV___REDACTED___patxDz7m0Nc",
* "backup_version" : "1"
* }
* }
*/
const val KEY_SECRETS_STR = "secrets"
} }
} }

View file

@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.A_SECRET
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
@ -114,7 +115,7 @@ class LoginWithClassicPresenterTest {
presenter.test { presenter.test {
skipItems(2) skipItems(2)
elementClassicConnection.emitState( elementClassicConnection.emitState(
ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID) ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID, secrets = A_SECRET)
) )
val readyState = awaitItem() val readyState = awaitItem()
assertThat(readyState.canLoginWithClassic).isTrue() assertThat(readyState.canLoginWithClassic).isTrue()
@ -140,7 +141,7 @@ class LoginWithClassicPresenterTest {
presenter.test { presenter.test {
skipItems(2) skipItems(2)
elementClassicConnection.emitState( elementClassicConnection.emitState(
ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID) ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID, secrets = A_SECRET)
) )
val readyState = awaitItem() val readyState = awaitItem()
assertThat(readyState.canLoginWithClassic).isTrue() assertThat(readyState.canLoginWithClassic).isTrue()
@ -175,7 +176,7 @@ class LoginWithClassicPresenterTest {
presenter.test { presenter.test {
skipItems(2) skipItems(2)
elementClassicConnection.emitState( elementClassicConnection.emitState(
ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID) ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID, secrets = A_SECRET)
) )
// No new item, because canLoginWithClassic is still false // No new item, because canLoginWithClassic is still false
} }
@ -192,7 +193,7 @@ class LoginWithClassicPresenterTest {
skipItems(1) skipItems(1)
// Note: it should not happen IRL // Note: it should not happen IRL
elementClassicConnection.emitState( elementClassicConnection.emitState(
ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID) ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID, secrets = A_SECRET)
) )
// No new item, because canLoginWithClassic is still false // No new item, because canLoginWithClassic is still false
} }