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) { private fun onNewMatrixClient(matrixClient: MatrixClient) {
val syncOrchestrator = syncOrchestratorFactory.create( val syncOrchestrator = syncOrchestratorFactory.create(
syncService = matrixClient.syncService, matrixClient = matrixClient,
sessionCoroutineScope = matrixClient.sessionCoroutineScope, sessionCoroutineScope = matrixClient.sessionCoroutineScope,
) )
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession( 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.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope 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.libraries.matrix.api.sync.SyncState
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.recordTransaction import io.element.android.services.analytics.api.recordTransaction
@@ -37,7 +37,7 @@ import kotlin.time.Duration.Companion.seconds
@AssistedInject @AssistedInject
class SyncOrchestrator( class SyncOrchestrator(
@Assisted private val syncService: SyncService, @Assisted private val matrixClient: MatrixClient,
@Assisted sessionCoroutineScope: CoroutineScope, @Assisted sessionCoroutineScope: CoroutineScope,
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
@@ -47,13 +47,15 @@ class SyncOrchestrator(
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(
syncService: SyncService, matrixClient: MatrixClient,
sessionCoroutineScope: CoroutineScope, sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator ): SyncOrchestrator
} }
private val tag = "SyncOrchestrator" private val tag = "SyncOrchestrator"
private val syncService = matrixClient.syncService
private val coroutineScope = sessionCoroutineScope.childScope(dispatchers.io, tag) private val coroutineScope = sessionCoroutineScope.childScope(dispatchers.io, tag)
private val started = AtomicBoolean(false) private val started = AtomicBoolean(false)
@@ -94,7 +96,7 @@ class SyncOrchestrator(
appForegroundStateService.isInCall, appForegroundStateService.isInCall,
appForegroundStateService.isSyncingNotificationEvent, appForegroundStateService.isSyncingNotificationEvent,
appForegroundStateService.hasRingingCall, appForegroundStateService.hasRingingCall,
appForegroundStateService.isSharingLiveLocation appForegroundStateService.isSharingLiveLocation,
) )
val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } } val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } }
combine( combine(
@@ -103,6 +105,11 @@ class SyncOrchestrator(
networkMonitor.connectivity, networkMonitor.connectivity,
isAppActiveFlow, isAppActiveFlow,
) { syncState, networkState, isAppActive -> ) { 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 val isNetworkAvailable = networkState == NetworkStatus.Connected
Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable") 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.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.sync.SyncState 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.libraries.matrix.test.sync.FakeSyncService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
@@ -40,7 +41,7 @@ class SyncOrchestratorTest {
} }
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val syncOrchestrator = createSyncOrchestrator( val syncOrchestrator = createSyncOrchestrator(
syncService = syncService, matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
) )
@@ -60,7 +61,7 @@ class SyncOrchestratorTest {
} }
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val syncOrchestrator = createSyncOrchestrator( val syncOrchestrator = createSyncOrchestrator(
syncService = syncService, matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
) )
@@ -85,7 +86,7 @@ class SyncOrchestratorTest {
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true) val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator( val syncOrchestrator = createSyncOrchestrator(
syncService = syncService, matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService, appForegroundStateService = appForegroundStateService,
) )
@@ -117,7 +118,7 @@ class SyncOrchestratorTest {
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true) val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator( val syncOrchestrator = createSyncOrchestrator(
syncService = syncService, matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService, appForegroundStateService = appForegroundStateService,
) )
@@ -165,7 +166,7 @@ class SyncOrchestratorTest {
initialIsSyncingNotificationEventValue = false, initialIsSyncingNotificationEventValue = false,
) )
val syncOrchestrator = createSyncOrchestrator( val syncOrchestrator = createSyncOrchestrator(
syncService = syncService, matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService, appForegroundStateService = appForegroundStateService,
) )
@@ -208,7 +209,7 @@ class SyncOrchestratorTest {
initialIsSyncingNotificationEventValue = false, initialIsSyncingNotificationEventValue = false,
) )
val syncOrchestrator = createSyncOrchestrator( val syncOrchestrator = createSyncOrchestrator(
syncService = syncService, matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService, appForegroundStateService = appForegroundStateService,
) )
@@ -252,7 +253,7 @@ class SyncOrchestratorTest {
initialHasRingingCall = false, initialHasRingingCall = false,
) )
val syncOrchestrator = createSyncOrchestrator( val syncOrchestrator = createSyncOrchestrator(
syncService = syncService, matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService, appForegroundStateService = appForegroundStateService,
) )
@@ -296,7 +297,7 @@ class SyncOrchestratorTest {
initialIsInCallValue = true, initialIsInCallValue = true,
) )
val syncOrchestrator = createSyncOrchestrator( val syncOrchestrator = createSyncOrchestrator(
syncService = syncService, matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService, appForegroundStateService = appForegroundStateService,
) )
@@ -339,7 +340,7 @@ class SyncOrchestratorTest {
initialIsInCallValue = false, initialIsInCallValue = false,
) )
val syncOrchestrator = createSyncOrchestrator( val syncOrchestrator = createSyncOrchestrator(
syncService = syncService, matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService, appForegroundStateService = appForegroundStateService,
) )
@@ -369,7 +370,7 @@ class SyncOrchestratorTest {
} }
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val syncOrchestrator = createSyncOrchestrator( val syncOrchestrator = createSyncOrchestrator(
syncService = syncService, matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
) )
@@ -381,12 +382,44 @@ class SyncOrchestratorTest {
startSyncRecorder.assertions().isNeverCalled() 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( private fun TestScope.createSyncOrchestrator(
syncService: FakeSyncService = FakeSyncService(), matrixClient: FakeMatrixClient = FakeMatrixClient(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(), appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
) = SyncOrchestrator( ) = SyncOrchestrator(
syncService = syncService, matrixClient = matrixClient,
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService, appForegroundStateService = appForegroundStateService,
@@ -11,7 +11,7 @@ package io.element.android.appnav.di
import com.bumble.appyx.core.state.MutableSavedStateMapImpl import com.bumble.appyx.core.state.MutableSavedStateMapImpl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor 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.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
@@ -138,11 +138,11 @@ class MatrixSessionCacheTest {
return object : SyncOrchestrator.Factory { return object : SyncOrchestrator.Factory {
override fun create( override fun create(
syncService: SyncService, matrixClient: MatrixClient,
sessionCoroutineScope: CoroutineScope, sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator { ): SyncOrchestrator {
return SyncOrchestrator( return SyncOrchestrator(
syncService = syncService, matrixClient = matrixClient,
sessionCoroutineScope = sessionCoroutineScope, sessionCoroutineScope = sessionCoroutineScope,
appForegroundStateService = FakeAppForegroundStateService(), appForegroundStateService = FakeAppForegroundStateService(),
networkMonitor = FakeNetworkMonitor(), 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 { private fun String.sanitize(): String {
return replace("\n", "") return filterNot { it == '\n' || it == '\r' }
} }
@PreviewsDayNight @PreviewsDayNight
@@ -60,7 +60,13 @@ class LeaveRoomPresenter(
roomId: RoomId, roomId: RoomId,
leaveAction: MutableState<AsyncAction<Unit>>, leaveAction: MutableState<AsyncAction<Unit>>,
) = launch(dispatchers.io) { ) = 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() val roomInfo = room.roomInfoFlow.first()
leaveAction.value = when { leaveAction.value = when {
roomInfo.isDm -> Confirmation.Dm(roomId) roomInfo.isDm -> Confirmation.Dm(roomId)
@@ -78,7 +84,8 @@ class LeaveRoomPresenter(
leaveAction: MutableState<AsyncAction<Unit>>, leaveAction: MutableState<AsyncAction<Unit>>,
) = launch(dispatchers.io) { ) = launch(dispatchers.io) {
leaveAction.runCatchingUpdatingState { leaveAction.runCatchingUpdatingState {
client.getRoom(roomId)!!.use { room -> val room = client.getRoom(roomId) ?: throw RoomNotFoundException(roomId)
room.use {
room room
.leave() .leave()
.onSuccess { notificationConversationService.onLeftRoom(client.sessionId, roomId) } .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 @Test
fun `present - reset state after error`() = runTest { fun `present - reset state after error`() = runTest {
val presenter = createLeaveRoomPresenter( val presenter = createLeaveRoomPresenter(
@@ -39,6 +39,10 @@ class LinkNewDesktopHandler(
LinkDesktopStep.Uninitialized 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> val stepFlow: StateFlow<LinkDesktopStep>
get() = linkDesktopStepFlow.asStateFlow() get() = linkDesktopStepFlow.asStateFlow()
@@ -53,15 +57,31 @@ class LinkNewDesktopHandler(
timerJob = null timerJob = null
} }
fun createNewHandler() { /**
resetJobs() * Starts (or restarts) a timer that will emit an error in [stepFlow] after 2 minutes.
handler = matrixClient.createLinkDesktopHandler().getOrNull() */
fun startTimer() {
timerJob?.cancel()
timerJob = sessionScope.launch { timerJob = sessionScope.launch {
delay(2.minutes) delay(2.minutes)
linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long."))) 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() { fun reset() {
resetJobs() resetJobs()
sessionScope.launch { 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) { fun onScannedCode(data: ByteArray) {
resetJobs() resetJobs()
val currentHandler = handler val currentHandler = handler
@@ -263,6 +263,8 @@ class LinkNewDeviceFlowNode(
} }
LinkDeviceType.Desktop -> { LinkDeviceType.Desktop -> {
linkNewDesktopHandler.reset() 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) backstack.push(NavTarget.DesktopNotice)
} }
} }
@@ -278,10 +280,14 @@ class LinkNewDeviceFlowNode(
NavTarget.DesktopNotice -> { NavTarget.DesktopNotice -> {
val callback = object : DesktopNoticeNode.Callback { val callback = object : DesktopNoticeNode.Callback {
override fun navigateBack() { override fun navigateBack() {
// Stop the timer when the user leaves the screen (user will have to authenticate again)
linkNewDesktopHandler.reset()
backstack.pop() backstack.pop()
} }
override fun navigateToQrCodeScanner() { 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) backstack.push(NavTarget.DesktopScanQrCode)
} }
} }
@@ -290,6 +296,8 @@ class LinkNewDeviceFlowNode(
NavTarget.DesktopScanQrCode -> { NavTarget.DesktopScanQrCode -> {
val callback = object : ScanQrCodeNode.Callback { val callback = object : ScanQrCodeNode.Callback {
override fun cancel() { override fun cancel() {
// start again the timer when the user goes back to the DesktopNoticeNode
linkNewDesktopHandler.startTimer()
backstack.pop() backstack.pop()
} }
} }
@@ -40,9 +40,19 @@ class LinkNewMobileHandler(
LinkMobileStep.Uninitialized 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> val stepFlow: StateFlow<LinkMobileStep>
get() = linkMobileStepFlow.asStateFlow() 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) { fun createAndStartNewHandler(forRotating: Boolean = false) {
Timber.tag(loggerTag.value).d("createAndStartNewHandler()") Timber.tag(loggerTag.value).d("createAndStartNewHandler()")
currentJob?.cancel() 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() { fun reset() {
currentJob?.cancel() currentJob?.cancel()
currentJob = null 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() { fun rotateQrCode() {
createAndStartNewHandler(forRotating = true) 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() { fun onTooManyRotation() {
reset() reset()
sessionScope.launch { 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.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.form.textFieldState 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.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -191,6 +192,7 @@ private fun LoginForm(
enabled = !isLoading, enabled = !isLoading,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.bringIntoViewOnImeVisible()
.onTabOrEnterKeyFocusNext(focusManager) .onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginEmailUsername) .testTag(TestTags.loginEmailUsername)
.semantics { .semantics {
@@ -243,6 +245,7 @@ private fun LoginForm(
enabled = !isLoading, enabled = !isLoading,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.bringIntoViewOnImeVisible()
.onTabOrEnterKeyFocusNext(focusManager) .onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword) .testTag(TestTags.loginPassword)
.semantics { .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 { private fun String.sanitize(): String {
return replace("\n", "") return filterNot { it == '\n' || it == '\r' }
} }
@Composable @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 @Test
fun `clearing login invokes the expected event`() = runAndroidComposeUiTest { fun `clearing login invokes the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>() 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 @Test
fun `reveal password makes the password visible`() = runAndroidComposeUiTest { fun `reveal password makes the password visible`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false) 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.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion 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.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import io.element.android.services.analytics.api.AnalyticsService 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.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -459,6 +462,14 @@ class MessageComposerPresenter(
val mentionTriggerFlow = merge(mentionStartTrigger, mentionCompletionTrigger) 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 val roomAliasSuggestionsFlow = roomAliasSuggestionsDataSource
.getAllRoomAliasSuggestions() .getAllRoomAliasSuggestions()
.stateIn(this, SharingStarted.Lazily, emptyList()) .stateIn(this, SharingStarted.Lazily, emptyList())
@@ -1056,7 +1056,8 @@ class MessageComposerPresenterTest : RobolectricTest() {
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions( roomPermissions = FakeRoomPermissions(
canTriggerRoomNotification = true, canTriggerRoomNotification = true,
) ),
updateMembersResult = {},
), ),
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
).apply { ).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 @Test
fun `present - room mention suggestions no permission`() = runTest { fun `present - room mention suggestions no permission`() = runTest {
val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN) val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
@@ -1113,7 +1163,8 @@ class MessageComposerPresenterTest : RobolectricTest() {
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions( roomPermissions = FakeRoomPermissions(
canTriggerRoomNotification = false, canTriggerRoomNotification = false,
) ),
updateMembersResult = {},
), ),
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
).apply { ).apply {
@@ -1144,6 +1195,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(canTriggerRoomNotification = true), roomPermissions = FakeRoomPermissions(canTriggerRoomNotification = true),
updateMembersResult = {},
), ),
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
).apply { ).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 # 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 # 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. # 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 # Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } 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. */ /** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */
val contentScanner: ContentScanner? 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]. * 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. * 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 timber.log.Timber
import java.io.File import java.io.File
import java.util.Optional import java.util.Optional
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -304,6 +305,10 @@ class RustMatrixClient(
.buffer(Channel.UNLIMITED) .buffer(Channel.UNLIMITED)
.stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf()) .stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf())
private val _isShuttingDown = AtomicBoolean(false)
override val isShuttingDown: Boolean
get() = _isShuttingDown.get()
init { init {
// Make sure the session delegate has a reference to the client to be able to logout on auth error // Make sure the session delegate has a reference to the client to be able to logout on auth error
sessionDelegate.bindClient(this) sessionDelegate.bindClient(this)
@@ -666,11 +671,13 @@ class RustMatrixClient(
} }
override suspend fun clearCache() { override suspend fun clearCache() {
_isShuttingDown.set(true)
innerClient.clearCaches(innerSyncService) innerClient.clearCaches(innerSyncService)
destroy() destroy()
} }
override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) { override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) {
_isShuttingDown.set(true)
sessionCoroutineScope.cancel() sessionCoroutineScope.cancel()
// Remove current delegate so we don't receive an auth error // Remove current delegate so we don't receive an auth error
clientDelegateTaskHandle?.cancelAndDestroy() clientDelegateTaskHandle?.cancelAndDestroy()
@@ -708,6 +715,8 @@ class RustMatrixClient(
} }
override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result<Unit> = withContext(sessionDispatcher) { override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result<Unit> = withContext(sessionDispatcher) {
_isShuttingDown.set(true)
Timber.w("Deactivating account") Timber.w("Deactivating account")
// Remove current delegate so we don't receive an auth error // Remove current delegate so we don't receive an auth error
clientDelegateTaskHandle?.cancelAndDestroy() clientDelegateTaskHandle?.cancelAndDestroy()
@@ -29,6 +29,7 @@ fun Throwable.mapAuthenticationException(): AuthenticationException {
is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message) is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message)
is ClientBuildException.EventCache -> AuthenticationException.Generic(message) is ClientBuildException.EventCache -> AuthenticationException.Generic(message)
is ClientBuildException.InvalidRawKey -> AuthenticationException.Generic(message) is ClientBuildException.InvalidRawKey -> AuthenticationException.Generic(message)
is ClientBuildException.WellKnownLookupDisabled -> AuthenticationException.Generic(message)
} }
is OAuthException -> when (this) { is OAuthException -> when (this) {
is OAuthException.Generic -> AuthenticationException.OAuth(message) is OAuthException.Generic -> AuthenticationException.OAuth(message)
@@ -8,11 +8,13 @@
package io.element.android.libraries.matrix.impl.permalink package io.element.android.libraries.matrix.impl.permalink
import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions 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.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.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@@ -51,6 +53,10 @@ class DefaultPermalinkParser(
matrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri) matrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri)
} }
matrixToUri.userIdWithSlash()?.let { userId ->
return PermalinkData.UserLink(userId = userId)
}
val result = runCatchingExceptions { val result = runCatchingExceptions {
parseMatrixEntityFrom(matrixToUri.toString()) parseMatrixEntityFrom(matrixToUri.toString())
}.getOrNull() }.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.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.UploadParameters import org.matrix.rustcomponents.sdk.UploadParameters
import org.matrix.rustcomponents.sdk.UploadSource import org.matrix.rustcomponents.sdk.UploadSource
import org.matrix.rustcomponents.sdk.createCaptionEdit
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
import uniffi.matrix_sdk.PaginationStatus import uniffi.matrix_sdk.PaginationStatus
@@ -341,7 +342,7 @@ class RustTimeline(
formattedCaption: String?, formattedCaption: String?,
): Result<Unit> = withContext(dispatcher) { ): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions<Unit> { runCatchingExceptions<Unit> {
val editedContent = EditedContent.MediaCaption( val editedContent = createCaptionEdit(
caption = caption, caption = caption,
formattedCaption = formattedCaption?.let { formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html) FormattedBody(body = it, format = MessageFormat.Html)
@@ -365,7 +366,7 @@ class RustTimeline(
fromNotification: Boolean, fromNotification: Boolean,
msgType: MsgType, msgType: MsgType,
): Result<Unit> = withContext(dispatcher) { ): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions { runCatchingExceptions<Unit> {
val msg = MessageEventContent.from( val msg = MessageEventContent.from(
body = body, body = body,
htmlBody = htmlBody, 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 resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { }, private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { },
override val contentScanner: ContentScanner? = null, override val contentScanner: ContentScanner? = null,
private val isShuttingDownResult: () -> Boolean = { false },
) : MatrixClient { ) : MatrixClient {
var setDisplayNameCalled: Boolean = false var setDisplayNameCalled: Boolean = false
private set private set
@@ -305,6 +306,9 @@ class FakeMatrixClient(
return knockRoomLambda(roomIdOrAlias, message, serverNames) return knockRoomLambda(roomIdOrAlias, message, serverNames)
} }
override val isShuttingDown: Boolean
get() = isShuttingDownResult()
// Mocks // Mocks
fun givenCreateRoomResult(result: Result<RoomId>) { fun givenCreateRoomResult(result: Result<RoomId>) {
@@ -62,10 +62,10 @@ class AndroidMediaPreProcessor(
/** /**
* Used for calculating `inSampleSize` for bitmaps. * 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). * 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) private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP)
} }
@@ -85,8 +85,9 @@ class AndroidMediaPreProcessor(
val result = when { val result = when {
mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType) mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType)
mimeType.isMimeTypeImage() -> { mimeType.isMimeTypeImage() -> {
val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes val imageMimeType = resolveImageMimeType(uri, mimeType)
processImage(uri, mimeType, shouldBeCompressed) val shouldBeCompressed = mediaOptimizationConfig.compressImages && imageMimeType !in notCompressibleImageTypes
processImage(uri, imageMimeType, shouldBeCompressed)
} }
mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, mediaOptimizationConfig.videoCompressionPreset) mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, mediaOptimizationConfig.videoCompressionPreset)
mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType) 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 { private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo {
Timber.d("Processing image ${uri.path.orEmpty().hash()}") Timber.d("Processing image ${uri.path.orEmpty().hash()}")
@@ -70,6 +70,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
mediaOptimizationConfig: MediaOptimizationConfig, mediaOptimizationConfig: MediaOptimizationConfig,
sdkIntVersion: Int = Build.VERSION_CODES.P, sdkIntVersion: Int = Build.VERSION_CODES.P,
deleteOriginal: Boolean = false, deleteOriginal: Boolean = false,
mimeType: String = asset.mimeType,
): MediaUploadInfo { ): MediaUploadInfo {
val context = InstrumentationRegistry.getInstrumentation().context val context = InstrumentationRegistry.getInstrumentation().context
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
@@ -81,7 +82,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
val file = getFileFromAssets(context, asset.filename) val file = getFileFromAssets(context, asset.filename)
val result = sut.process( val result = sut.process(
uri = file.toUri(), uri = file.toUri(),
mimeType = asset.mimeType, mimeType = mimeType,
deleteOriginal = deleteOriginal, deleteOriginal = deleteOriginal,
mediaOptimizationConfig = mediaOptimizationConfig, mediaOptimizationConfig = mediaOptimizationConfig,
) )
@@ -91,6 +92,52 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
return data 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 @Test
@Ignore("Ignore now that min API for enterprise is 33") @Ignore("Ignore now that min API for enterprise is 33")
fun `test processing png`() = runTest { fun `test processing png`() = runTest {
@@ -208,10 +255,10 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
assertThat(info.thumbnailFile).isNotNull() assertThat(info.thumbnailFile).isNotNull()
assertThat(info.imageInfo).isEqualTo( assertThat(info.imageInfo).isEqualTo(
ImageInfo( ImageInfo(
height = 979, height = 1958,
width = 3006, width = 6012,
mimetype = MimeTypes.Jpeg, mimetype = MimeTypes.Jpeg,
size = 84_845, size = 682_492,
ThumbnailInfo(height = 244, width = 751, mimetype = MimeTypes.Jpeg, size = 7_178), ThumbnailInfo(height = 244, width = 751, mimetype = MimeTypes.Jpeg, size = 7_178),
thumbnailSource = null, thumbnailSource = null,
blurhash = "K07gBzX=j_D4xZjoaSe,s:" blurhash = "K07gBzX=j_D4xZjoaSe,s:"
@@ -233,10 +280,10 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
assertThat(info.thumbnailFile).isNull() assertThat(info.thumbnailFile).isNull()
assertThat(info.imageInfo).isEqualTo( assertThat(info.imageInfo).isEqualTo(
ImageInfo( ImageInfo(
height = 979, height = 1_958,
width = 3_006, width = 6_012,
mimetype = MimeTypes.Jpeg, mimetype = MimeTypes.Jpeg,
size = 84_845, size = 682_492,
thumbnailInfo = null, thumbnailInfo = null,
thumbnailSource = null, thumbnailSource = null,
blurhash = null, blurhash = null,
+2 -2
View File
@@ -2,6 +2,6 @@
## Configuration ## 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") @file:Suppress("UnstableApiUsage")
import config.BuildTimeConfig
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies import extension.testCommonDependencies
import org.gradle.kotlin.dsl.withType import org.gradle.kotlin.dsl.withType
@@ -28,27 +27,10 @@ android {
buildTypes { buildTypes {
getByName("release") { getByName("release") {
consumerProguardFiles("consumer-proguard-rules.pro") 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") { register("nightly") {
consumerProguardFiles("consumer-proguard-rules.pro") consumerProguardFiles("consumer-proguard-rules.pro")
matchingFallbacks += listOf("release") 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 { object BuildTimeConfig {
const val APPLICATION_ID = "io.element.android.x" const val APPLICATION_ID = "io.element.android.x"
const val APPLICATION_NAME = "Element 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 METADATA_HOST_REVERSED: String? = null
val OAUTH_CLIENT_URL_PATH: String? = "apps/android"
val URL_WEBSITE: String? = null val URL_WEBSITE: String? = null
val URL_LOGO: String? = null val URL_LOGO: String? = null
val URL_COPYRIGHT: String? = null val URL_COPYRIGHT: String? = null
val URL_ACCEPTABLE_USE: String? = null val URL_ACCEPTABLE_USE: String? = null
val URL_PRIVACY: String? = null val URL_PRIVACY: String? = null
val URL_POLICY: 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_BASE_URL: String? = null
val SERVICES_MAPTILER_APIKEY: String? = null val SERVICES_MAPTILER_APIKEY: String? = null
val SERVICES_MAPTILER_LIGHT_MAPID: String? = null val SERVICES_MAPTILER_LIGHT_MAPID: String? = null
@@ -33,7 +29,6 @@ object BuildTimeConfig {
val SERVICES_SENTRY_DSN_RUST: String? = null val SERVICES_SENTRY_DSN_RUST: String? = null
val BUG_REPORT_URL: String? = null val BUG_REPORT_URL: String? = null
val BUG_REPORT_APP_NAME: String? = null val BUG_REPORT_APP_NAME: String? = null
const val PUSH_CONFIG_INCLUDE_FIREBASE: Boolean = true
const val PUSH_CONFIG_INCLUDE_FIREBASE = true const val PUSH_CONFIG_INCLUDE_UNIFIED_PUSH: Boolean = true
const val PUSH_CONFIG_INCLUDE_UNIFIED_PUSH = 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" fdroidTargetPath="${targetPath}/fdroid"
unzip "${targetPath}"/elementx-app-fdroid-apks-unsigned.zip -d "${fdroidTargetPath}" 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 "\n================================================================================\n"
printf "Signing the FDroid APKs...\n" printf "Signing the FDroid APKs...\n"