Merge remote-tracking branch 'origin/develop' into feature/account-provider-selection

This commit is contained in:
Jenna Vassar
2026-08-18 17:49:31 -07:00
33 changed files with 432 additions and 508 deletions
@@ -115,7 +115,7 @@ class MatrixSessionCache(
private fun onNewMatrixClient(matrixClient: MatrixClient) {
val syncOrchestrator = syncOrchestratorFactory.create(
syncService = matrixClient.syncService,
matrixClient = matrixClient,
sessionCoroutineScope = matrixClient.sessionCoroutineScope,
)
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
@@ -16,7 +16,7 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.recordTransaction
@@ -37,7 +37,7 @@ import kotlin.time.Duration.Companion.seconds
@AssistedInject
class SyncOrchestrator(
@Assisted private val syncService: SyncService,
@Assisted private val matrixClient: MatrixClient,
@Assisted sessionCoroutineScope: CoroutineScope,
private val appForegroundStateService: AppForegroundStateService,
private val networkMonitor: NetworkMonitor,
@@ -47,13 +47,15 @@ class SyncOrchestrator(
@AssistedFactory
interface Factory {
fun create(
syncService: SyncService,
matrixClient: MatrixClient,
sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator
}
private val tag = "SyncOrchestrator"
private val syncService = matrixClient.syncService
private val coroutineScope = sessionCoroutineScope.childScope(dispatchers.io, tag)
private val started = AtomicBoolean(false)
@@ -94,7 +96,7 @@ class SyncOrchestrator(
appForegroundStateService.isInCall,
appForegroundStateService.isSyncingNotificationEvent,
appForegroundStateService.hasRingingCall,
appForegroundStateService.isSharingLiveLocation
appForegroundStateService.isSharingLiveLocation,
)
val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } }
combine(
@@ -103,6 +105,11 @@ class SyncOrchestrator(
networkMonitor.connectivity,
isAppActiveFlow,
) { syncState, networkState, isAppActive ->
if (matrixClient.isShuttingDown) {
Timber.tag(tag).d("Matrix client is shutting down, no need to start the sync service again")
return@combine SyncStateAction.NoOp
}
val isNetworkAvailable = networkState == NetworkStatus.Connected
Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable")
@@ -12,6 +12,7 @@ import io.element.android.appnav.di.SyncOrchestrator
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
@@ -40,7 +41,7 @@ class SyncOrchestratorTest {
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
)
@@ -60,7 +61,7 @@ class SyncOrchestratorTest {
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
)
@@ -85,7 +86,7 @@ class SyncOrchestratorTest {
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -117,7 +118,7 @@ class SyncOrchestratorTest {
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -165,7 +166,7 @@ class SyncOrchestratorTest {
initialIsSyncingNotificationEventValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -208,7 +209,7 @@ class SyncOrchestratorTest {
initialIsSyncingNotificationEventValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -252,7 +253,7 @@ class SyncOrchestratorTest {
initialHasRingingCall = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -296,7 +297,7 @@ class SyncOrchestratorTest {
initialIsInCallValue = true,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -339,7 +340,7 @@ class SyncOrchestratorTest {
initialIsInCallValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -369,7 +370,7 @@ class SyncOrchestratorTest {
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
)
@@ -381,12 +382,44 @@ class SyncOrchestratorTest {
startSyncRecorder.assertions().isNeverCalled()
}
@Test
fun `when the client is shutting down, the orchestrator does nothing`() = runTest {
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = false,
initialIsSyncingNotificationEventValue = false,
initialIsInCallValue = false,
)
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
startSyncLambda = startSyncRecorder
}
val syncOrchestrator = createSyncOrchestrator(
matrixClient = FakeMatrixClient(syncService = syncService, isShuttingDownResult = { true }),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.observeStates()
// These should still not trigger a sync, sync the client is shutting down
networkMonitor.givenNetworkBlocked(false)
appForegroundStateService.updateIsInCallState(true)
appForegroundStateService.isInForeground.value = true
advanceTimeBy(10.seconds)
startSyncRecorder.assertions().isNeverCalled()
}
private fun TestScope.createSyncOrchestrator(
syncService: FakeSyncService = FakeSyncService(),
matrixClient: FakeMatrixClient = FakeMatrixClient(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
) = SyncOrchestrator(
syncService = syncService,
matrixClient = matrixClient,
sessionCoroutineScope = backgroundScope,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
@@ -11,7 +11,7 @@ package io.element.android.appnav.di
import com.bumble.appyx.core.state.MutableSavedStateMapImpl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
@@ -138,11 +138,11 @@ class MatrixSessionCacheTest {
return object : SyncOrchestrator.Factory {
override fun create(
syncService: SyncService,
matrixClient: MatrixClient,
sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator {
return SyncOrchestrator(
syncService = syncService,
matrixClient = matrixClient,
sessionCoroutineScope = sessionCoroutineScope,
appForegroundStateService = FakeAppForegroundStateService(),
networkMonitor = FakeNetworkMonitor(),
@@ -299,10 +299,10 @@ private fun Content(
}
/**
* Ensure that the string does not contain any new line characters, which can happen when pasting values.
* Ensure that the string does not contain any line separator, which can happen when pasting values.
*/
private fun String.sanitize(): String {
return replace("\n", "")
return filterNot { it == '\n' || it == '\r' }
}
@PreviewsDayNight
@@ -60,7 +60,13 @@ class LeaveRoomPresenter(
roomId: RoomId,
leaveAction: MutableState<AsyncAction<Unit>>,
) = launch(dispatchers.io) {
client.getRoom(roomId)?.use { room ->
val room = client.getRoom(roomId)
if (room == null) {
Timber.e("Unable to find room $roomId to leave it")
leaveAction.value = AsyncAction.Failure(RoomNotFoundException(roomId))
return@launch
}
room.use {
val roomInfo = room.roomInfoFlow.first()
leaveAction.value = when {
roomInfo.isDm -> Confirmation.Dm(roomId)
@@ -78,7 +84,8 @@ class LeaveRoomPresenter(
leaveAction: MutableState<AsyncAction<Unit>>,
) = launch(dispatchers.io) {
leaveAction.runCatchingUpdatingState {
client.getRoom(roomId)!!.use { room ->
val room = client.getRoom(roomId) ?: throw RoomNotFoundException(roomId)
room.use {
room
.leave()
.onSuccess { notificationConversationService.onLeftRoom(client.sessionId, roomId) }
@@ -100,3 +107,5 @@ class LeaveRoomPresenter(
}
}
}
internal class RoomNotFoundException(roomId: RoomId) : IllegalStateException("Room $roomId not found")
@@ -175,6 +175,32 @@ class LeaveBaseRoomPresenterTest {
}
}
@Test
fun `present - show error if the room to confirm leaving is not found`() = runTest {
val presenter = createLeaveRoomPresenter(client = FakeMatrixClient())
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
val errorState = awaitItem()
assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - show error if the room to leave is not found`() = runTest {
val presenter = createLeaveRoomPresenter(client = FakeMatrixClient())
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
val progressState = awaitItem()
assertThat(progressState.leaveAction).isEqualTo(AsyncAction.Loading)
val errorState = awaitItem()
assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - reset state after error`() = runTest {
val presenter = createLeaveRoomPresenter(
@@ -39,6 +39,10 @@ class LinkNewDesktopHandler(
LinkDesktopStep.Uninitialized
)
/**
* A [StateFlow] that emits the current step of the link desktop process.
* It starts with [LinkDesktopStep.Uninitialized] and will emit other steps as they occur.
*/
val stepFlow: StateFlow<LinkDesktopStep>
get() = linkDesktopStepFlow.asStateFlow()
@@ -53,15 +57,31 @@ class LinkNewDesktopHandler(
timerJob = null
}
fun createNewHandler() {
resetJobs()
handler = matrixClient.createLinkDesktopHandler().getOrNull()
/**
* Starts (or restarts) a timer that will emit an error in [stepFlow] after 2 minutes.
*/
fun startTimer() {
timerJob?.cancel()
timerJob = sessionScope.launch {
delay(2.minutes)
linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long.")))
}
}
/**
* Creates a new [LinkDesktopHandler] and cancels any existing job.
* This should be called when the user wants to retry scanning a QR code.
*/
fun createNewHandler() {
currentJob?.cancel()
currentJob = null
handler = matrixClient.createLinkDesktopHandler().getOrNull()
}
/**
* Resets the state of the handler and cancels any existing job.
* This should be called when the user wants to start over.
*/
fun reset() {
resetJobs()
sessionScope.launch {
@@ -69,6 +89,12 @@ class LinkNewDesktopHandler(
}
}
/**
* Handles the scanned QR code data.
* This should be called when the user scans a QR code.
*
* @param data The scanned QR code data as a byte array.
*/
fun onScannedCode(data: ByteArray) {
resetJobs()
val currentHandler = handler
@@ -263,6 +263,8 @@ class LinkNewDeviceFlowNode(
}
LinkDeviceType.Desktop -> {
linkNewDesktopHandler.reset()
// Ensure unlock state does not last longer than the timeout for scanning the QR code, so start the timer after unlock
linkNewDesktopHandler.startTimer()
backstack.push(NavTarget.DesktopNotice)
}
}
@@ -278,10 +280,14 @@ class LinkNewDeviceFlowNode(
NavTarget.DesktopNotice -> {
val callback = object : DesktopNoticeNode.Callback {
override fun navigateBack() {
// Stop the timer when the user leaves the screen (user will have to authenticate again)
linkNewDesktopHandler.reset()
backstack.pop()
}
override fun navigateToQrCodeScanner() {
// Start again the timer when the user enters the next screen (they clicked on "Ready to scan")
linkNewDesktopHandler.startTimer()
backstack.push(NavTarget.DesktopScanQrCode)
}
}
@@ -290,6 +296,8 @@ class LinkNewDeviceFlowNode(
NavTarget.DesktopScanQrCode -> {
val callback = object : ScanQrCodeNode.Callback {
override fun cancel() {
// start again the timer when the user goes back to the DesktopNoticeNode
linkNewDesktopHandler.startTimer()
backstack.pop()
}
}
@@ -40,9 +40,19 @@ class LinkNewMobileHandler(
LinkMobileStep.Uninitialized
)
/**
* A [StateFlow] that emits the current step of the link mobile process.
* It starts with [LinkMobileStep.Uninitialized] and will emit other steps as they occur.
*/
val stepFlow: StateFlow<LinkMobileStep>
get() = linkMobileStepFlow.asStateFlow()
/**
* Creates a new [LinkMobileHandler] and starts the linking process.
* If [forRotating] is true, it indicates that this is a rotation of the QR code.
*
* @param forRotating Whether this is a rotation of the QR code. Defaults to false.
*/
fun createAndStartNewHandler(forRotating: Boolean = false) {
Timber.tag(loggerTag.value).d("createAndStartNewHandler()")
currentJob?.cancel()
@@ -62,6 +72,10 @@ class LinkNewMobileHandler(
}
}
/**
* Resets the handler and cancels any ongoing job.
* This will also emit [LinkMobileStep.Uninitialized] to the [stepFlow].
*/
fun reset() {
currentJob?.cancel()
currentJob = null
@@ -70,10 +84,18 @@ class LinkNewMobileHandler(
}
}
/**
* Rotates the QR code by creating a new handler and starting the linking process.
* This is typically called when a timeout occurs for the current QR code.
*/
fun rotateQrCode() {
createAndStartNewHandler(forRotating = true)
}
/**
* Handles the case where there are too many QR code rotations.
* This will reset the handler and emit an error to the [stepFlow].
*/
fun onTooManyRotation() {
reset()
sessionScope.launch {
@@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.modifiers.bringIntoViewOnImeVisible
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -191,6 +192,7 @@ private fun LoginForm(
enabled = !isLoading,
modifier = Modifier
.fillMaxWidth()
.bringIntoViewOnImeVisible()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginEmailUsername)
.semantics {
@@ -243,6 +245,7 @@ private fun LoginForm(
enabled = !isLoading,
modifier = Modifier
.fillMaxWidth()
.bringIntoViewOnImeVisible()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword)
.semantics {
@@ -274,10 +277,10 @@ private fun LoginForm(
}
/**
* Ensure that the string does not contain any new line characters, which can happen when pasting values.
* Ensure that the string does not contain any line separator, which can happen when pasting values.
*/
private fun String.sanitize(): String {
return replace("\n", "")
return filterNot { it == '\n' || it == '\r' }
}
@Composable
@@ -81,6 +81,21 @@ class LoginPasswordViewTest : RobolectricTest() {
)
}
@Test
fun `changing login removes carriage returns the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
setLoginPasswordView(
aLoginPasswordState(
eventSink = eventsRecorder,
),
)
val userNameHint = activity!!.getString(CommonStrings.common_username)
onNodeWithText(userNameHint).performTextInput("a\r\nb")
eventsRecorder.assertSingle(
LoginPasswordEvents.SetLogin("ab")
)
}
@Test
fun `clearing login invokes the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
@@ -112,6 +127,21 @@ class LoginPasswordViewTest : RobolectricTest() {
)
}
@Test
fun `changing password removes carriage returns the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
setLoginPasswordView(
aLoginPasswordState(
eventSink = eventsRecorder,
),
)
val passwordHint = activity!!.getString(CommonStrings.common_password)
onNodeWithText(passwordHint).performTextInput("a\r\nb")
eventsRecorder.assertSingle(
LoginPasswordEvents.SetPassword("ab")
)
}
@Test
fun `reveal password makes the password visible`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false)
@@ -86,6 +86,7 @@ import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import io.element.android.services.analytics.api.AnalyticsService
@@ -103,7 +104,9 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -459,6 +462,14 @@ class MessageComposerPresenter(
val mentionTriggerFlow = merge(mentionStartTrigger, mentionCompletionTrigger)
// Refresh the room members, which are only loaded on demand, when a mention starts
launch {
suggestionSearchTrigger
.distinctUntilChangedBy { it?.type }
.filter { it?.type == SuggestionType.Mention }
.collect { room.updateMembers() }
}
val roomAliasSuggestionsFlow = roomAliasSuggestionsDataSource
.getAllRoomAliasSuggestions()
.stateIn(this, SharingStarted.Lazily, emptyList())
@@ -1056,7 +1056,8 @@ class MessageComposerPresenterTest : RobolectricTest() {
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canTriggerRoomNotification = true,
)
),
updateMembersResult = {},
),
typingNoticeResult = { Result.success(Unit) }
).apply {
@@ -1103,6 +1104,55 @@ class MessageComposerPresenterTest : RobolectricTest() {
}
}
@Test
fun `present - mention suggestions refresh the room members`() = runTest {
val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
val updateMembersResult = lambdaRecorder<Unit> { }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canTriggerRoomNotification = true,
),
updateMembersResult = updateMembersResult,
),
typingNoticeResult = { Result.success(Unit) }
).apply {
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(currentUser, bob),
)
)
givenRoomInfo(aRoomInfo(isDirect = false))
}
val presenter = createPresenter(
room = room,
slashCommandService = FakeSlashCommandService(
getSuggestionsResult = { _, _ -> emptyList() },
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
skipItems(1)
assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.AtRoom, ResolvedSuggestion.Member(bob))
updateMembersResult.assertions().isCalledOnce()
// Typing after the `@` must not refresh the members again
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 1, SuggestionType.Mention, "b")))
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 2, SuggestionType.Mention, "bo")))
advanceUntilIdle()
updateMembersResult.assertions().isCalledOnce()
// Starting a new mention refreshes them again
initialState.eventSink(MessageComposerEvent.SuggestionReceived(null))
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
advanceUntilIdle()
updateMembersResult.assertions().isCalledExactly(2)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - room mention suggestions no permission`() = runTest {
val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
@@ -1113,7 +1163,8 @@ class MessageComposerPresenterTest : RobolectricTest() {
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canTriggerRoomNotification = false,
)
),
updateMembersResult = {},
),
typingNoticeResult = { Result.success(Unit) }
).apply {
@@ -1144,6 +1195,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(canTriggerRoomNotification = true),
updateMembersResult = {},
),
typingNoticeResult = { Result.success(Unit) }
).apply {
+1 -1
View File
@@ -185,7 +185,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version
# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt
# All new features should not be implemented in the pull request that upgrades the version, developers should
# only fix API breaks and may add some TODOs.
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.13"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.17"
# Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
@@ -136,6 +136,9 @@ interface MatrixClient : UrlContentFetcher {
/** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */
val contentScanner: ContentScanner?
/** Whether the session is in the process of shutting down, either because it is being logged out or its cache is being cleared. */
val isShuttingDown: Boolean
/**
* Returns the room with the given id if the user has joined it, along with its live [Timeline].
* A new instance is created on each call and the caller owns it, so it must be closed once no longer needed.
@@ -135,6 +135,7 @@ import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
import java.util.Optional
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -304,6 +305,10 @@ class RustMatrixClient(
.buffer(Channel.UNLIMITED)
.stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf())
private val _isShuttingDown = AtomicBoolean(false)
override val isShuttingDown: Boolean
get() = _isShuttingDown.get()
init {
// Make sure the session delegate has a reference to the client to be able to logout on auth error
sessionDelegate.bindClient(this)
@@ -666,11 +671,13 @@ class RustMatrixClient(
}
override suspend fun clearCache() {
_isShuttingDown.set(true)
innerClient.clearCaches(innerSyncService)
destroy()
}
override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) {
_isShuttingDown.set(true)
sessionCoroutineScope.cancel()
// Remove current delegate so we don't receive an auth error
clientDelegateTaskHandle?.cancelAndDestroy()
@@ -708,6 +715,8 @@ class RustMatrixClient(
}
override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result<Unit> = withContext(sessionDispatcher) {
_isShuttingDown.set(true)
Timber.w("Deactivating account")
// Remove current delegate so we don't receive an auth error
clientDelegateTaskHandle?.cancelAndDestroy()
@@ -29,6 +29,7 @@ fun Throwable.mapAuthenticationException(): AuthenticationException {
is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message)
is ClientBuildException.EventCache -> AuthenticationException.Generic(message)
is ClientBuildException.InvalidRawKey -> AuthenticationException.Generic(message)
is ClientBuildException.WellKnownLookupDisabled -> AuthenticationException.Generic(message)
}
is OAuthException -> when (this) {
is OAuthException.Generic -> AuthenticationException.OAuth(message)
@@ -8,11 +8,13 @@
package io.element.android.libraries.matrix.impl.permalink
import android.net.Uri
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -51,6 +53,10 @@ class DefaultPermalinkParser(
matrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri)
}
matrixToUri.userIdWithSlash()?.let { userId ->
return PermalinkData.UserLink(userId = userId)
}
val result = runCatchingExceptions {
parseMatrixEntityFrom(matrixToUri.toString())
}.getOrNull()
@@ -84,3 +90,17 @@ class DefaultPermalinkParser(
}
}
}
/**
* A slash is allowed in the local part of a user id, but it also separates an identifier from an
* event id in a permalink, so [parseMatrixEntityFrom] cannot tell the two apart. Only a user id can
* start with an `@`, so recover it here rather than letting the ambiguity reach the SDK.
*/
private fun Uri.userIdWithSlash(): UserId? {
val identifier = fragment
?.removePrefix("/")
?.substringBefore('?')
?: return null
if (!identifier.startsWith("@") || !identifier.contains('/')) return null
return identifier.takeIf { MatrixPatterns.isUserId(it) }?.let(::UserId)
}
@@ -75,6 +75,7 @@ import org.matrix.rustcomponents.sdk.PollData
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.UploadParameters
import org.matrix.rustcomponents.sdk.UploadSource
import org.matrix.rustcomponents.sdk.createCaptionEdit
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import uniffi.matrix_sdk.PaginationStatus
@@ -341,7 +342,7 @@ class RustTimeline(
formattedCaption: String?,
): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions<Unit> {
val editedContent = EditedContent.MediaCaption(
val editedContent = createCaptionEdit(
caption = caption,
formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html)
@@ -365,7 +366,7 @@ class RustTimeline(
fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions {
runCatchingExceptions<Unit> {
val msg = MessageEventContent.from(
body = body,
htmlBody = htmlBody,
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.permalink
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class DefaultPermalinkParserTest : RobolectricTest() {
@Test
fun `a matrix-to link to a user id with a slash is parsed as a user link`() {
val result = createParser().parse("https://matrix.to/#/@it/sme:matrix.org")
assertThat(result).isEqualTo(PermalinkData.UserLink(UserId("@it/sme:matrix.org")))
}
@Test
fun `an escaped slash in a user id is parsed as a user link`() {
val result = createParser().parse("https://matrix.to/#/@it%2Fsme:matrix.org")
assertThat(result).isEqualTo(PermalinkData.UserLink(UserId("@it/sme:matrix.org")))
}
@Test
fun `an element web link to a user id with a slash is parsed as a user link`() {
val result = createParser().parse("https://app.element.io/#/user/@it/sme:matrix.org")
assertThat(result).isEqualTo(PermalinkData.UserLink(UserId("@it/sme:matrix.org")))
}
@Test
fun `a user id with a slash that is not a valid user id is not a user link`() {
val result = createParser().parse("https://example.org/foo/@it/sme")
assertThat(result).isInstanceOf(PermalinkData.FallbackLink::class.java)
}
private fun createParser() = DefaultPermalinkParser(
matrixToConverter = DefaultMatrixToConverter(),
)
}
@@ -133,6 +133,7 @@ class FakeMatrixClient(
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { },
override val contentScanner: ContentScanner? = null,
private val isShuttingDownResult: () -> Boolean = { false },
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
@@ -305,6 +306,9 @@ class FakeMatrixClient(
return knockRoomLambda(roomIdOrAlias, message, serverNames)
}
override val isShuttingDown: Boolean
get() = isShuttingDownResult()
// Mocks
fun givenCreateRoomResult(result: Result<RoomId>) {
@@ -62,10 +62,10 @@ class AndroidMediaPreProcessor(
/**
* Used for calculating `inSampleSize` for bitmaps.
*
* *Note*: Ideally, this should result in images of up to (but not included) 1280x1280 being sent. However, images with very different width and height
* *Note*: Ideally, this should result in images of up to (but not included) 2560x2560 being sent. However, images with very different width and height
* values may surpass this limit. (i.e.: an image of `480x3000px` would have `inSampleSize=1` and be sent as is).
*/
private const val IMAGE_SCALE_REF_SIZE = 640
private const val IMAGE_SCALE_REF_SIZE = 1280
private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP)
}
@@ -85,8 +85,9 @@ class AndroidMediaPreProcessor(
val result = when {
mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType)
mimeType.isMimeTypeImage() -> {
val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes
processImage(uri, mimeType, shouldBeCompressed)
val imageMimeType = resolveImageMimeType(uri, mimeType)
val shouldBeCompressed = mediaOptimizationConfig.compressImages && imageMimeType !in notCompressibleImageTypes
processImage(uri, imageMimeType, shouldBeCompressed)
}
mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, mediaOptimizationConfig.videoCompressionPreset)
mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType)
@@ -155,6 +156,19 @@ class AndroidMediaPreProcessor(
}
}
/**
* The declared mime type can be broader than the image actually is - a wildcard from a share intent, or a mislabelled file - and the encoder is picked
* from it. Re-encoding a transparent image as JPEG flattens its alpha channel to black, so resolve the real format from the image header first.
*/
private fun resolveImageMimeType(uri: Uri, declaredMimeType: String): String {
if (declaredMimeType == MimeTypes.Png || declaredMimeType == MimeTypes.Jpeg) return declaredMimeType
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
tryOrNull {
contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) }
}
return options.outMimeType ?: declaredMimeType
}
private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo {
Timber.d("Processing image ${uri.path.orEmpty().hash()}")
@@ -70,6 +70,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
mediaOptimizationConfig: MediaOptimizationConfig,
sdkIntVersion: Int = Build.VERSION_CODES.P,
deleteOriginal: Boolean = false,
mimeType: String = asset.mimeType,
): MediaUploadInfo {
val context = InstrumentationRegistry.getInstrumentation().context
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
@@ -81,7 +82,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
val file = getFileFromAssets(context, asset.filename)
val result = sut.process(
uri = file.toUri(),
mimeType = asset.mimeType,
mimeType = mimeType,
deleteOriginal = deleteOriginal,
mediaOptimizationConfig = mediaOptimizationConfig,
)
@@ -91,6 +92,52 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
return data
}
@Test
fun `a png shared with a wildcard mime type is still encoded as a png`() = runTest {
val mediaUploadInfo = process(
asset = assetImagePng,
mimeType = MimeTypes.Images,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
sdkIntVersion = Build.VERSION_CODES.Q,
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Png)
}
@Test
fun `a gif shared with a wildcard mime type is recognised and left uncompressed`() = runTest {
val mediaUploadInfo = process(
asset = assetAnimatedGif,
mimeType = MimeTypes.Images,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
sdkIntVersion = Build.VERSION_CODES.Q,
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Gif)
assertThat(info.imageInfo.size).isEqualTo(assetAnimatedGif.size)
}
@Test
fun `a file that is not an image keeps the wildcard mime type it was shared with`() = runTest {
val mediaUploadInfo = process(
asset = assetText,
mimeType = MimeTypes.Images,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
sdkIntVersion = Build.VERSION_CODES.Q,
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Images)
}
@Test
@Ignore("Ignore now that min API for enterprise is 33")
fun `test processing png`() = runTest {
@@ -208,10 +255,10 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
assertThat(info.thumbnailFile).isNotNull()
assertThat(info.imageInfo).isEqualTo(
ImageInfo(
height = 979,
width = 3006,
height = 1958,
width = 6012,
mimetype = MimeTypes.Jpeg,
size = 84_845,
size = 682_492,
ThumbnailInfo(height = 244, width = 751, mimetype = MimeTypes.Jpeg, size = 7_178),
thumbnailSource = null,
blurhash = "K07gBzX=j_D4xZjoaSe,s:"
@@ -233,10 +280,10 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
assertThat(info.thumbnailFile).isNull()
assertThat(info.imageInfo).isEqualTo(
ImageInfo(
height = 979,
width = 3_006,
height = 1_958,
width = 6_012,
mimetype = MimeTypes.Jpeg,
size = 84_845,
size = 682_492,
thumbnailInfo = null,
thumbnailSource = null,
blurhash = null,
+2 -2
View File
@@ -2,6 +2,6 @@
## Configuration
In order to make this module only know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module.
In order to make only this module know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module.
To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app/src/main`, build the project, and check the generated file `app/build/generated/res/google-services/<buildtype>/values/values.xml` to import the generated values into the `build.gradle.kts` files.
To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app`, build the project, and check the generated file `app/build/generated/res/google-services/<buildtype>/values/values.xml` to import the generated values into the `firebase.xml` files.
@@ -8,7 +8,6 @@
@file:Suppress("UnstableApiUsage")
import config.BuildTimeConfig
import extension.setupDependencyInjection
import extension.testCommonDependencies
import org.gradle.kotlin.dsl.withType
@@ -28,27 +27,10 @@ android {
buildTypes {
getByName("release") {
consumerProguardFiles("consumer-proguard-rules.pro")
resValue(
type = "string",
name = "google_app_id",
value = BuildTimeConfig.GOOGLE_APP_ID_RELEASE,
)
}
getByName("debug") {
resValue(
type = "string",
name = "google_app_id",
value = BuildTimeConfig.GOOGLE_APP_ID_DEBUG,
)
}
register("nightly") {
consumerProguardFiles("consumer-proguard-rules.pro")
matchingFallbacks += listOf("release")
resValue(
type = "string",
name = "google_app_id",
value = BuildTimeConfig.GOOGLE_APP_ID_NIGHTLY,
)
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="google_app_id" translatable="false" tools:ignore="UnusedResources">1:912726360885:android:def0a4e454042e9b00427c</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="google_app_id" translatable="false" tools:ignore="UnusedResources">1:912726360885:android:e17435e0beb0303000427c</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="google_app_id" translatable="false" tools:ignore="UnusedResources">1:912726360885:android:d097de99a4c23d2700427c</string>
</resources>
@@ -11,18 +11,14 @@ package config
object BuildTimeConfig {
const val APPLICATION_ID = "io.element.android.x"
const val APPLICATION_NAME = "Element X"
const val GOOGLE_APP_ID_RELEASE = "1:912726360885:android:d097de99a4c23d2700427c"
const val GOOGLE_APP_ID_DEBUG = "1:912726360885:android:def0a4e454042e9b00427c"
const val GOOGLE_APP_ID_NIGHTLY = "1:912726360885:android:e17435e0beb0303000427c"
val METADATA_HOST_REVERSED: String? = null
val OAUTH_CLIENT_URL_PATH: String? = "apps/android"
val URL_WEBSITE: String? = null
val URL_LOGO: String? = null
val URL_COPYRIGHT: String? = null
val URL_ACCEPTABLE_USE: String? = null
val URL_PRIVACY: String? = null
val URL_POLICY: String? = null
val OAUTH_CLIENT_URL_PATH: String? = "apps/android"
val SERVICES_MAPTILER_BASE_URL: String? = null
val SERVICES_MAPTILER_APIKEY: String? = null
val SERVICES_MAPTILER_LIGHT_MAPID: String? = null
@@ -33,7 +29,6 @@ object BuildTimeConfig {
val SERVICES_SENTRY_DSN_RUST: String? = null
val BUG_REPORT_URL: String? = null
val BUG_REPORT_APP_NAME: String? = null
const val PUSH_CONFIG_INCLUDE_FIREBASE = true
const val PUSH_CONFIG_INCLUDE_UNIFIED_PUSH = true
const val PUSH_CONFIG_INCLUDE_FIREBASE: Boolean = true
const val PUSH_CONFIG_INCLUDE_UNIFIED_PUSH: Boolean = true
}
-227
View File
@@ -1,227 +0,0 @@
#!/usr/bin/python3
# encoding: utf-8
# SPDX-FileCopyrightText: 2024 FC (Fay) Stegerman <flx@obfusk.net>
# SPDX-License-Identifier: GPL-3.0-or-later.
import hashlib
import os
import re
import struct
import zipfile
import zlib
from binascii import hexlify
from typing import Any, Dict, Match, Tuple
DEX_MAGIC = b"dex\n"
DEX_MAGIC_RE = re.compile(rb"dex\n(\d{3})\x00")
PROF_MAGIC = b"pro\x00"
PROF_010_P = b"010\x00"
CLASSES_DEX_RE = re.compile(r"classes\d*\.dex")
ASSET_PROF = "assets/dexopt/baseline.prof"
PG_MAP_ID_RE = re.compile(rb'(~~R8{"backend":"dex".*?"pg-map-id":")([0-9a-f]{7})(")')
ATTRS = ("compress_type", "create_system", "create_version", "date_time",
"external_attr", "extract_version", "flag_bits")
LEVELS = (9, 6, 4, 1)
class Error(RuntimeError):
pass
# FIXME: is there a better alternative?
class ReproducibleZipInfo(zipfile.ZipInfo):
"""Reproducible ZipInfo hack."""
if "_compresslevel" not in zipfile.ZipInfo.__slots__: # type: ignore[attr-defined]
if "compress_level" not in zipfile.ZipInfo.__slots__: # type: ignore[attr-defined]
raise Error("zipfile.ZipInfo has no ._compresslevel")
_compresslevel: int
_override: Dict[str, Any] = {}
def __init__(self, zinfo: zipfile.ZipInfo, **override: Any) -> None:
# pylint: disable=W0231
if override:
self._override = {**self._override, **override}
for k in self.__slots__:
if hasattr(zinfo, k):
setattr(self, k, getattr(zinfo, k))
def __getattribute__(self, name: str) -> Any:
if name != "_override":
try:
return self._override[name]
except KeyError:
pass
return object.__getattribute__(self, name)
def fix_pg_map_id(input_dir: str, output_dir: str, map_id: str) -> None:
file_data = {}
for filename in [ASSET_PROF] + sorted(os.listdir(input_dir)):
if re.fullmatch(CLASSES_DEX_RE, filename) or filename == ASSET_PROF:
print(f"reading {filename!r}...")
with open(os.path.join(input_dir, *filename.split("/")), "rb") as fh:
file_data[filename] = fh.read()
_fix_pg_map_id(file_data, map_id)
for filename, data in file_data.items():
print(f"writing {filename!r}...")
if "/" in filename:
os.makedirs(os.path.join(output_dir, *filename.split("/")[:-1]), exist_ok=True)
with open(os.path.join(output_dir, *filename.split("/")), "wb") as fh:
fh.write(data)
def fix_pg_map_id_apk(input_apk: str, output_apk: str, map_id: str) -> None:
with open(input_apk, "rb") as fh_raw:
with zipfile.ZipFile(input_apk) as zf_in:
with zipfile.ZipFile(output_apk, "w") as zf_out:
file_data = {}
for info in zf_in.infolist():
if re.fullmatch(CLASSES_DEX_RE, info.filename) or info.filename == ASSET_PROF:
print(f"reading {info.filename!r}...")
file_data[info.filename] = zf_in.read(info)
_fix_pg_map_id(file_data, map_id)
for info in zf_in.infolist():
attrs = {attr: getattr(info, attr) for attr in ATTRS}
zinfo = ReproducibleZipInfo(info, **attrs)
if info.compress_type == 8:
fh_raw.seek(info.header_offset)
n, m = struct.unpack("<HH", fh_raw.read(30)[26:30])
fh_raw.seek(info.header_offset + 30 + m + n)
ccrc = 0
size = info.compress_size
while size > 0:
ccrc = zlib.crc32(fh_raw.read(min(size, 4096)), ccrc)
size -= 4096
with zf_in.open(info) as fh_in:
comps = {lvl: zlib.compressobj(lvl, 8, -15) for lvl in LEVELS}
ccrcs = {lvl: 0 for lvl in LEVELS}
while True:
data = fh_in.read(4096)
if not data:
break
for lvl in LEVELS:
ccrcs[lvl] = zlib.crc32(comps[lvl].compress(data), ccrcs[lvl])
for lvl in LEVELS:
if ccrc == zlib.crc32(comps[lvl].flush(), ccrcs[lvl]):
zinfo._compresslevel = lvl
break
else:
raise Error(f"Unable to determine compresslevel for {info.filename!r}")
elif info.compress_type != 0:
raise Error(f"Unsupported compress_type {info.compress_type}")
if re.fullmatch(CLASSES_DEX_RE, info.filename) or info.filename == ASSET_PROF:
print(f"writing {info.filename!r}...")
zf_out.writestr(zinfo, file_data[info.filename])
else:
with zf_in.open(info) as fh_in:
with zf_out.open(zinfo, "w") as fh_out:
while True:
data = fh_in.read(4096)
if not data:
break
fh_out.write(data)
def _fix_pg_map_id(file_data: Dict[str, bytes], map_id: str) -> None:
crcs = {}
for filename in file_data:
if re.fullmatch(CLASSES_DEX_RE, filename):
print(f"fixing {filename!r}...")
data = _fix_dex_id_checksum(file_data[filename], map_id.encode())
file_data[filename] = data
crcs[filename] = zlib.crc32(data)
if ASSET_PROF in file_data:
print(f"fixing {ASSET_PROF!r}...")
file_data[ASSET_PROF] = _fix_prof_checksum(file_data[ASSET_PROF], crcs)
def _fix_dex_id_checksum(data: bytes, map_id: bytes) -> bytes:
def repl(m: Match[bytes]) -> bytes:
print(f"fixing pg-map-id: {m.group(2)!r} -> {map_id!r}")
return m.group(1) + map_id + m.group(3)
magic = data[:8]
if magic[:4] != DEX_MAGIC or not DEX_MAGIC_RE.fullmatch(magic):
raise Error(f"Unsupported magic {magic!r}")
print(f"dex version={int(magic[4:7]):03d}")
checksum, signature = struct.unpack("<I20s", data[8:32])
fixed_data = re.sub(PG_MAP_ID_RE, repl, data[32:])
if fixed_data == data[32:]:
print("(not modified)")
return data
fixed_sig = hashlib.sha1(fixed_data).digest()
print(f"fixing signature: {hexlify(signature).decode()} -> {hexlify(fixed_sig).decode()}")
fixed_data = fixed_sig + fixed_data
fixed_checksum = zlib.adler32(fixed_data)
print(f"fixing checksum: 0x{checksum:x} -> 0x{fixed_checksum:x}")
return magic + int.to_bytes(fixed_checksum, 4, "little") + fixed_data
def _fix_prof_checksum(data: bytes, crcs: Dict[str, int]) -> bytes:
magic, data = _split(data, 4)
version, data = _split(data, 4)
if magic == PROF_MAGIC:
if version == PROF_010_P:
print("prof version=010 P")
return PROF_MAGIC + PROF_010_P + _fix_prof_010_p_checksum(data, crcs)
else:
raise Error(f"Unsupported prof version {version!r}")
else:
raise Error(f"Unsupported magic {magic!r}")
def _fix_prof_010_p_checksum(data: bytes, crcs: Dict[str, int]) -> bytes:
num_dex_files, uncompressed_data_size, compressed_data_size, data = _unpack("<BII", data)
dex_data_headers = []
if len(data) != compressed_data_size:
raise Error("Compressed data size does not match")
data = zlib.decompress(data)
if len(data) != uncompressed_data_size:
raise Error("Uncompressed data size does not match")
for i in range(num_dex_files):
profile_key_size, num_type_ids, hot_method_region_size, \
dex_checksum, num_method_ids, data = _unpack("<HHIII", data)
profile_key, data = _split(data, profile_key_size)
filename = profile_key.decode()
fixed_checksum = crcs[filename]
if fixed_checksum != dex_checksum:
print(f"fixing {filename!r} checksum: 0x{dex_checksum:x} -> 0x{fixed_checksum:x}")
dex_data_headers.append(struct.pack(
"<HHIII", profile_key_size, num_type_ids, hot_method_region_size,
fixed_checksum, num_method_ids) + profile_key)
fixed_data = b"".join(dex_data_headers) + data
fixed_cdata = zlib.compress(fixed_data, 1)
fixed_hdr = struct.pack("<BII", num_dex_files, uncompressed_data_size, len(fixed_cdata))
return fixed_hdr + fixed_cdata
def _unpack(fmt: str, data: bytes) -> Any:
assert all(c in "<BHI" for c in fmt)
size = fmt.count("B") + 2 * fmt.count("H") + 4 * fmt.count("I")
return struct.unpack(fmt, data[:size]) + (data[size:],)
def _split(data: bytes, size: int) -> Tuple[bytes, bytes]:
return data[:size], data[size:]
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(prog="fix-pg-map-id.py")
parser.add_argument("input_dir_or_apk", metavar="INPUT_DIR_OR_APK")
parser.add_argument("output_dir_or_apk", metavar="OUTPUT_DIR_OR_APK")
parser.add_argument("pg_map_id", metavar="PG_MAP_ID")
args = parser.parse_args()
if os.path.isdir(args.input_dir_or_apk):
fix_pg_map_id(args.input_dir_or_apk, args.output_dir_or_apk, args.pg_map_id)
else:
fix_pg_map_id_apk(args.input_dir_or_apk, args.output_dir_or_apk, args.pg_map_id)
# vim: set tw=80 sw=4 sts=4 et fdm=marker :
-199
View File
@@ -1,199 +0,0 @@
#!/usr/bin/python3
# encoding: utf-8
# SPDX-FileCopyrightText: 2024 FC (Fay) Stegerman <flx@obfusk.net>
# SPDX-License-Identifier: GPL-3.0-or-later.
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
from typing import Optional, Tuple
COMMANDS = (
"fix-compresslevel",
"fix-files",
"fix-newlines",
"fix-pg-map-id",
"rm-files",
"sort-apk",
"sort-baseline",
)
BUILD_TOOLS_WITH_BROKEN_ZIPALIGN = ("31.0.0", "32.0.0")
BUILD_TOOLS_WITH_PAGE_SIZE_FROM = "35.0.0-rc1"
SDK_ENV = ("ANDROID_HOME", "ANDROID_SDK", "ANDROID_SDK_ROOT")
def _zipalign_cmd(page_align: bool, page_size: Optional[int]) -> Tuple[str, ...]:
if page_align:
if page_size is not None:
return ("zipalign", "-P", str(page_size), "4")
return ("zipalign", "-p", "4")
return ("zipalign", "4")
ZIPALIGN = _zipalign_cmd(page_align=False, page_size=None)
ZIPALIGN_P = _zipalign_cmd(page_align=True, page_size=None)
class Error(RuntimeError):
pass
def inplace_fix(command: str, input_file: str, *args: str,
zipalign: bool = False, page_align: bool = False,
page_size: Optional[int] = None, internal: bool = False) -> None:
if command not in COMMANDS:
raise Error(f"Unknown command {command}")
exe, script = _script_cmd(command)
ext = os.path.splitext(input_file)[1]
with tempfile.TemporaryDirectory() as tdir:
fixed = os.path.join(tdir, "fixed" + ext)
run_command(exe, script, input_file, fixed, *args, trim=2)
if zipalign:
aligned = os.path.join(tdir, "aligned" + ext)
zac = zipalign_cmd(page_align=page_align, page_size=page_size, internal=internal)
run_command(*zac, fixed, aligned, trim=2)
print(f"[MOVE] {aligned} to {input_file}")
shutil.move(aligned, input_file)
else:
print(f"[MOVE] {fixed} to {input_file}")
shutil.move(fixed, input_file)
def zipalign_cmd(page_align: bool = False, page_size: Optional[int] = None,
internal: bool = False) -> Tuple[str, ...]:
"""
Find zipalign command using $PATH or $ANDROID_HOME etc.
>>> zipalign_cmd()
('zipalign', '4')
>>> zipalign_cmd(page_align=True)
('zipalign', '-p', '4')
>>> zipalign_cmd(page_align=True, page_size=16)
('zipalign', '-P', '16', '4')
>>> cmd = zipalign_cmd(page_align=True, page_size=16, internal=True)
>>> [x.split("/")[-1] for x in cmd]
['python3', 'zipalign.py', '-P', '16', '4']
>>> os.environ["PATH"] = ""
>>> for k in SDK_ENV:
... os.environ[k] = ""
>>> cmd = zipalign_cmd()
>>> [x.split("/")[-1] for x in cmd]
['python3', 'zipalign.py', '4']
>>> os.environ["ANDROID_HOME"] = "test/fake-sdk"
>>> zipalign_cmd()
[SKIP BROKEN] 31.0.0
[FOUND] test/fake-sdk/build-tools/30.0.3/zipalign
('test/fake-sdk/build-tools/30.0.3/zipalign', '4')
>>> cmd = zipalign_cmd(page_align=True, page_size=16)
[SKIP TOO OLD] 31.0.0
[SKIP TOO OLD] 30.0.3
[SKIP TOO OLD] 26.0.2
>>> [x.split("/")[-1] for x in cmd]
['python3', 'zipalign.py', '-P', '16', '4']
>>> os.environ["ANDROID_HOME"] = "test/fake-sdk-2"
>>> zipalign_cmd(page_align=True, page_size=16)
[FOUND] test/fake-sdk-2/build-tools/35.0.0-rc1/zipalign
('test/fake-sdk-2/build-tools/35.0.0-rc1/zipalign', '-P', '16', '4')
"""
cmd, *args = _zipalign_cmd(page_align, page_size)
if not internal:
if shutil.which(cmd):
return (cmd, *args)
for k in SDK_ENV:
if home := os.environ.get(k):
tools = os.path.join(home, "build-tools")
if os.path.exists(tools):
for vsn in sorted(os.listdir(tools), key=_vsn, reverse=True):
if page_size and _vsn(vsn) < _vsn(BUILD_TOOLS_WITH_PAGE_SIZE_FROM):
print(f"[SKIP TOO OLD] {vsn}")
continue
for s in BUILD_TOOLS_WITH_BROKEN_ZIPALIGN:
if vsn.startswith(s):
print(f"[SKIP BROKEN] {vsn}")
break
else:
c = os.path.join(tools, vsn, cmd)
if shutil.which(c):
print(f"[FOUND] {c}")
return (c, *args)
return (*_script_cmd(cmd), *args)
def _vsn(v: str) -> Tuple[int, ...]:
"""
>>> vs = "31.0.0 32.1.0-rc1 34.0.0-rc3 34.0.0 35.0.0-rc1".split()
>>> for v in sorted(vs, key=_vsn, reverse=True):
... (_vsn(v), v)
((35, 0, 0, 0, 1), '35.0.0-rc1')
((34, 0, 0, 1, 0), '34.0.0')
((34, 0, 0, 0, 3), '34.0.0-rc3')
((32, 1, 0, 0, 1), '32.1.0-rc1')
((31, 0, 0, 1, 0), '31.0.0')
"""
if "-rc" in v:
v = v.replace("-rc", ".0.", 1)
else:
v = v + ".1.0"
return tuple(int(x) if x.isdigit() else -1 for x in v.split("."))
def _script_cmd(command: str) -> Tuple[str, str]:
script_dir = os.path.dirname(__file__)
for cmd in (command, command.replace("-", "_")):
script = os.path.join(script_dir, cmd + ".py")
if os.path.exists(script):
break
else:
raise Error(f"Script for {command} not found")
exe = sys.executable or "python3"
return exe, script
def run_command(*args: str, trim: int = 1) -> None:
targs = tuple(os.path.basename(a) for a in args[:trim]) + args[trim:]
print(f"[RUN] {' '.join(targs)}")
try:
subprocess.run(args, check=True)
except subprocess.CalledProcessError as e:
raise Error(f"{args[0]} command failed") from e
except FileNotFoundError as e:
raise Error(f"{args[0]} command not found") from e
def main() -> None:
prog = os.path.basename(sys.argv[0])
usage = (f"{prog} [-h] [--zipalign] [--page-align] [--page-size N] [--internal]\n"
f"{len('usage: ' + prog) * ' '} COMMAND INPUT_FILE [...]")
epilog = f"Commands: {', '.join(COMMANDS)}."
parser = argparse.ArgumentParser(usage=usage, epilog=epilog)
parser.add_argument("--zipalign", action="store_true",
help="run zipalign after COMMAND")
parser.add_argument("--page-align", action="store_true",
help="run zipalign w/ -p option (implies --zipalign)")
parser.add_argument("--page-size", metavar="N", type=int,
help="run zipalign w/ -P N option (implies --page-align)")
parser.add_argument("--internal", action="store_true",
help="use zipalign.py instead of searching $PATH/$ANDROID_HOME/etc.")
parser.add_argument("command", metavar="COMMAND")
parser.add_argument("input_file", metavar="INPUT_FILE")
args, rest = parser.parse_known_args()
try:
inplace_fix(args.command, args.input_file, *rest,
zipalign=bool(args.zipalign or args.page_align or args.page_size),
page_align=bool(args.page_align or args.page_size),
page_size=args.page_size, internal=args.internal)
except Error as e:
print(f"Error: {e}.", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
# vim: set tw=80 sw=4 sts=4 et fdm=marker :
-9
View File
@@ -225,15 +225,6 @@ printf "Unzipping the F-Droid artifact...\n"
fdroidTargetPath="${targetPath}/fdroid"
unzip "${targetPath}"/elementx-app-fdroid-apks-unsigned.zip -d "${fdroidTargetPath}"
printf "\n================================================================================\n"
printf "Patching the FDroid APKs using inplace-fix.py...\n"
inplaceFixScript="./tools/release/inplace-fix.py"
python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-arm64-v8a-release.apk '0000000'
python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release.apk '0000000'
python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-x86-release.apk '0000000'
python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-x86_64-release.apk '0000000'
printf "\n================================================================================\n"
printf "Signing the FDroid APKs...\n"