mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 03:06:14 +00:00
Merge remote-tracking branch 'origin/develop' into feature/account-provider-selection
This commit is contained in:
@@ -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(),
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+11
-2
@@ -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")
|
||||
|
||||
+26
@@ -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(
|
||||
|
||||
+29
-3
@@ -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
|
||||
|
||||
+8
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -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 {
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
+30
@@ -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)
|
||||
|
||||
+11
@@ -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())
|
||||
|
||||
+54
-2
@@ -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 {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+3
@@ -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.
|
||||
|
||||
+9
@@ -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()
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
+20
@@ -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)
|
||||
}
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+44
@@ -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(),
|
||||
)
|
||||
}
|
||||
+4
@@ -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>) {
|
||||
|
||||
+18
-4
@@ -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()}")
|
||||
|
||||
|
||||
+54
-7
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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 :
|
||||
@@ -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 :
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user